andy-widget-react 2.1.0 → 2.2.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/index.esm.js CHANGED
@@ -758,6 +758,84 @@ const initializeConfig = (packageName = 'unknown') => {
758
758
  }
759
759
  };
760
760
 
761
+ /** Stream a reply while the server restores this session's conversation history. */
762
+ const sendMessageForWidget = async (embedId, sessionId, message, options = {}) => {
763
+ const response = await fetch(`${getApiBaseUrl()}/chat/stream`, {
764
+ method: 'POST',
765
+ headers: { 'Content-Type': 'application/json' },
766
+ signal: options.signal,
767
+ body: JSON.stringify({
768
+ chatbotId: embedId,
769
+ sessionId,
770
+ historySource: 'session',
771
+ messages: [{ role: 'user', parts: [{ type: 'text', text: message }] }]
772
+ })
773
+ });
774
+ if (!response.ok || !response.headers.get('content-type')?.includes('text/event-stream')) {
775
+ const data = await response.json().catch(() => null);
776
+ throw new Error(data?.error || data?.message || 'Chat is temporarily unavailable.')
777
+ }
778
+ if (!response.body) throw new Error('Missing reply stream.')
779
+
780
+ const reader = response.body.getReader();
781
+ const decoder = new TextDecoder();
782
+ let buffer = '';
783
+ let eventData = [];
784
+ let text = '';
785
+ let published = '';
786
+ let complete = false;
787
+ let waiting = false;
788
+ const event = () => {
789
+ if (!eventData.length) return
790
+ const data = eventData.join('\n');
791
+ eventData = [];
792
+ if (data === '[DONE]') { complete = true; return }
793
+ const part = JSON.parse(data);
794
+ if (part.type === 'error' || part.type === 'abort') {
795
+ throw new Error(part.errorText || 'Reply interrupted.')
796
+ }
797
+ if (part.type === 'data-handoff' && part.data?.status === 'waiting') {
798
+ waiting = true;
799
+ return
800
+ }
801
+ if (part.type === 'text-start' && text && !text.endsWith('\n')) text += '\n';
802
+ if (part.type === 'text-delta') {
803
+ if (typeof part.delta !== 'string') throw new Error('Invalid reply text.')
804
+ text += part.delta;
805
+ }
806
+ };
807
+ const line = value => {
808
+ const cleaned = value.endsWith('\r') ? value.slice(0, -1) : value;
809
+ if (!cleaned) event();
810
+ else if (cleaned.startsWith('data:')) eventData.push(cleaned.slice(5).replace(/^ /, ''));
811
+ };
812
+ try {
813
+ while (!complete) {
814
+ const { value, done } = await reader.read();
815
+ buffer += decoder.decode(value, { stream: !done });
816
+ let newline;
817
+ while ((newline = buffer.indexOf('\n')) !== -1) {
818
+ line(buffer.slice(0, newline));
819
+ buffer = buffer.slice(newline + 1);
820
+ if (complete) break
821
+ }
822
+ // Publish once per received chunk, including the first useful text.
823
+ if (!waiting && text.trim() && text !== published) {
824
+ published = text;
825
+ options.onText?.(text);
826
+ }
827
+ if (done) break
828
+ }
829
+ if (!complete) throw new Error('Reply stream ended before completion.')
830
+ if (waiting) return { status: 'waiting' }
831
+ if (!text.trim()) throw new Error('The reply was empty.')
832
+ return { status: 'agent', text }
833
+ } finally {
834
+ await reader.cancel().catch(() => {});
835
+ reader.releaseLock();
836
+ }
837
+ };
838
+
761
839
  /**
762
840
  * AndyChat API client
763
841
  * Handles all communication with the AndyChat backend API
@@ -982,60 +1060,6 @@ const fetchChatbotConfig = async embedId => {
982
1060
  }
983
1061
  };
984
1062
 
985
- /**
986
- * Sends a user message to the API
987
- * @param {string} embedId - The chatbot embed ID
988
- * @param {string} sessionId - The current session ID
989
- * @param {string} message - The user's message
990
- * @returns {Promise<string>} The bot's response text
991
- */
992
- const sendMessageForWidget = async (embedId, sessionId, message) => {
993
- try {
994
- const apiBaseUrl = getApiBaseUrl();
995
- const response = await fetch(`${apiBaseUrl}/messages`, {
996
- method: 'POST',
997
- headers: { 'Content-Type': 'application/json' },
998
- body: JSON.stringify({
999
- chatbotId: embedId,
1000
- sessionId: sessionId,
1001
- currentUserMessage: {
1002
- role: 'user',
1003
- content: message
1004
- }
1005
- })
1006
- });
1007
-
1008
- if (response.ok) {
1009
- const data = await response.json();
1010
- if (data.error || data.limitStatus) {
1011
- throw new Error(data.error || 'Chat is temporarily unavailable.')
1012
- }
1013
- if (data.handoffStatus === 'paused') {
1014
- return { status: 'waiting' }
1015
- }
1016
- if (typeof data.botResponse !== 'string' || !data.botResponse.trim()) {
1017
- throw new Error('Unexpected response format from server.')
1018
- }
1019
- return { status: 'agent', text: data.botResponse }
1020
- } else {
1021
- // Handle HTTP errors (e.g., 500, 404)
1022
- let errorData;
1023
- try {
1024
- errorData = await response.json();
1025
- } catch (e) {
1026
- // If parsing error JSON fails, use status text
1027
- // Also, if response.text() is available and gives more info, consider using it.
1028
- const responseText = response.text ? await response.text() : '';
1029
- throw new Error(response.statusText || `Server returned an error. ${responseText}`.trim())
1030
- }
1031
- throw new Error(errorData.error || errorData.message || response.statusText || 'Server returned an error.')
1032
- }
1033
- } catch (error) {
1034
- console.error('AndyChat: Message request failed.');
1035
- throw error
1036
- }
1037
- };
1038
-
1039
1063
  /**
1040
1064
  * Visitor information collection is disabled until Andy exposes a durable public submission
1041
1065
  * contract. This explicit failure prevents a Widget from reporting a mocked success.
@@ -2129,6 +2153,10 @@ const getCoreStructuralCssString = () => {
2129
2153
  }
2130
2154
 
2131
2155
  /* Message Sub-Components */
