victor HF Staff commited on
Commit
72bda43
·
unverified ·
1 Parent(s): f43a7e1

New tools rendering (#2005)

Browse files

* Optimize message update handling and rendering

Refactors ChatMessage.svelte to interleave tool and stream updates for more accurate message rendering, consolidates consecutive stream tokens, and updates ToolUpdate.svelte styles for improved spacing. Also optimizes update handling in both the client and server to merge consecutive stream updates, reducing unnecessary array growth and improving performance.

* Improve message updates reactivity and storage efficiency

In the Svelte page, message update merging now creates new objects and arrays to ensure UI reactivity when streaming tokens are merged. On the server, ephemeral stream tokens and keep-alive updates are filtered out before persisting messages to the database, reducing payload size and improving rehydration performance.

* Improve chat message block handling and update persistence

Refactors ChatMessage.svelte to better handle interleaved tool and stream updates, ensuring correct text chunking and fallback behavior. Updates the conversation server logic to persist lightweight stream update markers (with token length only) instead of full tokens, preserving update order without duplicating content and improving rehydration performance.

* Improve type safety and token handling in chat messages

Adds explicit 'as const' type annotations for message block types in ChatMessage.svelte to improve type safety. Fixes token merging in conversation page to handle undefined tokens safely. Updates server logic to preserve existing token length if already present, preventing recomputation and ensuring correct ordering.

* Update ChatMessage.svelte

* Refactor chat block UI and improve tool/result handling

Introduces BlockWrapper.svelte for consistent block layout and connectors in chat UI. Refactors ToolUpdate and OpenReasoningResults to use BlockWrapper, improving expand/collapse behavior and visual feedback for loading, errors, and results. Updates message streaming logic to support compressed tokens with length markers, and cleans up related code in ChatMessage and server logic for more reliable rendering and update merging.

* Refine chat block styling and tool update UI

Improves visual spacing and block linking in chat components, updates icon and ring color logic for tool updates, and refines expandable content UI. Removes unused imports and adjusts styles for better consistency and clarity.

* Update tool status icon and loading color

Replaces the hammer icon with a check icon when a tool update is successful in ToolUpdate.svelte. Adjusts the loading path color opacity in BlockWrapper.svelte for improved visual feedback.

* Update error color classes for consistency

Adjusted Tailwind CSS classes for error backgrounds and borders to use more consistent red shades and opacity in both light and dark modes. This improves visual clarity and maintains a uniform appearance for error states.

* Update +page.svelte

* Update +page.svelte

* Refactor chat components for improved reasoning UI

Replaces global <think> block regex usage with a non-global version to avoid lastIndex side effects. Updates OpenReasoningResults to auto-expand on initial loading and improves collapsed preview by stripping markdown. Removes unused navigation props and UI from ToolUpdate for cleaner code.

* Update OpenReasoningResults.svelte

* Refine chat UI sizing and color styles

Adjusted icon and spacing sizes in BlockWrapper.svelte for improved alignment. Updated ToolUpdate.svelte to use more consistent gray text colors. Increased heading font sizes for .prose-sm in main.css from 55% to 75% of original for better readability.

* Update BlockWrapper.svelte

src/lib/components/chat/BlockWrapper.svelte ADDED
@@ -0,0 +1,72 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <script lang="ts">
2
+ import type { Snippet } from "svelte";
3
+
4
+ interface Props {
5
+ icon: Snippet;
6
+ iconBg?: string;
7
+ iconRing?: string;
8
+ hasNext?: boolean;
9
+ loading?: boolean;
10
+ children: Snippet;
11
+ }
12
+
13
+ let {
14
+ icon,
15
+ iconBg = "bg-gray-50 dark:bg-gray-800",
16
+ iconRing = "ring-gray-100 dark:ring-gray-700",
17
+ hasNext = false,
18
+ loading = false,
19
+ children,
20
+ }: Props = $props();
21
+ </script>
22
+
23
+ <div class="group flex gap-2 has-[+.prose]:mb-1.5 [.prose+&]:mt-3">
24
+ <!-- Left column: icon + connector line -->
25
+ <div class="flex w-[22px] flex-shrink-0 flex-col items-center">
26
+ <div
27
+ class="relative z-0 flex h-[22px] w-[22px] items-center justify-center rounded-md ring-1 {iconBg} {iconRing}"
28
+ >
29
+ {@render icon()}
30
+ {#if loading}
31
+ <svg
32
+ class="pointer-events-none absolute inset-0 h-[22px] w-[22px]"
33
+ viewBox="0 0 22 22"
34
+ fill="none"
35
+ xmlns="http://www.w3.org/2000/svg"
36
+ >
37
+ <rect
38
+ x="0.5"
39
+ y="0.5"
40
+ width="21"
41
+ height="21"
42
+ rx="5.5"
43
+ class="loading-path stroke-current text-purple-500/20"
44
+ stroke-width="1"
45
+ fill="none"
46
+ />
47
+ </svg>
48
+ {/if}
49
+ </div>
50
+ {#if hasNext}
51
+ <div class="my-1 w-px flex-1 bg-gray-200 dark:bg-gray-700"></div>
52
+ {/if}
53
+ </div>
54
+
55
+ <!-- Right column: content -->
56
+ <div class="min-w-0 flex-1 pb-2 pt-px">
57
+ {@render children()}
58
+ </div>
59
+ </div>
60
+
61
+ <style>
62
+ @keyframes loading {
63
+ to {
64
+ stroke-dashoffset: -100;
65
+ }
66
+ }
67
+
68
+ .loading-path {
69
+ stroke-dasharray: 60 40;
70
+ animation: loading 2s cubic-bezier(0.4, 0, 0.2, 1) infinite;
71
+ }
72
+ </style>
src/lib/components/chat/ChatMessage.svelte CHANGED
@@ -20,6 +20,7 @@
20
  import { requireAuthUser } from "$lib/utils/auth";
21
  import ToolUpdate from "./ToolUpdate.svelte";
22
  import { isMessageToolUpdate } from "$lib/utils/messageUpdates";
 
23
 
24
  interface Props {
25
  message: Message;
@@ -83,6 +84,10 @@
83
  const wrapper = document.createElement("div");
84
  wrapper.appendChild(range.cloneContents());
85
 
 
 
 
 
86
  wrapper.querySelectorAll("*").forEach((el) => {
87
  el.removeAttribute("style");
88
  el.removeAttribute("class");
@@ -110,6 +115,8 @@
110
 
111
  // Zero-config reasoning autodetection: detect <think> blocks in content
112
  const THINK_BLOCK_REGEX = /(<think>[\s\S]*?(?:<\/think>|$))/gi;
 
 
113
  let hasClientThink = $derived(message.content.split(THINK_BLOCK_REGEX).length > 1);
114
 
115
  // Strip think blocks for clipboard copy (always, regardless of detection)
@@ -117,39 +124,85 @@
117
  message.content.replace(THINK_BLOCK_REGEX, "").trim()
118
  );
119
 
120
- // Group tool updates (if any) by uuid for display
121
- let toolUpdateGroups = $derived.by(() => {
122
- const groups: Record<string, import("$lib/types/MessageUpdate").MessageToolUpdate[]> = {};
123
- for (const u of message.updates ?? []) {
124
- if (!isMessageToolUpdate(u)) continue;
125
- (groups[u.uuid] ||= []).push(u);
 
 
 
 
 
 
 
 
 
126
  }
127
- return groups;
128
- });
129
- let hasToolUpdates = $derived(Object.keys(toolUpdateGroups).length > 0);
130
 
131
- // Flatten to ordered array and keep a navigation index (defaults to last)
132
- let toolGroups = $derived(Object.values(toolUpdateGroups));
133
- let toolNavIndex = $state(0);
134
- // Auto-follow newest tool group while streaming until user navigates manually
135
- let toolAutoFollowLatest = $state(true);
136
- $effect(() => {
137
- const len = toolGroups.length;
138
- if (len === 0) {
139
- toolNavIndex = 0;
140
- return;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
141
  }
142
- // Clamp if groups shrink or grow
143
- if (toolNavIndex > len - 1) toolNavIndex = len - 1;
144
- // While streaming, default to most recent group unless user navigated away
145
- if (isLast && loading && toolAutoFollowLatest) toolNavIndex = len - 1;
146
- });
147
 
148
- // When streaming ends, re-enable auto-follow for the next turn
149
- $effect(() => {
150
- if (!loading) {
151
- toolAutoFollowLatest = true;
 
 
 
 
 
 
 
 
152
  }
 
 
153
  });
154
 
155
  $effect(() => {
@@ -200,63 +253,56 @@
200
  </div>
201
  {/if}
202
 
203
- {#if hasToolUpdates}
204
- {#if toolGroups.length}
205
- {@const group = toolGroups[toolNavIndex]}
206
- <ToolUpdate
207
- tool={group}
208
- {loading}
209
- index={toolNavIndex}
210
- total={toolGroups.length}
211
- onprev={() => {
212
- toolAutoFollowLatest = false;
213
- toolNavIndex = Math.max(0, toolNavIndex - 1);
214
- }}
215
- onnext={() => {
216
- toolNavIndex = Math.min(toolGroups.length - 1, toolNavIndex + 1);
217
- // If user moves back to the newest group, resume auto-follow
218
- toolAutoFollowLatest = toolNavIndex === toolGroups.length - 1;
219
- }}
220
- />
221
- {/if}
222
- {/if}
223
-
224
  <div bind:this={contentEl} oncopy={handleCopy}>
225
- {#if isLast && loading && message.content.length === 0}
226
  <IconLoading classNames="loading inline ml-2 first:ml-0" />
227
  {/if}
 
 
 
 
 
 
 
 
 
 
 
 
 
228
 
229
- {#if hasClientThink}
230
- {#each message.content.split(THINK_BLOCK_REGEX) as part, _i}
231
- {#if part && part.startsWith("<think>")}
232
- {@const isClosed = part.endsWith("</think>")}
233
- {@const thinkContent = part.slice(7, isClosed ? -8 : undefined)}
234
- {@const isInterrupted = !isClosed && !loading}
235
- {@const summary =
236
- isClosed || isInterrupted
237
- ? thinkContent.trim().split(/\n+/)[0] || "Reasoning"
238
- : "Thinking..."}
239
-
240
- <OpenReasoningResults
241
- {summary}
242
- content={thinkContent}
243
- loading={isLast && loading && !isClosed}
244
- />
245
- {:else if part && part.trim().length > 0}
 
 
 
 
 
 
 
246
  <div
247
  class="prose max-w-none dark:prose-invert max-sm:prose-sm prose-headings:font-semibold prose-h1:text-lg prose-h2:text-base prose-h3:text-base prose-pre:bg-gray-800 prose-img:my-0 prose-img:rounded-lg dark:prose-pre:bg-gray-900"
248
  >
249
- <MarkdownRenderer content={part} loading={isLast && loading} />
250
  </div>
251
  {/if}
252
- {/each}
253
- {:else}
254
- <div
255
- class="prose max-w-none dark:prose-invert max-sm:prose-sm prose-headings:font-semibold prose-h1:text-lg prose-h2:text-base prose-h3:text-base prose-pre:bg-gray-800 prose-img:my-0 prose-img:rounded-lg dark:prose-pre:bg-gray-900"
256
- >
257
- <MarkdownRenderer content={message.content} loading={isLast && loading} />
258
- </div>
259
- {/if}
260
  </div>
261
  </div>
262
 
 
20
  import { requireAuthUser } from "$lib/utils/auth";
21
  import ToolUpdate from "./ToolUpdate.svelte";
22
  import { isMessageToolUpdate } from "$lib/utils/messageUpdates";
23
+ import { MessageUpdateType, type MessageToolUpdate } from "$lib/types/MessageUpdate";
24
 
25
  interface Props {
26
  message: Message;
 
84
  const wrapper = document.createElement("div");
85
  wrapper.appendChild(range.cloneContents());
86
 
87
+ wrapper.querySelectorAll("[data-exclude-from-copy]").forEach((el) => {
88
+ el.remove();
89
+ });
90
+
91
  wrapper.querySelectorAll("*").forEach((el) => {
92
  el.removeAttribute("style");
93
  el.removeAttribute("class");
 
115
 
116
  // Zero-config reasoning autodetection: detect <think> blocks in content
117
  const THINK_BLOCK_REGEX = /(<think>[\s\S]*?(?:<\/think>|$))/gi;
118
+ // Non-global version for .test() calls to avoid lastIndex side effects
119
+ const THINK_BLOCK_TEST_REGEX = /(<think>[\s\S]*?(?:<\/think>|$))/i;
120
  let hasClientThink = $derived(message.content.split(THINK_BLOCK_REGEX).length > 1);
121
 
122
  // Strip think blocks for clipboard copy (always, regardless of detection)
 
124
  message.content.replace(THINK_BLOCK_REGEX, "").trim()
125
  );
126
 
127
+ type Block =
128
+ | { type: "text"; content: string }
129
+ | { type: "tool"; uuid: string; updates: MessageToolUpdate[] };
130
+
131
+ let blocks = $derived.by(() => {
132
+ const updates = message.updates ?? [];
133
+ const res: Block[] = [];
134
+ const hasTools = updates.some(isMessageToolUpdate);
135
+ let contentCursor = 0;
136
+ let sawFinalAnswer = false;
137
+
138
+ // Fast path: no tool updates at all
139
+ if (!hasTools && updates.length === 0) {
140
+ if (message.content) return [{ type: "text" as const, content: message.content }];
141
+ return [];
142
  }
 
 
 
143
 
144
+ for (const update of updates) {
145
+ if (update.type === MessageUpdateType.Stream) {
146
+ const token =
147
+ typeof update.token === "string" && update.token.length > 0 ? update.token : null;
148
+ const len = token !== null ? token.length : (update.len ?? 0);
149
+ const chunk =
150
+ token ??
151
+ (message.content ? message.content.slice(contentCursor, contentCursor + len) : "");
152
+ contentCursor += len;
153
+ if (!chunk) continue;
154
+ const last = res.at(-1);
155
+ if (last?.type === "text") last.content += chunk;
156
+ else res.push({ type: "text" as const, content: chunk });
157
+ } else if (isMessageToolUpdate(update)) {
158
+ const last = res.at(-1);
159
+ if (last?.type === "tool" && last.uuid === update.uuid) {
160
+ last.updates.push(update);
161
+ } else {
162
+ res.push({ type: "tool" as const, uuid: update.uuid, updates: [update] });
163
+ }
164
+ } else if (update.type === MessageUpdateType.FinalAnswer) {
165
+ sawFinalAnswer = true;
166
+ const finalText = update.text ?? "";
167
+ const currentText = res
168
+ .filter((b) => b.type === "text")
169
+ .map((b) => (b as { type: "text"; content: string }).content)
170
+ .join("");
171
+
172
+ let addedText = "";
173
+ if (finalText.startsWith(currentText)) {
174
+ addedText = finalText.slice(currentText.length);
175
+ } else if (!currentText.endsWith(finalText)) {
176
+ const needsGap = !/\n\n$/.test(currentText) && !/^\n/.test(finalText);
177
+ addedText = (needsGap ? "\n\n" : "") + finalText;
178
+ }
179
+
180
+ if (addedText) {
181
+ const last = res.at(-1);
182
+ if (last?.type === "text") {
183
+ last.content += addedText;
184
+ } else {
185
+ res.push({ type: "text" as const, content: addedText });
186
+ }
187
+ }
188
+ }
189
  }
 
 
 
 
 
190
 
191
+ // If content remains unmatched (e.g., persisted stream markers), append the remainder
192
+ // Skip when a FinalAnswer already provided the authoritative text.
193
+ if (!sawFinalAnswer && message.content && contentCursor < message.content.length) {
194
+ const remaining = message.content.slice(contentCursor);
195
+ if (remaining.length > 0) {
196
+ const last = res.at(-1);
197
+ if (last?.type === "text") last.content += remaining;
198
+ else res.push({ type: "text" as const, content: remaining });
199
+ }
200
+ } else if (!res.some((b) => b.type === "text") && message.content) {
201
+ // Fallback: no text produced at all
202
+ res.push({ type: "text" as const, content: message.content });
203
  }
204
+
205
+ return res;
206
  });
207
 
208
  $effect(() => {
 
253
  </div>
254
  {/if}
255
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
256
  <div bind:this={contentEl} oncopy={handleCopy}>
257
+ {#if isLast && loading && blocks.length === 0}
258
  <IconLoading classNames="loading inline ml-2 first:ml-0" />
259
  {/if}
260
+ {#each blocks as block, blockIndex (block.type === "tool" ? `${block.uuid}-${blockIndex}` : `text-${blockIndex}`)}
261
+ {@const nextBlock = blocks[blockIndex + 1]}
262
+ {@const nextBlockHasThink =
263
+ nextBlock?.type === "text" && THINK_BLOCK_TEST_REGEX.test(nextBlock.content)}
264
+ {@const nextIsLinkable = nextBlock?.type === "tool" || nextBlockHasThink}
265
+ {#if block.type === "tool"}
266
+ <div data-exclude-from-copy class="has-[+.prose]:mb-3 [.prose+&]:mt-4">
267
+ <ToolUpdate tool={block.updates} {loading} hasNext={nextIsLinkable} />
268
+ </div>
269
+ {:else if block.type === "text"}
270
+ {#if isLast && loading && block.content.length === 0}
271
+ <IconLoading classNames="loading inline ml-2 first:ml-0" />
272
+ {/if}
273
 
274
+ {#if hasClientThink}
275
+ {@const parts = block.content.split(THINK_BLOCK_REGEX)}
276
+ {#each parts as part, partIndex}
277
+ {@const remainingParts = parts.slice(partIndex + 1)}
278
+ {@const hasMoreLinkable =
279
+ remainingParts.some((p) => p && THINK_BLOCK_TEST_REGEX.test(p)) || nextIsLinkable}
280
+ {#if part && part.startsWith("<think>")}
281
+ {@const isClosed = part.endsWith("</think>")}
282
+ {@const thinkContent = part.slice(7, isClosed ? -8 : undefined)}
283
+
284
+ <OpenReasoningResults
285
+ content={thinkContent}
286
+ loading={isLast && loading && !isClosed}
287
+ hasNext={hasMoreLinkable}
288
+ />
289
+ {:else if part && part.trim().length > 0}
290
+ <div
291
+ class="prose max-w-none dark:prose-invert max-sm:prose-sm prose-headings:font-semibold prose-h1:text-lg prose-h2:text-base prose-h3:text-base prose-pre:bg-gray-800 prose-img:my-0 prose-img:rounded-lg dark:prose-pre:bg-gray-900"
292
+ >
293
+ <MarkdownRenderer content={part} loading={isLast && loading} />
294
+ </div>
295
+ {/if}
296
+ {/each}
297
+ {:else}
298
  <div
299
  class="prose max-w-none dark:prose-invert max-sm:prose-sm prose-headings:font-semibold prose-h1:text-lg prose-h2:text-base prose-h3:text-base prose-pre:bg-gray-800 prose-img:my-0 prose-img:rounded-lg dark:prose-pre:bg-gray-900"
300
  >
301
+ <MarkdownRenderer content={block.content} loading={isLast && loading} />
302
  </div>
303
  {/if}
304
+ {/if}
305
+ {/each}
 
 
 
 
 
 
306
  </div>
307
  </div>
308
 
src/lib/components/chat/OpenReasoningResults.svelte CHANGED
@@ -1,86 +1,81 @@
1
  <script lang="ts">
2
  import MarkdownRenderer from "./MarkdownRenderer.svelte";
3
- import CarbonCaretDown from "~icons/carbon/caret-down";
4
 
5
  interface Props {
6
- summary: string;
7
  content: string;
8
  loading?: boolean;
 
9
  }
10
 
11
- let { summary, content, loading = false }: Props = $props();
12
- let isOpen = $state(loading);
 
 
13
 
 
14
  $effect(() => {
15
- isOpen = loading;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
16
  });
17
  </script>
18
 
19
- <details
20
- bind:open={isOpen}
21
- class="group flex w-fit max-w-full flex-col rounded-xl border border-gray-200 bg-white shadow-sm dark:border-gray-800 dark:bg-gray-900 [&:has(+_.prose)]:mb-4 [.prose+&]:mt-4 [details+&]:mt-2"
 
 
 
 
 
 
 
 
 
 
 
 
22
  >
23
- <summary
24
- class="
25
- grid min-w-72 cursor-pointer select-none grid-cols-[40px,1fr,24px] items-center gap-2.5 rounded-xl p-2 group-open:rounded-b-none hover:bg-gray-50 dark:hover:bg-gray-800/20"
 
 
26
  >
27
- <div
28
- class="relative grid aspect-square place-content-center overflow-hidden rounded-lg bg-gray-100 dark:bg-gray-800"
29
- >
30
- <div class="grid h-dvh place-items-center">
31
- <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 32 32">
32
- <path
33
- class="stroke-gray-600 dark:stroke-gray-400"
34
- style="stroke-width: 1.9; fill: none; stroke-linecap: round; stroke-linejoin: round;"
35
- d="M16 6v3.33M16 6c0-2.65 3.25-4.3 5.4-2.62 1.2.95 1.6 2.65.95 4.04a3.63 3.63 0 0 1 4.61.16 3.45 3.45 0 0 1 .46 4.37 5.32 5.32 0 0 1 1.87 4.75c-.22 1.66-1.39 3.6-3.07 4.14M16 6c0-2.65-3.25-4.3-5.4-2.62a3.37 3.37 0 0 0-.95 4.04 3.65 3.65 0 0 0-4.6.16 3.37 3.37 0 0 0-.49 4.27 5.57 5.57 0 0 0-1.85 4.85 5.3 5.3 0 0 0 3.07 4.15M16 9.33v17.34m0-17.34c0 2.18 1.82 4 4 4m6.22 7.5c.67 1.3.56 2.91-.27 4.11a4.05 4.05 0 0 1-4.62 1.5c0 1.53-1.05 2.9-2.66 2.9A2.7 2.7 0 0 1 16 26.66m10.22-5.83a4.05 4.05 0 0 0-3.55-2.17m-16.9 2.18a4.05 4.05 0 0 0 .28 4.1c1 1.44 2.92 2.09 4.59 1.5 0 1.52 1.12 2.88 2.7 2.88A2.7 2.7 0 0 0 16 26.67M5.78 20.85a4.04 4.04 0 0 1 3.55-2.18"
36
- />
37
-
38
- {#if loading}
39
- <path
40
- class="animate-pulse stroke-white"
41
- style="stroke-width: 2; fill: none; stroke-linecap: round; stroke-linejoin: round; stroke-dasharray: 50;"
42
- d="M16 6v3.33M16 6c0-2.65 3.25-4.3 5.4-2.62 1.2.95 1.6 2.65.95 4.04a3.63 3.63 0 0 1 4.61.16 3.45 3.45 0 0 1 .46 4.37 5.32 5.32 0 0 1 1.87 4.75c-.22 1.66-1.39 3.6-3.07 4.14M16 6c0-2.65-3.25-4.3-5.4-2.62a3.37 3.37 0 0 0-.95 4.04 3.65 3.65 0 0 0-4.6.16 3.37 3.37 0 0 0-.49 4.27 5.57 5.57 0 0 0-1.85 4.85 5.3 5.3 0 0 0 3.07 4.15M16 9.33v17.34m0-17.34c0 2.18 1.82 4 4 4m6.22 7.5c.67 1.3.56 2.91-.27 4.11a4.05 4.05 0 0 1-4.62 1.5c0 1.53-1.05 2.9-2.66 2.9A2.7 2.7 0 0 1 16 26.66m10.22-5.83a4.05 4.05 0 0 0-3.55-2.17m-16.9 2.18a4.05 4.05 0 0 0 .28 4.1c1 1.44 2.92 2.09 4.59 1.5 0 1.52 1.12 2.88 2.7 2.88A2.7 2.7 0 0 0 16 26.67M5.78 20.85a4.04 4.04 0 0 1 3.55-2.18"
43
- >
44
- <animate
45
- attributeName="stroke-dashoffset"
46
- values="0;500"
47
- dur="12s"
48
- repeatCount="indefinite"
49
- />
50
- </path>
51
- {/if}
52
- </svg>
53
  </div>
54
- </div>
55
- <dl class="leading-4">
56
- <dd class="text-sm">Reasoning</dd>
57
- <dt
58
- class="flex items-center gap-1 truncate whitespace-nowrap text-[.82rem] text-gray-400"
59
  class:animate-pulse={loading}
60
  >
61
- {summary.length > 33
62
- ? summary.substring(0, 33) + "..."
63
- : summary.endsWith("...")
64
- ? summary
65
- : summary + "..."}
66
- </dt>
67
- </dl>
68
- <CarbonCaretDown
69
- class="transition-rotate size-5 -rotate-90 text-gray-400 group-open:rotate-0"
70
- />
71
- </summary>
72
-
73
- <div
74
- class="prose prose-sm !max-w-none space-y-4 border-t border-gray-200 p-3 text-sm text-gray-600 dark:prose-invert prose-img:my-0 prose-img:rounded-lg dark:border-gray-800 dark:text-gray-400"
75
- >
76
- {#key content}
77
- <MarkdownRenderer {content} {loading} />
78
- {/key}
79
- </div>
80
- </details>
81
-
82
- <style>
83
- details summary::-webkit-details-marker {
84
- display: none;
85
- }
86
- </style>
 
1
  <script lang="ts">
2
  import MarkdownRenderer from "./MarkdownRenderer.svelte";
3
+ import BlockWrapper from "./BlockWrapper.svelte";
4
 
5
  interface Props {
 
6
  content: string;
7
  loading?: boolean;
8
+ hasNext?: boolean;
9
  }
10
 
11
+ let { content, loading = false, hasNext = false }: Props = $props();
12
+ let isOpen = $state(false);
13
+ let wasLoading = $state(false);
14
+ let initialized = $state(false);
15
 
16
+ // Track loading transitions to auto-expand/collapse
17
  $effect(() => {
18
+ // Auto-expand on first render if already loading
19
+ if (!initialized) {
20
+ initialized = true;
21
+ if (loading) {
22
+ isOpen = true;
23
+ wasLoading = true;
24
+ return;
25
+ }
26
+ }
27
+
28
+ if (loading && !wasLoading) {
29
+ // Loading started - auto-expand
30
+ isOpen = true;
31
+ } else if (!loading && wasLoading) {
32
+ // Loading finished - auto-collapse
33
+ isOpen = false;
34
+ }
35
+ wasLoading = loading;
36
  });
37
  </script>
38
 
39
+ {#snippet icon()}
40
+ <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 32 32">
41
+ <path
42
+ class="stroke-gray-500 dark:stroke-gray-400"
43
+ style="stroke-width: 1.9; fill: none; stroke-linecap: round; stroke-linejoin: round;"
44
+ d="M16 6v3.33M16 6c0-2.65 3.25-4.3 5.4-2.62 1.2.95 1.6 2.65.95 4.04a3.63 3.63 0 0 1 4.61.16 3.45 3.45 0 0 1 .46 4.37 5.32 5.32 0 0 1 1.87 4.75c-.22 1.66-1.39 3.6-3.07 4.14M16 6c0-2.65-3.25-4.3-5.4-2.62a3.37 3.37 0 0 0-.95 4.04 3.65 3.65 0 0 0-4.6.16 3.37 3.37 0 0 0-.49 4.27 5.57 5.57 0 0 0-1.85 4.85 5.3 5.3 0 0 0 3.07 4.15M16 9.33v17.34m0-17.34c0 2.18 1.82 4 4 4m6.22 7.5c.67 1.3.56 2.91-.27 4.11a4.05 4.05 0 0 1-4.62 1.5c0 1.53-1.05 2.9-2.66 2.9A2.7 2.7 0 0 1 16 26.66m10.22-5.83a4.05 4.05 0 0 0-3.55-2.17m-16.9 2.18a4.05 4.05 0 0 0 .28 4.1c1 1.44 2.92 2.09 4.59 1.5 0 1.52 1.12 2.88 2.7 2.88A2.7 2.7 0 0 0 16 26.67M5.78 20.85a4.04 4.04 0 0 1 3.55-2.18"
45
+ />
46
+ </svg>
47
+ {/snippet}
48
+
49
+ <BlockWrapper
50
+ {icon}
51
+ {hasNext}
52
+ iconBg="bg-gray-100 dark:bg-gray-700"
53
+ iconRing="ring-gray-200 dark:ring-gray-600"
54
  >
55
+ <!-- Collapsed view (clickable to expand) -->
56
+ <button
57
+ type="button"
58
+ class="group/text w-full cursor-pointer text-left"
59
+ onclick={() => (isOpen = !isOpen)}
60
  >
61
+ {#if isOpen}
62
+ <!-- Expanded: show full content -->
63
+ <div
64
+ class="prose prose-sm max-w-none text-sm leading-relaxed text-gray-500 dark:prose-invert dark:text-gray-400"
65
+ >
66
+ <MarkdownRenderer {content} {loading} />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
67
  </div>
68
+ {:else}
69
+ <!-- Collapsed: 2-line preview (plain text, strip markdown) -->
70
+ <div
71
+ class="line-clamp-2 text-sm leading-relaxed text-gray-500 dark:text-gray-400"
 
72
  class:animate-pulse={loading}
73
  >
74
+ {content
75
+ .replace(/[#*_`~[\]]/g, "")
76
+ .replace(/\n+/g, " ")
77
+ .trim()}
78
+ </div>
79
+ {/if}
80
+ </button>
81
+ </BlockWrapper>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/lib/components/chat/ToolUpdate.svelte CHANGED
@@ -6,38 +6,32 @@
6
  isMessageToolResultUpdate,
7
  } from "$lib/utils/messageUpdates";
8
  import LucideHammer from "~icons/lucide/hammer";
 
9
  import { ToolResultStatus, type ToolFront } from "$lib/types/Tool";
10
  import { page } from "$app/state";
11
- import { onDestroy } from "svelte";
12
- import { browser } from "$app/environment";
13
- import CarbonChevronLeft from "~icons/carbon/chevron-left";
14
  import CarbonChevronRight from "~icons/carbon/chevron-right";
 
15
 
16
  interface Props {
17
  tool: MessageToolUpdate[];
18
  loading?: boolean;
19
- // Optional navigation props when multiple tool groups exist
20
- index?: number;
21
- total?: number;
22
- onprev?: () => void;
23
- onnext?: () => void;
24
  }
25
 
26
- let { tool, loading = false, index, total, onprev, onnext }: Props = $props();
 
 
27
 
28
  let toolFnName = $derived(tool.find(isMessageToolCallUpdate)?.call.name);
29
  let toolError = $derived(tool.some(isMessageToolErrorUpdate));
30
  let toolDone = $derived(tool.some(isMessageToolResultUpdate));
31
- let eta = $derived(tool.find((update) => update.subtype === MessageToolUpdateType.ETA)?.eta);
 
32
 
33
  const availableTools: ToolFront[] = $derived.by(
34
  () => (page.data as { tools?: ToolFront[] } | undefined)?.tools ?? []
35
  );
36
 
37
- let loadingBarEl: HTMLDivElement | undefined = $state(undefined);
38
- let animation: Animation | undefined = $state(undefined);
39
- let showingLoadingBar = $state(false);
40
-
41
  type ToolOutput = Record<string, unknown>;
42
  type McpImageContent = {
43
  type: "image";
@@ -98,218 +92,169 @@
98
  metadata: getMetadataEntries(output),
99
  }));
100
 
101
- $effect(() => {
102
- if (!toolError && !toolDone && loading && loadingBarEl && eta) {
103
- loadingBarEl.classList.remove("hidden");
104
- showingLoadingBar = true;
105
- animation = loadingBarEl.animate([{ width: "0%" }, { width: "calc(100%+1rem)" }], {
106
- duration: (eta ?? 0) * 1000,
107
- fill: "forwards",
108
- });
109
- }
110
- });
111
-
112
- onDestroy(() => {
113
- animation?.cancel();
114
- });
115
 
116
- $effect(() => {
117
- if ((!loading || toolDone || toolError) && browser && loadingBarEl && showingLoadingBar) {
118
- showingLoadingBar = false;
119
- loadingBarEl.classList.remove("hidden");
120
- animation?.cancel();
121
- const fromWidth = getComputedStyle(loadingBarEl).width;
122
- animation = loadingBarEl.animate([{ width: fromWidth }, { width: "calc(100%+1rem)" }], {
123
- duration: 300,
124
- fill: "forwards",
125
- });
126
- setTimeout(() => loadingBarEl?.classList.add("hidden"), 300);
127
- }
128
- });
129
  </script>
130
 
131
- {#if toolFnName}
132
- <details
133
- class="group/tool my-2.5 w-fit max-w-full cursor-pointer rounded-lg border border-gray-200 bg-white px-1 {(total ??
134
- 0) > 1
135
- ? ''
136
- : 'pr-2'} text-sm shadow-sm first:mt-0 open:mb-3 open:border-purple-500/10 open:bg-purple-600/5 open:shadow-sm dark:border-gray-800 dark:bg-gray-900 open:dark:border-purple-800/40 open:dark:bg-purple-800/10 [&+details]:-mt-2"
137
- >
138
- <summary
139
- class="relative flex select-none list-none items-center gap-1.5 py-1 group-open/tool:text-purple-700 group-open/tool:dark:text-purple-300"
140
- >
141
- <div
142
- bind:this={loadingBarEl}
143
- class="absolute -m-1 hidden h-full w-full rounded-lg bg-purple-500/5 transition-all dark:bg-purple-500/10"
144
- ></div>
145
 
146
- <div
147
- class="relative grid size-[22px] place-items-center rounded bg-purple-600/10 dark:bg-purple-600/20"
 
 
 
 
 
 
148
  >
149
- <svg
150
- class="absolute inset-0 text-purple-500/40 transition-opacity"
151
- class:invisible={toolDone || toolError}
152
- width="22"
153
- height="22"
154
- viewBox="0 0 38 38"
155
- fill="none"
156
- xmlns="http://www.w3.org/2000/svg"
157
  >
158
- <path
159
- class="loading-path"
160
- d="M8 2.5H30C30 2.5 35.5 2.5 35.5 8V30C35.5 30 35.5 35.5 30 35.5H8C8 35.5 2.5 35.5 2.5 30V8C2.5 8 2.5 2.5 8 2.5Z"
161
- pathLength="100"
162
- stroke="currentColor"
163
- stroke-width="1"
164
- stroke-linecap="round"
165
- id="shape"
166
- />
167
- </svg>
168
- <LucideHammer class="size-3.5 text-purple-700 dark:text-purple-500" />
169
- </div>
170
-
171
- <span class="relative">
172
- {toolError ? "Error calling" : toolDone ? "Called" : "Calling"} tool
173
- <span class="font-semibold"
174
- >{availableTools.find((entry) => entry.name === toolFnName)?.displayName ??
175
- toolFnName}</span
176
- >
177
- </span>
178
-
179
- {#if (total ?? 0) > 1}
180
- <div class="relative ml-auto flex items-center gap-1.5">
181
- <div
182
- class="flex items-center divide-x rounded-md border border-gray-200 bg-gray-50 dark:divide-gray-700 dark:border-gray-800 dark:bg-gray-800"
183
  >
184
- <button
185
- type="button"
186
- class="btn size-5 text-xs text-gray-500 hover:text-gray-700 focus:ring-0 disabled:opacity-40 dark:text-gray-400 dark:hover:text-gray-200"
187
- title="Previous tool"
188
- aria-label="Previous tool"
189
- disabled={(index ?? 0) <= 0}
190
- onclick={(e) => {
191
- e.preventDefault();
192
- e.stopPropagation();
193
- onprev?.();
194
- }}
195
- >
196
- <CarbonChevronLeft />
197
- </button>
198
-
199
- <span
200
- class="select-none px-1 text-center text-[10px] font-medium text-gray-500 dark:text-gray-400"
201
- aria-live="polite"
202
- >
203
- {(index ?? 0) + 1} <span class="text-gray-300 dark:text-gray-500">/</span>
204
- {total}
205
- </span>
206
- <button
207
- type="button"
208
- class="btn size-5 text-xs text-gray-500 hover:text-gray-700 focus:ring-0 disabled:opacity-40 dark:text-gray-400 dark:hover:text-gray-200"
209
- title="Next tool"
210
- aria-label="Next tool"
211
- disabled={(index ?? 0) >= (total ?? 1) - 1}
212
- onclick={(e) => {
213
- e.preventDefault();
214
- e.stopPropagation();
215
- onnext?.();
216
- }}
217
- >
218
- <CarbonChevronRight />
219
- </button>
220
- </div>
221
- </div>
222
- {/if}
223
- </summary>
224
 
225
- {#each tool as update}
226
- {#if update.subtype === MessageToolUpdateType.Call}
227
- <div class="mt-1 flex items-center gap-2 opacity-80">
228
- <h3 class="text-sm">Parameters</h3>
229
- <div class="h-px flex-1 bg-gradient-to-r from-gray-500/20"></div>
230
- </div>
231
- <ul class="py-1 text-sm">
232
- {#each Object.entries(update.call.parameters ?? {}) as [key, value]}
233
- {#if value != null}
234
- <li>
235
- <span class="font-semibold">{key}</span>:
236
- <span class="whitespace-pre-wrap">{formatValue(value)}</span>
237
- </li>
238
- {/if}
239
- {/each}
240
- </ul>
241
- {:else if update.subtype === MessageToolUpdateType.Error}
242
- <div class="mt-1 flex items-center gap-2 opacity-80">
243
- <h3 class="text-sm">Error</h3>
244
- <div class="h-px flex-1 bg-gradient-to-r from-gray-500/20"></div>
245
- </div>
246
- <p class="text-sm">{update.message}</p>
247
- {:else if isMessageToolResultUpdate(update) && update.result.status === ToolResultStatus.Success && update.result.display}
248
- <div class="mt-1 flex items-center gap-2 opacity-80">
249
- <h3 class="text-sm">Result</h3>
250
- <div class="h-px flex-1 bg-gradient-to-r from-gray-500/20"></div>
251
- </div>
252
- <div class="py-1 text-sm">
253
- {#each parseToolOutputs(update.result.outputs) as parsedOutput}
254
- <div class="space-y-2 py-2 first:pt-0 last:pb-0">
255
- {#if parsedOutput.text}
256
- <!-- prettier-ignore -->
257
- <pre class="whitespace-pre-wrap break-all font-mono text-xs">{parsedOutput.text}</pre>
258
- {/if}
259
 
260
- {#if parsedOutput.images.length > 0}
261
- <div class="flex flex-wrap gap-2">
262
- {#each parsedOutput.images as image, imageIndex}
263
- <img
264
- alt={`Tool result image ${imageIndex + 1}`}
265
- class="max-h-60 rounded border border-gray-200 dark:border-gray-800"
266
- src={`data:${image.mimeType};base64,${image.data}`}
267
- />
268
- {/each}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
269
  </div>
270
- {/if}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
271
 
272
- {#if parsedOutput.metadata.length > 0}
273
- <ul class="space-y-1">
274
- {#each parsedOutput.metadata as [key, value]}
275
- <li>
276
- <span class="font-semibold">{key}</span>:
277
- <span class="whitespace-pre-wrap">{formatValue(value)}</span>
278
- </li>
279
- {/each}
280
- </ul>
281
- {/if}
282
  </div>
283
- {/each}
284
- </div>
285
- {:else if isMessageToolResultUpdate(update) && update.result.status === ToolResultStatus.Error && update.result.display}
286
- <div class="mt-1 flex items-center gap-2 opacity-80">
287
- <h3 class="text-sm text-red-600 dark:text-red-400">Error</h3>
288
- <div class="h-px flex-1 bg-gradient-to-r from-red-500/20"></div>
289
- </div>
290
- <p class="whitespace-pre-wrap text-sm text-red-600 dark:text-red-400">
291
- {update.result.message}
292
- </p>
293
- {/if}
294
- {/each}
295
- </details>
 
 
 
 
 
 
296
  {/if}
297
-
298
- <style>
299
- details summary::-webkit-details-marker {
300
- display: none;
301
- }
302
-
303
- @keyframes loading {
304
- to {
305
- /* move one full perimeter, normalized via pathLength=100 */
306
- stroke-dashoffset: -100;
307
- }
308
- }
309
-
310
- .loading-path {
311
- /* larger traveling gap for clearer motion */
312
- stroke-dasharray: 80 20; /* 80% dash, 20% gap */
313
- animation: loading 1.6s linear infinite;
314
- }
315
- </style>
 
6
  isMessageToolResultUpdate,
7
  } from "$lib/utils/messageUpdates";
8
  import LucideHammer from "~icons/lucide/hammer";
9
+ import LucideCheck from "~icons/lucide/check";
10
  import { ToolResultStatus, type ToolFront } from "$lib/types/Tool";
11
  import { page } from "$app/state";
 
 
 
12
  import CarbonChevronRight from "~icons/carbon/chevron-right";
13
+ import BlockWrapper from "./BlockWrapper.svelte";
14
 
15
  interface Props {
16
  tool: MessageToolUpdate[];
17
  loading?: boolean;
18
+ hasNext?: boolean;
 
 
 
 
19
  }
20
 
21
+ let { tool, loading = false, hasNext = false }: Props = $props();
22
+
23
+ let isOpen = $state(false);
24
 
25
  let toolFnName = $derived(tool.find(isMessageToolCallUpdate)?.call.name);
26
  let toolError = $derived(tool.some(isMessageToolErrorUpdate));
27
  let toolDone = $derived(tool.some(isMessageToolResultUpdate));
28
+ let isExecuting = $derived(!toolDone && !toolError && loading);
29
+ let toolSuccess = $derived(toolDone && !toolError);
30
 
31
  const availableTools: ToolFront[] = $derived.by(
32
  () => (page.data as { tools?: ToolFront[] } | undefined)?.tools ?? []
33
  );
34
 
 
 
 
 
35
  type ToolOutput = Record<string, unknown>;
36
  type McpImageContent = {
37
  type: "image";
 
92
  metadata: getMetadataEntries(output),
93
  }));
94
 
95
+ // Icon styling based on state
96
+ let iconBg = $derived(
97
+ toolError ? "bg-red-100 dark:bg-red-900/40" : "bg-purple-100 dark:bg-purple-900/40"
98
+ );
 
 
 
 
 
 
 
 
 
 
99
 
100
+ let iconRing = $derived(
101
+ toolError ? "ring-red-200 dark:ring-red-500/30" : "ring-purple-200 dark:ring-purple-500/30"
102
+ );
 
 
 
 
 
 
 
 
 
 
103
  </script>
104
 
105
+ {#snippet icon()}
106
+ {#if toolSuccess}
107
+ <LucideCheck class="size-3.5 text-purple-600 dark:text-purple-400" />
108
+ {:else}
109
+ <LucideHammer
110
+ class="size-3.5 {toolError
111
+ ? 'text-red-500 dark:text-red-400'
112
+ : 'text-purple-600 dark:text-purple-400'}"
113
+ />
114
+ {/if}
115
+ {/snippet}
 
 
 
116
 
117
+ {#if toolFnName}
118
+ <BlockWrapper {icon} {iconBg} {iconRing} {hasNext} loading={isExecuting}>
119
+ <!-- Header row -->
120
+ <div class="flex w-full select-none items-center gap-2">
121
+ <button
122
+ type="button"
123
+ class="flex flex-1 cursor-pointer items-center gap-2 text-left"
124
+ onclick={() => (isOpen = !isOpen)}
125
  >
126
+ <span
127
+ class="text-sm font-medium {isExecuting
128
+ ? 'text-purple-700 dark:text-purple-300'
129
+ : toolError
130
+ ? 'text-red-600 dark:text-red-400'
131
+ : 'text-gray-700 dark:text-gray-300'}"
 
 
132
  >
133
+ {toolError ? "Error calling" : toolDone ? "Called" : "Calling"} tool
134
+ <code
135
+ class="rounded bg-gray-100 px-1.5 py-0.5 font-mono text-xs text-gray-500 opacity-90 dark:bg-gray-800 dark:text-gray-400"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
136
  >
137
+ {availableTools.find((entry) => entry.name === toolFnName)?.displayName ?? toolFnName}
138
+ </code>
139
+ </span>
140
+ </button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
141
 
142
+ <button
143
+ type="button"
144
+ class="cursor-pointer"
145
+ onclick={() => (isOpen = !isOpen)}
146
+ aria-label={isOpen ? "Collapse" : "Expand"}
147
+ >
148
+ <CarbonChevronRight
149
+ class="size-4 text-gray-400 transition-transform duration-200 {isOpen ? 'rotate-90' : ''}"
150
+ />
151
+ </button>
152
+ </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
153
 
154
+ <!-- Expandable content -->
155
+ {#if isOpen}
156
+ <div class="mt-2 space-y-3">
157
+ {#each tool as update, i (`${update.subtype}-${i}`)}
158
+ {#if update.subtype === MessageToolUpdateType.Call}
159
+ <div class="space-y-1">
160
+ <div
161
+ class="text-[10px] font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500"
162
+ >
163
+ Input
164
+ </div>
165
+ <div
166
+ class="rounded-md border border-gray-100 bg-gray-50 p-2 text-gray-500 dark:border-gray-700 dark:bg-gray-800/50 dark:text-gray-400"
167
+ >
168
+ <pre class="whitespace-pre-wrap break-all font-mono text-xs">{formatValue(
169
+ update.call.parameters
170
+ )}</pre>
171
+ </div>
172
+ </div>
173
+ {:else if update.subtype === MessageToolUpdateType.Error}
174
+ <div class="space-y-1">
175
+ <div
176
+ class="text-[10px] font-semibold uppercase tracking-wider text-red-500 dark:text-red-400"
177
+ >
178
+ Error
179
+ </div>
180
+ <div
181
+ class="rounded-md border border-red-200 bg-red-50 p-2 text-red-600 dark:border-red-500/30 dark:bg-red-900/20 dark:text-red-400"
182
+ >
183
+ <pre class="whitespace-pre-wrap break-all font-mono text-xs">{update.message}</pre>
184
+ </div>
185
+ </div>
186
+ {:else if isMessageToolResultUpdate(update) && update.result.status === ToolResultStatus.Success && update.result.display}
187
+ <div class="space-y-1">
188
+ <div class="flex items-center gap-2">
189
+ <div
190
+ class="text-[10px] font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500"
191
+ >
192
+ Output
193
  </div>
194
+ <svg
195
+ xmlns="http://www.w3.org/2000/svg"
196
+ width="12"
197
+ height="12"
198
+ viewBox="0 0 24 24"
199
+ fill="none"
200
+ stroke="currentColor"
201
+ stroke-width="2"
202
+ stroke-linecap="round"
203
+ stroke-linejoin="round"
204
+ class="text-emerald-500"
205
+ >
206
+ <circle cx="12" cy="12" r="10"></circle>
207
+ <path d="m9 12 2 2 4-4"></path>
208
+ </svg>
209
+ </div>
210
+ <div
211
+ class="scrollbar-custom rounded-md border border-gray-100 bg-white p-2 text-gray-500 dark:border-gray-700 dark:bg-gray-800/50 dark:text-gray-400"
212
+ >
213
+ {#each parseToolOutputs(update.result.outputs) as parsedOutput}
214
+ <div class="space-y-2">
215
+ {#if parsedOutput.text}
216
+ <pre
217
+ class="scrollbar-custom max-h-60 overflow-y-auto whitespace-pre-wrap break-all font-mono text-xs">{parsedOutput.text}</pre>
218
+ {/if}
219
+
220
+ {#if parsedOutput.images.length > 0}
221
+ <div class="flex flex-wrap gap-2">
222
+ {#each parsedOutput.images as image, imageIndex}
223
+ <img
224
+ alt={`Tool result image ${imageIndex + 1}`}
225
+ class="max-h-60 rounded border border-gray-200 dark:border-gray-700"
226
+ src={`data:${image.mimeType};base64,${image.data}`}
227
+ />
228
+ {/each}
229
+ </div>
230
+ {/if}
231
 
232
+ {#if parsedOutput.metadata.length > 0}
233
+ <pre class="whitespace-pre-wrap break-all font-mono text-xs">{formatValue(
234
+ Object.fromEntries(parsedOutput.metadata)
235
+ )}</pre>
236
+ {/if}
237
+ </div>
238
+ {/each}
239
+ </div>
 
 
240
  </div>
241
+ {:else if isMessageToolResultUpdate(update) && update.result.status === ToolResultStatus.Error && update.result.display}
242
+ <div class="space-y-1">
243
+ <div
244
+ class="text-[10px] font-semibold uppercase tracking-wider text-red-500 dark:text-red-400"
245
+ >
246
+ Error
247
+ </div>
248
+ <div
249
+ class="rounded-md border border-red-200 bg-red-50 p-2 text-red-600 dark:border-red-500/30 dark:bg-red-900/20 dark:text-red-400"
250
+ >
251
+ <pre class="whitespace-pre-wrap break-all font-mono text-xs">{update.result
252
+ .message}</pre>
253
+ </div>
254
+ </div>
255
+ {/if}
256
+ {/each}
257
+ </div>
258
+ {/if}
259
+ </BlockWrapper>
260
  {/if}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/lib/types/MessageUpdate.ts CHANGED
@@ -44,6 +44,8 @@ export interface MessageTitleUpdate {
44
  export interface MessageStreamUpdate {
45
  type: MessageUpdateType.Stream;
46
  token: string;
 
 
47
  }
48
 
49
  // Tool updates (for MCP and function calling)
 
44
  export interface MessageStreamUpdate {
45
  type: MessageUpdateType.Stream;
46
  token: string;
47
+ /** Length of the original token. Used for compressed/persisted stream markers where token is empty. */
48
+ len?: number;
49
  }
50
 
51
  // Tool updates (for MCP and function calling)
src/routes/conversation/[id]/+page.svelte CHANGED
@@ -250,13 +250,27 @@
250
  update.token = update.token.replaceAll("\0", "");
251
  }
252
 
253
- const isHighFrequencyUpdate =
254
- update.type === MessageUpdateType.Stream ||
255
- (update.type === MessageUpdateType.Status &&
256
- update.status === MessageUpdateStatus.KeepAlive);
257
-
258
- if (!isHighFrequencyUpdate) {
259
- messageToWriteTo.updates = [...(messageToWriteTo.updates ?? []), update];
 
 
 
 
 
 
 
 
 
 
 
 
 
 
260
  }
261
  const currentTime = new Date();
262
 
@@ -394,7 +408,7 @@
394
 
395
  function handleKeydown(event: KeyboardEvent) {
396
  // Stop generation on ESC key when loading
397
- if (event.key === "Escape" && loading) {
398
  event.preventDefault();
399
  stopGeneration();
400
  }
@@ -485,7 +499,7 @@
485
  const navigatingAway =
486
  navigation.to?.route.id !== page.route.id || navigation.to?.params?.id !== page.params.id;
487
 
488
- if (loading && navigatingAway) {
489
  addBackgroundGeneration({ id: page.params.id, startedAt: Date.now() });
490
  }
491
 
@@ -499,7 +513,7 @@
499
  });
500
  </script>
501
 
502
- <svelte:window on:keydown={handleKeydown} />
503
 
504
  <svelte:head>
505
  <title>{title}</title>
 
250
  update.token = update.token.replaceAll("\0", "");
251
  }
252
 
253
+ const isKeepAlive =
254
+ update.type === MessageUpdateType.Status &&
255
+ update.status === MessageUpdateStatus.KeepAlive;
256
+
257
+ if (!isKeepAlive) {
258
+ if (update.type === MessageUpdateType.Stream) {
259
+ const existingUpdates = messageToWriteTo.updates ?? [];
260
+ const lastUpdate = existingUpdates.at(-1);
261
+ if (lastUpdate?.type === MessageUpdateType.Stream) {
262
+ // Create fresh objects/arrays so the UI reacts to merged tokens
263
+ const merged = {
264
+ ...lastUpdate,
265
+ token: (lastUpdate.token ?? "") + (update.token ?? ""),
266
+ };
267
+ messageToWriteTo.updates = [...existingUpdates.slice(0, -1), merged];
268
+ } else {
269
+ messageToWriteTo.updates = [...existingUpdates, update];
270
+ }
271
+ } else {
272
+ messageToWriteTo.updates = [...(messageToWriteTo.updates ?? []), update];
273
+ }
274
  }
275
  const currentTime = new Date();
276
 
 
408
 
409
  function handleKeydown(event: KeyboardEvent) {
410
  // Stop generation on ESC key when loading
411
+ if (event.key === "Escape" && $loading) {
412
  event.preventDefault();
413
  stopGeneration();
414
  }
 
499
  const navigatingAway =
500
  navigation.to?.route.id !== page.route.id || navigation.to?.params?.id !== page.params.id;
501
 
502
+ if ($loading && navigatingAway) {
503
  addBackgroundGeneration({ id: page.params.id, startedAt: Date.now() });
504
  }
505
 
 
513
  });
514
  </script>
515
 
516
+ <svelte:window onkeydown={handleKeydown} />
517
 
518
  <svelte:head>
519
  <title>{title}</title>
src/routes/conversation/[id]/+server.ts CHANGED
@@ -11,6 +11,7 @@ import {
11
  MessageUpdateType,
12
  MessageReasoningUpdateType,
13
  type MessageUpdate,
 
14
  } from "$lib/types/MessageUpdate";
15
  import { uploadFile } from "$lib/server/files/uploadFile";
16
  import { convertLegacyConversation } from "$lib/utils/tree/convertLegacyConversation";
@@ -330,9 +331,27 @@ export async function POST({ request, locals, params, getClientAddress }) {
330
  const metricsLabels = { model: metricsModelId };
331
 
332
  const persistConversation = async () => {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
333
  await collections.conversations.updateOne(
334
  { _id: convId },
335
- { $set: { messages: conv.messages, title: conv.title, updatedAt: new Date() } }
336
  );
337
  };
338
 
@@ -470,15 +489,16 @@ export async function POST({ request, locals, params, getClientAddress }) {
470
  }
471
  }
472
 
473
- // Append to the persistent message updates if it's not a stream update
474
  if (
475
- event.type !== MessageUpdateType.Stream &&
476
  !(
477
  event.type === MessageUpdateType.Status &&
478
  event.status === MessageUpdateStatus.KeepAlive
479
  )
480
  ) {
481
- messageToWriteTo?.updates?.push(event);
 
 
482
  }
483
 
484
  // Avoid remote keylogging attack executed by watching packet lengths
 
11
  MessageUpdateType,
12
  MessageReasoningUpdateType,
13
  type MessageUpdate,
14
+ type MessageStreamUpdate,
15
  } from "$lib/types/MessageUpdate";
16
  import { uploadFile } from "$lib/server/files/uploadFile";
17
  import { convertLegacyConversation } from "$lib/utils/tree/convertLegacyConversation";
 
331
  const metricsLabels = { model: metricsModelId };
332
 
333
  const persistConversation = async () => {
334
+ const messagesForSave = conv.messages.map((msg) => {
335
+ const filteredUpdates =
336
+ msg.updates
337
+ ?.filter(
338
+ (u) =>
339
+ !(u.type === MessageUpdateType.Status && u.status === MessageUpdateStatus.KeepAlive)
340
+ )
341
+ .map((u) => {
342
+ if (u.type !== MessageUpdateType.Stream) return u;
343
+ // Preserve existing len if already compressed, otherwise compute from token
344
+ const len = u.len ?? (u.token ?? "").length;
345
+ // store a lightweight marker to preserve ordering without duplicating content
346
+ return { type: MessageUpdateType.Stream, token: "", len } satisfies MessageStreamUpdate;
347
+ }) ?? [];
348
+
349
+ return { ...msg, updates: filteredUpdates };
350
+ });
351
+
352
  await collections.conversations.updateOne(
353
  { _id: convId },
354
+ { $set: { messages: messagesForSave, title: conv.title, updatedAt: new Date() } }
355
  );
356
  };
357
 
 
489
  }
490
  }
491
 
492
+ // Append updates for audit/replay (streams too, to preserve ordering)
493
  if (
 
494
  !(
495
  event.type === MessageUpdateType.Status &&
496
  event.status === MessageUpdateStatus.KeepAlive
497
  )
498
  ) {
499
+ messageToWriteTo?.updates?.push(
500
+ event.type === MessageUpdateType.Stream ? { ...event } : event
501
+ );
502
  }
503
 
504
  // Avoid remote keylogging attack executed by watching packet lengths
src/styles/main.css CHANGED
@@ -88,34 +88,34 @@ body {
88
  @apply border-[0.5px] bg-white text-gray-600 dark:border-gray-700 dark:!bg-gray-900 dark:bg-inherit dark:text-inherit;
89
  }
90
 
91
- /* Override prose-sm title sizes - 55% of original */
92
  .prose-sm :where(h1):not(:where([class~="not-prose"], [class~="not-prose"] *)) {
93
- font-size: 1.17857em; /* 55% */
94
  @apply font-semibold;
95
  }
96
 
97
  .prose-sm :where(h2):not(:where([class~="not-prose"], [class~="not-prose"] *)) {
98
- font-size: 0.78571em; /* 55% */
99
  @apply font-semibold;
100
  }
101
 
102
  .prose-sm :where(h3):not(:where([class~="not-prose"], [class~="not-prose"] *)) {
103
- font-size: 0.70714em; /* 55% */
104
  @apply font-semibold;
105
  }
106
 
107
  .prose-sm :where(h4):not(:where([class~="not-prose"], [class~="not-prose"] *)) {
108
- font-size: 0.58929em; /* 55% */
109
  @apply font-semibold;
110
  }
111
 
112
  .prose-sm :where(h5):not(:where([class~="not-prose"], [class~="not-prose"] *)) {
113
- font-size: 0.55em; /* 55% */
114
  @apply font-semibold;
115
  }
116
 
117
  .prose-sm :where(h6):not(:where([class~="not-prose"], [class~="not-prose"] *)) {
118
- font-size: 0.55em; /* 55% */
119
  @apply font-semibold;
120
  }
121
  }
 
88
  @apply border-[0.5px] bg-white text-gray-600 dark:border-gray-700 dark:!bg-gray-900 dark:bg-inherit dark:text-inherit;
89
  }
90
 
91
+ /* Override prose-sm title sizes - 75% of original */
92
  .prose-sm :where(h1):not(:where([class~="not-prose"], [class~="not-prose"] *)) {
93
+ font-size: 1.6em; /* 75% */
94
  @apply font-semibold;
95
  }
96
 
97
  .prose-sm :where(h2):not(:where([class~="not-prose"], [class~="not-prose"] *)) {
98
+ font-size: 1.07em; /* 75% */
99
  @apply font-semibold;
100
  }
101
 
102
  .prose-sm :where(h3):not(:where([class~="not-prose"], [class~="not-prose"] *)) {
103
+ font-size: 0.96em; /* 75% */
104
  @apply font-semibold;
105
  }
106
 
107
  .prose-sm :where(h4):not(:where([class~="not-prose"], [class~="not-prose"] *)) {
108
+ font-size: 0.8em; /* 75% */
109
  @apply font-semibold;
110
  }
111
 
112
  .prose-sm :where(h5):not(:where([class~="not-prose"], [class~="not-prose"] *)) {
113
+ font-size: 0.75em; /* 75% */
114
  @apply font-semibold;
115
  }
116
 
117
  .prose-sm :where(h6):not(:where([class~="not-prose"], [class~="not-prose"] *)) {
118
+ font-size: 0.7em; /* 75% */
119
  @apply font-semibold;
120
  }
121
  }