@jungjaehoon/mama-os 0.1.1
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 +67 -0
- package/README.md +643 -0
- package/dist/agent/agent-loop.d.ts +98 -0
- package/dist/agent/agent-loop.d.ts.map +1 -0
- package/dist/agent/agent-loop.js +417 -0
- package/dist/agent/agent-loop.js.map +1 -0
- package/dist/agent/auto-recall.d.ts +48 -0
- package/dist/agent/auto-recall.d.ts.map +1 -0
- package/dist/agent/auto-recall.js +178 -0
- package/dist/agent/auto-recall.js.map +1 -0
- package/dist/agent/claude-cli-wrapper.d.ts +130 -0
- package/dist/agent/claude-cli-wrapper.d.ts.map +1 -0
- package/dist/agent/claude-cli-wrapper.js +227 -0
- package/dist/agent/claude-cli-wrapper.js.map +1 -0
- package/dist/agent/claude-client.d.ts +50 -0
- package/dist/agent/claude-client.d.ts.map +1 -0
- package/dist/agent/claude-client.js +214 -0
- package/dist/agent/claude-client.js.map +1 -0
- package/dist/agent/gateway-tool-executor.d.ts +75 -0
- package/dist/agent/gateway-tool-executor.d.ts.map +1 -0
- package/dist/agent/gateway-tool-executor.js +348 -0
- package/dist/agent/gateway-tool-executor.js.map +1 -0
- package/dist/agent/index.d.ts +13 -0
- package/dist/agent/index.d.ts.map +1 -0
- package/dist/agent/index.js +18 -0
- package/dist/agent/index.js.map +1 -0
- package/dist/agent/mcp-executor.d.ts +75 -0
- package/dist/agent/mcp-executor.d.ts.map +1 -0
- package/dist/agent/mcp-executor.js +307 -0
- package/dist/agent/mcp-executor.js.map +1 -0
- package/dist/agent/session-pool.d.ts +148 -0
- package/dist/agent/session-pool.d.ts.map +1 -0
- package/dist/agent/session-pool.js +272 -0
- package/dist/agent/session-pool.js.map +1 -0
- package/dist/agent/streaming-callback-manager.d.ts +85 -0
- package/dist/agent/streaming-callback-manager.d.ts.map +1 -0
- package/dist/agent/streaming-callback-manager.js +103 -0
- package/dist/agent/streaming-callback-manager.js.map +1 -0
- package/dist/agent/types.d.ts +437 -0
- package/dist/agent/types.d.ts.map +1 -0
- package/dist/agent/types.js +29 -0
- package/dist/agent/types.js.map +1 -0
- package/dist/api/cron-handler.d.ts +44 -0
- package/dist/api/cron-handler.d.ts.map +1 -0
- package/dist/api/cron-handler.js +195 -0
- package/dist/api/cron-handler.js.map +1 -0
- package/dist/api/error-handler.d.ts +22 -0
- package/dist/api/error-handler.d.ts.map +1 -0
- package/dist/api/error-handler.js +104 -0
- package/dist/api/error-handler.js.map +1 -0
- package/dist/api/heartbeat-handler.d.ts +49 -0
- package/dist/api/heartbeat-handler.d.ts.map +1 -0
- package/dist/api/heartbeat-handler.js +91 -0
- package/dist/api/heartbeat-handler.js.map +1 -0
- package/dist/api/index.d.ts +61 -0
- package/dist/api/index.d.ts.map +1 -0
- package/dist/api/index.js +145 -0
- package/dist/api/index.js.map +1 -0
- package/dist/api/types.d.ts +156 -0
- package/dist/api/types.d.ts.map +1 -0
- package/dist/api/types.js +62 -0
- package/dist/api/types.js.map +1 -0
- package/dist/auth/index.d.ts +7 -0
- package/dist/auth/index.d.ts.map +1 -0
- package/dist/auth/index.js +11 -0
- package/dist/auth/index.js.map +1 -0
- package/dist/auth/oauth-manager.d.ts +59 -0
- package/dist/auth/oauth-manager.d.ts.map +1 -0
- package/dist/auth/oauth-manager.js +237 -0
- package/dist/auth/oauth-manager.js.map +1 -0
- package/dist/auth/types.d.ts +92 -0
- package/dist/auth/types.d.ts.map +1 -0
- package/dist/auth/types.js +23 -0
- package/dist/auth/types.js.map +1 -0
- package/dist/cli/commands/init.d.ts +19 -0
- package/dist/cli/commands/init.d.ts.map +1 -0
- package/dist/cli/commands/init.js +155 -0
- package/dist/cli/commands/init.js.map +1 -0
- package/dist/cli/commands/run.d.ts +19 -0
- package/dist/cli/commands/run.d.ts.map +1 -0
- package/dist/cli/commands/run.js +89 -0
- package/dist/cli/commands/run.js.map +1 -0
- package/dist/cli/commands/setup.d.ts +19 -0
- package/dist/cli/commands/setup.d.ts.map +1 -0
- package/dist/cli/commands/setup.js +134 -0
- package/dist/cli/commands/setup.js.map +1 -0
- package/dist/cli/commands/start.d.ts +24 -0
- package/dist/cli/commands/start.d.ts.map +1 -0
- package/dist/cli/commands/start.js +1073 -0
- package/dist/cli/commands/start.js.map +1 -0
- package/dist/cli/commands/status.d.ts +10 -0
- package/dist/cli/commands/status.d.ts.map +1 -0
- package/dist/cli/commands/status.js +85 -0
- package/dist/cli/commands/status.js.map +1 -0
- package/dist/cli/commands/stop.d.ts +10 -0
- package/dist/cli/commands/stop.d.ts.map +1 -0
- package/dist/cli/commands/stop.js +65 -0
- package/dist/cli/commands/stop.js.map +1 -0
- package/dist/cli/config/config-manager.d.ts +51 -0
- package/dist/cli/config/config-manager.d.ts.map +1 -0
- package/dist/cli/config/config-manager.js +216 -0
- package/dist/cli/config/config-manager.js.map +1 -0
- package/dist/cli/config/types.d.ts +172 -0
- package/dist/cli/config/types.d.ts.map +1 -0
- package/dist/cli/config/types.js +48 -0
- package/dist/cli/config/types.js.map +1 -0
- package/dist/cli/index.d.ts +8 -0
- package/dist/cli/index.d.ts.map +1 -0
- package/dist/cli/index.js +92 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/cli/utils/pid-manager.d.ts +66 -0
- package/dist/cli/utils/pid-manager.d.ts.map +1 -0
- package/dist/cli/utils/pid-manager.js +167 -0
- package/dist/cli/utils/pid-manager.js.map +1 -0
- package/dist/concurrency/index.d.ts +13 -0
- package/dist/concurrency/index.d.ts.map +1 -0
- package/dist/concurrency/index.js +22 -0
- package/dist/concurrency/index.js.map +1 -0
- package/dist/concurrency/lane-manager.d.ts +113 -0
- package/dist/concurrency/lane-manager.d.ts.map +1 -0
- package/dist/concurrency/lane-manager.js +245 -0
- package/dist/concurrency/lane-manager.js.map +1 -0
- package/dist/concurrency/session-key.d.ts +41 -0
- package/dist/concurrency/session-key.d.ts.map +1 -0
- package/dist/concurrency/session-key.js +61 -0
- package/dist/concurrency/session-key.js.map +1 -0
- package/dist/concurrency/types.d.ts +69 -0
- package/dist/concurrency/types.d.ts.map +1 -0
- package/dist/concurrency/types.js +16 -0
- package/dist/concurrency/types.js.map +1 -0
- package/dist/gateways/channel-history.d.ts +102 -0
- package/dist/gateways/channel-history.d.ts.map +1 -0
- package/dist/gateways/channel-history.js +181 -0
- package/dist/gateways/channel-history.js.map +1 -0
- package/dist/gateways/context-injector.d.ts +74 -0
- package/dist/gateways/context-injector.d.ts.map +1 -0
- package/dist/gateways/context-injector.js +121 -0
- package/dist/gateways/context-injector.js.map +1 -0
- package/dist/gateways/discord.d.ts +122 -0
- package/dist/gateways/discord.d.ts.map +1 -0
- package/dist/gateways/discord.js +602 -0
- package/dist/gateways/discord.js.map +1 -0
- package/dist/gateways/index.d.ts +30 -0
- package/dist/gateways/index.d.ts.map +1 -0
- package/dist/gateways/index.js +49 -0
- package/dist/gateways/index.js.map +1 -0
- package/dist/gateways/message-router.d.ts +116 -0
- package/dist/gateways/message-router.d.ts.map +1 -0
- package/dist/gateways/message-router.js +315 -0
- package/dist/gateways/message-router.js.map +1 -0
- package/dist/gateways/message-splitter.d.ts +54 -0
- package/dist/gateways/message-splitter.d.ts.map +1 -0
- package/dist/gateways/message-splitter.js +146 -0
- package/dist/gateways/message-splitter.js.map +1 -0
- package/dist/gateways/plugin-loader.d.ts +76 -0
- package/dist/gateways/plugin-loader.d.ts.map +1 -0
- package/dist/gateways/plugin-loader.js +221 -0
- package/dist/gateways/plugin-loader.js.map +1 -0
- package/dist/gateways/session-store.d.ts +77 -0
- package/dist/gateways/session-store.d.ts.map +1 -0
- package/dist/gateways/session-store.js +233 -0
- package/dist/gateways/session-store.js.map +1 -0
- package/dist/gateways/slack.d.ts +90 -0
- package/dist/gateways/slack.d.ts.map +1 -0
- package/dist/gateways/slack.js +281 -0
- package/dist/gateways/slack.js.map +1 -0
- package/dist/gateways/telegram.d.ts +79 -0
- package/dist/gateways/telegram.d.ts.map +1 -0
- package/dist/gateways/telegram.js +207 -0
- package/dist/gateways/telegram.js.map +1 -0
- package/dist/gateways/types.d.ts +340 -0
- package/dist/gateways/types.d.ts.map +1 -0
- package/dist/gateways/types.js +6 -0
- package/dist/gateways/types.js.map +1 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +26 -0
- package/dist/index.js.map +1 -0
- package/dist/memory/memory-logger.d.ts +47 -0
- package/dist/memory/memory-logger.d.ts.map +1 -0
- package/dist/memory/memory-logger.js +126 -0
- package/dist/memory/memory-logger.js.map +1 -0
- package/dist/onboarding/all-tools.d.ts +18 -0
- package/dist/onboarding/all-tools.d.ts.map +1 -0
- package/dist/onboarding/all-tools.js +149 -0
- package/dist/onboarding/all-tools.js.map +1 -0
- package/dist/onboarding/autonomous-discovery-tools.d.ts +13 -0
- package/dist/onboarding/autonomous-discovery-tools.d.ts.map +1 -0
- package/dist/onboarding/autonomous-discovery-tools.js +268 -0
- package/dist/onboarding/autonomous-discovery-tools.js.map +1 -0
- package/dist/onboarding/bootstrap-template.d.ts +5 -0
- package/dist/onboarding/bootstrap-template.d.ts.map +1 -0
- package/dist/onboarding/bootstrap-template.js +142 -0
- package/dist/onboarding/bootstrap-template.js.map +1 -0
- package/dist/onboarding/complete-autonomous-prompt.d.ts +13 -0
- package/dist/onboarding/complete-autonomous-prompt.d.ts.map +1 -0
- package/dist/onboarding/complete-autonomous-prompt.js +1220 -0
- package/dist/onboarding/complete-autonomous-prompt.js.map +1 -0
- package/dist/onboarding/onboarding-state.d.ts +70 -0
- package/dist/onboarding/onboarding-state.d.ts.map +1 -0
- package/dist/onboarding/onboarding-state.js +184 -0
- package/dist/onboarding/onboarding-state.js.map +1 -0
- package/dist/onboarding/personality-quiz.d.ts +35 -0
- package/dist/onboarding/personality-quiz.d.ts.map +1 -0
- package/dist/onboarding/personality-quiz.js +219 -0
- package/dist/onboarding/personality-quiz.js.map +1 -0
- package/dist/onboarding/phase-5-summary.d.ts +22 -0
- package/dist/onboarding/phase-5-summary.d.ts.map +1 -0
- package/dist/onboarding/phase-5-summary.js +151 -0
- package/dist/onboarding/phase-5-summary.js.map +1 -0
- package/dist/onboarding/phase-6-security.d.ts +33 -0
- package/dist/onboarding/phase-6-security.d.ts.map +1 -0
- package/dist/onboarding/phase-6-security.js +473 -0
- package/dist/onboarding/phase-6-security.js.map +1 -0
- package/dist/onboarding/phase-7-integrations.d.ts +66 -0
- package/dist/onboarding/phase-7-integrations.d.ts.map +1 -0
- package/dist/onboarding/phase-7-integrations.js +619 -0
- package/dist/onboarding/phase-7-integrations.js.map +1 -0
- package/dist/onboarding/phase-8-demo.d.ts +43 -0
- package/dist/onboarding/phase-8-demo.d.ts.map +1 -0
- package/dist/onboarding/phase-8-demo.js +346 -0
- package/dist/onboarding/phase-8-demo.js.map +1 -0
- package/dist/onboarding/phase-9-finalization.d.ts +22 -0
- package/dist/onboarding/phase-9-finalization.d.ts.map +1 -0
- package/dist/onboarding/phase-9-finalization.js +375 -0
- package/dist/onboarding/phase-9-finalization.js.map +1 -0
- package/dist/onboarding/ritual-prompt.d.ts +2 -0
- package/dist/onboarding/ritual-prompt.d.ts.map +1 -0
- package/dist/onboarding/ritual-prompt.js +285 -0
- package/dist/onboarding/ritual-prompt.js.map +1 -0
- package/dist/onboarding/ritual-tools.d.ts +13 -0
- package/dist/onboarding/ritual-tools.d.ts.map +1 -0
- package/dist/onboarding/ritual-tools.js +93 -0
- package/dist/onboarding/ritual-tools.js.map +1 -0
- package/dist/runners/cli-runner.d.ts +59 -0
- package/dist/runners/cli-runner.d.ts.map +1 -0
- package/dist/runners/cli-runner.js +190 -0
- package/dist/runners/cli-runner.js.map +1 -0
- package/dist/runners/index.d.ts +11 -0
- package/dist/runners/index.d.ts.map +1 -0
- package/dist/runners/index.js +15 -0
- package/dist/runners/index.js.map +1 -0
- package/dist/runners/types.d.ts +81 -0
- package/dist/runners/types.d.ts.map +1 -0
- package/dist/runners/types.js +31 -0
- package/dist/runners/types.js.map +1 -0
- package/dist/scheduler/cron-scheduler.d.ts +115 -0
- package/dist/scheduler/cron-scheduler.d.ts.map +1 -0
- package/dist/scheduler/cron-scheduler.js +320 -0
- package/dist/scheduler/cron-scheduler.js.map +1 -0
- package/dist/scheduler/heartbeat.d.ts +53 -0
- package/dist/scheduler/heartbeat.d.ts.map +1 -0
- package/dist/scheduler/heartbeat.js +160 -0
- package/dist/scheduler/heartbeat.js.map +1 -0
- package/dist/scheduler/index.d.ts +22 -0
- package/dist/scheduler/index.d.ts.map +1 -0
- package/dist/scheduler/index.js +31 -0
- package/dist/scheduler/index.js.map +1 -0
- package/dist/scheduler/job-lock.d.ts +85 -0
- package/dist/scheduler/job-lock.d.ts.map +1 -0
- package/dist/scheduler/job-lock.js +137 -0
- package/dist/scheduler/job-lock.js.map +1 -0
- package/dist/scheduler/recovery.d.ts +78 -0
- package/dist/scheduler/recovery.d.ts.map +1 -0
- package/dist/scheduler/recovery.js +124 -0
- package/dist/scheduler/recovery.js.map +1 -0
- package/dist/scheduler/schedule-store.d.ts +112 -0
- package/dist/scheduler/schedule-store.d.ts.map +1 -0
- package/dist/scheduler/schedule-store.js +259 -0
- package/dist/scheduler/schedule-store.js.map +1 -0
- package/dist/scheduler/token-keep-alive.d.ts +49 -0
- package/dist/scheduler/token-keep-alive.d.ts.map +1 -0
- package/dist/scheduler/token-keep-alive.js +102 -0
- package/dist/scheduler/token-keep-alive.js.map +1 -0
- package/dist/scheduler/types.d.ts +96 -0
- package/dist/scheduler/types.d.ts.map +1 -0
- package/dist/scheduler/types.js +21 -0
- package/dist/scheduler/types.js.map +1 -0
- package/dist/setup/setup-prompt.d.ts +2 -0
- package/dist/setup/setup-prompt.d.ts.map +1 -0
- package/dist/setup/setup-prompt.js +138 -0
- package/dist/setup/setup-prompt.js.map +1 -0
- package/dist/setup/setup-server.d.ts +8 -0
- package/dist/setup/setup-server.d.ts.map +1 -0
- package/dist/setup/setup-server.js +71 -0
- package/dist/setup/setup-server.js.map +1 -0
- package/dist/setup/setup-tools.d.ts +13 -0
- package/dist/setup/setup-tools.d.ts.map +1 -0
- package/dist/setup/setup-tools.js +103 -0
- package/dist/setup/setup-tools.js.map +1 -0
- package/dist/setup/setup-websocket.d.ts +6 -0
- package/dist/setup/setup-websocket.d.ts.map +1 -0
- package/dist/setup/setup-websocket.js +312 -0
- package/dist/setup/setup-websocket.js.map +1 -0
- package/dist/skills/index.d.ts +10 -0
- package/dist/skills/index.d.ts.map +1 -0
- package/dist/skills/index.js +26 -0
- package/dist/skills/index.js.map +1 -0
- package/dist/skills/skill-executor.d.ts +48 -0
- package/dist/skills/skill-executor.d.ts.map +1 -0
- package/dist/skills/skill-executor.js +483 -0
- package/dist/skills/skill-executor.js.map +1 -0
- package/dist/skills/skill-loader.d.ts +40 -0
- package/dist/skills/skill-loader.d.ts.map +1 -0
- package/dist/skills/skill-loader.js +225 -0
- package/dist/skills/skill-loader.js.map +1 -0
- package/dist/skills/skill-matcher.d.ts +33 -0
- package/dist/skills/skill-matcher.d.ts.map +1 -0
- package/dist/skills/skill-matcher.js +190 -0
- package/dist/skills/skill-matcher.js.map +1 -0
- package/dist/skills/types.d.ts +123 -0
- package/dist/skills/types.d.ts.map +1 -0
- package/dist/skills/types.js +12 -0
- package/dist/skills/types.js.map +1 -0
- package/dist/tools/browser-tool.d.ts +149 -0
- package/dist/tools/browser-tool.d.ts.map +1 -0
- package/dist/tools/browser-tool.js +257 -0
- package/dist/tools/browser-tool.js.map +1 -0
- package/package.json +84 -0
- package/public/favicon.ico +0 -0
- package/public/setup.html +1026 -0
- package/public/viewer/icons/icon-192.png +0 -0
- package/public/viewer/icons/icon-512.png +0 -0
- package/public/viewer/js/modules/chat.js +1587 -0
- package/public/viewer/js/modules/dashboard.js +275 -0
- package/public/viewer/js/modules/graph.js +997 -0
- package/public/viewer/js/modules/memory.js +353 -0
- package/public/viewer/js/modules/settings.js +255 -0
- package/public/viewer/js/utils/api.js +169 -0
- package/public/viewer/js/utils/dom.js +92 -0
- package/public/viewer/js/utils/format.js +192 -0
- package/public/viewer/manifest.json +26 -0
- package/public/viewer/sw.js +131 -0
- package/public/viewer/viewer.css +500 -0
- package/public/viewer/viewer.html +1535 -0
- package/scripts/postinstall.js +118 -0
- package/templates/skills/document-analyze.md +63 -0
- package/templates/skills/heartbeat-report.md +75 -0
- package/templates/skills/image-translate.md +67 -0
- package/templates/workspace/skill-forge/DESIGN.md +115 -0
- package/templates/workspace/skill-forge/agents/architect.ts +295 -0
- package/templates/workspace/skill-forge/agents/developer.ts +364 -0
- package/templates/workspace/skill-forge/agents/qa.ts +313 -0
- package/templates/workspace/skill-forge/claude-api.ts +353 -0
- package/templates/workspace/skill-forge/discord-ui.ts +580 -0
- package/templates/workspace/skill-forge/error-handler.ts +354 -0
- package/templates/workspace/skill-forge/mama-integration.ts +357 -0
- package/templates/workspace/skill-forge/orchestrator.ts +495 -0
- package/templates/workspace/skill-forge/output/generated-skills/skills/hello-world/README.md +24 -0
- package/templates/workspace/skill-forge/output/generated-skills/skills/hello-world/index.ts +79 -0
- package/templates/workspace/skill-forge/output/generated-skills/skills/hello-world/types.ts +17 -0
- package/templates/workspace/skill-forge/package.json +21 -0
- package/templates/workspace/skill-forge/state/session.json +132 -0
- package/templates/workspace/skill-forge/test-e2e.ts +139 -0
- package/templates/workspace/skill-forge/tsconfig.json +20 -0
- package/templates/workspace/skill-forge/types.ts +159 -0
|
@@ -0,0 +1,1587 @@
|
|
|
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 { escapeHtml, showToast, scrollToBottom, autoResizeTextarea } from '../utils/dom.js';
|
|
16
|
+
import { formatMessageTime, formatAssistantMessage } from '../utils/format.js';
|
|
17
|
+
import { API } from '../utils/api.js';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Chat Module Class
|
|
21
|
+
*/
|
|
22
|
+
export class ChatModule {
|
|
23
|
+
constructor(memoryModule = null) {
|
|
24
|
+
// External dependencies
|
|
25
|
+
this.memoryModule = memoryModule;
|
|
26
|
+
|
|
27
|
+
// WebSocket state
|
|
28
|
+
this.ws = null;
|
|
29
|
+
this.sessionId = null;
|
|
30
|
+
this.reconnectAttempts = 0;
|
|
31
|
+
this.maxReconnectDelay = 30000; // 30 seconds
|
|
32
|
+
|
|
33
|
+
// Voice input state (STT)
|
|
34
|
+
this.speechRecognition = null;
|
|
35
|
+
this.isRecording = false;
|
|
36
|
+
this.silenceTimeout = null;
|
|
37
|
+
this.silenceDelay = 2500; // 2.5 seconds (increased for continuous mode)
|
|
38
|
+
this.accumulatedTranscript = ''; // Track accumulated final transcripts
|
|
39
|
+
|
|
40
|
+
// Voice output state (TTS)
|
|
41
|
+
this.speechSynthesis = window.speechSynthesis;
|
|
42
|
+
this.isSpeaking = false;
|
|
43
|
+
this.ttsEnabled = false; // Auto-play toggle
|
|
44
|
+
this.handsFreeMode = false; // Auto-listen after TTS
|
|
45
|
+
this.ttsVoice = null;
|
|
46
|
+
this.ttsRate = 1.8; // Speech rate (0.5 - 2.0), optimized for Korean
|
|
47
|
+
this.ttsPitch = 1.0; // Speech pitch (0.0 - 2.0)
|
|
48
|
+
|
|
49
|
+
// Streaming state
|
|
50
|
+
this.currentStreamEl = null;
|
|
51
|
+
this.currentStreamText = '';
|
|
52
|
+
this.streamBuffer = '';
|
|
53
|
+
this.rafPending = false;
|
|
54
|
+
|
|
55
|
+
// History state
|
|
56
|
+
this.history = [];
|
|
57
|
+
this.historyPrefix = 'mama_chat_history_';
|
|
58
|
+
this.maxHistoryMessages = 50;
|
|
59
|
+
this.historyExpiryMs = 24 * 60 * 60 * 1000; // 24 hours
|
|
60
|
+
|
|
61
|
+
// Idle auto-checkpoint state
|
|
62
|
+
this.idleTimer = null;
|
|
63
|
+
this.IDLE_TIMEOUT = 5 * 60 * 1000; // 5 minutes
|
|
64
|
+
this.checkpointCooldown = false;
|
|
65
|
+
this.COOLDOWN_MS = 60 * 1000; // 1 minute between checkpoints
|
|
66
|
+
|
|
67
|
+
// Initialize
|
|
68
|
+
this.initChatInput();
|
|
69
|
+
this.initLongPressCopy();
|
|
70
|
+
this.initSpeechRecognition();
|
|
71
|
+
this.initSpeechSynthesis();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// =============================================
|
|
75
|
+
// Idle Auto-Checkpoint
|
|
76
|
+
// =============================================
|
|
77
|
+
|
|
78
|
+
resetIdleTimer() {
|
|
79
|
+
if (this.idleTimer) {
|
|
80
|
+
clearTimeout(this.idleTimer);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
|
84
|
+
this.idleTimer = setTimeout(() => {
|
|
85
|
+
this.autoCheckpoint();
|
|
86
|
+
}, this.IDLE_TIMEOUT);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async autoCheckpoint() {
|
|
91
|
+
// DISABLED: Auto-checkpoint was saving raw conversation history to MAMA memory.
|
|
92
|
+
// Checkpoints should only be saved manually via /checkpoint command with proper summaries.
|
|
93
|
+
// The viewer chat uses localStorage for session persistence instead.
|
|
94
|
+
console.log('[ChatModule] Auto-checkpoint disabled (use /checkpoint for manual saves)');
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// =============================================
|
|
99
|
+
// Session Management
|
|
100
|
+
// =============================================
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Initialize chat session
|
|
104
|
+
*/
|
|
105
|
+
async initSession() {
|
|
106
|
+
// Check for resumable session first
|
|
107
|
+
await this.checkForResumableSession();
|
|
108
|
+
|
|
109
|
+
// Try to get last active server session first
|
|
110
|
+
const lastActiveSession = await API.getLastActiveSession();
|
|
111
|
+
if (lastActiveSession && lastActiveSession.id && lastActiveSession.isAlive) {
|
|
112
|
+
console.log('[Chat] Resuming last active session:', lastActiveSession.id);
|
|
113
|
+
this.addSystemMessage('Resuming previous session...');
|
|
114
|
+
localStorage.setItem('mama_chat_session_id', lastActiveSession.id);
|
|
115
|
+
this.initWebSocket(lastActiveSession.id);
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const savedSessionId = localStorage.getItem('mama_chat_session_id');
|
|
120
|
+
|
|
121
|
+
if (savedSessionId) {
|
|
122
|
+
console.log('[Chat] Trying saved session:', savedSessionId);
|
|
123
|
+
this.addSystemMessage('Connecting to session...');
|
|
124
|
+
this.initWebSocket(savedSessionId);
|
|
125
|
+
} else {
|
|
126
|
+
try {
|
|
127
|
+
this.addSystemMessage('Creating new session...');
|
|
128
|
+
const data = await API.createSession('.');
|
|
129
|
+
const sessionId = data.sessionId;
|
|
130
|
+
|
|
131
|
+
console.log('[Chat] Created new session:', sessionId);
|
|
132
|
+
localStorage.setItem('mama_chat_session_id', sessionId);
|
|
133
|
+
|
|
134
|
+
this.initWebSocket(sessionId);
|
|
135
|
+
} catch (error) {
|
|
136
|
+
console.error('[Chat] Failed to create session:', error);
|
|
137
|
+
this.addSystemMessage(`Failed to create session: ${error.message}`, 'error');
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Connect to session (public method)
|
|
144
|
+
*/
|
|
145
|
+
connectToSession(sessionId) {
|
|
146
|
+
this.initWebSocket(sessionId);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Disconnect from session (public method)
|
|
151
|
+
*/
|
|
152
|
+
disconnect() {
|
|
153
|
+
if (this.ws) {
|
|
154
|
+
this.sessionId = null; // Prevent auto-reconnect
|
|
155
|
+
this.ws.close();
|
|
156
|
+
this.ws = null;
|
|
157
|
+
}
|
|
158
|
+
this.updateStatus('disconnected');
|
|
159
|
+
this.enableInput(false);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// =============================================
|
|
163
|
+
// WebSocket Management
|
|
164
|
+
// =============================================
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Initialize WebSocket connection
|
|
168
|
+
*/
|
|
169
|
+
initWebSocket(sessionId) {
|
|
170
|
+
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
|
171
|
+
console.log('[Chat] Already connected');
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
this.sessionId = sessionId;
|
|
176
|
+
// Don't restore from localStorage - server will send authoritative history
|
|
177
|
+
// this.restoreHistory(sessionId);
|
|
178
|
+
|
|
179
|
+
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
180
|
+
const wsUrl = `${protocol}//${window.location.host}/ws?sessionId=${sessionId}`;
|
|
181
|
+
|
|
182
|
+
console.log('[Chat] Connecting to:', wsUrl);
|
|
183
|
+
this.ws = new WebSocket(wsUrl);
|
|
184
|
+
|
|
185
|
+
this.ws.onopen = () => {
|
|
186
|
+
console.log('[Chat] Connected');
|
|
187
|
+
this.reconnectAttempts = 0;
|
|
188
|
+
this.updateStatus('connected');
|
|
189
|
+
this.enableInput(true);
|
|
190
|
+
|
|
191
|
+
this.ws.send(
|
|
192
|
+
JSON.stringify({
|
|
193
|
+
type: 'attach',
|
|
194
|
+
sessionId: sessionId,
|
|
195
|
+
osAgentMode: true, // Enable OS Agent capabilities (Viewer-only)
|
|
196
|
+
language: navigator.language || 'en', // Browser language for greeting
|
|
197
|
+
})
|
|
198
|
+
);
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
this.ws.onmessage = (event) => {
|
|
202
|
+
try {
|
|
203
|
+
const data = JSON.parse(event.data);
|
|
204
|
+
this.handleMessage(data);
|
|
205
|
+
} catch (e) {
|
|
206
|
+
console.error('[Chat] Parse error:', e);
|
|
207
|
+
}
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
this.ws.onclose = (event) => {
|
|
211
|
+
console.log('[Chat] Disconnected:', event.code, event.reason);
|
|
212
|
+
this.updateStatus('disconnected');
|
|
213
|
+
this.enableInput(false);
|
|
214
|
+
|
|
215
|
+
if (this.sessionId) {
|
|
216
|
+
this.scheduleReconnect();
|
|
217
|
+
}
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
this.ws.onerror = (error) => {
|
|
221
|
+
console.error('[Chat] WebSocket error:', error);
|
|
222
|
+
this.updateStatus('disconnected');
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Handle incoming WebSocket message
|
|
228
|
+
*/
|
|
229
|
+
handleMessage(data) {
|
|
230
|
+
switch (data.type) {
|
|
231
|
+
case 'attached':
|
|
232
|
+
console.log('[Chat] Attached to session:', data.sessionId);
|
|
233
|
+
this.addSystemMessage('Connected to session');
|
|
234
|
+
break;
|
|
235
|
+
|
|
236
|
+
case 'history':
|
|
237
|
+
// Display conversation history from server
|
|
238
|
+
if (data.messages && data.messages.length > 0) {
|
|
239
|
+
console.log('[Chat] Received history:', data.messages.length, 'messages');
|
|
240
|
+
this.displayHistory(data.messages);
|
|
241
|
+
}
|
|
242
|
+
break;
|
|
243
|
+
|
|
244
|
+
case 'output':
|
|
245
|
+
case 'stream':
|
|
246
|
+
if (data.content) {
|
|
247
|
+
this.enableSend(true);
|
|
248
|
+
this.appendStreamChunk(data.content);
|
|
249
|
+
}
|
|
250
|
+
break;
|
|
251
|
+
|
|
252
|
+
case 'stream_end':
|
|
253
|
+
this.finalizeStreamMessage();
|
|
254
|
+
break;
|
|
255
|
+
|
|
256
|
+
case 'error':
|
|
257
|
+
if (data.error === 'session_not_found') {
|
|
258
|
+
console.log('[Chat] Session not found, creating new one...');
|
|
259
|
+
localStorage.removeItem('mama_chat_session_id');
|
|
260
|
+
this.addSystemMessage('Session expired. Creating new session...');
|
|
261
|
+
|
|
262
|
+
if (this.ws) {
|
|
263
|
+
this.ws.close();
|
|
264
|
+
this.ws = null;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
setTimeout(() => this.initSession(), 500);
|
|
268
|
+
} else {
|
|
269
|
+
this.addSystemMessage(`Error: ${data.message || data.error}`, 'error');
|
|
270
|
+
this.enableSend(true);
|
|
271
|
+
}
|
|
272
|
+
break;
|
|
273
|
+
|
|
274
|
+
case 'tool_use':
|
|
275
|
+
this.addToolCard(data.tool, data.toolId, data.input);
|
|
276
|
+
break;
|
|
277
|
+
|
|
278
|
+
case 'tool_complete':
|
|
279
|
+
this.completeToolCard(data.index);
|
|
280
|
+
break;
|
|
281
|
+
|
|
282
|
+
case 'pong':
|
|
283
|
+
break;
|
|
284
|
+
|
|
285
|
+
case 'connected':
|
|
286
|
+
console.log('[Chat] WebSocket connected:', data.clientId);
|
|
287
|
+
break;
|
|
288
|
+
|
|
289
|
+
default:
|
|
290
|
+
console.log('[Chat] Unknown message type:', data.type);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Schedule reconnection with exponential backoff
|
|
296
|
+
*/
|
|
297
|
+
scheduleReconnect() {
|
|
298
|
+
const delay = Math.min(1000 * Math.pow(2, this.reconnectAttempts), this.maxReconnectDelay);
|
|
299
|
+
this.reconnectAttempts++;
|
|
300
|
+
|
|
301
|
+
console.log(`[Chat] Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts})`);
|
|
302
|
+
this.addSystemMessage(
|
|
303
|
+
`Connection lost. Reconnecting in ${Math.round(delay / 1000)}s...`,
|
|
304
|
+
'warning'
|
|
305
|
+
);
|
|
306
|
+
|
|
307
|
+
setTimeout(() => {
|
|
308
|
+
if (this.sessionId) {
|
|
309
|
+
this.initWebSocket(this.sessionId);
|
|
310
|
+
}
|
|
311
|
+
}, delay);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// =============================================
|
|
315
|
+
// Message Handling
|
|
316
|
+
// =============================================
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Send chat message
|
|
320
|
+
*/
|
|
321
|
+
send() {
|
|
322
|
+
const input = document.getElementById('chat-input');
|
|
323
|
+
const message = input.value.trim();
|
|
324
|
+
|
|
325
|
+
if (!message) {
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// Handle slash commands
|
|
330
|
+
if (message.startsWith('/')) {
|
|
331
|
+
this.handleCommand(message);
|
|
332
|
+
input.value = '';
|
|
333
|
+
autoResizeTextarea(input);
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
|
338
|
+
this.addSystemMessage('Not connected. Please connect to a session first.', 'error');
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
this.addUserMessage(message);
|
|
343
|
+
this.enableSend(false);
|
|
344
|
+
|
|
345
|
+
this.ws.send(
|
|
346
|
+
JSON.stringify({
|
|
347
|
+
type: 'send',
|
|
348
|
+
sessionId: this.sessionId,
|
|
349
|
+
content: message,
|
|
350
|
+
})
|
|
351
|
+
);
|
|
352
|
+
|
|
353
|
+
// Search for related MAMA decisions
|
|
354
|
+
if (this.memoryModule) {
|
|
355
|
+
this.memoryModule.showRelatedForMessage(message);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
input.value = '';
|
|
359
|
+
autoResizeTextarea(input);
|
|
360
|
+
|
|
361
|
+
console.log('[Chat] Sent:', message);
|
|
362
|
+
this.resetIdleTimer();
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* Send quiz choice (A, B, C, D)
|
|
367
|
+
* Called from quiz-choice-btn onclick
|
|
368
|
+
*/
|
|
369
|
+
sendQuizChoice(choice) {
|
|
370
|
+
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
|
371
|
+
this.addSystemMessage('Not connected.', 'error');
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// Display choice as user message
|
|
376
|
+
this.addUserMessage(choice);
|
|
377
|
+
this.enableSend(false);
|
|
378
|
+
|
|
379
|
+
// Send to server
|
|
380
|
+
this.ws.send(
|
|
381
|
+
JSON.stringify({
|
|
382
|
+
type: 'send',
|
|
383
|
+
sessionId: this.sessionId,
|
|
384
|
+
content: choice,
|
|
385
|
+
})
|
|
386
|
+
);
|
|
387
|
+
|
|
388
|
+
console.log('[Chat] Quiz choice sent:', choice);
|
|
389
|
+
this.resetIdleTimer();
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Handle slash commands
|
|
394
|
+
*/
|
|
395
|
+
handleCommand(message) {
|
|
396
|
+
const parts = message.slice(1).split(' ');
|
|
397
|
+
const command = parts[0].toLowerCase();
|
|
398
|
+
const args = parts.slice(1).join(' ');
|
|
399
|
+
|
|
400
|
+
console.log('[Chat] Command:', command, 'Args:', args);
|
|
401
|
+
|
|
402
|
+
switch (command) {
|
|
403
|
+
case 'save':
|
|
404
|
+
this.commandSave(args);
|
|
405
|
+
break;
|
|
406
|
+
case 'search':
|
|
407
|
+
this.commandSearch(args);
|
|
408
|
+
break;
|
|
409
|
+
case 'checkpoint':
|
|
410
|
+
this.commandCheckpoint();
|
|
411
|
+
break;
|
|
412
|
+
case 'resume':
|
|
413
|
+
this.commandResume();
|
|
414
|
+
break;
|
|
415
|
+
case 'help':
|
|
416
|
+
this.commandHelp();
|
|
417
|
+
break;
|
|
418
|
+
default:
|
|
419
|
+
this.addSystemMessage(
|
|
420
|
+
`Unknown command: /${command}. Type /help for available commands.`,
|
|
421
|
+
'error'
|
|
422
|
+
);
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* /save <text> - Open Memory form with text
|
|
428
|
+
*/
|
|
429
|
+
commandSave(text) {
|
|
430
|
+
if (!this.memoryModule) {
|
|
431
|
+
this.addSystemMessage('Memory module not available', 'error');
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
if (!text) {
|
|
436
|
+
this.addSystemMessage('Usage: /save <decision text>', 'error');
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// Switch to Memory tab and open form with text
|
|
441
|
+
window.switchTab('memory');
|
|
442
|
+
this.memoryModule.showSaveFormWithText(text);
|
|
443
|
+
this.addSystemMessage(`💾 Opening save form with: "${text.substring(0, 50)}..."`);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* /search <query> - Search in Memory tab
|
|
448
|
+
*/
|
|
449
|
+
commandSearch(query) {
|
|
450
|
+
if (!this.memoryModule) {
|
|
451
|
+
this.addSystemMessage('Memory module not available', 'error');
|
|
452
|
+
return;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
if (!query) {
|
|
456
|
+
this.addSystemMessage('Usage: /search <query>', 'error');
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// Switch to Memory tab and execute search
|
|
461
|
+
window.switchTab('memory');
|
|
462
|
+
this.memoryModule.searchWithQuery(query);
|
|
463
|
+
this.addSystemMessage(`🔍 Searching for: "${query}"`);
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* /checkpoint - Save current session as checkpoint
|
|
468
|
+
*/
|
|
469
|
+
async commandCheckpoint() {
|
|
470
|
+
try {
|
|
471
|
+
const summary = this.generateCheckpointSummary();
|
|
472
|
+
await this.saveCheckpoint(summary);
|
|
473
|
+
this.addSystemMessage('✅ Checkpoint saved successfully');
|
|
474
|
+
} catch (error) {
|
|
475
|
+
console.error('[Chat] Checkpoint save failed:', error);
|
|
476
|
+
this.addSystemMessage(`Failed to save checkpoint: ${error.message}`, 'error');
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/**
|
|
481
|
+
* /resume - Load last checkpoint
|
|
482
|
+
*/
|
|
483
|
+
async commandResume() {
|
|
484
|
+
try {
|
|
485
|
+
const checkpoint = await this.loadCheckpoint();
|
|
486
|
+
if (checkpoint) {
|
|
487
|
+
this.addSystemMessage(
|
|
488
|
+
`📖 Last checkpoint (${new Date(checkpoint.timestamp).toLocaleString()}):`
|
|
489
|
+
);
|
|
490
|
+
this.addSystemMessage(checkpoint.summary);
|
|
491
|
+
} else {
|
|
492
|
+
this.addSystemMessage('No checkpoint found', 'error');
|
|
493
|
+
}
|
|
494
|
+
} catch (error) {
|
|
495
|
+
console.error('[Chat] Checkpoint load failed:', error);
|
|
496
|
+
this.addSystemMessage(`Failed to load checkpoint: ${error.message}`, 'error');
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/**
|
|
501
|
+
* /help - Show available commands
|
|
502
|
+
*/
|
|
503
|
+
commandHelp() {
|
|
504
|
+
const helpText = `
|
|
505
|
+
**Available Commands:**
|
|
506
|
+
|
|
507
|
+
**/save <text>** - Save a decision to Memory
|
|
508
|
+
**/search <query>** - Search decisions in Memory
|
|
509
|
+
**/checkpoint** - Save current session
|
|
510
|
+
**/resume** - Load last checkpoint
|
|
511
|
+
**/help** - Show this help message
|
|
512
|
+
|
|
513
|
+
**Keyboard Shortcuts:**
|
|
514
|
+
- **Enter** - Send message
|
|
515
|
+
- **Shift+Enter** - New line
|
|
516
|
+
- **Long press message** - Copy to clipboard
|
|
517
|
+
`.trim();
|
|
518
|
+
|
|
519
|
+
this.addSystemMessage(helpText);
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* Add user message to chat
|
|
524
|
+
*/
|
|
525
|
+
addUserMessage(text) {
|
|
526
|
+
const container = document.getElementById('chat-messages');
|
|
527
|
+
this.removePlaceholder();
|
|
528
|
+
|
|
529
|
+
const timestamp = new Date();
|
|
530
|
+
const msgEl = document.createElement('div');
|
|
531
|
+
msgEl.className = 'chat-message user';
|
|
532
|
+
msgEl.innerHTML = `
|
|
533
|
+
<div class="message-content">${escapeHtml(text)}</div>
|
|
534
|
+
<div class="message-time">${formatMessageTime(timestamp)}</div>
|
|
535
|
+
`;
|
|
536
|
+
|
|
537
|
+
container.appendChild(msgEl);
|
|
538
|
+
scrollToBottom(container);
|
|
539
|
+
|
|
540
|
+
this.saveToHistory('user', text, timestamp);
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/**
|
|
544
|
+
* Add assistant message to chat
|
|
545
|
+
*/
|
|
546
|
+
addAssistantMessage(text) {
|
|
547
|
+
const container = document.getElementById('chat-messages');
|
|
548
|
+
this.removePlaceholder();
|
|
549
|
+
|
|
550
|
+
this.enableSend(true);
|
|
551
|
+
|
|
552
|
+
const timestamp = new Date();
|
|
553
|
+
const msgEl = document.createElement('div');
|
|
554
|
+
msgEl.className = 'chat-message assistant';
|
|
555
|
+
msgEl.innerHTML = `
|
|
556
|
+
<div class="message-content">${formatAssistantMessage(text)}</div>
|
|
557
|
+
<div class="message-time">${formatMessageTime(timestamp)}</div>
|
|
558
|
+
`;
|
|
559
|
+
|
|
560
|
+
container.appendChild(msgEl);
|
|
561
|
+
scrollToBottom(container);
|
|
562
|
+
|
|
563
|
+
this.saveToHistory('assistant', text, timestamp);
|
|
564
|
+
|
|
565
|
+
// Auto-play TTS if enabled
|
|
566
|
+
if (this.ttsEnabled && text) {
|
|
567
|
+
console.log('[TTS] Auto-play enabled, speaking assistant message');
|
|
568
|
+
this.speak(text);
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
/**
|
|
573
|
+
* Add system message to chat
|
|
574
|
+
*/
|
|
575
|
+
addSystemMessage(text, type = 'info') {
|
|
576
|
+
const container = document.getElementById('chat-messages');
|
|
577
|
+
this.removePlaceholder();
|
|
578
|
+
|
|
579
|
+
const msgEl = document.createElement('div');
|
|
580
|
+
msgEl.className = `chat-message system ${type}`;
|
|
581
|
+
msgEl.innerHTML = `
|
|
582
|
+
<div class="message-content">${escapeHtml(text)}</div>
|
|
583
|
+
`;
|
|
584
|
+
|
|
585
|
+
container.appendChild(msgEl);
|
|
586
|
+
scrollToBottom(container);
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
/**
|
|
590
|
+
* Add tool usage card
|
|
591
|
+
*/
|
|
592
|
+
addToolCard(toolName, toolId, input) {
|
|
593
|
+
const container = document.getElementById('chat-messages');
|
|
594
|
+
this.removePlaceholder();
|
|
595
|
+
|
|
596
|
+
// Tool icon mapping
|
|
597
|
+
const iconMap = {
|
|
598
|
+
Read: '📄',
|
|
599
|
+
Write: '✏️',
|
|
600
|
+
Bash: '💻',
|
|
601
|
+
Edit: '🔧',
|
|
602
|
+
Grep: '🔍',
|
|
603
|
+
Glob: '📂',
|
|
604
|
+
Task: '🤖',
|
|
605
|
+
WebFetch: '🌐',
|
|
606
|
+
WebSearch: '🔎',
|
|
607
|
+
};
|
|
608
|
+
const icon = iconMap[toolName] || '🔧';
|
|
609
|
+
|
|
610
|
+
// Extract file path for Read tool
|
|
611
|
+
let detail = '';
|
|
612
|
+
if (toolName === 'Read' && input && input.file_path) {
|
|
613
|
+
const fileName = input.file_path.split('/').pop();
|
|
614
|
+
detail = `<div class="tool-detail">${escapeHtml(fileName)}</div>`;
|
|
615
|
+
} else if (toolName === 'Bash' && input && input.command) {
|
|
616
|
+
detail = `<div class="tool-detail">${escapeHtml(input.command.substring(0, 50))}${input.command.length > 50 ? '...' : ''}</div>`;
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
const cardEl = document.createElement('div');
|
|
620
|
+
cardEl.className = 'tool-card loading';
|
|
621
|
+
cardEl.dataset.toolId = toolId;
|
|
622
|
+
cardEl.dataset.collapsed = 'true';
|
|
623
|
+
cardEl.innerHTML = `
|
|
624
|
+
<div class="tool-header" onclick="window.chatModule.toggleToolCard('${toolId}')">
|
|
625
|
+
<span class="tool-icon">${icon}</span>
|
|
626
|
+
<span class="tool-name">${escapeHtml(toolName)}</span>
|
|
627
|
+
<span class="tool-spinner">⏳</span>
|
|
628
|
+
</div>
|
|
629
|
+
${detail}
|
|
630
|
+
`;
|
|
631
|
+
|
|
632
|
+
container.appendChild(cardEl);
|
|
633
|
+
scrollToBottom(container);
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
/**
|
|
637
|
+
* Complete tool card (mark as finished)
|
|
638
|
+
*/
|
|
639
|
+
completeToolCard(_index) {
|
|
640
|
+
// Find the most recent loading tool card
|
|
641
|
+
const loadingCards = document.querySelectorAll('.tool-card.loading');
|
|
642
|
+
if (loadingCards.length > 0) {
|
|
643
|
+
const lastCard = loadingCards[loadingCards.length - 1];
|
|
644
|
+
lastCard.classList.remove('loading');
|
|
645
|
+
lastCard.classList.add('completed');
|
|
646
|
+
|
|
647
|
+
// Replace spinner with checkmark
|
|
648
|
+
const spinner = lastCard.querySelector('.tool-spinner');
|
|
649
|
+
if (spinner) {
|
|
650
|
+
spinner.textContent = '✓';
|
|
651
|
+
spinner.classList.add('checkmark');
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
/**
|
|
657
|
+
* Toggle tool card collapsed/expanded state
|
|
658
|
+
*/
|
|
659
|
+
toggleToolCard(toolId) {
|
|
660
|
+
const card = document.querySelector(`.tool-card[data-tool-id="${toolId}"]`);
|
|
661
|
+
if (card) {
|
|
662
|
+
const isCollapsed = card.dataset.collapsed === 'true';
|
|
663
|
+
card.dataset.collapsed = isCollapsed ? 'false' : 'true';
|
|
664
|
+
// Future: expand to show detailed results
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
/**
|
|
669
|
+
* Remove placeholder
|
|
670
|
+
*/
|
|
671
|
+
removePlaceholder() {
|
|
672
|
+
const placeholder = document.querySelector('.chat-placeholder');
|
|
673
|
+
if (placeholder) {
|
|
674
|
+
placeholder.remove();
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
// =============================================
|
|
679
|
+
// Streaming Message Handling
|
|
680
|
+
// =============================================
|
|
681
|
+
|
|
682
|
+
/**
|
|
683
|
+
* Append streaming chunk with RAF batching
|
|
684
|
+
*/
|
|
685
|
+
appendStreamChunk(content) {
|
|
686
|
+
const container = document.getElementById('chat-messages');
|
|
687
|
+
|
|
688
|
+
if (!this.currentStreamEl) {
|
|
689
|
+
this.removePlaceholder();
|
|
690
|
+
this.currentStreamEl = document.createElement('div');
|
|
691
|
+
this.currentStreamEl.className = 'chat-message assistant streaming';
|
|
692
|
+
this.currentStreamEl.innerHTML = `
|
|
693
|
+
<div class="message-content"></div>
|
|
694
|
+
<div class="message-time">${formatMessageTime(new Date())}</div>
|
|
695
|
+
`;
|
|
696
|
+
container.appendChild(this.currentStreamEl);
|
|
697
|
+
this.currentStreamText = '';
|
|
698
|
+
this.streamBuffer = '';
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
this.streamBuffer += content;
|
|
702
|
+
|
|
703
|
+
if (!this.rafPending) {
|
|
704
|
+
this.rafPending = true;
|
|
705
|
+
requestAnimationFrame(() => {
|
|
706
|
+
if (this.streamBuffer) {
|
|
707
|
+
this.currentStreamText += this.streamBuffer;
|
|
708
|
+
this.streamBuffer = '';
|
|
709
|
+
|
|
710
|
+
const contentEl = this.currentStreamEl.querySelector('.message-content');
|
|
711
|
+
contentEl.innerHTML = formatAssistantMessage(this.currentStreamText);
|
|
712
|
+
|
|
713
|
+
container.scrollTo({
|
|
714
|
+
top: container.scrollHeight,
|
|
715
|
+
behavior: 'smooth',
|
|
716
|
+
});
|
|
717
|
+
}
|
|
718
|
+
this.rafPending = false;
|
|
719
|
+
});
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
/**
|
|
724
|
+
* Finalize streaming message
|
|
725
|
+
*/
|
|
726
|
+
finalizeStreamMessage() {
|
|
727
|
+
if (this.streamBuffer && this.currentStreamEl) {
|
|
728
|
+
this.currentStreamText += this.streamBuffer;
|
|
729
|
+
const contentEl = this.currentStreamEl.querySelector('.message-content');
|
|
730
|
+
contentEl.innerHTML = formatAssistantMessage(this.currentStreamText);
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
if (this.currentStreamText) {
|
|
734
|
+
this.saveToHistory('assistant', this.currentStreamText);
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
if (this.currentStreamEl) {
|
|
738
|
+
this.currentStreamEl.classList.remove('streaming');
|
|
739
|
+
this.currentStreamEl = null;
|
|
740
|
+
this.currentStreamText = '';
|
|
741
|
+
this.streamBuffer = '';
|
|
742
|
+
}
|
|
743
|
+
this.rafPending = false;
|
|
744
|
+
this.enableSend(true);
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
// =============================================
|
|
748
|
+
// UI Control
|
|
749
|
+
// =============================================
|
|
750
|
+
|
|
751
|
+
/**
|
|
752
|
+
* Update chat status
|
|
753
|
+
*/
|
|
754
|
+
updateStatus(status) {
|
|
755
|
+
const statusEl = document.getElementById('chat-status');
|
|
756
|
+
if (!statusEl) {
|
|
757
|
+
console.warn('[Chat] Status element not found');
|
|
758
|
+
return;
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
const indicator = statusEl.querySelector('.status-indicator');
|
|
762
|
+
const text = statusEl.querySelector('span:not(.status-indicator)');
|
|
763
|
+
|
|
764
|
+
if (!indicator || !text) {
|
|
765
|
+
console.warn('[Chat] Status indicator or text not found');
|
|
766
|
+
return;
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
indicator.className = 'status-indicator ' + status;
|
|
770
|
+
|
|
771
|
+
switch (status) {
|
|
772
|
+
case 'connected':
|
|
773
|
+
text.textContent = 'Connected';
|
|
774
|
+
break;
|
|
775
|
+
case 'disconnected':
|
|
776
|
+
text.textContent = 'Disconnected';
|
|
777
|
+
break;
|
|
778
|
+
case 'connecting':
|
|
779
|
+
text.textContent = 'Connecting...';
|
|
780
|
+
break;
|
|
781
|
+
default:
|
|
782
|
+
text.textContent = status;
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
/**
|
|
787
|
+
* Enable/disable chat input
|
|
788
|
+
*/
|
|
789
|
+
enableInput(enabled) {
|
|
790
|
+
const input = document.getElementById('chat-input');
|
|
791
|
+
const sendBtn = document.getElementById('chat-send');
|
|
792
|
+
|
|
793
|
+
input.disabled = !enabled;
|
|
794
|
+
sendBtn.disabled = !enabled;
|
|
795
|
+
|
|
796
|
+
if (enabled) {
|
|
797
|
+
input.placeholder = 'Type your message...';
|
|
798
|
+
} else {
|
|
799
|
+
input.placeholder = 'Connect to a session to chat';
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
/**
|
|
804
|
+
* Enable/disable send button
|
|
805
|
+
*/
|
|
806
|
+
enableSend(enabled) {
|
|
807
|
+
const sendBtn = document.getElementById('chat-send');
|
|
808
|
+
sendBtn.disabled = !enabled;
|
|
809
|
+
|
|
810
|
+
if (enabled) {
|
|
811
|
+
sendBtn.textContent = 'Send';
|
|
812
|
+
sendBtn.classList.remove('loading');
|
|
813
|
+
} else {
|
|
814
|
+
sendBtn.textContent = 'Sending...';
|
|
815
|
+
sendBtn.classList.add('loading');
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
/**
|
|
820
|
+
* Enable/disable mic button
|
|
821
|
+
*/
|
|
822
|
+
enableMic(enabled) {
|
|
823
|
+
const micBtn = document.getElementById('chat-mic');
|
|
824
|
+
if (micBtn) {
|
|
825
|
+
micBtn.disabled = !enabled;
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
// =============================================
|
|
830
|
+
// Input Handlers
|
|
831
|
+
// =============================================
|
|
832
|
+
|
|
833
|
+
/**
|
|
834
|
+
* Handle chat input keydown
|
|
835
|
+
*/
|
|
836
|
+
handleInputKeydown(event) {
|
|
837
|
+
if (event.key === 'Enter' && !event.shiftKey) {
|
|
838
|
+
event.preventDefault();
|
|
839
|
+
this.send();
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
/**
|
|
844
|
+
* Initialize chat input handlers
|
|
845
|
+
*/
|
|
846
|
+
initChatInput() {
|
|
847
|
+
const input = document.getElementById('chat-input');
|
|
848
|
+
|
|
849
|
+
input.addEventListener('input', () => {
|
|
850
|
+
autoResizeTextarea(input);
|
|
851
|
+
});
|
|
852
|
+
|
|
853
|
+
input.addEventListener('keydown', (event) => {
|
|
854
|
+
this.handleInputKeydown(event);
|
|
855
|
+
});
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
/**
|
|
859
|
+
* Initialize long press to copy message functionality
|
|
860
|
+
* Supports both touch (mobile) and mouse (desktop) events
|
|
861
|
+
*/
|
|
862
|
+
initLongPressCopy() {
|
|
863
|
+
const messagesContainer = document.getElementById('chat-messages');
|
|
864
|
+
let pressTimer = null;
|
|
865
|
+
const PRESS_DURATION = 750; // milliseconds
|
|
866
|
+
|
|
867
|
+
// Touch events (mobile)
|
|
868
|
+
messagesContainer.addEventListener('touchstart', (e) => {
|
|
869
|
+
const message = e.target.closest('.message');
|
|
870
|
+
if (!message || message.classList.contains('system')) {
|
|
871
|
+
return;
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
pressTimer = setTimeout(() => {
|
|
875
|
+
copyMessageText(message);
|
|
876
|
+
}, PRESS_DURATION);
|
|
877
|
+
});
|
|
878
|
+
|
|
879
|
+
messagesContainer.addEventListener('touchend', () => {
|
|
880
|
+
if (pressTimer) {
|
|
881
|
+
clearTimeout(pressTimer);
|
|
882
|
+
pressTimer = null;
|
|
883
|
+
}
|
|
884
|
+
});
|
|
885
|
+
|
|
886
|
+
messagesContainer.addEventListener('touchmove', () => {
|
|
887
|
+
if (pressTimer) {
|
|
888
|
+
clearTimeout(pressTimer);
|
|
889
|
+
pressTimer = null;
|
|
890
|
+
}
|
|
891
|
+
});
|
|
892
|
+
|
|
893
|
+
// Mouse events (desktop)
|
|
894
|
+
messagesContainer.addEventListener('mousedown', (e) => {
|
|
895
|
+
const message = e.target.closest('.message');
|
|
896
|
+
if (!message || message.classList.contains('system')) {
|
|
897
|
+
return;
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
pressTimer = setTimeout(() => {
|
|
901
|
+
copyMessageText(message);
|
|
902
|
+
}, PRESS_DURATION);
|
|
903
|
+
});
|
|
904
|
+
|
|
905
|
+
messagesContainer.addEventListener('mouseup', () => {
|
|
906
|
+
if (pressTimer) {
|
|
907
|
+
clearTimeout(pressTimer);
|
|
908
|
+
pressTimer = null;
|
|
909
|
+
}
|
|
910
|
+
});
|
|
911
|
+
|
|
912
|
+
messagesContainer.addEventListener('mouseleave', () => {
|
|
913
|
+
if (pressTimer) {
|
|
914
|
+
clearTimeout(pressTimer);
|
|
915
|
+
pressTimer = null;
|
|
916
|
+
}
|
|
917
|
+
});
|
|
918
|
+
|
|
919
|
+
/**
|
|
920
|
+
* Copy message text to clipboard
|
|
921
|
+
*/
|
|
922
|
+
async function copyMessageText(messageEl) {
|
|
923
|
+
const textContent = messageEl.querySelector('.message-text');
|
|
924
|
+
if (!textContent) {
|
|
925
|
+
return;
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
const text = textContent.textContent;
|
|
929
|
+
|
|
930
|
+
try {
|
|
931
|
+
await navigator.clipboard.writeText(text);
|
|
932
|
+
showToast('📋 Copied to clipboard');
|
|
933
|
+
|
|
934
|
+
// Visual feedback
|
|
935
|
+
messageEl.style.opacity = '0.5';
|
|
936
|
+
setTimeout(() => {
|
|
937
|
+
messageEl.style.opacity = '1';
|
|
938
|
+
}, 300);
|
|
939
|
+
} catch (err) {
|
|
940
|
+
console.error('[Chat] Copy failed:', err);
|
|
941
|
+
showToast('Failed to copy', 'error');
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
// =============================================
|
|
947
|
+
// Voice Input (Web Speech API)
|
|
948
|
+
// =============================================
|
|
949
|
+
|
|
950
|
+
/**
|
|
951
|
+
* Initialize speech recognition
|
|
952
|
+
*/
|
|
953
|
+
initSpeechRecognition() {
|
|
954
|
+
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
|
|
955
|
+
|
|
956
|
+
if (!SpeechRecognition) {
|
|
957
|
+
console.warn('[Voice] SpeechRecognition not supported');
|
|
958
|
+
const micBtn = document.getElementById('chat-mic');
|
|
959
|
+
if (micBtn) {
|
|
960
|
+
micBtn.style.display = 'none';
|
|
961
|
+
}
|
|
962
|
+
return;
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
this.speechRecognition = new SpeechRecognition();
|
|
966
|
+
this.speechRecognition.lang = navigator.language || 'ko-KR';
|
|
967
|
+
this.speechRecognition.continuous = true; // Enable continuous recognition for longer phrases
|
|
968
|
+
this.speechRecognition.interimResults = true;
|
|
969
|
+
this.speechRecognition.maxAlternatives = 3; // Get multiple recognition candidates for better accuracy
|
|
970
|
+
|
|
971
|
+
this.speechRecognition.onresult = (event) => {
|
|
972
|
+
const input = document.getElementById('chat-input');
|
|
973
|
+
let interimTranscript = '';
|
|
974
|
+
let finalTranscript = '';
|
|
975
|
+
|
|
976
|
+
// Build transcript from NEW results only (use resultIndex)
|
|
977
|
+
console.log(
|
|
978
|
+
'[Voice] onresult fired, resultIndex:',
|
|
979
|
+
event.resultIndex,
|
|
980
|
+
'total results:',
|
|
981
|
+
event.results.length
|
|
982
|
+
);
|
|
983
|
+
|
|
984
|
+
for (let i = event.resultIndex; i < event.results.length; i++) {
|
|
985
|
+
const result = event.results[i];
|
|
986
|
+
const transcript = result[0].transcript;
|
|
987
|
+
|
|
988
|
+
if (result.isFinal) {
|
|
989
|
+
finalTranscript += transcript;
|
|
990
|
+
console.log(
|
|
991
|
+
'[Voice] Final result [' + i + ']:',
|
|
992
|
+
transcript,
|
|
993
|
+
'Confidence:',
|
|
994
|
+
result[0].confidence
|
|
995
|
+
);
|
|
996
|
+
} else {
|
|
997
|
+
interimTranscript += transcript;
|
|
998
|
+
console.log('[Voice] Interim result [' + i + ']:', transcript);
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
// Handle final transcripts - accumulate them
|
|
1003
|
+
if (finalTranscript) {
|
|
1004
|
+
// Add space before appending if there's already text
|
|
1005
|
+
if (this.accumulatedTranscript) {
|
|
1006
|
+
this.accumulatedTranscript += ' ' + finalTranscript;
|
|
1007
|
+
} else {
|
|
1008
|
+
this.accumulatedTranscript = finalTranscript;
|
|
1009
|
+
}
|
|
1010
|
+
input.value = this.accumulatedTranscript;
|
|
1011
|
+
input.classList.remove('voice-active');
|
|
1012
|
+
console.log('[Voice] Accumulated transcript:', this.accumulatedTranscript);
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
// Handle interim transcripts - show temporarily with accumulated text
|
|
1016
|
+
if (interimTranscript) {
|
|
1017
|
+
const displayText = this.accumulatedTranscript
|
|
1018
|
+
? this.accumulatedTranscript + ' ' + interimTranscript
|
|
1019
|
+
: interimTranscript;
|
|
1020
|
+
input.value = displayText;
|
|
1021
|
+
input.classList.add('voice-active');
|
|
1022
|
+
console.log('[Voice] Showing interim (temp):', displayText);
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
autoResizeTextarea(input);
|
|
1026
|
+
|
|
1027
|
+
// Reset silence timer on each result
|
|
1028
|
+
clearTimeout(this.silenceTimeout);
|
|
1029
|
+
this.silenceTimeout = setTimeout(() => {
|
|
1030
|
+
if (this.isRecording) {
|
|
1031
|
+
console.log('[Voice] Silence detected, stopping...');
|
|
1032
|
+
this.stopVoice();
|
|
1033
|
+
}
|
|
1034
|
+
}, this.silenceDelay);
|
|
1035
|
+
};
|
|
1036
|
+
|
|
1037
|
+
this.speechRecognition.onend = () => {
|
|
1038
|
+
console.log('[Voice] Recognition ended');
|
|
1039
|
+
this.stopVoice();
|
|
1040
|
+
};
|
|
1041
|
+
|
|
1042
|
+
this.speechRecognition.onerror = (event) => {
|
|
1043
|
+
console.error('[Voice] Error:', event.error);
|
|
1044
|
+
this.stopVoice();
|
|
1045
|
+
|
|
1046
|
+
let errorMessage = '';
|
|
1047
|
+
switch (event.error) {
|
|
1048
|
+
case 'not-allowed':
|
|
1049
|
+
errorMessage = '마이크 권한이 거부되었습니다. 브라우저 설정에서 마이크를 허용해주세요.';
|
|
1050
|
+
break;
|
|
1051
|
+
case 'no-speech':
|
|
1052
|
+
errorMessage = '음성이 감지되지 않았습니다. 다시 시도해주세요.';
|
|
1053
|
+
break;
|
|
1054
|
+
case 'network':
|
|
1055
|
+
errorMessage = '네트워크 오류가 발생했습니다.';
|
|
1056
|
+
break;
|
|
1057
|
+
default:
|
|
1058
|
+
errorMessage = `음성 인식 오류: ${event.error}`;
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
this.addSystemMessage(errorMessage, 'error');
|
|
1062
|
+
};
|
|
1063
|
+
|
|
1064
|
+
console.log('[Voice] SpeechRecognition initialized (lang:', this.speechRecognition.lang + ')');
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
/**
|
|
1068
|
+
* Toggle voice input
|
|
1069
|
+
*/
|
|
1070
|
+
toggleVoice() {
|
|
1071
|
+
if (this.isRecording) {
|
|
1072
|
+
this.stopVoice();
|
|
1073
|
+
} else {
|
|
1074
|
+
this.startVoice();
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
/**
|
|
1079
|
+
* Start voice recording
|
|
1080
|
+
*/
|
|
1081
|
+
startVoice() {
|
|
1082
|
+
if (!this.speechRecognition) {
|
|
1083
|
+
this.addSystemMessage('이 브라우저에서는 음성 인식이 지원되지 않습니다.', 'error');
|
|
1084
|
+
return;
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
try {
|
|
1088
|
+
const micBtn = document.getElementById('chat-mic');
|
|
1089
|
+
const input = document.getElementById('chat-input');
|
|
1090
|
+
|
|
1091
|
+
// Clear input and accumulated transcript for new recording
|
|
1092
|
+
input.value = '';
|
|
1093
|
+
this.accumulatedTranscript = '';
|
|
1094
|
+
|
|
1095
|
+
this.speechRecognition.start();
|
|
1096
|
+
this.isRecording = true;
|
|
1097
|
+
|
|
1098
|
+
micBtn.classList.add('recording');
|
|
1099
|
+
input.classList.add('voice-active');
|
|
1100
|
+
input.placeholder = '말씀해주세요... (계속 말하면 이어서 인식됩니다)';
|
|
1101
|
+
|
|
1102
|
+
console.log('[Voice] Recording started (continuous mode)');
|
|
1103
|
+
console.log('[Voice] Settings:', {
|
|
1104
|
+
lang: this.speechRecognition.lang,
|
|
1105
|
+
continuous: this.speechRecognition.continuous,
|
|
1106
|
+
interimResults: this.speechRecognition.interimResults,
|
|
1107
|
+
maxAlternatives: this.speechRecognition.maxAlternatives,
|
|
1108
|
+
});
|
|
1109
|
+
|
|
1110
|
+
this.silenceTimeout = setTimeout(() => {
|
|
1111
|
+
if (this.isRecording) {
|
|
1112
|
+
this.stopVoice();
|
|
1113
|
+
}
|
|
1114
|
+
}, this.silenceDelay);
|
|
1115
|
+
} catch (err) {
|
|
1116
|
+
console.error('[Voice] Failed to start:', err);
|
|
1117
|
+
this.addSystemMessage('음성 인식을 시작할 수 없습니다.', 'error');
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
/**
|
|
1122
|
+
* Stop voice recording
|
|
1123
|
+
*/
|
|
1124
|
+
stopVoice() {
|
|
1125
|
+
if (!this.isRecording) {
|
|
1126
|
+
return;
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
clearTimeout(this.silenceTimeout);
|
|
1130
|
+
|
|
1131
|
+
try {
|
|
1132
|
+
this.speechRecognition.stop();
|
|
1133
|
+
} catch (e) {
|
|
1134
|
+
// Ignore errors
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
this.isRecording = false;
|
|
1138
|
+
|
|
1139
|
+
const micBtn = document.getElementById('chat-mic');
|
|
1140
|
+
const input = document.getElementById('chat-input');
|
|
1141
|
+
|
|
1142
|
+
micBtn.classList.remove('recording');
|
|
1143
|
+
input.classList.remove('voice-active');
|
|
1144
|
+
input.placeholder = 'Type your message...';
|
|
1145
|
+
|
|
1146
|
+
console.log('[Voice] Recording stopped');
|
|
1147
|
+
this.resetIdleTimer();
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
// =============================================
|
|
1151
|
+
// Text-to-Speech (TTS)
|
|
1152
|
+
// =============================================
|
|
1153
|
+
|
|
1154
|
+
/**
|
|
1155
|
+
* Initialize Speech Synthesis
|
|
1156
|
+
*/
|
|
1157
|
+
initSpeechSynthesis() {
|
|
1158
|
+
if (!this.speechSynthesis) {
|
|
1159
|
+
console.warn('[TTS] SpeechSynthesis not supported');
|
|
1160
|
+
return;
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
// Wait for voices to load
|
|
1164
|
+
const loadVoices = () => {
|
|
1165
|
+
const voices = this.speechSynthesis.getVoices();
|
|
1166
|
+
// Find Korean voice
|
|
1167
|
+
this.ttsVoice =
|
|
1168
|
+
voices.find((v) => v.lang === 'ko-KR') ||
|
|
1169
|
+
voices.find((v) => v.lang.startsWith('ko')) ||
|
|
1170
|
+
voices[0];
|
|
1171
|
+
|
|
1172
|
+
if (this.ttsVoice) {
|
|
1173
|
+
console.log('[TTS] Korean voice selected:', this.ttsVoice.name, this.ttsVoice.lang);
|
|
1174
|
+
} else {
|
|
1175
|
+
console.warn('[TTS] No Korean voice found, using default');
|
|
1176
|
+
}
|
|
1177
|
+
};
|
|
1178
|
+
|
|
1179
|
+
// Voices might not be loaded immediately
|
|
1180
|
+
if (this.speechSynthesis.getVoices().length > 0) {
|
|
1181
|
+
loadVoices();
|
|
1182
|
+
} else {
|
|
1183
|
+
this.speechSynthesis.onvoiceschanged = loadVoices;
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
console.log('[TTS] SpeechSynthesis initialized');
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
/**
|
|
1190
|
+
* Toggle TTS auto-play
|
|
1191
|
+
*/
|
|
1192
|
+
toggleTTS() {
|
|
1193
|
+
this.ttsEnabled = !this.ttsEnabled;
|
|
1194
|
+
const btn = document.getElementById('chat-tts-toggle');
|
|
1195
|
+
|
|
1196
|
+
if (btn) {
|
|
1197
|
+
btn.classList.toggle('active', this.ttsEnabled);
|
|
1198
|
+
btn.title = this.ttsEnabled
|
|
1199
|
+
? 'TTS 활성화됨 (클릭하여 끄기)'
|
|
1200
|
+
: 'TTS 비활성화됨 (클릭하여 켜기)';
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
console.log('[TTS] Auto-play:', this.ttsEnabled ? 'ON' : 'OFF');
|
|
1204
|
+
showToast(this.ttsEnabled ? '🔊 TTS 활성화' : '🔇 TTS 비활성화');
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
/**
|
|
1208
|
+
* Toggle hands-free mode
|
|
1209
|
+
*/
|
|
1210
|
+
toggleHandsFree() {
|
|
1211
|
+
this.handsFreeMode = !this.handsFreeMode;
|
|
1212
|
+
const btn = document.getElementById('chat-handsfree-toggle');
|
|
1213
|
+
|
|
1214
|
+
if (btn) {
|
|
1215
|
+
btn.classList.toggle('active', this.handsFreeMode);
|
|
1216
|
+
btn.title = this.handsFreeMode ? '핸즈프리 활성화됨' : '핸즈프리 비활성화됨';
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
console.log('[TTS] Hands-free mode:', this.handsFreeMode ? 'ON' : 'OFF');
|
|
1220
|
+
showToast(this.handsFreeMode ? '🎙️ 핸즈프리 모드 활성화' : '🎙️ 핸즈프리 모드 비활성화');
|
|
1221
|
+
|
|
1222
|
+
// Enable TTS automatically when hands-free is enabled
|
|
1223
|
+
if (this.handsFreeMode && !this.ttsEnabled) {
|
|
1224
|
+
this.toggleTTS();
|
|
1225
|
+
}
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
/**
|
|
1229
|
+
* Speak text using TTS
|
|
1230
|
+
*/
|
|
1231
|
+
speak(text) {
|
|
1232
|
+
if (!this.speechSynthesis || !text) {
|
|
1233
|
+
return;
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1236
|
+
// Stop any ongoing speech
|
|
1237
|
+
this.stopSpeaking();
|
|
1238
|
+
|
|
1239
|
+
const utterance = new SpeechSynthesisUtterance(text);
|
|
1240
|
+
utterance.voice = this.ttsVoice;
|
|
1241
|
+
utterance.rate = this.ttsRate;
|
|
1242
|
+
utterance.pitch = this.ttsPitch;
|
|
1243
|
+
utterance.lang = this.ttsVoice?.lang || navigator.language || 'ko-KR';
|
|
1244
|
+
|
|
1245
|
+
utterance.onstart = () => {
|
|
1246
|
+
this.isSpeaking = true;
|
|
1247
|
+
console.log('[TTS] Speaking started');
|
|
1248
|
+
};
|
|
1249
|
+
|
|
1250
|
+
utterance.onend = () => {
|
|
1251
|
+
this.isSpeaking = false;
|
|
1252
|
+
console.log('[TTS] Speaking ended');
|
|
1253
|
+
|
|
1254
|
+
// If hands-free mode, start listening after TTS finishes
|
|
1255
|
+
if (this.handsFreeMode && !this.isRecording) {
|
|
1256
|
+
console.log('[TTS] Hands-free mode: auto-starting voice input');
|
|
1257
|
+
setTimeout(() => {
|
|
1258
|
+
this.startVoice();
|
|
1259
|
+
}, 500); // Small delay for smooth transition
|
|
1260
|
+
}
|
|
1261
|
+
};
|
|
1262
|
+
|
|
1263
|
+
utterance.onerror = (event) => {
|
|
1264
|
+
this.isSpeaking = false;
|
|
1265
|
+
console.error('[TTS] Error:', event.error);
|
|
1266
|
+
};
|
|
1267
|
+
|
|
1268
|
+
this.speechSynthesis.speak(utterance);
|
|
1269
|
+
console.log('[TTS] Speaking:', text.substring(0, 50) + '...');
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
/**
|
|
1273
|
+
* Stop speaking
|
|
1274
|
+
*/
|
|
1275
|
+
stopSpeaking() {
|
|
1276
|
+
if (this.speechSynthesis && this.isSpeaking) {
|
|
1277
|
+
this.speechSynthesis.cancel();
|
|
1278
|
+
this.isSpeaking = false;
|
|
1279
|
+
console.log('[TTS] Speaking stopped');
|
|
1280
|
+
}
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1283
|
+
/**
|
|
1284
|
+
* Set TTS rate (0.5 - 2.0)
|
|
1285
|
+
*/
|
|
1286
|
+
setTTSRate(rate) {
|
|
1287
|
+
this.ttsRate = Math.max(0.5, Math.min(2.0, rate));
|
|
1288
|
+
console.log('[TTS] Rate set to:', this.ttsRate);
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
// =============================================
|
|
1292
|
+
// History Management
|
|
1293
|
+
// =============================================
|
|
1294
|
+
|
|
1295
|
+
/**
|
|
1296
|
+
* Save message to history
|
|
1297
|
+
*/
|
|
1298
|
+
saveToHistory(role, content, timestamp = new Date()) {
|
|
1299
|
+
if (!this.sessionId) {
|
|
1300
|
+
return;
|
|
1301
|
+
}
|
|
1302
|
+
|
|
1303
|
+
this.history.push({
|
|
1304
|
+
role,
|
|
1305
|
+
content,
|
|
1306
|
+
timestamp: timestamp.toISOString(),
|
|
1307
|
+
});
|
|
1308
|
+
|
|
1309
|
+
if (this.history.length > this.maxHistoryMessages) {
|
|
1310
|
+
this.history = this.history.slice(-this.maxHistoryMessages);
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
try {
|
|
1314
|
+
const storageKey = this.historyPrefix + this.sessionId;
|
|
1315
|
+
const storageData = {
|
|
1316
|
+
history: this.history,
|
|
1317
|
+
savedAt: Date.now(),
|
|
1318
|
+
};
|
|
1319
|
+
localStorage.setItem(storageKey, JSON.stringify(storageData));
|
|
1320
|
+
} catch (e) {
|
|
1321
|
+
console.warn('[Chat] Failed to save history:', e);
|
|
1322
|
+
}
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
|
+
/**
|
|
1326
|
+
* Load history from localStorage
|
|
1327
|
+
*/
|
|
1328
|
+
loadHistory(sessionId) {
|
|
1329
|
+
try {
|
|
1330
|
+
const storageKey = this.historyPrefix + sessionId;
|
|
1331
|
+
const stored = localStorage.getItem(storageKey);
|
|
1332
|
+
|
|
1333
|
+
if (!stored) {
|
|
1334
|
+
return null;
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1337
|
+
const data = JSON.parse(stored);
|
|
1338
|
+
|
|
1339
|
+
if (Date.now() - data.savedAt > this.historyExpiryMs) {
|
|
1340
|
+
localStorage.removeItem(storageKey);
|
|
1341
|
+
return null;
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1344
|
+
return data.history || [];
|
|
1345
|
+
} catch (e) {
|
|
1346
|
+
console.warn('[Chat] Failed to load history:', e);
|
|
1347
|
+
return null;
|
|
1348
|
+
}
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
/**
|
|
1352
|
+
* Restore chat history
|
|
1353
|
+
*/
|
|
1354
|
+
restoreHistory(sessionId) {
|
|
1355
|
+
const history = this.loadHistory(sessionId);
|
|
1356
|
+
|
|
1357
|
+
if (!history || history.length === 0) {
|
|
1358
|
+
return false;
|
|
1359
|
+
}
|
|
1360
|
+
|
|
1361
|
+
this.history = history;
|
|
1362
|
+
const container = document.getElementById('chat-messages');
|
|
1363
|
+
|
|
1364
|
+
this.removePlaceholder();
|
|
1365
|
+
|
|
1366
|
+
history.forEach((msg) => {
|
|
1367
|
+
const msgEl = document.createElement('div');
|
|
1368
|
+
msgEl.className = `chat-message ${msg.role}`;
|
|
1369
|
+
|
|
1370
|
+
if (msg.role === 'user') {
|
|
1371
|
+
msgEl.innerHTML = `
|
|
1372
|
+
<div class="message-content">${escapeHtml(msg.content)}</div>
|
|
1373
|
+
<div class="message-time">${formatMessageTime(new Date(msg.timestamp))}</div>
|
|
1374
|
+
`;
|
|
1375
|
+
} else if (msg.role === 'assistant') {
|
|
1376
|
+
msgEl.innerHTML = `
|
|
1377
|
+
<div class="message-content">${formatAssistantMessage(msg.content)}</div>
|
|
1378
|
+
<div class="message-time">${formatMessageTime(new Date(msg.timestamp))}</div>
|
|
1379
|
+
`;
|
|
1380
|
+
} else if (msg.role === 'system') {
|
|
1381
|
+
msgEl.innerHTML = `
|
|
1382
|
+
<div class="message-content">${escapeHtml(msg.content)}</div>
|
|
1383
|
+
`;
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1386
|
+
container.appendChild(msgEl);
|
|
1387
|
+
});
|
|
1388
|
+
|
|
1389
|
+
scrollToBottom(container);
|
|
1390
|
+
showToast('Previous conversation restored');
|
|
1391
|
+
|
|
1392
|
+
return true;
|
|
1393
|
+
}
|
|
1394
|
+
|
|
1395
|
+
/**
|
|
1396
|
+
* Display history received from server
|
|
1397
|
+
*/
|
|
1398
|
+
displayHistory(messages) {
|
|
1399
|
+
const container = document.getElementById('chat-messages');
|
|
1400
|
+
if (!container) {
|
|
1401
|
+
return;
|
|
1402
|
+
}
|
|
1403
|
+
|
|
1404
|
+
// Clear existing messages first (server history is authoritative)
|
|
1405
|
+
container.innerHTML = '';
|
|
1406
|
+
this.history = [];
|
|
1407
|
+
|
|
1408
|
+
messages.forEach((msg) => {
|
|
1409
|
+
const msgEl = document.createElement('div');
|
|
1410
|
+
msgEl.className = `chat-message ${msg.role}`;
|
|
1411
|
+
|
|
1412
|
+
const timestamp = msg.timestamp ? new Date(msg.timestamp) : new Date();
|
|
1413
|
+
|
|
1414
|
+
if (msg.role === 'user') {
|
|
1415
|
+
msgEl.innerHTML = `
|
|
1416
|
+
<div class="message-content">${escapeHtml(msg.content)}</div>
|
|
1417
|
+
<div class="message-time">${formatMessageTime(timestamp)}</div>
|
|
1418
|
+
`;
|
|
1419
|
+
} else if (msg.role === 'assistant') {
|
|
1420
|
+
msgEl.innerHTML = `
|
|
1421
|
+
<div class="message-content">${formatAssistantMessage(msg.content)}</div>
|
|
1422
|
+
<div class="message-time">${formatMessageTime(timestamp)}</div>
|
|
1423
|
+
`;
|
|
1424
|
+
} else if (msg.role === 'system') {
|
|
1425
|
+
msgEl.innerHTML = `
|
|
1426
|
+
<div class="message-content">${escapeHtml(msg.content)}</div>
|
|
1427
|
+
`;
|
|
1428
|
+
}
|
|
1429
|
+
|
|
1430
|
+
container.appendChild(msgEl);
|
|
1431
|
+
});
|
|
1432
|
+
|
|
1433
|
+
scrollToBottom(container);
|
|
1434
|
+
console.log('[Chat] Displayed', messages.length, 'history messages');
|
|
1435
|
+
}
|
|
1436
|
+
|
|
1437
|
+
/**
|
|
1438
|
+
* Clear chat history
|
|
1439
|
+
*/
|
|
1440
|
+
clearHistory(sessionId = null) {
|
|
1441
|
+
try {
|
|
1442
|
+
const storageKey = this.historyPrefix + (sessionId || this.sessionId);
|
|
1443
|
+
localStorage.removeItem(storageKey);
|
|
1444
|
+
this.history = [];
|
|
1445
|
+
} catch (e) {
|
|
1446
|
+
console.warn('[Chat] Failed to clear history:', e);
|
|
1447
|
+
}
|
|
1448
|
+
}
|
|
1449
|
+
|
|
1450
|
+
/**
|
|
1451
|
+
* Clean up expired histories
|
|
1452
|
+
*/
|
|
1453
|
+
cleanupExpiredHistories() {
|
|
1454
|
+
try {
|
|
1455
|
+
const keys = Object.keys(localStorage);
|
|
1456
|
+
const now = Date.now();
|
|
1457
|
+
|
|
1458
|
+
keys.forEach((key) => {
|
|
1459
|
+
if (key.startsWith(this.historyPrefix)) {
|
|
1460
|
+
try {
|
|
1461
|
+
const data = JSON.parse(localStorage.getItem(key));
|
|
1462
|
+
if (data && data.savedAt && now - data.savedAt > this.historyExpiryMs) {
|
|
1463
|
+
localStorage.removeItem(key);
|
|
1464
|
+
console.log('[Chat] Cleaned up expired history:', key);
|
|
1465
|
+
}
|
|
1466
|
+
} catch (e) {
|
|
1467
|
+
// Invalid data, remove it
|
|
1468
|
+
localStorage.removeItem(key);
|
|
1469
|
+
}
|
|
1470
|
+
}
|
|
1471
|
+
});
|
|
1472
|
+
} catch (e) {
|
|
1473
|
+
console.warn('[Chat] Failed to cleanup histories:', e);
|
|
1474
|
+
}
|
|
1475
|
+
}
|
|
1476
|
+
|
|
1477
|
+
// =============================================
|
|
1478
|
+
// Checkpoint Management
|
|
1479
|
+
// =============================================
|
|
1480
|
+
|
|
1481
|
+
/**
|
|
1482
|
+
* Generate checkpoint summary from current session (for manual /checkpoint command)
|
|
1483
|
+
*/
|
|
1484
|
+
generateCheckpointSummary() {
|
|
1485
|
+
const summary = {
|
|
1486
|
+
sessionId: this.sessionId,
|
|
1487
|
+
messageCount: this.history.length,
|
|
1488
|
+
lastActivity: new Date().toISOString(),
|
|
1489
|
+
messages: this.history.slice(-10).map((msg) => ({
|
|
1490
|
+
role: msg.role,
|
|
1491
|
+
preview: msg.content.substring(0, 100),
|
|
1492
|
+
timestamp: msg.timestamp,
|
|
1493
|
+
})),
|
|
1494
|
+
};
|
|
1495
|
+
|
|
1496
|
+
return JSON.stringify(summary, null, 2);
|
|
1497
|
+
}
|
|
1498
|
+
|
|
1499
|
+
/**
|
|
1500
|
+
* Save checkpoint via API
|
|
1501
|
+
*/
|
|
1502
|
+
async saveCheckpoint(summary) {
|
|
1503
|
+
const response = await fetch('/api/checkpoint/save', {
|
|
1504
|
+
method: 'POST',
|
|
1505
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1506
|
+
body: JSON.stringify({ summary }),
|
|
1507
|
+
});
|
|
1508
|
+
|
|
1509
|
+
if (!response.ok) {
|
|
1510
|
+
throw new Error('Failed to save checkpoint');
|
|
1511
|
+
}
|
|
1512
|
+
|
|
1513
|
+
return await response.json();
|
|
1514
|
+
}
|
|
1515
|
+
|
|
1516
|
+
/**
|
|
1517
|
+
* Load last checkpoint via API
|
|
1518
|
+
*/
|
|
1519
|
+
async loadCheckpoint() {
|
|
1520
|
+
const response = await fetch('/api/checkpoint/load');
|
|
1521
|
+
|
|
1522
|
+
if (!response.ok) {
|
|
1523
|
+
if (response.status === 404) {
|
|
1524
|
+
return null; // No checkpoint found
|
|
1525
|
+
}
|
|
1526
|
+
throw new Error('Failed to load checkpoint');
|
|
1527
|
+
}
|
|
1528
|
+
|
|
1529
|
+
return await response.json();
|
|
1530
|
+
}
|
|
1531
|
+
|
|
1532
|
+
/**
|
|
1533
|
+
* Check for resumable session on init
|
|
1534
|
+
*/
|
|
1535
|
+
async checkForResumableSession() {
|
|
1536
|
+
try {
|
|
1537
|
+
const checkpoint = await this.loadCheckpoint();
|
|
1538
|
+
if (checkpoint) {
|
|
1539
|
+
// Show resume banner
|
|
1540
|
+
const banner = document.getElementById('session-resume-banner');
|
|
1541
|
+
if (banner) {
|
|
1542
|
+
banner.style.display = 'flex';
|
|
1543
|
+
console.log('[Chat] Resume banner shown');
|
|
1544
|
+
}
|
|
1545
|
+
}
|
|
1546
|
+
} catch (error) {
|
|
1547
|
+
// Silent fail - no checkpoint is okay
|
|
1548
|
+
console.log('[Chat] No resumable session');
|
|
1549
|
+
}
|
|
1550
|
+
}
|
|
1551
|
+
|
|
1552
|
+
/**
|
|
1553
|
+
* Cleanup resources when module is destroyed
|
|
1554
|
+
* Prevents memory leaks by cleaning up timers, connections, and APIs
|
|
1555
|
+
*/
|
|
1556
|
+
cleanup() {
|
|
1557
|
+
// Clean up WebSocket
|
|
1558
|
+
if (this.ws) {
|
|
1559
|
+
this.ws.close();
|
|
1560
|
+
this.ws = null;
|
|
1561
|
+
}
|
|
1562
|
+
|
|
1563
|
+
// Clean up timers
|
|
1564
|
+
if (this.silenceTimeout) {
|
|
1565
|
+
clearTimeout(this.silenceTimeout);
|
|
1566
|
+
this.silenceTimeout = null;
|
|
1567
|
+
}
|
|
1568
|
+
if (this.idleTimer) {
|
|
1569
|
+
clearTimeout(this.idleTimer);
|
|
1570
|
+
this.idleTimer = null;
|
|
1571
|
+
}
|
|
1572
|
+
|
|
1573
|
+
// Clean up Speech Recognition
|
|
1574
|
+
if (this.speechRecognition) {
|
|
1575
|
+
this.speechRecognition.stop();
|
|
1576
|
+
this.speechRecognition = null;
|
|
1577
|
+
}
|
|
1578
|
+
|
|
1579
|
+
// Clean up Speech Synthesis
|
|
1580
|
+
if (this.isSpeaking) {
|
|
1581
|
+
this.speechSynthesis.cancel();
|
|
1582
|
+
this.isSpeaking = false;
|
|
1583
|
+
}
|
|
1584
|
+
|
|
1585
|
+
console.log('[Chat] Cleanup completed');
|
|
1586
|
+
}
|
|
1587
|
+
}
|