2156
+ .andy-message__stream {
2157
+ white-space: pre-wrap;
2158
+ }
2159
+
2132
2160
  .andy-message__content {
2133
2161
  line-height: 1.4;
2134
2162
  margin: 0;
@@ -3438,6 +3466,13 @@ const SVG_ICONS = {
3438
3466
  // Keyed by language so React and Vanilla render identical text for a visitor.
3439
3467
  const UI_STRINGS = {
3440
3468
  en: {
3469
+ openChat: 'Open chat',
3470
+ closeChat: 'Close chat',
3471
+ goBack: 'Go back',
3472
+ messageInput: 'Message input',
3473
+ sendMessage: 'Send message',
3474
+ welcomeMessage: 'Welcome message',
3475
+ closeWelcomeMessage: 'Close welcome message',
3441
3476
  inputPlaceholder: 'Type your message...',
3442
3477
  predefinedQuestionsTitle: 'What would you like to know?',
3443
3478
  sendFailure: 'Sorry, I could not answer. Please try again.',
@@ -3445,6 +3480,13 @@ const UI_STRINGS = {
3445
3480
  teamName: 'Team'
3446
3481
  },
3447
3482
  es: {
3483
+ openChat: 'Abrir chat',
3484
+ closeChat: 'Cerrar chat',
3485
+ goBack: 'Volver',
3486
+ messageInput: 'Entrada de mensaje',
3487
+ sendMessage: 'Enviar mensaje',
3488
+ welcomeMessage: 'Mensaje de bienvenida',
3489
+ closeWelcomeMessage: 'Cerrar mensaje de bienvenida',
3448
3490
  inputPlaceholder: 'Escribe tu mensaje...',
3449
3491
  predefinedQuestionsTitle: '¿Qué te gustaría saber?',
3450
3492
  sendFailure: 'Lo siento, no pude responder. Inténtalo de nuevo.',
@@ -3753,12 +3795,12 @@ const callConvex = async (convexUrl, kind, path, wireArgs, keepalive = false) =>
3753
3795
  const sortMessages = messages => [...messages].sort((left, right) =>
3754
3796
  left.createdAt - right.createdAt || String(left.id).localeCompare(String(right.id)));
3755
3797
 
3756
- const createBrowserHandoff = (chatbotId, bootstrap, onProjection) => {
3798
+ const createBrowserHandoff = (chatbotId, bootstrap, onProjection, fresh = false) => {
3757
3799
  if (!bootstrap?.convexUrl || !bootstrap.surfaceKey || !bootstrap.surfaceProof) return null
3758
3800
  const storageKey = identityKey(chatbotId);
3759
3801
  let identity;
3760
3802
  try {
3761
- const stored = JSON.parse(window.sessionStorage.getItem(storageKey) || 'null');
3803
+ const stored = fresh ? null : JSON.parse(window.sessionStorage.getItem(storageKey) || 'null');
3762
3804
  identity = stored?.sessionId && stored?.capability && stored.surfaceKey === bootstrap.surfaceKey
3763
3805
  ? { sessionId: stored.sessionId, capability: stored.capability }
3764
3806
  : randomIdentity();
@@ -3847,6 +3889,14 @@ const createBrowserHandoff = (chatbotId, bootstrap, onProjection) => {
3847
3889
  if (active) onProjection({ offline: true, identity, messages: [] });
3848
3890
  }
3849
3891
  }, 15000);
3892
+ const stop = () => {
3893
+ if (!active) return
3894
+ active = false;
3895
+ window.clearInterval(timer);
3896
+ document.removeEventListener('visibilitychange', onVisibility);
3897
+ window.removeEventListener('pagehide', release);
3898
+ void release();
3899
+ };
3850
3900
  return {
3851
3901
  identity: { sessionId: identity.sessionId },
3852
3902
  recordVisitorMessage: content => {
@@ -3868,14 +3918,11 @@ const createBrowserHandoff = (chatbotId, bootstrap, onProjection) => {
3868
3918
  acknowledging.delete(messageId);
3869
3919
  }
3870
3920
  },
3871
- stop: () => {
3872
- if (!active) return
3873
- active = false;
3874
- window.clearInterval(timer);
3875
- document.removeEventListener('visibilitychange', onVisibility);
3876
- window.removeEventListener('pagehide', release);
3877
- void release();
3878
- }
3921
+ restart: () => {
3922
+ stop();
3923
+ return createBrowserHandoff(chatbotId, bootstrap, onProjection, true)
3924
+ },
3925
+ stop
3879
3926
  }
3880
3927
  };
3881
3928
 
@@ -3889,6 +3936,8 @@ const AndyChat = ({ embedId, tooltip, name, headline, description, welcomeMessag
3889
3936
  const [isOpen, setIsOpen] = useState(initialOpen);
3890
3937
  const [messages, setMessages] = useState([]);
3891
3938
  const [isLoading, setIsLoading] = useState(false);
3939
+ const [replyStarted, setReplyStarted] = useState(false);
3940
+ const activeReplyRef = useRef(null);
3892
3941
  const [sessionId, setSessionId] = useState(() => generateSessionId());
3893
3942
  const handoffRef = useRef(null);
3894
3943
  const pendingHandoffIdsRef = useRef([]);
@@ -3912,16 +3961,27 @@ const AndyChat = ({ embedId, tooltip, name, headline, description, welcomeMessag
3912
3961
  const handoff = createBrowserHandoff(config.id, config.bootstrap, projection => {
3913
3962
  if (projection.offline)
3914
3963
  return;
3964
+ const paused = projection.control === 'requested' || projection.control === 'human';
3965
+ if (paused)
3966
+ activeReplyRef.current?.abort();
3967
+ else if (activeReplyRef.current)
3968
+ return;
3915
3969
  setSessionId(projection.identity.sessionId);
3916
3970
  pendingHandoffIdsRef.current = projection.pendingProjectionIds || [];
3917
3971
  const projectedMessages = projection.messages.map(message => ({ id: message.id, content: message.content, sender: message.role === 'user' ? 'user' : 'bot', timestamp: new Date(message.createdAt).toISOString(), isHuman: message.sentByHuman }));
3972
+ // An empty agent projection does not mean the client-only greeting is gone.
3973
+ if (projection.control === 'agent' && projectedMessages.length === 0)
3974
+ return;
3918
3975
  setMessages(projection.control === 'requested' || projection.control === 'human'
3919
3976
  ? [...projectedMessages, { id: 'handoff-waiting', content: strings.handoffWaiting, sender: 'bot', timestamp: new Date().toISOString(), isWaiting: true }]
3920
3977
  : projectedMessages);
3921
3978
  });
3922
3979
  handoffRef.current = handoff;
3980
+ if (handoff)
3981
+ setSessionId(handoff.identity.sessionId);
3923
3982
  return () => {
3924
- handoff?.stop();
3983
+ activeReplyRef.current?.abort();
3984
+ handoffRef.current?.stop();
3925
3985
  handoffRef.current = null;
3926
3986
  };
3927
3987
  }, [config?.bootstrap, config?.id]);
@@ -4243,11 +4303,13 @@ const AndyChat = ({ embedId, tooltip, name, headline, description, welcomeMessag
4243
4303
  }, [isCollectingUserInfo, messages]);
4244
4304
  const sendUserMessage = useCallback(async (rawMessageText, options = {}) => {
4245
4305
  const messageText = typeof rawMessageText === 'string' ? rawMessageText.trim() : '';
4246
- if (!messageText) {
4306
+ if (!messageText || activeReplyRef.current) {
4247
4307
  return;
4248
4308
  }
4249
4309
  const { hidePredefined = true } = options;
4250
4310
  const timestamp = Date.now();
4311
+ const controller = new AbortController();
4312
+ activeReplyRef.current = controller;
4251
4313
  const userMessage = {
4252
4314
  id: timestamp,
4253
4315
  content: messageText,
@@ -4261,18 +4323,30 @@ const AndyChat = ({ embedId, tooltip, name, headline, description, welcomeMessag
4261
4323
  }
4262
4324
  try {
4263
4325
  setIsLoading(true);
4264
- const response = await sendMessageForWidget(embedId, sessionId, messageText);
4326
+ setReplyStarted(false);
4327
+ const showReply = (content, isStreaming) => {
4328
+ if (controller.signal.aborted)
4329
+ return;
4330
+ setReplyStarted(true);
4331
+ const reply = { id: timestamp + 1, content, sender: 'bot', timestamp: new Date(timestamp).toISOString(), isStreaming };
4332
+ setMessages(prev => prev.some(message => message.id === reply.id)
4333
+ ? prev.map(message => message.id === reply.id ? reply : message)
4334
+ : [...prev, reply]);
4335
+ };
4336
+ const response = await sendMessageForWidget(embedId, sessionId, messageText, {
4337
+ signal: controller.signal,
4338
+ onText: text => showReply(text, true)
4339
+ });
4340
+ if (controller.signal.aborted)
4341
+ return;
4265
4342
  if (response.status === 'waiting') {
4266
- setMessages(prev => prev.some(message => message.isWaiting) ? prev : [...prev, { id: 'handoff-waiting', content: strings.handoffWaiting, sender: 'bot', timestamp: new Date().toISOString(), isWaiting: true }]);
4343
+ setMessages(prev => {
4344
+ const visible = prev.filter(message => message.id !== timestamp + 1);
4345
+ return visible.some(message => message.isWaiting) ? visible : [...visible, { id: 'handoff-waiting', content: strings.handoffWaiting, sender: 'bot', timestamp: new Date().toISOString(), isWaiting: true }];
4346
+ });
4267
4347
  return;
4268
4348
  }
4269
- const botMessage = {
4270
- id: timestamp + 1,
4271
- content: response.text,
4272
- sender: 'bot',
4273
- timestamp: new Date().toISOString()
4274
- };
4275
- setMessages(prev => [...prev, botMessage]);
4349
+ showReply(response.text, false);
4276
4350
  if (config?.collectUserInfo?.enabled && !userInfoCollected && !isCollectingUserInfo) {
4277
4351
  setPendingMessage(messageText);
4278
4352
  setIsCollectingUserInfo(true);
@@ -4287,6 +4361,8 @@ const AndyChat = ({ embedId, tooltip, name, headline, description, welcomeMessag
4287
4361
  }
4288
4362
  }
4289
4363
  catch {
4364
+ if (controller.signal.aborted)
4365
+ return;
4290
4366
  console.error('AndyChat: Message request failed.');
4291
4367
  const errorMessage = {
4292
4368
  id: timestamp + 3,
@@ -4295,10 +4371,14 @@ const AndyChat = ({ embedId, tooltip, name, headline, description, welcomeMessag
4295
4371
  timestamp: new Date().toISOString(),
4296
4372
  isError: true
4297
4373
  };
4298
- setMessages(prev => [...prev, errorMessage]);
4374
+ setMessages(prev => [...prev.filter(message => message.id !== timestamp + 1), errorMessage]);
4299
4375
  }
4300
4376
  finally {
4301
- setIsLoading(false);
4377
+ if (activeReplyRef.current === controller) {
4378
+ activeReplyRef.current = null;
4379
+ setIsLoading(false);
4380
+ setReplyStarted(false);
4381
+ }
4302
4382
  }
4303
4383
  }, [config, embedId, sessionId, userInfoCollected, isCollectingUserInfo]);
4304
4384
  const handleSendMessage = useCallback((messageText) => {
@@ -4397,7 +4477,7 @@ const AndyChat = ({ embedId, tooltip, name, headline, description, welcomeMessag
4397
4477
  bubble = document.createElement('button');
4398
4478
  bubble.type = 'button';
4399
4479
  bubble.className = 'andy-chat-bubble';
4400
- bubble.setAttribute('aria-label', 'Open chat');
4480
+ bubble.setAttribute('aria-label', strings.openChat);
4401
4481
  const tooltipHTML = tooltip
4402
4482
  ? `<span class="andy-chat-bubble__tooltip" role="tooltip">${tooltip}</span>`
4403
4483
  : '';
@@ -4409,6 +4489,7 @@ const AndyChat = ({ embedId, tooltip, name, headline, description, welcomeMessag
4409
4489
  const displayLabel = bubbleLabel || config?.bubbleLabel || (isPillStyle ? getDefaultBubbleLabel(uiLang) : null);
4410
4490
  const bubbleLogoUrl = config?.logoUrl || CHAT_BUBBLE_DEFAULT_ICON_URL;
4411
4491
  if (bubble) {
4492
+ bubble.setAttribute('aria-label', isOpen ? strings.closeChat : strings.openChat);
4412
4493
  bubble.className = 'andy-chat-bubble';
4413
4494
  if (isPillStyle) {
4414
4495
  bubble.classList.add('andy-chat-bubble--pill');
@@ -4451,7 +4532,7 @@ const AndyChat = ({ embedId, tooltip, name, headline, description, welcomeMessag
4451
4532
  }
4452
4533
  }
4453
4534
  }, [shadowReady, config, isOpen, tooltip, bubbleLabel]);
4454
- return (jsx("div", { ref: containerRef, id: 'andy-chatbot-container', children: portalRef.current && shadowReady && config && (jsxs(Fragment, { children: [ReactDOM.createPortal(jsxs("div", { className: `andy-chat-window ${isOpen ? 'andy-chat-window--open' : ''}`, children: [jsxs("header", { className: 'andy-header', children: [jsx("button", { type: 'button', className: 'andy-header__back', id: 'andy-back-button', "aria-label": 'Go back', onClick: () => setIsOpen(false), children: jsx("svg", { className: 'andy-header__back-icon', width: '20', height: '20', viewBox: '0 0 24 24', fill: 'none', children: jsx("path", { d: 'M15 18l-6-6 6-6', stroke: 'currentColor', strokeWidth: '2', strokeLinecap: 'round', strokeLinejoin: 'round' }) }) }), jsxs("div", { className: 'andy-header__bot-info', children: [jsx("div", { className: 'andy-header__avatar', children: config?.logoUrl ? (jsx("img", { src: config.logoUrl, alt: 'Bot Avatar', className: 'andy-header__avatar-image' })) : (jsx("img", { src: CHAT_BUBBLE_DEFAULT_ICON_URL, alt: 'Bot Avatar', className: 'andy-header__avatar-icon andy-header__avatar-icon--default' })) }), jsxs("div", { className: 'andy-header__text', children: [jsx("h1", { className: 'andy-header__name', children: config?.name || 'Andy bot' }), config?.headline && (jsx("p", { className: 'andy-header__subtitle', title: config.headline, children: config.headline }))] })] }), jsx("button", { type: 'button', className: 'andy-header__close', id: 'andy-close-button', "aria-label": 'Close chat', onClick: () => setIsOpen(false), children: jsx("svg", { className: 'andy-header__close-icon', width: '20', height: '20', viewBox: '0 0 24 24', fill: 'none', children: jsx("path", { d: 'M18 6L6 18M6 6l12 12', stroke: 'currentColor', strokeWidth: '2', strokeLinecap: 'round', strokeLinejoin: 'round' }) }) })] }), jsxs("div", { className: 'andy-chat-window__messages', id: 'andy-messages', ref: messagesContainerRef, children: [config?.description && (jsx("div", { className: 'andy-chat-subtitle', children: jsx("p", { className: 'andy-chat-subtitle__text', children: config.description }) })), messages.map(message => {
4535
+ return (jsx("div", { ref: containerRef, id: 'andy-chatbot-container', children: portalRef.current && shadowReady && config && (jsxs(Fragment, { children: [ReactDOM.createPortal(jsxs("div", { className: `andy-chat-window ${isOpen ? 'andy-chat-window--open' : ''}`, children: [jsxs("header", { className: 'andy-header', children: [jsx("button", { type: 'button', className: 'andy-header__back', id: 'andy-back-button', "aria-label": strings.goBack, onClick: () => setIsOpen(false), children: jsx("svg", { className: 'andy-header__back-icon', width: '20', height: '20', viewBox: '0 0 24 24', fill: 'none', children: jsx("path", { d: 'M15 18l-6-6 6-6', stroke: 'currentColor', strokeWidth: '2', strokeLinecap: 'round', strokeLinejoin: 'round' }) }) }), jsxs("div", { className: 'andy-header__bot-info', children: [jsx("div", { className: 'andy-header__avatar', children: config?.logoUrl ? (jsx("img", { src: config.logoUrl, alt: 'Bot Avatar', className: 'andy-header__avatar-image' })) : (jsx("img", { src: CHAT_BUBBLE_DEFAULT_ICON_URL, alt: 'Bot Avatar', className: 'andy-header__avatar-icon andy-header__avatar-icon--default' })) }), jsxs("div", { className: 'andy-header__text', children: [jsx("h1", { className: 'andy-header__name', children: config?.name || 'Andy bot' }), config?.headline && (jsx("p", { className: 'andy-header__subtitle', title: config.headline, children: config.headline }))] })] }), jsx("button", { type: 'button', className: 'andy-header__close', id: 'andy-close-button', "aria-label": strings.closeChat, onClick: () => setIsOpen(false), children: jsx("svg", { className: 'andy-header__close-icon', width: '20', height: '20', viewBox: '0 0 24 24', fill: 'none', children: jsx("path", { d: 'M18 6L6 18M6 6l12 12', stroke: 'currentColor', strokeWidth: '2', strokeLinecap: 'round', strokeLinejoin: 'round' }) }) })] }), jsxs("div", { className: 'andy-chat-window__messages', id: 'andy-messages', ref: messagesContainerRef, children: [config?.description && (jsx("div", { className: 'andy-chat-subtitle', children: jsx("p", { className: 'andy-chat-subtitle__text', children: config.description }) })), messages.map(message => {
4455
4536
  if (message.isForm) {
4456
4537
  return (jsxs("div", { className: 'andy-message-container andy-message-container--form', children: [jsx("div", { className: 'andy-avatar', children: jsx("svg", { className: 'andy-avatar__icon', width: '24', height: '24', viewBox: '0 0 24 24', fill: 'none', xmlns: 'http://www.w3.org/2000/svg', children: jsx("path", { d: 'M20 2H4C2.9 2 2 2.9 2 4V22L6 18H20C21.1 18 22 17.1 22 16V4C22 2.9 21.1 2 20 2Z', fill: 'currentColor' }) }) }), jsx("div", { className: 'andy-message andy-message--bot andy-message--form', children: jsx("div", { className: 'andy-user-info-form', children: jsxs("div", { className: 'andy-user-info-form__content', children: [jsx("h3", { className: 'andy-user-info-form__title', children: config?.collectUserInfo?.message ||
4457
4538
  'Please provide your information to continue:' }), jsxs("form", { className: 'andy-user-info-form__form', id: 'andy-user-info-form', children: [jsxs("div", { className: 'andy-form-group', children: [jsx("label", { htmlFor: 'andy-user-name', className: 'andy-form-group__label', children: "Name" }), jsx("input", { type: 'text', id: 'andy-user-name', className: 'andy-form-group__input', placeholder: 'Your Name', required: true })] }), jsxs("div", { className: 'andy-form-group', children: [jsx("label", { htmlFor: 'andy-user-email', className: 'andy-form-group__label', children: "Email" }), jsx("input", { type: 'email', id: 'andy-user-email', className: 'andy-form-group__input', placeholder: 'Your Email', required: true })] }), jsx("button", { type: 'submit', className: 'andy-button andy-button--primary andy-user-info-form__submit', children: "Submit" })] })] }) }) })] }, message.id));
@@ -4459,25 +4540,31 @@ const AndyChat = ({ embedId, tooltip, name, headline, description, welcomeMessag
4459
4540
  if (message.isLoading) {
4460
4541
  return (jsx("div", { className: 'andy-message-container andy-message-container--bot andy-message-container--loading', children: jsx("div", { className: 'andy-message andy-message--bot', children: jsxs("div", { className: 'andy-typing-indicator', children: [jsx("div", { className: 'andy-typing-indicator__dot' }), jsx("div", { className: 'andy-typing-indicator__dot' }), jsx("div", { className: 'andy-typing-indicator__dot' })] }) }) }, message.id));
4461
4542
  }
4462
- return (jsxs("div", { className: `andy-message-container andy-message-container--${message.sender} ${message.isWaiting ? 'andy-message-container--waiting' : ''}`, children: [message.sender === 'bot' && (jsxs("div", { className: `andy-message andy-message--bot ${message.isHuman ? 'andy-message--operator' : ''} ${message.isWaiting ? 'andy-message--waiting' : ''} ${message.isError ? 'andy-message--error' : ''}`, children: [jsxs("div", { className: 'andy-bot-header', children: [jsx("div", { className: 'andy-bot-header__icon', children: config?.logoUrl ? (jsx("img", { src: config.logoUrl, alt: 'Bot Avatar', className: 'andy-bot-header__icon-image' })) : (jsx("img", { src: CHAT_BUBBLE_DEFAULT_ICON_URL, alt: 'Bot Avatar', className: 'andy-bot-header__icon-image andy-bot-header__icon-image--default' })) }), jsx("span", { className: 'andy-bot-header__name', children: message.isHuman ? strings.teamName : config?.name || 'Andy bot' })] }), jsx("div", { className: 'andy-message__content', children: jsx("div", { dangerouslySetInnerHTML: { __html: parseMarkdown(message.content) } }) })] })), message.sender === 'user' && (jsx("div", { className: 'andy-message andy-message--user', children: jsx("div", { className: 'andy-message__content', children: jsx("p", { children: message.content }) }) }))] }, message.id));
4463
- }), isLoading && (jsx("div", { className: 'andy-message-container andy-message-container--bot andy-message-container--typing', children: jsx("div", { className: 'andy-message andy-message--bot andy-message--typing', children: jsxs("div", { className: 'andy-typing-indicator', children: [jsx("div", { className: 'andy-typing-indicator__dot' }), jsx("div", { className: 'andy-typing-indicator__dot' }), jsx("div", { className: 'andy-typing-indicator__dot' })] }) }) }))] }), jsxs("div", { className: 'andy-chat-window__bottom', children: [activeQuestions &&
4543
+ return (jsxs("div", { className: `andy-message-container andy-message-container--${message.sender} ${message.isWaiting ? 'andy-message-container--waiting' : ''}`, children: [message.sender === 'bot' && (jsxs("div", { className: `andy-message andy-message--bot ${message.isHuman ? 'andy-message--operator' : ''} ${message.isWaiting ? 'andy-message--waiting' : ''} ${message.isError ? 'andy-message--error' : ''}`, children: [jsxs("div", { className: 'andy-bot-header', children: [jsx("div", { className: 'andy-bot-header__icon', children: config?.logoUrl ? (jsx("img", { src: config.logoUrl, alt: 'Bot Avatar', className: 'andy-bot-header__icon-image' })) : (jsx("img", { src: CHAT_BUBBLE_DEFAULT_ICON_URL, alt: 'Bot Avatar', className: 'andy-bot-header__icon-image andy-bot-header__icon-image--default' })) }), jsx("span", { className: 'andy-bot-header__name', children: message.isHuman ? strings.teamName : config?.name || 'Andy bot' })] }), jsx("div", { className: 'andy-message__content', children: message.isStreaming ? jsx("div", { className: 'andy-message__stream', children: message.content }) : jsx("div", { dangerouslySetInnerHTML: { __html: parseMarkdown(message.content) } }) })] })), message.sender === 'user' && (jsx("div", { className: 'andy-message andy-message--user', children: jsx("div", { className: 'andy-message__content', children: jsx("p", { children: message.content }) }) }))] }, message.id));
4544
+ }), isLoading && !replyStarted && (jsx("div", { className: 'andy-message-container andy-message-container--bot andy-message-container--typing', children: jsx("div", { className: 'andy-message andy-message--bot andy-message--typing', children: jsxs("div", { className: 'andy-typing-indicator', children: [jsx("div", { className: 'andy-typing-indicator__dot' }), jsx("div", { className: 'andy-typing-indicator__dot' }), jsx("div", { className: 'andy-typing-indicator__dot' })] }) }) }))] }), jsxs("div", { className: 'andy-chat-window__bottom', children: [activeQuestions &&
4464
4545
  activeQuestions.length > 0 &&
4465
- showPredefinedQuestions && (jsxs("div", { className: 'andy-predefined-questions', id: 'andy-predefined-questions', children: [jsx("div", { className: 'andy-predefined-questions__header', children: jsx("p", { className: 'andy-predefined-questions__title', children: strings.predefinedQuestionsTitle }) }), jsx("div", { className: 'andy-predefined-questions__list', children: activeQuestions.map((q, index) => (jsxs("button", { type: 'button', className: 'andy-predefined-question', "data-question": q.question, disabled: isCollectingUserInfo || isLoading || !config, children: [jsx("span", { className: 'andy-predefined-question__icon', children: "\uD83D\uDCAC" }), jsx("span", { className: 'andy-predefined-question__text', children: q.question })] }, index))) })] })), jsx("div", { className: 'andy-chat-window__input', children: jsx("div", { className: 'andy-input-container', children: jsxs("div", { className: 'andy-input', children: [jsx("input", { type: 'text', className: 'andy-input__field', id: 'andy-message-input', placeholder: config ? strings.inputPlaceholder : '...', "aria-label": 'Message input', onKeyDown: e => {
4546
+ showPredefinedQuestions && (jsxs("div", { className: 'andy-predefined-questions', id: 'andy-predefined-questions', children: [jsx("div", { className: 'andy-predefined-questions__header', children: jsx("p", { className: 'andy-predefined-questions__title', children: strings.predefinedQuestionsTitle }) }), jsx("div", { className: 'andy-predefined-questions__list', children: activeQuestions.map((q, index) => (jsxs("button", { type: 'button', className: 'andy-predefined-question', "data-question": q.question, disabled: isCollectingUserInfo || isLoading || !config, children: [jsx("span", { className: 'andy-predefined-question__icon', children: "\uD83D\uDCAC" }), jsx("span", { className: 'andy-predefined-question__text', children: q.question })] }, index))) })] })), jsx("div", { className: 'andy-chat-window__input', children: jsx("div", { className: 'andy-input-container', children: jsxs("div", { className: 'andy-input', children: [jsx("input", { type: 'text', className: 'andy-input__field', id: 'andy-message-input', placeholder: config ? strings.inputPlaceholder : '...', "aria-label": strings.messageInput, onKeyDown: e => {
4547
+ if (e.key === 'Enter' && activeReplyRef.current) {
4548
+ e.preventDefault();
4549
+ return;
4550
+ }
4466
4551
  if (e.key === 'Enter' && e.target.value.trim()) {
4467
4552
  handleSendMessage(e.target.value);
4468
4553
  e.target.value = '';
4469
4554
  }
4470
- }, disabled: isCollectingUserInfo || !config }), jsx("button", { type: 'button', className: 'andy-input__button', id: 'andy-send-button', "aria-label": 'Send message', onClick: () => {
4555
+ }, disabled: isCollectingUserInfo || !config }), jsx("button", { type: 'button', className: 'andy-input__button', id: 'andy-send-button', "aria-label": strings.sendMessage, onClick: () => {
4556
+ if (activeReplyRef.current)
4557
+ return;
4471
4558
  const input = shadowRef.current?.getElementById('andy-message-input');
4472
4559
  if (input && input.value.trim()) {
4473
4560
  handleSendMessage(input.value);
4474
4561
  input.value = '';
4475
4562
  }
4476
- }, disabled: isCollectingUserInfo || !config, children: jsx("svg", { className: 'andy-input__button-icon', width: '18', height: '18', viewBox: '0 0 24 24', fill: 'currentColor', xmlns: 'http://www.w3.org/2000/svg', children: jsx("path", { d: 'M2.01 21L23 12 2.01 3 2 10l15 2-15 2z' }) }) })] }) }) }), config?.showBranding !== false && (jsx("footer", { className: 'andy-chat-window__footer', children: jsx("a", { href: getFooterUrl(), target: '_blank', rel: 'noopener noreferrer', className: 'andy-chat-window__footer-link', children: jsx("span", { className: 'andy-chat-window__footer-text', children: FOOTER_TEXT }) }) }))] })] }), portalRef.current), showWelcomePopup &&
4563
+ }, disabled: isCollectingUserInfo || isLoading || !config, children: jsx("svg", { className: 'andy-input__button-icon', width: '18', height: '18', viewBox: '0 0 24 24', fill: 'currentColor', xmlns: 'http://www.w3.org/2000/svg', children: jsx("path", { d: 'M2.01 21L23 12 2.01 3 2 10l15 2-15 2z' }) }) })] }) }) }), config?.showBranding !== false && (jsx("footer", { className: 'andy-chat-window__footer', children: jsx("a", { href: getFooterUrl(), target: '_blank', rel: 'noopener noreferrer', className: 'andy-chat-window__footer-link', children: jsx("span", { className: 'andy-chat-window__footer-text', children: FOOTER_TEXT }) }) }))] })] }), portalRef.current), showWelcomePopup &&
4477
4564
  !isOpen &&
4478
- ReactDOM.createPortal(jsxs("div", { className: `andy-welcome-popup ${config?.orientation === 'left' ? 'andychat-popup-left' : ''}`, style: { display: 'block' }, role: 'dialog', "aria-label": 'Welcome message', children: [jsxs("div", { className: 'andy-welcome-popup__content', children: [jsx("div", { className: 'andy-welcome-popup__message', children: config?.showWelcomePopup?.message ||
4565
+ ReactDOM.createPortal(jsxs("div", { className: `andy-welcome-popup ${config?.orientation === 'left' ? 'andychat-popup-left' : ''}`, style: { display: 'block' }, role: 'dialog', "aria-label": strings.welcomeMessage, children: [jsxs("div", { className: 'andy-welcome-popup__content', children: [jsx("div", { className: 'andy-welcome-popup__message', children: config?.showWelcomePopup?.message ||
4479
4566
  config?.welcomeMessage ||
4480
- 'Welcome! How can I help you today?' }), jsx("button", { type: 'button', className: 'andy-welcome-popup__close', "aria-label": 'Close welcome message', onClick: () => setShowWelcomePopup(false), children: jsx("svg", { className: 'andy-welcome-popup__close-icon', width: '20', height: '20', viewBox: '0 0 24 24', fill: 'none', xmlns: 'http://www.w3.org/2000/svg', children: jsx("path", { d: 'M18 6L6 18M6 6l12 12', stroke: 'currentColor', strokeWidth: '2', strokeLinecap: 'round', strokeLinejoin: 'round' }) }) })] }), jsx("div", { className: 'andy-welcome-popup__arrow' })] }), portalRef.current)] })) }));
4567
+ 'Welcome! How can I help you today?' }), jsx("button", { type: 'button', className: 'andy-welcome-popup__close', "aria-label": strings.closeWelcomeMessage, onClick: () => setShowWelcomePopup(false), children: jsx("svg", { className: 'andy-welcome-popup__close-icon', width: '20', height: '20', viewBox: '0 0 24 24', fill: 'none', xmlns: 'http://www.w3.org/2000/svg', children: jsx("path", { d: 'M18 6L6 18M6 6l12 12', stroke: 'currentColor', strokeWidth: '2', strokeLinecap: 'round', strokeLinejoin: 'round' }) }) })] }), jsx("div", { className: 'andy-welcome-popup__arrow' })] }), portalRef.current)] })) }));
4481
4568
  };
4482
4569
 
4483
4570
  const { SESSION_STORAGE_KEY, FORM_DISPLAY_DELAY_MS, PERSIST_SESSION } = CONFIG;
@@ -4494,6 +4581,11 @@ const useAndyChat = (embedId) => {
4494
4581
  const [showingWelcomePopup, setShowingWelcomePopup] = useState(false);
4495
4582
  const [showingUserInfoForm, setShowingUserInfoForm] = useState(false);
4496
4583
  const [welcomePopupShown, setWelcomePopupShown] = useState(false);
4584
+ const activeReplyRef = useRef(null);
4585
+ useEffect(() => () => {
4586
+ activeReplyRef.current?.abort();
4587
+ activeReplyRef.current = null;
4588
+ }, [embedId]);
4497
4589
  const welcomeTimeoutRef = useRef(null);
4498
4590
  const userInfoTimeoutRef = useRef(null);
4499
4591
  const initializeChatbot = useCallback(async () => {
@@ -4565,9 +4657,11 @@ const useAndyChat = (embedId) => {
4565
4657
  });
4566
4658
  }, [config, welcomePopupShown, userInfoCollected]);
4567
4659
  const sendMessage = useCallback(async (messageText) => {
4568
- if (!messageText.trim() || !config || !sessionId) {
4660
+ if (!messageText.trim() || !config || !sessionId || activeReplyRef.current) {
4569
4661
  return;
4570
4662
  }
4663
+ const controller = new AbortController();
4664
+ activeReplyRef.current = controller;
4571
4665
  const userMessage = {
4572
4666
  id: Date.now(),
4573
4667
  content: messageText,
@@ -4575,20 +4669,29 @@ const useAndyChat = (embedId) => {
4575
4669
  timestamp: new Date().toISOString()
4576
4670
  };
4577
4671
  setMessages(prev => [...prev, userMessage]);
4672
+ const replyId = Date.now() + 1;
4673
+ const showReply = (content, isStreaming) => {
4674
+ if (controller.signal.aborted)
4675
+ return;
4676
+ const reply = { id: replyId, content, sender: 'bot', timestamp: new Date().toISOString(), isStreaming };
4677
+ setMessages(prev => prev.some(message => message.id === replyId)
4678
+ ? prev.map(message => message.id === replyId ? reply : message)
4679
+ : [...prev, reply]);
4680
+ };
4578
4681
  try {
4579
- const response = await sendMessageForWidget(embedId, sessionId, messageText);
4580
- if (response.status === 'waiting')
4682
+ const response = await sendMessageForWidget(embedId, sessionId, messageText, { signal: controller.signal, onText: text => showReply(text, true) });
4683
+ if (controller.signal.aborted)
4684
+ return;
4685
+ if (response.status === 'waiting') {
4686
+ setMessages(prev => prev.filter(message => message.id !== replyId));
4581
4687
  return response;
4582
- const botMessage = {
4583
- id: Date.now() + 1,
4584
- content: response.text || 'I apologize, but I encountered an error processing your request.',
4585
- sender: 'bot',
4586
- timestamp: new Date().toISOString()
4587
- };
4588
- setMessages(prev => [...prev, botMessage]);
4688
+ }
4689
+ showReply(response.text, false);
4589
4690
  return response;
4590
4691
  }
4591
4692
  catch (err) {
4693
+ if (controller.signal.aborted)
4694
+ return;
4592
4695
  console.error('Error sending message:', err);
4593
4696
  const errorMessage = {
4594
4697
  id: Date.now() + 1,
@@ -4597,9 +4700,13 @@ const useAndyChat = (embedId) => {
4597
4700
  timestamp: new Date().toISOString(),
4598
4701
  isError: true
4599
4702
  };
4600
- setMessages(prev => [...prev, errorMessage]);
4703
+ setMessages(prev => [...prev.filter(message => message.id !== replyId), errorMessage]);
4601
4704
  throw err;
4602
4705
  }
4706
+ finally {
4707
+ if (activeReplyRef.current === controller)
4708
+ activeReplyRef.current = null;
4709
+ }
4603
4710
  }, [embedId, sessionId, config]);
4604
4711
  const showFaq = useCallback(() => {
4605
4712
  setShowingFaq(true);
@@ -4638,6 +4745,8 @@ const useAndyChat = (embedId) => {
4638
4745
  }
4639
4746
  }, [embedId, sessionId]);
4640
4747
  const startNewSession = useCallback(() => {
4748
+ activeReplyRef.current?.abort();
4749
+ activeReplyRef.current = null;
4641
4750
  const newSessionId = generateSessionId();
4642
4751
  setSessionId(newSessionId);
4643
4752
  {
@@ -4707,9 +4816,11 @@ const MessageComponent = ({ message, botName }) => {
4707
4816
  .filter(Boolean)
4708
4817
  .join(' ');
4709
4818
  const messageClasses = ['andy-message', `andy-message--${message.sender}`].filter(Boolean).join(' ');
4710
- const messageContent = isBot ? parseMarkdown(message.content) : message.content;
4819
+ const messageContent = isBot && !message.isStreaming ? parseMarkdown(message.content) : message.content;
4711
4820
  if (isBot) {
4712
- return (jsxs("div", { className: containerClasses, children: [jsxs("div", { className: 'andy-bot-header', children: [jsx("div", { className: 'andy-bot-header__icon', children: jsxs("svg", { width: '16', height: '16', viewBox: '0 0 24 24', fill: 'none', xmlns: 'http://www.w3.org/2000/svg', stroke: 'currentColor', strokeWidth: '2', strokeLinecap: 'round', strokeLinejoin: 'round', children: [jsx("path", { d: 'M12 6V2H8' }), jsx("path", { d: 'm8 18-4 4V8a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2Z' }), jsx("path", { d: 'M2 12h2' }), jsx("path", { d: 'M9 11v2' }), jsx("path", { d: 'M15 11v2' }), jsx("path", { d: 'M20 12h2' })] }) }), jsx("span", { className: 'andy-bot-header__name', children: botName || 'Andy bot' })] }), jsx("div", { className: messageClasses, children: jsx("div", { className: 'andy-message__content', dangerouslySetInnerHTML: { __html: messageContent } }) })] }));
4821
+ return (jsxs("div", { className: containerClasses, children: [jsxs("div", { className: 'andy-bot-header', children: [jsx("div", { className: 'andy-bot-header__icon', children: jsxs("svg", { width: '16', height: '16', viewBox: '0 0 24 24', fill: 'none', xmlns: 'http://www.w3.org/2000/svg', stroke: 'currentColor', strokeWidth: '2', strokeLinecap: 'round', strokeLinejoin: 'round', children: [jsx("path", { d: 'M12 6V2H8' }), jsx("path", { d: 'm8 18-4 4V8a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2Z' }), jsx("path", { d: 'M2 12h2' }), jsx("path", { d: 'M9 11v2' }), jsx("path", { d: 'M15 11v2' }), jsx("path", { d: 'M20 12h2' })] }) }), jsx("span", { className: 'andy-bot-header__name', children: botName || 'Andy bot' })] }), jsx("div", { className: messageClasses, children: message.isStreaming
4822
+ ? jsx("div", { className: 'andy-message__content andy-message__stream', children: message.content })
4823
+ : jsx("div", { className: 'andy-message__content', dangerouslySetInnerHTML: { __html: messageContent } }) })] }));
4713
4824
  }
4714
4825
  return (jsx("div", { className: containerClasses, children: jsx("div", { className: messageClasses, children: jsx("div", { className: 'andy-message__content', children: jsx("p", { children: messageContent }) }) }) }));
4715
4826
  };
@@ -4724,7 +4835,7 @@ const MessageList = ({ messages, isLoading, logoUrl, botName }) => {
4724
4835
  });
4725
4836
  }
4726
4837
  }, [messages, isLoading]);
4727
- return (jsxs(Fragment, { children: [messages.map(message => (jsx(MessageComponent, { message: message, logoUrl: logoUrl, botName: botName }, message.id || message.timestamp))), isLoading && jsx(TypingIndicator, {}), messages.length === 0 && !isLoading && (jsxs("div", { className: 'andy-empty-state', children: [jsx("div", { className: 'andy-empty-state__icon', children: "\uD83D\uDCAC" }), jsx("div", { className: 'andy-empty-state__text', children: "Start a conversation by typing a message below" })] })), jsx("div", { ref: messagesEndRef })] }));
4838
+ return (jsxs(Fragment, { children: [messages.map(message => (jsx(MessageComponent, { message: message, logoUrl: logoUrl, botName: botName }, message.id || message.timestamp))), isLoading && !messages.some(message => message.isStreaming) && jsx(TypingIndicator, {}), messages.length === 0 && !isLoading && (jsxs("div", { className: 'andy-empty-state', children: [jsx("div", { className: 'andy-empty-state__icon', children: "\uD83D\uDCAC" }), jsx("div", { className: 'andy-empty-state__text', children: "Start a conversation by typing a message below" })] })), jsx("div", { ref: messagesEndRef })] }));
4728
4839
  };
4729
4840
 
4730
4841
  const MessageInput = ({ onSendMessage, disabled = false, placeholder = 'Type your message here...' }) => {
@@ -4739,14 +4850,15 @@ const MessageInput = ({ onSendMessage, disabled = false, placeholder = 'Type you
4739
4850
  }
4740
4851
  try {
4741
4852
  setIsSubmitting(true);
4742
- await onSendMessage(trimmedMessage);
4743
4853
  setMessage('');
4854
+ await onSendMessage(trimmedMessage);
4744
4855
  if (inputRef.current) {
4745
4856
  inputRef.current.focus();
4746
4857
  }
4747
4858
  }
4748
4859
  catch (error) {
4749
4860
  console.error('Error sending message:', error);
4861
+ setMessage(current => current || trimmedMessage);
4750
4862
  }
4751
4863
  finally {
4752
4864
  setIsSubmitting(false);
@@ -4763,7 +4875,7 @@ const MessageInput = ({ onSendMessage, disabled = false, placeholder = 'Type you
4763
4875
  };
4764
4876
  const isInputDisabled = disabled || isSubmitting;
4765
4877
  const canSend = message.trim().length > 0 && !isInputDisabled;
4766
- return (jsxs("div", { className: 'andy-input', children: [jsx("input", { ref: inputRef, type: 'text', value: message, onChange: handleInputChange, onKeyDown: handleKeyDown, placeholder: placeholder, disabled: isInputDisabled, maxLength: 1000, autoComplete: 'off', id: 'andy-input' }), jsx("button", { type: 'button', onClick: () => handleSubmit({ preventDefault: () => { } }), disabled: !canSend, id: 'andy-send', children: isSubmitting ? ('...') : (jsx("svg", { width: '18', height: '18', viewBox: '0 0 24 24', fill: 'none', xmlns: 'http://www.w3.org/2000/svg', children: jsx("path", { d: 'M2.01 21L23 12 2.01 3 2 10l15 2-15 2z', fill: 'white' }) })) })] }));
4878
+ return (jsxs("div", { className: 'andy-input', children: [jsx("input", { ref: inputRef, type: 'text', value: message, onChange: handleInputChange, onKeyDown: handleKeyDown, placeholder: placeholder, disabled: disabled, maxLength: 1000, autoComplete: 'off', id: 'andy-input' }), jsx("button", { type: 'button', onClick: () => handleSubmit({ preventDefault: () => { } }), disabled: !canSend, id: 'andy-send', children: isSubmitting ? ('...') : (jsx("svg", { width: '18', height: '18', viewBox: '0 0 24 24', fill: 'none', xmlns: 'http://www.w3.org/2000/svg', children: jsx("path", { d: 'M2.01 21L23 12 2.01 3 2 10l15 2-15 2z', fill: 'white' }) })) })] }));
4767
4879
  };
4768
4880
 
4769
4881
  const ChatWindow = ({ config, messages, isLoading, onClose, onSendMessage }) => {