vanilla-agent 1.3.0 → 1.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/ui.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { escapeHtml } from "./postprocessors";
2
2
  import { AgentWidgetSession, AgentWidgetSessionStatus } from "./session";
3
- import { AgentWidgetConfig, AgentWidgetMessage } from "./types";
3
+ import { AgentWidgetConfig, AgentWidgetMessage, AgentWidgetEvent } from "./types";
4
4
  import { applyThemeVariables } from "./utils/theme";
5
5
  import { renderLucideIcon } from "./utils/icons";
6
6
  import { createElement } from "./utils/dom";
@@ -16,6 +16,9 @@ import { enhanceWithForms } from "./components/forms";
16
16
  import { pluginRegistry } from "./plugins/registry";
17
17
  import { mergeWithDefaults } from "./defaults";
18
18
 
19
+ // Default localStorage key for chat history (automatically cleared on clear chat)
20
+ const DEFAULT_CHAT_HISTORY_STORAGE_KEY = "vanilla-agent-chat-history";
21
+
19
22
  type Controller = {
20
23
  update: (config: AgentWidgetConfig) => void;
21
24
  destroy: () => void;
@@ -27,6 +30,7 @@ type Controller = {
27
30
  submitMessage: (message?: string) => boolean;
28
31
  startVoiceRecognition: () => boolean;
29
32
  stopVoiceRecognition: () => boolean;
33
+ injectTestMessage: (event: AgentWidgetEvent) => void;
30
34
  };
31
35
 
32
36
  const buildPostprocessor = (cfg?: AgentWidgetConfig): MessageTransform => {
@@ -152,6 +156,74 @@ export const createAgentExperience = (
152
156
  });
153
157
  };
154
158
 
159
+ // Track ongoing smooth scroll animation
160
+ let smoothScrollRAF: number | null = null;
161
+
162
+ // Get the scrollable container using its unique ID
163
+ const getScrollableContainer = (): HTMLElement => {
164
+ // Use the unique ID for reliable selection
165
+ const scrollable = wrapper.querySelector('#vanilla-agent-scroll-container') as HTMLElement;
166
+ // Fallback to body if ID not found (shouldn't happen, but safe fallback)
167
+ return scrollable || body;
168
+ };
169
+
170
+ // Custom smooth scroll animation with easing
171
+ const smoothScrollToBottom = (element: HTMLElement, duration = 500) => {
172
+ const start = element.scrollTop;
173
+ const clientHeight = element.clientHeight;
174
+ // Recalculate target dynamically to handle layout changes
175
+ let target = element.scrollHeight;
176
+ let distance = target - start;
177
+
178
+ // Check if already at bottom: scrollTop + clientHeight should be >= scrollHeight
179
+ // Add a small threshold (2px) to account for rounding/subpixel differences
180
+ const isAtBottom = start + clientHeight >= target - 2;
181
+
182
+ // If already at bottom or very close, skip animation to prevent glitch
183
+ if (isAtBottom || Math.abs(distance) < 5) {
184
+ return;
185
+ }
186
+
187
+ // Cancel any ongoing smooth scroll animation
188
+ if (smoothScrollRAF !== null) {
189
+ cancelAnimationFrame(smoothScrollRAF);
190
+ smoothScrollRAF = null;
191
+ }
192
+
193
+ const startTime = performance.now();
194
+
195
+ // Easing function: ease-out cubic for smooth deceleration
196
+ const easeOutCubic = (t: number): number => {
197
+ return 1 - Math.pow(1 - t, 3);
198
+ };
199
+
200
+ const animate = (currentTime: number) => {
201
+ // Recalculate target each frame in case scrollHeight changed
202
+ const currentTarget = element.scrollHeight;
203
+ if (currentTarget !== target) {
204
+ target = currentTarget;
205
+ distance = target - start;
206
+ }
207
+
208
+ const elapsed = currentTime - startTime;
209
+ const progress = Math.min(elapsed / duration, 1);
210
+ const eased = easeOutCubic(progress);
211
+
212
+ const currentScroll = start + distance * eased;
213
+ element.scrollTop = currentScroll;
214
+
215
+ if (progress < 1) {
216
+ smoothScrollRAF = requestAnimationFrame(animate);
217
+ } else {
218
+ // Ensure we end exactly at the target
219
+ element.scrollTop = element.scrollHeight;
220
+ smoothScrollRAF = null;
221
+ }
222
+ };
223
+
224
+ smoothScrollRAF = requestAnimationFrame(animate);
225
+ };
226
+
155
227
 
