@jungjaehoon/mama-os 0.8.3 → 0.9.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 (106) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/dist/agent/agent-loop.d.ts +1 -8
  3. package/dist/agent/agent-loop.d.ts.map +1 -1
  4. package/dist/agent/agent-loop.js +44 -159
  5. package/dist/agent/agent-loop.js.map +1 -1
  6. package/dist/agent/claude-cli-wrapper.d.ts +6 -0
  7. package/dist/agent/claude-cli-wrapper.d.ts.map +1 -1
  8. package/dist/agent/claude-cli-wrapper.js +6 -0
  9. package/dist/agent/claude-cli-wrapper.js.map +1 -1
  10. package/dist/agent/codex-mcp-process.d.ts +85 -0
  11. package/dist/agent/codex-mcp-process.d.ts.map +1 -0
  12. package/dist/agent/codex-mcp-process.js +357 -0
  13. package/dist/agent/codex-mcp-process.js.map +1 -0
  14. package/dist/agent/session-pool.d.ts +17 -2
  15. package/dist/agent/session-pool.d.ts.map +1 -1
  16. package/dist/agent/session-pool.js +51 -26
  17. package/dist/agent/session-pool.js.map +1 -1
  18. package/dist/agent/types.d.ts +9 -24
  19. package/dist/agent/types.d.ts.map +1 -1
  20. package/dist/agent/types.js.map +1 -1
  21. package/dist/api/graph-api.d.ts.map +1 -1
  22. package/dist/api/graph-api.js +133 -45
  23. package/dist/api/graph-api.js.map +1 -1
  24. package/dist/cli/commands/init.d.ts +1 -1
  25. package/dist/cli/commands/init.d.ts.map +1 -1
  26. package/dist/cli/commands/init.js +14 -25
  27. package/dist/cli/commands/init.js.map +1 -1
  28. package/dist/cli/commands/run.d.ts.map +1 -1
  29. package/dist/cli/commands/run.js +3 -10
  30. package/dist/cli/commands/run.js.map +1 -1
  31. package/dist/cli/commands/start.d.ts.map +1 -1
  32. package/dist/cli/commands/start.js +143 -54
  33. package/dist/cli/commands/start.js.map +1 -1
  34. package/dist/cli/commands/status.d.ts.map +1 -1
  35. package/dist/cli/commands/status.js +2 -7
  36. package/dist/cli/commands/status.js.map +1 -1
  37. package/dist/cli/config/config-manager.d.ts.map +1 -1
  38. package/dist/cli/config/config-manager.js +9 -17
  39. package/dist/cli/config/config-manager.js.map +1 -1
  40. package/dist/cli/config/types.d.ts +19 -25
  41. package/dist/cli/config/types.d.ts.map +1 -1
  42. package/dist/cli/config/types.js.map +1 -1
  43. package/dist/cli/index.js +2 -2
  44. package/dist/cli/index.js.map +1 -1
  45. package/dist/gateways/context-injector.d.ts.map +1 -1
  46. package/dist/gateways/context-injector.js +6 -3
  47. package/dist/gateways/context-injector.js.map +1 -1
  48. package/dist/gateways/discord.d.ts +4 -0
  49. package/dist/gateways/discord.d.ts.map +1 -1
  50. package/dist/gateways/discord.js +39 -16
  51. package/dist/gateways/discord.js.map +1 -1
  52. package/dist/gateways/message-router.d.ts +6 -1
  53. package/dist/gateways/message-router.d.ts.map +1 -1
  54. package/dist/gateways/message-router.js +92 -7
  55. package/dist/gateways/message-router.js.map +1 -1
  56. package/dist/multi-agent/agent-process-manager.d.ts.map +1 -1
  57. package/dist/multi-agent/agent-process-manager.js +36 -9
  58. package/dist/multi-agent/agent-process-manager.js.map +1 -1
  59. package/dist/multi-agent/runtime-process.d.ts +4 -4
  60. package/dist/multi-agent/runtime-process.d.ts.map +1 -1
  61. package/dist/multi-agent/runtime-process.js +9 -20
  62. package/dist/multi-agent/runtime-process.js.map +1 -1
  63. package/dist/multi-agent/types.d.ts +13 -8
  64. package/dist/multi-agent/types.d.ts.map +1 -1
  65. package/dist/multi-agent/types.js.map +1 -1
  66. package/dist/setup/setup-prompt.d.ts +1 -1
  67. package/dist/setup/setup-prompt.d.ts.map +1 -1
  68. package/dist/setup/setup-prompt.js +19 -0
  69. package/dist/setup/setup-prompt.js.map +1 -1
  70. package/dist/setup/setup-server.d.ts.map +1 -1
  71. package/dist/setup/setup-server.js +39 -16
  72. package/dist/setup/setup-server.js.map +1 -1
  73. package/dist/skills/skill-registry.d.ts.map +1 -1
  74. package/dist/skills/skill-registry.js +5 -2
  75. package/dist/skills/skill-registry.js.map +1 -1
  76. package/package.json +5 -3
  77. package/public/setup.html +12 -1
  78. package/public/viewer/js/modules/chat.js +1760 -1976
  79. package/public/viewer/js/modules/dashboard.js +613 -695
  80. package/public/viewer/js/modules/graph.js +857 -970
  81. package/public/viewer/js/modules/memory.js +357 -312
  82. package/public/viewer/js/modules/settings.js +1009 -1026
  83. package/public/viewer/js/modules/skills.js +336 -355
  84. package/public/viewer/js/utils/api.js +255 -255
  85. package/public/viewer/js/utils/debug-logger.js +20 -26
  86. package/public/viewer/js/utils/dom.js +73 -60
  87. package/public/viewer/js/utils/format.js +182 -228
  88. package/public/viewer/js/utils/markdown.js +40 -0
  89. package/public/viewer/src/modules/chat.ts +2258 -0
  90. package/public/viewer/src/modules/dashboard.ts +1052 -0
  91. package/public/viewer/src/modules/graph.ts +1080 -0
  92. package/public/viewer/src/modules/memory.ts +453 -0
  93. package/public/viewer/src/modules/settings.ts +1398 -0
  94. package/public/viewer/src/modules/skills.ts +457 -0
  95. package/public/viewer/src/types/global.d.ts +168 -0
  96. package/public/viewer/src/utils/api.ts +650 -0
  97. package/public/viewer/src/utils/debug-logger.ts +36 -0
  98. package/public/viewer/src/utils/dom.ts +138 -0
  99. package/public/viewer/src/utils/format.ts +331 -0
  100. package/public/viewer/src/utils/markdown.ts +46 -0
  101. package/public/viewer/tsconfig.viewer.json +18 -0
  102. package/public/viewer/viewer.html +214 -311
  103. package/dist/agent/codex-cli-wrapper.d.ts +0 -85
  104. package/dist/agent/codex-cli-wrapper.d.ts.map +0 -1
  105. package/dist/agent/codex-cli-wrapper.js +0 -295
  106. package/dist/agent/codex-cli-wrapper.js.map +0 -1
@@ -9,559 +9,475 @@
9
9
  * - Conversation history management
10
10
  * - Real-time streaming responses
11
11
  */
12
-
13
12
  /* eslint-env browser */
14
-
15
- import {
16
- escapeHtml,
17
- escapeAttr,
18
- showToast,
19
- scrollToBottom,
20
- autoResizeTextarea,
21
- } from '../utils/dom.js';
13
+ import { escapeHtml, escapeAttr, showToast, scrollToBottom, autoResizeTextarea, getElementByIdOrNull, getErrorMessage, } from '../utils/dom.js';
22
14
  import { formatMessageTime, formatAssistantMessage } from '../utils/format.js';
23
15
  import { API } from '../utils/api.js';
24
16
  import { DebugLogger } from '../utils/debug-logger.js';
25
-
26
17
  const logger = new DebugLogger('Chat');
27
-
28
18
  /**
29
19
  * Chat Module Class
30
20
  */
