@arcote.tech/arc-chat 0.7.7 → 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.
@@ -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 { ChatLabels, ChatMessageData, SendMessageOptions } from "@arcote.tech/arc-ds";
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
- | { type: "tool"; id: string; toolCallId: string; toolName: string; params: Record<string, unknown>; result?: unknown; calling: boolean; error?: string };
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 || !historyData || historyLen === 0) return;
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
- // Open turn (in progress). The row exists with `isGenerating: true`
119
- // and no blocks; the SSE stream identified by `sessionId` will
120
- // populate this bubble live. Use msg._id as the bubble id so the
121
- // next rebuild (after `assistantTurnCompleted` flips the flag and
122
- // sets blocks) replaces this bubble naturally.
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
- let blockIdx = 0;
152
- for (const block of blocks) {
153
- if (block.type === "text") {
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: "message",
157
- id: `${msg._id}_b${blockIdx}`,
158
- role: "assistant",
159
- content: block.text,
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
- const result = resultMap.get(block.id);
226
+ // brak partial — pusty streaming bubble jako placeholder
164
227
  items.push({
165
- type: "tool",
166
- id: `${msg._id}_b${blockIdx}`,
167
- toolCallId: block.id,
168
- toolName: block.name,
169
- params: block.arguments,
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
- blockIdx++;
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
- }, [historySig, isStreaming]);
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 streamUrl = `/route/chat/${chatName}/stream/${sessionId}`;
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 "content_delta":
234
- if (!event.content) break;
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
- if (
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.content }
329
+ ? { ...item, content: item.content + event.textDelta }
250
330
  : item,
251
331
  );
252
332
  }
253
- const newId = `assistant_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
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.content!,
343
+ content: event.textDelta!,
262
344
  isStreaming: true,
263
345
  },
264
346
  ];
265
347
  });
266
348
  break;
267
349
 
268
- case "server_tool_start":
269
- if (event.toolCall) {
270
- // Finalize any streaming text bubble before the tool — next
271
- // content_delta will start a fresh bubble after the tool.
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: `tc_${event.toolCall!.id}`,
281
- toolCallId: event.toolCall!.id,
282
- toolName: event.toolCall!.name,
283
- params: event.toolCall!.arguments ?? {},
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 "server_tool_result":
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: `tc_${tc.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 on mount if there's an active generation ────
389
- // After page reload, if the DB shows isGenerating=true with sessionId,
390
- // we reconnect to the stream registry to consume any in-flight events.
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 may have already ended or been GC'd — fall through
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
- }, [isStreaming, scopeId, consumeStream]);
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
- const streamUrl = `/route/chat/${chatName}/stream/${sessionId}`;
435
- const response = await fetch(streamUrl, {
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
- const reader = response.body!.getReader();
449
- const decoder = new TextDecoder();
450
- let partialLine = "";
451
-
452
- while (true) {
453
- const { value, done } = await reader.read();
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
- const streamUrl = `/route/chat/${chatName}/stream/${newSessionId}`;
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.4-mini", label: "GPT-5.4 Mini" }],
659
- defaultModel: "gpt-5.4-mini",
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
  ),