@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,436 @@
1
+ /**
2
+ * Mock WebSocket Server for testing the chat widget
3
+ *
4
+ * This server simulates the chatAgent backend for local development.
5
+ * It echoes messages back with a simulated AI response.
6
+ */
7
+
8
+ const http = require('http');
9
+ const crypto = require('crypto');
10
+ const { execFileSync } = require('child_process');
11
+ const { WebSocketServer } = require('ws');
12
+ const fs = require('fs');
13
+ const path = require('path');
14
+
15
+ const WS_PORT = 8080;
16
+ const HTTP_PORT = 3000;
17
+
18
+ // Store active sessions
19
+ const sessions = new Map();
20
+
21
+ // Create WebSocket server
22
+ const wss = new WebSocketServer({ port: WS_PORT });
23
+
24
+ console.log(`WebSocket server running on ws://localhost:${WS_PORT}`);
25
+
26
+ wss.on('connection', (ws, req) => {
27
+ const url = new URL(req.url, `http://localhost:${WS_PORT}`);
28
+ const clientId = url.searchParams.get('clientId') || 'unknown';
29
+ const sessionId = url.searchParams.get('sessionId') || crypto.randomUUID();
30
+
31
+ console.log(`[WS] New connection: clientId=${clientId}, sessionId=${sessionId}`);
32
+
33
+ const isResumed = sessions.has(sessionId);
34
+ sessions.set(sessionId, { clientId, lastActive: Date.now() });
35
+
36
+ // Send connection acknowledgment
37
+ setTimeout(() => {
38
+ sendMessage(ws, {
39
+ type: 'connection:ack',
40
+ payload: {
41
+ sessionId,
42
+ isResumed,
43
+ },
44
+ });
45
+ }, 100);
46
+
47
+ ws.on('message', (data) => {
48
+ try {
49
+ const message = JSON.parse(data.toString());
50
+ console.log(`[WS] Received (${sessionId}):`, message.type);
51
+ handleMessage(ws, sessionId, message);
52
+ } catch (error) {
53
+ console.error('[WS] Error parsing message:', error);
54
+ sendMessage(ws, {
55
+ type: 'error',
56
+ payload: { code: 'PARSE_ERROR', message: 'Failed to parse message' },
57
+ });
58
+ }
59
+ });
60
+
61
+ ws.on('close', () => {
62
+ console.log(`[WS] Connection closed: ${sessionId}`);
63
+ });
64
+
65
+ ws.on('error', (error) => {
66
+ console.error(`[WS] Socket error:`, error.message);
67
+ });
68
+ });
69
+
70
+ function sendMessage(ws, message) {
71
+ if (ws.readyState === 1) { // WebSocket.OPEN
72
+ const data = JSON.stringify({ ...message, timestamp: Date.now() });
73
+ console.log(`[WS] Sending: ${message.type}`);
74
+ ws.send(data);
75
+ } else {
76
+ console.log(`[WS] Cannot send ${message.type}, ws.readyState=${ws.readyState}`);
77
+ }
78
+ }
79
+
80
+ function handleMessage(ws, sessionId, message) {
81
+ switch (message.type) {
82
+ case 'session:init':
83
+ sendMessage(ws, {
84
+ type: 'connection:ack',
85
+ payload: { sessionId, isResumed: false },
86
+ });
87
+ break;
88
+
89
+ case 'message:send':
90
+ handleUserMessage(ws, sessionId, message.payload);
91
+ break;
92
+
93
+ case 'typing:start':
94
+ case 'typing:stop':
95
+ // Could broadcast to other clients if needed
96
+ break;
97
+
98
+ case 'ping':
99
+ sendMessage(ws, { type: 'pong' });
100
+ break;
101
+
102
+ default:
103
+ console.log(`[WS] Unknown message type: ${message.type}`);
104
+ }
105
+ }
106
+
107
+ function handleUserMessage(ws, sessionId, payload) {
108
+ const userMessage = payload?.content || '';
109
+ console.log(`[WS] User message (${sessionId}): ${userMessage}`);
110
+
111
+ // Send typing indicator
112
+ sendMessage(ws, { type: 'bot:typing' });
113
+
114
+ // Simulate AI thinking time (1-2 seconds)
115
+ const thinkingTime = 1000 + Math.random() * 1000;
116
+
117
+ setTimeout(() => {
118
+ const response = generateMockResponse(userMessage);
119
+
120
+ sendMessage(ws, {
121
+ type: 'message:receive',
122
+ payload: {
123
+ id: `msg_${crypto.randomUUID().slice(0, 8)}`,
124
+ content: response,
125
+ role: 'assistant',
126
+ },
127
+ });
128
+ }, thinkingTime);
129
+ }
130
+
131
+ function generateMockResponse(userMessage) {
132
+ const lowerMessage = userMessage.toLowerCase();
133
+
134
+ if (lowerMessage.includes('hello') || lowerMessage.includes('hi') || lowerMessage.includes('你好')) {
135
+ return '你好!我是智導 AI 助理。有什麼我可以幫助你的嗎?';
136
+ }
137
+
138
+ if (lowerMessage.includes('help') || lowerMessage.includes('幫助')) {
139
+ return '我可以協助你:\n• 回答產品相關問題\n• 提供技術支援\n• 預約諮詢服務\n\n請問你需要哪方面的協助?';
140
+ }
141
+
142
+ if (lowerMessage.includes('price') || lowerMessage.includes('價格') || lowerMessage.includes('費用')) {
143
+ return '關於價格方案,我們提供:\n• 基本方案:免費\n• 專業方案:$99/月\n• 企業方案:客製報價\n\n需要我詳細介紹哪個方案嗎?';
144
+ }
145
+
146
+ if (lowerMessage.includes('bye') || lowerMessage.includes('再見') || lowerMessage.includes('謝謝')) {
147
+ return '謝謝你的詢問!如果還有其他問題,隨時歡迎回來。祝你有美好的一天!';
148
+ }
149
+
150
+ const responses = [
151
+ `收到你的訊息:「${userMessage}」\n\n這是一個測試回覆。在正式環境中,AI 會根據你的問題提供更詳細的回答。`,
152
+ `我理解你說的是:「${userMessage}」\n\n有什麼其他問題我可以幫助你的嗎?`,
153
+ `感謝你的詢問!關於「${userMessage}」,我會盡力為你解答。\n\n這是測試伺服器的模擬回覆。`,
154
+ ];
155
+
156
+ return responses[Math.floor(Math.random() * responses.length)];
157
+ }
158
+
159
+ // HTTP server for serving static files
160
+ const httpServer = http.createServer((req, res) => {
161
+ let filePath = path.resolve(__dirname, '../dist', req.url === '/' ? 'index.html' : req.url.slice(1));
162
+
163
+ const ext = path.extname(filePath);
164
+ const contentTypes = {
165
+ '.html': 'text/html; charset=utf-8',
166
+ '.js': 'application/javascript; charset=utf-8',
167
+ '.css': 'text/css; charset=utf-8',
168
+ '.json': 'application/json; charset=utf-8',
169
+ };
170
+
171
+ fs.readFile(filePath, (err, content) => {
172
+ if (err) {
173
+ res.writeHead(404);
174
+ res.end('Not found');
175
+ return;
176
+ }
177
+ res.writeHead(200, { 'Content-Type': contentTypes[ext] || 'text/plain' });
178
+ res.end(content);
179
+ });
180
+ });
181
+
182
+ // Create test HTML page
183
+ function createTestPage() {
184
+ const testHtml = `<!DOCTYPE html>
185
+ <html lang="zh-TW">
186
+ <head>
187
+ <meta charset="UTF-8">
188
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
189
+ <title>Jidou Chat Widget - Test</title>
190
+ <style>
191
+ * { box-sizing: border-box; }
192
+ body {
193
+ font-family: system-ui, -apple-system, sans-serif;
194
+ margin: 0;
195
+ padding: 40px;
196
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
197
+ min-height: 100vh;
198
+ }
199
+ .container { max-width: 800px; margin: 0 auto; }
200
+ h1 { color: white; margin-bottom: 8px; }
201
+ .subtitle { color: rgba(255,255,255,0.8); margin-bottom: 32px; }
202
+ .card {
203
+ background: white;
204
+ border-radius: 12px;
205
+ padding: 24px;
206
+ margin-bottom: 24px;
207
+ box-shadow: 0 4px 20px rgba(0,0,0,0.15);
208
+ }
209
+ h2 { margin-top: 0; color: #333; font-size: 18px; }
210
+ .controls { display: flex; flex-wrap: wrap; gap: 8px; }
211
+ button {
212
+ padding: 10px 20px;
213
+ border: none;
214
+ border-radius: 8px;
215
+ background: #4F46E5;
216
+ color: white;
217
+ cursor: pointer;
218
+ font-size: 14px;
219
+ font-weight: 500;
220
+ transition: all 0.2s;
221
+ }
222
+ button:hover { background: #4338CA; transform: translateY(-1px); }
223
+ button.secondary { background: #E5E7EB; color: #374151; }
224
+ button.secondary:hover { background: #D1D5DB; }
225
+ .log {
226
+ margin-top: 16px;
227
+ padding: 16px;
228
+ background: #1a1a2e;
229
+ color: #0f0;
230
+ font-family: 'Monaco', 'Consolas', monospace;
231
+ font-size: 12px;
232
+ border-radius: 8px;
233
+ max-height: 200px;
234
+ overflow-y: auto;
235
+ }
236
+ .log-entry { margin: 4px 0; padding: 4px 0; border-bottom: 1px solid rgba(255,255,255,0.1); }
237
+ .log-time { color: #888; }
238
+ .log-event { color: #4FC3F7; }
239
+ .log-data { color: #81C784; }
240
+ #embed-container {
241
+ width: 100%;
242
+ height: 500px;
243
+ border: 2px dashed #ccc;
244
+ border-radius: 12px;
245
+ display: none;
246
+ background: #f9fafb;
247
+ margin-top: 16px;
248
+ }
249
+ .status {
250
+ display: inline-flex;
251
+ align-items: center;
252
+ gap: 6px;
253
+ padding: 6px 12px;
254
+ background: #f3f4f6;
255
+ border-radius: 20px;
256
+ font-size: 13px;
257
+ margin-bottom: 16px;
258
+ }
259
+ .status-dot {
260
+ width: 8px;
261
+ height: 8px;
262
+ border-radius: 50%;
263
+ background: #9CA3AF;
264
+ }
265
+ .status-dot.connected { background: #22C55E; }
266
+ .status-dot.connecting { background: #F59E0B; animation: pulse 1s infinite; }
267
+ .status-dot.failed { background: #EF4444; }
268
+ @keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } }
269
+ .info {
270
+ background: #EFF6FF;
271
+ border: 1px solid #BFDBFE;
272
+ border-radius: 8px;
273
+ padding: 16px;
274
+ margin-bottom: 16px;
275
+ color: #1E40AF;
276
+ font-size: 14px;
277
+ }
278
+ </style>
279
+ </head>
280
+ <body>
281
+ <div class="container">
282
+ <h1>Jidou Chat Widget</h1>
283
+ <p class="subtitle">Test Environment with Mock Server</p>
284
+
285
+ <div class="card">
286
+ <h2>Connection Status</h2>
287
+ <div class="status">
288
+ <span class="status-dot" id="status-dot"></span>
289
+ <span id="status-text">Disconnected</span>
290
+ </div>
291
+ <div class="info">
292
+ Mock WebSocket Server: ws://localhost:${WS_PORT}<br>
293
+ HTTP Server: http://localhost:${HTTP_PORT}
294
+ </div>
295
+ </div>
296
+
297
+ <div class="card">
298
+ <h2>Widget Controls</h2>
299
+ <div class="controls">
300
+ <button onclick="JidouChat.open()">Open Chat</button>
301
+ <button onclick="JidouChat.close()">Close Chat</button>
302
+ <button onclick="JidouChat.toggle()">Toggle</button>
303
+ <button class="secondary" onclick="JidouChat.sendMessage('Hello!')">Send "Hello!"</button>
304
+ <button class="secondary" onclick="JidouChat.sendMessage('價格是多少?')">Ask Price</button>
305
+ </div>
306
+ </div>
307
+
308
+ <div class="card">
309
+ <h2>Display Mode</h2>
310
+ <div class="controls">
311
+ <button onclick="switchMode('floating')">Floating</button>
312
+ <button onclick="switchMode('fullscreen')">Fullscreen</button>
313
+ <button onclick="switchMode('embedded')">Embedded</button>
314
+ </div>
315
+ <div id="embed-container"></div>
316
+ </div>
317
+
318
+ <div class="card">
319
+ <h2>Event Log</h2>
320
+ <div class="log" id="log"></div>
321
+ </div>
322
+ </div>
323
+
324
+ <script src="/widget.js"></script>
325
+ <script>
326
+ function log(event, data) {
327
+ var el = document.getElementById('log');
328
+ var entry = document.createElement('div');
329
+ entry.className = 'log-entry';
330
+
331
+ var time = document.createElement('span');
332
+ time.className = 'log-time';
333
+ time.textContent = new Date().toISOString().slice(11, 19) + ' ';
334
+ entry.appendChild(time);
335
+
336
+ var eventSpan = document.createElement('span');
337
+ eventSpan.className = 'log-event';
338
+ eventSpan.textContent = event;
339
+ entry.appendChild(eventSpan);
340
+
341
+ if (data !== undefined) {
342
+ var dataSpan = document.createElement('span');
343
+ dataSpan.className = 'log-data';
344
+ dataSpan.textContent = ' ' + (typeof data === 'object' ? JSON.stringify(data) : data);
345
+ entry.appendChild(dataSpan);
346
+ }
347
+
348
+ el.insertBefore(entry, el.firstChild);
349
+ }
350
+
351
+ function updateStatus(state) {
352
+ var dot = document.getElementById('status-dot');
353
+ var text = document.getElementById('status-text');
354
+ dot.className = 'status-dot';
355
+ if (state === 'connected') {
356
+ dot.classList.add('connected');
357
+ text.textContent = 'Connected';
358
+ } else if (state === 'connecting' || state === 'reconnecting') {
359
+ dot.classList.add('connecting');
360
+ text.textContent = state === 'reconnecting' ? 'Reconnecting...' : 'Connecting...';
361
+ } else if (state === 'failed') {
362
+ dot.classList.add('failed');
363
+ text.textContent = 'Connection Failed';
364
+ } else {
365
+ text.textContent = 'Disconnected';
366
+ }
367
+ }
368
+
369
+ window.JidouChatSettings = {
370
+ clientId: 'test-client',
371
+ displayMode: 'floating',
372
+ theme: 'light',
373
+ wsUrl: 'ws://localhost:${WS_PORT}',
374
+ colors: { primary: '#4F46E5' },
375
+ chatWindow: {
376
+ header: {
377
+ title: 'AI 助理',
378
+ subtitle: 'Online'
379
+ }
380
+ },
381
+ hooks: {
382
+ onLoad: function() { log('onLoad'); },
383
+ onReady: function() { log('onReady'); },
384
+ onOpen: function() { log('onOpen'); },
385
+ onClose: function() { log('onClose'); },
386
+ onMessageSent: function(msg) { log('onMessageSent', msg); },
387
+ onMessageReceived: function(msg) { log('onMessageReceived', msg); },
388
+ onConnectionStateChange: function(state) {
389
+ log('onConnectionStateChange', state);
390
+ updateStatus(state);
391
+ },
392
+ onReconnect: function(attempt) { log('onReconnect', 'Attempt ' + attempt); },
393
+ onError: function(err) { log('onError', err.message); }
394
+ }
395
+ };
396
+
397
+ function switchMode(mode) {
398
+ var container = document.getElementById('embed-container');
399
+ if (mode === 'embedded') {
400
+ container.style.display = 'block';
401
+ JidouChat.setDisplayMode('embedded', { container: '#embed-container' });
402
+ } else {
403
+ container.style.display = 'none';
404
+ JidouChat.setDisplayMode(mode);
405
+ }
406
+ log('switchMode', mode);
407
+ }
408
+ </script>
409
+ </body>
410
+ </html>`;
411
+
412
+ const distDir = path.resolve(__dirname, '../dist');
413
+ if (!fs.existsSync(distDir)) {
414
+ fs.mkdirSync(distDir, { recursive: true });
415
+ }
416
+ fs.writeFileSync(path.join(distDir, 'index.html'), testHtml);
417
+ }
418
+
419
+ // Build widget before starting
420
+ console.log('Building widget...');
421
+ try {
422
+ execFileSync('npm', ['run', 'build'], {
423
+ cwd: path.resolve(__dirname, '..'),
424
+ stdio: 'inherit',
425
+ });
426
+ } catch (error) {
427
+ console.error('Build failed, continuing anyway...');
428
+ }
429
+
430
+ createTestPage();
431
+
432
+ // Start HTTP server
433
+ httpServer.listen(HTTP_PORT, () => {
434
+ console.log(`HTTP server running on http://localhost:${HTTP_PORT}`);
435
+ console.log(`\nOpen http://localhost:${HTTP_PORT} in your browser to test the widget\n`);
436
+ });
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Base component class for all UI components
3
+ */
4
+
5
+ export abstract class BaseComponent<T = unknown> {
6
+ protected element: HTMLElement;
7
+ protected options: T;
8
+
9
+ constructor(options: T) {
10
+ this.options = options;
11
+ this.element = this.render();
12
+ }
13
+
14
+ /**
15
+ * Render the component and return the root element
16
+ */
17
+ protected abstract render(): HTMLElement;
18
+
19
+ /**
20
+ * Get the root element
21
+ */
22
+ getElement(): HTMLElement {
23
+ return this.element;
24
+ }
25
+
26
+ /**
27
+ * Mount the component to a parent element
28
+ */
29
+ mount(parent: HTMLElement | ShadowRoot): void {
30
+ parent.appendChild(this.element);
31
+ }
32
+
33
+ /**
34
+ * Unmount the component from the DOM
35
+ */
36
+ unmount(): void {
37
+ if (this.element.parentNode) {
38
+ this.element.parentNode.removeChild(this.element);
39
+ }
40
+ }
41
+
42
+ /**
43
+ * Update component with new options
44
+ */
45
+ update(options: Partial<T>): void {
46
+ this.options = { ...this.options, ...options };
47
+ this.refresh();
48
+ }
49
+
50
+ /**
51
+ * Re-render the component
52
+ */
53
+ protected refresh(): void {
54
+ const newElement = this.render();
55
+ if (this.element.parentNode) {
56
+ this.element.parentNode.replaceChild(newElement, this.element);
57
+ }
58
+ this.element = newElement;
59
+ }
60
+
61
+ /**
62
+ * Cleanup resources
63
+ */
64
+ destroy(): void {
65
+ this.unmount();
66
+ }
67
+ }