31
21
  export class ChatModule {
32
- constructor(memoryModule = null) {
33
- // External dependencies
34
- this.memoryModule = memoryModule;
35
-
36
- // WebSocket state
37
- this.ws = null;
38
- this.sessionId = null;
39
- this.reconnectAttempts = 0;
40
- this.maxReconnectDelay = 30000; // 30 seconds
41
-
42
- // Voice input state (STT)
43
- this.speechRecognition = null;
44
- this.isRecording = false;
45
- this.silenceTimeout = null;
46
- this.silenceDelay = 2500; // 2.5 seconds (increased for continuous mode)
47
- this.accumulatedTranscript = ''; // Track accumulated final transcripts
48
-
49
- // Voice output state (TTS)
50
- this.speechSynthesis = window.speechSynthesis;
51
- this.isSpeaking = false;
52
- this.ttsEnabled = false; // Auto-play toggle
53
- this.handsFreeMode = false; // Auto-listen after TTS
54
- this.ttsVoice = null;
55
- this.ttsRate = 1.8; // Speech rate (0.5 - 2.0), optimized for Korean
56
- this.ttsPitch = 1.0; // Speech pitch (0.0 - 2.0)
57
-
58
- // Streaming state
59
- this.currentStreamEl = null;
60
- this.currentStreamText = '';
61
- this.streamBuffer = '';
62
- this.rafPending = false;
63
-
64
- // History state
65
- this.history = [];
66
- this.historyPrefix = 'mama_chat_history_';
67
- this.maxHistoryMessages = 50;
68
- this.historyExpiryMs = 24 * 60 * 60 * 1000; // 24 hours
69
-
70
- // Idle auto-checkpoint state
71
- this.idleTimer = null;
72
- this.IDLE_TIMEOUT = 5 * 60 * 1000; // 5 minutes
73
- this.checkpointCooldown = false;
74
- this.COOLDOWN_MS = 60 * 1000; // 1 minute between checkpoints
75
-
76
- // Initialize
77
- this.initChatInput();
78
- this.initLongPressCopy();
79
- this.initSpeechRecognition();
80
- this.initSpeechSynthesis();
81
- }
82
-
83
- // =============================================
84
- // Idle Auto-Checkpoint
85
- // =============================================
86
-
87
- resetIdleTimer() {
88
- if (this.idleTimer) {
89
- clearTimeout(this.idleTimer);
22
+ memoryModule = null;
23
+ ws = null;
24
+ sessionId = null;
25
+ reconnectAttempts = 0;
26
+ maxReconnectDelay = 30000;
27
+ speechRecognition = null;
28
+ isRecording = false;
29
+ silenceTimeout = null;
30
+ silenceDelay = 2500;
31
+ accumulatedTranscript = '';
32
+ speechSynthesis = window.speechSynthesis;
33
+ isSpeaking = false;
34
+ ttsEnabled = false;
35
+ handsFreeMode = false;
36
+ ttsVoice = null;
37
+ ttsRate = 1.8;
38
+ ttsPitch = 1.0;
39
+ currentStreamEl = null;
40
+ currentStreamText = '';
41
+ streamBuffer = '';
42
+ rafPending = false;
43
+ history = [];
44
+ historyPrefix = 'mama_chat_history_';
45
+ maxHistoryMessages = 50;
46
+ historyExpiryMs = 24 * 60 * 60 * 1000;
47
+ checkpointCooldown = false;
48
+ COOLDOWN_MS = 60 * 1000;
49
+ idleTimer = null;
50
+ IDLE_TIMEOUT = 5 * 60 * 1000;
51
+ _onDragMouseMove = null;
52
+ _onDragMouseUp = null;
53
+ _onDragTouchMove = null;
54
+ _onDragTouchEnd = null;
55
+ _onResizeMouseMove = null;
56
+ _onResizeMouseUp = null;
57
+ _onResizeTouchMove = null;
58
+ _onResizeTouchEnd = null;
59
+ _onEscapeKey = null;
60
+ constructor(memoryModule = null) {
61
+ // External dependencies
62
+ this.memoryModule = memoryModule;
63
+ // Initialize
64
+ this.initChatInput();
65
+ this.initLongPressCopy();
66
+ this.initSpeechRecognition();
67
+ this.initSpeechSynthesis();
68
+ }
69
+ // =============================================
70
+ // Idle Auto-Checkpoint
71
+ // =============================================
72
+ resetIdleTimer() {
73
+ if (this.idleTimer) {
74
+ clearTimeout(this.idleTimer);
75
+ }
76
+ if (this.ws && this.ws.readyState === WebSocket.OPEN) {
77
+ this.idleTimer = setTimeout(() => {
78
+ this.autoCheckpoint();
79
+ }, this.IDLE_TIMEOUT);
80
+ }
90
81
  }
91
-
92
- if (this.ws && this.ws.readyState === WebSocket.OPEN) {
93
- this.idleTimer = setTimeout(() => {
94
- this.autoCheckpoint();
95
- }, this.IDLE_TIMEOUT);
82
+ async autoCheckpoint() {
83
+ // DISABLED: Auto-checkpoint was saving raw conversation history to MAMA memory.
84
+ // Checkpoints should only be saved manually via /checkpoint command with proper summaries.
85
+ // The viewer chat uses localStorage for session persistence instead.
86
+ logger.info('Auto-checkpoint disabled (use /checkpoint for manual saves)');
87
+ return;
96
88
  }
97
- }
98
-
99
- async autoCheckpoint() {
100
- // DISABLED: Auto-checkpoint was saving raw conversation history to MAMA memory.
101
- // Checkpoints should only be saved manually via /checkpoint command with proper summaries.
102
- // The viewer chat uses localStorage for session persistence instead.
103
- logger.info('Auto-checkpoint disabled (use /checkpoint for manual saves)');
104
- return;
105
- }
106
-
107
- // =============================================
108
- // Session Management
109
- // =============================================
110
-
111
- /**
112
- * Initialize chat session
113
- */
114
- async initSession() {
115
- // Check for resumable session first
116
- await this.checkForResumableSession();
117
-
118
- // Try to get last active server session first
119
- const lastActiveSession = await API.getLastActiveSession();
120
- if (lastActiveSession && lastActiveSession.id && lastActiveSession.isAlive) {
121
- logger.info('Resuming last active session:', lastActiveSession.id);
122
- this.addSystemMessage('Resuming previous session...');
123
- localStorage.setItem('mama_chat_session_id', lastActiveSession.id);
124
- this.initWebSocket(lastActiveSession.id);
125
- return;
89
+ // =============================================
90
+ // Session Management
91
+ // =============================================
92
+ /**
93
+ * Initialize chat session
94
+ */
95
+ async initSession() {
96
+ // Check for resumable session first
97
+ await this.checkForResumableSession();
98
+ // Try to get last active server session first
99
+ const lastActiveSession = await API.getLastActiveSession().catch(() => null);
100
+ if (lastActiveSession && lastActiveSession.id && lastActiveSession.isAlive) {
101
+ logger.info('Resuming last active session:', lastActiveSession.id);
102
+ this.addSystemMessage('Resuming previous session...');
103
+ localStorage.setItem('mama_chat_session_id', lastActiveSession.id);
104
+ this.initWebSocket(lastActiveSession.id);
105
+ return;
106
+ }
107
+ const savedSessionId = localStorage.getItem('mama_chat_session_id');
108
+ if (savedSessionId) {
109
+ logger.info('Trying saved session:', savedSessionId);
110
+ this.addSystemMessage('Connecting to session...');
111
+ this.initWebSocket(savedSessionId);
112
+ }
113
+ else {
114
+ try {
115
+ this.addSystemMessage('Creating new session...');
116
+ const data = await API.createSession('.');
117
+ const sessionId = data.sessionId;
118
+ logger.info('Created new session:', sessionId);
119
+ localStorage.setItem('mama_chat_session_id', sessionId);
120
+ this.initWebSocket(sessionId);
121
+ }
122
+ catch (error) {
123
+ logger.error('Failed to create session:', error);
124
+ const message = getErrorMessage(error);
125
+ this.addSystemMessage(`Failed to create session: ${message}`, 'error');
126
+ }
127
+ }
126
128
  }
127
-
128
- const savedSessionId = localStorage.getItem('mama_chat_session_id');
129
-
130
- if (savedSessionId) {
131
- logger.info('Trying saved session:', savedSessionId);
132
- this.addSystemMessage('Connecting to session...');
133
- this.initWebSocket(savedSessionId);
134
- } else {
135
- try {
136
- this.addSystemMessage('Creating new session...');
137
- const data = await API.createSession('.');
138
- const sessionId = data.sessionId;
139
-
140
- logger.info('Created new session:', sessionId);
141
- localStorage.setItem('mama_chat_session_id', sessionId);
142
-
129
+ /**
130
+ * Connect to session (public method)
131
+ */
132
+ connectToSession(sessionId) {
143
133
  this.initWebSocket(sessionId);
144
- } catch (error) {
145
- logger.error('Failed to create session:', error);
146
- this.addSystemMessage(`Failed to create session: ${error.message}`, 'error');
147
- }
148
- }
149
- }
150
-
151
- /**
152
- * Connect to session (public method)
153
- */
154
- connectToSession(sessionId) {
155
- this.initWebSocket(sessionId);
156
- }
157
-
158
- /**
159
- * Disconnect from session (public method)
160
- */
161
- disconnect() {
162
- if (this.ws) {
163
- this.sessionId = null; // Prevent auto-reconnect
164
- this.ws.close();
165
- this.ws = null;
166
- }
167
- this.updateStatus('disconnected');
168
- this.enableInput(false);
169
- }
170
-
171
- // =============================================
172
- // WebSocket Management
173
- // =============================================
174
-
175
- /**
176
- * Initialize WebSocket connection
177
- */
178
- initWebSocket(sessionId) {
179
- if (this.ws && this.ws.readyState === WebSocket.OPEN) {
180
- logger.info('Already connected');
181
- return;
182
134
  }
183
-
184
- this.sessionId = sessionId;
185
- this.restoreHistory(sessionId);
186
-
187
- const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
188
- const wsUrl = `${protocol}//${window.location.host}/ws?sessionId=${sessionId}`;
189
-
190
- logger.info('Connecting to:', wsUrl);
191
- this.ws = new WebSocket(wsUrl);
192
-
193
- this.ws.onopen = () => {
194
- logger.info('Connected');
195
- this.reconnectAttempts = 0;
196
- this.updateStatus('connected');
197
- this.enableInput(true);
198
-
199
- this.ws.send(
200
- JSON.stringify({
201
- type: 'attach',
202
- sessionId: sessionId,
203
- osAgentMode: true, // Enable OS Agent capabilities (Viewer-only)
204
- language: navigator.language || 'en', // Browser language for greeting
205
- })
206
- );
207
- };
208
-
209
- this.ws.onmessage = (event) => {
210
- try {
211
- const data = JSON.parse(event.data);
212
- this.handleMessage(data);
213
- } catch (e) {
214
- logger.error('Parse error:', e);
215
- }
216
- };
217
-
218
- this.ws.onclose = (event) => {
219
- logger.info('Disconnected:', event.code, event.reason);
220
- this.updateStatus('disconnected');
221
- this.enableInput(false);
222
-
223
- if (this.sessionId) {
224
- this.scheduleReconnect();
225
- }
226
- };
227
-
228
- this.ws.onerror = (error) => {
229
- logger.error('WebSocket error:', error);
230
- this.updateStatus('disconnected');
231
- };
232
- }
233
-
234
- /**
235
- * Handle incoming WebSocket message
236
- */
237
- handleMessage(data) {
238
- switch (data.type) {
239
- case 'attached':
240
- logger.info('Attached to session:', data.sessionId);
241
- this.addSystemMessage('Connected to session');
242
- break;
243
-
244
- case 'history':
245
- // Display conversation history from server
246
- if (data.messages && data.messages.length > 0) {
247
- logger.info('Received history:', data.messages.length, 'messages');
248
- this.displayHistory(data.messages);
249
- }
250
- break;
251
-
252
- case 'output':
253
- case 'stream':
254
- if (data.content) {
255
- this.hideTypingIndicator();
256
- this.enableSend(true);
257
- this.appendStreamChunk(data.content);
258
- }
259
- break;
260
-
261
- case 'stream_end':
262
- this.hideTypingIndicator();
263
- this.finalizeStreamMessage();
264
- break;
265
-
266
- case 'error':
267
- if (data.error === 'session_not_found') {
268
- logger.info('Session not found, creating new one...');
269
- localStorage.removeItem('mama_chat_session_id');
270
- this.addSystemMessage('Session expired. Creating new session...');
271
-
272
- if (this.ws) {
135
+ /**
136
+ * Disconnect from session (public method)
137
+ */
138
+ disconnect() {
139
+ if (this.ws) {
140
+ this.sessionId = null; // Prevent auto-reconnect
273
141
  this.ws.close();
274
142
  this.ws = null;
275
- }
276
-
277
- setTimeout(() => this.initSession(), 500);
278
- } else {
279
- this.addSystemMessage(`Error: ${data.message || data.error}`, 'error');
280
- this.enableSend(true);
281
143
  }
282
- break;
283
-
284
- case 'tool_use':
285
- this.addToolCard(data.tool, data.toolId, data.input);
286
- break;
287
-
288
- case 'tool_complete':
289
- this.completeToolCard(data.index);
290
- break;
291
-
292
- case 'typing':
293
- this.showTypingIndicator(data.elapsed);
294
- break;
295
-
296
- case 'pong':
297
- break;
298
-
299
- case 'connected':
300
- logger.info('WebSocket connected:', data.clientId);
301
- break;
302
-
303
- default:
304
- logger.warn('Unknown message type:', data.type);
305
- }
306
- }
307
-
308
- /**
309
- * Schedule reconnection with exponential backoff
310
- */
311
- scheduleReconnect() {
312
- const delay = Math.min(1000 * Math.pow(2, this.reconnectAttempts), this.maxReconnectDelay);
313
- this.reconnectAttempts++;
314
-
315
- logger.info(`Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts})`);
316
- this.addSystemMessage(
317
- `Connection lost. Reconnecting in ${Math.round(delay / 1000)}s...`,
318
- 'warning'
319
- );
320
-
321
- setTimeout(() => {
322
- if (this.sessionId) {
323
- this.initWebSocket(this.sessionId);
324
- }
325
- }, delay);
326
- }
327
-
328
- // =============================================
329
- // Message Handling
330
- // =============================================
331
-
332
- /**
333
- * Send chat message
334
- */
335
- send() {
336
- const input = document.getElementById('chat-input');
337
- const message = input.value.trim();
338
-
339
- if (!message) {
340
- return;
144
+ this.updateStatus('disconnected');
145
+ this.enableInput(false);
341
146
  }
342
-
343
- // Handle slash commands
344
- if (message.startsWith('/')) {
345
- this.handleCommand(message);
346
- input.value = '';
347
- autoResizeTextarea(input);
348
- return;
147
+ // =============================================
148
+ // WebSocket Management
149
+ // =============================================
150
+ /**
151
+ * Initialize WebSocket connection
152
+ */
153
+ initWebSocket(sessionId) {
154
+ if (this.ws && this.ws.readyState === WebSocket.OPEN) {
155
+ logger.info('Already connected');
156
+ return;
157
+ }
158
+ this.sessionId = sessionId;
159
+ this.restoreHistory(sessionId);
160
+ const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
161
+ const wsUrl = `${protocol}//${window.location.host}/ws?sessionId=${sessionId}`;
162
+ logger.info('Connecting to:', wsUrl);
163
+ this.ws = new WebSocket(wsUrl);
164
+ this.ws.onopen = () => {
165
+ logger.info('Connected');
166
+ this.reconnectAttempts = 0;
167
+ this.updateStatus('connected');
168
+ this.enableInput(true);
169
+ this.ws.send(JSON.stringify({
170
+ type: 'attach',
171
+ sessionId: sessionId,
172
+ osAgentMode: true, // Enable OS Agent capabilities (Viewer-only)
173
+ language: navigator.language || 'en', // Browser language for greeting
174
+ }));
175
+ };
176
+ this.ws.onmessage = (event) => {
177
+ try {
178
+ const data = JSON.parse(event.data);
179
+ this.handleMessage(data);
180
+ }
181
+ catch (e) {
182
+ logger.error('Parse error:', e);
183
+ }
184
+ };
185
+ this.ws.onclose = (event) => {
186
+ logger.info('Disconnected:', event.code, event.reason);
187
+ this.updateStatus('disconnected');
188
+ this.enableInput(false);
189
+ if (this.sessionId) {
190
+ this.scheduleReconnect();
191
+ }
192
+ };
193
+ this.ws.onerror = (error) => {
194
+ logger.error('WebSocket error:', error);
195
+ this.updateStatus('disconnected');
196
+ };
349
197
  }
350
-
351
- if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
352
- this.addSystemMessage('Not connected. Please connect to a session first.', 'error');
353
- return;
198
+ /**
199
+ * Handle incoming WebSocket message
200
+ */
201
+ handleMessage(data) {
202
+ switch (data.type) {
203
+ case 'attached':
204
+ logger.info('Attached to session:', data.sessionId);
205
+ this.addSystemMessage('Connected to session');
206
+ break;
207
+ case 'history':
208
+ // Display conversation history from server
209
+ if (data.messages && data.messages.length > 0) {
210
+ logger.info('Received history:', data.messages.length, 'messages');
211
+ this.displayHistory(data.messages);
212
+ }
213
+ break;
214
+ case 'output':
215
+ case 'stream':
216
+ if (data.content) {
217
+ this.hideTypingIndicator();
218
+ this.enableSend(true);
219
+ this.appendStreamChunk(data.content);
220
+ }
221
+ break;
222
+ case 'stream_end':
223
+ this.hideTypingIndicator();
224
+ this.finalizeStreamMessage();
225
+ break;
226
+ case 'error':
227
+ if (data.error === 'session_not_found') {
228
+ logger.info('Session not found, creating new one...');
229
+ localStorage.removeItem('mama_chat_session_id');
230
+ this.addSystemMessage('Session expired. Creating new session...');
231
+ if (this.ws) {
232
+ this.ws.close();
233
+ this.ws = null;
234
+ }
235
+ setTimeout(() => this.initSession(), 500);
236
+ }
237
+ else {
238
+ this.addSystemMessage(`Error: ${data.message || data.error}`, 'error');
239
+ this.enableSend(true);
240
+ }
241
+ break;
242
+ case 'tool_use':
243
+ this.addToolCard(data.tool || 'tool', data.toolId || '', data.input && typeof data.input === 'object' ? data.input : null);
244
+ break;
245
+ case 'tool_complete':
246
+ if (typeof data.index === 'number') {
247
+ this.completeToolCard(data.index);
248
+ }
249
+ break;
250
+ case 'typing':
251
+ this.showTypingIndicator(data.elapsed);
252
+ break;
253
+ case 'pong':
254
+ break;
255
+ case 'connected':
256
+ logger.info('WebSocket connected:', data.clientId);
257
+ break;
258
+ default:
259
+ logger.warn('Unknown message type:', data.type);
260
+ }
354
261
  }
355
-
356
- this.addUserMessage(message);
357
- this.enableSend(false);
358
-
359
- this.ws.send(
360
- JSON.stringify({
361
- type: 'send',
362
- sessionId: this.sessionId,
363
- content: message,
364
- })
365
- );
366
-
367
- // Search for related MAMA decisions
368
- if (this.memoryModule) {
369
- this.memoryModule.showRelatedForMessage(message);
262
+ /**
263
+ * Schedule reconnection with exponential backoff
264
+ */
265
+ scheduleReconnect() {
266
+ const delay = Math.min(1000 * Math.pow(2, this.reconnectAttempts), this.maxReconnectDelay);
267
+ this.reconnectAttempts++;
268
+ logger.info(`Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts})`);
269
+ this.addSystemMessage(`Connection lost. Reconnecting in ${Math.round(delay / 1000)}s...`, 'warning');
270
+ setTimeout(() => {
271
+ if (this.sessionId) {
272
+ this.initWebSocket(this.sessionId);
273
+ }
274
+ }, delay);
370
275
  }
371
-
372
- input.value = '';
373
- autoResizeTextarea(input);
374
-
375
- logger.info('Sent:', message);
376
- this.resetIdleTimer();
377
- }
378
-
379
- /**
380
- * Send quiz choice (A, B, C, D)
381
- * Called from quiz-choice-btn onclick
382
- */
383
- sendQuizChoice(choice) {
384
- if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
385
- this.addSystemMessage('Not connected.', 'error');
386
- return;
276
+ // =============================================
277
+ // Message Handling
278
+ // =============================================
279
+ /**
280
+ * Send chat message
281
+ */
282
+ send() {
283
+ const input = getElementByIdOrNull('chat-input');
284
+ if (!input) {
285
+ return;
286
+ }
287
+ const message = input.value.trim();
288
+ if (!message) {
289
+ return;
290
+ }
291
+ // Handle slash commands
292
+ if (message.startsWith('/')) {
293
+ this.handleCommand(message);
294
+ input.value = '';
295
+ autoResizeTextarea(input);
296
+ return;
297
+ }
298
+ if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
299
+ this.addSystemMessage('Not connected. Please connect to a session first.', 'error');
300
+ return;
301
+ }
302
+ this.addUserMessage(message);
303
+ this.enableSend(false);
304
+ this.ws.send(JSON.stringify({
305
+ type: 'send',
306
+ sessionId: this.sessionId,
307
+ content: message,
308
+ }));
309
+ // Search for related MAMA decisions
310
+ if (this.memoryModule) {
311
+ this.memoryModule.showRelatedForMessage(message);
312
+ }
313
+ input.value = '';
314
+ autoResizeTextarea(input);
315
+ logger.info('Sent:', message);
316
+ this.resetIdleTimer();
387
317
  }
388
-
389
- // Display choice as user message
390
- this.addUserMessage(choice);
391
- this.enableSend(false);
392
-
393
- // Send to server
394
- this.ws.send(
395
- JSON.stringify({
396
- type: 'send',
397
- sessionId: this.sessionId,
398
- content: choice,
399
- })
400
- );
401
-
402
- logger.info('Quiz choice sent:', choice);
403
- this.resetIdleTimer();
404
- }
405
-
406
- /**
407
- * Handle slash commands
408
- */
409
- handleCommand(message) {
410
- const parts = message.slice(1).split(' ');
411
- const command = parts[0].toLowerCase();
412
- const args = parts.slice(1).join(' ');
413
-
414
- logger.info('Command:', command, 'Args:', args);
415
-
416
- switch (command) {
417
- case 'save':
418
- this.commandSave(args);
419
- break;
420
- case 'search':
421
- this.commandSearch(args);
422
- break;
423
- case 'checkpoint':
424
- this.commandCheckpoint();
425
- break;
426
- case 'resume':
427
- this.commandResume();
428
- break;
429
- case 'help':
430
- this.commandHelp();
431
- break;
432
- default:
433
- // Forward unrecognized commands to agent as regular messages
434
- this.sendRaw(message);
435
- }
436
- }
437
-
438
- /**
439
- * Send a message directly to the agent (bypass command parsing)
440
- * Rewrites /command to avoid Claude CLI slash command interception
441
- */
442
- sendRaw(message) {
443
- if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
444
- this.addSystemMessage('Not connected. Please connect to a session first.', 'error');
445
- return;
318
+ /**
319
+ * Send quiz choice (A, B, C, D)
320
+ */
321
+ sendQuizChoice(choice) {
322
+ if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
323
+ this.addSystemMessage('Not connected.', 'error');
324
+ return;
325
+ }
326
+ // Display choice as user message
327
+ this.addUserMessage(choice);
328
+ this.enableSend(false);
329
+ // Send to server
330
+ this.ws.send(JSON.stringify({
331
+ type: 'send',
332
+ sessionId: this.sessionId,
333
+ content: choice,
334
+ }));
335
+ logger.info('Quiz choice sent:', choice);
336
+ this.resetIdleTimer();
446
337
  }
447
-
448
- this.addUserMessage(message);
449
- this.enableSend(false);
450
-
451
- // Rewrite /command natural language to avoid Claude CLI interception
452
- // Must be explicit enough to override built-in skills (BMAD, etc.)
453
- let agentMessage = message;
454
- if (message.startsWith('/')) {
455
- const parts = message.slice(1).split(' ');
456
- const cmd = parts[0];
457
- const args = parts.slice(1).join(' ');
458
- agentMessage = [
459
- `[INSTALLED PLUGIN COMMAND — DO NOT USE SKILL TOOL]`,
460
- `Look in your system prompt under "Installed Skills (PRIORITY)" for the "commands/${cmd}.md" section.`,
461
- `Execute ONLY the instructions from that installed plugin command file.`,
462
- `DO NOT invoke the Skill tool. DO NOT match to bmad or any other built-in skill.`,
463
- `This command comes from a user-installed Cowork/OpenClaw plugin, not a system skill.`,
464
- args ? `User arguments: <user_args>${args}</user_args>` : '',
465
- ]
466
- .filter(Boolean)
467
- .join(' ');
338
+ /**
339
+ * Handle slash commands
340
+ */
341
+ handleCommand(message) {
342
+ const parts = message.slice(1).split(' ');
343
+ const command = parts[0].toLowerCase();
344
+ const args = parts.slice(1).join(' ');
345
+ logger.info('Command:', command, 'Args:', args);
346
+ switch (command) {
347
+ case 'save':
348
+ this.commandSave(args);
349
+ break;
350
+ case 'search':
351
+ this.commandSearch(args);
352
+ break;
353
+ case 'checkpoint':
354
+ this.commandCheckpoint();
355
+ break;
356
+ case 'resume':
357
+ this.commandResume();
358
+ break;
359
+ case 'help':
360
+ this.commandHelp();
361
+ break;
362
+ default:
363
+ // Forward unrecognized commands to agent as regular messages
364
+ this.sendRaw(message);
365
+ }
468
366
  }
469
-
470
- this.ws.send(
471
- JSON.stringify({
472
- type: 'send',
473
- sessionId: this.sessionId,
474
- content: agentMessage,
475
- })
476
- );
477
-
478
- if (this.memoryModule) {
479
- this.memoryModule.showRelatedForMessage(message);
367
+ /**
368
+ * Send a message directly to the agent (bypass command parsing)
369
+ * Rewrites /command to avoid Claude CLI slash command interception
370
+ */
371
+ sendRaw(message) {
372
+ if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
373
+ this.addSystemMessage('Not connected. Please connect to a session first.', 'error');
374
+ return;
375
+ }
376
+ this.addUserMessage(message);
377
+ this.enableSend(false);
378
+ // Rewrite /command → natural language to avoid Claude CLI interception
379
+ // Must be explicit enough to override built-in skills (BMAD, etc.)
380
+ let agentMessage = message;
381
+ if (message.startsWith('/')) {
382
+ const parts = message.slice(1).split(' ');
383
+ const cmd = parts[0];
384
+ const args = parts.slice(1).join(' ');
385
+ agentMessage = [
386
+ `[INSTALLED PLUGIN COMMAND — DO NOT USE SKILL TOOL]`,
387
+ `Look in your system prompt under "Installed Skills (PRIORITY)" for the "commands/${cmd}.md" section.`,
388
+ `Execute ONLY the instructions from that installed plugin command file.`,
389
+ `DO NOT invoke the Skill tool. DO NOT match to bmad or any other built-in skill.`,
390
+ `This command comes from a user-installed Cowork/OpenClaw plugin, not a system skill.`,
391
+ args ? `User arguments: <user_args>${args}</user_args>` : '',
392
+ ]
393
+ .filter(Boolean)
394
+ .join(' ');
395
+ }
396
+ this.ws.send(JSON.stringify({
397
+ type: 'send',
398
+ sessionId: this.sessionId,
399
+ content: agentMessage,
400
+ }));
401
+ if (this.memoryModule) {
402
+ this.memoryModule.showRelatedForMessage(message);
403
+ }
404
+ logger.info('Forwarded to agent:', agentMessage);
405
+ this.resetIdleTimer();
480
406
  }
481
-
482
- logger.info('Forwarded to agent:', agentMessage);
483
- this.resetIdleTimer();
484
- }
485
-
486
- /**
487
- * /save <text> - Open Memory form with text
488
- */
489
- commandSave(text) {
490
- if (!this.memoryModule) {
491
- this.addSystemMessage('Memory module not available', 'error');
492
- return;
407
+ /**
408
+ * /save <text> - Open Memory form with text
409
+ */
410
+ commandSave(text) {
411
+ if (!this.memoryModule) {
412
+ this.addSystemMessage('Memory module not available', 'error');
413
+ return;
414
+ }
415
+ if (!text) {
416
+ this.addSystemMessage('Usage: /save <decision text>', 'error');
417
+ return;
418
+ }
419
+ // Switch to Memory tab and open form with text
420
+ window.switchTab('memory');
421
+ this.memoryModule.showSaveFormWithText(text);
422
+ this.addSystemMessage(`💾 Opening save form with: "${text.substring(0, 50)}..."`);
493
423
  }
494
-
495
- if (!text) {
496
- this.addSystemMessage('Usage: /save <decision text>', 'error');
497
- return;
424
+ /**
425
+ * /search <query> - Search in Memory tab
426
+ */
427
+ commandSearch(query) {
428
+ if (!this.memoryModule) {
429
+ this.addSystemMessage('Memory module not available', 'error');
430
+ return;
431
+ }
432
+ if (!query) {
433
+ this.addSystemMessage('Usage: /search <query>', 'error');
434
+ return;
435
+ }
436
+ // Switch to Memory tab and execute search
437
+ window.switchTab('memory');
438
+ this.memoryModule.searchWithQuery(query);
439
+ this.addSystemMessage(`🔍 Searching for: "${query}"`);
498
440
  }
499
-
500
- // Switch to Memory tab and open form with text
501
- window.switchTab('memory');
502
- this.memoryModule.showSaveFormWithText(text);
503
- this.addSystemMessage(`💾 Opening save form with: "${text.substring(0, 50)}..."`);
504
- }
505
-
506
- /**
507
- * /search <query> - Search in Memory tab
508
- */
509
- commandSearch(query) {
510
- if (!this.memoryModule) {
511
- this.addSystemMessage('Memory module not available', 'error');
512
- return;
441
+ /**
442
+ * /checkpoint - Save current session as checkpoint
443
+ */
444
+ async commandCheckpoint() {
445
+ try {
446
+ const summary = this.generateCheckpointSummary();
447
+ await this.saveCheckpoint(summary);
448
+ this.addSystemMessage('✅ Checkpoint saved successfully');
449
+ }
450
+ catch (error) {
451
+ logger.error('Checkpoint save failed:', error);
452
+ const message = getErrorMessage(error);
453
+ this.addSystemMessage(`Failed to save checkpoint: ${message}`, 'error');
454
+ }
513
455
  }
514
-
515
- if (!query) {
516
- this.addSystemMessage('Usage: /search <query>', 'error');
517
- return;
456
+ /**
457
+ * /resume - Load last checkpoint
458
+ */
459
+ async commandResume() {
460
+ try {
461
+ const checkpoint = await this.loadCheckpoint();
462
+ if (checkpoint) {
463
+ this.addSystemMessage(`📖 Last checkpoint (${new Date(checkpoint.timestamp).toLocaleString()}):`);
464
+ this.addSystemMessage(checkpoint.summary);
465
+ }
466
+ else {
467
+ this.addSystemMessage('No checkpoint found', 'error');
468
+ }
469
+ }
470
+ catch (error) {
471
+ logger.error('Checkpoint load failed:', error);
472
+ const message = getErrorMessage(error);
473
+ this.addSystemMessage(`Failed to load checkpoint: ${message}`, 'error');
474
+ }
518
475
  }
519
-
520
- // Switch to Memory tab and execute search
521
- window.switchTab('memory');
522
- this.memoryModule.searchWithQuery(query);
523
- this.addSystemMessage(`🔍 Searching for: "${query}"`);
524
- }
525
-
526
- /**
527
- * /checkpoint - Save current session as checkpoint
528
- */
529
- async commandCheckpoint() {
530
- try {
531
- const summary = this.generateCheckpointSummary();
532
- await this.saveCheckpoint(summary);
533
- this.addSystemMessage('✅ Checkpoint saved successfully');
534
- } catch (error) {
535
- logger.error('Checkpoint save failed:', error);
536
- this.addSystemMessage(`Failed to save checkpoint: ${error.message}`, 'error');
537
- }
538
- }
539
-
540
- /**
541
- * /resume - Load last checkpoint
542
- */
543
- async commandResume() {
544
- try {
545
- const checkpoint = await this.loadCheckpoint();
546
- if (checkpoint) {
547
- this.addSystemMessage(
548
- `📖 Last checkpoint (${new Date(checkpoint.timestamp).toLocaleString()}):`
549
- );
550
- this.addSystemMessage(checkpoint.summary);
551
- } else {
552
- this.addSystemMessage('No checkpoint found', 'error');
553
- }
554
- } catch (error) {
555
- logger.error('Checkpoint load failed:', error);
556
- this.addSystemMessage(`Failed to load checkpoint: ${error.message}`, 'error');
557
- }
558
- }
559
-
560
- /**
561
- * /help - Show available commands
562
- */
563
- commandHelp() {
564
- const helpText = `
476
+ /**
477
+ * /help - Show available commands
478
+ */
479
+ commandHelp() {
480
+ const helpText = `
565
481
  **Available Commands:**
566
482
 
567
483
  **/save <text>** - Save a decision to Memory
@@ -575,1541 +491,1409 @@ export class ChatModule {
575
491
  - **Shift+Enter** - New line
576
492
  - **Long press message** - Copy to clipboard
577
493
  `.trim();
578
-
579
- this.addSystemMessage(helpText);
580
- }
581
-
582
- /**
583
- * Add user message to chat
584
- */
585
- addUserMessage(text) {
586
- const container = document.getElementById('chat-messages');
587
- this.removePlaceholder();
588
-
589
- const timestamp = new Date();
590
- const msgEl = document.createElement('div');
591
- msgEl.className = 'chat-message user';
592
- msgEl.innerHTML = `
494
+ this.addSystemMessage(helpText);
495
+ }
496
+ /**
497
+ * Add user message to chat
498
+ */
499
+ addUserMessage(text) {
500
+ const container = getElementByIdOrNull('chat-messages');
501
+ if (!container) {
502
+ return;
503
+ }
504
+ this.removePlaceholder();
505
+ const timestamp = new Date();
506
+ const msgEl = document.createElement('div');
507
+ msgEl.className = 'chat-message user';
508
+ msgEl.innerHTML = `
593
509
  <div class="message-content">${escapeHtml(text)}</div>
594
510
  <div class="message-time">${formatMessageTime(timestamp)}</div>
595
511
  `;
596
-
597
- container.appendChild(msgEl);
598
- scrollToBottom(container);
599
-
600
- this.saveToHistory('user', text, timestamp);
601
- }
602
-
603
- addUserMessageWithAttachment(text, attachment) {
604
- const container = document.getElementById('chat-messages');
605
- this.removePlaceholder();
606
-
607
- const timestamp = new Date();
608
- const msgEl = document.createElement('div');
609
- msgEl.className = 'chat-message user';
610
-
611
- let attachHtml = '';
612
- if (attachment.isImage) {
613
- const safeUrl = escapeAttr(attachment.mediaUrl);
614
- const safeAlt = escapeAttr(attachment.originalName);
615
- attachHtml = `<img src="${safeUrl}" class="max-w-[200px] rounded-lg mt-1 cursor-pointer" alt="${safeAlt}" data-lightbox="${safeUrl}" />`;
616
- } else {
617
- const safeName = encodeURIComponent(attachment.filename);
618
- attachHtml = `<a href="/api/media/download/${safeName}" target="_blank" class="flex items-center gap-2 mt-1 px-3 py-2 bg-white/50 rounded-lg border border-gray-200 text-sm hover:bg-white/80 transition-colors"><span class="text-lg">\u{1F4CE}</span><span class="truncate max-w-[180px]">${escapeHtml(attachment.originalName)}</span></a>`;
512
+ container.appendChild(msgEl);
513
+ scrollToBottom(container);
514
+ this.saveToHistory('user', text, timestamp);
619
515
  }
620
-
621
- msgEl.innerHTML = `
516
+ addUserMessageWithAttachment(text, attachment) {
517
+ const container = getElementByIdOrNull('chat-messages');
518
+ if (!container) {
519
+ return;
520
+ }
521
+ this.removePlaceholder();
522
+ const timestamp = new Date();
523
+ const msgEl = document.createElement('div');
524
+ msgEl.className = 'chat-message user';
525
+ let attachHtml = '';
526
+ if (attachment.isImage) {
527
+ const safeUrl = escapeAttr(attachment.mediaUrl);
528
+ const safeAlt = escapeAttr(attachment.originalName);
529
+ attachHtml = `<img src="${safeUrl}" class="max-w-[200px] rounded-lg mt-1 cursor-pointer" alt="${safeAlt}" data-lightbox="${safeUrl}" />`;
530
+ }
531
+ else {
532
+ const safeName = encodeURIComponent(attachment.filename);
533
+ attachHtml = `<a href="/api/media/download/${safeName}" target="_blank" class="flex items-center gap-2 mt-1 px-3 py-2 bg-white/50 rounded-lg border border-gray-200 text-sm hover:bg-white/80 transition-colors"><span class="text-lg">\u{1F4CE}</span><span class="truncate max-w-[180px]">${escapeHtml(attachment.originalName)}</span></a>`;
534
+ }
535
+ msgEl.innerHTML = `
622
536
  <div class="message-content">${escapeHtml(text)}${attachHtml}</div>
623
537
  <div class="message-time">${formatMessageTime(timestamp)}</div>
624
538
  `;
625
-
626
- container.appendChild(msgEl);
627
- scrollToBottom(container);
628
-
629
- this.saveToHistory('user', text, timestamp, attachment);
630
- }
631
-
632
- /**
633
- * Add assistant message to chat
634
- */
635
- addAssistantMessage(text) {
636
- const container = document.getElementById('chat-messages');
637
- this.removePlaceholder();
638
-
639
- this.enableSend(true);
640
-
641
- const timestamp = new Date();
642
- const msgEl = document.createElement('div');
643
- msgEl.className = 'chat-message assistant';
644
- msgEl.innerHTML = `
539
+ container.appendChild(msgEl);
540
+ scrollToBottom(container);
541
+ this.saveToHistory('user', text, timestamp, attachment);
542
+ }
543
+ /**
544
+ * Add assistant message to chat
545
+ */
546
+ addAssistantMessage(text) {
547
+ const container = getElementByIdOrNull('chat-messages');
548
+ if (!container) {
549
+ return;
550
+ }
551
+ this.removePlaceholder();
552
+ this.enableSend(true);
553
+ const timestamp = new Date();
554
+ const msgEl = document.createElement('div');
555
+ msgEl.className = 'chat-message assistant';
556
+ msgEl.innerHTML = `
645
557
  <div class="message-content">${formatAssistantMessage(text)}</div>
646
558
  <div class="message-time">${formatMessageTime(timestamp)}</div>
647
559
  `;
648
-
649
- container.appendChild(msgEl);
650
- scrollToBottom(container);
651
-
652
- this.saveToHistory('assistant', text, timestamp);
653
-
654
- // Show unread badge if floating panel is closed
655
- this.showUnreadBadge();
656
-
657
- // Auto-play TTS if enabled
658
- if (this.ttsEnabled && text) {
659
- logger.info('Auto-play enabled, speaking assistant message');
660
- this.speak(text);
560
+ container.appendChild(msgEl);
561
+ scrollToBottom(container);
562
+ this.saveToHistory('assistant', text, timestamp);
563
+ // Show unread badge if floating panel is closed
564
+ this.showUnreadBadge();
565
+ // Auto-play TTS if enabled
566
+ if (this.ttsEnabled && text) {
567
+ logger.info('Auto-play enabled, speaking assistant message');
568
+ this.speak(text);
569
+ }
661
570
  }
662
- }
663
-
664
- /**
665
- * Add system message to chat
666
- */
667
- addSystemMessage(text, type = 'info') {
668
- const container = document.getElementById('chat-messages');
669
- this.removePlaceholder();
670
-
671
- const msgEl = document.createElement('div');
672
- msgEl.className = `chat-message system ${type}`;
673
- msgEl.innerHTML = `
571
+ /**
572
+ * Add system message to chat
573
+ */
574
+ addSystemMessage(text, type = 'info') {
575
+ const container = getElementByIdOrNull('chat-messages');
576
+ if (!container) {
577
+ return;
578
+ }
579
+ this.removePlaceholder();
580
+ const msgEl = document.createElement('div');
581
+ msgEl.className = `chat-message system ${type}`;
582
+ msgEl.innerHTML = `
674
583
  <div class="message-content">${escapeHtml(text)}</div>
675
584
  `;
676
-
677
- container.appendChild(msgEl);
678
- scrollToBottom(container);
679
- }
680
-
681
- /**
682
- * Add tool usage card
683
- */
684
- addToolCard(toolName, toolId, input) {
685
- const container = document.getElementById('chat-messages');
686
- this.removePlaceholder();
687
-
688
- // Tool icon mapping
689
- const iconMap = {
690
- Read: '📄',
691
- Write: '✏️',
692
- Bash: '💻',
693
- Edit: '🔧',
694
- Grep: '🔍',
695
- Glob: '📂',
696
- Task: '🤖',
697
- WebFetch: '🌐',
698
- WebSearch: '🔎',
699
- };
700
- const icon = iconMap[toolName] || '🔧';
701
-
702
- // Extract file path for Read tool
703
- let detail = '';
704
- if (toolName === 'Read' && input && input.file_path) {
705
- const fileName = input.file_path.split('/').pop();
706
- detail = `<div class="tool-detail">${escapeHtml(fileName)}</div>`;
707
- } else if (toolName === 'Bash' && input && input.command) {
708
- detail = `<div class="tool-detail">${escapeHtml(input.command.substring(0, 50))}${input.command.length > 50 ? '...' : ''}</div>`;
585
+ container.appendChild(msgEl);
586
+ scrollToBottom(container);
709
587
  }
710
-
711
- const cardEl = document.createElement('div');
712
- cardEl.className = 'tool-card loading';
713
- cardEl.dataset.toolId = toolId;
714
- cardEl.dataset.collapsed = 'true';
715
- cardEl.innerHTML = `
716
- <div class="tool-header" onclick="window.chatModule.toggleToolCard('${toolId}')">
588
+ /**
589
+ * Add tool usage card
590
+ */
591
+ addToolCard(toolName, toolId, input) {
592
+ const container = getElementByIdOrNull('chat-messages');
593
+ if (!container) {
594
+ return;
595
+ }
596
+ this.removePlaceholder();
597
+ // Tool icon mapping
598
+ const iconMap = {
599
+ Read: '📄',
600
+ Write: '✏️',
601
+ Bash: '💻',
602
+ Edit: '🔧',
603
+ Grep: '🔍',
604
+ Glob: '📂',
605
+ Task: '🤖',
606
+ WebFetch: '🌐',
607
+ WebSearch: '🔎',
608
+ };
609
+ const icon = iconMap[toolName] || '🔧';
610
+ // Extract file path for Read tool
611
+ let detail = '';
612
+ if (toolName === 'Read' && input?.file_path) {
613
+ const fileName = input.file_path.split('/').pop();
614
+ detail = `<div class="tool-detail">${escapeHtml(fileName)}</div>`;
615
+ }
616
+ else if (toolName === 'Bash' && input?.command) {
617
+ const command = String(input.command);
618
+ detail = `<div class="tool-detail">${escapeHtml(command.substring(0, 50))}${command.length > 50 ? '...' : ''}</div>`;
619
+ }
620
+ const cardEl = document.createElement('div');
621
+ cardEl.className = 'tool-card loading';
622
+ cardEl.dataset.collapsed = 'true';
623
+ cardEl.dataset.toolId = toolId;
624
+ cardEl.innerHTML = `
625
+ <div class="tool-header" data-tool-toggle="true">
717
626
  <span class="tool-icon">${icon}</span>
718
627
  <span class="tool-name">${escapeHtml(toolName)}</span>
719
628
  <span class="tool-spinner">⏳</span>
720
629
  </div>
721
630
  ${detail}
722
631
  `;
723
-
724
- container.appendChild(cardEl);
725
- scrollToBottom(container);
726
- }
727
-
728
- /**
729
- * Complete tool card (mark as finished)
730
- */
731
- completeToolCard(_index) {
732
- // Find the most recent loading tool card
733
- const loadingCards = document.querySelectorAll('.tool-card.loading');
734
- if (loadingCards.length > 0) {
735
- const lastCard = loadingCards[loadingCards.length - 1];
736
- lastCard.classList.remove('loading');
737
- lastCard.classList.add('completed');
738
-
739
- // Replace spinner with checkmark
740
- const spinner = lastCard.querySelector('.tool-spinner');
741
- if (spinner) {
742
- spinner.textContent = '✓';
743
- spinner.classList.add('checkmark');
744
- }
745
- }
746
- }
747
-
748
- /**
749
- * Toggle tool card collapsed/expanded state
750
- */
751
- toggleToolCard(toolId) {
752
- const card = document.querySelector(`.tool-card[data-tool-id="${toolId}"]`);
753
- if (card) {
754
- const isCollapsed = card.dataset.collapsed === 'true';
755
- card.dataset.collapsed = isCollapsed ? 'false' : 'true';
756
- // Future: expand to show detailed results
757
- }
758
- }
759
-
760
- /**
761
- * Remove placeholder
762
- */
763
- removePlaceholder() {
764
- const placeholder = document.querySelector('.chat-placeholder');
765
- if (placeholder) {
766
- placeholder.remove();
767
- }
768
- }
769
-
770
- // =============================================
771
- // Streaming Message Handling
772
- // =============================================
773
-
774
- /**
775
- * Append streaming chunk with RAF batching
776
- */
777
- appendStreamChunk(content) {
778
- const container = document.getElementById('chat-messages');
779
-
780
- if (!this.currentStreamEl) {
781
- this.removePlaceholder();
782
- this.currentStreamEl = document.createElement('div');
783
- this.currentStreamEl.className = 'chat-message assistant streaming';
784
- this.currentStreamEl.innerHTML = `
785
- <div class="message-content"></div>
786
- <div class="message-time">${formatMessageTime(new Date())}</div>
787
- `;
788
- container.appendChild(this.currentStreamEl);
789
- this.currentStreamText = '';
790
- this.streamBuffer = '';
632
+ // Bind click handler safely (avoid inline onclick with string interpolation)
633
+ const header = cardEl.querySelector('.tool-header');
634
+ if (header) {
635
+ header.addEventListener('click', () => this.toggleToolCard(toolId));
636
+ }
637
+ container.appendChild(cardEl);
638
+ scrollToBottom(container);
791
639
  }
792
-
793
- this.streamBuffer += content;
794
-
795
- if (!this.rafPending) {
796
- this.rafPending = true;
797
- requestAnimationFrame(() => {
798
- if (this.streamBuffer) {
799
- this.currentStreamText += this.streamBuffer;
800
- this.streamBuffer = '';
801
-
802
- const contentEl = this.currentStreamEl.querySelector('.message-content');
803
- contentEl.innerHTML = formatAssistantMessage(this.currentStreamText);
804
-
805
- container.scrollTo({
806
- top: container.scrollHeight,
807
- behavior: 'auto',
808
- });
640
+ /**
641
+ * Complete tool card (mark as finished)
642
+ */
643
+ completeToolCard(_index) {
644
+ // Find the most recent loading tool card
645
+ const loadingCards = document.querySelectorAll('.tool-card.loading');
646
+ if (loadingCards.length > 0) {
647
+ const lastCard = loadingCards[loadingCards.length - 1];
648
+ lastCard.classList.remove('loading');
649
+ lastCard.classList.add('completed');
650
+ // Replace spinner with checkmark
651
+ const spinner = lastCard.querySelector('.tool-spinner');
652
+ if (spinner) {
653
+ spinner.textContent = '✓';
654
+ spinner.classList.add('checkmark');
655
+ }
809
656
  }
810
- this.rafPending = false;
811
- });
812
657
  }
813
- }
814
-
815
- /**
816
- * Finalize streaming message
817
- */
818
- finalizeStreamMessage() {
819
- if (this.streamBuffer && this.currentStreamEl) {
820
- this.currentStreamText += this.streamBuffer;
821
- const contentEl = this.currentStreamEl.querySelector('.message-content');
822
- contentEl.innerHTML = formatAssistantMessage(this.currentStreamText);
658
+ /**
659
+ * Toggle tool card collapsed/expanded state
660
+ */
661
+ toggleToolCard(toolId) {
662
+ const card = document.querySelector(`.tool-card[data-tool-id="${CSS.escape(toolId)}"]`);
663
+ if (!card) {
664
+ return;
665
+ }
666
+ const toolCard = card;
667
+ const isCollapsed = toolCard.dataset.collapsed === 'true';
668
+ toolCard.dataset.collapsed = isCollapsed ? 'false' : 'true';
823
669
  }
824
-
825
- if (this.currentStreamText) {
826
- this.saveToHistory('assistant', this.currentStreamText);
827
-
828
- // Auto-play TTS for streamed responses
829
- if (this.ttsEnabled) {
830
- this.speak(this.currentStreamText);
831
- }
670
+ /**
671
+ * Remove placeholder
672
+ */
673
+ removePlaceholder() {
674
+ const placeholder = document.querySelector('.chat-placeholder');
675
+ if (placeholder) {
676
+ placeholder.remove();
677
+ }
832
678
  }
833
-
834
- // Show unread badge if floating panel is closed
835
- this.showUnreadBadge();
836
-
837
- if (this.currentStreamEl) {
838
- this.currentStreamEl.classList.remove('streaming');
839
- this.currentStreamEl = null;
840
- this.currentStreamText = '';
841
- this.streamBuffer = '';
842
- }
843
- this.rafPending = false;
844
- this.enableSend(true);
845
- }
846
-
847
- /**
848
- * Show typing indicator while agent is processing
849
- */
850
- showTypingIndicator(elapsed) {
851
- const container = document.getElementById('chat-messages');
852
- let indicator = container.querySelector('.chat-typing-indicator');
853
- if (!indicator) {
854
- indicator = document.createElement('div');
855
- indicator.className = 'chat-typing-indicator';
856
- indicator.innerHTML = `
857
- <div class="typing-dots">
858
- <span></span><span></span><span></span>
859
- </div>
860
- <span class="typing-label">thinking...</span>`;
861
- container.appendChild(indicator);
862
- scrollToBottom(container);
679
+ // =============================================
680
+ // Streaming Message Handling
681
+ // =============================================
682
+ /**
683
+ * Append streaming chunk with RAF batching
684
+ */
685
+ appendStreamChunk(content) {
686
+ const container = getElementByIdOrNull('chat-messages');
687
+ if (!container) {
688
+ return;
689
+ }
690
+ if (!this.currentStreamEl) {
691
+ this.removePlaceholder();
692
+ this.currentStreamEl = document.createElement('div');
693
+ this.currentStreamEl.className = 'chat-message assistant streaming';
694
+ this.currentStreamEl.innerHTML = `
695
+ <div class="message-content"></div>
696
+ <div class="message-time">${formatMessageTime(new Date())}</div>
697
+ `;
698
+ container.appendChild(this.currentStreamEl);
699
+ this.currentStreamText = '';
700
+ this.streamBuffer = '';
701
+ }
702
+ this.streamBuffer += content;
703
+ if (!this.rafPending) {
704
+ this.rafPending = true;
705
+ requestAnimationFrame(() => {
706
+ if (this.streamBuffer) {
707
+ this.currentStreamText += this.streamBuffer;
708
+ this.streamBuffer = '';
709
+ const contentEl = this.currentStreamEl.querySelector('.message-content');
710
+ if (contentEl) {
711
+ contentEl.innerHTML = formatAssistantMessage(this.currentStreamText);
712
+ }
713
+ container.scrollTo({
714
+ top: container.scrollHeight,
715
+ behavior: 'auto',
716
+ });
717
+ }
718
+ this.rafPending = false;
719
+ });
720
+ }
863
721
  }
864
- if (elapsed) {
865
- const label = indicator.querySelector('.typing-label');
866
- label.textContent = `thinking... (${elapsed}s)`;
722
+ /**
723
+ * Finalize streaming message
724
+ */
725
+ finalizeStreamMessage() {
726
+ if (this.streamBuffer && this.currentStreamEl) {
727
+ this.currentStreamText += this.streamBuffer;
728
+ const contentEl = this.currentStreamEl.querySelector('.message-content');
729
+ contentEl.innerHTML = formatAssistantMessage(this.currentStreamText);
730
+ }
731
+ if (this.currentStreamText) {
732
+ this.saveToHistory('assistant', this.currentStreamText);
733
+ // Auto-play TTS for streamed responses
734
+ if (this.ttsEnabled) {
735
+ this.speak(this.currentStreamText);
736
+ }
737
+ }
738
+ // Show unread badge if floating panel is closed
739
+ this.showUnreadBadge();
740
+ if (this.currentStreamEl) {
741
+ this.currentStreamEl.classList.remove('streaming');
742
+ this.currentStreamEl = null;
743
+ this.currentStreamText = '';
744
+ this.streamBuffer = '';
745
+ }
746
+ this.rafPending = false;
747
+ this.enableSend(true);
867
748
  }
868
- }
869
-
870
- /**
871
- * Hide typing indicator
872
- */
873
- hideTypingIndicator() {
874
- const container = document.getElementById('chat-messages');
875
- const indicator = container?.querySelector('.chat-typing-indicator');
876
- if (indicator) {
877
- indicator.remove();
878
- }
879
- }
880
-
881
- // =============================================
882
- // UI Control
883
- // =============================================
884
-
885
- /**
886
- * Update chat status
887
- */
888
- updateStatus(status) {
889
- const statusEl = document.getElementById('chat-status');
890
- if (!statusEl) {
891
- logger.warn('Status element not found');
892
- return;
749
+ /**
750
+ * Show typing indicator while agent is processing
751
+ */
752
+ showTypingIndicator(elapsed) {
753
+ const container = getElementByIdOrNull('chat-messages');
754
+ if (!container) {
755
+ return;
756
+ }
757
+ let indicator = container.querySelector('.chat-typing-indicator');
758
+ if (!indicator) {
759
+ indicator = document.createElement('div');
760
+ indicator.className = 'chat-typing-indicator';
761
+ indicator.innerHTML = `
762
+ <div class="typing-dots">
763
+ <span></span><span></span><span></span>
764
+ </div>
765
+ <span class="typing-label">thinking...</span>`;
766
+ container.appendChild(indicator);
767
+ scrollToBottom(container);
768
+ }
769
+ if (elapsed) {
770
+ const label = indicator.querySelector('.typing-label');
771
+ if (label) {
772
+ label.textContent = `thinking... (${elapsed}s)`;
773
+ }
774
+ }
893
775
  }
894
-
895
- const indicator = statusEl.querySelector('.status-indicator');
896
- const text = statusEl.querySelector('span:not(.status-indicator)');
897
-
898
- if (!indicator || !text) {
899
- logger.warn('Status indicator or text not found');
900
- return;
776
+ /**
777
+ * Hide typing indicator
778
+ */
779
+ hideTypingIndicator() {
780
+ const container = getElementByIdOrNull('chat-messages');
781
+ if (!container) {
782
+ return;
783
+ }
784
+ const indicator = container.querySelector('.chat-typing-indicator');
785
+ if (indicator) {
786
+ indicator.remove();
787
+ }
901
788
  }
902
-
903
- indicator.className = 'status-indicator ' + status;
904
-
905
- switch (status) {
906
- case 'connected':
907
- text.textContent = 'Connected';
908
- break;
909
- case 'disconnected':
910
- text.textContent = 'Disconnected';
911
- break;
912
- case 'connecting':
913
- text.textContent = 'Connecting...';
914
- break;
915
- default:
916
- text.textContent = status;
917
- }
918
- }
919
-
920
- /**
921
- * Enable/disable chat input
922
- */
923
- enableInput(enabled) {
924
- const input = document.getElementById('chat-input');
925
- const sendBtn = document.getElementById('chat-send');
926
-
927
- input.disabled = !enabled;
928
- sendBtn.disabled = !enabled;
929
-
930
- if (enabled) {
931
- input.placeholder = 'Type your message...';
932
- } else {
933
- input.placeholder = 'Connect to a session to chat';
789
+ // =============================================
790
+ // UI Control
791
+ // =============================================
792
+ /**
793
+ * Update chat status
794
+ */
795
+ updateStatus(status) {
796
+ const statusEl = getElementByIdOrNull('chat-status');
797
+ if (!statusEl) {
798
+ logger.warn('Status element not found');
799
+ return;
800
+ }
801
+ const indicator = statusEl.querySelector('.status-indicator');
802
+ const text = statusEl.querySelector('span:not(.status-indicator)');
803
+ if (!indicator || !text) {
804
+ logger.warn('Status indicator or text not found');
805
+ return;
806
+ }
807
+ indicator.className = 'status-indicator ' + status;
808
+ switch (status) {
809
+ case 'connected':
810
+ text.textContent = 'Connected';
811
+ break;
812
+ case 'disconnected':
813
+ text.textContent = 'Disconnected';
814
+ break;
815
+ case 'connecting':
816
+ text.textContent = 'Connecting...';
817
+ break;
818
+ default:
819
+ text.textContent = status;
820
+ }
934
821
  }
935
- }
936
-
937
- /**
938
- * Enable/disable send button
939
- */
940
- enableSend(enabled) {
941
- const sendBtn = document.getElementById('chat-send');
942
- sendBtn.disabled = !enabled;
943
-
944
- if (enabled) {
945
- sendBtn.textContent = 'Send';
946
- sendBtn.classList.remove('loading');
947
- } else {
948
- sendBtn.textContent = 'Sending...';
949
- sendBtn.classList.add('loading');
950
- }
951
- }
952
-
953
- /**
954
- * Enable/disable mic button
955
- */
956
- enableMic(enabled) {
957
- const micBtn = document.getElementById('chat-mic');
958
- if (micBtn) {
959
- micBtn.disabled = !enabled;
960
- }
961
- }
962
-
963
- // =============================================
964
- // Input Handlers
965
- // =============================================
966
-
967
- /**
968
- * Handle chat input keydown
969
- */
970
- handleInputKeydown(event) {
971
- if (event.key === 'Enter' && !event.shiftKey) {
972
- event.preventDefault();
973
- this.send();
974
- }
975
- }
976
-
977
- /**
978
- * Initialize chat input handlers
979
- */
980
- initChatInput() {
981
- const input = document.getElementById('chat-input');
982
-
983
- input.addEventListener('input', () => {
984
- autoResizeTextarea(input);
985
- });
986
-
987
- input.addEventListener('keydown', (event) => {
988
- this.handleInputKeydown(event);
989
- });
990
- }
991
-
992
- /**
993
- * Initialize long press to copy message functionality
994
- * Supports both touch (mobile) and mouse (desktop) events
995
- */
996
- initLongPressCopy() {
997
- const messagesContainer = document.getElementById('chat-messages');
998
- let pressTimer = null;
999
- const PRESS_DURATION = 750; // milliseconds
1000
-
1001
- // Touch events (mobile)
1002
- messagesContainer.addEventListener('touchstart', (e) => {
1003
- const message = e.target.closest('.message');
1004
- if (!message || message.classList.contains('system')) {
1005
- return;
1006
- }
1007
-
1008
- pressTimer = setTimeout(() => {
1009
- copyMessageText(message);
1010
- }, PRESS_DURATION);
1011
- });
1012
-
1013
- messagesContainer.addEventListener('touchend', () => {
1014
- if (pressTimer) {
1015
- clearTimeout(pressTimer);
1016
- pressTimer = null;
1017
- }
1018
- });
1019
-
1020
- messagesContainer.addEventListener('touchmove', () => {
1021
- if (pressTimer) {
1022
- clearTimeout(pressTimer);
1023
- pressTimer = null;
1024
- }
1025
- });
1026
-
1027
- // Mouse events (desktop)
1028
- messagesContainer.addEventListener('mousedown', (e) => {
1029
- const message = e.target.closest('.message');
1030
- if (!message || message.classList.contains('system')) {
1031
- return;
1032
- }
1033
-
1034
- pressTimer = setTimeout(() => {
1035
- copyMessageText(message);
1036
- }, PRESS_DURATION);
1037
- });
1038
-
1039
- messagesContainer.addEventListener('mouseup', () => {
1040
- if (pressTimer) {
1041
- clearTimeout(pressTimer);
1042
- pressTimer = null;
1043
- }
1044
- });
1045
-
1046
- messagesContainer.addEventListener('mouseleave', () => {
1047
- if (pressTimer) {
1048
- clearTimeout(pressTimer);
1049
- pressTimer = null;
1050
- }
1051
- });
1052
-
1053
822
  /**
1054
- * Copy message text to clipboard
823
+ * Enable/disable chat input
1055
824
  */
1056
- async function copyMessageText(messageEl) {
1057
- const textContent = messageEl.querySelector('.message-text');
1058
- if (!textContent) {
1059
- return;
1060
- }
1061
-
1062
- const text = textContent.textContent;
1063
-
1064
- try {
1065
- await navigator.clipboard.writeText(text);
1066
- showToast('📋 Copied to clipboard');
1067
-
1068
- // Visual feedback
1069
- messageEl.style.opacity = '0.5';
1070
- setTimeout(() => {
1071
- messageEl.style.opacity = '1';
1072
- }, 300);
1073
- } catch (err) {
1074
- logger.error('Copy failed:', err);
1075
- showToast('Failed to copy', 'error');
1076
- }
1077
- }
1078
- }
1079
-
1080
- // =============================================
1081
- // Voice Input (Web Speech API)
1082
- // =============================================
1083
-
1084
- /**
1085
- * Initialize speech recognition
1086
- */
1087
- initSpeechRecognition() {
1088
- const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
1089
-
1090
- if (!SpeechRecognition) {
1091
- logger.warn('SpeechRecognition not supported');
1092
- const micBtn = document.getElementById('chat-mic');
1093
- if (micBtn) {
1094
- micBtn.style.display = 'none';
1095
- }
1096
- return;
825
+ enableInput(enabled) {
826
+ const input = getElementByIdOrNull('chat-input');
827
+ const sendBtn = getElementByIdOrNull('chat-send');
828
+ if (!input || !sendBtn) {
829
+ return;
830
+ }
831
+ input.disabled = !enabled;
832
+ sendBtn.disabled = !enabled;
833
+ if (enabled) {
834
+ input.placeholder = 'Type your message...';
835
+ }
836
+ else {
837
+ input.placeholder = 'Connect to a session to chat';
838
+ }
1097
839
  }
1098
-
1099
- this.speechRecognition = new SpeechRecognition();
1100
- this.speechRecognition.lang = navigator.language || 'ko-KR';
1101
- this.speechRecognition.continuous = true; // Enable continuous recognition for longer phrases
1102
- this.speechRecognition.interimResults = true;
1103
- this.speechRecognition.maxAlternatives = 3; // Get multiple recognition candidates for better accuracy
1104
-
1105
- this.speechRecognition.onresult = (event) => {
1106
- const input = document.getElementById('chat-input');
1107
- let interimTranscript = '';
1108
- let finalTranscript = '';
1109
-
1110
- // Build transcript from NEW results only (use resultIndex)
1111
- logger.debug(
1112
- 'onresult fired, resultIndex:',
1113
- event.resultIndex,
1114
- 'total results:',
1115
- event.results.length
1116
- );
1117
-
1118
- for (let i = event.resultIndex; i < event.results.length; i++) {
1119
- const result = event.results[i];
1120
- const transcript = result[0].transcript;
1121
-
1122
- if (result.isFinal) {
1123
- finalTranscript += transcript;
1124
- logger.debug(
1125
- 'Final result [' + i + ']:',
1126
- transcript,
1127
- 'Confidence:',
1128
- result[0].confidence
1129
- );
1130
- } else {
1131
- interimTranscript += transcript;
1132
- logger.debug('Interim result [' + i + ']:', transcript);
1133
- }
1134
- }
1135
-
1136
- // Handle final transcripts - accumulate them
1137
- if (finalTranscript) {
1138
- // Add space before appending if there's already text
1139
- if (this.accumulatedTranscript) {
1140
- this.accumulatedTranscript += ' ' + finalTranscript;
1141
- } else {
1142
- this.accumulatedTranscript = finalTranscript;
1143
- }
1144
- input.value = this.accumulatedTranscript;
1145
- input.classList.remove('voice-active');
1146
- logger.debug('Accumulated transcript:', this.accumulatedTranscript);
1147
- }
1148
-
1149
- // Handle interim transcripts - show temporarily with accumulated text
1150
- if (interimTranscript) {
1151
- const displayText = this.accumulatedTranscript
1152
- ? this.accumulatedTranscript + ' ' + interimTranscript
1153
- : interimTranscript;
1154
- input.value = displayText;
1155
- input.classList.add('voice-active');
1156
- logger.debug('Showing interim (temp):', displayText);
1157
- }
1158
-
1159
- autoResizeTextarea(input);
1160
-
1161
- // Reset silence timer on each result
1162
- clearTimeout(this.silenceTimeout);
1163
- this.silenceTimeout = setTimeout(() => {
1164
- if (this.isRecording) {
1165
- logger.info('Silence detected, stopping...');
1166
- this.stopVoice();
840
+ /**
841
+ * Enable/disable send button
842
+ */
843
+ enableSend(enabled) {
844
+ const sendBtn = getElementByIdOrNull('chat-send');
845
+ if (!sendBtn) {
846
+ return;
847
+ }
848
+ sendBtn.disabled = !enabled;
849
+ if (enabled) {
850
+ sendBtn.textContent = 'Send';
851
+ sendBtn.classList.remove('loading');
852
+ }
853
+ else {
854
+ sendBtn.textContent = 'Sending...';
855
+ sendBtn.classList.add('loading');
1167
856
  }
1168
- }, this.silenceDelay);
1169
- };
1170
-
1171
- this.speechRecognition.onend = () => {
1172
- logger.info('Recognition ended');
1173
- this.stopVoice();
1174
- };
1175
-
1176
- this.speechRecognition.onerror = (event) => {
1177
- logger.error('Error:', event.error);
1178
- this.stopVoice();
1179
-
1180
- let errorMessage = '';
1181
- switch (event.error) {
1182
- case 'not-allowed':
1183
- errorMessage = '마이크 권한이 거부되었습니다. 브라우저 설정에서 마이크를 허용해주세요.';
1184
- break;
1185
- case 'no-speech':
1186
- errorMessage = '음성이 감지되지 않았습니다. 다시 시도해주세요.';
1187
- break;
1188
- case 'network':
1189
- errorMessage = '네트워크 오류가 발생했습니다.';
1190
- break;
1191
- default:
1192
- errorMessage = `음성 인식 오류: ${event.error}`;
1193
- }
1194
-
1195
- this.addSystemMessage(errorMessage, 'error');
1196
- };
1197
-
1198
- logger.info('SpeechRecognition initialized (lang:', this.speechRecognition.lang + ')');
1199
- }
1200
-
1201
- /**
1202
- * Toggle voice input
1203
- */
1204
- toggleVoice() {
1205
- if (this.isRecording) {
1206
- this.stopVoice();
1207
- } else {
1208
- this.startVoice();
1209
- }
1210
- }
1211
-
1212
- /**
1213
- * Start voice recording
1214
- */
1215
- startVoice() {
1216
- if (!this.speechRecognition) {
1217
- this.addSystemMessage('이 브라우저에서는 음성 인식이 지원되지 않습니다.', 'error');
1218
- return;
1219
857
  }
1220
-
1221
- try {
1222
- const micBtn = document.getElementById('chat-mic');
1223
- const input = document.getElementById('chat-input');
1224
-
1225
- // Clear input and accumulated transcript for new recording
1226
- input.value = '';
1227
- this.accumulatedTranscript = '';
1228
-
1229
- this.speechRecognition.start();
1230
- this.isRecording = true;
1231
-
1232
- micBtn.classList.add('recording');
1233
- input.classList.add('voice-active');
1234
- input.placeholder = '말씀해주세요... (계속 말하면 이어서 인식됩니다)';
1235
-
1236
- logger.info('Recording started (continuous mode)');
1237
- logger.debug('Settings:', {
1238
- lang: this.speechRecognition.lang,
1239
- continuous: this.speechRecognition.continuous,
1240
- interimResults: this.speechRecognition.interimResults,
1241
- maxAlternatives: this.speechRecognition.maxAlternatives,
1242
- });
1243
-
1244
- this.silenceTimeout = setTimeout(() => {
1245
- if (this.isRecording) {
1246
- this.stopVoice();
858
+ /**
859
+ * Enable/disable mic button
860
+ */
861
+ enableMic(enabled) {
862
+ const micBtn = getElementByIdOrNull('chat-mic');
863
+ if (micBtn) {
864
+ micBtn.disabled = !enabled;
1247
865
  }
1248
- }, this.silenceDelay);
1249
- } catch (err) {
1250
- logger.error('Failed to start:', err);
1251
- this.addSystemMessage('음성 인식을 시작할 수 없습니다.', 'error');
1252
866
  }
1253
- }
1254
-
1255
- /**
1256
- * Stop voice recording
1257
- */
1258
- stopVoice() {
1259
- if (!this.isRecording) {
1260
- return;
867
+ // =============================================
868
+ // Input Handlers
869
+ // =============================================
870
+ /**
871
+ * Handle chat input keydown
872
+ */
873
+ handleInputKeydown(event) {
874
+ if (event.key === 'Enter' && !event.shiftKey) {
875
+ event.preventDefault();
876
+ this.send();
877
+ }
1261
878
  }
1262
-
1263
- clearTimeout(this.silenceTimeout);
1264
-
1265
- try {
1266
- this.speechRecognition.stop();
1267
- } catch (e) {
1268
- // Ignore errors
879
+ /**
880
+ * Initialize chat input handlers
881
+ */
882
+ initChatInput() {
883
+ const input = getElementByIdOrNull('chat-input');
884
+ if (!input) {
885
+ return;
886
+ }
887
+ const messagesContainer = getElementByIdOrNull('chat-messages');
888
+ input.addEventListener('input', () => {
889
+ autoResizeTextarea(input);
890
+ });
891
+ if (messagesContainer) {
892
+ messagesContainer.addEventListener('click', (event) => {
893
+ const target = event.target?.closest('.quiz-choice-btn');
894
+ if (!target) {
895
+ return;
896
+ }
897
+ const choice = target.dataset.choice;
898
+ if (!choice) {
899
+ return;
900
+ }
901
+ event.preventDefault();
902
+ this.sendQuizChoice(choice);
903
+ });
904
+ }
905
+ input.addEventListener('keydown', (event) => {
906
+ this.handleInputKeydown(event);
907
+ });
1269
908
  }
1270
-
1271
- this.isRecording = false;
1272
-
1273
- const micBtn = document.getElementById('chat-mic');
1274
- const input = document.getElementById('chat-input');
1275
-
1276
- micBtn.classList.remove('recording');
1277
- input.classList.remove('voice-active');
1278
- input.placeholder = 'Type your message...';
1279
-
1280
- logger.info('Recording stopped');
1281
- this.resetIdleTimer();
1282
- }
1283
-
1284
- // =============================================
1285
- // Text-to-Speech (TTS)
1286
- // =============================================
1287
-
1288
- /**
1289
- * Initialize Speech Synthesis
1290
- */
1291
- initSpeechSynthesis() {
1292
- if (!this.speechSynthesis) {
1293
- logger.warn('SpeechSynthesis not supported');
1294
- return;
909
+ /**
910
+ * Initialize long press to copy message functionality
911
+ * Supports both touch (mobile) and mouse (desktop) events
912
+ */
913
+ initLongPressCopy() {
914
+ const messagesContainer = getElementByIdOrNull('chat-messages');
915
+ if (!messagesContainer) {
916
+ return;
917
+ }
918
+ let pressTimer = null;
919
+ const PRESS_DURATION = 750; // milliseconds
920
+ // Touch events (mobile)
921
+ messagesContainer.addEventListener('touchstart', (e) => {
922
+ const target = e.target;
923
+ const message = target?.closest('.chat-message');
924
+ if (!message || message.classList.contains('system')) {
925
+ return;
926
+ }
927
+ pressTimer = setTimeout(() => {
928
+ copyMessageText(message);
929
+ }, PRESS_DURATION);
930
+ });
931
+ messagesContainer.addEventListener('touchend', () => {
932
+ if (pressTimer) {
933
+ clearTimeout(pressTimer);
934
+ pressTimer = null;
935
+ }
936
+ });
937
+ messagesContainer.addEventListener('touchmove', () => {
938
+ if (pressTimer) {
939
+ clearTimeout(pressTimer);
940
+ pressTimer = null;
941
+ }
942
+ });
943
+ // Mouse events (desktop)
944
+ messagesContainer.addEventListener('mousedown', (e) => {
945
+ const target = e.target;
946
+ const message = target?.closest('.chat-message');
947
+ if (!message || message.classList.contains('system')) {
948
+ return;
949
+ }
950
+ pressTimer = setTimeout(() => {
951
+ copyMessageText(message);
952
+ }, PRESS_DURATION);
953
+ });
954
+ messagesContainer.addEventListener('mouseup', () => {
955
+ if (pressTimer) {
956
+ clearTimeout(pressTimer);
957
+ pressTimer = null;
958
+ }
959
+ });
960
+ messagesContainer.addEventListener('mouseleave', () => {
961
+ if (pressTimer) {
962
+ clearTimeout(pressTimer);
963
+ pressTimer = null;
964
+ }
965
+ });
966
+ /**
967
+ * Copy message text to clipboard
968
+ */
969
+ async function copyMessageText(messageEl) {
970
+ const textContent = messageEl.querySelector('.message-content');
971
+ if (!textContent) {
972
+ return;
973
+ }
974
+ const text = textContent.textContent || '';
975
+ try {
976
+ await navigator.clipboard.writeText(text);
977
+ showToast('📋 Copied to clipboard');
978
+ // Visual feedback
979
+ messageEl.style.opacity = '0.5';
980
+ setTimeout(() => {
981
+ messageEl.style.opacity = '1';
982
+ }, 300);
983
+ }
984
+ catch (err) {
985
+ logger.error('Copy failed:', err);
986
+ showToast('Failed to copy');
987
+ }
988
+ }
1295
989
  }
1296
-
1297
- // Wait for voices to load
1298
- const loadVoices = () => {
1299
- const voices = this.speechSynthesis.getVoices();
1300
- // Find Korean voice
1301
- this.ttsVoice =
1302
- voices.find((v) => v.lang === 'ko-KR') ||
1303
- voices.find((v) => v.lang.startsWith('ko')) ||
1304
- voices[0];
1305
-
1306
- if (this.ttsVoice) {
1307
- logger.info('Korean voice selected:', this.ttsVoice.name, this.ttsVoice.lang);
1308
- } else {
1309
- logger.warn('No Korean voice found, using default');
1310
- }
1311
- };
1312
-
1313
- // Voices might not be loaded immediately
1314
- if (this.speechSynthesis.getVoices().length > 0) {
1315
- loadVoices();
1316
- } else {
1317
- this.speechSynthesis.onvoiceschanged = loadVoices;
990
+ // =============================================
991
+ // Voice Input (Web Speech API)
992
+ // =============================================
993
+ /**
994
+ * Initialize speech recognition
995
+ */
996
+ initSpeechRecognition() {
997
+ const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
998
+ if (!SpeechRecognition) {
999
+ logger.warn('SpeechRecognition not supported');
1000
+ const micBtn = getElementByIdOrNull('chat-mic');
1001
+ if (micBtn) {
1002
+ micBtn.style.display = 'none';
1003
+ }
1004
+ return;
1005
+ }
1006
+ this.speechRecognition = new SpeechRecognition();
1007
+ this.speechRecognition.lang = navigator.language || 'ko-KR';
1008
+ this.speechRecognition.continuous = true; // Enable continuous recognition for longer phrases
1009
+ this.speechRecognition.interimResults = true;
1010
+ this.speechRecognition.maxAlternatives = 3; // Get multiple recognition candidates for better accuracy
1011
+ this.speechRecognition.onresult = (event) => {
1012
+ const input = getElementByIdOrNull('chat-input');
1013
+ if (!input) {
1014
+ return;
1015
+ }
1016
+ let interimTranscript = '';
1017
+ let finalTranscript = '';
1018
+ // Build transcript from NEW results only (use resultIndex)
1019
+ logger.debug('onresult fired, resultIndex:', event.resultIndex, 'total results:', event.results.length);
1020
+ for (let i = event.resultIndex; i < event.results.length; i++) {
1021
+ const result = event.results[i];
1022
+ const transcript = result[0].transcript;
1023
+ if (result.isFinal) {
1024
+ finalTranscript += transcript;
1025
+ logger.debug('Final result [' + i + ']:', transcript, 'Confidence:', result[0].confidence);
1026
+ }
1027
+ else {
1028
+ interimTranscript += transcript;
1029
+ logger.debug('Interim result [' + i + ']:', transcript);
1030
+ }
1031
+ }
1032
+ // Handle final transcripts - accumulate them
1033
+ if (finalTranscript) {
1034
+ // Add space before appending if there's already text
1035
+ if (this.accumulatedTranscript) {
1036
+ this.accumulatedTranscript += ' ' + finalTranscript;
1037
+ }
1038
+ else {
1039
+ this.accumulatedTranscript = finalTranscript;
1040
+ }
1041
+ input.value = this.accumulatedTranscript;
1042
+ input.classList.remove('voice-active');
1043
+ logger.debug('Accumulated transcript:', this.accumulatedTranscript);
1044
+ }
1045
+ // Handle interim transcripts - show temporarily with accumulated text
1046
+ if (interimTranscript) {
1047
+ const displayText = this.accumulatedTranscript
1048
+ ? this.accumulatedTranscript + ' ' + interimTranscript
1049
+ : interimTranscript;
1050
+ input.value = displayText;
1051
+ input.classList.add('voice-active');
1052
+ logger.debug('Showing interim (temp):', displayText);
1053
+ }
1054
+ autoResizeTextarea(input);
1055
+ // Reset silence timer on each result
1056
+ clearTimeout(this.silenceTimeout);
1057
+ this.silenceTimeout = setTimeout(() => {
1058
+ if (this.isRecording) {
1059
+ logger.info('Silence detected, stopping...');
1060
+ this.stopVoice();
1061
+ }
1062
+ }, this.silenceDelay);
1063
+ };
1064
+ this.speechRecognition.onend = () => {
1065
+ logger.info('Recognition ended');
1066
+ this.stopVoice();
1067
+ };
1068
+ this.speechRecognition.onerror = (event) => {
1069
+ logger.error('Error:', event.error);
1070
+ this.stopVoice();
1071
+ let errorMessage = '';
1072
+ switch (event.error) {
1073
+ case 'not-allowed':
1074
+ errorMessage = '마이크 권한이 거부되었습니다. 브라우저 설정에서 마이크를 허용해주세요.';
1075
+ break;
1076
+ case 'no-speech':
1077
+ errorMessage = '음성이 감지되지 않았습니다. 다시 시도해주세요.';
1078
+ break;
1079
+ case 'network':
1080
+ errorMessage = '네트워크 오류가 발생했습니다.';
1081
+ break;
1082
+ default:
1083
+ errorMessage = `음성 인식 오류: ${event.error}`;
1084
+ }
1085
+ this.addSystemMessage(errorMessage, 'error');
1086
+ };
1087
+ logger.info('SpeechRecognition initialized (lang:', this.speechRecognition.lang + ')');
1318
1088
  }
1319
-
1320
- logger.info('SpeechSynthesis initialized');
1321
- }
1322
-
1323
- /**
1324
- * Toggle TTS auto-play
1325
- */
1326
- toggleTTS() {
1327
- this.ttsEnabled = !this.ttsEnabled;
1328
- if (!this.ttsEnabled) {
1329
- this.stopSpeaking();
1330
- }
1331
- const btn = document.getElementById('chat-tts-toggle');
1332
-
1333
- if (btn) {
1334
- btn.classList.toggle('active', this.ttsEnabled);
1335
- btn.title = this.ttsEnabled
1336
- ? 'TTS 활성화됨 (클릭하여 끄기)'
1337
- : 'TTS 비활성화됨 (클릭하여 켜기)';
1089
+ /**
1090
+ * Toggle voice input
1091
+ */
1092
+ toggleVoice() {
1093
+ if (this.isRecording) {
1094
+ this.stopVoice();
1095
+ }
1096
+ else {
1097
+ this.startVoice();
1098
+ }
1338
1099
  }
1339
-
1340
- logger.info('Auto-play:', this.ttsEnabled ? 'ON' : 'OFF');
1341
- showToast(this.ttsEnabled ? '🔊 TTS 활성화' : '🔇 TTS 비활성화');
1342
- }
1343
-
1344
- /**
1345
- * Toggle hands-free mode
1346
- */
1347
- toggleHandsFree() {
1348
- this.handsFreeMode = !this.handsFreeMode;
1349
- const btn = document.getElementById('chat-handsfree-toggle');
1350
-
1351
- if (btn) {
1352
- btn.classList.toggle('active', this.handsFreeMode);
1353
- btn.title = this.handsFreeMode ? '핸즈프리 활성화됨' : '핸즈프리 비활성화됨';
1100
+ /**
1101
+ * Start voice recording
1102
+ */
1103
+ startVoice() {
1104
+ if (!this.speechRecognition) {
1105
+ this.addSystemMessage('이 브라우저에서는 음성 인식이 지원되지 않습니다.', 'error');
1106
+ return;
1107
+ }
1108
+ try {
1109
+ const micBtn = getElementByIdOrNull('chat-mic');
1110
+ const input = getElementByIdOrNull('chat-input');
1111
+ if (!micBtn || !input) {
1112
+ return;
1113
+ }
1114
+ // Clear input and accumulated transcript for new recording
1115
+ input.value = '';
1116
+ this.accumulatedTranscript = '';
1117
+ this.speechRecognition.start();
1118
+ this.isRecording = true;
1119
+ micBtn.classList.add('recording');
1120
+ input.classList.add('voice-active');
1121
+ input.placeholder = '말씀해주세요... (계속 말하면 이어서 인식됩니다)';
1122
+ logger.info('Recording started (continuous mode)');
1123
+ logger.debug('Settings:', {
1124
+ lang: this.speechRecognition.lang,
1125
+ continuous: this.speechRecognition.continuous,
1126
+ interimResults: this.speechRecognition.interimResults,
1127
+ maxAlternatives: this.speechRecognition.maxAlternatives,
1128
+ });
1129
+ this.silenceTimeout = setTimeout(() => {
1130
+ if (this.isRecording) {
1131
+ this.stopVoice();
1132
+ }
1133
+ }, this.silenceDelay);
1134
+ }
1135
+ catch (err) {
1136
+ logger.error('Failed to start:', err);
1137
+ this.addSystemMessage('음성 인식을 시작할 수 없습니다.', 'error');
1138
+ }
1354
1139
  }
1355
-
1356
- logger.info('Hands-free mode:', this.handsFreeMode ? 'ON' : 'OFF');
1357
- showToast(this.handsFreeMode ? '🎙️ 핸즈프리 모드 활성화' : '🎙️ 핸즈프리 모드 비활성화');
1358
-
1359
- // Enable TTS automatically when hands-free is enabled
1360
- if (this.handsFreeMode && !this.ttsEnabled) {
1361
- this.toggleTTS();
1140
+ /**
1141
+ * Stop voice recording
1142
+ */
1143
+ stopVoice() {
1144
+ if (!this.isRecording) {
1145
+ return;
1146
+ }
1147
+ clearTimeout(this.silenceTimeout);
1148
+ try {
1149
+ this.speechRecognition.stop();
1150
+ }
1151
+ catch {
1152
+ // Ignore errors
1153
+ }
1154
+ this.isRecording = false;
1155
+ const micBtn = getElementByIdOrNull('chat-mic');
1156
+ const input = getElementByIdOrNull('chat-input');
1157
+ if (!micBtn || !input || !this.speechRecognition) {
1158
+ return;
1159
+ }
1160
+ micBtn.classList.remove('recording');
1161
+ input.classList.remove('voice-active');
1162
+ input.placeholder = 'Type your message...';
1163
+ logger.info('Recording stopped');
1164
+ this.resetIdleTimer();
1362
1165
  }
1363
- }
1364
-
1365
- /**
1366
- * Speak text using TTS
1367
- */
1368
- stripMarkdownForTTS(text) {
1369
- return text
1370
- .replace(/```[\s\S]*?```/g, '') // code blocks
1371
- .replace(/`([^`]+)`/g, '$1') // inline code
1372
- .replace(/\*\*([^*]+)\*\*/g, '$1') // bold
1373
- .replace(/\*([^*]+)\*/g, '$1') // italic
1374
- .replace(/~~([^~]+)~~/g, '$1') // strikethrough
1375
- .replace(/#{1,6}\s(.+)/g, '$1') // headers
1376
- .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') // links
1377
- .replace(/^[-*]\s/gm, '') // list markers
1378
- .replace(/!\[([^\]]*)\]\([^)]+\)/g, '$1') // images
1379
- .replace(/~\/.mama\/workspace\/media\/[^\s]+/g, '') // media paths
1380
- .replace(
1381
- /[\u{1F600}-\u{1F9FF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}\u{1F300}-\u{1F5FF}\u{1F680}-\u{1F6FF}\u{1FA00}-\u{1FAFF}]/gu,
1382
- ''
1383
- ) // emoji
1384
- .replace(/\n{2,}/g, '. ')
1385
- .trim();
1386
- }
1387
-
1388
- speak(text) {
1389
- if (!this.speechSynthesis || !text) {
1390
- return;
1166
+ // =============================================
1167
+ // Text-to-Speech (TTS)
1168
+ // =============================================
1169
+ /**
1170
+ * Initialize Speech Synthesis
1171
+ */
1172
+ initSpeechSynthesis() {
1173
+ if (!this.speechSynthesis) {
1174
+ logger.warn('SpeechSynthesis not supported');
1175
+ return;
1176
+ }
1177
+ // Wait for voices to load
1178
+ const loadVoices = () => {
1179
+ const voices = this.speechSynthesis.getVoices();
1180
+ // Find Korean voice
1181
+ this.ttsVoice =
1182
+ voices.find((v) => v.lang === 'ko-KR') ||
1183
+ voices.find((v) => v.lang.startsWith('ko')) ||
1184
+ voices[0];
1185
+ if (this.ttsVoice) {
1186
+ logger.info('Korean voice selected:', this.ttsVoice.name, this.ttsVoice.lang);
1187
+ }
1188
+ else {
1189
+ logger.warn('No Korean voice found, using default');
1190
+ }
1191
+ };
1192
+ // Voices might not be loaded immediately
1193
+ if (this.speechSynthesis.getVoices().length > 0) {
1194
+ loadVoices();
1195
+ }
1196
+ else {
1197
+ this.speechSynthesis.onvoiceschanged = loadVoices;
1198
+ }
1199
+ logger.info('SpeechSynthesis initialized');
1391
1200
  }
1392
-
1393
- text = this.stripMarkdownForTTS(text);
1394
- if (!text) {
1395
- return;
1201
+ /**
1202
+ * Toggle TTS auto-play
1203
+ */
1204
+ toggleTTS() {
1205
+ this.ttsEnabled = !this.ttsEnabled;
1206
+ if (!this.ttsEnabled) {
1207
+ this.stopSpeaking();
1208
+ }
1209
+ const btn = getElementByIdOrNull('chat-tts-toggle');
1210
+ if (btn) {
1211
+ btn.classList.toggle('active', this.ttsEnabled);
1212
+ btn.title = this.ttsEnabled
1213
+ ? 'TTS 활성화됨 (클릭하여 끄기)'
1214
+ : 'TTS 비활성화됨 (클릭하여 켜기)';
1215
+ }
1216
+ logger.info('Auto-play:', this.ttsEnabled ? 'ON' : 'OFF');
1217
+ showToast(this.ttsEnabled ? '🔊 TTS 활성화' : '🔇 TTS 비활성화');
1396
1218
  }
1397
-
1398
- // Stop any ongoing speech
1399
- this.stopSpeaking();
1400
-
1401
- const utterance = new SpeechSynthesisUtterance(text);
1402
- utterance.voice = this.ttsVoice;
1403
- utterance.rate = this.ttsRate;
1404
- utterance.pitch = this.ttsPitch;
1405
- utterance.lang = this.ttsVoice?.lang || navigator.language || 'ko-KR';
1406
-
1407
- utterance.onstart = () => {
1408
- this.isSpeaking = true;
1409
- logger.debug('Speaking started');
1410
- };
1411
-
1412
- utterance.onend = () => {
1413
- this.isSpeaking = false;
1414
- logger.debug('Speaking ended');
1415
-
1416
- // If hands-free mode, start listening after TTS finishes
1417
- if (this.handsFreeMode && !this.isRecording) {
1418
- logger.info('Hands-free mode: auto-starting voice input');
1419
- setTimeout(() => {
1420
- this.startVoice();
1421
- }, 500); // Small delay for smooth transition
1422
- }
1423
- };
1424
-
1425
- utterance.onerror = (event) => {
1426
- this.isSpeaking = false;
1427
- logger.error('Error:', event.error);
1428
- };
1429
-
1430
- this.speechSynthesis.speak(utterance);
1431
- logger.debug('Speaking:', text.substring(0, 50) + '...');
1432
- }
1433
-
1434
- /**
1435
- * Stop speaking
1436
- */
1437
- stopSpeaking() {
1438
- if (this.speechSynthesis && this.isSpeaking) {
1439
- this.speechSynthesis.cancel();
1440
- this.isSpeaking = false;
1441
- logger.debug('Speaking stopped');
1442
- }
1443
- }
1444
-
1445
- /**
1446
- * Set TTS rate (0.5 - 2.0)
1447
- */
1448
- setTTSRate(rate) {
1449
- this.ttsRate = Math.max(0.5, Math.min(2.0, rate));
1450
- logger.info('Rate set to:', this.ttsRate);
1451
- }
1452
-
1453
- // =============================================
1454
- // History Management
1455
- // =============================================
1456
-
1457
- /**
1458
- * Save message to history
1459
- */
1460
- saveToHistory(role, content, timestamp = new Date(), attachment = null) {
1461
- if (!this.sessionId) {
1462
- return;
1219
+ /**
1220
+ * Toggle hands-free mode
1221
+ */
1222
+ toggleHandsFree() {
1223
+ this.handsFreeMode = !this.handsFreeMode;
1224
+ const btn = getElementByIdOrNull('chat-handsfree-toggle');
1225
+ if (btn) {
1226
+ btn.classList.toggle('active', this.handsFreeMode);
1227
+ btn.title = this.handsFreeMode ? '핸즈프리 활성화됨' : '핸즈프리 비활성화됨';
1228
+ }
1229
+ logger.info('Hands-free mode:', this.handsFreeMode ? 'ON' : 'OFF');
1230
+ showToast(this.handsFreeMode ? '🎙️ 핸즈프리 모드 활성화' : '🎙️ 핸즈프리 모드 비활성화');
1231
+ // Enable TTS automatically when hands-free is enabled
1232
+ if (this.handsFreeMode && !this.ttsEnabled) {
1233
+ this.toggleTTS();
1234
+ }
1463
1235
  }
1464
-
1465
- const entry = {
1466
- role,
1467
- content,
1468
- timestamp: timestamp.toISOString(),
1469
- };
1470
- if (attachment) {
1471
- entry.attachment = attachment;
1236
+ /**
1237
+ * Speak text using TTS
1238
+ */
1239
+ stripMarkdownForTTS(text) {
1240
+ return text
1241
+ .replace(/```[\s\S]*?```/g, '') // code blocks
1242
+ .replace(/`([^`]+)`/g, '$1') // inline code
1243
+ .replace(/\*\*([^*]+)\*\*/g, '$1') // bold
1244
+ .replace(/\*([^*]+)\*/g, '$1') // italic
1245
+ .replace(/~~([^~]+)~~/g, '$1') // strikethrough
1246
+ .replace(/#{1,6}\s(.+)/g, '$1') // headers
1247
+ .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') // links
1248
+ .replace(/^[-*]\s/gm, '') // list markers
1249
+ .replace(/!\[([^\]]*)\]\([^)]+\)/g, '$1') // images
1250
+ .replace(/~\/.mama\/workspace\/media\/[^\s]+/g, '') // media paths
1251
+ .replace(/[\u{1F600}-\u{1F9FF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}\u{1F300}-\u{1F5FF}\u{1F680}-\u{1F6FF}\u{1FA00}-\u{1FAFF}]/gu, '') // emoji
1252
+ .replace(/\n{2,}/g, '. ')
1253
+ .trim();
1254
+ }
1255
+ speak(text) {
1256
+ if (!this.speechSynthesis || !text) {
1257
+ return;
1258
+ }
1259
+ text = this.stripMarkdownForTTS(text);
1260
+ if (!text) {
1261
+ return;
1262
+ }
1263
+ // Stop any ongoing speech
1264
+ this.stopSpeaking();
1265
+ const utterance = new SpeechSynthesisUtterance(text);
1266
+ utterance.voice = this.ttsVoice;
1267
+ utterance.rate = this.ttsRate;
1268
+ utterance.pitch = this.ttsPitch;
1269
+ utterance.lang = this.ttsVoice?.lang || navigator.language || 'ko-KR';
1270
+ utterance.onstart = () => {
1271
+ this.isSpeaking = true;
1272
+ logger.debug('Speaking started');
1273
+ };
1274
+ utterance.onend = () => {
1275
+ this.isSpeaking = false;
1276
+ logger.debug('Speaking ended');
1277
+ // If hands-free mode, start listening after TTS finishes
1278
+ if (this.handsFreeMode && !this.isRecording) {
1279
+ logger.info('Hands-free mode: auto-starting voice input');
1280
+ setTimeout(() => {
1281
+ this.startVoice();
1282
+ }, 500); // Small delay for smooth transition
1283
+ }
1284
+ };
1285
+ utterance.onerror = (event) => {
1286
+ this.isSpeaking = false;
1287
+ logger.error('Error:', event.error);
1288
+ };
1289
+ this.speechSynthesis.speak(utterance);
1290
+ logger.debug('Speaking:', text.substring(0, 50) + '...');
1472
1291
  }
1473
-
1474
- this.history.push(entry);
1475
-
1476
- if (this.history.length > this.maxHistoryMessages) {
1477
- this.history = this.history.slice(-this.maxHistoryMessages);
1292
+ /**
1293
+ * Stop speaking
1294
+ */
1295
+ stopSpeaking() {
1296
+ if (this.speechSynthesis && this.isSpeaking) {
1297
+ this.speechSynthesis.cancel();
1298
+ this.isSpeaking = false;
1299
+ logger.debug('Speaking stopped');
1300
+ }
1478
1301
  }
1479
-
1480
- try {
1481
- const storageKey = this.historyPrefix + this.sessionId;
1482
- const storageData = {
1483
- history: this.history,
1484
- savedAt: Date.now(),
1485
- };
1486
- localStorage.setItem(storageKey, JSON.stringify(storageData));
1487
- } catch (e) {
1488
- logger.warn('Failed to save history:', e);
1489
- }
1490
- }
1491
-
1492
- /**
1493
- * Load history from localStorage
1494
- */
1495
- loadHistory(sessionId) {
1496
- try {
1497
- const storageKey = this.historyPrefix + sessionId;
1498
- const stored = localStorage.getItem(storageKey);
1499
-
1500
- if (!stored) {
1501
- return null;
1502
- }
1503
-
1504
- const data = JSON.parse(stored);
1505
-
1506
- if (Date.now() - data.savedAt > this.historyExpiryMs) {
1507
- localStorage.removeItem(storageKey);
1508
- return null;
1509
- }
1510
-
1511
- return data.history || [];
1512
- } catch (e) {
1513
- logger.warn('Failed to load history:', e);
1514
- return null;
1302
+ /**
1303
+ * Set TTS rate (0.5 - 2.0)
1304
+ */
1305
+ setTTSRate(rate) {
1306
+ this.ttsRate = Math.max(0.5, Math.min(2.0, rate));
1307
+ logger.info('Rate set to:', this.ttsRate);
1515
1308
  }
1516
- }
1517
-
1518
- /**
1519
- * Restore chat history
1520
- */
1521
- restoreHistory(sessionId) {
1522
- const history = this.loadHistory(sessionId);
1523
-
1524
- if (!history || history.length === 0) {
1525
- return false;
1309
+ // =============================================
1310
+ // History Management
1311
+ // =============================================
1312
+ /**
1313
+ * Save message to history
1314
+ */
1315
+ saveToHistory(role, content, timestamp = new Date(), attachment = null) {
1316
+ if (!this.sessionId) {
1317
+ return;
1318
+ }
1319
+ const entry = {
1320
+ role,
1321
+ content,
1322
+ timestamp: timestamp.toISOString(),
1323
+ ...(attachment ? { attachment } : {}),
1324
+ };
1325
+ this.history.push(entry);
1326
+ if (this.history.length > this.maxHistoryMessages) {
1327
+ this.history = this.history.slice(-this.maxHistoryMessages);
1328
+ }
1329
+ try {
1330
+ const storageKey = this.historyPrefix + this.sessionId;
1331
+ const storageData = {
1332
+ history: this.history,
1333
+ savedAt: Date.now(),
1334
+ };
1335
+ localStorage.setItem(storageKey, JSON.stringify(storageData));
1336
+ }
1337
+ catch (e) {
1338
+ logger.warn('Failed to save history:', e);
1339
+ }
1526
1340
  }
1527
-
1528
- this.history = history;
1529
- const container = document.getElementById('chat-messages');
1530
-
1531
- this.removePlaceholder();
1532
-
1533
- history.forEach((msg) => {
1534
- const msgEl = document.createElement('div');
1535
- msgEl.className = `chat-message ${msg.role}`;
1536
-
1537
- if (msg.role === 'user') {
1538
- let attachHtml = '';
1539
- if (msg.attachment) {
1540
- const att = msg.attachment;
1541
- if (att.isImage) {
1542
- const safeUrl = escapeAttr(att.mediaUrl);
1543
- const safeAlt = escapeAttr(att.originalName || '');
1544
- attachHtml = `<img src="${safeUrl}" class="max-w-[200px] rounded-lg mt-1 cursor-pointer" alt="${safeAlt}" data-lightbox="${safeUrl}" />`;
1545
- } else {
1546
- const safeName = encodeURIComponent(att.filename);
1547
- attachHtml = `<a href="/api/media/download/${safeName}" target="_blank" class="flex items-center gap-2 mt-1 px-3 py-2 bg-white/50 rounded-lg border border-gray-200 text-sm hover:bg-white/80 transition-colors"><span class="text-lg">\u{1F4CE}</span><span class="truncate max-w-[180px]">${escapeHtml(att.originalName || att.filename)}</span></a>`;
1548
- }
1341
+ /**
1342
+ * Load history from localStorage
1343
+ */
1344
+ loadHistory(sessionId) {
1345
+ try {
1346
+ const storageKey = this.historyPrefix + sessionId;
1347
+ const stored = localStorage.getItem(storageKey);
1348
+ if (!stored) {
1349
+ return null;
1350
+ }
1351
+ const data = JSON.parse(stored);
1352
+ if (Date.now() - data.savedAt > this.historyExpiryMs) {
1353
+ localStorage.removeItem(storageKey);
1354
+ return null;
1355
+ }
1356
+ return data.history || [];
1549
1357
  }
1550
- msgEl.innerHTML = `
1358
+ catch (e) {
1359
+ logger.warn('Failed to load history:', e);
1360
+ return null;
1361
+ }
1362
+ }
1363
+ /**
1364
+ * Restore chat history
1365
+ */
1366
+ restoreHistory(sessionId) {
1367
+ const history = this.loadHistory(sessionId);
1368
+ if (!history || history.length === 0) {
1369
+ return false;
1370
+ }
1371
+ this.history = history;
1372
+ const container = getElementByIdOrNull('chat-messages');
1373
+ if (!container) {
1374
+ return false;
1375
+ }
1376
+ this.removePlaceholder();
1377
+ history.forEach((msg) => {
1378
+ const msgEl = document.createElement('div');
1379
+ msgEl.className = `chat-message ${msg.role}`;
1380
+ if (msg.role === 'user') {
1381
+ let attachHtml = '';
1382
+ if (msg.attachment) {
1383
+ const att = msg.attachment;
1384
+ if (att.isImage) {
1385
+ const safeUrl = escapeAttr(att.mediaUrl);
1386
+ const safeAlt = escapeAttr(att.originalName || '');
1387
+ attachHtml = `<img src="${safeUrl}" class="max-w-[200px] rounded-lg mt-1 cursor-pointer" alt="${safeAlt}" data-lightbox="${safeUrl}" />`;
1388
+ }
1389
+ else {
1390
+ const safeName = encodeURIComponent(att.filename);
1391
+ attachHtml = `<a href="/api/media/download/${safeName}" target="_blank" class="flex items-center gap-2 mt-1 px-3 py-2 bg-white/50 rounded-lg border border-gray-200 text-sm hover:bg-white/80 transition-colors"><span class="text-lg">\u{1F4CE}</span><span class="truncate max-w-[180px]">${escapeHtml(att.originalName || att.filename)}</span></a>`;
1392
+ }
1393
+ }
1394
+ msgEl.innerHTML = `
1551
1395
  <div class="message-content">${escapeHtml(msg.content)}${attachHtml}</div>
1552
1396
  <div class="message-time">${formatMessageTime(new Date(msg.timestamp))}</div>
1553
1397
  `;
1554
- } else if (msg.role === 'assistant') {
1555
- msgEl.innerHTML = `
1398
+ }
1399
+ else if (msg.role === 'assistant') {
1400
+ msgEl.innerHTML = `
1556
1401
  <div class="message-content">${formatAssistantMessage(msg.content)}</div>
1557
1402
  <div class="message-time">${formatMessageTime(new Date(msg.timestamp))}</div>
1558
1403
  `;
1559
- } else if (msg.role === 'system') {
1560
- msgEl.innerHTML = `
1404
+ }
1405
+ else if (msg.role === 'system') {
1406
+ msgEl.innerHTML = `
1561
1407
  <div class="message-content">${escapeHtml(msg.content)}</div>
1562
1408
  `;
1563
- }
1564
-
1565
- container.appendChild(msgEl);
1566
- });
1567
-
1568
- scrollToBottom(container);
1569
- showToast('Previous conversation restored');
1570
-
1571
- return true;
1572
- }
1573
-
1574
- /**
1575
- * Display history received from server
1576
- */
1577
- displayHistory(messages) {
1578
- const container = document.getElementById('chat-messages');
1579
- if (!container) {
1580
- return;
1581
- }
1582
-
1583
- // Server history is authoritative — always use it when available
1584
- if (messages.length === 0 && this.history.length > 0) {
1585
- logger.info(`Server sent empty history, keeping local (${this.history.length})`);
1586
- return;
1409
+ }
1410
+ container.appendChild(msgEl);
1411
+ });
1412
+ scrollToBottom(container);
1413
+ showToast('Previous conversation restored');
1414
+ return true;
1587
1415
  }
1588
-
1589
- container.innerHTML = '';
1590
- this.history = [];
1591
-
1592
- messages.forEach((msg) => {
1593
- const msgEl = document.createElement('div');
1594
- msgEl.className = `chat-message ${msg.role}`;
1595
-
1596
- const timestamp = msg.timestamp ? new Date(msg.timestamp) : new Date();
1597
-
1598
- if (msg.role === 'user') {
1599
- msgEl.innerHTML = `
1416
+ /**
1417
+ * Display history received from server
1418
+ */
1419
+ displayHistory(messages) {
1420
+ const container = getElementByIdOrNull('chat-messages');
1421
+ if (!container) {
1422
+ return;
1423
+ }
1424
+ // Server history is authoritative always use it when available
1425
+ if (messages.length === 0 && this.history.length > 0) {
1426
+ logger.info(`Server sent empty history, keeping local (${this.history.length})`);
1427
+ return;
1428
+ }
1429
+ container.innerHTML = '';
1430
+ this.history = [];
1431
+ messages.forEach((msg) => {
1432
+ const msgEl = document.createElement('div');
1433
+ msgEl.className = `chat-message ${msg.role}`;
1434
+ const timestamp = msg.timestamp ? new Date(msg.timestamp) : new Date();
1435
+ if (msg.role === 'user') {
1436
+ msgEl.innerHTML = `
1600
1437
  <div class="message-content">${escapeHtml(msg.content)}</div>
1601
1438
  <div class="message-time">${formatMessageTime(timestamp)}</div>
1602
1439
  `;
1603
- } else if (msg.role === 'assistant') {
1604
- msgEl.innerHTML = `
1440
+ }
1441
+ else if (msg.role === 'assistant') {
1442
+ msgEl.innerHTML = `
1605
1443
  <div class="message-content">${formatAssistantMessage(msg.content)}</div>
1606
1444
  <div class="message-time">${formatMessageTime(timestamp)}</div>
1607
1445
  `;
1608
- } else if (msg.role === 'system') {
1609
- msgEl.innerHTML = `
1446
+ }
1447
+ else if (msg.role === 'system') {
1448
+ msgEl.innerHTML = `
1610
1449
  <div class="message-content">${escapeHtml(msg.content)}</div>
1611
1450
  `;
1612
- }
1613
-
1614
- container.appendChild(msgEl);
1615
- });
1616
-
1617
- scrollToBottom(container);
1618
- logger.info('Displayed', messages.length, 'history messages');
1619
- }
1620
-
1621
- /**
1622
- * Clear chat history
1623
- */
1624
- clearHistory(sessionId = null) {
1625
- try {
1626
- const storageKey = this.historyPrefix + (sessionId || this.sessionId);
1627
- localStorage.removeItem(storageKey);
1628
- this.history = [];
1629
- } catch (e) {
1630
- logger.warn('Failed to clear history:', e);
1631
- }
1632
- }
1633
-
1634
- /**
1635
- * Clean up expired histories
1636
- */
1637
- cleanupExpiredHistories() {
1638
- try {
1639
- const keys = Object.keys(localStorage);
1640
- const now = Date.now();
1641
-
1642
- keys.forEach((key) => {
1643
- if (key.startsWith(this.historyPrefix)) {
1644
- try {
1645
- const data = JSON.parse(localStorage.getItem(key));
1646
- if (data && data.savedAt && now - data.savedAt > this.historyExpiryMs) {
1647
- localStorage.removeItem(key);
1648
- logger.info('Cleaned up expired history:', key);
1649
1451
  }
1650
- } catch (e) {
1651
- // Invalid data, remove it
1652
- localStorage.removeItem(key);
1653
- }
1654
- }
1655
- });
1656
- } catch (e) {
1657
- logger.warn('Failed to cleanup histories:', e);
1658
- }
1659
- }
1660
-
1661
- // =============================================
1662
- // Checkpoint Management
1663
- // =============================================
1664
-
1665
- /**
1666
- * Generate checkpoint summary from current session (for manual /checkpoint command)
1667
- */
1668
- generateCheckpointSummary() {
1669
- const summary = {
1670
- sessionId: this.sessionId,
1671
- messageCount: this.history.length,
1672
- lastActivity: new Date().toISOString(),
1673
- messages: this.history.slice(-10).map((msg) => ({
1674
- role: msg.role,
1675
- preview: msg.content.substring(0, 100),
1676
- timestamp: msg.timestamp,
1677
- })),
1678
- };
1679
-
1680
- return JSON.stringify(summary, null, 2);
1681
- }
1682
-
1683
- /**
1684
- * Save checkpoint via API
1685
- */
1686
- async saveCheckpoint(summary) {
1687
- const response = await fetch('/api/checkpoint/save', {
1688
- method: 'POST',
1689
- headers: { 'Content-Type': 'application/json' },
1690
- body: JSON.stringify({ summary }),
1691
- });
1692
-
1693
- if (!response.ok) {
1694
- throw new Error('Failed to save checkpoint');
1695
- }
1696
-
1697
- return await response.json();
1698
- }
1699
-
1700
- /**
1701
- * Load last checkpoint via API
1702
- */
1703
- async loadCheckpoint() {
1704
- const response = await fetch('/api/checkpoint/load');
1705
-
1706
- if (!response.ok) {
1707
- if (response.status === 404) {
1708
- return null; // No checkpoint found
1709
- }
1710
- throw new Error('Failed to load checkpoint');
1711
- }
1712
-
1713
- return await response.json();
1714
- }
1715
-
1716
- /**
1717
- * Check for resumable session on init
1718
- */
1719
- async checkForResumableSession() {
1720
- try {
1721
- const checkpoint = await this.loadCheckpoint();
1722
- if (checkpoint) {
1723
- // Show resume banner
1724
- const banner = document.getElementById('session-resume-banner');
1725
- if (banner) {
1726
- banner.style.display = 'flex';
1727
- logger.info('Resume banner shown');
1728
- }
1729
- }
1730
- } catch (error) {
1731
- // Silent fail - no checkpoint is okay
1732
- logger.info('No resumable session');
1733
- }
1734
- }
1735
-
1736
- // =============================================
1737
- // Floating Chat
1738
- // =============================================
1739
-
1740
- /**
1741
- * Initialize floating chat panel bindings
1742
- */
1743
- initFloating() {
1744
- const bubble = document.getElementById('chat-bubble');
1745
- const closeBtn = document.getElementById('chat-close');
1746
- const resizeHandle = document.getElementById('chat-resize-handle');
1747
- const panel = document.getElementById('chat-panel');
1748
- const header = document.getElementById('chat-header');
1749
-
1750
- if (bubble) {
1751
- bubble.addEventListener('click', () => this.togglePanel());
1452
+ container.appendChild(msgEl);
1453
+ });
1454
+ scrollToBottom(container);
1455
+ logger.info('Displayed', messages.length, 'history messages');
1752
1456
  }
1753
- if (closeBtn) {
1754
- closeBtn.addEventListener('click', () => this.togglePanel(false));
1755
- }
1756
-
1757
- if (panel && header) {
1758
- let dragging = false;
1759
- let startX = 0;
1760
- let startY = 0;
1761
- let startLeft = 0;
1762
- let startTop = 0;
1763
-
1764
- const startDrag = (clientX, clientY) => {
1765
- dragging = true;
1766
- const rect = panel.getBoundingClientRect();
1767
- startX = clientX;
1768
- startY = clientY;
1769
- startLeft = rect.left;
1770
- startTop = rect.top;
1771
- panel.classList.add('chat-panel-draggable');
1772
- document.body.style.userSelect = 'none';
1773
- };
1774
-
1775
- const doDrag = (clientX, clientY) => {
1776
- if (!dragging) {
1777
- return;
1778
- }
1779
- const dx = clientX - startX;
1780
- const dy = clientY - startY;
1781
- const nextLeft = Math.max(8, Math.min(window.innerWidth - 80, startLeft + dx));
1782
- const nextTop = Math.max(8, Math.min(window.innerHeight - 80, startTop + dy));
1783
- panel.style.left = `${nextLeft}px`;
1784
- panel.style.top = `${nextTop}px`;
1785
- };
1786
-
1787
- const endDrag = () => {
1788
- if (!dragging) {
1789
- return;
1790
- }
1791
- dragging = false;
1792
- document.body.style.userSelect = '';
1793
- document.body.classList.remove('no-scroll');
1794
- this.savePanelState(panel);
1795
- };
1796
-
1797
- header.addEventListener('mousedown', (e) => {
1798
- if (e.target.closest('button, a, input, select')) {
1799
- return;
1457
+ /**
1458
+ * Clear chat history
1459
+ */
1460
+ clearHistory(sessionId = null) {
1461
+ try {
1462
+ const storageKey = this.historyPrefix + (sessionId || this.sessionId);
1463
+ localStorage.removeItem(storageKey);
1464
+ this.history = [];
1465
+ }
1466
+ catch (e) {
1467
+ logger.warn('Failed to clear history:', e);
1800
1468
  }
1801
- e.preventDefault();
1802
- startDrag(e.clientX, e.clientY);
1803
- });
1804
-
1805
- this._onDragMouseMove = (e) => doDrag(e.clientX, e.clientY);
1806
- this._onDragMouseUp = endDrag;
1807
- window.addEventListener('mousemove', this._onDragMouseMove);
1808
- window.addEventListener('mouseup', this._onDragMouseUp);
1809
-
1810
- this._onDragTouchMove = (e) => {
1811
- const touch = e.touches[0];
1812
- if (!touch) {
1813
- return;
1814
- }
1815
- if (!dragging) {
1816
- return;
1817
- }
1818
- e.preventDefault();
1819
- doDrag(touch.clientX, touch.clientY);
1820
- };
1821
- this._onDragTouchEnd = endDrag;
1822
-
1823
- header.addEventListener(
1824
- 'touchstart',
1825
- (e) => {
1826
- if (e.target.closest('button, a, input, select')) {
1827
- return;
1828
- }
1829
- const touch = e.touches[0];
1830
- if (!touch) {
1831
- return;
1832
- }
1833
- e.preventDefault();
1834
- startDrag(touch.clientX, touch.clientY);
1835
- document.body.classList.add('no-scroll');
1836
- },
1837
- { passive: false }
1838
- );
1839
- window.addEventListener('touchmove', this._onDragTouchMove, { passive: false });
1840
- window.addEventListener('touchend', this._onDragTouchEnd);
1841
- }
1842
-
1843
- if (resizeHandle && panel) {
1844
- let resizing = false;
1845
- let startX = 0;
1846
- let startY = 0;
1847
- let startW = 0;
1848
- let startH = 0;
1849
-
1850
- const startResize = (clientX, clientY) => {
1851
- resizing = true;
1852
- const rect = panel.getBoundingClientRect();
1853
- startX = clientX;
1854
- startY = clientY;
1855
- startW = rect.width;
1856
- startH = rect.height;
1857
- document.body.style.userSelect = 'none';
1858
- };
1859
-
1860
- const doResize = (clientX, clientY) => {
1861
- if (!resizing) {
1862
- return;
1863
- }
1864
- const dx = clientX - startX;
1865
- const dy = clientY - startY;
1866
- const minW = 280;
1867
- const minH = 320;
1868
- const maxW = Math.min(window.innerWidth * 0.96, 800);
1869
- const maxH = Math.min(window.innerHeight * 0.85, 900);
1870
- const nextW = Math.max(minW, Math.min(maxW, startW + dx));
1871
- const nextH = Math.max(minH, Math.min(maxH, startH + dy));
1872
- panel.style.width = `${nextW}px`;
1873
- panel.style.height = `${nextH}px`;
1874
- };
1875
-
1876
- const endResize = () => {
1877
- if (!resizing) {
1878
- return;
1879
- }
1880
- resizing = false;
1881
- document.body.style.userSelect = '';
1882
- document.body.classList.remove('no-scroll');
1883
- this.savePanelState(panel);
1884
- };
1885
-
1886
- resizeHandle.addEventListener('mousedown', (e) => {
1887
- e.preventDefault();
1888
- startResize(e.clientX, e.clientY);
1889
- });
1890
-
1891
- this._onResizeMouseMove = (e) => doResize(e.clientX, e.clientY);
1892
- this._onResizeMouseUp = endResize;
1893
- window.addEventListener('mousemove', this._onResizeMouseMove);
1894
- window.addEventListener('mouseup', this._onResizeMouseUp);
1895
-
1896
- this._onResizeTouchMove = (e) => {
1897
- const touch = e.touches[0];
1898
- if (!touch) {
1899
- return;
1900
- }
1901
- if (!resizing) {
1902
- return;
1903
- }
1904
- e.preventDefault();
1905
- doResize(touch.clientX, touch.clientY);
1906
- };
1907
- this._onResizeTouchEnd = endResize;
1908
-
1909
- resizeHandle.addEventListener(
1910
- 'touchstart',
1911
- (e) => {
1912
- const touch = e.touches[0];
1913
- if (!touch) {
1914
- return;
1915
- }
1916
- e.preventDefault();
1917
- startResize(touch.clientX, touch.clientY);
1918
- document.body.classList.add('no-scroll');
1919
- },
1920
- { passive: false }
1921
- );
1922
- window.addEventListener('touchmove', this._onResizeTouchMove, { passive: false });
1923
- window.addEventListener('touchend', this._onResizeTouchEnd);
1924
- }
1925
-
1926
- this._onEscapeKey = (e) => {
1927
- if (e.key === 'Escape' && this.isFloatingOpen()) {
1928
- this.togglePanel(false);
1929
- }
1930
- };
1931
- document.addEventListener('keydown', this._onEscapeKey);
1932
-
1933
- logger.info('Floating mode initialized');
1934
- }
1935
-
1936
- /**
1937
- * Toggle floating chat panel open/close
1938
- * @param {boolean} [forceState] - Force open (true) or close (false)
1939
- */
1940
- togglePanel(forceState) {
1941
- const panel = document.getElementById('chat-panel');
1942
- const bubble = document.getElementById('chat-bubble');
1943
- const badge = document.getElementById('chat-badge');
1944
- if (!panel) {
1945
- return;
1946
- }
1947
-
1948
- const shouldOpen = forceState !== undefined ? forceState : panel.classList.contains('hidden');
1949
-
1950
- if (shouldOpen) {
1951
- panel.classList.remove('hidden');
1952
- panel.classList.add('animate-slide-up');
1953
- this.restorePanelState(panel);
1954
- if (bubble) {
1955
- bubble.classList.add('scale-0');
1956
- }
1957
- if (badge) {
1958
- badge.classList.add('hidden');
1959
- }
1960
- const input = document.getElementById('chat-input');
1961
- if (input) {
1962
- setTimeout(() => input.focus(), 100);
1963
- }
1964
- const messages = document.getElementById('chat-messages');
1965
- if (messages) {
1966
- messages.scrollTop = messages.scrollHeight;
1967
- }
1968
- } else {
1969
- panel.classList.add('hidden');
1970
- panel.classList.remove('animate-slide-up');
1971
- if (bubble) {
1972
- bubble.classList.remove('scale-0');
1973
- }
1974
- }
1975
- }
1976
-
1977
- /**
1978
- * Persist panel size + position
1979
- */
1980
- savePanelState(panel) {
1981
- try {
1982
- const rect = panel.getBoundingClientRect();
1983
- const state = {
1984
- width: rect.width,
1985
- height: rect.height,
1986
- left: rect.left,
1987
- top: rect.top,
1988
- };
1989
- localStorage.setItem('mama_chat_panel_state', JSON.stringify(state));
1990
- } catch {
1991
- // ignore storage errors
1992
- }
1993
- }
1994
-
1995
- /**
1996
- * Restore panel size + position
1997
- */
1998
- restorePanelState(panel) {
1999
- try {
2000
- const raw = localStorage.getItem('mama_chat_panel_state');
2001
- if (!raw) {
2002
- return;
2003
- }
2004
- const state = JSON.parse(raw);
2005
- if (state.width) {
2006
- panel.style.width = `${state.width}px`;
2007
- }
2008
- if (state.height) {
2009
- panel.style.height = `${state.height}px`;
2010
- }
2011
- if (state.left !== undefined && state.top !== undefined) {
2012
- panel.classList.add('chat-panel-draggable');
2013
- panel.style.left = `${state.left}px`;
2014
- panel.style.top = `${state.top}px`;
2015
- }
2016
- } catch {
2017
- // ignore storage errors
2018
- }
2019
- }
2020
-
2021
- /**
2022
- * Check if floating panel is open
2023
- */
2024
- isFloatingOpen() {
2025
- const panel = document.getElementById('chat-panel');
2026
- return panel && !panel.classList.contains('hidden');
2027
- }
2028
-
2029
- /**
2030
- * Show unread badge on bubble when panel is closed
2031
- */
2032
- showUnreadBadge() {
2033
- if (this.isFloatingOpen()) {
2034
- return;
2035
- }
2036
- const badge = document.getElementById('chat-badge');
2037
- if (badge) {
2038
- badge.classList.remove('hidden');
2039
- }
2040
- }
2041
-
2042
- /**
2043
- * Cleanup resources when module is destroyed
2044
- * Prevents memory leaks by cleaning up timers, connections, and APIs
2045
- */
2046
- cleanup() {
2047
- // Clean up WebSocket
2048
- if (this.ws) {
2049
- this.ws.close();
2050
- this.ws = null;
2051
- }
2052
-
2053
- // Clean up timers
2054
- if (this.silenceTimeout) {
2055
- clearTimeout(this.silenceTimeout);
2056
- this.silenceTimeout = null;
2057
1469
  }
2058
- if (this.idleTimer) {
2059
- clearTimeout(this.idleTimer);
2060
- this.idleTimer = null;
1470
+ /**
1471
+ * Clean up expired histories
1472
+ */
1473
+ cleanupExpiredHistories() {
1474
+ try {
1475
+ const keys = Object.keys(localStorage);
1476
+ const now = Date.now();
1477
+ keys.forEach((key) => {
1478
+ if (key.startsWith(this.historyPrefix)) {
1479
+ try {
1480
+ const data = JSON.parse(localStorage.getItem(key));
1481
+ if (data && data.savedAt && now - data.savedAt > this.historyExpiryMs) {
1482
+ localStorage.removeItem(key);
1483
+ logger.info('Cleaned up expired history:', key);
1484
+ }
1485
+ }
1486
+ catch {
1487
+ // Invalid data, remove it
1488
+ localStorage.removeItem(key);
1489
+ }
1490
+ }
1491
+ });
1492
+ }
1493
+ catch (e) {
1494
+ logger.warn('Failed to cleanup histories:', e);
1495
+ }
2061
1496
  }
2062
-
2063
- // Clean up Speech Recognition
2064
- if (this.speechRecognition) {
2065
- this.speechRecognition.stop();
2066
- this.speechRecognition = null;
1497
+ // =============================================
1498
+ // Checkpoint Management
1499
+ // =============================================
1500
+ /**
1501
+ * Generate checkpoint summary from current session (for manual /checkpoint command)
1502
+ */
1503
+ generateCheckpointSummary() {
1504
+ const summary = {
1505
+ sessionId: this.sessionId,
1506
+ messageCount: this.history.length,
1507
+ lastActivity: new Date().toISOString(),
1508
+ messages: this.history.slice(-10).map((msg) => ({
1509
+ role: msg.role,
1510
+ preview: msg.content.substring(0, 100),
1511
+ timestamp: msg.timestamp,
1512
+ })),
1513
+ };
1514
+ return JSON.stringify(summary, null, 2);
2067
1515
  }
2068
-
2069
- // Clean up Speech Synthesis
2070
- if (this.isSpeaking) {
2071
- this.speechSynthesis.cancel();
2072
- this.isSpeaking = false;
1516
+ /**
1517
+ * Save checkpoint via API
1518
+ */
1519
+ async saveCheckpoint(summary) {
1520
+ return await API.post('/api/checkpoint/save', { summary });
2073
1521
  }
2074
-
2075
- // Clean up window/document event listeners
2076
- if (this._onDragMouseMove) {
2077
- window.removeEventListener('mousemove', this._onDragMouseMove);
2078
- this._onDragMouseMove = null;
1522
+ /**
1523
+ * Load last checkpoint via API
1524
+ */
1525
+ async loadCheckpoint() {
1526
+ try {
1527
+ return await API.get('/api/checkpoint/load');
1528
+ }
1529
+ catch (error) {
1530
+ if (error instanceof Error && error.message.includes('HTTP 404')) {
1531
+ return null;
1532
+ }
1533
+ throw error;
1534
+ }
2079
1535
  }
2080
- if (this._onDragMouseUp) {
2081
- window.removeEventListener('mouseup', this._onDragMouseUp);
2082
- this._onDragMouseUp = null;
1536
+ /**
1537
+ * Check for resumable session on init
1538
+ */
1539
+ async checkForResumableSession() {
1540
+ try {
1541
+ const checkpoint = await this.loadCheckpoint();
1542
+ if (checkpoint) {
1543
+ // Show resume banner
1544
+ const banner = getElementByIdOrNull('session-resume-banner');
1545
+ if (banner) {
1546
+ banner.style.display = 'flex';
1547
+ logger.info('Resume banner shown');
1548
+ }
1549
+ }
1550
+ }
1551
+ catch {
1552
+ // Silent fail - no checkpoint is okay
1553
+ logger.info('No resumable session');
1554
+ }
2083
1555
  }
2084
- if (this._onDragTouchMove) {
2085
- window.removeEventListener('touchmove', this._onDragTouchMove);
2086
- this._onDragTouchMove = null;
1556
+ // =============================================
1557
+ // Floating Chat
1558
+ // =============================================
1559
+ /**
1560
+ * Initialize floating chat panel bindings
1561
+ */
1562
+ initFloating() {
1563
+ const bubble = getElementByIdOrNull('chat-bubble');
1564
+ const closeBtn = getElementByIdOrNull('chat-close');
1565
+ const resizeHandle = getElementByIdOrNull('chat-resize-handle');
1566
+ const panel = getElementByIdOrNull('chat-panel');
1567
+ const header = getElementByIdOrNull('chat-header');
1568
+ if (bubble) {
1569
+ bubble.addEventListener('click', () => this.togglePanel());
1570
+ }
1571
+ if (closeBtn) {
1572
+ closeBtn.addEventListener('click', () => this.togglePanel(false));
1573
+ }
1574
+ if (panel && header) {
1575
+ let dragging = false;
1576
+ let startX = 0;
1577
+ let startY = 0;
1578
+ let startLeft = 0;
1579
+ let startTop = 0;
1580
+ const startDrag = (clientX, clientY) => {
1581
+ dragging = true;
1582
+ const rect = panel.getBoundingClientRect();
1583
+ startX = clientX;
1584
+ startY = clientY;
1585
+ startLeft = rect.left;
1586
+ startTop = rect.top;
1587
+ panel.classList.add('chat-panel-draggable');
1588
+ document.body.style.userSelect = 'none';
1589
+ };
1590
+ const doDrag = (clientX, clientY) => {
1591
+ if (!dragging) {
1592
+ return;
1593
+ }
1594
+ const dx = clientX - startX;
1595
+ const dy = clientY - startY;
1596
+ const nextLeft = Math.max(8, Math.min(window.innerWidth - 80, startLeft + dx));
1597
+ const nextTop = Math.max(8, Math.min(window.innerHeight - 80, startTop + dy));
1598
+ panel.style.left = `${nextLeft}px`;
1599
+ panel.style.top = `${nextTop}px`;
1600
+ };
1601
+ const endDrag = () => {
1602
+ if (!dragging) {
1603
+ return;
1604
+ }
1605
+ dragging = false;
1606
+ document.body.style.userSelect = '';
1607
+ document.body.classList.remove('no-scroll');
1608
+ this.savePanelState(panel);
1609
+ };
1610
+ header.addEventListener('mousedown', (e) => {
1611
+ const target = e.target;
1612
+ if (target?.closest('button, a, input, select')) {
1613
+ return;
1614
+ }
1615
+ e.preventDefault();
1616
+ startDrag(e.clientX, e.clientY);
1617
+ });
1618
+ this._onDragMouseMove = (e) => doDrag(e.clientX, e.clientY);
1619
+ this._onDragMouseUp = endDrag;
1620
+ window.addEventListener('mousemove', this._onDragMouseMove);
1621
+ window.addEventListener('mouseup', this._onDragMouseUp);
1622
+ this._onDragTouchMove = (e) => {
1623
+ const touch = e.touches[0];
1624
+ if (!touch) {
1625
+ return;
1626
+ }
1627
+ if (!dragging) {
1628
+ return;
1629
+ }
1630
+ e.preventDefault();
1631
+ doDrag(touch.clientX, touch.clientY);
1632
+ };
1633
+ this._onDragTouchEnd = endDrag;
1634
+ header.addEventListener('touchstart', (e) => {
1635
+ const target = e.target;
1636
+ if (target?.closest('button, a, input, select')) {
1637
+ return;
1638
+ }
1639
+ const touch = e.touches[0];
1640
+ if (!touch) {
1641
+ return;
1642
+ }
1643
+ e.preventDefault();
1644
+ startDrag(touch.clientX, touch.clientY);
1645
+ document.body.classList.add('no-scroll');
1646
+ }, { passive: false });
1647
+ window.addEventListener('touchmove', this._onDragTouchMove, { passive: false });
1648
+ window.addEventListener('touchend', this._onDragTouchEnd);
1649
+ }
1650
+ if (resizeHandle && panel) {
1651
+ let resizing = false;
1652
+ let startX = 0;
1653
+ let startY = 0;
1654
+ let startW = 0;
1655
+ let startH = 0;
1656
+ const startResize = (clientX, clientY) => {
1657
+ resizing = true;
1658
+ const rect = panel.getBoundingClientRect();
1659
+ startX = clientX;
1660
+ startY = clientY;
1661
+ startW = rect.width;
1662
+ startH = rect.height;
1663
+ document.body.style.userSelect = 'none';
1664
+ };
1665
+ const doResize = (clientX, clientY) => {
1666
+ if (!resizing) {
1667
+ return;
1668
+ }
1669
+ const dx = clientX - startX;
1670
+ const dy = clientY - startY;
1671
+ const minW = 280;
1672
+ const minH = 320;
1673
+ const maxW = Math.min(window.innerWidth * 0.96, 800);
1674
+ const maxH = Math.min(window.innerHeight * 0.85, 900);
1675
+ const nextW = Math.max(minW, Math.min(maxW, startW + dx));
1676
+ const nextH = Math.max(minH, Math.min(maxH, startH + dy));
1677
+ panel.style.width = `${nextW}px`;
1678
+ panel.style.height = `${nextH}px`;
1679
+ };
1680
+ const endResize = () => {
1681
+ if (!resizing) {
1682
+ return;
1683
+ }
1684
+ resizing = false;
1685
+ document.body.style.userSelect = '';
1686
+ document.body.classList.remove('no-scroll');
1687
+ this.savePanelState(panel);
1688
+ };
1689
+ resizeHandle.addEventListener('mousedown', (e) => {
1690
+ e.preventDefault();
1691
+ startResize(e.clientX, e.clientY);
1692
+ });
1693
+ this._onResizeMouseMove = (e) => doResize(e.clientX, e.clientY);
1694
+ this._onResizeMouseUp = endResize;
1695
+ window.addEventListener('mousemove', this._onResizeMouseMove);
1696
+ window.addEventListener('mouseup', this._onResizeMouseUp);
1697
+ this._onResizeTouchMove = (e) => {
1698
+ const touch = e.touches[0];
1699
+ if (!touch) {
1700
+ return;
1701
+ }
1702
+ if (!resizing) {
1703
+ return;
1704
+ }
1705
+ e.preventDefault();
1706
+ doResize(touch.clientX, touch.clientY);
1707
+ };
1708
+ this._onResizeTouchEnd = endResize;
1709
+ resizeHandle.addEventListener('touchstart', (e) => {
1710
+ const touch = e.touches[0];
1711
+ if (!touch) {
1712
+ return;
1713
+ }
1714
+ e.preventDefault();
1715
+ startResize(touch.clientX, touch.clientY);
1716
+ document.body.classList.add('no-scroll');
1717
+ }, { passive: false });
1718
+ window.addEventListener('touchmove', this._onResizeTouchMove, { passive: false });
1719
+ window.addEventListener('touchend', this._onResizeTouchEnd);
1720
+ }
1721
+ this._onEscapeKey = (e) => {
1722
+ if (e.key === 'Escape' && this.isFloatingOpen()) {
1723
+ this.togglePanel(false);
1724
+ }
1725
+ };
1726
+ document.addEventListener('keydown', this._onEscapeKey);
1727
+ logger.info('Floating mode initialized');
2087
1728
  }
2088
- if (this._onDragTouchEnd) {
2089
- window.removeEventListener('touchend', this._onDragTouchEnd);
2090
- this._onDragTouchEnd = null;
1729
+ /**
1730
+ * Toggle floating chat panel open/close
1731
+ * @param {boolean} [forceState] - Force open (true) or close (false)
1732
+ */
1733
+ togglePanel(forceState) {
1734
+ const panel = getElementByIdOrNull('chat-panel');
1735
+ const bubble = getElementByIdOrNull('chat-bubble');
1736
+ const badge = getElementByIdOrNull('chat-badge');
1737
+ if (!panel) {
1738
+ return;
1739
+ }
1740
+ const shouldOpen = forceState !== undefined ? forceState : panel.classList.contains('hidden');
1741
+ if (shouldOpen) {
1742
+ panel.classList.remove('hidden');
1743
+ panel.classList.add('animate-slide-up');
1744
+ this.restorePanelState(panel);
1745
+ if (bubble) {
1746
+ bubble.classList.add('scale-0');
1747
+ }
1748
+ if (badge) {
1749
+ badge.classList.add('hidden');
1750
+ }
1751
+ const input = getElementByIdOrNull('chat-input');
1752
+ if (input) {
1753
+ setTimeout(() => input.focus(), 100);
1754
+ }
1755
+ const messages = getElementByIdOrNull('chat-messages');
1756
+ if (messages) {
1757
+ messages.scrollTop = messages.scrollHeight;
1758
+ }
1759
+ }
1760
+ else {
1761
+ panel.classList.add('hidden');
1762
+ panel.classList.remove('animate-slide-up');
1763
+ if (bubble) {
1764
+ bubble.classList.remove('scale-0');
1765
+ }
1766
+ }
2091
1767
  }
2092
- if (this._onResizeMouseMove) {
2093
- window.removeEventListener('mousemove', this._onResizeMouseMove);
2094
- this._onResizeMouseMove = null;
1768
+ /**
1769
+ * Persist panel size + position
1770
+ */
1771
+ savePanelState(panel) {
1772
+ try {
1773
+ const rect = panel.getBoundingClientRect();
1774
+ const state = {
1775
+ width: rect.width,
1776
+ height: rect.height,
1777
+ left: rect.left,
1778
+ top: rect.top,
1779
+ };
1780
+ localStorage.setItem('mama_chat_panel_state', JSON.stringify(state));
1781
+ }
1782
+ catch {
1783
+ // ignore storage errors
1784
+ }
2095
1785
  }
2096
- if (this._onResizeMouseUp) {
2097
- window.removeEventListener('mouseup', this._onResizeMouseUp);
2098
- this._onResizeMouseUp = null;
1786
+ /**
1787
+ * Restore panel size + position
1788
+ */
1789
+ restorePanelState(panel) {
1790
+ try {
1791
+ const raw = localStorage.getItem('mama_chat_panel_state');
1792
+ if (!raw) {
1793
+ return;
1794
+ }
1795
+ const state = JSON.parse(raw);
1796
+ if (state.width) {
1797
+ panel.style.width = `${state.width}px`;
1798
+ }
1799
+ if (state.height) {
1800
+ panel.style.height = `${state.height}px`;
1801
+ }
1802
+ if (state.left !== undefined && state.top !== undefined) {
1803
+ panel.classList.add('chat-panel-draggable');
1804
+ panel.style.left = `${state.left}px`;
1805
+ panel.style.top = `${state.top}px`;
1806
+ }
1807
+ }
1808
+ catch {
1809
+ // ignore storage errors
1810
+ }
2099
1811
  }
2100
- if (this._onResizeTouchMove) {
2101
- window.removeEventListener('touchmove', this._onResizeTouchMove);
2102
- this._onResizeTouchMove = null;
1812
+ /**
1813
+ * Check if floating panel is open
1814
+ */
1815
+ isFloatingOpen() {
1816
+ const panel = getElementByIdOrNull('chat-panel');
1817
+ return Boolean(panel && !panel.classList.contains('hidden'));
2103
1818
  }
2104
- if (this._onResizeTouchEnd) {
2105
- window.removeEventListener('touchend', this._onResizeTouchEnd);
2106
- this._onResizeTouchEnd = null;
1819
+ /**
1820
+ * Show unread badge on bubble when panel is closed
1821
+ */
1822
+ showUnreadBadge() {
1823
+ if (this.isFloatingOpen()) {
1824
+ return;
1825
+ }
1826
+ const badge = getElementByIdOrNull('chat-badge');
1827
+ if (badge) {
1828
+ badge.classList.remove('hidden');
1829
+ }
2107
1830
  }
2108
- if (this._onEscapeKey) {
2109
- document.removeEventListener('keydown', this._onEscapeKey);
2110
- this._onEscapeKey = null;
1831
+ /**
1832
+ * Cleanup resources when module is destroyed
1833
+ * Prevents memory leaks by cleaning up timers, connections, and APIs
1834
+ */
1835
+ cleanup() {
1836
+ // Clean up WebSocket
1837
+ if (this.ws) {
1838
+ this.ws.close();
1839
+ this.ws = null;
1840
+ }
1841
+ // Clean up timers
1842
+ if (this.silenceTimeout) {
1843
+ clearTimeout(this.silenceTimeout);
1844
+ this.silenceTimeout = null;
1845
+ }
1846
+ if (this.idleTimer) {
1847
+ clearTimeout(this.idleTimer);
1848
+ this.idleTimer = null;
1849
+ }
1850
+ // Clean up Speech Recognition
1851
+ if (this.speechRecognition) {
1852
+ this.speechRecognition.stop();
1853
+ this.speechRecognition = null;
1854
+ }
1855
+ // Clean up Speech Synthesis
1856
+ if (this.isSpeaking) {
1857
+ this.speechSynthesis.cancel();
1858
+ this.isSpeaking = false;
1859
+ }
1860
+ // Clean up window/document event listeners
1861
+ if (this._onDragMouseMove) {
1862
+ window.removeEventListener('mousemove', this._onDragMouseMove);
1863
+ this._onDragMouseMove = null;
1864
+ }
1865
+ if (this._onDragMouseUp) {
1866
+ window.removeEventListener('mouseup', this._onDragMouseUp);
1867
+ this._onDragMouseUp = null;
1868
+ }
1869
+ if (this._onDragTouchMove) {
1870
+ window.removeEventListener('touchmove', this._onDragTouchMove);
1871
+ this._onDragTouchMove = null;
1872
+ }
1873
+ if (this._onDragTouchEnd) {
1874
+ window.removeEventListener('touchend', this._onDragTouchEnd);
1875
+ this._onDragTouchEnd = null;
1876
+ }
1877
+ if (this._onResizeMouseMove) {
1878
+ window.removeEventListener('mousemove', this._onResizeMouseMove);
1879
+ this._onResizeMouseMove = null;
1880
+ }
1881
+ if (this._onResizeMouseUp) {
1882
+ window.removeEventListener('mouseup', this._onResizeMouseUp);
1883
+ this._onResizeMouseUp = null;
1884
+ }
1885
+ if (this._onResizeTouchMove) {
1886
+ window.removeEventListener('touchmove', this._onResizeTouchMove);
1887
+ this._onResizeTouchMove = null;
1888
+ }
1889
+ if (this._onResizeTouchEnd) {
1890
+ window.removeEventListener('touchend', this._onResizeTouchEnd);
1891
+ this._onResizeTouchEnd = null;
1892
+ }
1893
+ if (this._onEscapeKey) {
1894
+ document.removeEventListener('keydown', this._onEscapeKey);
1895
+ this._onEscapeKey = null;
1896
+ }
1897
+ logger.info('Cleanup completed');
2111
1898
  }
2112
-
2113
- logger.info('Cleanup completed');
2114
- }
2115
1899
  }