dylanebert commited on
Commit
3342a1d
·
1 Parent(s): ebb12a0

improve chat

Browse files
src/lib/components/chat/ChatPanel.svelte CHANGED
@@ -1,5 +1,6 @@
1
  <script lang="ts">
2
  import { onMount, onDestroy, afterUpdate } from "svelte";
 
3
  import { agentStore, isConnected, isProcessing } from "../../stores/agent";
4
  import { authStore } from "../../services/auth";
5
  import gsap from "gsap";
@@ -8,6 +9,7 @@
8
  import ToolCallDisplay from "./ToolCallDisplay.svelte";
9
  import ToolCallBlock from "./ToolCallBlock.svelte";
10
  import InProgressBlock from "./InProgressBlock.svelte";
 
11
 
12
  let inputValue = "";
13
  let messagesContainer: HTMLDivElement;
@@ -92,12 +94,59 @@
92
  });
93
  }
94
 
 
 
 
 
95
  afterUpdate(() => {
96
- if (messagesContainer) {
97
- messagesContainer.scrollTop = messagesContainer.scrollHeight;
98
  }
99
  });
100
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
101
  function handleSubmit() {
102
  if (inputValue.trim() && $authStore.isAuthenticated && $isConnected && !$isProcessing) {
103
  if (sendButton) {
@@ -187,7 +236,20 @@
187
  </script>
188
 
189
  <div class="chat-panel">
190
- <div class="messages" bind:this={messagesContainer}>
 
 
 
 
 
 
 
 
 
 
 
 
 
191
  {#if !$authStore.isAuthenticated && !$authStore.loading}
192
  <div class="auth-prompt">
193
  <p>Sign in to chat.</p>
@@ -200,16 +262,7 @@
200
  <div class="ready-message">Ready to Chat!</div>
201
  {/if}
202
 
203
- {#if $agentStore.streamingStatus !== "idle" && (!$agentStore.streamingContent || $agentStore.streamingStatus === "thinking")}
204
- <InProgressBlock
205
- status={$agentStore.streamingStatus === "completing" ? "completing" : $agentStore.streamingStatus}
206
- content={$agentStore.streamingContent}
207
- startTime={$agentStore.thinkingStartTime || Date.now()}
208
- isExpanded={false}
209
- />
210
- {/if}
211
-
212
- {#each $agentStore.messages as message}
213
  {#if message.role === "tool" && message.toolExecutions}
214
  <ToolCallBlock toolExecutions={message.toolExecutions} />
215
  {:else}
@@ -217,26 +270,41 @@
217
  {#if message.reasoning && message.role === "assistant"}
218
  <ReasoningBlock reasoning={message.reasoning} />
219
  {/if}
220
- <div class="message-content">
221
- {#if message.role === "assistant"}
222
- {@const parts = parseMessageContent(message.content.trim())}
223
- {#each parts as part}
224
- {#if part.type === 'text' && part.content.trim()}
225
- <MarkdownRenderer content={part.content} streaming={false} />
226
- {:else if part.type === 'tool' && part.toolName}
227
- <ToolCallDisplay toolName={part.toolName} parameters={part.params} />
228
- {/if}
229
  {/each}
230
- {#if message.streaming}
231
- <span class="cursor">▊</span>
 
 
 
 
 
 
 
 
 
 
 
 
232
  {/if}
233
- {:else}
234
- {message.content.trim()}
235
- {/if}
236
- </div>
237
  </div>
238
  {/if}
239
  {/each}
 
 
 
 
 
 
 
 
 
 
240
  {#if $agentStore.error}
241
  <div class="error-message">
242
  Error: {$agentStore.error}
@@ -284,6 +352,36 @@
284
  width: 100%;
285
  background: rgba(20, 20, 20, 0.5);
286
  border-top: 1px solid rgba(255, 255, 255, 0.1);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
287
  }
288
 
289
  .messages {
@@ -332,6 +430,11 @@
332
  display: block;
333
  }
334
 
 
 
 
 
 
335
  .message.user .message-content {
336
  color: rgba(255, 255, 255, 0.9);
337
  line-height: 1.5;
@@ -339,21 +442,6 @@
339
  word-wrap: break-word;
340
  }
341
 
342
- .cursor {
343
- animation: blink 1s infinite;
344
- }
345
-
346
- @keyframes blink {
347
- 0%,
348
- 50% {
349
- opacity: 1;
350
- }
351
- 51%,
352
- 100% {
353
- opacity: 0;
354
- }
355
- }
356
-
357
  .error-message {
358
  background: rgba(244, 67, 54, 0.1);
359
  border-left: 2px solid #f44336;
 
1
  <script lang="ts">
2
  import { onMount, onDestroy, afterUpdate } from "svelte";
3
+ import { fade } from "svelte/transition";
4
  import { agentStore, isConnected, isProcessing } from "../../stores/agent";
5
  import { authStore } from "../../services/auth";
6
  import gsap from "gsap";
 
9
  import ToolCallDisplay from "./ToolCallDisplay.svelte";
10
  import ToolCallBlock from "./ToolCallBlock.svelte";
11
  import InProgressBlock from "./InProgressBlock.svelte";
12
+ import MessageSegment from "./MessageSegment.svelte";
13
 
14
  let inputValue = "";
15
  let messagesContainer: HTMLDivElement;
 
94
  });
95
  }
96
 
97
+ let autoScroll = true;
98
+ let isUserScrolling = false;
99
+ let scrollAnimation: gsap.core.Tween | null = null;
100
+
101
  afterUpdate(() => {
102
+ if (messagesContainer && autoScroll) {
103
+ setTimeout(() => smoothScrollToBottom(), 100);
104
  }
105
  });
106
 
107
+ function smoothScrollToBottom() {
108
+ if (!messagesContainer || isUserScrolling) return;
109
+
110
+ if (scrollAnimation) {
111
+ scrollAnimation.kill();
112
+ }
113
+
114
+ const targetScroll = messagesContainer.scrollHeight - messagesContainer.clientHeight;
115
+ const currentScroll = messagesContainer.scrollTop;
116
+ const distance = Math.abs(targetScroll - currentScroll);
117
+ const duration = Math.min(0.5, distance / 1000);
118
+
119
+ scrollAnimation = gsap.to(messagesContainer, {
120
+ scrollTop: targetScroll,
121
+ duration: duration,
122
+ ease: "power2.out",
123
+ onComplete: () => {
124
+ scrollAnimation = null;
125
+ }
126
+ });
127
+ }
128
+
129
+ function handleScroll() {
130
+ if (!messagesContainer) return;
131
+
132
+ const isNearBottom =
133
+ messagesContainer.scrollHeight - messagesContainer.scrollTop - messagesContainer.clientHeight < 100;
134
+
135
+ autoScroll = isNearBottom;
136
+
137
+ if (!autoScroll && !isUserScrolling) {
138
+ isUserScrolling = true;
139
+ } else if (autoScroll) {
140
+ isUserScrolling = false;
141
+ }
142
+ }
143
+
144
+ function scrollToBottom() {
145
+ autoScroll = true;
146
+ isUserScrolling = false;
147
+ smoothScrollToBottom();
148
+ }
149
+
150
  function handleSubmit() {
151
  if (inputValue.trim() && $authStore.isAuthenticated && $isConnected && !$isProcessing) {
152
  if (sendButton) {
 
236
  </script>
237
 
238
  <div class="chat-panel">
239
+ {#if isUserScrolling}
240
+ <button
241
+ class="scroll-to-bottom"
242
+ on:click={scrollToBottom}
243
+ title="Scroll to bottom"
244
+ transition:fade={{ duration: 200 }}
245
+ >
246
+ <svg width="20" height="20" viewBox="0 0 20 20" fill="currentColor">
247
+ <path d="M10 14L5 9L6.41 7.59L10 11.17L13.59 7.59L15 9L10 14Z" />
248
+ </svg>
249
+ </button>
250
+ {/if}
251
+
252
+ <div class="messages" bind:this={messagesContainer} on:scroll={handleScroll}>
253
  {#if !$authStore.isAuthenticated && !$authStore.loading}
254
  <div class="auth-prompt">
255
  <p>Sign in to chat.</p>
 
262
  <div class="ready-message">Ready to Chat!</div>
263
  {/if}
264
 
265
+ {#each $agentStore.messages as message (message.id)}
 
 
 
 
 
 
 
 
 
266
  {#if message.role === "tool" && message.toolExecutions}
267
  <ToolCallBlock toolExecutions={message.toolExecutions} />
268
  {:else}
 
270
  {#if message.reasoning && message.role === "assistant"}
271
  <ReasoningBlock reasoning={message.reasoning} />
272
  {/if}
273
+ {#if message.segments && message.segments.length > 0}
274
+ <div class="message-segments">
275
+ {#each message.segments as segment (segment.id)}
276
+ <MessageSegment {segment} />
 
 
 
 
 
277
  {/each}
278
+ </div>
279
+ {:else}
280
+ <div class="message-content">
281
+ {#if message.role === "assistant"}
282
+ {@const parts = parseMessageContent(message.content.trim())}
283
+ {#each parts as part}
284
+ {#if part.type === 'text' && part.content.trim()}
285
+ <MarkdownRenderer content={part.content} streaming={false} />
286
+ {:else if part.type === 'tool' && part.toolName}
287
+ <ToolCallDisplay toolName={part.toolName} parameters={part.params} />
288
+ {/if}
289
+ {/each}
290
+ {:else}
291
+ {message.content.trim()}
292
  {/if}
293
+ </div>
294
+ {/if}
 
 
295
  </div>
296
  {/if}
297
  {/each}
298
+
299
+ {#if $agentStore.streamingStatus !== "idle" && (!$agentStore.streamingContent || $agentStore.streamingStatus === "thinking")}
300
+ <InProgressBlock
301
+ status={$agentStore.streamingStatus === "completing" ? "completing" : $agentStore.streamingStatus}
302
+ content={$agentStore.streamingContent}
303
+ startTime={$agentStore.thinkingStartTime || Date.now()}
304
+ isExpanded={false}
305
+ />
306
+ {/if}
307
+
308
  {#if $agentStore.error}
309
  <div class="error-message">
310
  Error: {$agentStore.error}
 
352
  width: 100%;
353
  background: rgba(20, 20, 20, 0.5);
354
  border-top: 1px solid rgba(255, 255, 255, 0.1);
355
+ position: relative;
356
+ }
357
+
358
+ .scroll-to-bottom {
359
+ position: absolute;
360
+ bottom: 4.5rem;
361
+ right: 1rem;
362
+ width: 36px;
363
+ height: 36px;
364
+ border-radius: 50%;
365
+ background: rgba(65, 105, 225, 0.2);
366
+ border: 1px solid rgba(65, 105, 225, 0.3);
367
+ color: rgba(65, 105, 225, 0.9);
368
+ display: flex;
369
+ align-items: center;
370
+ justify-content: center;
371
+ cursor: pointer;
372
+ z-index: 10;
373
+ transition: all 0.2s ease;
374
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
375
+ }
376
+
377
+ .scroll-to-bottom:hover {
378
+ background: rgba(65, 105, 225, 0.3);
379
+ border-color: rgba(65, 105, 225, 0.5);
380
+ transform: scale(1.1);
381
+ }
382
+
383
+ .scroll-to-bottom:active {
384
+ transform: scale(0.95);
385
  }
386
 
387
  .messages {
 
430
  display: block;
431
  }
432
 
433
+ .message-segments {
434
+ display: flex;
435
+ flex-direction: column;
436
+ }
437
+
438
  .message.user .message-content {
439
  color: rgba(255, 255, 255, 0.9);
440
  line-height: 1.5;
 
442
  word-wrap: break-word;
443
  }
444
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
445
  .error-message {
446
  background: rgba(244, 67, 54, 0.1);
447
  border-left: 2px solid #f44336;
src/lib/components/chat/InProgressBlock.svelte CHANGED
@@ -11,11 +11,20 @@
11
  let blockElement: HTMLDivElement;
12
  let contentElement: HTMLDivElement;
13
  let expandIcon: HTMLSpanElement;
 
 
14
  let collapseTimeout: number | null = null;
 
 
15
 
16
  $: duration = Date.now() - startTime;
17
  $: formattedDuration = formatDuration(duration);
18
 
 
 
 
 
 
19
  function formatDuration(ms: number): string {
20
  if (ms < 1000) return `${ms}ms`;
21
  return `${(ms / 1000).toFixed(1)}s`;
@@ -29,38 +38,92 @@
29
  function updateExpandState() {
30
  if (!expandIcon || !contentElement) return;
31
 
32
- if (isExpanded) {
33
- gsap.to(expandIcon, {
34
- rotation: 90,
35
- duration: 0.2,
36
- ease: "power2.out",
37
- });
38
 
39
- gsap.set(contentElement, { display: "block" });
40
- gsap.fromTo(
41
- contentElement,
42
- { opacity: 0, maxHeight: 0 },
43
- { opacity: 1, maxHeight: 500, duration: 0.3, ease: "power2.out" },
44
- );
 
 
 
 
 
 
 
 
45
  } else {
46
- gsap.to(expandIcon, {
47
- rotation: 0,
48
- duration: 0.2,
49
- ease: "power2.in",
50
- });
51
-
52
- gsap.to(contentElement, {
53
- opacity: 0,
54
- maxHeight: 0,
55
- duration: 0.2,
56
- ease: "power2.in",
57
- onComplete: () => {
58
- gsap.set(contentElement, { display: "none" });
59
- },
60
- });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
61
  }
62
  }
63
 
 
 
 
 
 
64
  function getStatusIcon() {
65
  switch (status) {
66
  case "thinking":
@@ -99,8 +162,15 @@
99
  if (blockElement) {
100
  gsap.fromTo(
101
  blockElement,
102
- { opacity: 0, y: -10 },
103
- { opacity: 1, y: 0, duration: 0.3, ease: "power2.out" },
 
 
 
 
 
 
 
104
  );
105
  }
106
 
@@ -113,10 +183,15 @@
113
 
114
  onDestroy(() => {
115
  if (collapseTimeout) clearTimeout(collapseTimeout);
 
116
  });
117
  </script>
118
 
119
  <div class="in-progress-block {status}" bind:this={blockElement}>
 
 
 
 
120
  <button class="progress-header" on:click={toggleExpanded} aria-expanded={isExpanded}>
121
  <span class="status-icon">
122
  {#if status === "streaming"}
@@ -128,7 +203,7 @@
128
  {/if}
129
  </span>
130
 
131
- <span class="status-text">
132
  {getStatusText()}<span class="dots"></span>
133
  </span>
134
 
@@ -162,6 +237,7 @@
162
  border: 1px solid rgba(255, 210, 30, 0.2);
163
  background: rgba(255, 210, 30, 0.05);
164
  transition: all 0.2s ease;
 
165
  }
166
 
167
  .in-progress-block.thinking {
@@ -179,6 +255,41 @@
179
  background: rgba(0, 255, 0, 0.05);
180
  }
181
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
182
  .progress-header {
183
  display: flex;
184
  align-items: center;
 
11
  let blockElement: HTMLDivElement;
12
  let contentElement: HTMLDivElement;
13
  let expandIcon: HTMLSpanElement;
14
+ let statusElement: HTMLSpanElement;
15
+ let progressBar: HTMLDivElement;
16
  let collapseTimeout: number | null = null;
17
+ let prevStatus: string = status;
18
+ let timeline: gsap.core.Timeline | null = null;
19
 
20
  $: duration = Date.now() - startTime;
21
  $: formattedDuration = formatDuration(duration);
22
 
23
+ $: if (status !== prevStatus && statusElement) {
24
+ animateStatusTransition(prevStatus, status);
25
+ prevStatus = status;
26
+ }
27
+
28
  function formatDuration(ms: number): string {
29
  if (ms < 1000) return `${ms}ms`;
30
  return `${(ms / 1000).toFixed(1)}s`;
 
38
  function updateExpandState() {
39
  if (!expandIcon || !contentElement) return;
40
 
41
+ if (timeline) timeline.kill();
42
+ timeline = gsap.timeline();
 
 
 
 
43
 
44
+ if (isExpanded) {
45
+ timeline
46
+ .to(expandIcon, {
47
+ rotation: 90,
48
+ duration: 0.2,
49
+ ease: "power2.out",
50
+ })
51
+ .set(contentElement, { display: "block" })
52
+ .fromTo(
53
+ contentElement,
54
+ { opacity: 0, maxHeight: 0, y: -10 },
55
+ { opacity: 1, maxHeight: 500, y: 0, duration: 0.3, ease: "power2.out" },
56
+ "-=0.1"
57
+ );
58
  } else {
59
+ timeline
60
+ .to(expandIcon, {
61
+ rotation: 0,
62
+ duration: 0.2,
63
+ ease: "power2.in",
64
+ })
65
+ .to(contentElement, {
66
+ opacity: 0,
67
+ maxHeight: 0,
68
+ y: -5,
69
+ duration: 0.2,
70
+ ease: "power2.in",
71
+ onComplete: () => {
72
+ gsap.set(contentElement, { display: "none" });
73
+ },
74
+ }, "-=0.1");
75
+ }
76
+ }
77
+
78
+ function animateStatusTransition(_from: string, to: string) {
79
+ if (!statusElement || !blockElement) return;
80
+
81
+ const tl = gsap.timeline();
82
+
83
+ tl.to(blockElement, {
84
+ duration: 0.4,
85
+ ease: "power2.inOut",
86
+ onUpdate: function() {
87
+ updateBlockStyle(to);
88
+ }
89
+ });
90
+
91
+ tl.to(statusElement, {
92
+ scale: 0.9,
93
+ opacity: 0,
94
+ duration: 0.15,
95
+ ease: "power2.in",
96
+ onComplete: () => {}
97
+ })
98
+ .to(statusElement, {
99
+ scale: 1,
100
+ opacity: 1,
101
+ duration: 0.15,
102
+ ease: "power2.out"
103
+ });
104
+
105
+ if (progressBar) {
106
+ if (to === "streaming") {
107
+ gsap.to(progressBar, {
108
+ width: "60%",
109
+ duration: 1,
110
+ ease: "power2.out"
111
+ });
112
+ } else if (to === "completing") {
113
+ gsap.to(progressBar, {
114
+ width: "90%",
115
+ duration: 0.5,
116
+ ease: "power2.out"
117
+ });
118
+ }
119
  }
120
  }
121
 
122
+ function updateBlockStyle(status: string) {
123
+ if (!blockElement) return;
124
+ blockElement.className = `in-progress-block ${status}`;
125
+ }
126
+
127
  function getStatusIcon() {
128
  switch (status) {
129
  case "thinking":
 
162
  if (blockElement) {
163
  gsap.fromTo(
164
  blockElement,
165
+ { opacity: 0, y: -10, scale: 0.95 },
166
+ { opacity: 1, y: 0, scale: 1, duration: 0.4, ease: "back.out(1.2)" },
167
+ );
168
+ }
169
+
170
+ if (progressBar) {
171
+ gsap.fromTo(progressBar,
172
+ { width: "0%" },
173
+ { width: "30%", duration: 0.5, ease: "power2.out" }
174
  );
175
  }
176
 
 
183
 
184
  onDestroy(() => {
185
  if (collapseTimeout) clearTimeout(collapseTimeout);
186
+ if (timeline) timeline.kill();
187
  });
188
  </script>
189
 
190
  <div class="in-progress-block {status}" bind:this={blockElement}>
191
+ <div class="progress-bar-track">
192
+ <div bind:this={progressBar} class="progress-bar"></div>
193
+ </div>
194
+
195
  <button class="progress-header" on:click={toggleExpanded} aria-expanded={isExpanded}>
196
  <span class="status-icon">
197
  {#if status === "streaming"}
 
203
  {/if}
204
  </span>
205
 
206
+ <span bind:this={statusElement} class="status-text">
207
  {getStatusText()}<span class="dots"></span>
208
  </span>
209
 
 
237
  border: 1px solid rgba(255, 210, 30, 0.2);
238
  background: rgba(255, 210, 30, 0.05);
239
  transition: all 0.2s ease;
240
+ position: relative;
241
  }
242
 
243
  .in-progress-block.thinking {
 
255
  background: rgba(0, 255, 0, 0.05);
256
  }
257
 
258
+ .progress-bar-track {
259
+ position: absolute;
260
+ top: 0;
261
+ left: 0;
262
+ right: 0;
263
+ height: 2px;
264
+ background: rgba(255, 255, 255, 0.05);
265
+ overflow: hidden;
266
+ }
267
+
268
+ .progress-bar {
269
+ height: 100%;
270
+ background: linear-gradient(90deg,
271
+ rgba(255, 210, 30, 0.6) 0%,
272
+ rgba(65, 105, 225, 0.6) 50%,
273
+ rgba(0, 255, 0, 0.6) 100%);
274
+ box-shadow: 0 0 10px rgba(65, 105, 225, 0.4);
275
+ transition: width 0.3s ease;
276
+ }
277
+
278
+ .in-progress-block.thinking .progress-bar {
279
+ background: rgba(255, 210, 30, 0.6);
280
+ box-shadow: 0 0 10px rgba(255, 210, 30, 0.4);
281
+ }
282
+
283
+ .in-progress-block.streaming .progress-bar {
284
+ background: rgba(65, 105, 225, 0.6);
285
+ box-shadow: 0 0 10px rgba(65, 105, 225, 0.4);
286
+ }
287
+
288
+ .in-progress-block.completing .progress-bar {
289
+ background: rgba(0, 255, 0, 0.6);
290
+ box-shadow: 0 0 10px rgba(0, 255, 0, 0.4);
291
+ }
292
+
293
  .progress-header {
294
  display: flex;
295
  align-items: center;
src/lib/components/chat/MarkdownRenderer.svelte CHANGED
@@ -1,13 +1,15 @@
1
  <script lang="ts">
2
  import { marked } from "marked";
 
3
 
4
  export let content: string;
5
  export let streaming: boolean = false;
6
 
7
  let htmlContent = "";
 
8
 
9
  const renderer = new marked.Renderer();
10
-
11
  renderer.code = ({ text, lang }) => {
12
  const language = lang || "text";
13
  return `<pre><code class="language-${language}">${escapeHtml(text)}</code></pre>`;
@@ -31,21 +33,33 @@
31
  gfm: true,
32
  });
33
 
34
- $: if (content) {
35
- try {
36
- htmlContent = marked.parse(content) as string;
37
- } catch {
38
- htmlContent = escapeHtml(content);
 
 
 
 
 
 
39
  }
40
  }
41
  </script>
42
 
43
- <div class="markdown-content">
44
- {@html htmlContent}
45
- {#if streaming}
46
- <span class="cursor">▊</span>
47
- {/if}
48
- </div>
 
 
 
 
 
 
49
 
50
  <style>
51
  .markdown-content {
 
1
  <script lang="ts">
2
  import { marked } from "marked";
3
+ import StreamingText from "./StreamingText.svelte";
4
 
5
  export let content: string;
6
  export let streaming: boolean = false;
7
 
8
  let htmlContent = "";
9
+ let useStreamingText = false;
10
 
11
  const renderer = new marked.Renderer();
12
+
13
  renderer.code = ({ text, lang }) => {
14
  const language = lang || "text";
15
  return `<pre><code class="language-${language}">${escapeHtml(text)}</code></pre>`;
 
33
  gfm: true,
34
  });
35
 
36
+ $: {
37
+ if (streaming && content) {
38
+ useStreamingText = true;
39
+ htmlContent = "";
40
+ } else if (content) {
41
+ useStreamingText = false;
42
+ try {
43
+ htmlContent = marked.parse(content) as string;
44
+ } catch {
45
+ htmlContent = escapeHtml(content);
46
+ }
47
  }
48
  }
49
  </script>
50
 
51
+ {#if useStreamingText}
52
+ <div class="markdown-content">
53
+ <StreamingText {content} {streaming} speed={120} />
54
+ </div>
55
+ {:else}
56
+ <div class="markdown-content">
57
+ {@html htmlContent}
58
+ {#if streaming}
59
+ <span class="cursor">▊</span>
60
+ {/if}
61
+ </div>
62
+ {/if}
63
 
64
  <style>
65
  .markdown-content {
src/lib/components/chat/MessageSegment.svelte ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <script lang="ts">
2
+ import type { MessageSegment } from "../../stores/agent";
3
+ import MarkdownRenderer from "./MarkdownRenderer.svelte";
4
+ import ToolInvocation from "./ToolInvocation.svelte";
5
+
6
+ export let segment: MessageSegment;
7
+ export let hideToolResult: boolean = false;
8
+ </script>
9
+
10
+ {#if segment.type === "text"}
11
+ {#if segment.content.trim()}
12
+ <div class="text-segment">
13
+ <MarkdownRenderer content={segment.content} streaming={segment.streaming} />
14
+ </div>
15
+ {/if}
16
+ {:else if segment.type === "tool-invocation"}
17
+ <ToolInvocation {segment} />
18
+ {:else if segment.type === "tool-result"}
19
+ {#if !hideToolResult}
20
+ <ToolInvocation {segment} />
21
+ {/if}
22
+ {:else if segment.type === "reasoning"}
23
+ <div class="reasoning-segment">
24
+ <details class="reasoning-details">
25
+ <summary>Thinking...</summary>
26
+ <div class="reasoning-content">
27
+ {segment.content}
28
+ </div>
29
+ </details>
30
+ </div>
31
+ {/if}
32
+
33
+ <style>
34
+ .text-segment {
35
+ margin: 0.25rem 0;
36
+ }
37
+
38
+ .reasoning-segment {
39
+ margin: 0.25rem 0;
40
+ padding: 0.4rem 0.6rem;
41
+ background: rgba(255, 255, 255, 0.02);
42
+ border-radius: 3px;
43
+ border-left: 2px solid rgba(255, 255, 255, 0.1);
44
+ }
45
+
46
+ .reasoning-details {
47
+ cursor: pointer;
48
+ }
49
+
50
+ .reasoning-details summary {
51
+ color: rgba(255, 255, 255, 0.5);
52
+ font-size: 0.8rem;
53
+ user-select: none;
54
+ }
55
+
56
+ .reasoning-details summary:hover {
57
+ color: rgba(255, 255, 255, 0.7);
58
+ }
59
+
60
+ .reasoning-content {
61
+ margin-top: 0.5rem;
62
+ padding: 0.5rem;
63
+ background: rgba(0, 0, 0, 0.2);
64
+ border-radius: 3px;
65
+ color: rgba(255, 255, 255, 0.7);
66
+ font-size: 0.825rem;
67
+ font-family: "Monaco", "Menlo", monospace;
68
+ white-space: pre-wrap;
69
+ }
70
+ </style>
src/lib/components/chat/ReasoningBlock.svelte CHANGED
@@ -1,30 +1,48 @@
1
  <script lang="ts">
2
- import { onMount } from "svelte";
3
  import gsap from "gsap";
4
-
5
  export let reasoning: string;
6
  export let isExpanded = false;
7
-
 
 
8
  let iconElement: HTMLSpanElement;
9
  let contentElement: HTMLDivElement;
10
-
 
 
 
 
 
 
 
 
 
 
11
  function toggleExpanded() {
12
  isExpanded = !isExpanded;
13
-
14
  if (!iconElement || !contentElement) return;
15
-
16
  if (isExpanded) {
17
- gsap.to(iconElement, {
 
 
 
 
 
 
 
 
 
 
18
  rotation: 180,
19
  duration: 0.15,
20
  ease: "power2.out"
21
- });
22
-
23
- gsap.set(contentElement, {
24
- display: 'block'
25
- });
26
-
27
- gsap.fromTo(contentElement, {
28
  opacity: 0,
29
  maxHeight: 0,
30
  y: -10
@@ -34,16 +52,19 @@
34
  y: 0,
35
  duration: 0.2,
36
  ease: "power2.out"
37
- });
38
-
39
- } else {
40
- gsap.to(iconElement, {
 
 
 
 
41
  rotation: 0,
42
  duration: 0.1,
43
  ease: "power2.in"
44
- });
45
-
46
- gsap.to(contentElement, {
47
  opacity: 0,
48
  maxHeight: 0,
49
  y: -5,
@@ -52,10 +73,19 @@
52
  onComplete: () => {
53
  gsap.set(contentElement, { display: 'none' });
54
  }
 
 
 
 
 
 
 
 
 
 
55
  });
56
- }
57
  }
58
-
59
  onMount(() => {
60
  if (iconElement) {
61
  gsap.set(iconElement, {
@@ -63,7 +93,7 @@
63
  rotation: isExpanded ? 180 : 0
64
  });
65
  }
66
-
67
  if (contentElement) {
68
  gsap.set(contentElement, {
69
  display: isExpanded ? 'block' : 'none',
@@ -72,11 +102,28 @@
72
  y: isExpanded ? 0 : -10
73
  });
74
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
75
  });
76
  </script>
77
 
78
- <div class="reasoning-block">
79
- <button
80
  class="reasoning-toggle"
81
  on:click={toggleExpanded}
82
  title={isExpanded ? "Hide AI thinking" : "Show AI thinking"}
 
1
  <script lang="ts">
2
+ import { onMount, onDestroy } from "svelte";
3
  import gsap from "gsap";
4
+
5
  export let reasoning: string;
6
  export let isExpanded = false;
7
+ export let autoCollapse = true;
8
+ export let responseComplete = false;
9
+
10
  let iconElement: HTMLSpanElement;
11
  let contentElement: HTMLDivElement;
12
+ let blockElement: HTMLDivElement;
13
+ let collapseTimeout: number | null = null;
14
+
15
+ $: if (responseComplete && autoCollapse && isExpanded) {
16
+ if (collapseTimeout) clearTimeout(collapseTimeout);
17
+ collapseTimeout = window.setTimeout(() => {
18
+ isExpanded = false;
19
+ animateCollapse();
20
+ }, 800);
21
+ }
22
+
23
  function toggleExpanded() {
24
  isExpanded = !isExpanded;
25
+
26
  if (!iconElement || !contentElement) return;
27
+
28
  if (isExpanded) {
29
+ animateExpand();
30
+ } else {
31
+ animateCollapse();
32
+ }
33
+ }
34
+
35
+ function animateExpand() {
36
+ if (!iconElement || !contentElement) return;
37
+
38
+ gsap.timeline()
39
+ .to(iconElement, {
40
  rotation: 180,
41
  duration: 0.15,
42
  ease: "power2.out"
43
+ })
44
+ .set(contentElement, { display: 'block' }, 0)
45
+ .fromTo(contentElement, {
 
 
 
 
46
  opacity: 0,
47
  maxHeight: 0,
48
  y: -10
 
52
  y: 0,
53
  duration: 0.2,
54
  ease: "power2.out"
55
+ }, 0);
56
+ }
57
+
58
+ function animateCollapse() {
59
+ if (!iconElement || !contentElement) return;
60
+
61
+ gsap.timeline()
62
+ .to(iconElement, {
63
  rotation: 0,
64
  duration: 0.1,
65
  ease: "power2.in"
66
+ })
67
+ .to(contentElement, {
 
68
  opacity: 0,
69
  maxHeight: 0,
70
  y: -5,
 
73
  onComplete: () => {
74
  gsap.set(contentElement, { display: 'none' });
75
  }
76
+ }, 0)
77
+ .to(blockElement, {
78
+ scale: 0.98,
79
+ duration: 0.1,
80
+ ease: "power2.in"
81
+ }, 0)
82
+ .to(blockElement, {
83
+ scale: 1,
84
+ duration: 0.2,
85
+ ease: "back.out(1.5)"
86
  });
 
87
  }
88
+
89
  onMount(() => {
90
  if (iconElement) {
91
  gsap.set(iconElement, {
 
93
  rotation: isExpanded ? 180 : 0
94
  });
95
  }
96
+
97
  if (contentElement) {
98
  gsap.set(contentElement, {
99
  display: isExpanded ? 'block' : 'none',
 
102
  y: isExpanded ? 0 : -10
103
  });
104
  }
105
+
106
+ if (blockElement) {
107
+ gsap.fromTo(blockElement,
108
+ { opacity: 0, scale: 0.95, y: -5 },
109
+ {
110
+ opacity: 1,
111
+ scale: 1,
112
+ y: 0,
113
+ duration: 0.3,
114
+ ease: "power2.out"
115
+ }
116
+ );
117
+ }
118
+ });
119
+
120
+ onDestroy(() => {
121
+ if (collapseTimeout) clearTimeout(collapseTimeout);
122
  });
123
  </script>
124
 
125
+ <div bind:this={blockElement} class="reasoning-block">
126
+ <button
127
  class="reasoning-toggle"
128
  on:click={toggleExpanded}
129
  title={isExpanded ? "Hide AI thinking" : "Show AI thinking"}
src/lib/components/chat/StreamingText.svelte ADDED
@@ -0,0 +1,144 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <script lang="ts">
2
+ import { onMount, afterUpdate } from "svelte";
3
+ import gsap from "gsap";
4
+
5
+ export let content: string = "";
6
+ export let streaming: boolean = false;
7
+ export let speed: number = 60;
8
+ export let onComplete: (() => void) | undefined = undefined;
9
+
10
+ let displayedContent = "";
11
+ let cursorElement: HTMLSpanElement;
12
+ let containerElement: HTMLDivElement;
13
+ let animationTimeline: any = null;
14
+ let buffer: string[] = [];
15
+ let isProcessing = false;
16
+ let lastProcessedLength = 0;
17
+
18
+ $: if (streaming && content) {
19
+ // Only process truly new content
20
+ if (content.length > lastProcessedLength) {
21
+ const newChars = content.slice(lastProcessedLength);
22
+ if (newChars) {
23
+ buffer.push(...newChars.split(''));
24
+ lastProcessedLength = content.length;
25
+ processBuffer();
26
+ }
27
+ }
28
+ } else if (!streaming && content !== displayedContent) {
29
+ displayedContent = content;
30
+ lastProcessedLength = content.length;
31
+ if (animationTimeline) {
32
+ animationTimeline.kill();
33
+ }
34
+ hideCursor();
35
+ }
36
+
37
+ async function processBuffer() {
38
+ if (isProcessing || buffer.length === 0) return;
39
+ isProcessing = true;
40
+
41
+ while (buffer.length > 0) {
42
+ // Process multiple characters at once for better performance
43
+ const chunkSize = Math.min(3, buffer.length);
44
+ const chunk = buffer.splice(0, chunkSize).join('');
45
+ displayedContent += chunk;
46
+
47
+ // Only delay if there are more characters to process
48
+ if (buffer.length > 0) {
49
+ await new Promise(resolve => setTimeout(resolve, 1000 / speed));
50
+ }
51
+ }
52
+
53
+ isProcessing = false;
54
+
55
+ if (!streaming && displayedContent === content) {
56
+ hideCursor();
57
+ onComplete?.();
58
+ }
59
+ }
60
+
61
+ function showCursor() {
62
+ if (cursorElement) {
63
+ gsap.set(cursorElement, { display: "inline-block", opacity: 1 });
64
+ gsap.to(cursorElement, {
65
+ opacity: 0,
66
+ duration: 0.5,
67
+ repeat: -1,
68
+ yoyo: true,
69
+ ease: "steps(1)"
70
+ });
71
+ }
72
+ }
73
+
74
+ function hideCursor() {
75
+ if (cursorElement) {
76
+ gsap.killTweensOf(cursorElement);
77
+ gsap.to(cursorElement, {
78
+ opacity: 0,
79
+ duration: 0.2,
80
+ onComplete: () => {
81
+ if (cursorElement) {
82
+ gsap.set(cursorElement, { display: "none" });
83
+ }
84
+ }
85
+ });
86
+ }
87
+ }
88
+
89
+ onMount(() => {
90
+ if (streaming) {
91
+ showCursor();
92
+ // Reset tracking when component mounts with streaming
93
+ lastProcessedLength = 0;
94
+ displayedContent = "";
95
+
96
+ gsap.fromTo(containerElement,
97
+ { opacity: 0, y: 5 },
98
+ { opacity: 1, y: 0, duration: 0.3, ease: "power2.out" }
99
+ );
100
+ }
101
+
102
+ return () => {
103
+ if (animationTimeline) {
104
+ animationTimeline.kill();
105
+ }
106
+ };
107
+ });
108
+
109
+ afterUpdate(() => {
110
+ if (streaming && cursorElement) {
111
+ showCursor();
112
+ }
113
+ });
114
+ </script>
115
+
116
+ <div class="streaming-text" bind:this={containerElement}>
117
+ <span class="text-content">{displayedContent}</span>
118
+ {#if streaming}
119
+ <span bind:this={cursorElement} class="cursor">▊</span>
120
+ {/if}
121
+ </div>
122
+
123
+ <style>
124
+ .streaming-text {
125
+ display: inline;
126
+ word-wrap: break-word;
127
+ white-space: pre-wrap;
128
+ }
129
+
130
+ .text-content {
131
+ color: inherit;
132
+ font-family: inherit;
133
+ line-height: inherit;
134
+ }
135
+
136
+ .cursor {
137
+ display: inline-block;
138
+ color: rgba(65, 105, 225, 0.8);
139
+ font-weight: bold;
140
+ margin-left: 1px;
141
+ animation: none;
142
+ vertical-align: baseline;
143
+ }
144
+ </style>
src/lib/components/chat/ToolInvocation.svelte ADDED
@@ -0,0 +1,458 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <script lang="ts">
2
+ import { onMount, onDestroy } from "svelte";
3
+ import gsap from "gsap";
4
+ import type { MessageSegment } from "../../stores/agent";
5
+
6
+ export let segment: MessageSegment;
7
+
8
+ let element: HTMLDivElement;
9
+ let expanded = false;
10
+ let statusIcon: HTMLSpanElement;
11
+ let progressRing: SVGCircleElement;
12
+ let detailsElement: HTMLDivElement;
13
+ let timeline: gsap.core.Timeline | null = null;
14
+ let prevStatus = segment.toolStatus;
15
+
16
+ const toolIcons: Record<string, string> = {
17
+ read_editor: "📄",
18
+ write_editor: "✏️",
19
+ observe_console: "📟",
20
+ default: "🔧",
21
+ };
22
+
23
+ const statusIcons: Record<string, string> = {
24
+ pending: "⏳",
25
+ running: "⚡",
26
+ completed: "✅",
27
+ error: "❌",
28
+ };
29
+
30
+ $: if (segment.toolStatus !== prevStatus && statusIcon) {
31
+ animateStatusChange(prevStatus, segment.toolStatus);
32
+ prevStatus = segment.toolStatus;
33
+ }
34
+
35
+ function toggle() {
36
+ expanded = !expanded;
37
+ animateToggle();
38
+ }
39
+
40
+ function animateToggle() {
41
+ if (!detailsElement) return;
42
+
43
+ if (timeline) timeline.kill();
44
+ timeline = gsap.timeline();
45
+
46
+ if (expanded) {
47
+ timeline
48
+ .set(detailsElement, { display: "block" })
49
+ .fromTo(detailsElement,
50
+ { opacity: 0, height: 0, y: -10 },
51
+ { opacity: 1, height: "auto", y: 0, duration: 0.3, ease: "power2.out" }
52
+ );
53
+ } else {
54
+ timeline
55
+ .to(detailsElement,
56
+ { opacity: 0, height: 0, y: -5, duration: 0.2, ease: "power2.in" }
57
+ )
58
+ .set(detailsElement, { display: "none" });
59
+ }
60
+ }
61
+
62
+ function animateStatusChange(_from: string | undefined, to: string | undefined) {
63
+ if (!statusIcon || !element) return;
64
+
65
+ gsap.timeline()
66
+ .to(statusIcon, {
67
+ scale: 1.3,
68
+ duration: 0.15,
69
+ ease: "power2.in"
70
+ })
71
+ .to(statusIcon, {
72
+ scale: 1,
73
+ duration: 0.15,
74
+ ease: "back.out(2)"
75
+ });
76
+
77
+ if (to === "completed") {
78
+ gsap.to(element, {
79
+ borderColor: "rgba(0, 255, 0, 0.3)",
80
+ backgroundColor: "rgba(0, 255, 0, 0.05)",
81
+ duration: 0.3,
82
+ ease: "power2.out"
83
+ });
84
+ } else if (to === "error") {
85
+ gsap.to(element, {
86
+ borderColor: "rgba(255, 0, 0, 0.4)",
87
+ backgroundColor: "rgba(255, 0, 0, 0.1)",
88
+ duration: 0.3,
89
+ ease: "power2.out"
90
+ });
91
+ }
92
+
93
+ if (progressRing) {
94
+ if (to === "running") {
95
+ gsap.to(progressRing, {
96
+ strokeDashoffset: 50,
97
+ duration: 1,
98
+ ease: "power2.inOut",
99
+ repeat: -1,
100
+ yoyo: true
101
+ });
102
+ } else if (to === "completed") {
103
+ gsap.to(progressRing, {
104
+ strokeDashoffset: 0,
105
+ duration: 0.3,
106
+ ease: "power2.out"
107
+ });
108
+ }
109
+ }
110
+ }
111
+
112
+ function formatDuration(): string {
113
+ if (!segment.startTime) return "";
114
+ const duration = (segment.endTime || Date.now()) - segment.startTime;
115
+ if (duration < 1000) {
116
+ return `${duration}ms`;
117
+ }
118
+ return `${(duration / 1000).toFixed(1)}s`;
119
+ }
120
+
121
+ onMount(() => {
122
+ gsap.fromTo(
123
+ element,
124
+ { opacity: 0, x: -20, scale: 0.95 },
125
+ {
126
+ opacity: 1,
127
+ x: 0,
128
+ scale: 1,
129
+ duration: 0.4,
130
+ ease: "back.out(1.5)"
131
+ }
132
+ );
133
+
134
+ if (segment.toolStatus === "running" && progressRing) {
135
+ gsap.to(progressRing, {
136
+ strokeDashoffset: 50,
137
+ duration: 1,
138
+ ease: "power2.inOut",
139
+ repeat: -1,
140
+ yoyo: true
141
+ });
142
+ }
143
+ });
144
+
145
+ onDestroy(() => {
146
+ if (timeline) timeline.kill();
147
+ });
148
+ </script>
149
+
150
+ <div class="tool-invocation {segment.toolStatus}" bind:this={element}>
151
+ <button class="tool-header" on:click={toggle} aria-expanded={expanded}>
152
+ <div class="status-indicator">
153
+ <svg class="progress-ring" width="24" height="24">
154
+ <circle
155
+ cx="12"
156
+ cy="12"
157
+ r="10"
158
+ stroke="rgba(255, 255, 255, 0.1)"
159
+ stroke-width="2"
160
+ fill="none"
161
+ />
162
+ <circle
163
+ bind:this={progressRing}
164
+ cx="12"
165
+ cy="12"
166
+ r="10"
167
+ stroke="currentColor"
168
+ stroke-width="2"
169
+ fill="none"
170
+ stroke-dasharray="62.83"
171
+ stroke-dashoffset="62.83"
172
+ transform="rotate(-90 12 12)"
173
+ />
174
+ </svg>
175
+ <span bind:this={statusIcon} class="status-icon">
176
+ {#if segment.toolStatus === "running"}
177
+ <span class="spinner">{statusIcons[segment.toolStatus || "pending"]}</span>
178
+ {:else}
179
+ {statusIcons[segment.toolStatus || "pending"]}
180
+ {/if}
181
+ </span>
182
+ </div>
183
+ <span class="tool-icon">{toolIcons[segment.toolName || "default"] || toolIcons.default}</span>
184
+ <span class="tool-name">
185
+ {#if segment.toolStatus === "running"}
186
+ Calling {segment.toolName}...
187
+ {:else if segment.toolStatus === "completed"}
188
+ Called {segment.toolName}
189
+ {:else if segment.toolStatus === "error"}
190
+ Failed to call {segment.toolName}
191
+ {:else}
192
+ Preparing {segment.toolName}...
193
+ {/if}
194
+ </span>
195
+ {#if segment.startTime}
196
+ <span class="duration">{formatDuration()}</span>
197
+ {/if}
198
+ <span class="expand-icon" class:rotated={expanded}>▶</span>
199
+ </button>
200
+
201
+ <div bind:this={detailsElement} class="tool-details" style="display: none;">
202
+ {#if segment.toolArgs && Object.keys(segment.toolArgs).length > 0}
203
+ <div class="section-title">Parameters:</div>
204
+ <div class="params">
205
+ {#each Object.entries(segment.toolArgs) as [key, value]}
206
+ <div class="param">
207
+ <span class="param-key">{key}:</span>
208
+ <span class="param-value">
209
+ {#if typeof value === 'string' && value.length > 100}
210
+ <pre>{value}</pre>
211
+ {:else}
212
+ {JSON.stringify(value)}
213
+ {/if}
214
+ </span>
215
+ </div>
216
+ {/each}
217
+ </div>
218
+ {/if}
219
+ {#if segment.toolOutput || segment.toolResult}
220
+ <div class="section-title">Result:</div>
221
+ <div class="tool-output">
222
+ <pre>{segment.toolOutput || segment.toolResult}</pre>
223
+ </div>
224
+ {/if}
225
+ {#if segment.consoleOutput && segment.consoleOutput.length > 0}
226
+ <div class="section-title">Console Output:</div>
227
+ <div class="console-output">
228
+ {#each segment.consoleOutput as line}
229
+ <div class="console-line">{line}</div>
230
+ {/each}
231
+ </div>
232
+ {/if}
233
+ {#if segment.toolError}
234
+ <div class="section-title">Error:</div>
235
+ <div class="tool-error">
236
+ {segment.toolError}
237
+ </div>
238
+ {/if}
239
+ </div>
240
+ </div>
241
+
242
+ <style>
243
+ .tool-invocation {
244
+ margin: 0.5rem 0;
245
+ margin-left: 1rem;
246
+ border-radius: 4px;
247
+ overflow: hidden;
248
+ border: 1px solid rgba(65, 105, 225, 0.2);
249
+ background: rgba(65, 105, 225, 0.05);
250
+ transition: all 0.2s ease;
251
+ }
252
+
253
+ .tool-invocation.running {
254
+ background: rgba(255, 210, 30, 0.08);
255
+ border-color: rgba(255, 210, 30, 0.3);
256
+ animation: pulse 2s ease-in-out infinite;
257
+ }
258
+
259
+ @keyframes pulse {
260
+ 0%, 100% {
261
+ opacity: 1;
262
+ }
263
+ 50% {
264
+ opacity: 0.8;
265
+ }
266
+ }
267
+
268
+ .tool-invocation.completed {
269
+ background: rgba(0, 255, 0, 0.04);
270
+ border-color: rgba(0, 255, 0, 0.15);
271
+ }
272
+
273
+ .tool-invocation.error {
274
+ background: rgba(255, 0, 0, 0.08);
275
+ border-color: rgba(255, 0, 0, 0.3);
276
+ }
277
+
278
+ .tool-header {
279
+ display: flex;
280
+ align-items: center;
281
+ gap: 0.5rem;
282
+ width: 100%;
283
+ padding: 0.35rem 0.5rem;
284
+ background: transparent;
285
+ border: none;
286
+ color: inherit;
287
+ font: inherit;
288
+ text-align: left;
289
+ cursor: pointer;
290
+ transition: background 0.2s ease;
291
+ }
292
+
293
+ .tool-header:hover {
294
+ background: rgba(255, 255, 255, 0.02);
295
+ }
296
+
297
+ .status-indicator {
298
+ position: relative;
299
+ width: 24px;
300
+ height: 24px;
301
+ display: flex;
302
+ align-items: center;
303
+ justify-content: center;
304
+ }
305
+
306
+ .progress-ring {
307
+ position: absolute;
308
+ top: 0;
309
+ left: 0;
310
+ color: rgba(65, 105, 225, 0.6);
311
+ }
312
+
313
+ .tool-invocation.running .progress-ring {
314
+ color: rgba(255, 210, 30, 0.8);
315
+ }
316
+
317
+ .tool-invocation.completed .progress-ring {
318
+ color: rgba(0, 255, 0, 0.6);
319
+ }
320
+
321
+ .tool-invocation.error .progress-ring {
322
+ color: rgba(255, 0, 0, 0.6);
323
+ }
324
+
325
+ .status-icon {
326
+ font-size: 0.85rem;
327
+ position: relative;
328
+ z-index: 1;
329
+ }
330
+
331
+ .tool-icon {
332
+ font-size: 0.95rem;
333
+ }
334
+
335
+ .tool-name {
336
+ flex: 1;
337
+ color: rgba(255, 255, 255, 0.85);
338
+ font-size: 0.8rem;
339
+ }
340
+
341
+ .duration {
342
+ color: rgba(255, 255, 255, 0.4);
343
+ font-size: 0.7rem;
344
+ font-family: "Monaco", "Menlo", monospace;
345
+ }
346
+
347
+ .expand-icon {
348
+ font-size: 0.65rem;
349
+ color: rgba(255, 255, 255, 0.4);
350
+ transition: transform 0.2s ease;
351
+ }
352
+
353
+ .expand-icon.rotated {
354
+ transform: rotate(90deg);
355
+ }
356
+
357
+ .tool-details {
358
+ padding: 0.5rem;
359
+ background: rgba(0, 0, 0, 0.2);
360
+ border-top: 1px solid rgba(255, 255, 255, 0.05);
361
+ }
362
+
363
+ .section-title {
364
+ color: rgba(255, 255, 255, 0.5);
365
+ font-size: 0.7rem;
366
+ font-weight: 600;
367
+ margin-bottom: 0.25rem;
368
+ text-transform: uppercase;
369
+ letter-spacing: 0.5px;
370
+ }
371
+
372
+ .params {
373
+ font-family: "Monaco", "Menlo", monospace;
374
+ font-size: 0.75rem;
375
+ }
376
+
377
+ .param {
378
+ display: flex;
379
+ gap: 0.5rem;
380
+ margin: 0.2rem 0;
381
+ }
382
+
383
+ .param-key {
384
+ color: rgba(255, 255, 255, 0.5);
385
+ }
386
+
387
+ .param-value {
388
+ color: rgba(255, 210, 30, 0.8);
389
+ word-break: break-all;
390
+ }
391
+
392
+ .param-value pre {
393
+ margin: 0;
394
+ padding: 0.4rem;
395
+ background: rgba(0, 0, 0, 0.3);
396
+ border-radius: 3px;
397
+ font-size: 0.7rem;
398
+ overflow-x: auto;
399
+ max-height: 150px;
400
+ }
401
+
402
+ .spinner {
403
+ display: inline-block;
404
+ animation: spin 0.8s linear infinite;
405
+ }
406
+
407
+ @keyframes spin {
408
+ from { transform: rotate(0deg); }
409
+ to { transform: rotate(360deg); }
410
+ }
411
+
412
+ .tool-output {
413
+ margin-top: 0.5rem;
414
+ padding: 0.5rem;
415
+ background: rgba(0, 255, 0, 0.05);
416
+ border: 1px solid rgba(0, 255, 0, 0.1);
417
+ border-radius: 3px;
418
+ }
419
+
420
+ .tool-output pre {
421
+ margin: 0;
422
+ font-family: "Monaco", "Menlo", monospace;
423
+ font-size: 0.7rem;
424
+ color: rgba(0, 255, 0, 0.8);
425
+ white-space: pre-wrap;
426
+ word-wrap: break-word;
427
+ max-height: 200px;
428
+ overflow-y: auto;
429
+ }
430
+
431
+ .console-output {
432
+ margin-top: 0.5rem;
433
+ background: rgba(0, 0, 0, 0.3);
434
+ border-radius: 3px;
435
+ padding: 0.4rem;
436
+ font-family: "Monaco", "Menlo", monospace;
437
+ font-size: 0.7rem;
438
+ color: rgba(255, 255, 255, 0.8);
439
+ overflow-x: auto;
440
+ max-height: 200px;
441
+ overflow-y: auto;
442
+ }
443
+
444
+ .console-line {
445
+ margin: 0.1rem 0;
446
+ }
447
+
448
+ .tool-error {
449
+ margin-top: 0.5rem;
450
+ padding: 0.5rem;
451
+ background: rgba(255, 0, 0, 0.08);
452
+ border: 1px solid rgba(255, 0, 0, 0.2);
453
+ border-radius: 3px;
454
+ color: rgba(255, 100, 100, 0.9);
455
+ font-size: 0.75rem;
456
+ font-family: "Monaco", "Menlo", monospace;
457
+ }
458
+ </style>
src/lib/components/chat/context.md CHANGED
@@ -1,20 +1,22 @@
1
  # Chat Context
2
 
3
- AI chat interface with markdown rendering and tool visualization.
4
 
5
  ## Components
6
 
7
- - `ChatPanel.svelte` - Main chat UI with message display
8
- - `ReasoningBlock.svelte` - Collapsible AI thinking viewer
9
- - `MarkdownRenderer.svelte` - Markdown parser and renderer
10
- - `ToolCallDisplay.svelte` - Inline badges for [TOOL:] patterns
11
- - `ToolCallBlock.svelte` - Expandable tool execution tracker
 
 
 
12
 
13
- ## Features
14
 
15
- - Real-time message streaming
16
- - Markdown formatting for assistant messages
17
- - Expandable tool execution tracking with status
18
- - Collapsible thinking blocks (0.1s collapse)
19
- - Authentication integration
20
- - GSAP animations
 
1
  # Chat Context
2
 
3
+ AI chat interface with real-time streaming.
4
 
5
  ## Components
6
 
7
+ - `ChatPanel.svelte` - Main chat UI with smooth scroll and keyed message iteration
8
+ - `MessageSegment.svelte` - Renders text, tool invocations, and results
9
+ - `StreamingText.svelte` - Optimized character streaming with state persistence
10
+ - `ToolInvocation.svelte` - Consolidated tool execution and results display
11
+ - `InProgressBlock.svelte` - Status indicator with progress bar
12
+ - `ReasoningBlock.svelte` - Auto-collapsing thinking viewer
13
+ - `MarkdownRenderer.svelte` - Markdown parser with configurable streaming speed
14
+ - `ToolCallDisplay.svelte`, `ToolCallBlock.svelte` - Legacy tool rendering (deprecated)
15
 
16
+ ## Architecture
17
 
18
+ - Explicit message IDs with proper component keying
19
+ - StreamingText tracks processed content length to prevent duplication
20
+ - Batch character processing (3 chars/cycle) at 120 chars/sec
21
+ - Tool invocations and results merged in single visual blocks
22
+ - Segments keyed by ID for stable component identity
 
src/lib/server/api.ts CHANGED
@@ -11,6 +11,9 @@ export interface WebSocketMessage {
11
  | "error"
12
  | "status"
13
  | "stream"
 
 
 
14
  | "auth"
15
  | "editor_update"
16
  | "editor_sync"
@@ -20,16 +23,17 @@ export interface WebSocketMessage {
20
  content?: string;
21
  role?: string;
22
  chunk?: string;
 
23
  error?: string;
24
  processing?: boolean;
25
  connected?: boolean;
26
  message?: string;
27
- token?: string;
28
  toolName?: string;
29
  toolArgs?: Record<string, unknown>;
30
  toolResult?: string;
31
  id?: string;
32
  type?: string;
 
33
  };
34
  timestamp: number;
35
  }
@@ -144,24 +148,36 @@ class WebSocketManager {
144
  }
145
  connectionData.messages.push(new HumanMessage(userMessage));
146
 
 
 
 
 
 
 
 
 
147
  const response = await connectionData.agent.processMessage(
148
  userMessage,
149
  connectionData.messages.slice(0, -1),
150
  (chunk: string) => {
151
  this.sendMessage(ws, {
152
- type: "stream",
153
- payload: { chunk },
 
 
 
154
  timestamp: Date.now(),
155
  });
156
  },
 
157
  );
158
 
159
  connectionData.messages.push(new AIMessage(response));
160
 
161
  this.sendMessage(ws, {
162
- type: "chat",
163
  payload: {
164
- role: "assistant",
165
  content: response,
166
  },
167
  timestamp: Date.now(),
 
11
  | "error"
12
  | "status"
13
  | "stream"
14
+ | "stream_start"
15
+ | "stream_token"
16
+ | "stream_end"
17
  | "auth"
18
  | "editor_update"
19
  | "editor_sync"
 
23
  content?: string;
24
  role?: string;
25
  chunk?: string;
26
+ token?: string;
27
  error?: string;
28
  processing?: boolean;
29
  connected?: boolean;
30
  message?: string;
 
31
  toolName?: string;
32
  toolArgs?: Record<string, unknown>;
33
  toolResult?: string;
34
  id?: string;
35
  type?: string;
36
+ messageId?: string;
37
  };
38
  timestamp: number;
39
  }
 
148
  }
149
  connectionData.messages.push(new HumanMessage(userMessage));
150
 
151
+ const messageId = `msg_${Date.now()}`;
152
+
153
+ this.sendMessage(ws, {
154
+ type: "stream_start",
155
+ payload: { messageId },
156
+ timestamp: Date.now(),
157
+ });
158
+
159
  const response = await connectionData.agent.processMessage(
160
  userMessage,
161
  connectionData.messages.slice(0, -1),
162
  (chunk: string) => {
163
  this.sendMessage(ws, {
164
+ type: "stream_token",
165
+ payload: {
166
+ token: chunk,
167
+ messageId,
168
+ },
169
  timestamp: Date.now(),
170
  });
171
  },
172
+ messageId,
173
  );
174
 
175
  connectionData.messages.push(new AIMessage(response));
176
 
177
  this.sendMessage(ws, {
178
+ type: "stream_end",
179
  payload: {
180
+ messageId,
181
  content: response,
182
  },
183
  timestamp: Date.now(),
src/lib/server/context.md CHANGED
@@ -1,35 +1,29 @@
1
  # Server Context
2
 
3
- WebSocket server with React Agent pattern for AI-assisted game development.
4
 
5
  ## Key Components
6
 
7
- - **api.ts** - WebSocket routing with editor sync, console forwarding, and tool feedback
8
- - **langgraph-agent.ts** - React Agent with conditional loops for tool execution
9
- - **tools.ts** - Editor read/write with game reload feedback, console observation
10
- - **console-buffer.ts** - Server-side console message storage and game state tracking
11
  - **documentation.ts** - VibeGame documentation loader
12
- - **prompts.ts** - Documentation formatting utilities
13
 
14
  ## Architecture
15
 
16
- React Agent pattern with conditional execution flow:
17
 
18
- - Agent node processes messages and executes tools
19
- - Tool results include console feedback from game reload
20
- - Console messages forwarded from client to server buffer
21
- - Write operations wait for game state before returning
22
 
23
- ## Message Types
24
 
25
  - `auth` - HF token authentication
26
  - `chat` - User messages
27
- - `editor_sync` - Sync editor content with server
28
- - `editor_update` - Server updates client editor
29
- - `console_sync` - Forward console messages to server
30
- - `tool_start` - Tool begins execution with status
31
- - `tool_output` - Streaming tool output chunks
32
- - `tool_complete` - Tool execution completed
33
- - `tool_error` - Tool execution failed
34
- - `stream` - Response streaming
35
- - `status` - Connection and processing status
 
1
  # Server Context
2
 
3
+ WebSocket server with LangGraph agent for AI-assisted game development.
4
 
5
  ## Key Components
6
 
7
+ - **api.ts** - WebSocket message routing
8
+ - **langgraph-agent.ts** - LangGraph agent with character streaming
9
+ - **tools.ts** - Editor read/write with game reload
10
+ - **console-buffer.ts** - Console message storage
11
  - **documentation.ts** - VibeGame documentation loader
 
12
 
13
  ## Architecture
14
 
15
+ LangGraph state machine with real-time streaming:
16
 
17
+ - Streams text segments character-by-character as they arrive
18
+ - Tool invocations interrupt text streaming
19
+ - Explicit message IDs required for all segment operations
 
20
 
21
+ ## Message Protocol
22
 
23
  - `auth` - HF token authentication
24
  - `chat` - User messages
25
+ - `stream_start/token/end` - Legacy streaming
26
+ - `segment_start/token/end` - Segment streaming
27
+ - `editor_sync` - Sync editor content
28
+ - `console_sync` - Forward console messages
29
+ - `status` - Connection and processing state
 
 
 
 
src/lib/server/langgraph-agent.ts CHANGED
@@ -60,28 +60,113 @@ export class LangGraphAgent {
60
  private setupGraph() {
61
  const graph = new StateGraph(AgentState);
62
 
63
- graph.addNode("agent", async (state) => {
64
  const systemPrompt = this.buildSystemPrompt();
65
  const messages = this.formatMessages(state.messages, systemPrompt);
66
 
67
- const response = await this.callModel(messages);
68
- const toolCalls = this.parseToolCalls(response);
 
 
 
69
 
70
- if (toolCalls.length > 0) {
71
- const toolResults = await this.executeTools(toolCalls);
72
- const toolMessage = new AIMessage({
73
- content: response,
74
- additional_kwargs: { has_tool_calls: true },
75
- });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
76
 
 
 
 
 
 
 
 
77
  return {
78
- messages: [toolMessage, ...toolResults],
 
 
 
 
 
 
79
  hasToolCalls: true,
80
  };
81
  }
82
 
83
  return {
84
- messages: [new AIMessage(response)],
85
  hasToolCalls: false,
86
  };
87
  });
@@ -173,21 +258,31 @@ Be concise, accurate, and focus on practical solutions.`;
173
  return formatted;
174
  }
175
 
176
- private async callModel(
177
  messages: Array<{ role: string; content: string }>,
178
- ): Promise<string> {
179
  if (!this.client) {
180
  throw new Error("Agent not initialized");
181
  }
182
 
183
- const response = await this.client.chatCompletion({
 
 
184
  model: this.model,
185
  messages,
186
  temperature: 0.3,
187
  max_tokens: 2048,
188
  });
189
 
190
- return response.choices[0].message.content || "";
 
 
 
 
 
 
 
 
191
  }
192
 
193
  private parseToolCalls(
@@ -221,24 +316,38 @@ Be concise, accurate, and focus on practical solutions.`;
221
  return toolCalls;
222
  }
223
 
224
- private async executeTools(
225
  toolCalls: Array<{ name: string; args: Record<string, unknown> }>,
 
226
  ): Promise<BaseMessage[]> {
227
  const results = [];
228
 
229
  for (const call of toolCalls) {
230
- const toolId = `${call.name}_${Date.now()}`;
231
 
232
  try {
233
  if (this.ws && this.ws.readyState === this.ws.OPEN) {
234
  this.ws.send(
235
  JSON.stringify({
236
- type: "tool_start",
237
  payload: {
238
- toolId,
 
 
239
  toolName: call.name,
240
  toolArgs: call.args,
 
 
 
 
 
 
 
 
 
 
241
  toolStatus: "running",
 
242
  },
243
  timestamp: Date.now(),
244
  }),
@@ -246,27 +355,18 @@ Be concise, accurate, and focus on practical solutions.`;
246
  }
247
 
248
  let result;
 
 
249
  if (call.name === "read_editor") {
250
  result = await readEditorTool.func("");
251
  } else if (call.name === "write_editor") {
252
  result = await writeEditorTool.func(call.args as { content: string });
253
 
254
  const consoleMatch = result.match(/Console output:\n([\s\S]*?)$/);
255
- if (consoleMatch && this.ws && this.ws.readyState === this.ws.OPEN) {
256
- const consoleLines = consoleMatch[1]
257
  .split("\n")
258
  .filter((line) => line.trim());
259
- this.ws.send(
260
- JSON.stringify({
261
- type: "tool_output",
262
- payload: {
263
- toolId,
264
- toolOutput: "",
265
- consoleOutput: consoleLines,
266
- },
267
- timestamp: Date.now(),
268
- }),
269
- );
270
  }
271
  } else if (call.name === "observe_console") {
272
  result = await observeConsoleTool.func("");
@@ -277,12 +377,40 @@ Be concise, accurate, and focus on practical solutions.`;
277
  if (this.ws && this.ws.readyState === this.ws.OPEN) {
278
  this.ws.send(
279
  JSON.stringify({
280
- type: "tool_complete",
281
  payload: {
282
- toolId,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
283
  toolName: call.name,
284
- toolResult: result,
 
 
 
 
 
 
 
 
 
 
 
 
285
  toolStatus: "completed",
 
286
  },
287
  timestamp: Date.now(),
288
  }),
@@ -292,7 +420,7 @@ Be concise, accurate, and focus on practical solutions.`;
292
  results.push(
293
  new ToolMessage({
294
  content: result,
295
- tool_call_id: toolId,
296
  name: call.name,
297
  }),
298
  );
@@ -300,12 +428,13 @@ Be concise, accurate, and focus on practical solutions.`;
300
  if (this.ws && this.ws.readyState === this.ws.OPEN) {
301
  this.ws.send(
302
  JSON.stringify({
303
- type: "tool_error",
304
  payload: {
305
- toolId,
306
- toolName: call.name,
307
- error: error instanceof Error ? error.message : String(error),
308
  toolStatus: "error",
 
 
 
309
  },
310
  timestamp: Date.now(),
311
  }),
@@ -315,7 +444,7 @@ Be concise, accurate, and focus on practical solutions.`;
315
  results.push(
316
  new ToolMessage({
317
  content: `Error executing ${call.name}: ${error}`,
318
- tool_call_id: toolId,
319
  name: call.name,
320
  }),
321
  );
@@ -329,58 +458,35 @@ Be concise, accurate, and focus on practical solutions.`;
329
  message: string,
330
  messageHistory: BaseMessage[] = [],
331
  onStream?: (chunk: string) => void,
 
332
  ): Promise<string> {
333
  if (!this.client) {
334
  throw new Error("Agent not initialized");
335
  }
336
 
337
- const messages = [...messageHistory, new HumanMessage(message)];
338
-
339
- const stream = await this.graph.stream({
340
- messages,
341
- hasToolCalls: false,
342
- });
 
 
 
 
343
 
344
  let fullResponse = "";
345
- let isStreaming = false;
346
- let lastAIResponse = "";
347
- let hasExecutedTools = false;
348
 
349
  for await (const chunk of stream) {
350
- if (chunk.agent?.messages) {
351
- for (const msg of chunk.agent.messages) {
352
- if (msg instanceof AIMessage) {
353
- const content = msg.content as string;
354
-
355
- if (msg.additional_kwargs?.has_tool_calls) {
356
- hasExecutedTools = true;
357
- lastAIResponse = content;
358
- } else {
359
- if (!isStreaming) {
360
- isStreaming = true;
361
- for (const char of content) {
362
- fullResponse += char;
363
- onStream?.(char);
364
- await new Promise((resolve) => setTimeout(resolve, 5));
365
- }
366
- } else {
367
- fullResponse = content;
368
- }
369
- lastAIResponse = content;
370
- }
371
- } else if (msg instanceof ToolMessage) {
372
- if (onStream && !hasExecutedTools) {
373
- const toolNotification = `\n🔧 ${msg.name}\n`;
374
- for (const char of toolNotification) {
375
- onStream(char);
376
- await new Promise((resolve) => setTimeout(resolve, 5));
377
- }
378
- }
379
- }
380
  }
381
  }
382
  }
383
 
384
- return fullResponse || lastAIResponse;
385
  }
386
  }
 
60
  private setupGraph() {
61
  const graph = new StateGraph(AgentState);
62
 
63
+ graph.addNode("agent", async (state, config) => {
64
  const systemPrompt = this.buildSystemPrompt();
65
  const messages = this.formatMessages(state.messages, systemPrompt);
66
 
67
+ let fullResponse = "";
68
+ let currentSegmentId: string | null = null;
69
+ let currentSegmentContent = "";
70
+ let inToolCall = false;
71
+ const messageId = config?.metadata?.messageId;
72
 
73
+ for await (const token of this.streamModelResponse(messages)) {
74
+ fullResponse += token;
75
+ config?.writer?.({ type: "token", content: token });
76
+
77
+ const isToolStart = token.includes("[TOOL:");
78
+ const isToolEnd = inToolCall && token.includes("]");
79
+
80
+ if (isToolStart) {
81
+ if (currentSegmentId && currentSegmentContent.trim() && this.ws) {
82
+ this.ws.send(
83
+ JSON.stringify({
84
+ type: "segment_end",
85
+ payload: {
86
+ segmentId: currentSegmentId,
87
+ content: currentSegmentContent,
88
+ messageId,
89
+ },
90
+ timestamp: Date.now(),
91
+ }),
92
+ );
93
+ }
94
+ currentSegmentId = null;
95
+ currentSegmentContent = "";
96
+ inToolCall = true;
97
+ } else if (isToolEnd) {
98
+ inToolCall = false;
99
+ } else if (!inToolCall) {
100
+ if (!currentSegmentId && token.trim() && this.ws) {
101
+ currentSegmentId = `seg_${Date.now()}_${Math.random()}`;
102
+ currentSegmentContent = "";
103
+ this.ws.send(
104
+ JSON.stringify({
105
+ type: "segment_start",
106
+ payload: {
107
+ segmentId: currentSegmentId,
108
+ segmentType: "text",
109
+ messageId,
110
+ },
111
+ timestamp: Date.now(),
112
+ }),
113
+ );
114
+ }
115
+
116
+ if (currentSegmentId) {
117
+ currentSegmentContent += token;
118
+ if (this.ws) {
119
+ this.ws.send(
120
+ JSON.stringify({
121
+ type: "segment_token",
122
+ payload: {
123
+ segmentId: currentSegmentId,
124
+ token,
125
+ messageId,
126
+ },
127
+ timestamp: Date.now(),
128
+ }),
129
+ );
130
+ }
131
+ }
132
+ }
133
+ }
134
+
135
+ if (currentSegmentId && currentSegmentContent.trim() && this.ws) {
136
+ this.ws.send(
137
+ JSON.stringify({
138
+ type: "segment_end",
139
+ payload: {
140
+ segmentId: currentSegmentId,
141
+ content: currentSegmentContent,
142
+ messageId,
143
+ },
144
+ timestamp: Date.now(),
145
+ }),
146
+ );
147
+ }
148
 
149
+ const toolCalls = this.parseToolCalls(fullResponse);
150
+
151
+ if (toolCalls.length > 0) {
152
+ const toolResults = await this.executeToolsWithSegments(
153
+ toolCalls,
154
+ config?.metadata?.messageId as string | undefined,
155
+ );
156
  return {
157
+ messages: [
158
+ new AIMessage({
159
+ content: fullResponse,
160
+ additional_kwargs: { has_tool_calls: true },
161
+ }),
162
+ ...toolResults,
163
+ ],
164
  hasToolCalls: true,
165
  };
166
  }
167
 
168
  return {
169
+ messages: [new AIMessage(fullResponse)],
170
  hasToolCalls: false,
171
  };
172
  });
 
258
  return formatted;
259
  }
260
 
261
+ private async *streamModelResponse(
262
  messages: Array<{ role: string; content: string }>,
263
+ ): AsyncGenerator<string, string, unknown> {
264
  if (!this.client) {
265
  throw new Error("Agent not initialized");
266
  }
267
 
268
+ let fullContent = "";
269
+
270
+ const stream = this.client.chatCompletionStream({
271
  model: this.model,
272
  messages,
273
  temperature: 0.3,
274
  max_tokens: 2048,
275
  });
276
 
277
+ for await (const chunk of stream) {
278
+ const token = chunk.choices[0]?.delta?.content || "";
279
+ if (token) {
280
+ fullContent += token;
281
+ yield token;
282
+ }
283
+ }
284
+
285
+ return fullContent;
286
  }
287
 
288
  private parseToolCalls(
 
316
  return toolCalls;
317
  }
318
 
319
+ private async executeToolsWithSegments(
320
  toolCalls: Array<{ name: string; args: Record<string, unknown> }>,
321
+ messageId?: string,
322
  ): Promise<BaseMessage[]> {
323
  const results = [];
324
 
325
  for (const call of toolCalls) {
326
+ const segmentId = `seg_tool_${Date.now()}_${Math.random()}`;
327
 
328
  try {
329
  if (this.ws && this.ws.readyState === this.ws.OPEN) {
330
  this.ws.send(
331
  JSON.stringify({
332
+ type: "segment_start",
333
  payload: {
334
+ segmentId,
335
+ segmentType: "tool-invocation",
336
+ messageId,
337
  toolName: call.name,
338
  toolArgs: call.args,
339
+ },
340
+ timestamp: Date.now(),
341
+ }),
342
+ );
343
+
344
+ this.ws.send(
345
+ JSON.stringify({
346
+ type: "segment_end",
347
+ payload: {
348
+ segmentId,
349
  toolStatus: "running",
350
+ messageId,
351
  },
352
  timestamp: Date.now(),
353
  }),
 
355
  }
356
 
357
  let result;
358
+ let consoleOutput: string[] = [];
359
+
360
  if (call.name === "read_editor") {
361
  result = await readEditorTool.func("");
362
  } else if (call.name === "write_editor") {
363
  result = await writeEditorTool.func(call.args as { content: string });
364
 
365
  const consoleMatch = result.match(/Console output:\n([\s\S]*?)$/);
366
+ if (consoleMatch) {
367
+ consoleOutput = consoleMatch[1]
368
  .split("\n")
369
  .filter((line) => line.trim());
 
 
 
 
 
 
 
 
 
 
 
370
  }
371
  } else if (call.name === "observe_console") {
372
  result = await observeConsoleTool.func("");
 
377
  if (this.ws && this.ws.readyState === this.ws.OPEN) {
378
  this.ws.send(
379
  JSON.stringify({
380
+ type: "segment_end",
381
  payload: {
382
+ segmentId,
383
+ toolStatus: "completed",
384
+ messageId,
385
+ },
386
+ timestamp: Date.now(),
387
+ }),
388
+ );
389
+
390
+ const resultSegmentId = `seg_result_${Date.now()}_${Math.random()}`;
391
+ this.ws.send(
392
+ JSON.stringify({
393
+ type: "segment_start",
394
+ payload: {
395
+ segmentId: resultSegmentId,
396
+ segmentType: "tool-result",
397
+ messageId,
398
  toolName: call.name,
399
+ },
400
+ timestamp: Date.now(),
401
+ }),
402
+ );
403
+
404
+ this.ws.send(
405
+ JSON.stringify({
406
+ type: "segment_end",
407
+ payload: {
408
+ segmentId: resultSegmentId,
409
+ toolOutput: result,
410
+ consoleOutput:
411
+ consoleOutput.length > 0 ? consoleOutput : undefined,
412
  toolStatus: "completed",
413
+ messageId,
414
  },
415
  timestamp: Date.now(),
416
  }),
 
420
  results.push(
421
  new ToolMessage({
422
  content: result,
423
+ tool_call_id: segmentId,
424
  name: call.name,
425
  }),
426
  );
 
428
  if (this.ws && this.ws.readyState === this.ws.OPEN) {
429
  this.ws.send(
430
  JSON.stringify({
431
+ type: "segment_end",
432
  payload: {
433
+ segmentId,
 
 
434
  toolStatus: "error",
435
+ toolError:
436
+ error instanceof Error ? error.message : String(error),
437
+ messageId,
438
  },
439
  timestamp: Date.now(),
440
  }),
 
444
  results.push(
445
  new ToolMessage({
446
  content: `Error executing ${call.name}: ${error}`,
447
+ tool_call_id: segmentId,
448
  name: call.name,
449
  }),
450
  );
 
458
  message: string,
459
  messageHistory: BaseMessage[] = [],
460
  onStream?: (chunk: string) => void,
461
+ messageId?: string,
462
  ): Promise<string> {
463
  if (!this.client) {
464
  throw new Error("Agent not initialized");
465
  }
466
 
467
+ const stream = await this.graph.stream(
468
+ {
469
+ messages: [...messageHistory, new HumanMessage(message)],
470
+ hasToolCalls: false,
471
+ },
472
+ {
473
+ streamMode: ["custom", "updates"] as const,
474
+ metadata: { messageId },
475
+ },
476
+ );
477
 
478
  let fullResponse = "";
 
 
 
479
 
480
  for await (const chunk of stream) {
481
+ if (Array.isArray(chunk)) {
482
+ const [mode, data] = chunk;
483
+ if (mode === "custom" && data?.type === "token") {
484
+ fullResponse += data.content;
485
+ onStream?.(data.content);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
486
  }
487
  }
488
  }
489
 
490
+ return fullResponse;
491
  }
492
  }
src/lib/stores/agent.ts CHANGED
@@ -15,6 +15,30 @@ export interface ToolExecution {
15
  expanded?: boolean;
16
  }
17
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
18
  export interface ChatMessage {
19
  id: string;
20
  role: "user" | "assistant" | "system" | "tool";
@@ -24,6 +48,7 @@ export interface ChatMessage {
24
  reasoning?: string;
25
  showReasoning?: boolean;
26
  toolExecutions?: ToolExecution[];
 
27
  }
28
 
29
  export interface AgentState {
@@ -131,6 +156,8 @@ function createAgentStore() {
131
  processing?: boolean;
132
  connected?: boolean;
133
  chunk?: string;
 
 
134
  reasoning?: string;
135
  role?: string;
136
  content?: string;
@@ -142,7 +169,10 @@ function createAgentStore() {
142
  toolId?: string;
143
  toolStatus?: "pending" | "running" | "completed" | "error";
144
  toolOutput?: string;
 
145
  consoleOutput?: string[];
 
 
146
  };
147
  }) {
148
  switch (message.type) {
@@ -164,45 +194,73 @@ function createAgentStore() {
164
  }
165
  break;
166
 
167
- case "stream":
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
168
  update((state) => {
169
  const newContent =
170
- state.streamingContent + (message.payload.chunk || "");
171
-
172
- if (!currentStreamId) {
173
- currentStreamId = `msg_${Date.now()}`;
174
- const streamMessage: ChatMessage = {
175
- id: currentStreamId,
176
- role: "assistant",
177
- content: newContent,
178
- timestamp: Date.now(),
179
- streaming: true,
180
- reasoning: message.payload.reasoning,
181
- };
182
- return {
183
- ...state,
184
- streamingContent: newContent,
185
- messages: [...state.messages, streamMessage],
186
- streamingStatus: "streaming",
187
- };
188
- } else {
189
- const messages = state.messages.map((msg) => {
190
- if (msg.id === currentStreamId) {
191
- return {
192
- ...msg,
193
- content: newContent,
194
- reasoning: message.payload.reasoning || msg.reasoning,
195
- };
196
- }
197
- return msg;
198
- });
199
- return {
200
- ...state,
201
- streamingContent: newContent,
202
- messages,
203
- streamingStatus: "streaming",
204
- };
205
- }
206
  });
207
  break;
208
 
@@ -374,6 +432,134 @@ function createAgentStore() {
374
  return { ...state, messages };
375
  });
376
  break;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
377
  }
378
  }
379
 
@@ -384,10 +570,11 @@ function createAgentStore() {
384
  }
385
 
386
  const userMessage: ChatMessage = {
387
- id: `msg_${Date.now()}`,
388
  role: "user",
389
  content,
390
  timestamp: Date.now(),
 
391
  };
392
 
393
  update((state) => ({
 
15
  expanded?: boolean;
16
  }
17
 
18
+ export type MessageSegmentType =
19
+ | "text"
20
+ | "tool-invocation"
21
+ | "tool-result"
22
+ | "reasoning";
23
+
24
+ export interface MessageSegment {
25
+ id: string;
26
+ type: MessageSegmentType;
27
+ content: string;
28
+ toolName?: string;
29
+ toolArgs?: Record<string, unknown>;
30
+ toolStatus?: "pending" | "running" | "completed" | "error";
31
+ toolOutput?: string;
32
+ toolResult?: string;
33
+ toolError?: string;
34
+ startTime?: number;
35
+ endTime?: number;
36
+ consoleOutput?: string[];
37
+ expanded?: boolean;
38
+ streaming?: boolean;
39
+ mergeWithPrevious?: boolean;
40
+ }
41
+
42
  export interface ChatMessage {
43
  id: string;
44
  role: "user" | "assistant" | "system" | "tool";
 
48
  reasoning?: string;
49
  showReasoning?: boolean;
50
  toolExecutions?: ToolExecution[];
51
+ segments?: MessageSegment[];
52
  }
53
 
54
  export interface AgentState {
 
156
  processing?: boolean;
157
  connected?: boolean;
158
  chunk?: string;
159
+ token?: string;
160
+ messageId?: string;
161
  reasoning?: string;
162
  role?: string;
163
  content?: string;
 
169
  toolId?: string;
170
  toolStatus?: "pending" | "running" | "completed" | "error";
171
  toolOutput?: string;
172
+ toolError?: string;
173
  consoleOutput?: string[];
174
+ segmentId?: string;
175
+ segmentType?: MessageSegmentType;
176
  };
177
  }) {
178
  switch (message.type) {
 
194
  }
195
  break;
196
 
197
+ case "stream_start": {
198
+ const assistantId =
199
+ message.payload.messageId || `assistant_${Date.now()}`;
200
+ currentStreamId = assistantId;
201
+ update((state) => {
202
+ return {
203
+ ...state,
204
+ streamingContent: "",
205
+ messages: [
206
+ ...state.messages,
207
+ {
208
+ id: assistantId,
209
+ role: "assistant",
210
+ content: "",
211
+ timestamp: Date.now(),
212
+ streaming: true,
213
+ segments: [],
214
+ },
215
+ ],
216
+ streamingStatus: "streaming",
217
+ thinkingStartTime: null,
218
+ };
219
+ });
220
+ break;
221
+ }
222
+
223
+ case "stream_token":
224
+ if (!message.payload.messageId) {
225
+ console.error("stream_token without messageId");
226
+ break;
227
+ }
228
  update((state) => {
229
  const newContent =
230
+ state.streamingContent + (message.payload.token || "");
231
+ return {
232
+ ...state,
233
+ streamingContent: newContent,
234
+ messages: state.messages.map((msg) =>
235
+ msg.id === message.payload.messageId
236
+ ? { ...msg, content: newContent }
237
+ : msg,
238
+ ),
239
+ streamingStatus: "streaming",
240
+ };
241
+ });
242
+ break;
243
+
244
+ case "stream_end":
245
+ if (!message.payload.messageId) {
246
+ console.error("stream_end without messageId");
247
+ break;
248
+ }
249
+ update((state) => {
250
+ const finalContent =
251
+ message.payload.content || state.streamingContent;
252
+ currentStreamId = null;
253
+ return {
254
+ ...state,
255
+ streamingContent: "",
256
+ messages: state.messages.map((msg) =>
257
+ msg.id === message.payload.messageId
258
+ ? { ...msg, content: finalContent, streaming: false }
259
+ : msg,
260
+ ),
261
+ streamingStatus: "idle",
262
+ thinkingStartTime: null,
263
+ };
 
 
264
  });
265
  break;
266
 
 
432
  return { ...state, messages };
433
  });
434
  break;
435
+
436
+ case "segment_start":
437
+ update((state) => {
438
+ const segmentId = message.payload.segmentId || `seg_${Date.now()}`;
439
+ const segmentType = message.payload.segmentType as MessageSegmentType;
440
+ const newSegment: MessageSegment = {
441
+ id: segmentId,
442
+ type: segmentType,
443
+ content: "",
444
+ toolName: message.payload.toolName,
445
+ toolArgs: message.payload.toolArgs,
446
+ startTime: Date.now(),
447
+ streaming: segmentType === "text",
448
+ toolStatus:
449
+ segmentType === "tool-invocation" ? "pending" : undefined,
450
+ };
451
+
452
+ if (!message.payload.messageId) {
453
+ console.error("segment_start without messageId");
454
+ return state;
455
+ }
456
+ return {
457
+ ...state,
458
+ messages: state.messages.map((msg) => {
459
+ if (msg.id === message.payload.messageId) {
460
+ return {
461
+ ...msg,
462
+ segments: [...(msg.segments || []), newSegment],
463
+ };
464
+ }
465
+ return msg;
466
+ }),
467
+ };
468
+ });
469
+ break;
470
+
471
+ case "segment_token":
472
+ if (!message.payload.messageId) {
473
+ console.error("segment_token without messageId");
474
+ break;
475
+ }
476
+ update((state) => {
477
+ return {
478
+ ...state,
479
+ messages: state.messages.map((msg) => {
480
+ if (msg.id === message.payload.messageId) {
481
+ const segments = msg.segments?.map((seg) => {
482
+ if (seg.id === message.payload.segmentId) {
483
+ return {
484
+ ...seg,
485
+ content: seg.content + (message.payload.token || ""),
486
+ };
487
+ }
488
+ return seg;
489
+ });
490
+ return { ...msg, segments };
491
+ }
492
+ return msg;
493
+ }),
494
+ };
495
+ });
496
+ break;
497
+
498
+ case "segment_end":
499
+ if (!message.payload.messageId) {
500
+ console.error("segment_end without messageId");
501
+ break;
502
+ }
503
+ update((state) => {
504
+ return {
505
+ ...state,
506
+ messages: state.messages.map((msg) => {
507
+ if (msg.id === message.payload.messageId) {
508
+ const segments = msg.segments?.map((seg, index, allSegs) => {
509
+ if (seg.id === message.payload.segmentId) {
510
+ const updatedSegment = {
511
+ ...seg,
512
+ streaming: false,
513
+ content: message.payload.content || seg.content,
514
+ toolOutput: message.payload.toolOutput,
515
+ toolResult: message.payload.toolResult,
516
+ toolStatus: message.payload.toolStatus || seg.toolStatus,
517
+ toolError: message.payload.toolError,
518
+ endTime: Date.now(),
519
+ consoleOutput: message.payload.consoleOutput,
520
+ };
521
+
522
+ if (seg.type === "tool-result" && index > 0) {
523
+ const prevSegment = allSegs[index - 1];
524
+ if (
525
+ prevSegment.type === "tool-invocation" &&
526
+ prevSegment.toolName === seg.toolName
527
+ ) {
528
+ return { ...updatedSegment, mergeWithPrevious: true };
529
+ }
530
+ }
531
+ return updatedSegment;
532
+ }
533
+ return seg;
534
+ });
535
+
536
+ const mergedSegments = segments?.reduce((acc, seg, index) => {
537
+ if (seg.mergeWithPrevious && index > 0 && acc.length > 0) {
538
+ const prevIndex = acc.length - 1;
539
+ acc[prevIndex] = {
540
+ ...acc[prevIndex],
541
+ toolOutput: seg.toolOutput || acc[prevIndex].toolOutput,
542
+ toolResult: seg.toolResult || seg.toolOutput,
543
+ toolError: seg.toolError || acc[prevIndex].toolError,
544
+ toolStatus: seg.toolStatus || acc[prevIndex].toolStatus,
545
+ consoleOutput:
546
+ seg.consoleOutput || acc[prevIndex].consoleOutput,
547
+ endTime: seg.endTime,
548
+ };
549
+ return acc;
550
+ }
551
+ const cleanSegment = { ...seg };
552
+ delete cleanSegment.mergeWithPrevious;
553
+ return [...acc, cleanSegment];
554
+ }, [] as MessageSegment[]);
555
+
556
+ return { ...msg, segments: mergedSegments };
557
+ }
558
+ return msg;
559
+ }),
560
+ };
561
+ });
562
+ break;
563
  }
564
  }
565
 
 
570
  }
571
 
572
  const userMessage: ChatMessage = {
573
+ id: `user_${Date.now()}`,
574
  role: "user",
575
  content,
576
  timestamp: Date.now(),
577
+ streaming: false,
578
  };
579
 
580
  update((state) => ({
src/lib/stores/editor.ts CHANGED
@@ -10,6 +10,8 @@ const DEFAULT_CONTENT =
10
  `<canvas id="game-canvas"></canvas>
11
 
12
  <world canvas="#game-canvas" sky="#87ceeb">
 
 
13
  <!-- Ground -->
14
  <static-part pos="0 -0.5 0" shape="box" size="20 1 20" color="#90ee90"></static-part>
15
 
 
10
  `<canvas id="game-canvas"></canvas>
11
 
12
  <world canvas="#game-canvas" sky="#87ceeb">
13
+ <player pos="0 0 0"></player>
14
+
15
  <!-- Ground -->
16
  <static-part pos="0 -0.5 0" shape="box" size="20 1 20" color="#90ee90"></static-part>
17