@jidou-ai/chat-widget 1.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.
Files changed (40) hide show
  1. package/.claude/settings.local.json +28 -0
  2. package/README.md +276 -0
  3. package/dist/index.html +236 -0
  4. package/dist/test-local.html +268 -0
  5. package/dist/test-production.html +455 -0
  6. package/dist/widget.js +2781 -0
  7. package/package.json +41 -0
  8. package/scripts/build.js +73 -0
  9. package/scripts/dev.js +183 -0
  10. package/scripts/mock-server.js +436 -0
  11. package/src/components/BaseComponent.ts +67 -0
  12. package/src/components/ChatWindow.ts +283 -0
  13. package/src/components/ConnectionStatus.ts +50 -0
  14. package/src/components/InputArea.ts +164 -0
  15. package/src/components/Launcher.ts +121 -0
  16. package/src/components/MessageList.ts +150 -0
  17. package/src/components/QuickReplies.ts +42 -0
  18. package/src/components/icons.ts +51 -0
  19. package/src/components/index.ts +9 -0
  20. package/src/core/Widget.ts +478 -0
  21. package/src/core/index.ts +1 -0
  22. package/src/i18n/index.ts +85 -0
  23. package/src/i18n/translations.ts +150 -0
  24. package/src/index.ts +76 -0
  25. package/src/services/ChatHistoryManager.ts +98 -0
  26. package/src/services/StateManager.ts +148 -0
  27. package/src/services/WebSocketClient.ts +295 -0
  28. package/src/services/index.ts +3 -0
  29. package/src/styles/base.ts +231 -0
  30. package/src/styles/chatWindow.ts +239 -0
  31. package/src/styles/index.ts +21 -0
  32. package/src/styles/launcher.ts +186 -0
  33. package/src/styles/messages.ts +329 -0
  34. package/src/types/index.ts +345 -0
  35. package/src/utils/dom.ts +130 -0
  36. package/src/utils/events.ts +52 -0
  37. package/src/utils/id.ts +41 -0
  38. package/src/utils/index.ts +129 -0
  39. package/src/utils/storage.ts +64 -0
  40. package/tsconfig.json +26 -0
