andy-widget-react 2.1.1 → 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.
@@ -54,6 +54,7 @@ export interface Message {
54
54
  isError?: boolean;
55
55
  isHuman?: boolean;
56
56
  isWaiting?: boolean;
57
+ isStreaming?: boolean;
57
58
  }
58
59
  export interface UserInfo {
59
60
  name: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "andy-widget-react",
3
- "version": "2.1.1",
3
+ "version": "2.2.0",
4
4
  "description": "Official React Widget for embeddable Andy agents",
5
5
  "type": "module",
6
6
  "main": "dist/index.cjs",
@@ -67,6 +67,6 @@
67
67
  "react-dom": "^18.0.0 || ^19.0.0"
68
68
  },
69
69
  "dependencies": {
70
- "andy-widget-core": "2.1.1"
70
+ "andy-widget-core": "2.2.0"
71
71
  }
72
72
  }
package/src/AndyChat.tsx CHANGED
@@ -95,6 +95,8 @@ const AndyChat: React.FC<AndyChatProps> = ({
95
95
  const [isOpen, setIsOpen] = useState(initialOpen)
96
96
  const [messages, setMessages] = useState<Message[]>([])
97
97
  const [isLoading, setIsLoading] = useState(false)
98
+ const [replyStarted, setReplyStarted] = useState(false)
99
+ const activeReplyRef = useRef<AbortController | null>(null)
98
100
  const [sessionId, setSessionId] = useState(() => generateSessionId())
99
101
  const handoffRef = useRef<ReturnType<typeof createBrowserHandoff>>(null)
100
102
  const pendingHandoffIdsRef = useRef<string[]>([])
@@ -118,6 +120,9 @@ const AndyChat: React.FC<AndyChatProps> = ({
118
120
  handoffRef.current?.stop()
119
121
  const handoff = createBrowserHandoff(config.id, config.bootstrap, projection => {
120
122
  if (projection.offline) return
123
+ const paused = projection.control === 'requested' || projection.control === 'human'
124
+ if (paused) activeReplyRef.current?.abort()
125
+ else if (activeReplyRef.current) return
121
126
  setSessionId(projection.identity.sessionId)
122
127
  pendingHandoffIdsRef.current = projection.pendingProjectionIds || []
123
128
  const projectedMessages = projection.messages.map(message => ({ id: message.id, content: message.content, sender: message.role === 'user' ? 'user' as const : 'bot' as const, timestamp: new Date(message.createdAt).toISOString(), isHuman: message.sentByHuman }))
@@ -128,8 +133,10 @@ const AndyChat: React.FC<AndyChatProps> = ({
128
133
  : projectedMessages)
129
134
  })
130
135
  handoffRef.current = handoff
136
+ if (handoff) setSessionId(handoff.identity.sessionId)
131
137
  return () => {
132
- handoff?.stop()
138
+ activeReplyRef.current?.abort()
139
+ handoffRef.current?.stop()
133
140
  handoffRef.current = null
134
141
  }
135
142
  }, [config?.bootstrap, config?.id])
@@ -487,7 +494,13 @@ const AndyChat: React.FC<AndyChatProps> = ({
487
494
  }, [isCollectingUserInfo, messages])
488
495
 
489
496
  const handleNewChat = () => {
490
- setSessionId(generateSessionId())
497
+ activeReplyRef.current?.abort()
498
+ activeReplyRef.current = null
499
+ setIsLoading(false)
500
+ setReplyStarted(false)
501
+ handoffRef.current = handoffRef.current?.restart() || null
502
+ pendingHandoffIdsRef.current = []
503
+ setSessionId(handoffRef.current?.identity.sessionId || generateSessionId())
491
504
 
492
505
  if (config?.welcomeMessage) {
493
506
  const greeting = createGreetingMessage(config)
@@ -513,12 +526,14 @@ const AndyChat: React.FC<AndyChatProps> = ({
513
526
  const sendUserMessage = useCallback(
514
527
  async (rawMessageText: string, options: { hidePredefined?: boolean } = {}) => {
515
528
  const messageText = typeof rawMessageText === 'string' ? rawMessageText.trim() : ''
516
- if (!messageText) {
529
+ if (!messageText || activeReplyRef.current) {
517
530
  return
518
531
  }
519
532
 
520
533
  const { hidePredefined = true } = options
521
534
  const timestamp = Date.now()
535
+ const controller = new AbortController()
536
+ activeReplyRef.current = controller
522
537
 
523
538
  const userMessage: Message = {
524
539
  id: timestamp,
@@ -535,19 +550,29 @@ const AndyChat: React.FC<AndyChatProps> = ({
535
550
 
536
551
  try {
537
552
  setIsLoading(true)
538
- const response = await sendMessageToAPI(embedId!, sessionId, messageText)
553
+ setReplyStarted(false)
554
+ const showReply = (content: string, isStreaming: boolean) => {
555
+ if (controller.signal.aborted) return
556
+ setReplyStarted(true)
557
+ const reply: Message = { id: timestamp + 1, content, sender: 'bot', timestamp: new Date(timestamp).toISOString(), isStreaming }
558
+ setMessages(prev => prev.some(message => message.id === reply.id)
559
+ ? prev.map(message => message.id === reply.id ? reply : message)
560
+ : [...prev, reply])
561
+ }
562
+ const response = await sendMessageToAPI(embedId!, sessionId, messageText, {
563
+ signal: controller.signal,
564
+ onText: text => showReply(text, true)
565
+ })
566
+ if (controller.signal.aborted) return
539
567
  if (response.status === 'waiting') {
540
- setMessages(prev => prev.some(message => message.isWaiting) ? prev : [...prev, { id: 'handoff-waiting', content: strings.handoffWaiting, sender: 'bot', timestamp: new Date().toISOString(), isWaiting: true }])
568
+ setMessages(prev => {
569
+ const visible = prev.filter(message => message.id !== timestamp + 1)
570
+ return visible.some(message => message.isWaiting) ? visible : [...visible, { id: 'handoff-waiting', content: strings.handoffWaiting, sender: 'bot', timestamp: new Date().toISOString(), isWaiting: true }]
571
+ })
541
572
  return
542
573
  }
543
574
 
544
- const botMessage: Message = {
545
- id: timestamp + 1,
546
- content: response.text,
547
- sender: 'bot',
548
- timestamp: new Date().toISOString()
549
- }
550
- setMessages(prev => [...prev, botMessage])
575
+ showReply(response.text, false)
551
576
 
552
577
  if (config?.collectUserInfo?.enabled && !userInfoCollected && !isCollectingUserInfo) {
553
578
  setPendingMessage(messageText)
@@ -563,6 +588,7 @@ const AndyChat: React.FC<AndyChatProps> = ({
563
588
  setMessages(prev => [...prev, formMessage])
564
589
  }
565
590
  } catch {
591
+ if (controller.signal.aborted) return
566
592
  console.error('AndyChat: Message request failed.')
567
593
  const errorMessage: Message = {
568
594
  id: timestamp + 3,
@@ -571,9 +597,13 @@ const AndyChat: React.FC<AndyChatProps> = ({
571
597
  timestamp: new Date().toISOString(),
572
598
  isError: true
573
599
  }
574
- setMessages(prev => [...prev, errorMessage])
600
+ setMessages(prev => [...prev.filter(message => message.id !== timestamp + 1), errorMessage])
575
601
  } finally {
576
- setIsLoading(false)
602
+ if (activeReplyRef.current === controller) {
603
+ activeReplyRef.current = null
604
+ setIsLoading(false)
605
+ setReplyStarted(false)
606
+ }
577
607
  }
578
608
  },
579
609
  [config, embedId, sessionId, userInfoCollected, isCollectingUserInfo]
@@ -963,7 +993,7 @@ const AndyChat: React.FC<AndyChatProps> = ({
963
993
  </span>
964
994
  </div>
965
995
  <div className='andy-message__content'>
966
- <div dangerouslySetInnerHTML={{ __html: parseMarkdown(message.content) }} />
996
+ {message.isStreaming ? <div className='andy-message__stream'>{message.content}</div> : <div dangerouslySetInnerHTML={{ __html: parseMarkdown(message.content) }} />}
967
997
  </div>
968
998
  </div>
969
999
  )}
@@ -978,7 +1008,7 @@ const AndyChat: React.FC<AndyChatProps> = ({
978
1008
  )
979
1009
  })}
980
1010
 
981
- {isLoading && (
1011
+ {isLoading && !replyStarted && (
982
1012
  <div className='andy-message-container andy-message-container--bot andy-message-container--typing'>
983
1013
  <div className='andy-message andy-message--bot andy-message--typing'>
984
1014
  <div className='andy-typing-indicator'>
@@ -1026,6 +1056,7 @@ const AndyChat: React.FC<AndyChatProps> = ({
1026
1056
  placeholder={config ? strings.inputPlaceholder : '...'}
1027
1057
  aria-label={strings.messageInput}
1028
1058
  onKeyDown={e => {
1059
+ if (e.key === 'Enter' && activeReplyRef.current) { e.preventDefault(); return }
1029
1060
  if (e.key === 'Enter' && (e.target as HTMLInputElement).value.trim()) {
1030
1061
  handleSendMessage((e.target as HTMLInputElement).value)
1031
1062
  ;(e.target as HTMLInputElement).value = ''
@@ -1039,6 +1070,7 @@ const AndyChat: React.FC<AndyChatProps> = ({
1039
1070
  id='andy-send-button'
1040
1071
  aria-label={strings.sendMessage}
1041
1072
  onClick={() => {
1073
+ if (activeReplyRef.current) return
1042
1074
  const input = shadowRef.current?.getElementById(
1043
1075
  'andy-message-input'
1044
1076
  ) as HTMLInputElement | null
@@ -1047,7 +1079,7 @@ const AndyChat: React.FC<AndyChatProps> = ({
1047
1079
  input.value = ''
1048
1080
  }
1049
1081
  }}
1050
- disabled={isCollectingUserInfo || !config}
1082
+ disabled={isCollectingUserInfo || isLoading || !config}
1051
1083
  >
1052
1084
  <svg
1053
1085
  className='andy-input__button-icon'
@@ -25,14 +25,15 @@ const MessageInput: React.FC<MessageInputProps> = ({
25
25
 
26
26
  try {
27
27
  setIsSubmitting(true)
28
- await onSendMessage(trimmedMessage)
29
28
  setMessage('')
29
+ await onSendMessage(trimmedMessage)
30
30
 
31
31
  if (inputRef.current) {
32
32
  inputRef.current.focus()
33
33
  }
34
34
  } catch (error) {
35
35
  console.error('Error sending message:', error)
36
+ setMessage(current => current || trimmedMessage)
36
37
  } finally {
37
38
  setIsSubmitting(false)
38
39
  }
@@ -61,7 +62,7 @@ const MessageInput: React.FC<MessageInputProps> = ({
61
62
  onChange={handleInputChange}
62
63
  onKeyDown={handleKeyDown}
63
64
  placeholder={placeholder}
64
- disabled={isInputDisabled}
65
+ disabled={disabled}
65
66
  maxLength={1000}
66
67
  autoComplete='off'
67
68
  id='andy-input'
@@ -17,7 +17,7 @@ const MessageComponent: React.FC<MessageProps> = ({ message, botName }) => {
17
17
 
18
18
  const messageClasses = ['andy-message', `andy-message--${message.sender}`].filter(Boolean).join(' ')
19
19
 
20
- const messageContent = isBot ? parseMarkdown(message.content) : message.content
20
+ const messageContent = isBot && !message.isStreaming ? parseMarkdown(message.content) : message.content
21
21
 
22
22
  if (isBot) {
23
23
  return (
@@ -46,7 +46,9 @@ const MessageComponent: React.FC<MessageProps> = ({ message, botName }) => {
46
46
  <span className='andy-bot-header__name'>{botName || 'Andy bot'}</span>
47
47
  </div>
48
48
  <div className={messageClasses}>
49
- <div className='andy-message__content' dangerouslySetInnerHTML={{ __html: messageContent }} />
49
+ {message.isStreaming
50
+ ? <div className='andy-message__content andy-message__stream'>{message.content}</div>
51
+ : <div className='andy-message__content' dangerouslySetInnerHTML={{ __html: messageContent }} />}
50
52
  </div>
51
53
  </div>
52
54
  )
@@ -105,7 +107,7 @@ const MessageList: React.FC<MessageListProps> = ({ messages, isLoading, logoUrl,
105
107
  />
106
108
  ))}
107
109
 
108
- {isLoading && <TypingIndicator />}
110
+ {isLoading && !messages.some(message => message.isStreaming) && <TypingIndicator />}
109
111
 
110
112
  {messages.length === 0 && !isLoading && (
111
113
  <div className='andy-empty-state'>
@@ -1,4 +1,4 @@
1
- import { useState, useCallback, useRef } from 'react'
1
+ import { useState, useCallback, useRef, useEffect } from 'react'
2
2
  import {
3
3
  fetchChatbotConfig,
4
4
  generateSessionId,
@@ -31,6 +31,12 @@ export const useAndyChat = (embedId: string): UseAndyChatReturn => {
31
31
  const [showingUserInfoForm, setShowingUserInfoForm] = useState(false)
32
32
  const [welcomePopupShown, setWelcomePopupShown] = useState(false)
33
33
 
34
+ const activeReplyRef = useRef<AbortController | null>(null)
35
+ useEffect(() => () => {
36
+ activeReplyRef.current?.abort()
37
+ activeReplyRef.current = null
38
+ }, [embedId])
39
+
34
40
  const welcomeTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
35
41
  const userInfoTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
36
42
 
@@ -127,10 +133,12 @@ export const useAndyChat = (embedId: string): UseAndyChatReturn => {
127
133
 
128
134
  const sendMessage = useCallback(
129
135
  async (messageText: string) => {
130
- if (!messageText.trim() || !config || !sessionId) {
136
+ if (!messageText.trim() || !config || !sessionId || activeReplyRef.current) {
131
137
  return
132
138
  }
133
139
 
140
+ const controller = new AbortController()
141
+ activeReplyRef.current = controller
134
142
  const userMessage: Message = {
135
143
  id: Date.now(),
136
144
  content: messageText,
@@ -140,21 +148,26 @@ export const useAndyChat = (embedId: string): UseAndyChatReturn => {
140
148
 
141
149
  setMessages(prev => [...prev, userMessage])
142
150
 
151
+ const replyId = Date.now() + 1
152
+ const showReply = (content: string, isStreaming: boolean) => {
153
+ if (controller.signal.aborted) return
154
+ const reply: Message = { id: replyId, content, sender: 'bot', timestamp: new Date().toISOString(), isStreaming }
155
+ setMessages(prev => prev.some(message => message.id === replyId)
156
+ ? prev.map(message => message.id === replyId ? reply : message)
157
+ : [...prev, reply])
158
+ }
143
159
  try {
144
- const response = await sendMessageToAPI(embedId, sessionId, messageText)
145
- if (response.status === 'waiting') return response
146
-
147
- const botMessage: Message = {
148
- id: Date.now() + 1,
149
- content: response.text || 'I apologize, but I encountered an error processing your request.',
150
- sender: 'bot',
151
- timestamp: new Date().toISOString()
160
+ const response = await sendMessageToAPI(embedId, sessionId, messageText, { signal: controller.signal, onText: text => showReply(text, true) })
161
+ if (controller.signal.aborted) return
162
+ if (response.status === 'waiting') {
163
+ setMessages(prev => prev.filter(message => message.id !== replyId))
164
+ return response
152
165
  }
153
-
154
- setMessages(prev => [...prev, botMessage])
166
+ showReply(response.text, false)
155
167
 
156
168
  return response
157
169
  } catch (err) {
170
+ if (controller.signal.aborted) return
158
171
  console.error('Error sending message:', err)
159
172
 
160
173
  const errorMessage: Message = {
@@ -165,8 +178,10 @@ export const useAndyChat = (embedId: string): UseAndyChatReturn => {
165
178
  isError: true
166
179
  }
167
180
 
168
- setMessages(prev => [...prev, errorMessage])
181
+ setMessages(prev => [...prev.filter(message => message.id !== replyId), errorMessage])
169
182
  throw err
183
+ } finally {
184
+ if (activeReplyRef.current === controller) activeReplyRef.current = null
170
185
  }
171
186
  },
172
187
  [embedId, sessionId, config]
@@ -220,6 +235,8 @@ export const useAndyChat = (embedId: string): UseAndyChatReturn => {
220
235
  )
221
236
 
222
237
  const startNewSession = useCallback(() => {
238
+ activeReplyRef.current?.abort()
239
+ activeReplyRef.current = null
223
240
  const newSessionId = generateSessionId()
224
241
  setSessionId(newSessionId)
225
242
 
@@ -66,6 +66,7 @@ export interface Message {
66
66
  isError?: boolean
67
67
  isHuman?: boolean
68
68
  isWaiting?: boolean
69
+ isStreaming?: boolean
69
70
  }
70
71
 
71
72
  // User info for form submission