@jungjaehoon/mama-os 0.32.3 → 0.34.0

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