@arcote.tech/arc-chat 0.7.8 → 0.7.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +7 -6
- package/src/aggregates/message.ts +131 -7
- package/src/chat-builder.ts +8 -1
- package/src/listeners/ai-generation-listener.ts +176 -36
- package/src/react/chat-component.tsx +283 -164
- package/src/routes/chat-stream-route.ts +7 -2
- package/src/streaming/stream-registry.ts +24 -5
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
import { useState, useCallback, useMemo, useRef, useEffect, type ComponentType, createElement, type ReactNode } from "react";
|
|
2
2
|
import type { ChatStreamEvent, ArcToolAny } from "@arcote.tech/arc-ai";
|
|
3
|
-
import type {
|
|
3
|
+
import type {
|
|
4
|
+
ChatInputTextareaSlotProps,
|
|
5
|
+
ChatLabels,
|
|
6
|
+
ChatMessageData,
|
|
7
|
+
SendMessageOptions,
|
|
8
|
+
} from "@arcote.tech/arc-ds";
|
|
4
9
|
import { Chat, ChatMessage, ChatInputProvider, ChatLabelsProvider, ChatToolLog, useChatLabels } from "@arcote.tech/arc-ds";
|
|
10
|
+
import { VoiceTextarea } from "@arcote.tech/arc-ai-voice";
|
|
5
11
|
|
|
6
12
|
interface ChatComponentConfig {
|
|
7
13
|
chatName: string;
|
|
@@ -19,6 +25,12 @@ interface ChatComponentConfig {
|
|
|
19
25
|
onClick: () => void;
|
|
20
26
|
disabled: boolean;
|
|
21
27
|
}) => ReactNode;
|
|
28
|
+
/**
|
|
29
|
+
* Slot na pole tekstowe ChatInput. Konsument może podpiąć `VoiceTextarea`
|
|
30
|
+
* z `@arcote.tech/arc-ai-voice` żeby włączyć dyktowanie głosowe. Bez
|
|
31
|
+
* propsa — używany domyślny `TextareaField` z DS.
|
|
32
|
+
*/
|
|
33
|
+
renderTextarea?: (props: ChatInputTextareaSlotProps) => ReactNode;
|
|
22
34
|
/** Partial overrides for chat i18n labels. Falls back to English defaults. */
|
|
23
35
|
labels?: Partial<ChatLabels>;
|
|
24
36
|
/**
|
|
@@ -28,9 +40,22 @@ interface ChatComponentConfig {
|
|
|
28
40
|
footer?: ReactNode;
|
|
29
41
|
}
|
|
30
42
|
|
|
43
|
+
type ToolStatus = "pending" | "executing" | "complete" | "error";
|
|
44
|
+
|
|
31
45
|
type TimelineItem =
|
|
32
46
|
| { type: "message"; id: string; role: "user" | "assistant"; content: string; isStreaming?: boolean }
|
|
33
|
-
| {
|
|
47
|
+
| {
|
|
48
|
+
type: "tool";
|
|
49
|
+
id: string; // = toolCallId, stabilne pomiędzy SSE i DB rebuild
|
|
50
|
+
toolCallId: string;
|
|
51
|
+
toolName: string;
|
|
52
|
+
params: Record<string, unknown>;
|
|
53
|
+
result?: unknown;
|
|
54
|
+
status: ToolStatus;
|
|
55
|
+
/** Legacy compat — true gdy status !== complete. Renderowane przez stary ChatToolLog. */
|
|
56
|
+
calling: boolean;
|
|
57
|
+
error?: string;
|
|
58
|
+
};
|
|
34
59
|
|
|
35
60
|
export function createChatComponent(
|
|
36
61
|
config: ChatComponentConfig,
|
|
@@ -42,6 +67,7 @@ export function createChatComponent(
|
|
|
42
67
|
showModelSelector = true,
|
|
43
68
|
showWebSearch = true,
|
|
44
69
|
renderSendButton,
|
|
70
|
+
renderTextarea,
|
|
45
71
|
labels,
|
|
46
72
|
footer,
|
|
47
73
|
} = config;
|
|
@@ -54,6 +80,10 @@ export function createChatComponent(
|
|
|
54
80
|
const sessionIdRef = useRef<string | null>(null);
|
|
55
81
|
const currentAssistantIdRef = useRef<string | null>(null);
|
|
56
82
|
const resumedSessionRef = useRef<string | null>(null);
|
|
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);
|
|
57
87
|
|
|
58
88
|
const queries = scope.useQuery();
|
|
59
89
|
const mutations = scope.useMutation();
|
|
@@ -76,15 +106,22 @@ export function createChatComponent(
|
|
|
76
106
|
historyData
|
|
77
107
|
?.map(
|
|
78
108
|
(m: any) =>
|
|
79
|
-
`${m._id}:${m.isGenerating ? 1 : 0}:${m.blocks ? "f" : "e"}:${m.content?.length ?? 0}`,
|
|
109
|
+
`${m._id}:${m.isGenerating ? 1 : 0}:${m.blocks ? "f" : "e"}:${m.content?.length ?? 0}:${m.partialLastSeq ?? 0}`,
|
|
80
110
|
)
|
|
81
111
|
.join("|") ?? "",
|
|
82
112
|
[historyData],
|
|
83
113
|
);
|
|
84
114
|
|
|
85
115
|
// ─── Restore timeline from DB history ───────────────────────
|
|
116
|
+
// Podczas streamingu SSE jest źródłem prawdy dla aktualnie generowanej
|
|
117
|
+
// wiadomości — rebuild byłby kolizją. Snapshoty do `partialBlocks` służą
|
|
118
|
+
// wyłącznie do hydratacji po reload (gdy `isStreaming` jest jeszcze
|
|
119
|
+
// `false` na mount). Po `done` SSE klient ustawia `isStreaming=false` i
|
|
120
|
+
// ten useEffect refireuje (dep `isStreaming`) — zaaplikuje final blocks
|
|
121
|
+
// z `assistantTurnCompleted`.
|
|
86
122
|
useEffect(() => {
|
|
87
|
-
if (isStreaming
|
|
123
|
+
if (isStreaming) return;
|
|
124
|
+
if (!historyData || historyLen === 0) return;
|
|
88
125
|
|
|
89
126
|
const resultIds = new Set<string>();
|
|
90
127
|
const resultMap = new Map<string, { content: string; isError?: boolean }>();
|
|
@@ -115,30 +152,11 @@ export function createChatComponent(
|
|
|
115
152
|
}
|
|
116
153
|
|
|
117
154
|
if (msg.role === "assistant") {
|
|
118
|
-
//
|
|
119
|
-
//
|
|
120
|
-
//
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
if (msg.isGenerating === true) {
|
|
124
|
-
if (msg.sessionId) sessionIdRef.current = msg.sessionId;
|
|
125
|
-
hasActiveGeneration = true;
|
|
126
|
-
items.push({
|
|
127
|
-
type: "message",
|
|
128
|
-
id: msg._id,
|
|
129
|
-
role: "assistant",
|
|
130
|
-
content: "",
|
|
131
|
-
isStreaming: true,
|
|
132
|
-
});
|
|
133
|
-
currentAssistantIdRef.current = msg._id;
|
|
134
|
-
continue;
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
// Closed turn — render from blocks. Each TextBlock becomes a
|
|
138
|
-
// message item, each ToolCallBlock becomes a tool item paired with
|
|
139
|
-
// its result row.
|
|
140
|
-
const blocks =
|
|
141
|
-
(tryParseJson(msg.blocks ?? "") as Array<
|
|
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
|
+
const renderBlocks = (
|
|
159
|
+
blocks: Array<
|
|
142
160
|
| { type: "text"; text: string }
|
|
143
161
|
| {
|
|
144
162
|
type: "tool_call";
|
|
@@ -146,34 +164,81 @@ export function createChatComponent(
|
|
|
146
164
|
name: string;
|
|
147
165
|
arguments: Record<string, unknown>;
|
|
148
166
|
}
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
if (block.text) {
|
|
167
|
+
>,
|
|
168
|
+
options: { isStreaming?: boolean } = {},
|
|
169
|
+
) => {
|
|
170
|
+
let textCount = 0;
|
|
171
|
+
for (const block of blocks) {
|
|
172
|
+
if (block.type === "text") {
|
|
173
|
+
if (block.text) {
|
|
174
|
+
items.push({
|
|
175
|
+
type: "message",
|
|
176
|
+
id: `${msg._id}_t${textCount}`,
|
|
177
|
+
role: "assistant",
|
|
178
|
+
content: block.text,
|
|
179
|
+
isStreaming: options.isStreaming,
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
textCount++;
|
|
183
|
+
} else {
|
|
184
|
+
const result = resultMap.get(block.id);
|
|
185
|
+
const hasResult = resultIds.has(block.id);
|
|
186
|
+
// Brak result w DB = tool wciąż w toku:
|
|
187
|
+
// - server tool podczas exec → "executing"
|
|
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
|
+
const status: ToolStatus = result?.isError
|
|
192
|
+
? "error"
|
|
193
|
+
: hasResult
|
|
194
|
+
? "complete"
|
|
195
|
+
: options.isStreaming
|
|
196
|
+
? "executing"
|
|
197
|
+
: "pending";
|
|
155
198
|
items.push({
|
|
156
|
-
type: "
|
|
157
|
-
id:
|
|
158
|
-
|
|
159
|
-
|
|
199
|
+
type: "tool",
|
|
200
|
+
id: block.id, // = toolCallId
|
|
201
|
+
toolCallId: block.id,
|
|
202
|
+
toolName: block.name,
|
|
203
|
+
params: block.arguments,
|
|
204
|
+
result: result ? tryParseJson(result.content) : undefined,
|
|
205
|
+
status,
|
|
206
|
+
calling: !hasResult,
|
|
207
|
+
error: result?.isError ? result.content : undefined,
|
|
160
208
|
});
|
|
161
209
|
}
|
|
210
|
+
}
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
// Open turn (in progress). The row exists with `isGenerating: true`.
|
|
214
|
+
// SQLite zwraca booleany jako 0/1 — używamy truthy check zamiast
|
|
215
|
+
// `=== true` żeby zaakceptować zarówno true jak i 1.
|
|
216
|
+
if (msg.isGenerating) {
|
|
217
|
+
if (msg.sessionId) sessionIdRef.current = msg.sessionId;
|
|
218
|
+
hasActiveGeneration = true;
|
|
219
|
+
currentAssistantIdRef.current = msg._id;
|
|
220
|
+
resumeAfterSeqRef.current = msg.partialLastSeq ?? 0;
|
|
221
|
+
const partial =
|
|
222
|
+
(tryParseJson(msg.partialBlocks ?? "") as Array<any>) ?? [];
|
|
223
|
+
if (partial.length > 0) {
|
|
224
|
+
renderBlocks(partial, { isStreaming: true });
|
|
162
225
|
} else {
|
|
163
|
-
|
|
226
|
+
// brak partial — pusty streaming bubble jako placeholder
|
|
164
227
|
items.push({
|
|
165
|
-
type: "
|
|
166
|
-
id:
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
result: result ? tryParseJson(result.content) : undefined,
|
|
171
|
-
calling: !resultIds.has(block.id),
|
|
172
|
-
error: result?.isError ? result.content : undefined,
|
|
228
|
+
type: "message",
|
|
229
|
+
id: msg._id,
|
|
230
|
+
role: "assistant",
|
|
231
|
+
content: "",
|
|
232
|
+
isStreaming: true,
|
|
173
233
|
});
|
|
174
234
|
}
|
|
175
|
-
|
|
235
|
+
continue;
|
|
176
236
|
}
|
|
237
|
+
|
|
238
|
+
// Closed turn — render z final blocks.
|
|
239
|
+
const blocks =
|
|
240
|
+
(tryParseJson(msg.blocks ?? "") as Array<any>) ?? [];
|
|
241
|
+
renderBlocks(blocks);
|
|
177
242
|
}
|
|
178
243
|
}
|
|
179
244
|
|
|
@@ -181,15 +246,21 @@ export function createChatComponent(
|
|
|
181
246
|
if (!isStreaming && hasActiveGeneration) {
|
|
182
247
|
setIsStreaming(true);
|
|
183
248
|
}
|
|
184
|
-
|
|
249
|
+
// Dep TYLKO `historySig` — bez `isStreaming`. Gdyby `isStreaming` było
|
|
250
|
+
// w deps: po `done` SSE flips isStreaming=false → useEffect refires →
|
|
251
|
+
// może zobaczyć STAREJ `isGenerating:1` row (DB jeszcze nie zaprojektowała
|
|
252
|
+
// assistantTurnCompleted) → reset do streaming mode → caret nigdy nie znika.
|
|
253
|
+
// Rebuild fires tylko gdy faktycznie zmienia się stan w DB.
|
|
254
|
+
}, [historySig]);
|
|
185
255
|
|
|
186
256
|
// ─── SSE stream consumer ────────────────────────────────────
|
|
187
257
|
// Reusable: handles fetch + read loop + processEvent dispatch.
|
|
188
258
|
// Caller is responsible for setting isStreaming/sessionIdRef before
|
|
189
259
|
// and clearing them after (different lifecycle in send vs respond vs resume).
|
|
190
260
|
const consumeStream = useCallback(
|
|
191
|
-
async (sessionId: string): Promise<void> => {
|
|
192
|
-
const
|
|
261
|
+
async (sessionId: string, afterSeq = 0): Promise<void> => {
|
|
262
|
+
const query = afterSeq > 0 ? `?afterSeq=${afterSeq}` : "";
|
|
263
|
+
const streamUrl = `/route/chat/${chatName}/stream/${sessionId}${query}`;
|
|
193
264
|
const response = await fetch(streamUrl, {
|
|
194
265
|
credentials: "include",
|
|
195
266
|
headers: { Accept: "text/event-stream" },
|
|
@@ -211,6 +282,10 @@ export function createChatComponent(
|
|
|
211
282
|
try {
|
|
212
283
|
const event = JSON.parse(line.slice(6)) as ChatStreamEvent;
|
|
213
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));
|
|
214
289
|
} catch {}
|
|
215
290
|
}
|
|
216
291
|
}
|
|
@@ -229,28 +304,35 @@ export function createChatComponent(
|
|
|
229
304
|
const processEventRef = useRef<((event: ChatStreamEvent) => Promise<void>) | null>(null);
|
|
230
305
|
const processEvent = useCallback(
|
|
231
306
|
async (event: ChatStreamEvent) => {
|
|
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
|
+
|
|
232
315
|
switch (event.type) {
|
|
233
|
-
case "
|
|
234
|
-
if (!event.
|
|
235
|
-
// Append to the trailing assistant text bubble. If there isn't
|
|
236
|
-
// one (e.g. just finished a tool, or no streaming bubble yet),
|
|
237
|
-
// create a new one. This produces correctly interleaved
|
|
238
|
-
// text/tool/text/tool ordering during streaming.
|
|
316
|
+
case "text_delta":
|
|
317
|
+
if (!event.textDelta) break;
|
|
239
318
|
setTimeline((prev) => {
|
|
240
319
|
const last = prev[prev.length - 1];
|
|
241
|
-
|
|
320
|
+
const willAppend = !!(
|
|
242
321
|
last &&
|
|
243
322
|
last.type === "message" &&
|
|
244
323
|
last.role === "assistant" &&
|
|
245
324
|
last.isStreaming
|
|
246
|
-
)
|
|
325
|
+
);
|
|
326
|
+
if (willAppend) {
|
|
247
327
|
return prev.map((item, i) =>
|
|
248
328
|
i === prev.length - 1 && item.type === "message"
|
|
249
|
-
? { ...item, content: item.content + event.
|
|
329
|
+
? { ...item, content: item.content + event.textDelta }
|
|
250
330
|
: item,
|
|
251
331
|
);
|
|
252
332
|
}
|
|
253
|
-
const newId =
|
|
333
|
+
const newId = event.messageId
|
|
334
|
+
? `${event.messageId}_streaming_${prev.length}`
|
|
335
|
+
: `assistant_${Date.now()}`;
|
|
254
336
|
currentAssistantIdRef.current = newId;
|
|
255
337
|
return [
|
|
256
338
|
...prev,
|
|
@@ -258,18 +340,31 @@ export function createChatComponent(
|
|
|
258
340
|
type: "message",
|
|
259
341
|
id: newId,
|
|
260
342
|
role: "assistant",
|
|
261
|
-
content: event.
|
|
343
|
+
content: event.textDelta!,
|
|
262
344
|
isStreaming: true,
|
|
263
345
|
},
|
|
264
346
|
];
|
|
265
347
|
});
|
|
266
348
|
break;
|
|
267
349
|
|
|
268
|
-
case "
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
350
|
+
case "tool_call_pending":
|
|
351
|
+
// AI właśnie zaczyna tool call. Dla SERVER tools pokazujemy loader
|
|
352
|
+
// "Przygotowuje: {name}…" od razu — ViewComponent dostaje `params: {}`
|
|
353
|
+
// ale to OK bo server tool nie używa params do init'a UI.
|
|
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.
|
|
359
|
+
if (event.toolCallId) {
|
|
360
|
+
const toolDef = event.toolCallName
|
|
361
|
+
? toolsMap.get(event.toolCallName)
|
|
362
|
+
: undefined;
|
|
363
|
+
if (toolDef && !toolDef.isServerTool) break;
|
|
272
364
|
setTimeline((prev) => {
|
|
365
|
+
if (prev.some((it) => it.type === "tool" && it.id === event.toolCallId)) {
|
|
366
|
+
return prev;
|
|
367
|
+
}
|
|
273
368
|
const next = prev.map((item) =>
|
|
274
369
|
item.type === "message" && item.isStreaming
|
|
275
370
|
? { ...item, isStreaming: false }
|
|
@@ -277,10 +372,11 @@ export function createChatComponent(
|
|
|
277
372
|
);
|
|
278
373
|
next.push({
|
|
279
374
|
type: "tool",
|
|
280
|
-
id:
|
|
281
|
-
toolCallId: event.
|
|
282
|
-
toolName: event.
|
|
283
|
-
params:
|
|
375
|
+
id: event.toolCallId!,
|
|
376
|
+
toolCallId: event.toolCallId!,
|
|
377
|
+
toolName: event.toolCallName ?? "",
|
|
378
|
+
params: {},
|
|
379
|
+
status: "pending",
|
|
284
380
|
calling: true,
|
|
285
381
|
});
|
|
286
382
|
return next;
|
|
@@ -289,7 +385,59 @@ export function createChatComponent(
|
|
|
289
385
|
}
|
|
290
386
|
break;
|
|
291
387
|
|
|
292
|
-
case "
|
|
388
|
+
case "tool_call_arguments_delta":
|
|
389
|
+
// Args lecą znak po znaku — UI zwykle nie potrzebuje tej granularności.
|
|
390
|
+
// Pomijamy update'y żeby nie re-renderować bez powodu.
|
|
391
|
+
break;
|
|
392
|
+
|
|
393
|
+
case "tool_call_arguments_complete":
|
|
394
|
+
// Pełne args dostępne. Trzy przypadki:
|
|
395
|
+
// 1. Server tool już jest w timeline (od tool_call_pending) → update params/status.
|
|
396
|
+
// 2. Interactive tool jeszcze nie jest w timeline (skipnięty w pending) → ADD tutaj
|
|
397
|
+
// żeby zachować kolejność emisji przez AI (np. [tool, text]).
|
|
398
|
+
// 3. Tool już dodany przez interactive_tool_request → no-op.
|
|
399
|
+
if (event.toolCallId) {
|
|
400
|
+
setTimeline((prev) => {
|
|
401
|
+
const existing = prev.find(
|
|
402
|
+
(it): it is Extract<TimelineItem, { type: "tool" }> =>
|
|
403
|
+
it.type === "tool" && it.toolCallId === event.toolCallId,
|
|
404
|
+
);
|
|
405
|
+
if (existing) {
|
|
406
|
+
return prev.map((item) =>
|
|
407
|
+
item.type === "tool" && item.toolCallId === event.toolCallId
|
|
408
|
+
? {
|
|
409
|
+
...item,
|
|
410
|
+
params: event.arguments ?? item.params,
|
|
411
|
+
status: "executing",
|
|
412
|
+
calling: true,
|
|
413
|
+
toolName: event.toolCallName ?? item.toolName,
|
|
414
|
+
}
|
|
415
|
+
: item,
|
|
416
|
+
);
|
|
417
|
+
}
|
|
418
|
+
// Brak w timeline → interactive tool, dodaj teraz w odpowiednim
|
|
419
|
+
// miejscu (po finalizacji ewentualnej trwającej streaming bubble).
|
|
420
|
+
const next = prev.map((item) =>
|
|
421
|
+
item.type === "message" && item.isStreaming
|
|
422
|
+
? { ...item, isStreaming: false }
|
|
423
|
+
: item,
|
|
424
|
+
);
|
|
425
|
+
next.push({
|
|
426
|
+
type: "tool",
|
|
427
|
+
id: event.toolCallId!,
|
|
428
|
+
toolCallId: event.toolCallId!,
|
|
429
|
+
toolName: event.toolCallName ?? "",
|
|
430
|
+
params: event.arguments ?? {},
|
|
431
|
+
status: "pending",
|
|
432
|
+
calling: true,
|
|
433
|
+
});
|
|
434
|
+
return next;
|
|
435
|
+
});
|
|
436
|
+
currentAssistantIdRef.current = null;
|
|
437
|
+
}
|
|
438
|
+
break;
|
|
439
|
+
|
|
440
|
+
case "tool_call_executed":
|
|
293
441
|
if (event.toolResult) {
|
|
294
442
|
setTimeline((prev) =>
|
|
295
443
|
prev.map((item) =>
|
|
@@ -297,6 +445,7 @@ export function createChatComponent(
|
|
|
297
445
|
? {
|
|
298
446
|
...item,
|
|
299
447
|
result: tryParseJson(event.toolResult!.content),
|
|
448
|
+
status: event.toolResult!.isError ? "error" : "complete",
|
|
300
449
|
calling: false,
|
|
301
450
|
error: event.toolResult!.isError ? event.toolResult!.content : undefined,
|
|
302
451
|
}
|
|
@@ -308,21 +457,42 @@ export function createChatComponent(
|
|
|
308
457
|
|
|
309
458
|
case "interactive_tool_request":
|
|
310
459
|
if (event.toolCalls) {
|
|
311
|
-
// Finalize current text bubble and append the interactive tool
|
|
312
|
-
// items. After this, the loop pauses until userResponded.
|
|
313
460
|
setTimeline((prev) => {
|
|
461
|
+
const byId = new Map(
|
|
462
|
+
prev
|
|
463
|
+
.filter((it) => it.type === "tool")
|
|
464
|
+
.map((it) => [(it as any).id, it]),
|
|
465
|
+
);
|
|
314
466
|
const next = prev.map((item) =>
|
|
315
467
|
item.type === "message" && item.isStreaming
|
|
316
468
|
? { ...item, isStreaming: false }
|
|
317
469
|
: item,
|
|
318
470
|
);
|
|
319
471
|
for (const tc of event.toolCalls!) {
|
|
472
|
+
const existing = byId.get(tc.id);
|
|
473
|
+
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
|
+
const idx = next.findIndex(
|
|
478
|
+
(it) => it.type === "tool" && (it as any).id === tc.id,
|
|
479
|
+
);
|
|
480
|
+
if (idx >= 0) {
|
|
481
|
+
next[idx] = {
|
|
482
|
+
...(next[idx] as any),
|
|
483
|
+
toolName: tc.name || (next[idx] as any).toolName,
|
|
484
|
+
params: tc.arguments ?? (next[idx] as any).params,
|
|
485
|
+
};
|
|
486
|
+
}
|
|
487
|
+
continue;
|
|
488
|
+
}
|
|
320
489
|
next.push({
|
|
321
490
|
type: "tool",
|
|
322
|
-
id:
|
|
491
|
+
id: tc.id,
|
|
323
492
|
toolCallId: tc.id,
|
|
324
493
|
toolName: tc.name,
|
|
325
494
|
params: tc.arguments ?? {},
|
|
495
|
+
status: "pending",
|
|
326
496
|
calling: true,
|
|
327
497
|
});
|
|
328
498
|
}
|
|
@@ -333,7 +503,6 @@ export function createChatComponent(
|
|
|
333
503
|
break;
|
|
334
504
|
|
|
335
505
|
case "done":
|
|
336
|
-
// Mark any trailing streaming bubble as done.
|
|
337
506
|
setTimeline((prev) =>
|
|
338
507
|
prev.map((item) =>
|
|
339
508
|
item.type === "message" && item.isStreaming
|
|
@@ -385,28 +554,35 @@ export function createChatComponent(
|
|
|
385
554
|
// Keep ref in sync so consumeStream (stable callback) can call latest version
|
|
386
555
|
processEventRef.current = processEvent;
|
|
387
556
|
|
|
388
|
-
// ─── Resume SSE
|
|
389
|
-
//
|
|
390
|
-
//
|
|
557
|
+
// ─── Resume/auto-open SSE dla isGenerating rowsów ────────────
|
|
558
|
+
// Każdy `isGenerating: true` row z `sessionId` w DB triggers SSE subscribe.
|
|
559
|
+
// Obejmuje: (a) reload mid-stream, (b) welcome-message-style auto-trigger
|
|
560
|
+
// gdzie onEnter listener tworzy assistant turn bez user message.
|
|
561
|
+
// `consumeStream` z `afterSeq` (zhydratowane z `partialLastSeq`) sprawia
|
|
562
|
+
// że replay buffer skipuje już zaaplikowane chunki.
|
|
391
563
|
useEffect(() => {
|
|
392
564
|
if (!scopeId) return;
|
|
393
565
|
const sid = sessionIdRef.current;
|
|
394
566
|
if (!sid) return;
|
|
395
567
|
if (resumedSessionRef.current === sid) return;
|
|
396
|
-
if (!isStreaming) return;
|
|
397
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);
|
|
573
|
+
setIsStreaming(true);
|
|
398
574
|
(async () => {
|
|
399
575
|
try {
|
|
400
|
-
await consumeStream(sid);
|
|
576
|
+
await consumeStream(sid, afterSeq);
|
|
401
577
|
} catch {
|
|
402
|
-
// Stream
|
|
578
|
+
// Stream może już skończony / GC'd
|
|
403
579
|
} finally {
|
|
404
580
|
setIsStreaming(false);
|
|
405
581
|
sessionIdRef.current = null;
|
|
406
582
|
currentAssistantIdRef.current = null;
|
|
407
583
|
}
|
|
408
584
|
})();
|
|
409
|
-
}, [
|
|
585
|
+
}, [historySig, scopeId, consumeStream]);
|
|
410
586
|
|
|
411
587
|
// ─── Send message ───────────────────────────────────────────
|
|
412
588
|
const handleSend = useCallback(
|
|
@@ -430,53 +606,18 @@ export function createChatComponent(
|
|
|
430
606
|
|
|
431
607
|
const { sessionId } = sendResult as { sessionId: string; messageId: string };
|
|
432
608
|
sessionIdRef.current = sessionId;
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
credentials: "include",
|
|
437
|
-
headers: { Accept: "text/event-stream" },
|
|
438
|
-
});
|
|
439
|
-
|
|
440
|
-
if (!response.ok) throw new Error(`Stream failed: ${response.status}`);
|
|
441
|
-
|
|
442
|
-
// Don't pre-create an assistant bubble here — the SSE handler will
|
|
443
|
-
// create one lazily on the first content_delta. This way, if the
|
|
444
|
-
// model jumps straight to a tool call (no text), we don't render an
|
|
445
|
-
// empty bubble.
|
|
609
|
+
// Zapobiega dwukrotnemu konsumpowaniu SSE — resume effect zobaczy
|
|
610
|
+
// że ten sessionId już jest "resumed" i pominie.
|
|
611
|
+
resumedSessionRef.current = sessionId;
|
|
446
612
|
currentAssistantIdRef.current = null;
|
|
447
613
|
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
if (done) break;
|
|
455
|
-
|
|
456
|
-
const text = partialLine + decoder.decode(value, { stream: true });
|
|
457
|
-
const lines = text.split("\n");
|
|
458
|
-
partialLine = lines.pop() ?? "";
|
|
459
|
-
|
|
460
|
-
for (const line of lines) {
|
|
461
|
-
if (line.startsWith("data: ")) {
|
|
462
|
-
try {
|
|
463
|
-
const event = JSON.parse(line.slice(6)) as ChatStreamEvent;
|
|
464
|
-
await processEvent(event);
|
|
465
|
-
} catch {}
|
|
466
|
-
}
|
|
467
|
-
}
|
|
468
|
-
}
|
|
469
|
-
|
|
470
|
-
if (partialLine.startsWith("data: ")) {
|
|
471
|
-
try {
|
|
472
|
-
const event = JSON.parse(partialLine.slice(6)) as ChatStreamEvent;
|
|
473
|
-
await processEvent(event);
|
|
474
|
-
} catch {}
|
|
614
|
+
try {
|
|
615
|
+
await consumeStream(sessionId, 0);
|
|
616
|
+
} finally {
|
|
617
|
+
setIsStreaming(false);
|
|
618
|
+
sessionIdRef.current = null;
|
|
619
|
+
currentAssistantIdRef.current = null;
|
|
475
620
|
}
|
|
476
|
-
|
|
477
|
-
setIsStreaming(false);
|
|
478
|
-
sessionIdRef.current = null;
|
|
479
|
-
currentAssistantIdRef.current = null;
|
|
480
621
|
} catch (err) {
|
|
481
622
|
const errorMsg = err instanceof Error ? err.message : chatLabels.errorLabel;
|
|
482
623
|
setTimeline((prev) => [
|
|
@@ -546,47 +687,13 @@ export function createChatComponent(
|
|
|
546
687
|
const { sessionId: newSessionId } = respondResult as { sessionId: string };
|
|
547
688
|
if (!newSessionId) return;
|
|
548
689
|
|
|
549
|
-
// Connect to new SSE stream for resumed generation. Don't pre-create
|
|
550
|
-
// an assistant bubble — the SSE handler creates one lazily on the
|
|
551
|
-
// first content_delta (same pattern as handleSend).
|
|
552
690
|
sessionIdRef.current = newSessionId;
|
|
691
|
+
resumedSessionRef.current = newSessionId; // skip auto-resume
|
|
553
692
|
setIsStreaming(true);
|
|
554
693
|
currentAssistantIdRef.current = null;
|
|
555
694
|
|
|
556
695
|
try {
|
|
557
|
-
|
|
558
|
-
const response = await fetch(streamUrl, {
|
|
559
|
-
credentials: "include",
|
|
560
|
-
headers: { Accept: "text/event-stream" },
|
|
561
|
-
});
|
|
562
|
-
|
|
563
|
-
if (!response.ok) throw new Error(`Stream failed: ${response.status}`);
|
|
564
|
-
|
|
565
|
-
const reader = response.body!.getReader();
|
|
566
|
-
const decoder = new TextDecoder();
|
|
567
|
-
let partialLine = "";
|
|
568
|
-
|
|
569
|
-
while (true) {
|
|
570
|
-
const { value, done } = await reader.read();
|
|
571
|
-
if (done) break;
|
|
572
|
-
const text = partialLine + decoder.decode(value, { stream: true });
|
|
573
|
-
const lines = text.split("\n");
|
|
574
|
-
partialLine = lines.pop() ?? "";
|
|
575
|
-
for (const line of lines) {
|
|
576
|
-
if (line.startsWith("data: ")) {
|
|
577
|
-
try {
|
|
578
|
-
const evt = JSON.parse(line.slice(6)) as ChatStreamEvent;
|
|
579
|
-
await processEvent(evt);
|
|
580
|
-
} catch {}
|
|
581
|
-
}
|
|
582
|
-
}
|
|
583
|
-
}
|
|
584
|
-
if (partialLine.startsWith("data: ")) {
|
|
585
|
-
try {
|
|
586
|
-
const evt = JSON.parse(partialLine.slice(6)) as ChatStreamEvent;
|
|
587
|
-
await processEvent(evt);
|
|
588
|
-
} catch {}
|
|
589
|
-
}
|
|
696
|
+
await consumeStream(newSessionId, 0);
|
|
590
697
|
} catch (err) {
|
|
591
698
|
setTimeline((prev) => [
|
|
592
699
|
...prev,
|
|
@@ -655,12 +762,24 @@ export function createChatComponent(
|
|
|
655
762
|
),
|
|
656
763
|
createElement(Chat, {
|
|
657
764
|
messages: [],
|
|
658
|
-
models: [{ value: "gpt-5
|
|
659
|
-
defaultModel: "gpt-5
|
|
765
|
+
models: [{ value: "gpt-5", label: "GPT-5" }],
|
|
766
|
+
defaultModel: "gpt-5",
|
|
660
767
|
onSend: handleSend,
|
|
661
768
|
showModelSelector,
|
|
662
769
|
showWebSearch,
|
|
663
770
|
renderSendButton,
|
|
771
|
+
// Default = VoiceTextarea (chat z dyktowaniem out-of-the-box).
|
|
772
|
+
// Konsument może podać własny `renderTextarea` (np. czysty
|
|
773
|
+
// TextareaField gdy świadomie wyłącza voice).
|
|
774
|
+
renderTextarea:
|
|
775
|
+
renderTextarea ??
|
|
776
|
+
(({ value, onChange, placeholder, rows }) =>
|
|
777
|
+
createElement(VoiceTextarea, {
|
|
778
|
+
value,
|
|
779
|
+
onChange,
|
|
780
|
+
placeholder,
|
|
781
|
+
rows,
|
|
782
|
+
})),
|
|
664
783
|
disabled: isStreaming || hasWaitingInteractive,
|
|
665
784
|
}),
|
|
666
785
|
),
|