vanilla-agent 1.12.0 → 1.14.0

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/dist/widget.css CHANGED
@@ -861,3 +861,80 @@ form:focus-within textarea {
861
861
  color: inherit;
862
862
  text-decoration: underline;
863
863
  }
864
+
865
+ /* Markdown paragraph styles */
866
+ .vanilla-message-bubble p {
867
+ margin: 0;
868
+ }
869
+
870
+ .vanilla-message-bubble p + p {
871
+ margin-top: 0.75rem;
872
+ }
873
+
874
+ /* Markdown list styles */
875
+ .vanilla-message-bubble ul,
876
+ .vanilla-message-bubble ol {
877
+ margin: 0.5rem 0;
878
+ padding-left: 1.5rem;
879
+ }
880
+
881
+ .vanilla-message-bubble ul {
882
+ list-style-type: disc;
883
+ }
884
+
885
+ .vanilla-message-bubble ol {
886
+ list-style-type: decimal;
887
+ }
888
+
889
+ .vanilla-message-bubble li {
890
+ margin: 0.25rem 0;
891
+ padding-left: 0.25rem;
892
+ }
893
+
894
+ /* Nested lists */
895
+ .vanilla-message-bubble ul ul,
896
+ .vanilla-message-bubble ol ul {
897
+ list-style-type: circle;
898
+ margin: 0.25rem 0;
899
+ }
900
+
901
+ .vanilla-message-bubble ul ul ul,
902
+ .vanilla-message-bubble ol ul ul,
903
+ .vanilla-message-bubble ul ol ul,
904
+ .vanilla-message-bubble ol ol ul {
905
+ list-style-type: square;
906
+ }
907
+
908
+ .vanilla-message-bubble ul ol,
909
+ .vanilla-message-bubble ol ol {
910
+ list-style-type: lower-alpha;
911
+ margin: 0.25rem 0;
912
+ }
913
+
914
+ /* Ensure user message paragraphs and lists have proper styling too */
915
+ .vanilla-message-user-bubble p {
916
+ margin: 0;
917
+ }
918
+
919
+ .vanilla-message-user-bubble p + p {
920
+ margin-top: 0.75rem;
921
+ }
922
+
923
+ .vanilla-message-user-bubble ul,
924
+ .vanilla-message-user-bubble ol {
925
+ margin: 0.5rem 0;
926
+ padding-left: 1.5rem;
927
+ }
928
+
929
+ .vanilla-message-user-bubble ul {
930
+ list-style-type: disc;
931
+ }
932
+
933
+ .vanilla-message-user-bubble ol {
934
+ list-style-type: decimal;
935
+ }
936
+
937
+ .vanilla-message-user-bubble li {
938
+ margin: 0.25rem 0;
939
+ padding-left: 0.25rem;
940
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vanilla-agent",
3
- "version": "1.12.0",
3
+ "version": "1.14.0",
4
4
  "description": "Themeable, plugable streaming agent widget for websites, in plain JS with support for voice input and reasoning / tool output.",
5
5
  "type": "module",
6
6
  "main": "dist/index.cjs",
package/src/client.ts CHANGED
@@ -876,6 +876,7 @@ export class AgentWidgetClient {
876
876
  // Try to extract text from final structured content
877
877
  const parser = streamParsers.get(assistant.id);
878
878
  let extractedText: string | null = null;
879
+ let asyncPending = false;
879
880
 
880
881
  if (parser) {
881
882
  // First check if parser already has extracted text
@@ -890,6 +891,7 @@ export class AgentWidgetClient {
890
891
  // Try parser.processChunk as last resort
891
892
  const parsedResult = parser.processChunk(contentToProcess);
892
893
  if (parsedResult instanceof Promise) {
894
+ asyncPending = true;
893
895
  parsedResult.then((result) => {
894
896
  // Extract text from result (could be string or object)
895
897
  const text = typeof result === 'string' ? result : result?.text ?? null;
@@ -898,6 +900,9 @@ export class AgentWidgetClient {
898
900
  if (currentAssistant && currentAssistant.id === assistant.id) {
899
901
  currentAssistant.content = text;
900
902
  currentAssistant.streaming = false;
903
+ // Clean up
904
+ streamParsers.delete(currentAssistant.id);
905
+ rawContentBuffers.delete(currentAssistant.id);
901
906
  emitMessage(currentAssistant);
902
907
  }
903
908
  }
@@ -909,26 +914,29 @@ export class AgentWidgetClient {
909
914
  }
910
915
  }
911
916
 
912
- // Set content: use extracted text if available, otherwise use raw content
913
- if (extractedText !== null && extractedText.trim() !== "") {
914
- assistant.content = extractedText;
915
- } else if (!rawContentBuffers.has(assistant.id)) {
916
- // Only use raw final content if we didn't accumulate chunks
917
- assistant.content = ensureStringContent(finalContent);
918
- }
919
-
920
- // Clean up parser and buffer
921
- const parserToClose = streamParsers.get(assistant.id);
922
- if (parserToClose) {
923
- const closeResult = parserToClose.close?.();
924
- if (closeResult instanceof Promise) {
925
- closeResult.catch(() => {});
917
+ // Skip sync emit if we're waiting on async parser
918
+ if (!asyncPending) {
919
+ // Set content: use extracted text if available, otherwise use raw content
920
+ if (extractedText !== null && extractedText.trim() !== "") {
921
+ assistant.content = extractedText;
922
+ } else if (!rawContentBuffers.has(assistant.id)) {
923
+ // Only use raw final content if we didn't accumulate chunks
924
+ assistant.content = ensureStringContent(finalContent);
926
925
  }
927
- streamParsers.delete(assistant.id);
926
+
927
+ // Clean up parser and buffer
928
+ const parserToClose = streamParsers.get(assistant.id);
929
+ if (parserToClose) {
930
+ const closeResult = parserToClose.close?.();
931
+ if (closeResult instanceof Promise) {
932
+ closeResult.catch(() => {});
933
+ }
934
+ streamParsers.delete(assistant.id);
935
+ }
936
+ rawContentBuffers.delete(assistant.id);
937
+ assistant.streaming = false;
938
+ emitMessage(assistant);
928
939
  }
929
- rawContentBuffers.delete(assistant.id);
930
- assistant.streaming = false;
931
- emitMessage(assistant);
932
940
  }
933
941
  }
934
942
  } else if (payloadType === "step_complete") {
@@ -945,6 +953,7 @@ export class AgentWidgetClient {
945
953
  // Check if we already have extracted text from streaming
946
954
  const parser = streamParsers.get(assistant.id);
947
955
  let hasExtractedText = false;
956
+ let asyncPending = false;
948
957
 
949
958
  if (parser) {
950
959
  // First check if parser already extracted text during streaming
@@ -971,6 +980,7 @@ export class AgentWidgetClient {
971
980
  // Try parser
972
981
  const parsedResult = parser.processChunk(contentToProcess);
973
982
  if (parsedResult instanceof Promise) {
983
+ asyncPending = true;
974
984
  parsedResult.then((result) => {
975
985
  // Extract text from result (could be string or object)
976
986
  const text = typeof result === 'string' ? result : result?.text ?? null;
@@ -980,22 +990,27 @@ export class AgentWidgetClient {
980
990
  if (currentAssistant && currentAssistant.id === assistant.id) {
981
991
  currentAssistant.content = text;
982
992
  currentAssistant.streaming = false;
993
+ // Clean up
994
+ streamParsers.delete(currentAssistant.id);
995
+ rawContentBuffers.delete(currentAssistant.id);
983
996
  emitMessage(currentAssistant);
984
997
  }
985
998
  } else {
986
999
  // No extracted text - check if we should show raw content
987
1000
  const finalExtractedText = parser.getExtractedText();
988
- if (finalExtractedText === null || finalExtractedText.trim() === "") {
989
- // No extracted text available - show raw content only if no streaming happened
990
- const currentAssistant = assistantMessage;
991
- if (currentAssistant && currentAssistant.id === assistant.id) {
1001
+ const currentAssistant = assistantMessage;
1002
+ if (currentAssistant && currentAssistant.id === assistant.id) {
1003
+ if (finalExtractedText !== null && finalExtractedText.trim() !== "") {
1004
+ currentAssistant.content = finalExtractedText;
1005
+ } else if (!rawContentBuffers.has(currentAssistant.id)) {
992
1006
  // Only show raw content if we never had any extracted text
993
- if (!rawContentBuffers.has(assistant.id)) {
994
- currentAssistant.content = ensureStringContent(finalContent);
995
- }
996
- currentAssistant.streaming = false;
997
- emitMessage(currentAssistant);
1007
+ currentAssistant.content = ensureStringContent(finalContent);
998
1008
  }
1009
+ currentAssistant.streaming = false;
1010
+ // Clean up
1011
+ streamParsers.delete(currentAssistant.id);
1012
+ rawContentBuffers.delete(currentAssistant.id);
1013
+ emitMessage(currentAssistant);
999
1014
  }
1000
1015
  }
1001
1016
  });
@@ -1019,29 +1034,32 @@ export class AgentWidgetClient {
1019
1034
  }
1020
1035
  }
1021
1036
 
1022
- // Ensure rawContent is set even if there's no parser (for action parsing)
1023
- if (!assistant.rawContent) {
1024
- const rawBuffer = rawContentBuffers.get(assistant.id);
1025
- assistant.rawContent = rawBuffer ?? ensureStringContent(finalContent);
1026
- }
1027
-
1028
- // Only show raw content if we never extracted any text and no buffer was used
1029
- if (!hasExtractedText && !rawContentBuffers.has(assistant.id)) {
1030
- // No extracted text and no streaming happened - show raw content
1031
- assistant.content = ensureStringContent(finalContent);
1032
- }
1033
-
1034
- // Clean up parser and buffer
1035
- if (parser) {
1036
- const closeResult = parser.close?.();
1037
- if (closeResult instanceof Promise) {
1038
- closeResult.catch(() => {});
1037
+ // Skip sync emit if we're waiting on async parser
1038
+ if (!asyncPending) {
1039
+ // Ensure rawContent is set even if there's no parser (for action parsing)
1040
+ if (!assistant.rawContent) {
1041
+ const rawBuffer = rawContentBuffers.get(assistant.id);
1042
+ assistant.rawContent = rawBuffer ?? ensureStringContent(finalContent);
1039
1043
  }
1044
+
1045
+ // Only show raw content if we never extracted any text and no buffer was used
1046
+ if (!hasExtractedText && !rawContentBuffers.has(assistant.id)) {
1047
+ // No extracted text and no streaming happened - show raw content
1048
+ assistant.content = ensureStringContent(finalContent);
1049
+ }
1050
+
1051
+ // Clean up parser and buffer
1052
+ if (parser) {
1053
+ const closeResult = parser.close?.();
1054
+ if (closeResult instanceof Promise) {
1055
+ closeResult.catch(() => {});
1056
+ }
1057
+ }
1058
+ streamParsers.delete(assistant.id);
1059
+ rawContentBuffers.delete(assistant.id);
1060
+ assistant.streaming = false;
1061
+ emitMessage(assistant);
1040
1062
  }
1041
- streamParsers.delete(assistant.id);
1042
- rawContentBuffers.delete(assistant.id);
1043
- assistant.streaming = false;
1044
- emitMessage(assistant);
1045
1063
  } else {
1046
1064
  // No final content, just mark as complete and clean up
1047
1065
  streamParsers.delete(assistant.id);
@@ -1090,12 +1108,20 @@ export class AgentWidgetClient {
1090
1108
  // Clean up parser and buffer
1091
1109
  streamParsers.delete(assistant.id);
1092
1110
  rawContentBuffers.delete(assistant.id);
1093
- if (displayContent !== assistant.content) {
1111
+
1112
+ // Only emit if something actually changed to avoid flicker
1113
+ const contentChanged = displayContent !== assistant.content;
1114
+ const streamingChanged = assistant.streaming !== false;
1115
+
1116
+ if (contentChanged) {
1094
1117
  assistant.content = displayContent;
1095
- emitMessage(assistant);
1096
1118
  }
1097
1119
  assistant.streaming = false;
1098
- emitMessage(assistant);
1120
+
1121
+ // Only emit if content or streaming state changed
1122
+ if (contentChanged || streamingChanged) {
1123
+ emitMessage(assistant);
1124
+ }
1099
1125
  } else {
1100
1126
  // No final content, just mark as complete and clean up
1101
1127
  if (assistantMessage !== null) {
@@ -1104,8 +1130,12 @@ export class AgentWidgetClient {
1104
1130
  const msg: AgentWidgetMessage = assistantMessage;
1105
1131
  streamParsers.delete(msg.id);
1106
1132
  rawContentBuffers.delete(msg.id);
1107
- msg.streaming = false;
1108
- emitMessage(msg);
1133
+
1134
+ // Only emit if streaming state changed
1135
+ if (msg.streaming !== false) {
1136
+ msg.streaming = false;
1137
+ emitMessage(msg);
1138
+ }
1109
1139
  }
1110
1140
  }
1111
1141
  onEvent({ type: "status", status: "idle" });
@@ -861,3 +861,80 @@ form:focus-within textarea {
861
861
  color: inherit;
862
862
  text-decoration: underline;
863
863
  }
864
+
865
+ /* Markdown paragraph styles */
866
+ .vanilla-message-bubble p {
867
+ margin: 0;
868
+ }
869
+
870
+ .vanilla-message-bubble p + p {
871
+ margin-top: 0.75rem;
872
+ }
873
+
874
+ /* Markdown list styles */
875
+ .vanilla-message-bubble ul,
876
+ .vanilla-message-bubble ol {
877
+ margin: 0.5rem 0;
878
+ padding-left: 1.5rem;
879
+ }
880
+
881
+ .vanilla-message-bubble ul {
882
+ list-style-type: disc;
883
+ }
884
+
885
+ .vanilla-message-bubble ol {
886
+ list-style-type: decimal;
887
+ }
888
+
889
+ .vanilla-message-bubble li {
890
+ margin: 0.25rem 0;
891
+ padding-left: 0.25rem;
892
+ }
893
+
894
+ /* Nested lists */
895
+ .vanilla-message-bubble ul ul,
896
+ .vanilla-message-bubble ol ul {
897
+ list-style-type: circle;
898
+ margin: 0.25rem 0;
899
+ }
900
+
901
+ .vanilla-message-bubble ul ul ul,
902
+ .vanilla-message-bubble ol ul ul,
903
+ .vanilla-message-bubble ul ol ul,
904
+ .vanilla-message-bubble ol ol ul {
905
+ list-style-type: square;
906
+ }
907
+
908
+ .vanilla-message-bubble ul ol,
909
+ .vanilla-message-bubble ol ol {
910
+ list-style-type: lower-alpha;
911
+ margin: 0.25rem 0;
912
+ }
913
+
914
+ /* Ensure user message paragraphs and lists have proper styling too */
915
+ .vanilla-message-user-bubble p {
916
+ margin: 0;
917
+ }
918
+
919
+ .vanilla-message-user-bubble p + p {
920
+ margin-top: 0.75rem;
921
+ }
922
+
923
+ .vanilla-message-user-bubble ul,
924
+ .vanilla-message-user-bubble ol {
925
+ margin: 0.5rem 0;
926
+ padding-left: 1.5rem;
927
+ }
928
+
929
+ .vanilla-message-user-bubble ul {
930
+ list-style-type: disc;
931
+ }
932
+
933
+ .vanilla-message-user-bubble ol {
934
+ list-style-type: decimal;
935
+ }
936
+
937
+ .vanilla-message-user-bubble li {
938
+ margin: 0.25rem 0;
939
+ padding-left: 0.25rem;
940
+ }
package/src/ui.ts CHANGED
@@ -764,8 +764,8 @@ export const createAgentExperience = (
764
764
  };
765
765
 
766
766
 
767
- // Message rendering with plugin support
768
- const renderMessagesWithPlugins = (
767
+ // Message rendering with plugin support (implementation)
768
+ const renderMessagesWithPluginsImpl = (
769
769
  container: HTMLElement,
770
770
  messages: AgentWidgetMessage[],
771
771
  transform: MessageTransform
@@ -915,8 +915,13 @@ export const createAgentExperience = (
915
915
  const hasStreamingAssistantMessage = messages.some(
916
916
  (msg) => msg.role === "assistant" && msg.streaming
917
917
  );
918
+
919
+ // Also check if there's a recently completed assistant message (streaming just ended)
920
+ // This prevents flicker when the message completes but isStreaming hasn't updated yet
921
+ const lastMessage = messages[messages.length - 1];
922
+ const hasRecentAssistantResponse = lastMessage?.role === "assistant" && !lastMessage.streaming;
918
923
 
919
- if (isStreaming && messages.some((msg) => msg.role === "user") && !hasStreamingAssistantMessage) {
924
+ if (isStreaming && messages.some((msg) => msg.role === "user") && !hasStreamingAssistantMessage && !hasRecentAssistantResponse) {
920
925
  const typingIndicator = createTypingIndicator();
921
926
 
922
927
  // Create a bubble wrapper for the typing indicator (similar to assistant messages)
@@ -956,6 +961,9 @@ export const createAgentExperience = (
956
961
  });
957
962
  };
958
963
 
964
+ // Alias for clarity - the implementation handles flicker prevention via typing indicator logic
965
+ const renderMessagesWithPlugins = renderMessagesWithPluginsImpl;
966
+
959
967
  const updateOpenState = () => {
960
968
  if (!launcherEnabled) return;
961
969
  if (open) {
@@ -1017,7 +1025,8 @@ export const createAgentExperience = (
1017
1025
  };
1018
1026
 
1019
1027
  const setComposerDisabled = (disabled: boolean) => {
1020
- textarea.disabled = disabled;
1028
+ // Keep textarea always enabled so users can type while streaming
1029
+ // Only disable submit controls to prevent sending during streaming
1021
1030
  sendButton.disabled = disabled;
1022
1031
  if (micButton) {
1023
1032
  micButton.disabled = disabled;
@@ -792,6 +792,7 @@ function generateScriptInstallerCode(config: any): string {
792
792
  lines.push(` debug: ${config.debug},`);
793
793
  }
794
794
 
795
+ lines.push(" postprocessMessage: ({ text }) => window.AgentWidget.markdownPostprocessor(text)");
795
796
  lines.push(" }");
796
797
  lines.push(" };");
797
798
  lines.push("</script>");
@@ -925,6 +926,7 @@ function generateScriptManualCode(config: any): string {
925
926
  lines.push(` debug: ${config.debug},`);
926
927
  }
927
928
 
929
+ lines.push(" postprocessMessage: ({ text }) => window.AgentWidget.markdownPostprocessor(text)");
928
930
  lines.push(" }");
929
931
  lines.push(" });");
930
932
  lines.push("</script>");
@@ -1181,7 +1183,8 @@ function generateScriptAdvancedCode(config: any): string {
1181
1183
  lines.push(" requestMiddleware: ({ payload }) => ({");
1182
1184
  lines.push(" ...payload,");
1183
1185
  lines.push(" metadata: domContextProvider()");
1184
- lines.push(" })");
1186
+ lines.push(" }),");
1187
+ lines.push(" postprocessMessage: ({ text }) => agentWidget.markdownPostprocessor(text)");
1185
1188
  lines.push(" });");
1186
1189
  lines.push("");
1187
1190
  lines.push(" // Initialize widget when DOM is loaded");
@@ -1208,6 +1211,7 @@ function generateScriptAdvancedCode(config: any): string {
1208
1211
  lines.push(" // Initialize widget with DOM context");
1209
1212
  lines.push(" const handle = agentWidget.initAgentWidget({");
1210
1213
  lines.push(" target: 'body',");
1214
+ lines.push(" useShadowDom: false,");
1211
1215
  lines.push(" config: widgetConfig");
1212
1216
  lines.push(" });");
1213
1217
  lines.push("");
@@ -1,6 +1,19 @@
1
1
  import { AgentWidgetReasoning, AgentWidgetToolCall, AgentWidgetStreamParser, AgentWidgetStreamParserResult } from "../types";
2
2
  import { parse as parsePartialJson, STR, OBJ } from "partial-json";
3
3
 
4
+ /**
5
+ * Unescapes JSON string escape sequences that LLMs often double-escape.
6
+ * Converts literal \n, \r, \t sequences to actual control characters.
7
+ */
8
+ const unescapeJsonString = (str: string): string => {
9
+ return str
10
+ .replace(/\\n/g, '\n')
11
+ .replace(/\\r/g, '\r')
12
+ .replace(/\\t/g, '\t')
13
+ .replace(/\\"/g, '"')
14
+ .replace(/\\\\/g, '\\');
15
+ };
16
+
4
17
  export const formatUnknownValue = (value: unknown): string => {
5
18
  if (value === null) return "null";
6
19
  if (value === undefined) return "";
@@ -277,7 +290,7 @@ export const createJsonStreamParser = (): AgentWidgetStreamParser => {
277
290
  // Check for component directives - extract text if present for combined text+component
278
291
  if (parsed.component && typeof parsed.component === "string") {
279
292
  // For component directives, extract text if present, otherwise empty
280
- extractedText = typeof parsed.text === "string" ? parsed.text : "";
293
+ extractedText = typeof parsed.text === "string" ? unescapeJsonString(parsed.text) : "";
281
294
  }
282
295
  // Check for form directives - these also don't have text fields
283
296
  else if (parsed.type === "init" && parsed.form) {
@@ -286,7 +299,7 @@ export const createJsonStreamParser = (): AgentWidgetStreamParser => {
286
299
  }
287
300
  // Extract text field if available
288
301
  else if (typeof parsed.text === "string") {
289
- extractedText = parsed.text;
302
+ extractedText = unescapeJsonString(parsed.text);
290
303
  }
291
304
  }
292
305
  } catch (error) {
@@ -332,10 +345,15 @@ export const createFlexibleJsonStreamParser = (
332
345
  const defaultExtractor = (parsed: any): string | null => {
333
346
  if (!parsed || typeof parsed !== "object") return null;
334
347
 
348
+ // Helper to safely extract and unescape text
349
+ const getText = (value: any): string | null => {
350
+ return typeof value === "string" ? unescapeJsonString(value) : null;
351
+ };
352
+
335
353
  // Check for component directives - extract text if present for combined text+component
336
354
  if (parsed.component && typeof parsed.component === "string") {
337
355
  // For component directives, extract text if present, otherwise empty
338
- return typeof parsed.text === "string" ? parsed.text : "";
356
+ return typeof parsed.text === "string" ? unescapeJsonString(parsed.text) : "";
339
357
  }
340
358
 
341
359
  // Check for form directives - these also don't have text fields
@@ -348,18 +366,18 @@ export const createFlexibleJsonStreamParser = (
348
366
  if (parsed.action) {
349
367
  switch (parsed.action) {
350
368
  case 'nav_then_click':
351
- return parsed.on_load_text || parsed.text || null;
369
+ return getText(parsed.on_load_text) || getText(parsed.text) || null;
352
370
  case 'message':
353
371
  case 'message_and_click':
354
372
  case 'checkout':
355
- return parsed.text || null;
373
+ return getText(parsed.text) || null;
356
374
  default:
357
- return parsed.text || parsed.display_text || parsed.message || null;
375
+ return getText(parsed.text) || getText(parsed.display_text) || getText(parsed.message) || null;
358
376
  }
359
377
  }
360
378
 
361
379
  // Fallback to common text field names
362
- return parsed.text || parsed.display_text || parsed.message || parsed.content || null;
380
+ return getText(parsed.text) || getText(parsed.display_text) || getText(parsed.message) || getText(parsed.content) || null;
363
381
  };
364
382
 
365
383
  const extractText = textExtractor || defaultExtractor;