156
228
  // Message rendering with plugin support
157
229
  const renderMessagesWithPlugins = (
@@ -191,7 +263,7 @@ export const createAgentExperience = (
191
263
  if (!showToolCalls) return;
192
264
  bubble = matchingPlugin.renderToolCall({
193
265
  message,
194
- defaultRenderer: () => createToolBubble(message),
266
+ defaultRenderer: () => createToolBubble(message, config),
195
267
  config
196
268
  });
197
269
  } else if (matchingPlugin.renderMessage) {
@@ -216,7 +288,7 @@ export const createAgentExperience = (
216
288
  bubble = createReasoningBubble(message);
217
289
  } else if (message.variant === "tool" && message.toolCall) {
218
290
  if (!showToolCalls) return;
219
- bubble = createToolBubble(message);
291
+ bubble = createToolBubble(message, config);
220
292
  } else {
221
293
  bubble = createStandardBubble(message, transform);
222
294
  if (message.role !== "user") {
@@ -236,8 +308,9 @@ export const createAgentExperience = (
236
308
 
237
309
  // Add standalone typing indicator only if streaming but no assistant message is streaming yet
238
310
  // (This shows while waiting for the stream to start)
311
+ // Check for ANY streaming assistant message, even if empty (to avoid duplicate bubbles)
239
312
  const hasStreamingAssistantMessage = messages.some(
240
- (msg) => msg.role === "assistant" && msg.streaming && msg.content && msg.content.trim()
313
+ (msg) => msg.role === "assistant" && msg.streaming
241
314
  );
242
315
 
243
316
  if (isStreaming && messages.some((msg) => msg.role === "user") && !hasStreamingAssistantMessage) {
@@ -268,7 +341,16 @@ export const createAgentExperience = (
268
341
  }
269
342
 
270
343
  container.appendChild(fragment);
271
- container.scrollTop = container.scrollHeight;
344
+ // Defer scroll to next frame for smoother animation and to prevent jolt
345
+ // This allows the browser to update layout (e.g., typing indicator removal) before scrolling
346
+ // Use double RAF to ensure layout has fully settled before starting scroll animation
347
+ // Get the scrollable container using its unique ID (#vanilla-agent-scroll-container)
348
+ requestAnimationFrame(() => {
349
+ requestAnimationFrame(() => {
350
+ const scrollableContainer = getScrollableContainer();
351
+ smoothScrollToBottom(scrollableContainer);
352
+ });
353
+ });
272
354
  };
273
355
 
274
356
  const updateOpenState = () => {
@@ -793,6 +875,28 @@ export const createAgentExperience = (
793
875
  // Clear messages in session (this will trigger onMessagesChanged which re-renders)
794
876
  session.clearMessages();
795
877
 
878
+ // Always clear the default localStorage key
879
+ try {
880
+ localStorage.removeItem(DEFAULT_CHAT_HISTORY_STORAGE_KEY);
881
+ if (config.debug) {
882
+ console.log(`[AgentWidget] Cleared default localStorage key: ${DEFAULT_CHAT_HISTORY_STORAGE_KEY}`);
883
+ }
884
+ } catch (error) {
885
+ console.error("[AgentWidget] Failed to clear default localStorage:", error);
886
+ }
887
+
888
+ // Also clear custom localStorage key if configured
889
+ if (config.clearChatHistoryStorageKey && config.clearChatHistoryStorageKey !== DEFAULT_CHAT_HISTORY_STORAGE_KEY) {
890
+ try {
891
+ localStorage.removeItem(config.clearChatHistoryStorageKey);
892
+ if (config.debug) {
893
+ console.log(`[AgentWidget] Cleared custom localStorage key: ${config.clearChatHistoryStorageKey}`);
894
+ }
895
+ } catch (error) {
896
+ console.error("[AgentWidget] Failed to clear custom localStorage:", error);
897
+ }
898
+ }
899
+
796
900
  // Dispatch custom event for external handlers (e.g., localStorage clearing in examples)
797
901
  const clearEvent = new CustomEvent("vanilla-agent:clear-chat", {
798
902
  detail: { timestamp: new Date().toISOString() }
@@ -823,6 +927,7 @@ export const createAgentExperience = (
823
927
 
824
928
  return {
825
929
  update(nextConfig: AgentWidgetConfig) {
930
+ const previousToolCallConfig = config.toolCall;
826
931
  config = { ...config, ...nextConfig };
827
932
  applyThemeVariables(mount, config);
828
933
 
@@ -876,6 +981,12 @@ export const createAgentExperience = (
876
981
  recalcPanelHeight();
877
982
  refreshCloseButton();
878
983
 
984
+ // Re-render messages if toolCall config changed (to apply new styles)
985
+ const toolCallConfigChanged = JSON.stringify(nextConfig.toolCall) !== JSON.stringify(previousToolCallConfig);
986
+ if (toolCallConfigChanged && session) {
987
+ renderMessagesWithPlugins(messagesWrapper, session.getMessages(), postprocess);
988
+ }
989
+
879
990
  // Update panel icon sizes
880
991
  const launcher = config.launcher ?? {};
881
992
  const headerIconHidden = launcher.headerIconHidden ?? false;
@@ -1594,6 +1705,28 @@ export const createAgentExperience = (
1594
1705
  // Clear messages in session (this will trigger onMessagesChanged which re-renders)
1595
1706
  session.clearMessages();
1596
1707
 
1708
+ // Always clear the default localStorage key
1709
+ try {
1710
+ localStorage.removeItem(DEFAULT_CHAT_HISTORY_STORAGE_KEY);
1711
+ if (config.debug) {
1712
+ console.log(`[AgentWidget] Cleared default localStorage key: ${DEFAULT_CHAT_HISTORY_STORAGE_KEY}`);
1713
+ }
1714
+ } catch (error) {
1715
+ console.error("[AgentWidget] Failed to clear default localStorage:", error);
1716
+ }
1717
+
1718
+ // Also clear custom localStorage key if configured
1719
+ if (config.clearChatHistoryStorageKey && config.clearChatHistoryStorageKey !== DEFAULT_CHAT_HISTORY_STORAGE_KEY) {
1720
+ try {
1721
+ localStorage.removeItem(config.clearChatHistoryStorageKey);
1722
+ if (config.debug) {
1723
+ console.log(`[AgentWidget] Cleared custom localStorage key: ${config.clearChatHistoryStorageKey}`);
1724
+ }
1725
+ } catch (error) {
1726
+ console.error("[AgentWidget] Failed to clear custom localStorage:", error);
1727
+ }
1728
+ }
1729
+
1597
1730
  // Dispatch custom event for external handlers (e.g., localStorage clearing in examples)
1598
1731
  const clearEvent = new CustomEvent("vanilla-agent:clear-chat", {
1599
1732
  detail: { timestamp: new Date().toISOString() }
@@ -1649,6 +1782,13 @@ export const createAgentExperience = (
1649
1782
  stopVoiceRecognition();
1650
1783
  return true;
1651
1784
  },
1785
+ injectTestMessage(event: AgentWidgetEvent) {
1786
+ // Auto-open widget if closed and launcher is enabled
1787
+ if (!open && launcherEnabled) {
1788
+ setOpenState(true);
1789
+ }
1790
+ session.injectTestEvent(event);
1791
+ },
1652
1792
  destroy() {
1653
1793
  destroyCallbacks.forEach((cb) => cb());
1654
1794
  wrapper.remove();
@@ -11,3 +11,5 @@ export const statusCopy: Record<AgentWidgetSessionStatus, string> = {
11
11
 
12
12
 
13
13
 
14
+
15
+
package/src/utils/dom.ts CHANGED
@@ -20,3 +20,5 @@ export const createFragment = (): DocumentFragment => {
20
20
 
21
21
 
22
22
 
23
+
24
+
@@ -0,0 +1,160 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { createJsonStreamParser } from "./formatting";
3
+
4
+ describe("JSON Stream Parser", () => {
5
+ it("should extract text field incrementally as JSON streams in", () => {
6
+ // Simulate the actual stream chunks from the user's example
7
+ const chunks = [
8
+ '{\n',
9
+ ' ',
10
+ ' "',
11
+ 'action',
12
+ '":',
13
+ ' "',
14
+ 'message',
15
+ '",\n',
16
+ ' ',
17
+ ' "',
18
+ 'text',
19
+ '":',
20
+ ' "',
21
+ 'You\'re',
22
+ ' welcome',
23
+ '!',
24
+ ' Enjoy',
25
+ ' your',
26
+ ' browsing',
27
+ ',',
28
+ ' and',
29
+ ' I\'m',
30
+ ' here',
31
+ ' if',
32
+ ' you',
33
+ ' need',
34
+ ' anything',
35
+ '!"\n',
36
+ '}'
37
+ ];
38
+
39
+ const parser = createJsonStreamParser();
40
+ let accumulatedContent = "";
41
+ const extractedTexts: string[] = [];
42
+
43
+ // Process each chunk incrementally
44
+ for (const chunk of chunks) {
45
+ accumulatedContent += chunk;
46
+ const result = parser.processChunk(accumulatedContent);
47
+
48
+ if (result !== null) {
49
+ extractedTexts.push(result);
50
+ }
51
+
52
+ // Also check getExtractedText
53
+ const currentText = parser.getExtractedText();
54
+ if (currentText !== null && !extractedTexts.includes(currentText)) {
55
+ extractedTexts.push(currentText);
56
+ }
57
+ }
58
+
59
+ // Verify that we extracted text progressively
60
+ expect(extractedTexts.length).toBeGreaterThan(5); // Should have many incremental updates
61
+
62
+ // The final extracted text should be the complete text value
63
+ const finalText = parser.getExtractedText();
64
+ expect(finalText).toBe("You're welcome! Enjoy your browsing, and I'm here if you need anything!");
65
+
66
+ // Verify intermediate extractions show progressive text
67
+ // The text should start appearing once the "text" field value starts streaming
68
+ const hasPartialText = extractedTexts.some(text =>
69
+ text.includes("You're") || text.includes("welcome")
70
+ );
71
+ expect(hasPartialText).toBe(true);
72
+ });
73
+
74
+ it("should handle incomplete JSON gracefully", () => {
75
+ const chunks = [
76
+ '{\n',
77
+ ' "action": "message",\n',
78
+ ' "text": "',
79
+ 'Hello',
80
+ ' ',
81
+ 'world'
82
+ // Note: No closing quote or brace
83
+ ];
84
+
85
+ const parser = createJsonStreamParser();
86
+ let accumulated = "";
87
+
88
+ for (const chunk of chunks) {
89
+ accumulated += chunk;
90
+ parser.processChunk(accumulated);
91
+ }
92
+
93
+ // Should still extract partial text
94
+ const result = parser.getExtractedText();
95
+ expect(result).toBe("Hello world");
96
+ });
97
+
98
+ it("should handle complete JSON in one chunk", () => {
99
+ const completeJson = '{"action": "message", "text": "Hello world!"}';
100
+
101
+ const parser = createJsonStreamParser();
102
+ const result = parser.processChunk(completeJson);
103
+
104
+ expect(result).toBe("Hello world!");
105
+ expect(parser.getExtractedText()).toBe("Hello world!");
106
+ });
107
+
108
+ it("should handle the exact stream format from user example", () => {
109
+ // Extract just the text chunks from the SSE stream
110
+ const textChunks = [
111
+ '{\n',
112
+ ' ',
113
+ ' "',
114
+ 'action',
115
+ '":',
116
+ ' "',
117
+ 'message',
118
+ '",\n',
119
+ ' ',
120
+ ' "',
121
+ 'text',
122
+ '":',
123
+ ' "',
124
+ 'You\'re',
125
+ ' welcome',
126
+ '!',
127
+ ' Enjoy',
128
+ ' your',
129
+ ' browsing',
130
+ ',',
131
+ ' and',
132
+ ' I\'m',
133
+ ' here',
134
+ ' if',
135
+ ' you',
136
+ ' need',
137
+ ' anything',
138
+ '!"\n',
139
+ '}'
140
+ ];
141
+
142
+ const parser = createJsonStreamParser();
143
+ let accumulated = "";
144
+ const allExtractedTexts: (string | null)[] = [];
145
+
146
+ for (const chunk of textChunks) {
147
+ accumulated += chunk;
148
+ const result = parser.processChunk(accumulated);
149
+ allExtractedTexts.push(result);
150
+ }
151
+
152
+ // Should have many non-null results (incremental updates)
153
+ const nonNullResults = allExtractedTexts.filter(r => r !== null);
154
+ expect(nonNullResults.length).toBeGreaterThan(10);
155
+
156
+ // Final result should be the complete text
157
+ const finalResult = parser.getExtractedText();
158
+ expect(finalResult).toBe("You're welcome! Enjoy your browsing, and I'm here if you need anything!");
159
+ });
160
+ });
@@ -1,4 +1,5 @@
1
- import { AgentWidgetReasoning, AgentWidgetToolCall } from "../types";
1
+ import { AgentWidgetReasoning, AgentWidgetToolCall, AgentWidgetStreamParser, AgentWidgetStreamParserResult } from "../types";
2
+ import { parse as parsePartialJson, STR, OBJ } from "partial-json";
2
3
 
3
4
  export const formatUnknownValue = (value: unknown): string => {
4
5
  if (value === null) return "null";
@@ -73,6 +74,256 @@ export const describeToolTitle = (tool: AgentWidgetToolCall) => {
73
74
  return "Using tool...";
74
75
  };
75
76
 
77
+ /**
78
+ * Creates a regex-based parser for extracting text from JSON streams.
79
+ * This is a simpler alternative to schema-stream that uses regex to extract
80
+ * the 'text' field incrementally as JSON streams in.
81
+ *
82
+ * This can be used as an alternative parser option.
83
+ */
84
+ const createRegexJsonParserInternal = (): {
85
+ processChunk(accumulatedContent: string): Promise<AgentWidgetStreamParserResult | string | null>;
86
+ getExtractedText(): string | null;
87
+ close?(): Promise<void>;
88
+ } => {
89
+ let extractedText: string | null = null;
90
+ let processedLength = 0;
91
+
92
+ // Regex-based extraction for incremental JSON parsing
93
+ const extractTextFromIncompleteJson = (jsonString: string): string | null => {
94
+ // Look for "text": "value" pattern, handling incomplete strings
95
+ // Match: "text": " followed by any characters (including incomplete)
96
+ const textFieldRegex = /"text"\s*:\s*"((?:[^"\\]|\\.|")*?)"/;
97
+ const match = jsonString.match(textFieldRegex);
98
+
99
+ if (match && match[1]) {
100
+ // Unescape the string value
101
+ try {
102
+ // Replace escaped characters
103
+ let unescaped = match[1]
104
+ .replace(/\\n/g, '\n')
105
+ .replace(/\\r/g, '\r')
106
+ .replace(/\\t/g, '\t')
107
+ .replace(/\\"/g, '"')
108
+ .replace(/\\\\/g, '\\');
109
+ return unescaped;
110
+ } catch {
111
+ return match[1];
112
+ }
113
+ }
114
+
115
+ // Also try to match incomplete text field (text field that hasn't closed yet)
116
+ // Look for "text": " followed by content that may not be closed
117
+ const incompleteTextFieldRegex = /"text"\s*:\s*"((?:[^"\\]|\\.)*)/;
118
+ const incompleteMatch = jsonString.match(incompleteTextFieldRegex);
119
+
120
+ if (incompleteMatch && incompleteMatch[1]) {
121
+ // Unescape the partial string value
122
+ try {
123
+ let unescaped = incompleteMatch[1]
124
+ .replace(/\\n/g, '\n')
125
+ .replace(/\\r/g, '\r')
126
+ .replace(/\\t/g, '\t')
127
+ .replace(/\\"/g, '"')
128
+ .replace(/\\\\/g, '\\');
129
+ return unescaped;
130
+ } catch {
131
+ return incompleteMatch[1];
132
+ }
133
+ }
134
+
135
+ return null;
136
+ };
137
+
138
+ return {
139
+ getExtractedText: () => extractedText,
140
+ processChunk: async (accumulatedContent: string): Promise<AgentWidgetStreamParserResult | string | null> => {
141
+ // Skip if no new content
142
+ if (accumulatedContent.length <= processedLength) {
143
+ return extractedText ? { text: extractedText, raw: accumulatedContent } : extractedText;
144
+ }
145
+
146
+ // Validate that the accumulated content looks like valid JSON
147
+ const trimmed = accumulatedContent.trim();
148
+ if (!trimmed.startsWith('{') && !trimmed.startsWith('[')) {
149
+ return null;
150
+ }
151
+
152
+ // Try to extract text field using regex
153
+ const extracted = extractTextFromIncompleteJson(accumulatedContent);
154
+ if (extracted !== null) {
155
+ extractedText = extracted;
156
+ }
157
+
158
+ // Update processed length
159
+ processedLength = accumulatedContent.length;
160
+
161
+ // Return both the extracted text and raw JSON
162
+ return extractedText ? {
163
+ text: extractedText,
164
+ raw: accumulatedContent
165
+ } : extractedText;
166
+ },
167
+ close: async () => {
168
+ // No cleanup needed for regex-based parser
169
+ }
170
+ };
171
+ };
172
+
173
+ /**
174
+ * Extracts the text field from JSON (works with partial JSON during streaming).
175
+ * For complete JSON, uses fast path. For incomplete JSON, returns null (use stateful parser in client.ts).
176
+ *
177
+ * @param jsonString - The JSON string (can be partial/incomplete during streaming)
178
+ * @returns The extracted text value, or null if not found or invalid
179
+ */
180
+ export const extractTextFromJson = (jsonString: string): string | null => {
181
+ try {
182
+ // Try to parse complete JSON first (fast path)
183
+ const parsed = JSON.parse(jsonString);
184
+ if (parsed && typeof parsed === "object" && typeof parsed.text === "string") {
185
+ return parsed.text;
186
+ }
187
+ } catch {
188
+ // For incomplete JSON, return null - use stateful parser in client.ts
189
+ return null;
190
+ }
191
+ return null;
192
+ };
193
+
194
+ /**
195
+ * Plain text parser - passes through text as-is without any parsing.
196
+ * This is the default parser.
197
+ */
198
+ export const createPlainTextParser = (): AgentWidgetStreamParser => {
199
+ const parser: AgentWidgetStreamParser = {
200
+ processChunk: (accumulatedContent: string): string | null => {
201
+ // Always return null to indicate this isn't a structured format
202
+ // Content will be displayed as plain text
203
+ return null;
204
+ },
205
+ getExtractedText: (): string | null => {
206
+ return null;
207
+ }
208
+ };
209
+ // Mark this as a plain text parser
210
+ (parser as any).__isPlainTextParser = true;
211
+ return parser;
212
+ };
213
+
214
+ /**
215
+ * JSON parser using regex-based extraction.
216
+ * Extracts the 'text' field from JSON responses using regex patterns.
217
+ * This is a simpler regex-based alternative to createJsonStreamParser.
218
+ * Less robust for complex/malformed JSON but has no external dependencies.
219
+ */
220
+ export const createRegexJsonParser = (): AgentWidgetStreamParser => {
221
+ const regexParser = createRegexJsonParserInternal();
222
+
223
+ return {
224
+ processChunk: async (accumulatedContent: string): Promise<AgentWidgetStreamParserResult | string | null> => {
225
+ // Only process if it looks like JSON
226
+ const trimmed = accumulatedContent.trim();
227
+ if (!trimmed.startsWith('{') && !trimmed.startsWith('[')) {
228
+ return null;
229
+ }
230
+ return regexParser.processChunk(accumulatedContent);
231
+ },
232
+ getExtractedText: regexParser.getExtractedText.bind(regexParser),
233
+ close: regexParser.close?.bind(regexParser)
234
+ };
235
+ };
236
+
237
+ /**
238
+ * JSON stream parser using partial-json library.
239
+ * Extracts the 'text' field from JSON responses using the partial-json library,
240
+ * which is specifically designed for parsing incomplete JSON from LLMs.
241
+ * This is the recommended parser as it's more robust than regex.
242
+ *
243
+ * Library: https://github.com/promplate/partial-json-parser-js
244
+ */
245
+ export const createJsonStreamParser = (): AgentWidgetStreamParser => {
246
+ let extractedText: string | null = null;
247
+ let processedLength = 0;
248
+
249
+ return {
250
+ getExtractedText: () => extractedText,
251
+ processChunk: (accumulatedContent: string): AgentWidgetStreamParserResult | string | null => {
252
+ // Validate that the accumulated content looks like JSON
253
+ const trimmed = accumulatedContent.trim();
254
+ if (!trimmed.startsWith('{') && !trimmed.startsWith('[')) {
255
+ return null;
256
+ }
257
+
258
+ // Skip if no new content
259
+ if (accumulatedContent.length <= processedLength) {
260
+ return extractedText ? { text: extractedText, raw: accumulatedContent } : extractedText;
261
+ }
262
+
263
+ try {
264
+ // Parse partial JSON - allow partial strings and objects
265
+ // STR | OBJ allows incomplete strings and objects during streaming
266
+ const parsed = parsePartialJson(accumulatedContent, STR | OBJ);
267
+
268
+ // Extract text field if available
269
+ if (parsed && typeof parsed === "object" && typeof parsed.text === "string") {
270
+ extractedText = parsed.text;
271
+ }
272
+ } catch (error) {
273
+ // If parsing fails completely, keep the last extracted text
274
+ // This can happen with very malformed JSON
275
+ }
276
+
277
+ // Update processed length
278
+ processedLength = accumulatedContent.length;
279
+
280
+ // Return both the extracted text and raw JSON
281
+ return extractedText ? {
282
+ text: extractedText,
283
+ raw: accumulatedContent
284
+ } : extractedText;
285
+ },
286
+ close: () => {
287
+ // No cleanup needed
288
+ }
289
+ };
290
+ };
291
+
292
+ /**
293
+ * XML stream parser.
294
+ * Extracts text from <text>...</text> tags in XML responses.
295
+ */
296
+ export const createXmlParser = (): AgentWidgetStreamParser => {
297
+ let extractedText: string | null = null;
298
+
299
+ return {
300
+ processChunk: (accumulatedContent: string): AgentWidgetStreamParserResult | string | null => {
301
+ // Return null if not XML format
302
+ const trimmed = accumulatedContent.trim();
303
+ if (!trimmed.startsWith('<')) {
304
+ return null;
305
+ }
306
+
307
+ // Extract text from <text>...</text> tags
308
+ // Handle both <text>content</text> and <text attr="value">content</text>
309
+ const match = accumulatedContent.match(/<text[^>]*>([\s\S]*?)<\/text>/);
310
+ if (match && match[1]) {
311
+ extractedText = match[1];
312
+ // For XML, we typically don't need the raw content for middleware
313
+ // but we can include it for consistency
314
+ return { text: extractedText, raw: accumulatedContent };
315
+ }
316
+
317
+ return null;
318
+ },
319
+ getExtractedText: (): string | null => {
320
+ return extractedText;
321
+ }
322
+ };
323
+ };
324
+
325
+
326
+
76
327
 
77
328
 
78
329
 
@@ -12,3 +12,5 @@ export const positionMap: Record<
12
12
 
13
13
 
14
14
 
15
+
16
+
@@ -20,3 +20,5 @@ export const applyThemeVariables = (
20
20
 
21
21
 
22
22
 
23
+
24
+