@object-ui/plugin-chatbot 0.3.1 → 2.0.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/.turbo/turbo-build.log +16 -0
- package/CHANGELOG.md +14 -0
- package/dist/index.js +31288 -361
- package/dist/index.umd.cjs +38 -2
- package/dist/src/ChatbotEnhanced.d.ts +38 -0
- package/dist/src/ChatbotEnhanced.d.ts.map +1 -0
- package/dist/src/utils.d.ts +13 -0
- package/dist/src/utils.d.ts.map +1 -0
- package/package.json +11 -7
- package/src/ChatbotEnhanced.tsx +375 -0
- package/src/__tests__/ChatbotEnhanced.test.tsx +199 -0
- package/src/renderer.tsx +123 -2
- package/src/utils.ts +18 -0
- package/vite.config.ts +5 -0
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ObjectUI
|
|
3
|
+
* Copyright (c) 2024-present ObjectStack Inc.
|
|
4
|
+
*
|
|
5
|
+
* This source code is licensed under the MIT license found in the
|
|
6
|
+
* LICENSE file in the root directory of this source tree.
|
|
7
|
+
*/
|
|
8
|
+
import * as React from "react";
|
|
9
|
+
export interface ChatMessage {
|
|
10
|
+
id: string;
|
|
11
|
+
role: "user" | "assistant" | "system";
|
|
12
|
+
content: string;
|
|
13
|
+
timestamp?: string;
|
|
14
|
+
avatar?: string;
|
|
15
|
+
avatarFallback?: string;
|
|
16
|
+
streaming?: boolean;
|
|
17
|
+
}
|
|
18
|
+
export interface ChatbotEnhancedProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
19
|
+
messages?: ChatMessage[];
|
|
20
|
+
placeholder?: string;
|
|
21
|
+
onSendMessage?: (message: string, files?: File[]) => void;
|
|
22
|
+
onClear?: () => void;
|
|
23
|
+
disabled?: boolean;
|
|
24
|
+
showTimestamp?: boolean;
|
|
25
|
+
userAvatarUrl?: string;
|
|
26
|
+
userAvatarFallback?: string;
|
|
27
|
+
assistantAvatarUrl?: string;
|
|
28
|
+
assistantAvatarFallback?: string;
|
|
29
|
+
maxHeight?: string;
|
|
30
|
+
enableMarkdown?: boolean;
|
|
31
|
+
enableFileUpload?: boolean;
|
|
32
|
+
acceptedFileTypes?: string;
|
|
33
|
+
maxFileSize?: number;
|
|
34
|
+
onStreamingUpdate?: (messageId: string, content: string) => void;
|
|
35
|
+
}
|
|
36
|
+
declare const ChatbotEnhanced: React.ForwardRefExoticComponent<ChatbotEnhancedProps & React.RefAttributes<HTMLDivElement>>;
|
|
37
|
+
export { ChatbotEnhanced };
|
|
38
|
+
//# sourceMappingURL=ChatbotEnhanced.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ChatbotEnhanced.d.ts","sourceRoot":"","sources":["../../src/ChatbotEnhanced.tsx"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,OAAO,KAAK,KAAK,MAAM,OAAO,CAAA;AAS9B,MAAM,WAAW,WAAW;IAC1B,EAAE,EAAE,MAAM,CAAA;IACV,IAAI,EAAE,MAAM,GAAG,WAAW,GAAG,QAAQ,CAAA;IACrC,OAAO,EAAE,MAAM,CAAA;IACf,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,cAAc,CAAC,EAAE,MAAM,CAAA;IACvB,SAAS,CAAC,EAAE,OAAO,CAAA;CACpB;AAED,MAAM,WAAW,oBAAqB,SAAQ,KAAK,CAAC,cAAc,CAAC,cAAc,CAAC;IAChF,QAAQ,CAAC,EAAE,WAAW,EAAE,CAAA;IACxB,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,aAAa,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,EAAE,KAAK,CAAC,EAAE,IAAI,EAAE,KAAK,IAAI,CAAA;IACzD,OAAO,CAAC,EAAE,MAAM,IAAI,CAAA;IACpB,QAAQ,CAAC,EAAE,OAAO,CAAA;IAClB,aAAa,CAAC,EAAE,OAAO,CAAA;IACvB,aAAa,CAAC,EAAE,MAAM,CAAA;IACtB,kBAAkB,CAAC,EAAE,MAAM,CAAA;IAC3B,kBAAkB,CAAC,EAAE,MAAM,CAAA;IAC3B,uBAAuB,CAAC,EAAE,MAAM,CAAA;IAChC,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,cAAc,CAAC,EAAE,OAAO,CAAA;IACxB,gBAAgB,CAAC,EAAE,OAAO,CAAA;IAC1B,iBAAiB,CAAC,EAAE,MAAM,CAAA;IAC1B,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,iBAAiB,CAAC,EAAE,CAAC,SAAS,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,KAAK,IAAI,CAAA;CACjE;AAiED,QAAA,MAAM,eAAe,6FAqQpB,CAAA;AAID,OAAO,EAAE,eAAe,EAAE,CAAA"}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ObjectUI
|
|
3
|
+
* Copyright (c) 2024-present ObjectStack Inc.
|
|
4
|
+
*
|
|
5
|
+
* This source code is licensed under the MIT license found in the
|
|
6
|
+
* LICENSE file in the root directory of this source tree.
|
|
7
|
+
*/
|
|
8
|
+
/**
|
|
9
|
+
* Generates a unique ID for messages or other entities
|
|
10
|
+
* Uses crypto.randomUUID() if available, otherwise falls back to timestamp + random string
|
|
11
|
+
*/
|
|
12
|
+
export declare function generateUniqueId(prefix?: string): string;
|
|
13
|
+
//# sourceMappingURL=utils.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"utils.d.ts","sourceRoot":"","sources":["../../src/utils.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH;;;GAGG;AACH,wBAAgB,gBAAgB,CAAC,MAAM,SAAQ,GAAG,MAAM,CAKvD"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@object-ui/plugin-chatbot",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "2.0.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"description": "Chatbot interface plugin for Object UI",
|
|
@@ -25,19 +25,23 @@
|
|
|
25
25
|
},
|
|
26
26
|
"dependencies": {
|
|
27
27
|
"lucide-react": "^0.563.0",
|
|
28
|
-
"
|
|
29
|
-
"
|
|
30
|
-
"
|
|
31
|
-
"@object-ui/
|
|
28
|
+
"react-markdown": "^10.1.0",
|
|
29
|
+
"react-syntax-highlighter": "^16.1.0",
|
|
30
|
+
"remark-gfm": "^4.0.0",
|
|
31
|
+
"@object-ui/react": "2.0.0",
|
|
32
|
+
"@object-ui/core": "2.0.0",
|
|
33
|
+
"@object-ui/components": "2.0.0",
|
|
34
|
+
"@object-ui/types": "2.0.0"
|
|
32
35
|
},
|
|
33
36
|
"peerDependencies": {
|
|
34
37
|
"react": "^18.0.0 || ^19.0.0",
|
|
35
38
|
"react-dom": "^18.0.0 || ^19.0.0"
|
|
36
39
|
},
|
|
37
40
|
"devDependencies": {
|
|
38
|
-
"@types/react": "^19.2.
|
|
41
|
+
"@types/react": "^19.2.13",
|
|
39
42
|
"@types/react-dom": "^19.2.3",
|
|
40
|
-
"@
|
|
43
|
+
"@types/react-syntax-highlighter": "^15.5.13",
|
|
44
|
+
"@vitejs/plugin-react": "^5.1.3",
|
|
41
45
|
"typescript": "^5.9.3",
|
|
42
46
|
"vite": "^7.3.1",
|
|
43
47
|
"vite-plugin-dts": "^4.5.4"
|
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ObjectUI
|
|
3
|
+
* Copyright (c) 2024-present ObjectStack Inc.
|
|
4
|
+
*
|
|
5
|
+
* This source code is licensed under the MIT license found in the
|
|
6
|
+
* LICENSE file in the root directory of this source tree.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import * as React from "react"
|
|
10
|
+
import { cn } from "@object-ui/components"
|
|
11
|
+
import { Button, Input, ScrollArea, Avatar, AvatarFallback, AvatarImage } from "@object-ui/components"
|
|
12
|
+
import { Send, Trash2, Paperclip, X } from "lucide-react"
|
|
13
|
+
import ReactMarkdown from "react-markdown"
|
|
14
|
+
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"
|
|
15
|
+
import { oneDark } from "react-syntax-highlighter/dist/esm/styles/prism"
|
|
16
|
+
import remarkGfm from "remark-gfm"
|
|
17
|
+
|
|
18
|
+
export interface ChatMessage {
|
|
19
|
+
id: string
|
|
20
|
+
role: "user" | "assistant" | "system"
|
|
21
|
+
content: string
|
|
22
|
+
timestamp?: string
|
|
23
|
+
avatar?: string
|
|
24
|
+
avatarFallback?: string
|
|
25
|
+
streaming?: boolean
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface ChatbotEnhancedProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
29
|
+
messages?: ChatMessage[]
|
|
30
|
+
placeholder?: string
|
|
31
|
+
onSendMessage?: (message: string, files?: File[]) => void
|
|
32
|
+
onClear?: () => void
|
|
33
|
+
disabled?: boolean
|
|
34
|
+
showTimestamp?: boolean
|
|
35
|
+
userAvatarUrl?: string
|
|
36
|
+
userAvatarFallback?: string
|
|
37
|
+
assistantAvatarUrl?: string
|
|
38
|
+
assistantAvatarFallback?: string
|
|
39
|
+
maxHeight?: string
|
|
40
|
+
enableMarkdown?: boolean
|
|
41
|
+
enableFileUpload?: boolean
|
|
42
|
+
acceptedFileTypes?: string
|
|
43
|
+
maxFileSize?: number
|
|
44
|
+
onStreamingUpdate?: (messageId: string, content: string) => void
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function MessageContent({ content, enableMarkdown }: { content: string; enableMarkdown?: boolean }) {
|
|
48
|
+
if (!enableMarkdown) {
|
|
49
|
+
return <div className="whitespace-pre-wrap">{content}</div>
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return (
|
|
53
|
+
<div className="prose prose-sm dark:prose-invert max-w-none">
|
|
54
|
+
<ReactMarkdown
|
|
55
|
+
remarkPlugins={[remarkGfm]}
|
|
56
|
+
components={{
|
|
57
|
+
code({ node, inline, className, children, ...props }: any) {
|
|
58
|
+
const match = /language-(\w+)/.exec(className || '')
|
|
59
|
+
return !inline && match ? (
|
|
60
|
+
<SyntaxHighlighter
|
|
61
|
+
style={oneDark}
|
|
62
|
+
language={match[1]}
|
|
63
|
+
PreTag="div"
|
|
64
|
+
className="rounded-md my-2"
|
|
65
|
+
{...props}
|
|
66
|
+
>
|
|
67
|
+
{String(children).replace(/\n$/, '')}
|
|
68
|
+
</SyntaxHighlighter>
|
|
69
|
+
) : (
|
|
70
|
+
<code className={cn("bg-muted px-1 py-0.5 rounded text-sm", className)} {...props}>
|
|
71
|
+
{children}
|
|
72
|
+
</code>
|
|
73
|
+
)
|
|
74
|
+
},
|
|
75
|
+
p: ({ children }) => <p className="mb-2 last:mb-0">{children}</p>,
|
|
76
|
+
ul: ({ children }) => <ul className="list-disc pl-4 mb-2">{children}</ul>,
|
|
77
|
+
ol: ({ children }) => <ol className="list-decimal pl-4 mb-2">{children}</ol>,
|
|
78
|
+
li: ({ children }) => <li className="mb-1">{children}</li>,
|
|
79
|
+
a: ({ href, children }) => (
|
|
80
|
+
<a href={href} className="text-primary hover:underline" target="_blank" rel="noopener noreferrer">
|
|
81
|
+
{children}
|
|
82
|
+
</a>
|
|
83
|
+
),
|
|
84
|
+
blockquote: ({ children }) => (
|
|
85
|
+
<blockquote className="border-l-4 border-primary pl-4 italic my-2 text-muted-foreground">
|
|
86
|
+
{children}
|
|
87
|
+
</blockquote>
|
|
88
|
+
),
|
|
89
|
+
table: ({ children }) => (
|
|
90
|
+
<div className="overflow-x-auto my-2">
|
|
91
|
+
<table className="min-w-full divide-y divide-border">{children}</table>
|
|
92
|
+
</div>
|
|
93
|
+
),
|
|
94
|
+
th: ({ children }) => (
|
|
95
|
+
<th className="px-3 py-2 text-left text-xs font-medium uppercase tracking-wider bg-muted">
|
|
96
|
+
{children}
|
|
97
|
+
</th>
|
|
98
|
+
),
|
|
99
|
+
td: ({ children }) => (
|
|
100
|
+
<td className="px-3 py-2 text-sm border-t border-border">{children}</td>
|
|
101
|
+
),
|
|
102
|
+
}}
|
|
103
|
+
>
|
|
104
|
+
{content}
|
|
105
|
+
</ReactMarkdown>
|
|
106
|
+
</div>
|
|
107
|
+
)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const ChatbotEnhanced = React.forwardRef<HTMLDivElement, ChatbotEnhancedProps>(
|
|
111
|
+
(
|
|
112
|
+
{
|
|
113
|
+
className,
|
|
114
|
+
messages = [],
|
|
115
|
+
placeholder = "Type your message...",
|
|
116
|
+
onSendMessage,
|
|
117
|
+
onClear,
|
|
118
|
+
disabled = false,
|
|
119
|
+
showTimestamp = false,
|
|
120
|
+
userAvatarUrl,
|
|
121
|
+
userAvatarFallback = "You",
|
|
122
|
+
assistantAvatarUrl,
|
|
123
|
+
assistantAvatarFallback = "AI",
|
|
124
|
+
maxHeight = "500px",
|
|
125
|
+
enableMarkdown = true,
|
|
126
|
+
enableFileUpload = false,
|
|
127
|
+
acceptedFileTypes = "image/*,.pdf,.doc,.docx,.txt",
|
|
128
|
+
maxFileSize = 10 * 1024 * 1024, // 10MB
|
|
129
|
+
...props
|
|
130
|
+
},
|
|
131
|
+
ref
|
|
132
|
+
) => {
|
|
133
|
+
const [inputValue, setInputValue] = React.useState("")
|
|
134
|
+
const [selectedFiles, setSelectedFiles] = React.useState<File[]>([])
|
|
135
|
+
const scrollRef = React.useRef<HTMLDivElement>(null)
|
|
136
|
+
const inputRef = React.useRef<HTMLInputElement>(null)
|
|
137
|
+
const fileInputRef = React.useRef<HTMLInputElement>(null)
|
|
138
|
+
|
|
139
|
+
// Auto-scroll to bottom when new messages arrive
|
|
140
|
+
React.useEffect(() => {
|
|
141
|
+
if (scrollRef.current) {
|
|
142
|
+
const scrollElement = scrollRef.current.querySelector('[data-radix-scroll-area-viewport]')
|
|
143
|
+
if (scrollElement) {
|
|
144
|
+
scrollElement.scrollTop = scrollElement.scrollHeight
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}, [messages])
|
|
148
|
+
|
|
149
|
+
const handleSend = () => {
|
|
150
|
+
if ((inputValue.trim() || selectedFiles.length > 0) && onSendMessage) {
|
|
151
|
+
onSendMessage(inputValue.trim(), selectedFiles)
|
|
152
|
+
setInputValue("")
|
|
153
|
+
setSelectedFiles([])
|
|
154
|
+
inputRef.current?.focus()
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
|
159
|
+
if (e.key === "Enter" && !e.shiftKey) {
|
|
160
|
+
e.preventDefault()
|
|
161
|
+
handleSend()
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
166
|
+
const files = Array.from(e.target.files || [])
|
|
167
|
+
|
|
168
|
+
// Validate file sizes and MIME types
|
|
169
|
+
const validFiles = files.filter(file => {
|
|
170
|
+
// Size validation
|
|
171
|
+
if (file.size > maxFileSize) {
|
|
172
|
+
console.warn(`File ${file.name} exceeds ${maxFileSize / 1024 / 1024}MB limit`)
|
|
173
|
+
return false
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Basic MIME type validation
|
|
177
|
+
const acceptedTypes = acceptedFileTypes.split(',').map(t => t.trim())
|
|
178
|
+
const matchesType = acceptedTypes.some(type => {
|
|
179
|
+
if (type.startsWith('.')) {
|
|
180
|
+
return file.name.toLowerCase().endsWith(type.toLowerCase())
|
|
181
|
+
}
|
|
182
|
+
if (type.endsWith('/*')) {
|
|
183
|
+
const category = type.split('/')[0]
|
|
184
|
+
return file.type.startsWith(category + '/')
|
|
185
|
+
}
|
|
186
|
+
return file.type === type
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
if (!matchesType) {
|
|
190
|
+
console.warn(`File ${file.name} type ${file.type} not accepted`)
|
|
191
|
+
return false
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return true
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
if (validFiles.length < files.length) {
|
|
198
|
+
console.warn(`${files.length - validFiles.length} file(s) were rejected`)
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
setSelectedFiles(prev => [...prev, ...validFiles])
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const handleRemoveFile = (index: number) => {
|
|
205
|
+
setSelectedFiles(prev => prev.filter((_, i) => i !== index))
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const handleClear = () => {
|
|
209
|
+
if (onClear) {
|
|
210
|
+
onClear()
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
return (
|
|
215
|
+
<div
|
|
216
|
+
ref={ref}
|
|
217
|
+
className={cn(
|
|
218
|
+
"flex flex-col border rounded-lg bg-background overflow-hidden",
|
|
219
|
+
className
|
|
220
|
+
)}
|
|
221
|
+
style={{ maxHeight }}
|
|
222
|
+
{...props}
|
|
223
|
+
>
|
|
224
|
+
{/* Header with clear button */}
|
|
225
|
+
{onClear && messages.length > 0 && (
|
|
226
|
+
<div className="flex items-center justify-between px-4 py-2 border-b bg-muted/30">
|
|
227
|
+
<span className="text-sm text-muted-foreground">
|
|
228
|
+
{messages.length} message{messages.length !== 1 ? 's' : ''}
|
|
229
|
+
</span>
|
|
230
|
+
<Button
|
|
231
|
+
variant="ghost"
|
|
232
|
+
size="sm"
|
|
233
|
+
onClick={handleClear}
|
|
234
|
+
className="h-8 text-xs"
|
|
235
|
+
>
|
|
236
|
+
<Trash2 className="h-3 w-3 mr-1" />
|
|
237
|
+
Clear
|
|
238
|
+
</Button>
|
|
239
|
+
</div>
|
|
240
|
+
)}
|
|
241
|
+
|
|
242
|
+
{/* Messages area */}
|
|
243
|
+
<ScrollArea ref={scrollRef} className="flex-1 p-4">
|
|
244
|
+
<div className="space-y-4">
|
|
245
|
+
{messages.length === 0 ? (
|
|
246
|
+
<div className="flex items-center justify-center h-32 text-muted-foreground text-sm">
|
|
247
|
+
Start a conversation...
|
|
248
|
+
</div>
|
|
249
|
+
) : (
|
|
250
|
+
messages.map((message) => (
|
|
251
|
+
<div
|
|
252
|
+
key={message.id}
|
|
253
|
+
className={cn(
|
|
254
|
+
"flex gap-3",
|
|
255
|
+
message.role === "user" ? "justify-end" : "justify-start"
|
|
256
|
+
)}
|
|
257
|
+
>
|
|
258
|
+
{message.role !== "user" && (
|
|
259
|
+
<Avatar className="h-8 w-8 flex-shrink-0">
|
|
260
|
+
<AvatarImage src={message.avatar || assistantAvatarUrl} />
|
|
261
|
+
<AvatarFallback>{message.avatarFallback || assistantAvatarFallback}</AvatarFallback>
|
|
262
|
+
</Avatar>
|
|
263
|
+
)}
|
|
264
|
+
|
|
265
|
+
<div
|
|
266
|
+
className={cn(
|
|
267
|
+
"rounded-lg px-4 py-2 max-w-[80%]",
|
|
268
|
+
message.role === "user"
|
|
269
|
+
? "bg-primary text-primary-foreground"
|
|
270
|
+
: "bg-muted"
|
|
271
|
+
)}
|
|
272
|
+
>
|
|
273
|
+
<MessageContent content={message.content} enableMarkdown={enableMarkdown} />
|
|
274
|
+
{showTimestamp && message.timestamp && (
|
|
275
|
+
<div className="text-xs opacity-70 mt-1">
|
|
276
|
+
{message.timestamp}
|
|
277
|
+
</div>
|
|
278
|
+
)}
|
|
279
|
+
{message.streaming && (
|
|
280
|
+
<div className="flex gap-1 mt-2">
|
|
281
|
+
<div className="w-2 h-2 bg-current rounded-full animate-bounce" style={{ animationDelay: '0ms' }} />
|
|
282
|
+
<div className="w-2 h-2 bg-current rounded-full animate-bounce" style={{ animationDelay: '150ms' }} />
|
|
283
|
+
<div className="w-2 h-2 bg-current rounded-full animate-bounce" style={{ animationDelay: '300ms' }} />
|
|
284
|
+
</div>
|
|
285
|
+
)}
|
|
286
|
+
</div>
|
|
287
|
+
|
|
288
|
+
{message.role === "user" && (
|
|
289
|
+
<Avatar className="h-8 w-8 flex-shrink-0">
|
|
290
|
+
<AvatarImage src={message.avatar || userAvatarUrl} />
|
|
291
|
+
<AvatarFallback>{message.avatarFallback || userAvatarFallback}</AvatarFallback>
|
|
292
|
+
</Avatar>
|
|
293
|
+
)}
|
|
294
|
+
</div>
|
|
295
|
+
))
|
|
296
|
+
)}
|
|
297
|
+
</div>
|
|
298
|
+
</ScrollArea>
|
|
299
|
+
|
|
300
|
+
{/* Selected files preview */}
|
|
301
|
+
{selectedFiles.length > 0 && (
|
|
302
|
+
<div className="px-4 py-2 border-t bg-muted/30">
|
|
303
|
+
<div className="flex flex-wrap gap-2">
|
|
304
|
+
{selectedFiles.map((file, index) => (
|
|
305
|
+
<div
|
|
306
|
+
key={index}
|
|
307
|
+
className="flex items-center gap-2 bg-background border rounded px-2 py-1 text-xs"
|
|
308
|
+
>
|
|
309
|
+
<Paperclip className="h-3 w-3" />
|
|
310
|
+
<span className="max-w-[150px] truncate">{file.name}</span>
|
|
311
|
+
<Button
|
|
312
|
+
variant="ghost"
|
|
313
|
+
size="sm"
|
|
314
|
+
className="h-4 w-4 p-0"
|
|
315
|
+
onClick={() => handleRemoveFile(index)}
|
|
316
|
+
>
|
|
317
|
+
<X className="h-3 w-3" />
|
|
318
|
+
</Button>
|
|
319
|
+
</div>
|
|
320
|
+
))}
|
|
321
|
+
</div>
|
|
322
|
+
</div>
|
|
323
|
+
)}
|
|
324
|
+
|
|
325
|
+
{/* Input area */}
|
|
326
|
+
<div className="flex items-center gap-2 p-4 border-t">
|
|
327
|
+
{enableFileUpload && (
|
|
328
|
+
<>
|
|
329
|
+
<input
|
|
330
|
+
ref={fileInputRef}
|
|
331
|
+
type="file"
|
|
332
|
+
multiple
|
|
333
|
+
accept={acceptedFileTypes}
|
|
334
|
+
onChange={handleFileSelect}
|
|
335
|
+
className="hidden"
|
|
336
|
+
/>
|
|
337
|
+
<Button
|
|
338
|
+
variant="ghost"
|
|
339
|
+
size="icon"
|
|
340
|
+
onClick={() => fileInputRef.current?.click()}
|
|
341
|
+
disabled={disabled}
|
|
342
|
+
aria-label="Attach file"
|
|
343
|
+
>
|
|
344
|
+
<Paperclip className="h-4 w-4" />
|
|
345
|
+
</Button>
|
|
346
|
+
</>
|
|
347
|
+
)}
|
|
348
|
+
|
|
349
|
+
<Input
|
|
350
|
+
ref={inputRef}
|
|
351
|
+
value={inputValue}
|
|
352
|
+
onChange={(e) => setInputValue(e.target.value)}
|
|
353
|
+
onKeyDown={handleKeyDown}
|
|
354
|
+
placeholder={placeholder}
|
|
355
|
+
disabled={disabled}
|
|
356
|
+
className="flex-1"
|
|
357
|
+
/>
|
|
358
|
+
|
|
359
|
+
<Button
|
|
360
|
+
onClick={handleSend}
|
|
361
|
+
disabled={disabled || (!inputValue.trim() && selectedFiles.length === 0)}
|
|
362
|
+
size="icon"
|
|
363
|
+
aria-label="Send message"
|
|
364
|
+
>
|
|
365
|
+
<Send className="h-4 w-4" />
|
|
366
|
+
</Button>
|
|
367
|
+
</div>
|
|
368
|
+
</div>
|
|
369
|
+
)
|
|
370
|
+
}
|
|
371
|
+
)
|
|
372
|
+
|
|
373
|
+
ChatbotEnhanced.displayName = "ChatbotEnhanced"
|
|
374
|
+
|
|
375
|
+
export { ChatbotEnhanced }
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ObjectUI
|
|
3
|
+
* Copyright (c) 2024-present ObjectStack Inc.
|
|
4
|
+
*
|
|
5
|
+
* This source code is licensed under the MIT license found in the
|
|
6
|
+
* LICENSE file in the root directory of this source tree.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import '@testing-library/jest-dom';
|
|
10
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
11
|
+
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
|
12
|
+
import { ChatbotEnhanced, type ChatMessage } from '../ChatbotEnhanced';
|
|
13
|
+
|
|
14
|
+
describe('ChatbotEnhanced', () => {
|
|
15
|
+
const mockMessages: ChatMessage[] = [
|
|
16
|
+
{
|
|
17
|
+
id: '1',
|
|
18
|
+
role: 'user',
|
|
19
|
+
content: 'Hello!',
|
|
20
|
+
timestamp: '10:00 AM',
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
id: '2',
|
|
24
|
+
role: 'assistant',
|
|
25
|
+
content: 'Hi there! How can I help you?',
|
|
26
|
+
timestamp: '10:01 AM',
|
|
27
|
+
},
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
it('should render without crashing', () => {
|
|
31
|
+
const { container } = render(<ChatbotEnhanced />);
|
|
32
|
+
expect(container).toBeTruthy();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('should render messages', () => {
|
|
36
|
+
render(<ChatbotEnhanced messages={mockMessages} />);
|
|
37
|
+
|
|
38
|
+
expect(screen.getByText('Hello!')).toBeInTheDocument();
|
|
39
|
+
expect(screen.getByText('Hi there! How can I help you?')).toBeInTheDocument();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('should render placeholder in input', () => {
|
|
43
|
+
const placeholder = 'Type your message...';
|
|
44
|
+
render(<ChatbotEnhanced placeholder={placeholder} />);
|
|
45
|
+
|
|
46
|
+
expect(screen.getByPlaceholderText(placeholder)).toBeInTheDocument();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('should call onSendMessage when send button is clicked', async () => {
|
|
50
|
+
const onSendMessage = vi.fn();
|
|
51
|
+
render(<ChatbotEnhanced onSendMessage={onSendMessage} />);
|
|
52
|
+
|
|
53
|
+
const input = screen.getByPlaceholderText(/message/i);
|
|
54
|
+
const sendButton = screen.getByRole('button', { name: /send/i });
|
|
55
|
+
|
|
56
|
+
fireEvent.change(input, { target: { value: 'Test message' } });
|
|
57
|
+
fireEvent.click(sendButton);
|
|
58
|
+
|
|
59
|
+
await waitFor(() => {
|
|
60
|
+
expect(onSendMessage).toHaveBeenCalledWith('Test message', []);
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('should call onSendMessage when Enter is pressed', async () => {
|
|
65
|
+
const onSendMessage = vi.fn();
|
|
66
|
+
render(<ChatbotEnhanced onSendMessage={onSendMessage} />);
|
|
67
|
+
|
|
68
|
+
const input = screen.getByPlaceholderText(/message/i);
|
|
69
|
+
|
|
70
|
+
fireEvent.change(input, { target: { value: 'Test message' } });
|
|
71
|
+
fireEvent.keyDown(input, { key: 'Enter', code: 'Enter' });
|
|
72
|
+
|
|
73
|
+
await waitFor(() => {
|
|
74
|
+
expect(onSendMessage).toHaveBeenCalledWith('Test message', []);
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('should not send empty messages', async () => {
|
|
79
|
+
const onSendMessage = vi.fn();
|
|
80
|
+
render(<ChatbotEnhanced onSendMessage={onSendMessage} />);
|
|
81
|
+
|
|
82
|
+
const sendButton = screen.getByRole('button', { name: /send/i });
|
|
83
|
+
|
|
84
|
+
fireEvent.click(sendButton);
|
|
85
|
+
|
|
86
|
+
await waitFor(() => {
|
|
87
|
+
expect(onSendMessage).not.toHaveBeenCalled();
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('should call onClear when clear button is clicked', () => {
|
|
92
|
+
const onClear = vi.fn();
|
|
93
|
+
render(<ChatbotEnhanced messages={mockMessages} onClear={onClear} />);
|
|
94
|
+
|
|
95
|
+
const clearButton = screen.getByRole('button', { name: /clear/i });
|
|
96
|
+
fireEvent.click(clearButton);
|
|
97
|
+
|
|
98
|
+
expect(onClear).toHaveBeenCalled();
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('should render markdown content when enabled', () => {
|
|
102
|
+
const markdownMessage: ChatMessage[] = [
|
|
103
|
+
{
|
|
104
|
+
id: '1',
|
|
105
|
+
role: 'assistant',
|
|
106
|
+
content: '**Bold text** and *italic text*',
|
|
107
|
+
},
|
|
108
|
+
];
|
|
109
|
+
|
|
110
|
+
render(<ChatbotEnhanced messages={markdownMessage} enableMarkdown={true} />);
|
|
111
|
+
|
|
112
|
+
// Check that markdown is rendered (will create strong and em tags)
|
|
113
|
+
const { container } = render(<ChatbotEnhanced messages={markdownMessage} enableMarkdown={true} />);
|
|
114
|
+
expect(container.querySelector('strong')).toBeTruthy();
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('should render plain text when markdown is disabled', () => {
|
|
118
|
+
const textMessage: ChatMessage[] = [
|
|
119
|
+
{
|
|
120
|
+
id: '1',
|
|
121
|
+
role: 'assistant',
|
|
122
|
+
content: '**Bold text** and *italic text*',
|
|
123
|
+
},
|
|
124
|
+
];
|
|
125
|
+
|
|
126
|
+
render(<ChatbotEnhanced messages={textMessage} enableMarkdown={false} />);
|
|
127
|
+
|
|
128
|
+
// Should render as plain text
|
|
129
|
+
expect(screen.getByText('**Bold text** and *italic text*')).toBeInTheDocument();
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('should show timestamps when enabled', () => {
|
|
133
|
+
render(<ChatbotEnhanced messages={mockMessages} showTimestamp={true} />);
|
|
134
|
+
|
|
135
|
+
expect(screen.getByText('10:00 AM')).toBeInTheDocument();
|
|
136
|
+
expect(screen.getByText('10:01 AM')).toBeInTheDocument();
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('should disable input when disabled prop is true', () => {
|
|
140
|
+
render(<ChatbotEnhanced disabled={true} />);
|
|
141
|
+
|
|
142
|
+
const input = screen.getByPlaceholderText(/message/i);
|
|
143
|
+
expect(input).toBeDisabled();
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('should render file upload button when enabled', () => {
|
|
147
|
+
render(<ChatbotEnhanced enableFileUpload={true} />);
|
|
148
|
+
|
|
149
|
+
const fileButton = screen.getByRole('button', { name: /attach/i });
|
|
150
|
+
expect(fileButton).toBeInTheDocument();
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('should handle streaming messages', () => {
|
|
154
|
+
const streamingMessage: ChatMessage[] = [
|
|
155
|
+
{
|
|
156
|
+
id: '1',
|
|
157
|
+
role: 'assistant',
|
|
158
|
+
content: 'Streaming content...',
|
|
159
|
+
streaming: true,
|
|
160
|
+
},
|
|
161
|
+
];
|
|
162
|
+
|
|
163
|
+
const { container } = render(<ChatbotEnhanced messages={streamingMessage} />);
|
|
164
|
+
|
|
165
|
+
expect(screen.getByText('Streaming content...')).toBeInTheDocument();
|
|
166
|
+
// Streaming messages typically have a visual indicator (cursor/animation)
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('should render user and assistant avatars', () => {
|
|
170
|
+
render(
|
|
171
|
+
<ChatbotEnhanced
|
|
172
|
+
messages={mockMessages}
|
|
173
|
+
userAvatarFallback="U"
|
|
174
|
+
assistantAvatarFallback="A"
|
|
175
|
+
/>
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
// Avatars should be rendered
|
|
179
|
+
expect(screen.getByText('U')).toBeInTheDocument();
|
|
180
|
+
expect(screen.getByText('A')).toBeInTheDocument();
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('should clear input after sending message', async () => {
|
|
184
|
+
const onSendMessage = vi.fn();
|
|
185
|
+
render(<ChatbotEnhanced onSendMessage={onSendMessage} />);
|
|
186
|
+
|
|
187
|
+
const input = screen.getByPlaceholderText(/message/i) as HTMLInputElement;
|
|
188
|
+
|
|
189
|
+
fireEvent.change(input, { target: { value: 'Test message' } });
|
|
190
|
+
expect(input.value).toBe('Test message');
|
|
191
|
+
|
|
192
|
+
const sendButton = screen.getByRole('button', { name: /send/i });
|
|
193
|
+
fireEvent.click(sendButton);
|
|
194
|
+
|
|
195
|
+
await waitFor(() => {
|
|
196
|
+
expect(input.value).toBe('');
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
});
|