@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.
- package/CHANGELOG.md +10 -0
- package/dist/agent/agent-loop.d.ts +1 -8
- package/dist/agent/agent-loop.d.ts.map +1 -1
- package/dist/agent/agent-loop.js +44 -159
- package/dist/agent/agent-loop.js.map +1 -1
- package/dist/agent/claude-cli-wrapper.d.ts +6 -0
- package/dist/agent/claude-cli-wrapper.d.ts.map +1 -1
- package/dist/agent/claude-cli-wrapper.js +6 -0
- package/dist/agent/claude-cli-wrapper.js.map +1 -1
- package/dist/agent/codex-mcp-process.d.ts +85 -0
- package/dist/agent/codex-mcp-process.d.ts.map +1 -0
- package/dist/agent/codex-mcp-process.js +357 -0
- package/dist/agent/codex-mcp-process.js.map +1 -0
- package/dist/agent/session-pool.d.ts +17 -2
- package/dist/agent/session-pool.d.ts.map +1 -1
- package/dist/agent/session-pool.js +51 -26
- package/dist/agent/session-pool.js.map +1 -1
- package/dist/agent/types.d.ts +9 -24
- package/dist/agent/types.d.ts.map +1 -1
- package/dist/agent/types.js.map +1 -1
- package/dist/api/graph-api.d.ts.map +1 -1
- package/dist/api/graph-api.js +133 -45
- package/dist/api/graph-api.js.map +1 -1
- package/dist/cli/commands/init.d.ts +1 -1
- package/dist/cli/commands/init.d.ts.map +1 -1
- package/dist/cli/commands/init.js +14 -25
- package/dist/cli/commands/init.js.map +1 -1
- package/dist/cli/commands/run.d.ts.map +1 -1
- package/dist/cli/commands/run.js +3 -10
- package/dist/cli/commands/run.js.map +1 -1
- package/dist/cli/commands/start.d.ts.map +1 -1
- package/dist/cli/commands/start.js +143 -54
- package/dist/cli/commands/start.js.map +1 -1
- package/dist/cli/commands/status.d.ts.map +1 -1
- package/dist/cli/commands/status.js +2 -7
- package/dist/cli/commands/status.js.map +1 -1
- package/dist/cli/config/config-manager.d.ts.map +1 -1
- package/dist/cli/config/config-manager.js +9 -17
- package/dist/cli/config/config-manager.js.map +1 -1
- package/dist/cli/config/types.d.ts +19 -25
- package/dist/cli/config/types.d.ts.map +1 -1
- package/dist/cli/config/types.js.map +1 -1
- package/dist/cli/index.js +2 -2
- package/dist/cli/index.js.map +1 -1
- package/dist/gateways/context-injector.d.ts.map +1 -1
- package/dist/gateways/context-injector.js +6 -3
- package/dist/gateways/context-injector.js.map +1 -1
- package/dist/gateways/discord.d.ts +4 -0
- package/dist/gateways/discord.d.ts.map +1 -1
- package/dist/gateways/discord.js +39 -16
- package/dist/gateways/discord.js.map +1 -1
- package/dist/gateways/message-router.d.ts +6 -1
- package/dist/gateways/message-router.d.ts.map +1 -1
- package/dist/gateways/message-router.js +92 -7
- package/dist/gateways/message-router.js.map +1 -1
- package/dist/multi-agent/agent-process-manager.d.ts.map +1 -1
- package/dist/multi-agent/agent-process-manager.js +36 -9
- package/dist/multi-agent/agent-process-manager.js.map +1 -1
- package/dist/multi-agent/runtime-process.d.ts +4 -4
- package/dist/multi-agent/runtime-process.d.ts.map +1 -1
- package/dist/multi-agent/runtime-process.js +9 -20
- package/dist/multi-agent/runtime-process.js.map +1 -1
- package/dist/multi-agent/types.d.ts +13 -8
- package/dist/multi-agent/types.d.ts.map +1 -1
- package/dist/multi-agent/types.js.map +1 -1
- package/dist/setup/setup-prompt.d.ts +1 -1
- package/dist/setup/setup-prompt.d.ts.map +1 -1
- package/dist/setup/setup-prompt.js +19 -0
- package/dist/setup/setup-prompt.js.map +1 -1
- package/dist/setup/setup-server.d.ts.map +1 -1
- package/dist/setup/setup-server.js +39 -16
- package/dist/setup/setup-server.js.map +1 -1
- package/dist/skills/skill-registry.d.ts.map +1 -1
- package/dist/skills/skill-registry.js +5 -2
- package/dist/skills/skill-registry.js.map +1 -1
- package/package.json +5 -3
- package/public/setup.html +12 -1
- package/public/viewer/js/modules/chat.js +1760 -1976
- package/public/viewer/js/modules/dashboard.js +613 -695
- package/public/viewer/js/modules/graph.js +857 -970
- package/public/viewer/js/modules/memory.js +357 -312
- package/public/viewer/js/modules/settings.js +1009 -1026
- package/public/viewer/js/modules/skills.js +336 -355
- package/public/viewer/js/utils/api.js +255 -255
- package/public/viewer/js/utils/debug-logger.js +20 -26
- package/public/viewer/js/utils/dom.js +73 -60
- package/public/viewer/js/utils/format.js +182 -228
- package/public/viewer/js/utils/markdown.js +40 -0
- package/public/viewer/src/modules/chat.ts +2258 -0
- package/public/viewer/src/modules/dashboard.ts +1052 -0
- package/public/viewer/src/modules/graph.ts +1080 -0
- package/public/viewer/src/modules/memory.ts +453 -0
- package/public/viewer/src/modules/settings.ts +1398 -0
- package/public/viewer/src/modules/skills.ts +457 -0
- package/public/viewer/src/types/global.d.ts +168 -0
- package/public/viewer/src/utils/api.ts +650 -0
- package/public/viewer/src/utils/debug-logger.ts +36 -0
- package/public/viewer/src/utils/dom.ts +138 -0
- package/public/viewer/src/utils/format.ts +331 -0
- package/public/viewer/src/utils/markdown.ts +46 -0
- package/public/viewer/tsconfig.viewer.json +18 -0
- package/public/viewer/viewer.html +214 -311
- package/dist/agent/codex-cli-wrapper.d.ts +0 -85
- package/dist/agent/codex-cli-wrapper.d.ts.map +0 -1
- package/dist/agent/codex-cli-wrapper.js +0 -295
- 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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
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
|
-
|
|
352
|
-
|
|
353
|
-
|
|
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
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
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
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
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
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
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
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
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
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
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
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
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
|
-
|
|
496
|
-
|
|
497
|
-
|
|
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
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
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
|
-
|
|
516
|
-
|
|
517
|
-
|
|
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
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
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
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
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
|
-
|
|
598
|
-
|
|
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
|
-
|
|
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
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
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
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
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
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
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
|
-
|
|
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
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
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
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
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
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
if (
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
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
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
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
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
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
|
-
//
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
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
|
-
|
|
865
|
-
|
|
866
|
-
|
|
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
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
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
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
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
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
text
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
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
|
-
*
|
|
823
|
+
* Enable/disable chat input
|
|
1055
824
|
*/
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
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
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
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
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
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
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
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
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
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
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
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
|
-
//
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
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
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
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
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
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
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
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
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
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
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
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
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
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
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
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
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
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
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
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
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
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
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1555
|
-
|
|
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
|
-
|
|
1560
|
-
|
|
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
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
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
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
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
|
-
|
|
1604
|
-
|
|
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
|
-
|
|
1609
|
-
|
|
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
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
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
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
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
|
-
|
|
2059
|
-
|
|
2060
|
-
|
|
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
|
-
//
|
|
2064
|
-
|
|
2065
|
-
|
|
2066
|
-
|
|
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
|
-
|
|
2070
|
-
|
|
2071
|
-
|
|
2072
|
-
|
|
1516
|
+
/**
|
|
1517
|
+
* Save checkpoint via API
|
|
1518
|
+
*/
|
|
1519
|
+
async saveCheckpoint(summary) {
|
|
1520
|
+
return await API.post('/api/checkpoint/save', { summary });
|
|
2073
1521
|
}
|
|
2074
|
-
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
|
|
2078
|
-
|
|
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
|
-
|
|
2081
|
-
|
|
2082
|
-
|
|
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
|
-
|
|
2085
|
-
|
|
2086
|
-
|
|
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
|
-
|
|
2089
|
-
|
|
2090
|
-
|
|
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
|
-
|
|
2093
|
-
|
|
2094
|
-
|
|
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
|
-
|
|
2097
|
-
|
|
2098
|
-
|
|
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
|
-
|
|
2101
|
-
|
|
2102
|
-
|
|
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
|
-
|
|
2105
|
-
|
|
2106
|
-
|
|
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
|
-
|
|
2109
|
-
|
|
2110
|
-
|
|
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
|
}
|