@arcote.tech/arc-chat 0.7.9 → 0.7.11
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 +258 -0
- package/package.json +7 -7
- package/src/aggregates/message.ts +74 -58
- package/src/chat-builder.ts +7 -1
- package/src/index.ts +14 -2
- package/src/listeners/ai-generation-listener.ts +155 -178
- package/src/react/chat-component.tsx +241 -204
- package/src/routes/chat-stream-route.ts +21 -10
- package/src/streaming/stream-registry.ts +252 -118
- package/src/tools/ask-questions.tsx +5 -2
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useState, useCallback, useMemo, useRef, useEffect, type ComponentType, createElement, type ReactNode } from "react";
|
|
2
|
-
import type { ChatStreamEvent, ArcToolAny } from "@arcote.tech/arc-ai";
|
|
2
|
+
import type { AssistantContentBlock, ChatStreamEvent, ArcToolAny } from "@arcote.tech/arc-ai";
|
|
3
3
|
import type {
|
|
4
4
|
ChatInputTextareaSlotProps,
|
|
5
5
|
ChatLabels,
|
|
@@ -55,6 +55,11 @@ type TimelineItem =
|
|
|
55
55
|
/** Legacy compat — true gdy status !== complete. Renderowane przez stary ChatToolLog. */
|
|
56
56
|
calling: boolean;
|
|
57
57
|
error?: string;
|
|
58
|
+
}
|
|
59
|
+
| {
|
|
60
|
+
type: "interrupted";
|
|
61
|
+
id: string;
|
|
62
|
+
messageId: string;
|
|
58
63
|
};
|
|
59
64
|
|
|
60
65
|
export function createChatComponent(
|
|
@@ -77,13 +82,9 @@ export function createChatComponent(
|
|
|
77
82
|
const chatLabels = useChatLabels();
|
|
78
83
|
const [timeline, setTimeline] = useState<TimelineItem[]>([]);
|
|
79
84
|
const [isStreaming, setIsStreaming] = useState(false);
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
const
|
|
83
|
-
/** Last seq applied per session — dedup dla replay buffer + double subscribe. */
|
|
84
|
-
const lastSeqBySessionRef = useRef<Map<string, number>>(new Map());
|
|
85
|
-
/** afterSeq dla SSE resume — read once z partialLastSeq w DB rebuild. */
|
|
86
|
-
const resumeAfterSeqRef = useRef<number>(0);
|
|
85
|
+
/** Set messageIds dla których SSE zwrócił 410 (server restart mid-stream).
|
|
86
|
+
* Renderowane jako TimelineItem "interrupted" z retry button. */
|
|
87
|
+
const [interruptedIds, setInterruptedIds] = useState<Set<string>>(() => new Set());
|
|
87
88
|
|
|
88
89
|
const queries = scope.useQuery();
|
|
89
90
|
const mutations = scope.useMutation();
|
|
@@ -106,19 +107,35 @@ export function createChatComponent(
|
|
|
106
107
|
historyData
|
|
107
108
|
?.map(
|
|
108
109
|
(m: any) =>
|
|
109
|
-
`${m._id}:${m.isGenerating ? 1 : 0}:${m.blocks ? "f" : "e"}:${m.content?.length ?? 0}
|
|
110
|
+
`${m._id}:${m.isGenerating ? 1 : 0}:${m.blocks ? "f" : "e"}:${m.content?.length ?? 0}`,
|
|
110
111
|
)
|
|
111
112
|
.join("|") ?? "",
|
|
112
113
|
[historyData],
|
|
113
114
|
);
|
|
114
115
|
|
|
116
|
+
/** ID assistant rowa, którego currently otwarty stream subskrybujemy.
|
|
117
|
+
* Wyciąga się z DB historii — jest max jeden taki w danej chwili
|
|
118
|
+
* (listener sekwencyjny). Null gdy nie ma czego streamować. */
|
|
119
|
+
const activeGeneratingMessageId = useMemo<string | null>(() => {
|
|
120
|
+
if (!historyData) return null;
|
|
121
|
+
for (const msg of historyData) {
|
|
122
|
+
if (
|
|
123
|
+
msg.role === "assistant" &&
|
|
124
|
+
msg.isGenerating &&
|
|
125
|
+
!interruptedIds.has(msg._id)
|
|
126
|
+
) {
|
|
127
|
+
return msg._id;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return null;
|
|
131
|
+
}, [historyData, interruptedIds]);
|
|
132
|
+
|
|
115
133
|
// ─── Restore timeline from DB history ───────────────────────
|
|
116
134
|
// Podczas streamingu SSE jest źródłem prawdy dla aktualnie generowanej
|
|
117
|
-
// wiadomości — rebuild byłby kolizją.
|
|
118
|
-
//
|
|
119
|
-
//
|
|
120
|
-
//
|
|
121
|
-
// z `assistantTurnCompleted`.
|
|
135
|
+
// wiadomości — rebuild byłby kolizją. Po `done` klient ustawia
|
|
136
|
+
// `isStreaming=false` i ten useEffect refireuje — zaaplikuje final
|
|
137
|
+
// blocks z `assistantTurnCompleted` projection (jedyny moment gdy treść
|
|
138
|
+
// assistant'a ląduje w DB).
|
|
122
139
|
useEffect(() => {
|
|
123
140
|
if (isStreaming) return;
|
|
124
141
|
if (!historyData || historyLen === 0) return;
|
|
@@ -133,7 +150,6 @@ export function createChatComponent(
|
|
|
133
150
|
}
|
|
134
151
|
|
|
135
152
|
const items: TimelineItem[] = [];
|
|
136
|
-
let hasActiveGeneration = false;
|
|
137
153
|
|
|
138
154
|
for (const msg of historyData) {
|
|
139
155
|
// System messages are developer-injected priming prompts. They go
|
|
@@ -152,19 +168,8 @@ export function createChatComponent(
|
|
|
152
168
|
}
|
|
153
169
|
|
|
154
170
|
if (msg.role === "assistant") {
|
|
155
|
-
// Helper — renderuje block array jako TimelineItem-y do `items`.
|
|
156
|
-
// Tool block id = block.id (toolCallId) — JEDEN klucz wszędzie,
|
|
157
|
-
// bez `${msg._id}_b${idx}` mismatchu z SSE path'a (tc_${id}).
|
|
158
171
|
const renderBlocks = (
|
|
159
|
-
blocks:
|
|
160
|
-
| { type: "text"; text: string }
|
|
161
|
-
| {
|
|
162
|
-
type: "tool_call";
|
|
163
|
-
id: string;
|
|
164
|
-
name: string;
|
|
165
|
-
arguments: Record<string, unknown>;
|
|
166
|
-
}
|
|
167
|
-
>,
|
|
172
|
+
blocks: AssistantContentBlock[],
|
|
168
173
|
options: { isStreaming?: boolean } = {},
|
|
169
174
|
) => {
|
|
170
175
|
let textCount = 0;
|
|
@@ -183,11 +188,8 @@ export function createChatComponent(
|
|
|
183
188
|
} else {
|
|
184
189
|
const result = resultMap.get(block.id);
|
|
185
190
|
const hasResult = resultIds.has(block.id);
|
|
186
|
-
// Brak result w DB = tool wciąż w toku:
|
|
187
|
-
//
|
|
188
|
-
// - interactive tool czekający na user response → "pending"
|
|
189
|
-
// (z perspektywy klienta różnica jest tylko semantyczna,
|
|
190
|
-
// `calling: true` w obu przypadkach utrzymuje loader/input override)
|
|
191
|
+
// Brak result w DB = tool wciąż w toku (server: executing,
|
|
192
|
+
// interactive: pending). `calling: true` w obu utrzymuje loader/input override.
|
|
191
193
|
const status: ToolStatus = result?.isError
|
|
192
194
|
? "error"
|
|
193
195
|
: hasResult
|
|
@@ -197,7 +199,7 @@ export function createChatComponent(
|
|
|
197
199
|
: "pending";
|
|
198
200
|
items.push({
|
|
199
201
|
type: "tool",
|
|
200
|
-
id: block.id,
|
|
202
|
+
id: block.id,
|
|
201
203
|
toolCallId: block.id,
|
|
202
204
|
toolName: block.name,
|
|
203
205
|
params: block.arguments,
|
|
@@ -210,23 +212,21 @@ export function createChatComponent(
|
|
|
210
212
|
}
|
|
211
213
|
};
|
|
212
214
|
|
|
213
|
-
// Open turn (in progress). The row exists
|
|
214
|
-
//
|
|
215
|
-
//
|
|
215
|
+
// Open turn (in progress). The row exists w DB z `isGenerating: true`
|
|
216
|
+
// ale BEZ `blocks` — final treść ląduje w DB dopiero po
|
|
217
|
+
// `assistantTurnCompleted`. Live wartość jest in-memory w stream-registry
|
|
218
|
+
// i zostanie pobrana przez SSE `init` event.
|
|
216
219
|
if (msg.isGenerating) {
|
|
217
|
-
if (msg.
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
if (partial.length > 0) {
|
|
224
|
-
renderBlocks(partial, { isStreaming: true });
|
|
220
|
+
if (interruptedIds.has(msg._id)) {
|
|
221
|
+
items.push({
|
|
222
|
+
type: "interrupted",
|
|
223
|
+
id: `${msg._id}_interrupted`,
|
|
224
|
+
messageId: msg._id,
|
|
225
|
+
});
|
|
225
226
|
} else {
|
|
226
|
-
// brak partial — pusty streaming bubble jako placeholder
|
|
227
227
|
items.push({
|
|
228
228
|
type: "message",
|
|
229
|
-
id: msg._id
|
|
229
|
+
id: `${msg._id}_t0`,
|
|
230
230
|
role: "assistant",
|
|
231
231
|
content: "",
|
|
232
232
|
isStreaming: true,
|
|
@@ -237,82 +237,83 @@ export function createChatComponent(
|
|
|
237
237
|
|
|
238
238
|
// Closed turn — render z final blocks.
|
|
239
239
|
const blocks =
|
|
240
|
-
(tryParseJson(msg.blocks ?? "") as
|
|
240
|
+
(tryParseJson(msg.blocks ?? "") as AssistantContentBlock[]) ?? [];
|
|
241
241
|
renderBlocks(blocks);
|
|
242
242
|
}
|
|
243
243
|
}
|
|
244
244
|
|
|
245
245
|
setTimeline(items);
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
}
|
|
249
|
-
// Dep TYLKO `historySig` — bez `isStreaming`. Gdyby `isStreaming` było
|
|
250
|
-
// w deps: po `done` SSE flips isStreaming=false → useEffect refires →
|
|
246
|
+
// Dep TYLKO `historySig` + `interruptedIds`. Bez `isStreaming` — gdyby
|
|
247
|
+
// było w deps: po `done` SSE flips isStreaming=false → useEffect refires →
|
|
251
248
|
// może zobaczyć STAREJ `isGenerating:1` row (DB jeszcze nie zaprojektowała
|
|
252
249
|
// assistantTurnCompleted) → reset do streaming mode → caret nigdy nie znika.
|
|
253
|
-
|
|
254
|
-
}, [historySig]);
|
|
255
|
-
|
|
256
|
-
// ─── SSE stream consumer ────────────────────────────────────
|
|
257
|
-
// Reusable: handles fetch + read loop + processEvent dispatch.
|
|
258
|
-
// Caller is responsible for setting isStreaming/sessionIdRef before
|
|
259
|
-
// and clearing them after (different lifecycle in send vs respond vs resume).
|
|
260
|
-
const consumeStream = useCallback(
|
|
261
|
-
async (sessionId: string, afterSeq = 0): Promise<void> => {
|
|
262
|
-
const query = afterSeq > 0 ? `?afterSeq=${afterSeq}` : "";
|
|
263
|
-
const streamUrl = `/route/chat/${chatName}/stream/${sessionId}${query}`;
|
|
264
|
-
const response = await fetch(streamUrl, {
|
|
265
|
-
credentials: "include",
|
|
266
|
-
headers: { Accept: "text/event-stream" },
|
|
267
|
-
});
|
|
268
|
-
if (!response.ok) throw new Error(`Stream failed: ${response.status}`);
|
|
269
|
-
|
|
270
|
-
const reader = response.body!.getReader();
|
|
271
|
-
const decoder = new TextDecoder();
|
|
272
|
-
let partialLine = "";
|
|
273
|
-
|
|
274
|
-
while (true) {
|
|
275
|
-
const { value, done } = await reader.read();
|
|
276
|
-
if (done) break;
|
|
277
|
-
const text = partialLine + decoder.decode(value, { stream: true });
|
|
278
|
-
const lines = text.split("\n");
|
|
279
|
-
partialLine = lines.pop() ?? "";
|
|
280
|
-
for (const line of lines) {
|
|
281
|
-
if (line.startsWith("data: ")) {
|
|
282
|
-
try {
|
|
283
|
-
const event = JSON.parse(line.slice(6)) as ChatStreamEvent;
|
|
284
|
-
await processEventRef.current?.(event);
|
|
285
|
-
// Yield między eventami w tym samym TCP chunku — gdy server
|
|
286
|
-
// wypycha kilka eventów naraz (np. replay buffer), bez tego
|
|
287
|
-
// wszystkie setTimeline batchują się w jeden render.
|
|
288
|
-
await new Promise<void>((r) => setTimeout(r, 0));
|
|
289
|
-
} catch {}
|
|
290
|
-
}
|
|
291
|
-
}
|
|
292
|
-
}
|
|
293
|
-
if (partialLine.startsWith("data: ")) {
|
|
294
|
-
try {
|
|
295
|
-
const event = JSON.parse(partialLine.slice(6)) as ChatStreamEvent;
|
|
296
|
-
await processEventRef.current?.(event);
|
|
297
|
-
} catch {}
|
|
298
|
-
}
|
|
299
|
-
},
|
|
300
|
-
[],
|
|
301
|
-
);
|
|
250
|
+
}, [historySig, interruptedIds]);
|
|
302
251
|
|
|
303
252
|
// ─── SSE event processing ───────────────────────────────────
|
|
304
|
-
const processEventRef = useRef<((event: ChatStreamEvent) =>
|
|
253
|
+
const processEventRef = useRef<((event: ChatStreamEvent) => void) | null>(null);
|
|
305
254
|
const processEvent = useCallback(
|
|
306
|
-
|
|
307
|
-
// Seq dedup — replay buffer może wysłać te same eventy ponownie po
|
|
308
|
-
// reconnect. Klient odrzuca seq <= lastSeq.
|
|
309
|
-
if (event.seq != null && event.sessionId) {
|
|
310
|
-
const lastSeq = lastSeqBySessionRef.current.get(event.sessionId) ?? 0;
|
|
311
|
-
if (event.seq <= lastSeq) return;
|
|
312
|
-
lastSeqBySessionRef.current.set(event.sessionId, event.seq);
|
|
313
|
-
}
|
|
314
|
-
|
|
255
|
+
(event: ChatStreamEvent) => {
|
|
315
256
|
switch (event.type) {
|
|
257
|
+
case "init": {
|
|
258
|
+
// Pierwszy event po `subscribe(messageId)`. Niesie snapshot
|
|
259
|
+
// in-memory `currentBlocks` — może być pusty (świeży stream) albo
|
|
260
|
+
// wypełniony (graceful reconnect mid-stream). Hydratuje bubble
|
|
261
|
+
// assistant'a.
|
|
262
|
+
if (!event.messageId) break;
|
|
263
|
+
const messageId = event.messageId;
|
|
264
|
+
const blocks = event.currentBlocks ?? [];
|
|
265
|
+
setTimeline((prev) => {
|
|
266
|
+
// Wytnij placeholdery i istniejące bubble assistantów dla tego
|
|
267
|
+
// messageId (z timeline rebuild) — zastąpimy świeżą wersją.
|
|
268
|
+
const cleaned = prev.filter(
|
|
269
|
+
(it) =>
|
|
270
|
+
!(
|
|
271
|
+
it.type === "message" &&
|
|
272
|
+
typeof it.id === "string" &&
|
|
273
|
+
it.id.startsWith(`${messageId}_t`)
|
|
274
|
+
) &&
|
|
275
|
+
!(it.type === "tool" && blocks.some((b) => b.type === "tool_call" && b.id === it.id)),
|
|
276
|
+
);
|
|
277
|
+
let textCount = 0;
|
|
278
|
+
const newItems: TimelineItem[] = [];
|
|
279
|
+
for (const block of blocks) {
|
|
280
|
+
if (block.type === "text") {
|
|
281
|
+
newItems.push({
|
|
282
|
+
type: "message",
|
|
283
|
+
id: `${messageId}_t${textCount}`,
|
|
284
|
+
role: "assistant",
|
|
285
|
+
content: block.text,
|
|
286
|
+
isStreaming: true,
|
|
287
|
+
});
|
|
288
|
+
textCount++;
|
|
289
|
+
} else {
|
|
290
|
+
newItems.push({
|
|
291
|
+
type: "tool",
|
|
292
|
+
id: block.id,
|
|
293
|
+
toolCallId: block.id,
|
|
294
|
+
toolName: block.name,
|
|
295
|
+
params: block.arguments,
|
|
296
|
+
status: "executing",
|
|
297
|
+
calling: true,
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
// Jeśli init przyniósł 0 text blocks → wciąż dodaj pusty
|
|
302
|
+
// streaming placeholder, żeby text_delta miał gdzie dolepić.
|
|
303
|
+
if (!newItems.some((it) => it.type === "message" && it.role === "assistant")) {
|
|
304
|
+
newItems.push({
|
|
305
|
+
type: "message",
|
|
306
|
+
id: `${messageId}_t0`,
|
|
307
|
+
role: "assistant",
|
|
308
|
+
content: "",
|
|
309
|
+
isStreaming: true,
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
return [...cleaned, ...newItems];
|
|
313
|
+
});
|
|
314
|
+
break;
|
|
315
|
+
}
|
|
316
|
+
|
|
316
317
|
case "text_delta":
|
|
317
318
|
if (!event.textDelta) break;
|
|
318
319
|
setTimeline((prev) => {
|
|
@@ -333,7 +334,6 @@ export function createChatComponent(
|
|
|
333
334
|
const newId = event.messageId
|
|
334
335
|
? `${event.messageId}_streaming_${prev.length}`
|
|
335
336
|
: `assistant_${Date.now()}`;
|
|
336
|
-
currentAssistantIdRef.current = newId;
|
|
337
337
|
return [
|
|
338
338
|
...prev,
|
|
339
339
|
{
|
|
@@ -349,13 +349,8 @@ export function createChatComponent(
|
|
|
349
349
|
|
|
350
350
|
case "tool_call_pending":
|
|
351
351
|
// AI właśnie zaczyna tool call. Dla SERVER tools pokazujemy loader
|
|
352
|
-
// "Przygotowuje: {name}…" od razu
|
|
353
|
-
//
|
|
354
|
-
//
|
|
355
|
-
// Dla INTERACTIVE tools NIE pushujemy do timeline tutaj — ich
|
|
356
|
-
// ViewComponent (np. AskQuestionsView) używa `params.questions`
|
|
357
|
-
// od mount-time do registerInputOverride, więc musi dostać pełne
|
|
358
|
-
// args. Czekamy na `interactive_tool_request` które niesie pełne args.
|
|
352
|
+
// "Przygotowuje: {name}…" od razu. Dla INTERACTIVE tools czekamy
|
|
353
|
+
// na `interactive_tool_request` z pełnymi args.
|
|
359
354
|
if (event.toolCallId) {
|
|
360
355
|
const toolDef = event.toolCallName
|
|
361
356
|
? toolsMap.get(event.toolCallName)
|
|
@@ -381,20 +376,17 @@ export function createChatComponent(
|
|
|
381
376
|
});
|
|
382
377
|
return next;
|
|
383
378
|
});
|
|
384
|
-
currentAssistantIdRef.current = null;
|
|
385
379
|
}
|
|
386
380
|
break;
|
|
387
381
|
|
|
388
382
|
case "tool_call_arguments_delta":
|
|
389
|
-
// Args lecą znak po znaku — UI
|
|
390
|
-
// Pomijamy update'y żeby nie re-renderować bez powodu.
|
|
383
|
+
// Args lecą znak po znaku — UI nie potrzebuje tej granularności.
|
|
391
384
|
break;
|
|
392
385
|
|
|
393
386
|
case "tool_call_arguments_complete":
|
|
394
387
|
// Pełne args dostępne. Trzy przypadki:
|
|
395
|
-
// 1. Server tool już jest w timeline (od tool_call_pending) → update
|
|
396
|
-
// 2. Interactive tool jeszcze nie jest w timeline
|
|
397
|
-
// żeby zachować kolejność emisji przez AI (np. [tool, text]).
|
|
388
|
+
// 1. Server tool już jest w timeline (od tool_call_pending) → update.
|
|
389
|
+
// 2. Interactive tool jeszcze nie jest w timeline → ADD tutaj.
|
|
398
390
|
// 3. Tool już dodany przez interactive_tool_request → no-op.
|
|
399
391
|
if (event.toolCallId) {
|
|
400
392
|
setTimeline((prev) => {
|
|
@@ -415,8 +407,6 @@ export function createChatComponent(
|
|
|
415
407
|
: item,
|
|
416
408
|
);
|
|
417
409
|
}
|
|
418
|
-
// Brak w timeline → interactive tool, dodaj teraz w odpowiednim
|
|
419
|
-
// miejscu (po finalizacji ewentualnej trwającej streaming bubble).
|
|
420
410
|
const next = prev.map((item) =>
|
|
421
411
|
item.type === "message" && item.isStreaming
|
|
422
412
|
? { ...item, isStreaming: false }
|
|
@@ -433,7 +423,6 @@ export function createChatComponent(
|
|
|
433
423
|
});
|
|
434
424
|
return next;
|
|
435
425
|
});
|
|
436
|
-
currentAssistantIdRef.current = null;
|
|
437
426
|
}
|
|
438
427
|
break;
|
|
439
428
|
|
|
@@ -471,9 +460,6 @@ export function createChatComponent(
|
|
|
471
460
|
for (const tc of event.toolCalls!) {
|
|
472
461
|
const existing = byId.get(tc.id);
|
|
473
462
|
if (existing) {
|
|
474
|
-
// Tool już istnieje (dodany przez tool_call_arguments_complete).
|
|
475
|
-
// Upewnij się że toolName + params są aktualne — fallback
|
|
476
|
-
// gdyby serwer wcześniej wysłał je puste.
|
|
477
463
|
const idx = next.findIndex(
|
|
478
464
|
(it) => it.type === "tool" && (it as any).id === tc.id,
|
|
479
465
|
);
|
|
@@ -498,11 +484,13 @@ export function createChatComponent(
|
|
|
498
484
|
}
|
|
499
485
|
return next;
|
|
500
486
|
});
|
|
501
|
-
currentAssistantIdRef.current = null;
|
|
502
487
|
}
|
|
503
488
|
break;
|
|
504
489
|
|
|
505
490
|
case "done":
|
|
491
|
+
// Stream zakończył turn. setIsStreaming(false) odpali timeline
|
|
492
|
+
// rebuild z DB — final blocks z `assistantTurnCompleted` projection
|
|
493
|
+
// zastąpią streaming bubble.
|
|
506
494
|
setTimeline((prev) =>
|
|
507
495
|
prev.map((item) =>
|
|
508
496
|
item.type === "message" && item.isStreaming
|
|
@@ -511,7 +499,6 @@ export function createChatComponent(
|
|
|
511
499
|
),
|
|
512
500
|
);
|
|
513
501
|
setIsStreaming(false);
|
|
514
|
-
currentAssistantIdRef.current = null;
|
|
515
502
|
break;
|
|
516
503
|
|
|
517
504
|
case "error":
|
|
@@ -544,80 +531,102 @@ export function createChatComponent(
|
|
|
544
531
|
];
|
|
545
532
|
});
|
|
546
533
|
setIsStreaming(false);
|
|
547
|
-
currentAssistantIdRef.current = null;
|
|
548
534
|
break;
|
|
549
535
|
}
|
|
550
536
|
},
|
|
551
537
|
[chatLabels],
|
|
552
538
|
);
|
|
553
|
-
|
|
554
|
-
// Keep ref in sync so consumeStream (stable callback) can call latest version
|
|
555
539
|
processEventRef.current = processEvent;
|
|
556
540
|
|
|
557
|
-
// ───
|
|
558
|
-
//
|
|
559
|
-
//
|
|
560
|
-
//
|
|
561
|
-
//
|
|
562
|
-
//
|
|
541
|
+
// ─── Auto-subscribe SSE per active assistant messageId ──────
|
|
542
|
+
// Driven przez DB history: za każdym razem gdy w DB pojawia się
|
|
543
|
+
// assistant row z `isGenerating=true`, otwieramy do niego SSE.
|
|
544
|
+
// Wszystkie scenariusze (send, respond, retry, page reload mid-stream)
|
|
545
|
+
// przechodzą przez ten sam mechanizm — handleSend/respond/retry tylko
|
|
546
|
+
// wywołują mutację, ten effect załapie nowy generating row z DB query
|
|
547
|
+
// update.
|
|
563
548
|
useEffect(() => {
|
|
564
|
-
if (!
|
|
565
|
-
const
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
resumedSessionRef.current = sid;
|
|
569
|
-
const afterSeq = resumeAfterSeqRef.current;
|
|
570
|
-
// Inicjalizujemy lastSeq dla tej sesji od `afterSeq` żeby uniknąć
|
|
571
|
-
// ponownej aplikacji już-zhydratowanych chunków (gdyby replay pominął).
|
|
572
|
-
lastSeqBySessionRef.current.set(sid, afterSeq);
|
|
549
|
+
if (!activeGeneratingMessageId) return;
|
|
550
|
+
const messageId = activeGeneratingMessageId;
|
|
551
|
+
const ctrl = new AbortController();
|
|
552
|
+
let cancelled = false;
|
|
573
553
|
setIsStreaming(true);
|
|
554
|
+
|
|
574
555
|
(async () => {
|
|
575
556
|
try {
|
|
576
|
-
await
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
557
|
+
const res = await fetch(
|
|
558
|
+
`/route/chat/${chatName}/stream/${messageId}`,
|
|
559
|
+
{
|
|
560
|
+
credentials: "include",
|
|
561
|
+
signal: ctrl.signal,
|
|
562
|
+
headers: { Accept: "text/event-stream" },
|
|
563
|
+
},
|
|
564
|
+
);
|
|
565
|
+
if (res.status === 410) {
|
|
566
|
+
// Stream nie istnieje — proces zrestartował się mid-stream
|
|
567
|
+
// (in-memory state utracony). Mark messageId jako interrupted,
|
|
568
|
+
// klient pokaże retry UI.
|
|
569
|
+
setInterruptedIds((prev) => new Set(prev).add(messageId));
|
|
570
|
+
setIsStreaming(false);
|
|
571
|
+
return;
|
|
572
|
+
}
|
|
573
|
+
if (!res.ok) throw new Error(`Stream failed: ${res.status}`);
|
|
574
|
+
|
|
575
|
+
const reader = res.body!.getReader();
|
|
576
|
+
const decoder = new TextDecoder();
|
|
577
|
+
let buf = "";
|
|
578
|
+
while (!cancelled) {
|
|
579
|
+
const { value, done } = await reader.read();
|
|
580
|
+
if (done) break;
|
|
581
|
+
buf += decoder.decode(value, { stream: true });
|
|
582
|
+
const lines = buf.split("\n");
|
|
583
|
+
buf = lines.pop() ?? "";
|
|
584
|
+
for (const line of lines) {
|
|
585
|
+
if (!line.startsWith("data: ")) continue;
|
|
586
|
+
try {
|
|
587
|
+
const event = JSON.parse(line.slice(6)) as ChatStreamEvent;
|
|
588
|
+
processEventRef.current?.(event);
|
|
589
|
+
// Yield between events in the same TCP chunk — bez tego
|
|
590
|
+
// React batchuje setTimeline w jeden render, streaming
|
|
591
|
+
// niewidoczny.
|
|
592
|
+
await new Promise<void>((r) => setTimeout(r, 0));
|
|
593
|
+
} catch {}
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
} catch (err) {
|
|
597
|
+
if ((err as any)?.name === "AbortError") return;
|
|
598
|
+
// Network glitch / SSE hang — treat as interrupted.
|
|
599
|
+
setInterruptedIds((prev) => new Set(prev).add(messageId));
|
|
580
600
|
setIsStreaming(false);
|
|
581
|
-
sessionIdRef.current = null;
|
|
582
|
-
currentAssistantIdRef.current = null;
|
|
583
601
|
}
|
|
584
602
|
})();
|
|
585
|
-
|
|
603
|
+
|
|
604
|
+
return () => {
|
|
605
|
+
cancelled = true;
|
|
606
|
+
ctrl.abort();
|
|
607
|
+
};
|
|
608
|
+
}, [activeGeneratingMessageId]);
|
|
586
609
|
|
|
587
610
|
// ─── Send message ───────────────────────────────────────────
|
|
588
611
|
const handleSend = useCallback(
|
|
589
612
|
async (content: string, options: SendMessageOptions) => {
|
|
590
613
|
if (isStreaming || !scopeId) return;
|
|
591
|
-
|
|
592
614
|
setIsStreaming(true);
|
|
593
|
-
|
|
594
615
|
const userMsgId = `user_${Date.now()}`;
|
|
595
616
|
setTimeline((prev) => [
|
|
596
617
|
...prev,
|
|
597
618
|
{ type: "message", id: userMsgId, role: "user", content },
|
|
598
619
|
]);
|
|
599
|
-
|
|
600
620
|
try {
|
|
601
|
-
|
|
621
|
+
await messageMutations.sendMessage({
|
|
602
622
|
scopeId,
|
|
603
623
|
content,
|
|
604
624
|
model: options.model,
|
|
605
625
|
});
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
//
|
|
610
|
-
// że ten sessionId już jest "resumed" i pominie.
|
|
611
|
-
resumedSessionRef.current = sessionId;
|
|
612
|
-
currentAssistantIdRef.current = null;
|
|
613
|
-
|
|
614
|
-
try {
|
|
615
|
-
await consumeStream(sessionId, 0);
|
|
616
|
-
} finally {
|
|
617
|
-
setIsStreaming(false);
|
|
618
|
-
sessionIdRef.current = null;
|
|
619
|
-
currentAssistantIdRef.current = null;
|
|
620
|
-
}
|
|
626
|
+
// Reszta dzieje się przez auto-subscribe effect powyżej: mutacja
|
|
627
|
+
// emit'uje `assistantTurnStarted` → DB query pushuje fresh assistant
|
|
628
|
+
// row do klienta → `activeGeneratingMessageId` ustawia się → effect
|
|
629
|
+
// otwiera SSE.
|
|
621
630
|
} catch (err) {
|
|
622
631
|
const errorMsg = err instanceof Error ? err.message : chatLabels.errorLabel;
|
|
623
632
|
setTimeline((prev) => [
|
|
@@ -625,11 +634,33 @@ export function createChatComponent(
|
|
|
625
634
|
{ type: "message", id: `error_${Date.now()}`, role: "assistant", content: `${chatLabels.errorLabel}: ${errorMsg}` },
|
|
626
635
|
]);
|
|
627
636
|
setIsStreaming(false);
|
|
628
|
-
sessionIdRef.current = null;
|
|
629
|
-
currentAssistantIdRef.current = null;
|
|
630
637
|
}
|
|
631
638
|
},
|
|
632
|
-
[isStreaming, scopeId, messageMutations,
|
|
639
|
+
[isStreaming, scopeId, messageMutations, chatLabels],
|
|
640
|
+
);
|
|
641
|
+
|
|
642
|
+
// ─── Retry interrupted generation ───────────────────────────
|
|
643
|
+
const handleRetry = useCallback(
|
|
644
|
+
async (messageId: string) => {
|
|
645
|
+
if (!scopeId) return;
|
|
646
|
+
try {
|
|
647
|
+
await messageMutations.retryGeneration({ messageId });
|
|
648
|
+
// Mutacja usuwa interrupted row + tworzy fresh assistant row →
|
|
649
|
+
// auto-subscribe effect załapie nowy generating row.
|
|
650
|
+
setInterruptedIds((prev) => {
|
|
651
|
+
const next = new Set(prev);
|
|
652
|
+
next.delete(messageId);
|
|
653
|
+
return next;
|
|
654
|
+
});
|
|
655
|
+
} catch (err) {
|
|
656
|
+
const errorMsg = err instanceof Error ? err.message : chatLabels.errorLabel;
|
|
657
|
+
setTimeline((prev) => [
|
|
658
|
+
...prev,
|
|
659
|
+
{ type: "message", id: `error_${Date.now()}`, role: "assistant", content: `${chatLabels.errorLabel}: ${errorMsg}` },
|
|
660
|
+
]);
|
|
661
|
+
}
|
|
662
|
+
},
|
|
663
|
+
[scopeId, messageMutations, chatLabels],
|
|
633
664
|
);
|
|
634
665
|
|
|
635
666
|
// ─── Build messages for Chat DS (only user/assistant) ───────
|
|
@@ -666,8 +697,6 @@ export function createChatComponent(
|
|
|
666
697
|
// Interactive tool
|
|
667
698
|
const respond = async (result: unknown) => {
|
|
668
699
|
if (!scopeId) return;
|
|
669
|
-
|
|
670
|
-
// Update timeline immediately
|
|
671
700
|
setTimeline((prev) =>
|
|
672
701
|
prev.map((t) =>
|
|
673
702
|
t.type === "tool" && t.toolCallId === item.toolCallId
|
|
@@ -675,25 +704,15 @@ export function createChatComponent(
|
|
|
675
704
|
: t,
|
|
676
705
|
),
|
|
677
706
|
);
|
|
678
|
-
|
|
679
|
-
// Send response — returns new sessionId for SSE
|
|
680
|
-
const respondResult = await messageMutations.respondToTool({
|
|
681
|
-
scopeId,
|
|
682
|
-
toolCallId: item.toolCallId,
|
|
683
|
-
toolName: item.toolName,
|
|
684
|
-
result: JSON.stringify(result),
|
|
685
|
-
});
|
|
686
|
-
|
|
687
|
-
const { sessionId: newSessionId } = respondResult as { sessionId: string };
|
|
688
|
-
if (!newSessionId) return;
|
|
689
|
-
|
|
690
|
-
sessionIdRef.current = newSessionId;
|
|
691
|
-
resumedSessionRef.current = newSessionId; // skip auto-resume
|
|
692
|
-
setIsStreaming(true);
|
|
693
|
-
currentAssistantIdRef.current = null;
|
|
694
|
-
|
|
695
707
|
try {
|
|
696
|
-
await
|
|
708
|
+
await messageMutations.respondToTool({
|
|
709
|
+
scopeId,
|
|
710
|
+
toolCallId: item.toolCallId,
|
|
711
|
+
toolName: item.toolName,
|
|
712
|
+
result: JSON.stringify(result),
|
|
713
|
+
});
|
|
714
|
+
// Auto-subscribe effect załapie nowy assistant row (utworzony
|
|
715
|
+
// atomowo przez mutację respondToTool razem z userResponded event).
|
|
697
716
|
} catch (err) {
|
|
698
717
|
setTimeline((prev) => [
|
|
699
718
|
...prev,
|
|
@@ -704,10 +723,6 @@ export function createChatComponent(
|
|
|
704
723
|
content: `${chatLabels.errorLabel}: ${err instanceof Error ? err.message : chatLabels.errorLabel}`,
|
|
705
724
|
},
|
|
706
725
|
]);
|
|
707
|
-
} finally {
|
|
708
|
-
setIsStreaming(false);
|
|
709
|
-
sessionIdRef.current = null;
|
|
710
|
-
currentAssistantIdRef.current = null;
|
|
711
726
|
}
|
|
712
727
|
};
|
|
713
728
|
|
|
@@ -740,6 +755,26 @@ export function createChatComponent(
|
|
|
740
755
|
timelineElements.push(
|
|
741
756
|
createElement("div", { key: item.id }, renderToolItem(item)),
|
|
742
757
|
);
|
|
758
|
+
} else if (item.type === "interrupted") {
|
|
759
|
+
timelineElements.push(
|
|
760
|
+
createElement(
|
|
761
|
+
"div",
|
|
762
|
+
{
|
|
763
|
+
key: item.id,
|
|
764
|
+
className: "flex items-center gap-2 text-sm text-muted-foreground",
|
|
765
|
+
},
|
|
766
|
+
createElement("span", null, chatLabels.interruptedLabel),
|
|
767
|
+
createElement(
|
|
768
|
+
"button",
|
|
769
|
+
{
|
|
770
|
+
type: "button",
|
|
771
|
+
className: "underline cursor-pointer",
|
|
772
|
+
onClick: () => handleRetry(item.messageId),
|
|
773
|
+
},
|
|
774
|
+
chatLabels.retryLabel,
|
|
775
|
+
),
|
|
776
|
+
),
|
|
777
|
+
);
|
|
743
778
|
}
|
|
744
779
|
}
|
|
745
780
|
|
|
@@ -748,6 +783,8 @@ export function createChatComponent(
|
|
|
748
783
|
(item) => item.type === "tool" && item.calling && !toolsMap.get(item.toolName)?.isServerTool,
|
|
749
784
|
);
|
|
750
785
|
|
|
786
|
+
void chatMessages;
|
|
787
|
+
|
|
751
788
|
return createElement(
|
|
752
789
|
ChatInputProvider,
|
|
753
790
|
null,
|