acecoderz-chat-ui 1.0.4 → 1.0.7
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/README.md +96 -0
- package/dist/adapters/react/ChatUI.d.ts.map +1 -1
- package/dist/adapters/react/ChatUI.js +34 -3
- package/dist/adapters/vanilla/index.d.ts +8 -0
- package/dist/adapters/vanilla/index.d.ts.map +1 -1
- package/dist/adapters/vanilla/index.js +297 -21
- package/dist/browser/chatbot-ui.css +192 -0
- package/dist/browser/chatbot-ui.js +345 -20
- package/dist/browser/chatbot-ui.js.map +3 -3
- package/dist/browser/chatbot-ui.min.js +94 -36
- package/dist/core/src/ChatEngine.d.ts +5 -0
- package/dist/core/src/ChatEngine.d.ts.map +1 -1
- package/dist/core/src/ChatEngine.js +72 -0
- package/dist/core/src/types.d.ts +10 -0
- package/dist/core/src/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/styles/chat.css +192 -0
package/README.md
CHANGED
|
@@ -11,6 +11,10 @@ A framework-agnostic, fully customizable chat UI package that works with React,
|
|
|
11
11
|
- 🎯 **TypeScript** - Full TypeScript support
|
|
12
12
|
- 📦 **Modular** - Import only what you need
|
|
13
13
|
- ✅ **Package Manager Compatible** - Works with both npm and pnpm
|
|
14
|
+
- 💾 **Message Persistence** - Automatic localStorage save/restore
|
|
15
|
+
- ✂️ **Message Actions** - Copy, delete, and edit messages
|
|
16
|
+
- ♿ **Accessible** - Full ARIA support and keyboard navigation
|
|
17
|
+
- 🎭 **Improved Loading** - Animated indicators with accessibility
|
|
14
18
|
|
|
15
19
|
## Installation
|
|
16
20
|
|
|
@@ -189,17 +193,26 @@ const container = document.getElementById('chat-container');
|
|
|
189
193
|
const chatUI = createChatUI({
|
|
190
194
|
config: {
|
|
191
195
|
apiUrl: 'http://localhost:3000/api',
|
|
196
|
+
enablePersistence: true, // Optional: Save messages to localStorage
|
|
197
|
+
storageKey: 'my-chat-app', // Optional: Custom storage key
|
|
192
198
|
},
|
|
193
199
|
theme: {
|
|
194
200
|
primaryColor: '#3b82f6',
|
|
195
201
|
},
|
|
196
202
|
container,
|
|
203
|
+
// Optional: Enable message actions (copy, delete, edit)
|
|
204
|
+
enableMessageActions: true,
|
|
205
|
+
showCopyButton: true,
|
|
206
|
+
showDeleteButton: true,
|
|
207
|
+
showEditButton: true,
|
|
197
208
|
});
|
|
198
209
|
|
|
199
210
|
// Later, to destroy:
|
|
200
211
|
// chatUI.destroy();
|
|
201
212
|
```
|
|
202
213
|
|
|
214
|
+
**Note**: The vanilla adapter now handles form submission automatically - no workarounds needed! See [INTEGRATION_GUIDE.md](../../INTEGRATION_GUIDE.md) for complete integration examples.
|
|
215
|
+
|
|
203
216
|
### Core Only (Advanced)
|
|
204
217
|
|
|
205
218
|
```typescript
|
|
@@ -259,9 +272,92 @@ interface ChatConfig {
|
|
|
259
272
|
onError?: (error: Error) => void; // Error callback
|
|
260
273
|
enableWebSocket?: boolean; // Enable WebSocket
|
|
261
274
|
websocketUrl?: string; // WebSocket URL
|
|
275
|
+
enablePersistence?: boolean; // Enable localStorage persistence (default: false)
|
|
276
|
+
storageKey?: string; // Storage key prefix (default: 'chat-ui')
|
|
262
277
|
}
|
|
263
278
|
```
|
|
264
279
|
|
|
280
|
+
## New Features
|
|
281
|
+
|
|
282
|
+
### Message Persistence
|
|
283
|
+
|
|
284
|
+
Automatically save and restore conversations across page reloads:
|
|
285
|
+
|
|
286
|
+
```javascript
|
|
287
|
+
const chatUI = createChatUI({
|
|
288
|
+
config: {
|
|
289
|
+
apiUrl: '/api/chat',
|
|
290
|
+
enablePersistence: true, // Enable localStorage
|
|
291
|
+
storageKey: 'my-app', // Optional: custom key
|
|
292
|
+
},
|
|
293
|
+
container: document.getElementById('chat'),
|
|
294
|
+
});
|
|
295
|
+
```
|
|
296
|
+
|
|
297
|
+
### Message Actions
|
|
298
|
+
|
|
299
|
+
Enable copy, delete, and edit buttons on messages:
|
|
300
|
+
|
|
301
|
+
```javascript
|
|
302
|
+
const chatUI = createChatUI({
|
|
303
|
+
config: { apiUrl: '/api/chat' },
|
|
304
|
+
container: document.getElementById('chat'),
|
|
305
|
+
enableMessageActions: true,
|
|
306
|
+
showCopyButton: true,
|
|
307
|
+
showDeleteButton: true,
|
|
308
|
+
showEditButton: true,
|
|
309
|
+
onMessageCopy: (message) => console.log('Copied:', message.content),
|
|
310
|
+
onMessageDelete: (message) => console.log('Deleted:', message.id),
|
|
311
|
+
onMessageEdit: (message, newContent) => console.log('Edited:', newContent),
|
|
312
|
+
});
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
### Initial Greeting
|
|
316
|
+
|
|
317
|
+
Display a welcome message when the chat first opens:
|
|
318
|
+
|
|
319
|
+
**Vanilla JS:**
|
|
320
|
+
```javascript
|
|
321
|
+
const chatUI = createChatUI({
|
|
322
|
+
config: { apiUrl: '/api/chat' },
|
|
323
|
+
container: document.getElementById('chat'),
|
|
324
|
+
initialGreeting: "Hello! 👋 I'm your assistant. How can I help you today?",
|
|
325
|
+
});
|
|
326
|
+
```
|
|
327
|
+
|
|
328
|
+
**React:**
|
|
329
|
+
```tsx
|
|
330
|
+
<ChatUI
|
|
331
|
+
config={config}
|
|
332
|
+
initialGreeting="Hello! 👋 I'm your assistant. How can I help you today?"
|
|
333
|
+
/>
|
|
334
|
+
```
|
|
335
|
+
|
|
336
|
+
The greeting will:
|
|
337
|
+
- Appear automatically when the chat opens
|
|
338
|
+
- Only show once per conversation
|
|
339
|
+
- Reappear after clearing the chat
|
|
340
|
+
- Not trigger an API call (it's a local message)
|
|
341
|
+
|
|
342
|
+
### Improved Loading States
|
|
343
|
+
|
|
344
|
+
- Animated loading indicators
|
|
345
|
+
- Better accessibility with ARIA live regions
|
|
346
|
+
- Smooth transitions
|
|
347
|
+
|
|
348
|
+
See [NEW_FEATURES.md](./NEW_FEATURES.md) for complete feature documentation.
|
|
349
|
+
|
|
350
|
+
## Integration Guide
|
|
351
|
+
|
|
352
|
+
For complete integration instructions, examples, and troubleshooting, see the [Integration Guide](../../INTEGRATION_GUIDE.md).
|
|
353
|
+
|
|
354
|
+
The integration guide includes:
|
|
355
|
+
- Complete vanilla JS examples with modal
|
|
356
|
+
- React integration examples
|
|
357
|
+
- Backend configuration
|
|
358
|
+
- Feature documentation
|
|
359
|
+
- Troubleshooting guide
|
|
360
|
+
|
|
265
361
|
## Customization
|
|
266
362
|
|
|
267
363
|
### Custom Message Renderer (React)
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ChatUI.d.ts","sourceRoot":"","sources":["../../../adapters/react/ChatUI.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,MAAM,OAAO,CAAC;AAE1B,OAAO,KAAK,EAAE,UAAU,EAAE,SAAS,EAAE,OAAO,EAAE,MAAM,sBAAsB,CAAC;AAuD3E,MAAM,WAAW,WAAW;IAC1B,MAAM,EAAE,UAAU,CAAC;IACnB,KAAK,CAAC,EAAE,SAAS,CAAC;IAClB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,aAAa,CAAC,EAAE,OAAO,CAAC;IACxB,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,UAAU,CAAC,EAAE,MAAM,GAAG,KAAK,CAAC,SAAS,CAAC;IACtC,eAAe,CAAC,EAAE,MAAM,GAAG,KAAK,CAAC,SAAS,CAAC;IAC3C,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,eAAe,CAAC,EAAE,MAAM,CAAC;IAEzB,0BAA0B,CAAC,EAAE,MAAM,CAAC;IACpC,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,uBAAuB,CAAC,EAAE,MAAM,CAAC;IACjC,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,mBAAmB,CAAC,EAAE,MAAM,CAAC;IAE7B,qBAAqB,CAAC,EAAE,CAAC,OAAO,EAAE,OAAO,KAAK,KAAK,CAAC,SAAS,CAAC;IAC9D,mBAAmB,CAAC,EAAE,CAAC,KAAK,EAAE;QAC5B,KAAK,EAAE,MAAM,CAAC;QACd,QAAQ,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;QAClC,MAAM,EAAE,MAAM,IAAI,CAAC;QACnB,QAAQ,EAAE,OAAO,CAAC;KACnB,KAAK,KAAK,CAAC,SAAS,CAAC;IAEtB,cAAc,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CACzC;AAED,eAAO,MAAM,MAAM,EAAE,KAAK,CAAC,EAAE,CAAC,WAAW,
|
|
1
|
+
{"version":3,"file":"ChatUI.d.ts","sourceRoot":"","sources":["../../../adapters/react/ChatUI.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,MAAM,OAAO,CAAC;AAE1B,OAAO,KAAK,EAAE,UAAU,EAAE,SAAS,EAAE,OAAO,EAAE,MAAM,sBAAsB,CAAC;AAuD3E,MAAM,WAAW,WAAW;IAC1B,MAAM,EAAE,UAAU,CAAC;IACnB,KAAK,CAAC,EAAE,SAAS,CAAC;IAClB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,aAAa,CAAC,EAAE,OAAO,CAAC;IACxB,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,UAAU,CAAC,EAAE,MAAM,GAAG,KAAK,CAAC,SAAS,CAAC;IACtC,eAAe,CAAC,EAAE,MAAM,GAAG,KAAK,CAAC,SAAS,CAAC;IAC3C,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,eAAe,CAAC,EAAE,MAAM,CAAC;IAEzB,0BAA0B,CAAC,EAAE,MAAM,CAAC;IACpC,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,uBAAuB,CAAC,EAAE,MAAM,CAAC;IACjC,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,mBAAmB,CAAC,EAAE,MAAM,CAAC;IAE7B,qBAAqB,CAAC,EAAE,CAAC,OAAO,EAAE,OAAO,KAAK,KAAK,CAAC,SAAS,CAAC;IAC9D,mBAAmB,CAAC,EAAE,CAAC,KAAK,EAAE;QAC5B,KAAK,EAAE,MAAM,CAAC;QACd,QAAQ,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;QAClC,MAAM,EAAE,MAAM,IAAI,CAAC;QACnB,QAAQ,EAAE,OAAO,CAAC;KACnB,KAAK,KAAK,CAAC,SAAS,CAAC;IAEtB,cAAc,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CACzC;AAED,eAAO,MAAM,MAAM,EAAE,KAAK,CAAC,EAAE,CAAC,WAAW,CAsQxC,CAAC"}
|
|
@@ -39,9 +39,36 @@ const MarkdownContent = ({ content }) => {
|
|
|
39
39
|
};
|
|
40
40
|
export const ChatUI = ({ config, theme = {}, className = '', placeholder = 'Type your message...', showTimestamp = true, showAvatar = true, userAvatar, assistantAvatar, maxHeight = '600px', disabled = false, initialGreeting, messagesContainerClassName = '', messageClassName = '', inputContainerClassName = '', inputClassName = '', buttonClassName = '', errorClassName = '', emptyStateClassName = '', customMessageRenderer, customInputRenderer, dataAttributes = {}, }) => {
|
|
41
41
|
const { messages, input, isLoading, error, sendMessage, setInput, addMessage, } = useChat(config);
|
|
42
|
-
//
|
|
42
|
+
// Track if we've already added the initial greeting
|
|
43
|
+
const greetingAddedRef = React.useRef(false);
|
|
44
|
+
const previousInitialGreetingRef = React.useRef(initialGreeting);
|
|
45
|
+
const messagesInitializedRef = React.useRef(false);
|
|
46
|
+
// Reset greeting flag if initialGreeting prop changes
|
|
47
|
+
if (previousInitialGreetingRef.current !== initialGreeting) {
|
|
48
|
+
greetingAddedRef.current = false;
|
|
49
|
+
previousInitialGreetingRef.current = initialGreeting;
|
|
50
|
+
}
|
|
51
|
+
// Mark when messages are initialized (loaded from storage or empty)
|
|
52
|
+
React.useEffect(() => {
|
|
53
|
+
if (messages.length >= 0) {
|
|
54
|
+
messagesInitializedRef.current = true;
|
|
55
|
+
}
|
|
56
|
+
}, [messages.length]);
|
|
57
|
+
// Add initial greeting message once when component mounts and messages are ready
|
|
43
58
|
React.useEffect(() => {
|
|
44
|
-
if
|
|
59
|
+
// Skip if no greeting or already added
|
|
60
|
+
if (!initialGreeting || greetingAddedRef.current) {
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
// Wait for messages to be initialized
|
|
64
|
+
if (!messagesInitializedRef.current) {
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
// Check if there's already a greeting message with the same content
|
|
68
|
+
const hasGreeting = messages.some(msg => msg.role === 'assistant' && msg.content === initialGreeting);
|
|
69
|
+
// Add greeting if it doesn't already exist
|
|
70
|
+
if (!hasGreeting) {
|
|
71
|
+
greetingAddedRef.current = true;
|
|
45
72
|
const greetingMessage = {
|
|
46
73
|
id: `greeting-${Date.now()}`,
|
|
47
74
|
content: initialGreeting,
|
|
@@ -50,7 +77,11 @@ export const ChatUI = ({ config, theme = {}, className = '', placeholder = 'Type
|
|
|
50
77
|
};
|
|
51
78
|
addMessage(greetingMessage);
|
|
52
79
|
}
|
|
53
|
-
|
|
80
|
+
else {
|
|
81
|
+
// If greeting already exists, mark as added
|
|
82
|
+
greetingAddedRef.current = true;
|
|
83
|
+
}
|
|
84
|
+
}, [initialGreeting, messages, addMessage]);
|
|
54
85
|
const handleSubmit = (e) => {
|
|
55
86
|
e.preventDefault();
|
|
56
87
|
if (input.trim() && !isLoading && !disabled) {
|
|
@@ -18,6 +18,14 @@ export interface ChatUIOptions {
|
|
|
18
18
|
loadingMessage?: string;
|
|
19
19
|
customMessageRenderer?: (message: Message) => string;
|
|
20
20
|
onInit?: (instance: ChatUIInstance) => void;
|
|
21
|
+
initialGreeting?: string;
|
|
22
|
+
enableMessageActions?: boolean;
|
|
23
|
+
showCopyButton?: boolean;
|
|
24
|
+
showDeleteButton?: boolean;
|
|
25
|
+
showEditButton?: boolean;
|
|
26
|
+
onMessageCopy?: (message: Message) => void;
|
|
27
|
+
onMessageDelete?: (message: Message) => void;
|
|
28
|
+
onMessageEdit?: (message: Message, newContent: string) => void;
|
|
21
29
|
}
|
|
22
30
|
export interface ChatUIInstance {
|
|
23
31
|
engine: ChatEngine;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../adapters/vanilla/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,MAAM,2BAA2B,CAAC;AACvD,OAAO,KAAK,EAAE,UAAU,EAAE,OAAO,EAAE,SAAS,EAAE,MAAM,sBAAsB,CAAC;AAE3E,MAAM,WAAW,aAAa;IAC5B,MAAM,EAAE,UAAU,CAAC;IACnB,KAAK,CAAC,EAAE,SAAS,CAAC;IAClB,SAAS,EAAE,WAAW,GAAG,MAAM,CAAC;IAChC,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,aAAa,CAAC,EAAE,OAAO,CAAC;IACxB,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,cAAc,CAAC,EAAE,OAAO,CAAC;IACzB,eAAe,CAAC,EAAE,OAAO,CAAC;IAC1B,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,qBAAqB,CAAC,EAAE,CAAC,OAAO,EAAE,OAAO,KAAK,MAAM,CAAC;IACrD,MAAM,CAAC,EAAE,CAAC,QAAQ,EAAE,cAAc,KAAK,IAAI,CAAC;
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../adapters/vanilla/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,MAAM,2BAA2B,CAAC;AACvD,OAAO,KAAK,EAAE,UAAU,EAAE,OAAO,EAAE,SAAS,EAAE,MAAM,sBAAsB,CAAC;AAE3E,MAAM,WAAW,aAAa;IAC5B,MAAM,EAAE,UAAU,CAAC;IACnB,KAAK,CAAC,EAAE,SAAS,CAAC;IAClB,SAAS,EAAE,WAAW,GAAG,MAAM,CAAC;IAChC,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,aAAa,CAAC,EAAE,OAAO,CAAC;IACxB,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,cAAc,CAAC,EAAE,OAAO,CAAC;IACzB,eAAe,CAAC,EAAE,OAAO,CAAC;IAC1B,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,qBAAqB,CAAC,EAAE,CAAC,OAAO,EAAE,OAAO,KAAK,MAAM,CAAC;IACrD,MAAM,CAAC,EAAE,CAAC,QAAQ,EAAE,cAAc,KAAK,IAAI,CAAC;IAC5C,eAAe,CAAC,EAAE,MAAM,CAAC;IAEzB,oBAAoB,CAAC,EAAE,OAAO,CAAC;IAC/B,cAAc,CAAC,EAAE,OAAO,CAAC;IACzB,gBAAgB,CAAC,EAAE,OAAO,CAAC;IAC3B,cAAc,CAAC,EAAE,OAAO,CAAC;IACzB,aAAa,CAAC,EAAE,CAAC,OAAO,EAAE,OAAO,KAAK,IAAI,CAAC;IAC3C,eAAe,CAAC,EAAE,CAAC,OAAO,EAAE,OAAO,KAAK,IAAI,CAAC;IAC7C,aAAa,CAAC,EAAE,CAAC,OAAO,EAAE,OAAO,EAAE,UAAU,EAAE,MAAM,KAAK,IAAI,CAAC;CAChE;AAED,MAAM,WAAW,cAAc;IAC7B,MAAM,EAAE,UAAU,CAAC;IACnB,MAAM,EAAE,MAAM,IAAI,CAAC;IACnB,OAAO,EAAE,MAAM,IAAI,CAAC;IACpB,KAAK,EAAE,MAAM,IAAI,CAAC;IAClB,cAAc,EAAE,MAAM,IAAI,CAAC;IAC3B,YAAY,EAAE,MAAM,WAAW,CAAC;CACjC;AA2DD,wBAAgB,YAAY,CAAC,OAAO,EAAE,aAAa,GAAG,cAAc,CAsoBnE"}
|
|
@@ -47,7 +47,7 @@ function getContainerElement(container) {
|
|
|
47
47
|
return container;
|
|
48
48
|
}
|
|
49
49
|
export function createChatUI(options) {
|
|
50
|
-
const { config, theme = {}, container, placeholder = 'Type your message...', showTimestamp = true, showAvatar = true, userAvatar, assistantAvatar, maxHeight = '600px', disabled = false, autoScroll = true, enableMarkdown = true, showClearButton = true, emptyStateMessage = 'Start a conversation...', loadingMessage = 'Thinking...', customMessageRenderer, onInit, } = options;
|
|
50
|
+
const { config, theme = {}, container, placeholder = 'Type your message...', showTimestamp = true, showAvatar = true, userAvatar, assistantAvatar, maxHeight = '600px', disabled = false, autoScroll = true, enableMarkdown = true, showClearButton = true, emptyStateMessage = 'Start a conversation...', loadingMessage = 'Thinking...', customMessageRenderer, onInit, initialGreeting, enableMessageActions = false, showCopyButton = true, showDeleteButton = true, showEditButton = true, onMessageCopy, onMessageDelete, onMessageEdit, } = options;
|
|
51
51
|
// Get container element
|
|
52
52
|
const containerElement = getContainerElement(container);
|
|
53
53
|
const engine = new ChatEngine(config);
|
|
@@ -58,6 +58,12 @@ export function createChatUI(options) {
|
|
|
58
58
|
let sendButtonElement = null;
|
|
59
59
|
let clearButtonElement = null;
|
|
60
60
|
let isUpdatingInputProgrammatically = false; // Flag to prevent input event loop
|
|
61
|
+
// Track if we've already added the initial greeting
|
|
62
|
+
let greetingAdded = false;
|
|
63
|
+
let previousInitialGreeting = initialGreeting;
|
|
64
|
+
let messagesInitialized = false;
|
|
65
|
+
// Store event handlers for proper cleanup
|
|
66
|
+
const eventHandlers = [];
|
|
61
67
|
// Apply CSS variables for theming
|
|
62
68
|
const applyTheme = () => {
|
|
63
69
|
const root = containerElement;
|
|
@@ -107,12 +113,59 @@ export function createChatUI(options) {
|
|
|
107
113
|
const messageContent = enableMarkdown
|
|
108
114
|
? `<div class="chat-markdown">${formatText(message.content, enableMarkdown)}</div>`
|
|
109
115
|
: `<div class="chat-message-text">${formatText(message.content, false)}</div>`;
|
|
116
|
+
const messageId = escapeHtml(message.id);
|
|
117
|
+
const messageActions = enableMessageActions ? `
|
|
118
|
+
<div class="chat-message-actions" role="group" aria-label="Message actions">
|
|
119
|
+
${showCopyButton ? `
|
|
120
|
+
<button
|
|
121
|
+
class="chat-message-action chat-message-copy"
|
|
122
|
+
type="button"
|
|
123
|
+
aria-label="Copy message"
|
|
124
|
+
data-message-id="${messageId}"
|
|
125
|
+
data-action="copy"
|
|
126
|
+
title="Copy message"
|
|
127
|
+
>
|
|
128
|
+
<span aria-hidden="true">📋</span>
|
|
129
|
+
</button>
|
|
130
|
+
` : ''}
|
|
131
|
+
${showDeleteButton ? `
|
|
132
|
+
<button
|
|
133
|
+
class="chat-message-action chat-message-delete"
|
|
134
|
+
type="button"
|
|
135
|
+
aria-label="Delete message"
|
|
136
|
+
data-message-id="${messageId}"
|
|
137
|
+
data-action="delete"
|
|
138
|
+
title="Delete message"
|
|
139
|
+
>
|
|
140
|
+
<span aria-hidden="true">🗑️</span>
|
|
141
|
+
</button>
|
|
142
|
+
` : ''}
|
|
143
|
+
${showEditButton && isUser && !isSystem ? `
|
|
144
|
+
<button
|
|
145
|
+
class="chat-message-action chat-message-edit"
|
|
146
|
+
type="button"
|
|
147
|
+
aria-label="Edit message"
|
|
148
|
+
data-message-id="${messageId}"
|
|
149
|
+
data-action="edit"
|
|
150
|
+
title="Edit message"
|
|
151
|
+
>
|
|
152
|
+
<span aria-hidden="true">✏️</span>
|
|
153
|
+
</button>
|
|
154
|
+
` : ''}
|
|
155
|
+
</div>
|
|
156
|
+
` : '';
|
|
110
157
|
return `
|
|
111
|
-
<div
|
|
158
|
+
<div
|
|
159
|
+
class="chat-message ${isUser ? 'chat-message-user' : isSystem ? 'chat-message-system' : 'chat-message-assistant'}"
|
|
160
|
+
data-message-id="${messageId}"
|
|
161
|
+
role="article"
|
|
162
|
+
aria-label="${isUser ? 'Your message' : 'Assistant message'}"
|
|
163
|
+
>
|
|
112
164
|
${!isUser && !isSystem ? avatarHtml : ''}
|
|
113
165
|
<div class="chat-message-content ${isUser ? 'chat-message-content-user' : isSystem ? 'chat-message-content-system' : 'chat-message-content-assistant'}">
|
|
114
166
|
${messageContent}
|
|
115
|
-
${showTimestamp ? `<div class="chat-message-timestamp">${formatTimestamp(message.timestamp)}</div>` : ''}
|
|
167
|
+
${showTimestamp ? `<div class="chat-message-timestamp" aria-label="Sent at ${formatTimestamp(message.timestamp)}">${formatTimestamp(message.timestamp)}</div>` : ''}
|
|
168
|
+
${messageActions}
|
|
116
169
|
</div>
|
|
117
170
|
${isUser ? avatarHtml : ''}
|
|
118
171
|
</div>
|
|
@@ -129,15 +182,24 @@ export function createChatUI(options) {
|
|
|
129
182
|
applyTheme();
|
|
130
183
|
containerElement.className = `chat-container ${containerElement.className || ''}`.trim();
|
|
131
184
|
containerElement.setAttribute('data-chat-ui', 'true');
|
|
185
|
+
containerElement.setAttribute('role', 'log');
|
|
186
|
+
containerElement.setAttribute('aria-label', 'Chat conversation');
|
|
187
|
+
containerElement.setAttribute('aria-live', 'polite');
|
|
188
|
+
containerElement.setAttribute('aria-atomic', 'false');
|
|
132
189
|
containerElement.innerHTML = `
|
|
133
|
-
<div class="chat-messages-container">
|
|
190
|
+
<div class="chat-messages-container" role="log" aria-live="polite" aria-label="Chat messages">
|
|
134
191
|
${messages.length === 0 ? `<div class="chat-empty-state">${escapeHtml(emptyStateMessage)}</div>` : ''}
|
|
135
192
|
${messages.map(renderMessage).join('')}
|
|
136
193
|
${isLoading ? `
|
|
137
|
-
<div class="chat-message chat-message-assistant chat-message-loading">
|
|
194
|
+
<div class="chat-message chat-message-assistant chat-message-loading" role="status" aria-live="polite" aria-label="Loading response">
|
|
138
195
|
${renderAvatar('assistant')}
|
|
139
196
|
<div class="chat-message-content chat-message-content-assistant">
|
|
140
|
-
<div class="chat-message-text"
|
|
197
|
+
<div class="chat-message-text">
|
|
198
|
+
<span class="chat-loading-dots">
|
|
199
|
+
<span></span><span></span><span></span>
|
|
200
|
+
</span>
|
|
201
|
+
<span class="chat-loading-text">${escapeHtml(loadingMessage)}</span>
|
|
202
|
+
</div>
|
|
141
203
|
</div>
|
|
142
204
|
</div>
|
|
143
205
|
` : ''}
|
|
@@ -152,7 +214,15 @@ export function createChatUI(options) {
|
|
|
152
214
|
</div>
|
|
153
215
|
` : ''}
|
|
154
216
|
<div class="chat-input-container">
|
|
155
|
-
<form
|
|
217
|
+
<form
|
|
218
|
+
class="chat-input-form"
|
|
219
|
+
action="javascript:void(0)"
|
|
220
|
+
method="post"
|
|
221
|
+
novalidate
|
|
222
|
+
data-chat-form="true"
|
|
223
|
+
role="form"
|
|
224
|
+
aria-label="Chat input form"
|
|
225
|
+
>
|
|
156
226
|
<input
|
|
157
227
|
type="text"
|
|
158
228
|
class="chat-input"
|
|
@@ -189,58 +259,249 @@ export function createChatUI(options) {
|
|
|
189
259
|
}
|
|
190
260
|
});
|
|
191
261
|
}
|
|
192
|
-
// Attach event listeners
|
|
262
|
+
// Attach event listeners with proper cleanup tracking
|
|
193
263
|
if (formElement) {
|
|
194
|
-
|
|
195
|
-
|
|
264
|
+
// Use capture phase to ensure preventDefault runs before any other handlers
|
|
265
|
+
// This prevents page reloads even if parent elements have submit handlers
|
|
266
|
+
const handleSubmit = (e) => {
|
|
267
|
+
const submitEvent = e;
|
|
268
|
+
submitEvent.preventDefault();
|
|
269
|
+
submitEvent.stopPropagation();
|
|
196
270
|
if (!disabled && !isLoading && input.trim()) {
|
|
197
271
|
engine.sendMessage();
|
|
198
272
|
}
|
|
199
|
-
}
|
|
273
|
+
};
|
|
274
|
+
// Add handler in capture phase (runs first) to prevent any page reloads
|
|
275
|
+
formElement.addEventListener('submit', handleSubmit, true);
|
|
276
|
+
eventHandlers.push({ element: formElement, event: 'submit', handler: handleSubmit, options: true });
|
|
277
|
+
// Also add in bubble phase as fallback
|
|
278
|
+
formElement.addEventListener('submit', handleSubmit, false);
|
|
279
|
+
eventHandlers.push({ element: formElement, event: 'submit', handler: handleSubmit, options: false });
|
|
200
280
|
}
|
|
201
281
|
if (inputElement) {
|
|
202
282
|
// Handle input changes
|
|
203
|
-
|
|
283
|
+
const handleInput = (e) => {
|
|
204
284
|
// Skip if we're programmatically updating the input
|
|
205
285
|
if (isUpdatingInputProgrammatically) {
|
|
206
286
|
return;
|
|
207
287
|
}
|
|
208
288
|
const target = e.target;
|
|
209
289
|
engine.setInput(target.value);
|
|
210
|
-
}
|
|
290
|
+
};
|
|
291
|
+
inputElement.addEventListener('input', handleInput);
|
|
292
|
+
eventHandlers.push({ element: inputElement, event: 'input', handler: handleInput });
|
|
211
293
|
// Keyboard shortcuts: Enter to send, Shift+Enter for newline
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
294
|
+
const handleKeydown = (e) => {
|
|
295
|
+
const keyboardEvent = e;
|
|
296
|
+
if (keyboardEvent.key === 'Enter' && !keyboardEvent.shiftKey) {
|
|
297
|
+
keyboardEvent.preventDefault();
|
|
215
298
|
if (!disabled && !isLoading && input.trim()) {
|
|
216
299
|
engine.sendMessage();
|
|
217
300
|
}
|
|
218
301
|
}
|
|
219
302
|
// Shift+Enter allows default behavior (newline if textarea, ignored if input)
|
|
220
|
-
}
|
|
303
|
+
};
|
|
304
|
+
inputElement.addEventListener('keydown', handleKeydown);
|
|
305
|
+
eventHandlers.push({ element: inputElement, event: 'keydown', handler: handleKeydown });
|
|
221
306
|
}
|
|
222
307
|
// Clear button handler
|
|
223
308
|
if (clearButtonElement) {
|
|
224
|
-
|
|
309
|
+
const handleClearClick = () => {
|
|
225
310
|
if (confirm('Are you sure you want to clear the conversation?')) {
|
|
226
311
|
engine.clearMessages();
|
|
227
312
|
}
|
|
228
|
-
}
|
|
313
|
+
};
|
|
314
|
+
clearButtonElement.addEventListener('click', handleClearClick);
|
|
315
|
+
eventHandlers.push({ element: clearButtonElement, event: 'click', handler: handleClearClick });
|
|
229
316
|
}
|
|
230
317
|
// Retry button handler
|
|
231
318
|
if (retryButton) {
|
|
232
|
-
|
|
319
|
+
const handleRetryClick = () => {
|
|
233
320
|
engine.retryLastMessage();
|
|
234
|
-
}
|
|
321
|
+
};
|
|
322
|
+
retryButton.addEventListener('click', handleRetryClick);
|
|
323
|
+
eventHandlers.push({ element: retryButton, event: 'click', handler: handleRetryClick });
|
|
324
|
+
}
|
|
325
|
+
// Message actions handlers (using event delegation)
|
|
326
|
+
if (enableMessageActions) {
|
|
327
|
+
const handleMessageAction = async (e) => {
|
|
328
|
+
const target = e.target;
|
|
329
|
+
const actionButton = target.closest('[data-action]');
|
|
330
|
+
if (!actionButton)
|
|
331
|
+
return;
|
|
332
|
+
e.preventDefault();
|
|
333
|
+
e.stopPropagation();
|
|
334
|
+
const action = actionButton.getAttribute('data-action');
|
|
335
|
+
const messageId = actionButton.getAttribute('data-message-id');
|
|
336
|
+
if (!messageId || !action)
|
|
337
|
+
return;
|
|
338
|
+
const message = engine.getMessages().find(m => m.id === messageId);
|
|
339
|
+
if (!message)
|
|
340
|
+
return;
|
|
341
|
+
switch (action) {
|
|
342
|
+
case 'copy':
|
|
343
|
+
await copyMessageToClipboard(message);
|
|
344
|
+
break;
|
|
345
|
+
case 'delete':
|
|
346
|
+
deleteMessage(message);
|
|
347
|
+
break;
|
|
348
|
+
case 'edit':
|
|
349
|
+
editMessage(message);
|
|
350
|
+
break;
|
|
351
|
+
}
|
|
352
|
+
};
|
|
353
|
+
containerElement.addEventListener('click', handleMessageAction);
|
|
354
|
+
eventHandlers.push({ element: containerElement, event: 'click', handler: handleMessageAction });
|
|
235
355
|
}
|
|
356
|
+
// Toast notification helper
|
|
357
|
+
const showToast = (message, type = 'info') => {
|
|
358
|
+
const toast = document.createElement('div');
|
|
359
|
+
toast.className = `chat-toast chat-toast-${type}`;
|
|
360
|
+
toast.setAttribute('role', 'alert');
|
|
361
|
+
toast.setAttribute('aria-live', 'polite');
|
|
362
|
+
toast.textContent = message;
|
|
363
|
+
containerElement.appendChild(toast);
|
|
364
|
+
// Animate in
|
|
365
|
+
requestAnimationFrame(() => {
|
|
366
|
+
toast.classList.add('chat-toast-show');
|
|
367
|
+
});
|
|
368
|
+
// Remove after 3 seconds
|
|
369
|
+
setTimeout(() => {
|
|
370
|
+
toast.classList.remove('chat-toast-show');
|
|
371
|
+
setTimeout(() => toast.remove(), 300);
|
|
372
|
+
}, 3000);
|
|
373
|
+
};
|
|
374
|
+
const copyMessageToClipboard = async (message) => {
|
|
375
|
+
try {
|
|
376
|
+
await navigator.clipboard.writeText(message.content);
|
|
377
|
+
showToast('Message copied to clipboard', 'success');
|
|
378
|
+
onMessageCopy?.(message);
|
|
379
|
+
}
|
|
380
|
+
catch (err) {
|
|
381
|
+
console.error('Failed to copy:', err);
|
|
382
|
+
showToast('Failed to copy message', 'error');
|
|
383
|
+
}
|
|
384
|
+
};
|
|
385
|
+
const deleteMessage = (message) => {
|
|
386
|
+
if (confirm('Are you sure you want to delete this message?')) {
|
|
387
|
+
engine.deleteMessage(message.id);
|
|
388
|
+
showToast('Message deleted', 'info');
|
|
389
|
+
onMessageDelete?.(message);
|
|
390
|
+
}
|
|
391
|
+
};
|
|
392
|
+
const editMessage = (message) => {
|
|
393
|
+
const messageElement = containerElement.querySelector(`[data-message-id="${message.id}"]`);
|
|
394
|
+
if (!messageElement)
|
|
395
|
+
return;
|
|
396
|
+
const contentElement = messageElement.querySelector('.chat-message-text, .chat-markdown');
|
|
397
|
+
if (!contentElement)
|
|
398
|
+
return;
|
|
399
|
+
const originalContent = message.content;
|
|
400
|
+
const input = document.createElement('input');
|
|
401
|
+
input.type = 'text';
|
|
402
|
+
input.value = originalContent;
|
|
403
|
+
input.className = 'chat-message-edit-input';
|
|
404
|
+
input.setAttribute('aria-label', 'Edit message');
|
|
405
|
+
const buttonContainer = document.createElement('div');
|
|
406
|
+
buttonContainer.className = 'chat-message-edit-buttons';
|
|
407
|
+
const saveButton = document.createElement('button');
|
|
408
|
+
saveButton.textContent = 'Save';
|
|
409
|
+
saveButton.className = 'chat-message-edit-save';
|
|
410
|
+
saveButton.type = 'button';
|
|
411
|
+
saveButton.setAttribute('aria-label', 'Save changes');
|
|
412
|
+
const cancelButton = document.createElement('button');
|
|
413
|
+
cancelButton.textContent = 'Cancel';
|
|
414
|
+
cancelButton.className = 'chat-message-edit-cancel';
|
|
415
|
+
cancelButton.type = 'button';
|
|
416
|
+
cancelButton.setAttribute('aria-label', 'Cancel editing');
|
|
417
|
+
buttonContainer.appendChild(saveButton);
|
|
418
|
+
buttonContainer.appendChild(cancelButton);
|
|
419
|
+
const originalHTML = contentElement.innerHTML;
|
|
420
|
+
contentElement.innerHTML = '';
|
|
421
|
+
contentElement.appendChild(input);
|
|
422
|
+
contentElement.appendChild(buttonContainer);
|
|
423
|
+
input.focus();
|
|
424
|
+
input.select();
|
|
425
|
+
const handleSave = () => {
|
|
426
|
+
const newContent = input.value.trim();
|
|
427
|
+
if (newContent && newContent !== originalContent) {
|
|
428
|
+
try {
|
|
429
|
+
engine.editMessage(message.id, newContent);
|
|
430
|
+
showToast('Message updated', 'success');
|
|
431
|
+
onMessageEdit?.(message, newContent);
|
|
432
|
+
}
|
|
433
|
+
catch (error) {
|
|
434
|
+
showToast(error instanceof Error ? error.message : 'Failed to edit message', 'error');
|
|
435
|
+
contentElement.innerHTML = originalHTML;
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
else {
|
|
439
|
+
contentElement.innerHTML = originalHTML;
|
|
440
|
+
}
|
|
441
|
+
};
|
|
442
|
+
const handleCancel = () => {
|
|
443
|
+
contentElement.innerHTML = originalHTML;
|
|
444
|
+
};
|
|
445
|
+
saveButton.addEventListener('click', handleSave);
|
|
446
|
+
cancelButton.addEventListener('click', handleCancel);
|
|
447
|
+
input.addEventListener('keydown', (e) => {
|
|
448
|
+
if (e.key === 'Enter' && !e.shiftKey) {
|
|
449
|
+
e.preventDefault();
|
|
450
|
+
handleSave();
|
|
451
|
+
}
|
|
452
|
+
if (e.key === 'Escape') {
|
|
453
|
+
e.preventDefault();
|
|
454
|
+
handleCancel();
|
|
455
|
+
}
|
|
456
|
+
});
|
|
457
|
+
};
|
|
236
458
|
// Auto-scroll to bottom after render
|
|
237
459
|
// Use setTimeout to ensure DOM is updated
|
|
238
460
|
setTimeout(() => {
|
|
239
461
|
scrollToBottom();
|
|
240
462
|
}, 0);
|
|
241
463
|
};
|
|
464
|
+
// Helper function to add initial greeting if needed
|
|
465
|
+
const addInitialGreetingIfNeeded = () => {
|
|
466
|
+
if (!initialGreeting || greetingAdded) {
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
469
|
+
// Reset greeting flag if initialGreeting prop changed
|
|
470
|
+
if (previousInitialGreeting !== initialGreeting) {
|
|
471
|
+
greetingAdded = false;
|
|
472
|
+
previousInitialGreeting = initialGreeting;
|
|
473
|
+
}
|
|
474
|
+
// Check if there's already a greeting message with the same content
|
|
475
|
+
const messages = engine.getMessages();
|
|
476
|
+
const hasGreeting = messages.some(msg => msg.role === 'assistant' && msg.content === initialGreeting);
|
|
477
|
+
// Add greeting if it doesn't already exist
|
|
478
|
+
if (!hasGreeting) {
|
|
479
|
+
greetingAdded = true;
|
|
480
|
+
const greetingMessage = {
|
|
481
|
+
id: `greeting-${Date.now()}`,
|
|
482
|
+
content: initialGreeting,
|
|
483
|
+
role: 'assistant',
|
|
484
|
+
timestamp: new Date(),
|
|
485
|
+
};
|
|
486
|
+
engine.addMessage(greetingMessage);
|
|
487
|
+
}
|
|
488
|
+
else {
|
|
489
|
+
// If greeting already exists, mark as added
|
|
490
|
+
greetingAdded = true;
|
|
491
|
+
}
|
|
492
|
+
};
|
|
242
493
|
// Subscribe to engine events
|
|
243
494
|
engine.on('messagesChange', () => {
|
|
495
|
+
// Mark messages as initialized once we get the first messagesChange event
|
|
496
|
+
if (!messagesInitialized) {
|
|
497
|
+
messagesInitialized = true;
|
|
498
|
+
// Try to add greeting after messages are initialized
|
|
499
|
+
addInitialGreetingIfNeeded();
|
|
500
|
+
// If greeting was added, it will trigger another messagesChange, so return early
|
|
501
|
+
if (greetingAdded) {
|
|
502
|
+
return;
|
|
503
|
+
}
|
|
504
|
+
}
|
|
244
505
|
render();
|
|
245
506
|
// Scroll after messages change
|
|
246
507
|
setTimeout(() => {
|
|
@@ -297,10 +558,23 @@ export function createChatUI(options) {
|
|
|
297
558
|
engine.on('error', render);
|
|
298
559
|
// Initial render
|
|
299
560
|
render();
|
|
561
|
+
// After initial render, check if we need to add greeting
|
|
562
|
+
// Use setTimeout to ensure messages are loaded from storage first
|
|
563
|
+
setTimeout(() => {
|
|
564
|
+
if (!messagesInitialized) {
|
|
565
|
+
messagesInitialized = true;
|
|
566
|
+
addInitialGreetingIfNeeded();
|
|
567
|
+
}
|
|
568
|
+
}, 100);
|
|
300
569
|
const instance = {
|
|
301
570
|
engine,
|
|
302
571
|
render,
|
|
303
572
|
destroy: () => {
|
|
573
|
+
// Remove all event listeners
|
|
574
|
+
eventHandlers.forEach(({ element, event, handler, options }) => {
|
|
575
|
+
element.removeEventListener(event, handler, options);
|
|
576
|
+
});
|
|
577
|
+
eventHandlers.length = 0;
|
|
304
578
|
engine.destroy();
|
|
305
579
|
containerElement.innerHTML = '';
|
|
306
580
|
// Clear references
|
|
@@ -311,6 +585,8 @@ export function createChatUI(options) {
|
|
|
311
585
|
clearButtonElement = null;
|
|
312
586
|
},
|
|
313
587
|
clear: () => {
|
|
588
|
+
// Reset greeting flag so it can be added again after clearing
|
|
589
|
+
greetingAdded = false;
|
|
314
590
|
engine.clearMessages();
|
|
315
591
|
},
|
|
316
592
|
scrollToBottom,
|