@@ -0,0 +1,283 @@
1
+ import { BaseComponent } from './BaseComponent';
2
+ import { MessageList } from './MessageList';
3
+ import { InputArea } from './InputArea';
4
+ import { ConnectionStatus } from './ConnectionStatus';
5
+ import { QuickReplies } from './QuickReplies';
6
+ import { createIcon } from './icons';
7
+ import { t } from '../i18n';
8
+ import type {
9
+ ChatWindowConfig,
10
+ DisplayMode,
11
+ ConnectionState,
12
+ Message,
13
+ QuickReply,
14
+ LauncherPosition,
15
+ } from '../types';
16
+
17
+ export interface ChatWindowOptions {
18
+ config: ChatWindowConfig;
19
+ displayMode: DisplayMode;
20
+ position: LauncherPosition;
21
+ connectionState: ConnectionState;
22
+ reconnectAttempt?: number;
23
+ messages: Message[];
24
+ isTyping: boolean;
25
+ quickReplies?: QuickReply[];
26
+ showBranding?: boolean;
27
+ onClose: () => void;
28
+ onCollapse?: () => void;
29
+ onExpand?: () => void;
30
+ onSend: (message: string) => void;
31
+ onTypingStart?: () => void;
32
+ onTypingStop?: () => void;
33
+ onQuickReplySelect?: (item: QuickReply) => void;
34
+ }
35
+
36
+ export class ChatWindow extends BaseComponent<ChatWindowOptions> {
37
+ private messageList!: MessageList;
38
+ private inputArea!: InputArea;
39
+ private connectionStatus!: ConnectionStatus;
40
+ private quickReplies: QuickReplies | null = null;
41
+
42
+ protected render(): HTMLElement {
43
+ const {
44
+ config,
45
+ displayMode,
46
+ position,
47
+ connectionState,
48
+ reconnectAttempt,
49
+ messages,
50
+ isTyping,
51
+ quickReplies,
52
+ showBranding = true,
53
+ onClose,
54
+ onCollapse,
55
+ onExpand,
56
+ onSend,
57
+ onTypingStart,
58
+ onTypingStop,
59
+ onQuickReplySelect,
60
+ } = this.options;
61
+
62
+ const container = document.createElement('div');
63
+ container.className = this.getClassName(displayMode, position);
64
+ container.setAttribute('role', 'dialog');
65
+ container.setAttribute('aria-label', t('chat.window'));
66
+
67
+ // Apply size styles for floating mode
68
+ if (displayMode === 'floating') {
69
+ const width = config.width || 380;
70
+ const height = config.height || 600;
71
+ container.style.width = typeof width === 'number' ? `${width}px` : width;
72
+ container.style.height = typeof height === 'number' ? `${height}px` : height;
73
+ if (config.borderRadius) {
74
+ container.style.borderRadius = `${config.borderRadius}px`;
75
+ }
76
+ }
77
+
78
+ // Header (can be hidden via config.header.show = false)
79
+ if (config.header?.show !== false) {
80
+ const header = this.renderHeader(config, displayMode, onClose, onCollapse, onExpand);
81
+ container.appendChild(header);
82
+ }
83
+
84
+ // Body
85
+ const body = document.createElement('div');
86
+ body.className = 'jd-chat-body';
87
+
88
+ // Connection status
89
+ const connStatus = new ConnectionStatus({
90
+ state: connectionState,
91
+ reconnectAttempt,
92
+ });
93
+ this.connectionStatus = connStatus;
94
+ body.appendChild(connStatus.getElement());
95
+
96
+ // Message list
97
+ this.messageList = new MessageList({
98
+ messages,
99
+ isTyping,
100
+ });
101
+ body.appendChild(this.messageList.getElement());
102
+
103
+ // Quick replies
104
+ if (quickReplies && quickReplies.length > 0 && onQuickReplySelect) {
105
+ this.quickReplies = new QuickReplies({
106
+ items: quickReplies,
107
+ onSelect: (item) => {
108
+ onQuickReplySelect(item);
109
+ // Hide quick replies after selection
110
+ this.quickReplies?.setVisible(false);
111
+ },
112
+ });
113
+ body.appendChild(this.quickReplies.getElement());
114
+ }
115
+
116
+ container.appendChild(body);
117
+
118
+ // Input area
119
+ this.inputArea = new InputArea({
120
+ placeholder: config.footer?.input?.placeholder || t('input.placeholder'),
121
+ maxLength: config.footer?.input?.maxLength,
122
+ disabled: connectionState !== 'connected',
123
+ onSend,
124
+ onTypingStart,
125
+ onTypingStop,
126
+ });
127
+ container.appendChild(this.inputArea.getElement());
128
+
129
+ // Branding
130
+ if (showBranding && config.footer?.branding?.show !== false) {
131
+ const branding = this.renderBranding(config.footer?.branding?.text);
132
+ container.appendChild(branding);
133
+ }
134
+
135
+ return container;
136
+ }
137
+
138
+ private getClassName(displayMode: DisplayMode, position: LauncherPosition): string {
139
+ const classes = [
140
+ 'jd-chat-window',
141
+ `jd-chat-window--${displayMode}`,
142
+ ];
143
+
144
+ if (displayMode === 'floating') {
145
+ classes.push(`jd-chat-window--${position}`);
146
+ }
147
+
148
+ return classes.join(' ');
149
+ }
150
+
151
+ private renderHeader(
152
+ config: ChatWindowConfig,
153
+ displayMode: DisplayMode,
154
+ onClose: () => void,
155
+ onCollapse?: () => void,
156
+ onExpand?: () => void
157
+ ): HTMLElement {
158
+ const header = document.createElement('header');
159
+ header.className = 'jd-chat-header';
160
+
161
+ // Logo
162
+ if (config.header?.logo?.show !== false) {
163
+ const logoWrapper = document.createElement('div');
164
+ logoWrapper.className = 'jd-chat-header__logo';
165
+
166
+ if (config.header?.logo?.url) {
167
+ const img = document.createElement('img');
168
+ img.src = config.header.logo.url;
169
+ img.alt = '';
170
+ if (config.header.logo.size) {
171
+ img.style.width = `${config.header.logo.size}px`;
172
+ img.style.height = `${config.header.logo.size}px`;
173
+ }
174
+ logoWrapper.appendChild(img);
175
+ } else {
176
+ logoWrapper.appendChild(createIcon('bot'));
177
+ }
178
+
179
+ header.appendChild(logoWrapper);
180
+ }
181
+
182
+ // Title and subtitle
183
+ const content = document.createElement('div');
184
+ content.className = 'jd-chat-header__content';
185
+
186
+ const title = document.createElement('h2');
187
+ title.className = 'jd-chat-header__title';
188
+ title.textContent = config.header?.title || t('chat.title');
189
+ content.appendChild(title);
190
+
191
+ if (config.header?.subtitle) {
192
+ const subtitle = document.createElement('p');
193
+ subtitle.className = 'jd-chat-header__subtitle';
194
+ subtitle.textContent = config.header.subtitle;
195
+ content.appendChild(subtitle);
196
+ }
197
+
198
+ header.appendChild(content);
199
+
200
+ // Actions
201
+ const actions = document.createElement('div');
202
+ actions.className = 'jd-chat-header__actions';
203
+
204
+ const addButton = (icon: Parameters<typeof createIcon>[0], label: string, onClick: () => void) => {
205
+ const btn = document.createElement('button');
206
+ btn.className = 'jd-chat-header__btn';
207
+ btn.type = 'button';
208
+ btn.setAttribute('aria-label', label);
209
+ btn.appendChild(createIcon(icon));
210
+ btn.addEventListener('click', onClick);
211
+ actions.appendChild(btn);
212
+ };
213
+
214
+ if (displayMode === 'fullscreen' && onCollapse) {
215
+ addButton('collapse', t('chat.collapse'), onCollapse);
216
+ }
217
+
218
+ if (displayMode === 'floating') {
219
+ if (onExpand) addButton('expand', t('chat.expand'), onExpand);
220
+ addButton('close', t('chat.closeChat'), onClose);
221
+ }
222
+
223
+ header.appendChild(actions);
224
+
225
+ return header;
226
+ }
227
+
228
+ private renderBranding(text?: string): HTMLElement {
229
+ const branding = document.createElement('div');
230
+ branding.className = 'jd-branding';
231
+
232
+ const link = document.createElement('a');
233
+ link.href = 'https://jidou.ai';
234
+ link.target = '_blank';
235
+ link.rel = 'noopener noreferrer';
236
+ link.textContent = text || t('branding.poweredBy');
237
+ branding.appendChild(link);
238
+
239
+ return branding;
240
+ }
241
+
242
+ show(): void {
243
+ this.element.classList.remove('jd-chat-window--hidden');
244
+ this.inputArea?.focus();
245
+ }
246
+
247
+ hide(): void {
248
+ this.element.classList.add('jd-chat-window--hidden');
249
+ }
250
+
251
+ setMessages(messages: Message[]): void {
252
+ this.messageList?.setMessages(messages);
253
+ }
254
+
255
+ addMessage(message: Message): void {
256
+ this.messageList?.addMessage(message);
257
+ }
258
+
259
+ updateMessage(id: string, updates: Partial<Message>): void {
260
+ this.messageList?.updateMessage(id, updates);
261
+ }
262
+
263
+ setTyping(isTyping: boolean): void {
264
+ this.messageList?.setTyping(isTyping);
265
+ }
266
+
267
+ setConnectionState(state: ConnectionState, reconnectAttempt?: number): void {
268
+ this.connectionStatus?.setState(state, reconnectAttempt);
269
+ this.inputArea?.setDisabled(state !== 'connected');
270
+ }
271
+
272
+ focusInput(): void {
273
+ this.inputArea?.focus();
274
+ }
275
+
276
+ destroy(): void {
277
+ this.messageList?.destroy();
278
+ this.inputArea?.destroy();
279
+ this.connectionStatus?.destroy();
280
+ this.quickReplies?.destroy();
281
+ super.destroy();
282
+ }
283
+ }
@@ -0,0 +1,50 @@
1
+ import { BaseComponent } from './BaseComponent';
2
+ import type { ConnectionState } from '../types';
3
+ import { t } from '../i18n';
4
+
5
+ export interface ConnectionStatusOptions {
6
+ state: ConnectionState;
7
+ reconnectAttempt?: number;
8
+ }
9
+
10
+ const STATUS_KEYS: Record<ConnectionState, Parameters<typeof t>[0]> = {
11
+ connecting: 'status.connecting',
12
+ connected: 'status.connected',
13
+ disconnected: 'status.disconnected',
14
+ reconnecting: 'status.reconnecting',
15
+ failed: 'status.failed',
16
+ };
17
+
18
+ export class ConnectionStatus extends BaseComponent<ConnectionStatusOptions> {
19
+ protected render(): HTMLElement {
20
+ const { state, reconnectAttempt } = this.options;
21
+
22
+ const container = document.createElement('div');
23
+ container.className = `jd-connection-status jd-connection-status--${state}`;
24
+
25
+ const dot = document.createElement('span');
26
+ dot.className = 'jd-connection-status__dot';
27
+ container.appendChild(dot);
28
+
29
+ const text = document.createElement('span');
30
+ text.className = 'jd-connection-status__text';
31
+
32
+ let statusText = t(STATUS_KEYS[state]);
33
+ if (state === 'reconnecting' && reconnectAttempt) {
34
+ statusText = `${t('status.reconnecting').replace('...', '')} (${reconnectAttempt}/5)...`;
35
+ }
36
+ text.textContent = statusText;
37
+ container.appendChild(text);
38
+
39
+ // Don't show if connected (hide the indicator)
40
+ if (state === 'connected') {
41
+ container.style.display = 'none';
42
+ }
43
+
44
+ return container;
45
+ }
46
+
47
+ setState(state: ConnectionState, reconnectAttempt?: number): void {
48
+ this.update({ state, reconnectAttempt });
49
+ }
50
+ }
@@ -0,0 +1,164 @@
1
+ import { BaseComponent } from './BaseComponent';
2
+ import { createIcon } from './icons';
3
+ import { t } from '../i18n';
4
+
5
+ export interface InputAreaOptions {
6
+ placeholder?: string;
7
+ maxLength?: number;
8
+ disabled?: boolean;
9
+ onSend: (message: string) => void;
10
+ onTypingStart?: () => void;
11
+ onTypingStop?: () => void;
12
+ }
13
+
14
+ export class InputArea extends BaseComponent<InputAreaOptions> {
15
+ private textarea!: HTMLTextAreaElement;
16
+ private sendButton!: HTMLButtonElement;
17
+ private isTyping = false;
18
+ private typingTimeout: ReturnType<typeof setTimeout> | null = null;
19
+
20
+ protected render(): HTMLElement {
21
+ const { placeholder, maxLength, disabled } = this.options;
22
+
23
+ const container = document.createElement('div');
24
+ container.className = 'jd-input-area';
25
+
26
+ // Input wrapper
27
+ const wrapper = document.createElement('div');
28
+ wrapper.className = 'jd-input-wrapper';
29
+
30
+ // Textarea
31
+ this.textarea = document.createElement('textarea');
32
+ this.textarea.className = 'jd-input';
33
+ this.textarea.placeholder = placeholder || t('input.placeholder');
34
+ this.textarea.rows = 1;
35
+ this.textarea.disabled = disabled || false;
36
+ if (maxLength) {
37
+ this.textarea.maxLength = maxLength;
38
+ }
39
+ this.textarea.setAttribute('aria-label', t('input.ariaLabel'));
40
+
41
+ // Event handlers
42
+ this.textarea.addEventListener('input', this.handleInput.bind(this));
43
+ this.textarea.addEventListener('keydown', this.handleKeyDown.bind(this));
44
+
45
+ wrapper.appendChild(this.textarea);
46
+ container.appendChild(wrapper);
47
+
48
+ // Send button
49
+ this.sendButton = document.createElement('button');
50
+ this.sendButton.className = 'jd-send-btn';
51
+ this.sendButton.type = 'button';
52
+ this.sendButton.disabled = true;
53
+ this.sendButton.setAttribute('aria-label', t('input.send'));
54
+ this.sendButton.appendChild(createIcon('send'));
55
+ this.sendButton.addEventListener('click', this.handleSend.bind(this));
56
+
57
+ container.appendChild(this.sendButton);
58
+
59
+ return container;
60
+ }
61
+
62
+ private handleInput(): void {
63
+ if (!this.textarea || !this.sendButton) return;
64
+
65
+ const value = this.textarea.value.trim();
66
+ this.sendButton.disabled = value.length === 0;
67
+
68
+ // Auto-resize textarea
69
+ this.textarea.style.height = 'auto';
70
+ this.textarea.style.height = `${Math.min(this.textarea.scrollHeight, 120)}px`;
71
+
72
+ // Handle typing indicators
73
+ if (value.length > 0 && !this.isTyping) {
74
+ this.isTyping = true;
75
+ this.options.onTypingStart?.();
76
+ }
77
+
78
+ // Reset typing timeout
79
+ if (this.typingTimeout) {
80
+ clearTimeout(this.typingTimeout);
81
+ }
82
+
83
+ if (value.length > 0) {
84
+ this.typingTimeout = setTimeout(() => {
85
+ if (this.isTyping) {
86
+ this.isTyping = false;
87
+ this.options.onTypingStop?.();
88
+ }
89
+ }, 1000);
90
+ } else if (this.isTyping) {
91
+ this.isTyping = false;
92
+ this.options.onTypingStop?.();
93
+ }
94
+ }
95
+
96
+ private handleKeyDown(e: KeyboardEvent): void {
97
+ // Send on Enter (without Shift)
98
+ if (e.key === 'Enter' && !e.shiftKey) {
99
+ e.preventDefault();
100
+ this.handleSend();
101
+ }
102
+ }
103
+
104
+ private handleSend(): void {
105
+ if (!this.textarea) return;
106
+
107
+ const value = this.textarea.value.trim();
108
+ if (value.length === 0) return;
109
+
110
+ // Stop typing indicator
111
+ if (this.isTyping) {
112
+ this.isTyping = false;
113
+ this.options.onTypingStop?.();
114
+ }
115
+ if (this.typingTimeout) {
116
+ clearTimeout(this.typingTimeout);
117
+ this.typingTimeout = null;
118
+ }
119
+
120
+ // Clear input
121
+ this.textarea.value = '';
122
+ this.textarea.style.height = 'auto';
123
+ if (this.sendButton) {
124
+ this.sendButton.disabled = true;
125
+ }
126
+
127
+ // Send message
128
+ this.options.onSend(value);
129
+ }
130
+
131
+ focus(): void {
132
+ this.textarea?.focus();
133
+ }
134
+
135
+ clear(): void {
136
+ if (this.textarea) {
137
+ this.textarea.value = '';
138
+ this.textarea.style.height = 'auto';
139
+ }
140
+ if (this.sendButton) {
141
+ this.sendButton.disabled = true;
142
+ }
143
+ }
144
+
145
+ setDisabled(disabled: boolean): void {
146
+ if (this.textarea) {
147
+ this.textarea.disabled = disabled;
148
+ }
149
+ if (this.sendButton) {
150
+ this.sendButton.disabled = disabled || !this.textarea?.value.trim();
151
+ }
152
+ }
153
+
154
+ getValue(): string {
155
+ return this.textarea?.value.trim() || '';
156
+ }
157
+
158
+ destroy(): void {
159
+ if (this.typingTimeout) {
160
+ clearTimeout(this.typingTimeout);
161
+ }
162
+ super.destroy();
163
+ }
164
+ }
@@ -0,0 +1,121 @@
1
+ import { BaseComponent } from './BaseComponent';
2
+ import { createIcon } from './icons';
3
+ import { t } from '../i18n';
4
+ import type { LauncherConfig, LauncherPosition, LauncherSize, LauncherShape } from '../types';
5
+
6
+ export interface LauncherOptions {
7
+ config: LauncherConfig;
8
+ isOpen: boolean;
9
+ unreadCount: number;
10
+ onClick: () => void;
11
+ }
12
+
13
+ export class Launcher extends BaseComponent<LauncherOptions> {
14
+ protected render(): HTMLElement {
15
+ const {
16
+ config,
17
+ isOpen,
18
+ unreadCount,
19
+ onClick,
20
+ } = this.options;
21
+
22
+ const position = config.position || 'bottom-right';
23
+ const size = config.size || 'medium';
24
+ const shape = config.shape || 'circle';
25
+
26
+ const button = document.createElement('button');
27
+ button.className = this.getClassName(position, size, shape, isOpen);
28
+ button.setAttribute('aria-label', isOpen ? t('chat.closeChat') : t('chat.openChat'));
29
+ button.setAttribute('type', 'button');
30
+
31
+ // Apply offset styles
32
+ if (config.offsetX !== undefined) {
33
+ const isRight = position.includes('right');
34
+ button.style[isRight ? 'right' : 'left'] = `${config.offsetX}px`;
35
+ }
36
+ if (config.offsetY !== undefined) {
37
+ button.style.bottom = `${config.offsetY}px`;
38
+ }
39
+
40
+ // Icon
41
+ const iconWrapper = document.createElement('span');
42
+ iconWrapper.className = 'jd-launcher__icon';
43
+
44
+ if (config.icon?.type === 'custom' && config.icon.url) {
45
+ const img = document.createElement('img');
46
+ img.className = 'jd-launcher__img';
47
+ img.src = config.icon.url;
48
+ img.alt = '';
49
+ iconWrapper.appendChild(img);
50
+ button.appendChild(iconWrapper);
51
+ } else if (config.icon?.type === 'text' && config.icon.text) {
52
+ const textSpan = document.createElement('span');
53
+ textSpan.className = 'jd-launcher__text';
54
+ textSpan.textContent = config.icon.text;
55
+ button.appendChild(textSpan);
56
+ } else {
57
+ // Default icon - toggle between chat and close
58
+ const icon = isOpen ? createIcon('close') : createIcon('chat');
59
+ iconWrapper.appendChild(icon);
60
+ button.appendChild(iconWrapper);
61
+ }
62
+
63
+ // Badge for unread count
64
+ if (unreadCount > 0 && !isOpen) {
65
+ const badge = document.createElement('span');
66
+ badge.className = 'jd-launcher__badge';
67
+ badge.textContent = unreadCount > 99 ? '99+' : String(unreadCount);
68
+ badge.setAttribute('aria-label', `${unreadCount} unread messages`);
69
+ button.appendChild(badge);
70
+ }
71
+
72
+ // Tooltip
73
+ if (config.tooltip?.show && config.tooltip.text && !isOpen) {
74
+ const tooltip = document.createElement('span');
75
+ tooltip.className = 'jd-launcher__tooltip';
76
+ tooltip.textContent = config.tooltip.text;
77
+ button.appendChild(tooltip);
78
+ }
79
+
80
+ // Click handler
81
+ button.addEventListener('click', onClick);
82
+
83
+ return button;
84
+ }
85
+
86
+ private getClassName(
87
+ position: LauncherPosition,
88
+ size: LauncherSize,
89
+ shape: LauncherShape,
90
+ isOpen: boolean
91
+ ): string {
92
+ const classes = [
93
+ 'jd-launcher',
94
+ `jd-launcher--${position}`,
95
+ `jd-launcher--${size}`,
96
+ `jd-launcher--${shape}`,
97
+ ];
98
+
99
+ if (isOpen) {
100
+ classes.push('jd-launcher--open');
101
+ }
102
+
103
+ return classes.join(' ');
104
+ }
105
+
106
+ show(): void {
107
+ this.element.classList.remove('jd-launcher--hidden');
108
+ }
109
+
110
+ hide(): void {
111
+ this.element.classList.add('jd-launcher--hidden');
112
+ }
113
+
114
+ setOpen(isOpen: boolean): void {
115
+ this.update({ ...this.options, isOpen });
116
+ }
117
+
118
+ setUnreadCount(count: number): void {
119
+ this.update({ ...this.options, unreadCount: count });
120
+ }
121
+ }