|
11 | 11 | import { ERROR_MESSAGES, error } from "$lib/stores/errors"; |
12 | 12 | import { findCurrentModel } from "$lib/utils/models"; |
13 | 13 | import type { Message } from "$lib/types/Message"; |
14 | | - import { MessageUpdateStatus, MessageUpdateType } from "$lib/types/MessageUpdate"; |
| 14 | + import { |
| 15 | + MessageUpdateStatus, |
| 16 | + MessageUpdateType, |
| 17 | + type MessageUpdate, |
| 18 | + } from "$lib/types/MessageUpdate"; |
15 | 19 | import { useConversationsStore } from "$lib/stores/conversations.svelte"; |
16 | 20 | import file2base64 from "$lib/utils/file2base64"; |
17 | 21 | import { addChildren } from "$lib/utils/tree/addChildren"; |
|
292 | 296 | let lastUpdateTime = new Date(); |
293 | 297 | let frameFlushScheduled = false; |
294 | 298 |
|
| 299 | + // Local authoritative copy of message.updates during streaming. |
| 300 | + // Assigning the reactive field on every network chunk (~100/s on fast |
| 301 | + // providers) re-triggers the full markdown block derivation per chunk; |
| 302 | + // buffering here keeps `.updates` on the same flush cadence as |
| 303 | + // `.content`. All readers inside this loop must use the buffer, not |
| 304 | + // the $state field, or they would see stale data between flushes. |
| 305 | + let updatesBuffer: MessageUpdate[] = messageToWriteTo.updates ?? []; |
| 306 | + let updatesDirty = false; |
| 307 | +
|
295 | 308 | const flushBuffer = (currentTime: Date) => { |
296 | | - if (buffer.length === 0) return; |
297 | | - messageToWriteTo.content += buffer; |
298 | | - buffer = ""; |
299 | | - lastUpdateTime = currentTime; |
| 309 | + if (buffer.length === 0 && !updatesDirty) return; |
| 310 | + if (buffer.length > 0) { |
| 311 | + messageToWriteTo.content += buffer; |
| 312 | + buffer = ""; |
| 313 | + lastUpdateTime = currentTime; |
| 314 | + } |
| 315 | + if (updatesDirty) { |
| 316 | + messageToWriteTo.updates = updatesBuffer; |
| 317 | + updatesDirty = false; |
| 318 | + } |
300 | 319 | }; |
301 | 320 |
|
302 | 321 | const scheduleFrameFlush = () => { |
|
315 | 334 |
|
316 | 335 | for await (const update of messageUpdatesIterator) { |
317 | 336 | if ($isAborted) { |
| 337 | + // Commit anything still sitting in the content/updates buffers: |
| 338 | + // the navigation-abort path skips the post-stream refresh, so a |
| 339 | + // dropped buffer here would be lost from the UI for good. |
| 340 | + flushBuffer(new Date()); |
318 | 341 | messageUpdatesAbortController.abort(); |
319 | 342 | return; |
320 | 343 | } |
|
331 | 354 |
|
332 | 355 | if (!isKeepAlive) { |
333 | 356 | if (update.type === MessageUpdateType.Stream) { |
334 | | - const existingUpdates = messageToWriteTo.updates ?? []; |
335 | | - const lastUpdate = existingUpdates.at(-1); |
| 357 | + const lastUpdate = updatesBuffer.at(-1); |
336 | 358 | if (lastUpdate?.type === MessageUpdateType.Stream) { |
337 | 359 | // Create fresh objects/arrays so the UI reacts to merged tokens |
338 | 360 | const merged = { |
339 | 361 | ...lastUpdate, |
340 | 362 | token: (lastUpdate.token ?? "") + (update.token ?? ""), |
341 | 363 | }; |
342 | | - messageToWriteTo.updates = [...existingUpdates.slice(0, -1), merged]; |
| 364 | + updatesBuffer = [...updatesBuffer.slice(0, -1), merged]; |
343 | 365 | } else { |
344 | | - messageToWriteTo.updates = [...existingUpdates, update]; |
| 366 | + updatesBuffer = [...updatesBuffer, update]; |
345 | 367 | } |
346 | 368 | } else { |
347 | | - messageToWriteTo.updates = [...(messageToWriteTo.updates ?? []), update]; |
| 369 | + updatesBuffer = [...updatesBuffer, update]; |
348 | 370 | } |
| 371 | + updatesDirty = true; |
349 | 372 | } |
350 | 373 | const currentTime = new Date(); |
351 | 374 |
|
352 | 375 | // If we receive a non-stream update (e.g. tool/status/final answer), |
353 | | - // flush any buffered stream tokens so the UI doesn't appear to cut |
354 | | - // mid-sentence while tools are running or the final answer arrives. |
355 | | - if (update.type !== MessageUpdateType.Stream && buffer.length > 0) { |
| 376 | + // flush buffered stream tokens and pending updates so the UI doesn't |
| 377 | + // appear to cut mid-sentence while tools are running or the final |
| 378 | + // answer arrives. |
| 379 | + if (update.type !== MessageUpdateType.Stream) { |
356 | 380 | flushBuffer(currentTime); |
357 | 381 | } |
358 | 382 |
|
|
377 | 401 | // pre‑tool streamed content when appropriate. |
378 | 402 | const finalText = update.text ?? ""; |
379 | 403 | const isInterrupted = update.interrupted === true; |
380 | | - const hadTools = |
381 | | - messageToWriteTo.updates?.some((u) => u.type === MessageUpdateType.Tool) ?? false; |
| 404 | + const hadTools = updatesBuffer.some((u) => u.type === MessageUpdateType.Tool); |
382 | 405 |
|
383 | 406 | if (isInterrupted) { |
384 | 407 | if (!messageToWriteTo.content) { |
|
463 | 486 | } |
464 | 487 | } |
465 | 488 |
|
466 | | - if (buffer.length > 0) { |
467 | | - flushBuffer(new Date()); |
468 | | - } |
| 489 | + flushBuffer(new Date()); |
469 | 490 | } catch (err) { |
470 | 491 | if ($isAborted || (err instanceof DOMException && err.name === "AbortError")) { |
471 | 492 | // User-initiated abort, not an error |
|
0 commit comments