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/README.md +219 -3
- package/dist/index.cjs +7 -5
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +131 -1
- package/dist/index.d.ts +131 -1
- package/dist/index.global.js +51 -48
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +7 -5
- package/dist/index.js.map +1 -1
- package/dist/widget.css +21 -0
- package/package.json +4 -2
- package/src/client.test.ts +197 -0
- package/src/client.ts +330 -15
- package/src/components/forms.ts +2 -0
- package/src/components/message-bubble.ts +9 -4
- package/src/components/messages.ts +5 -3
- package/src/components/panel.ts +1 -0
- package/src/components/reasoning-bubble.ts +26 -8
- package/src/components/tool-bubble.ts +139 -22
- package/src/index.ts +9 -1
- package/src/plugins/registry.ts +2 -0
- package/src/plugins/types.ts +2 -0
- package/src/runtime/init.ts +4 -1
- package/src/session.ts +4 -0
- package/src/styles/widget.css +21 -0
- package/src/types.ts +107 -0
- package/src/ui.ts +145 -5
- package/src/utils/constants.ts +2 -0
- package/src/utils/dom.ts +2 -0
- package/src/utils/formatting.test.ts +160 -0
- package/src/utils/formatting.ts +252 -1
- package/src/utils/positioning.ts +2 -0
- package/src/utils/theme.ts +2 -0
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
|
|
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
|
-
|
|
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();
|
package/src/utils/constants.ts
CHANGED
package/src/utils/dom.ts
CHANGED
|
@@ -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
|
+
});
|
package/src/utils/formatting.ts
CHANGED
|
@@ -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
|
|
package/src/utils/positioning.ts
CHANGED