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.
- package/dist/index.cjs +190 -96
- package/dist/index.cjs.map +1 -1
- package/dist/index.esm.js +190 -96
- package/dist/index.esm.js.map +1 -1
- package/dist/types/types/index.d.ts +1 -0
- package/package.json +2 -2
- package/src/AndyChat.tsx +49 -17
- package/src/components/MessageInput.tsx +3 -2
- package/src/components/MessageList.tsx +5 -3
- package/src/hooks/useAndyChat.ts +30 -13
- package/src/types/index.ts +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "andy-widget-react",
|
|
3
|
-
"version": "2.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =>
|
|
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
|
-
|
|
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
|
-
|
|
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={
|
|
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
|
-
|
|
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'>
|
package/src/hooks/useAndyChat.ts
CHANGED
|
@@ -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 (
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
|