@leverageaiapps/locus-dev 1.1.3

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 (58) hide show
  1. package/Dockerfile +29 -0
  2. package/LICENSE +21 -0
  3. package/README.md +153 -0
  4. package/dist/capture.d.ts +3 -0
  5. package/dist/capture.d.ts.map +1 -0
  6. package/dist/capture.js +134 -0
  7. package/dist/capture.js.map +1 -0
  8. package/dist/config.d.ts +7 -0
  9. package/dist/config.d.ts.map +1 -0
  10. package/dist/config.js +84 -0
  11. package/dist/config.js.map +1 -0
  12. package/dist/context-extractor.d.ts +17 -0
  13. package/dist/context-extractor.d.ts.map +1 -0
  14. package/dist/context-extractor.js +118 -0
  15. package/dist/context-extractor.js.map +1 -0
  16. package/dist/debug-logger.d.ts +19 -0
  17. package/dist/debug-logger.d.ts.map +1 -0
  18. package/dist/debug-logger.js +48 -0
  19. package/dist/debug-logger.js.map +1 -0
  20. package/dist/exec.d.ts +20 -0
  21. package/dist/exec.d.ts.map +1 -0
  22. package/dist/exec.js +158 -0
  23. package/dist/exec.js.map +1 -0
  24. package/dist/index.d.ts +3 -0
  25. package/dist/index.d.ts.map +1 -0
  26. package/dist/index.js +83 -0
  27. package/dist/index.js.map +1 -0
  28. package/dist/pty.d.ts +7 -0
  29. package/dist/pty.d.ts.map +1 -0
  30. package/dist/pty.js +27 -0
  31. package/dist/pty.js.map +1 -0
  32. package/dist/relay.d.ts +5 -0
  33. package/dist/relay.d.ts.map +1 -0
  34. package/dist/relay.js +131 -0
  35. package/dist/relay.js.map +1 -0
  36. package/dist/session.d.ts +6 -0
  37. package/dist/session.d.ts.map +1 -0
  38. package/dist/session.js +250 -0
  39. package/dist/session.js.map +1 -0
  40. package/dist/voice-recognition-modelscope.d.ts +50 -0
  41. package/dist/voice-recognition-modelscope.d.ts.map +1 -0
  42. package/dist/voice-recognition-modelscope.js +171 -0
  43. package/dist/voice-recognition-modelscope.js.map +1 -0
  44. package/dist/vortex-tunnel.d.ts +9 -0
  45. package/dist/vortex-tunnel.d.ts.map +1 -0
  46. package/dist/vortex-tunnel.js +589 -0
  47. package/dist/vortex-tunnel.js.map +1 -0
  48. package/dist/web-server.d.ts +6 -0
  49. package/dist/web-server.d.ts.map +1 -0
  50. package/dist/web-server.js +2096 -0
  51. package/dist/web-server.js.map +1 -0
  52. package/docs/CNAME +1 -0
  53. package/docs/index.html +492 -0
  54. package/docs/install.sh +329 -0
  55. package/install.sh +329 -0
  56. package/package.json +69 -0
  57. package/scripts/postinstall.js +66 -0
  58. package/scripts/verify-install.js +128 -0
@@ -0,0 +1,2096 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ var __importDefault = (this && this.__importDefault) || function (mod) {
36
+ return (mod && mod.__esModule) ? mod : { "default": mod };
37
+ };
38
+ Object.defineProperty(exports, "__esModule", { value: true });
39
+ exports.startWebServer = startWebServer;
40
+ exports.stopWebServer = stopWebServer;
41
+ const express_1 = __importDefault(require("express"));
42
+ const cors_1 = __importDefault(require("cors"));
43
+ const cookie_parser_1 = __importDefault(require("cookie-parser"));
44
+ const http_1 = require("http");
45
+ const ws_1 = require("ws");
46
+ const exec_1 = require("./exec");
47
+ let httpServer = null;
48
+ let wss = null;
49
+ let connectedClients = new Map();
50
+ let pendingCommands = new Map();
51
+ const COMMAND_TIMEOUT = 60000; // 60 seconds max wait
52
+ // PIN authentication state
53
+ let serverPIN = '';
54
+ let failedAttempts = new Map();
55
+ let blockedIPs = new Set();
56
+ const MAX_FAILED_ATTEMPTS = 10;
57
+ const BLOCK_DURATION = 60000; // 1 minute in milliseconds
58
+ // Terminal output buffer for new connections
59
+ let outputBuffer = [];
60
+ const MAX_BUFFER_SIZE = 5000;
61
+ // Generate unique client ID
62
+ let clientIdCounter = 0;
63
+ function generateClientId() {
64
+ return `client-${Date.now()}-${++clientIdCounter}`;
65
+ }
66
+ // Size tracking is not needed in exec mode
67
+ function applyMinSize() {
68
+ // No-op in exec mode as we don't have terminal size constraints
69
+ }
70
+ /**
71
+ * Get client IP address from request
72
+ */
73
+ function getClientIP(req) {
74
+ return req.ip || req.connection.remoteAddress || '127.0.0.1';
75
+ }
76
+ /**
77
+ * Check if IP is blocked
78
+ */
79
+ function isIPBlocked(ip) {
80
+ return blockedIPs.has(ip);
81
+ }
82
+ /**
83
+ * Check if user is authenticated via cookie
84
+ */
85
+ function isAuthenticated(req) {
86
+ return req.cookies && req.cookies.auth === serverPIN;
87
+ }
88
+ /**
89
+ * PIN authentication middleware
90
+ */
91
+ function requireAuth(req, res, next) {
92
+ const clientIP = getClientIP(req);
93
+ // Check if IP is blocked
94
+ if (isIPBlocked(clientIP)) {
95
+ res.status(429).json({ error: 'IP blocked due to too many failed attempts' });
96
+ return;
97
+ }
98
+ // Check if authenticated
99
+ if (isAuthenticated(req)) {
100
+ next();
101
+ return;
102
+ }
103
+ // Not authenticated, redirect to login
104
+ if (req.path === '/login' || req.path === '/api/login') {
105
+ next();
106
+ return;
107
+ }
108
+ res.redirect('/login');
109
+ }
110
+ /**
111
+ * Generate login page HTML
112
+ */
113
+ function generateLoginPage() {
114
+ return `
115
+ <!DOCTYPE html>
116
+ <html>
117
+ <head>
118
+ <meta charset="UTF-8">
119
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
120
+ <title>Locus - Enter PIN</title>
121
+ <style>
122
+ * { margin: 0; padding: 0; box-sizing: border-box; }
123
+ body {
124
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
125
+ background: #0a0a0a;
126
+ color: #fff;
127
+ min-height: 100vh;
128
+ display: flex;
129
+ align-items: center;
130
+ justify-content: center;
131
+ }
132
+ .login-container {
133
+ background: #1a1a1a;
134
+ padding: 2rem;
135
+ border-radius: 12px;
136
+ border: 1px solid #333;
137
+ max-width: 400px;
138
+ width: 100%;
139
+ margin: 1rem;
140
+ }
141
+ .logo {
142
+ text-align: center;
143
+ margin-bottom: 2rem;
144
+ }
145
+ .logo h1 {
146
+ color: #3b82f6;
147
+ font-size: 1.5rem;
148
+ margin-bottom: 0.5rem;
149
+ }
150
+ .logo p {
151
+ color: #888;
152
+ font-size: 0.9rem;
153
+ }
154
+ .form-group {
155
+ margin-bottom: 1.5rem;
156
+ }
157
+ label {
158
+ display: block;
159
+ margin-bottom: 0.5rem;
160
+ color: #ccc;
161
+ }
162
+ input[type="text"] {
163
+ width: 100%;
164
+ padding: 0.75rem;
165
+ background: #0a0a0a;
166
+ border: 1px solid #333;
167
+ border-radius: 6px;
168
+ color: #fff;
169
+ font-size: 1.1rem;
170
+ text-align: center;
171
+ letter-spacing: 0.1em;
172
+ }
173
+ input[type="text"]:focus {
174
+ outline: none;
175
+ border-color: #3b82f6;
176
+ }
177
+ .submit-btn {
178
+ width: 100%;
179
+ padding: 0.75rem;
180
+ background: #3b82f6;
181
+ border: none;
182
+ border-radius: 6px;
183
+ color: white;
184
+ font-size: 1rem;
185
+ cursor: pointer;
186
+ transition: background 0.2s;
187
+ }
188
+ .submit-btn:hover {
189
+ background: #2563eb;
190
+ }
191
+ .submit-btn:disabled {
192
+ background: #555;
193
+ cursor: not-allowed;
194
+ }
195
+ .error {
196
+ color: #ef4444;
197
+ font-size: 0.9rem;
198
+ margin-top: 0.5rem;
199
+ text-align: center;
200
+ }
201
+ .info {
202
+ color: #888;
203
+ font-size: 0.8rem;
204
+ text-align: center;
205
+ margin-top: 1rem;
206
+ }
207
+ </style>
208
+ </head>
209
+ <body>
210
+ <div class="login-container">
211
+ <div class="logo">
212
+ <h1>🚀 Locus</h1>
213
+ <p>Enter your 6-digit PIN to access the terminal</p>
214
+ </div>
215
+
216
+ <form id="loginForm">
217
+ <div class="form-group">
218
+ <label for="pin">PIN</label>
219
+ <input type="text" id="pin" name="pin" placeholder="000000" maxlength="6" required autocomplete="off">
220
+ </div>
221
+ <button type="submit" class="submit-btn">Access Terminal</button>
222
+ <div id="error-message" class="error"></div>
223
+ </form>
224
+
225
+ <div class="info">
226
+ The PIN was displayed when the server started.
227
+ </div>
228
+ </div>
229
+
230
+ <script>
231
+ const form = document.getElementById('loginForm');
232
+ const pinInput = document.getElementById('pin');
233
+ const errorDiv = document.getElementById('error-message');
234
+ const submitBtn = form.querySelector('.submit-btn');
235
+
236
+ // Auto-focus on PIN input
237
+ pinInput.focus();
238
+
239
+ // Allow only digits
240
+ pinInput.addEventListener('input', (e) => {
241
+ e.target.value = e.target.value.replace(/[^0-9]/g, '');
242
+ });
243
+
244
+ form.addEventListener('submit', async (e) => {
245
+ e.preventDefault();
246
+
247
+ const pin = pinInput.value.trim();
248
+
249
+ if (pin.length !== 6) {
250
+ errorDiv.textContent = 'PIN must be exactly 6 digits';
251
+ return;
252
+ }
253
+
254
+ submitBtn.disabled = true;
255
+ submitBtn.textContent = 'Verifying...';
256
+ errorDiv.textContent = '';
257
+
258
+ try {
259
+ const response = await fetch('/api/login', {
260
+ method: 'POST',
261
+ headers: { 'Content-Type': 'application/json' },
262
+ body: JSON.stringify({ pin })
263
+ });
264
+
265
+ const result = await response.json();
266
+
267
+ if (response.ok) {
268
+ // Success - redirect to main page
269
+ window.location.href = '/';
270
+ } else {
271
+ errorDiv.textContent = result.error || 'Authentication failed';
272
+ pinInput.value = '';
273
+ pinInput.focus();
274
+ }
275
+ } catch (error) {
276
+ errorDiv.textContent = 'Network error. Please try again.';
277
+ } finally {
278
+ submitBtn.disabled = false;
279
+ submitBtn.textContent = 'Access Terminal';
280
+ }
281
+ });
282
+ </script>
283
+ </body>
284
+ </html>`;
285
+ }
286
+ // ASR debug logging flag
287
+ let debugAsrEnabled = false;
288
+ // Helper function for ASR debug logging
289
+ function asrLog(...args) {
290
+ if (debugAsrEnabled) {
291
+ console.log(...args);
292
+ }
293
+ }
294
+ // Process transcript with Claude API
295
+ async function processWithClaude(ws, transcript, context, apiKey, model) {
296
+ asrLog('[Claude] Processing transcript with Claude API');
297
+ asrLog('[Claude] Transcript:', transcript);
298
+ asrLog('[Claude] Context length:', context?.length || 0);
299
+ asrLog('[Claude] Model:', model);
300
+ try {
301
+ const systemPrompt = `You are a speech-to-text correction assistant for a terminal/coding environment. Your ONLY job is to fix transcription errors based on context and common sense.
302
+
303
+ IMPORTANT: Common technical terms that are often misrecognized:
304
+ - "Claude" (AI assistant by Anthropic) is often mistranscribed as "cloud", "克劳德", or "科劳德"
305
+ - "Claude Code" (coding assistant) is often mistranscribed as "cloud code"
306
+ - "Claude API" is often mistranscribed as "cloud API" or "cloud的API"
307
+ - "API" not "a p i" or "ap i"
308
+ - "npm" not "n p m"
309
+ - "git" not "get"
310
+ - "GitHub" not "get hub"
311
+ - "Docker" not "doctor"
312
+ - "webpack" not "web pack"
313
+ - "React" not "react" (capitalize)
314
+ - "Vue" not "view"
315
+ - "VS Code" not "vs coat" or "vscode"
316
+ - "Python" not "python" (capitalize properly)
317
+ - "JavaScript" not "java script"
318
+ - "TypeScript" not "type script"
319
+ - "terminal" (终端) not "terminal" when speaking Chinese
320
+ - Terminal commands: ls, cd, pwd, mkdir, rm, grep, cat, echo, etc.
321
+
322
+ Correction Rules:
323
+ 1. Fix obvious transcription errors based on context (especially tech terms above)
324
+ 2. Remove filler words ONLY: um, uh, er, ah, well, 嗯, 呃, 那个, 就是
325
+ 3. Fix spacing and punctuation errors
326
+ 4. DO NOT change sentence structure or meaning
327
+ 5. DO NOT convert natural language to commands unless explicitly a command
328
+ 6. Keep user's original intent and wording
329
+ 7. When you see "cloud" in contexts about AI, coding, or APIs, it's likely "Claude"
330
+
331
+ ABSOLUTE OUTPUT REQUIREMENT:
332
+ - Output ONLY the corrected text itself
333
+ - NO explanations, NO parentheses, NO annotations
334
+ - NO text like "(Minor correction: ...)" or "(Note: ...)"
335
+ - NO meta-commentary about what you changed
336
+ - Just return the clean, corrected text and nothing else
337
+ - If the input is "xxxxx", output should be "xxxxx" NOT "xxxxx (some explanation)"
338
+ - If the input is empty, blank, or contains no meaningful speech, output a single space character " " and nothing else
339
+ - NEVER output phrases like "[empty string - no output]" or "[no speech detected]" - just output a space
340
+
341
+ Terminal context (helps identify what user is working on):
342
+ ${context || ''}`;
343
+ const userMessage = `Transcribed speech: "${transcript}"
344
+
345
+ Output the corrected text only, with no explanations or parenthetical notes.`;
346
+ // Use dynamic import for axios
347
+ const axios = (await Promise.resolve().then(() => __importStar(require('axios')))).default;
348
+ asrLog('[Claude] Sending request to Claude API...');
349
+ const response = await axios({
350
+ method: 'POST',
351
+ url: 'https://api.anthropic.com/v1/messages',
352
+ headers: {
353
+ 'Content-Type': 'application/json',
354
+ 'X-API-Key': apiKey,
355
+ 'anthropic-version': '2023-06-01'
356
+ },
357
+ data: {
358
+ model: model,
359
+ messages: [
360
+ {
361
+ role: 'user',
362
+ content: userMessage
363
+ }
364
+ ],
365
+ system: systemPrompt,
366
+ max_tokens: 1000,
367
+ stream: true
368
+ },
369
+ responseType: 'stream'
370
+ });
371
+ asrLog('[Claude] Received streaming response');
372
+ // Process streaming response
373
+ let buffer = '';
374
+ response.data.on('data', (chunk) => {
375
+ buffer += chunk.toString();
376
+ const lines = buffer.split('\n');
377
+ buffer = lines.pop() || ''; // Keep incomplete line in buffer
378
+ for (const line of lines) {
379
+ if (line.startsWith('data: ')) {
380
+ const data = line.slice(6);
381
+ if (data === '[DONE]') {
382
+ ws.send(JSON.stringify({
383
+ type: 'claude_response',
384
+ data: { done: true }
385
+ }));
386
+ continue;
387
+ }
388
+ try {
389
+ const event = JSON.parse(data);
390
+ if (event.type === 'content_block_delta' && event.delta?.text) {
391
+ // Stream text to client
392
+ ws.send(JSON.stringify({
393
+ type: 'claude_response',
394
+ data: { text: event.delta.text }
395
+ }));
396
+ }
397
+ }
398
+ catch (e) {
399
+ // Ignore JSON parse errors
400
+ }
401
+ }
402
+ }
403
+ });
404
+ response.data.on('end', () => {
405
+ asrLog('[Claude] Streaming complete');
406
+ ws.send(JSON.stringify({
407
+ type: 'claude_response',
408
+ data: { done: true }
409
+ }));
410
+ });
411
+ response.data.on('error', (error) => {
412
+ console.error('[Claude] Stream error:', error);
413
+ ws.send(JSON.stringify({
414
+ type: 'claude_response',
415
+ data: { error: error.message, fallback: transcript }
416
+ }));
417
+ });
418
+ }
419
+ catch (error) {
420
+ console.error('[Claude] API error:', error.message);
421
+ if (error.response) {
422
+ console.error('[Claude] Response status:', error.response.status);
423
+ console.error('[Claude] Response data:', error.response.data);
424
+ }
425
+ // Send error to client with fallback
426
+ ws.send(JSON.stringify({
427
+ type: 'claude_response',
428
+ data: { error: error.message, fallback: transcript }
429
+ }));
430
+ }
431
+ }
432
+ function startWebServer(port, pin, options = {}) {
433
+ return new Promise((resolve, reject) => {
434
+ // Set the server PIN
435
+ serverPIN = pin || '';
436
+ // Set ASR debug logging flag
437
+ debugAsrEnabled = options.debugAsr || false;
438
+ if (debugAsrEnabled) {
439
+ console.log('[ASR] Debug logging enabled');
440
+ }
441
+ // Reset authentication state
442
+ failedAttempts.clear();
443
+ blockedIPs.clear();
444
+ const app = (0, express_1.default)();
445
+ // Trust proxy for getting real client IP
446
+ app.set('trust proxy', true);
447
+ app.use((0, cors_1.default)());
448
+ app.use((0, cookie_parser_1.default)());
449
+ app.use(express_1.default.json());
450
+ // Health check (no auth required)
451
+ app.get('/api/health', (req, res) => {
452
+ res.json({ status: 'ok', timestamp: Date.now() });
453
+ });
454
+ // Login page
455
+ app.get('/login', (req, res) => {
456
+ if (serverPIN && !isAuthenticated(req)) {
457
+ res.send(generateLoginPage());
458
+ }
459
+ else {
460
+ res.redirect('/');
461
+ }
462
+ });
463
+ // Login API
464
+ app.post('/api/login', (req, res) => {
465
+ const { pin } = req.body;
466
+ const clientIP = getClientIP(req);
467
+ // Check if IP is blocked
468
+ if (isIPBlocked(clientIP)) {
469
+ res.status(429).json({ error: 'IP blocked due to too many failed attempts' });
470
+ return;
471
+ }
472
+ // Validate PIN
473
+ if (!pin || typeof pin !== 'string' || pin.length !== 6) {
474
+ res.status(400).json({ error: 'PIN must be exactly 6 digits' });
475
+ return;
476
+ }
477
+ if (pin === serverPIN) {
478
+ // Success - set authentication cookie
479
+ res.cookie('auth', pin, {
480
+ httpOnly: true,
481
+ secure: false, // Set to true in production with HTTPS
482
+ maxAge: 24 * 60 * 60 * 1000, // 24 hours
483
+ sameSite: 'lax'
484
+ });
485
+ // Clear failed attempts for this IP
486
+ failedAttempts.delete(clientIP);
487
+ res.json({ success: true });
488
+ }
489
+ else {
490
+ // Failed authentication
491
+ const attempts = (failedAttempts.get(clientIP) || 0) + 1;
492
+ failedAttempts.set(clientIP, attempts);
493
+ if (attempts >= MAX_FAILED_ATTEMPTS) {
494
+ // Block IP
495
+ blockedIPs.add(clientIP);
496
+ // Unblock after duration
497
+ setTimeout(() => {
498
+ blockedIPs.delete(clientIP);
499
+ failedAttempts.delete(clientIP);
500
+ }, BLOCK_DURATION);
501
+ res.status(429).json({
502
+ error: `Too many failed attempts. IP blocked for ${BLOCK_DURATION / 60000} minute(s)`
503
+ });
504
+ }
505
+ else {
506
+ res.status(401).json({
507
+ error: `Invalid PIN. ${MAX_FAILED_ATTEMPTS - attempts} attempts remaining`
508
+ });
509
+ }
510
+ }
511
+ });
512
+ // Logout API
513
+ app.post('/api/logout', (req, res) => {
514
+ res.clearCookie('auth');
515
+ res.json({ success: true });
516
+ });
517
+ // Apply authentication middleware if PIN is set
518
+ if (serverPIN) {
519
+ app.use(requireAuth);
520
+ }
521
+ app.get('/api/terminal-context', (req, res) => {
522
+ res.json({
523
+ recentOutput: outputBuffer.slice(-50),
524
+ bufferLength: outputBuffer.length,
525
+ debugAsr: debugAsrEnabled // Include debug flag
526
+ });
527
+ });
528
+ // Proxy for ModelScope API to handle CORS
529
+ app.post('/api/modelscope/proxy', async (req, res) => {
530
+ try {
531
+ const { url, headers, body } = req.body;
532
+ console.log('[ModelScope Proxy] Request to:', url);
533
+ console.log('[ModelScope Proxy] Headers:', { ...headers, Authorization: headers.Authorization ? 'Bearer ***' : 'Not set' });
534
+ // Make request to ModelScope API
535
+ const axios = (await Promise.resolve().then(() => __importStar(require('axios')))).default;
536
+ const response = await axios({
537
+ method: 'POST',
538
+ url: url,
539
+ headers: headers,
540
+ data: body
541
+ });
542
+ res.json(response.data);
543
+ }
544
+ catch (error) {
545
+ console.error('[ModelScope Proxy] Error:', error.message);
546
+ if (error.response) {
547
+ console.error('[ModelScope Proxy] Response status:', error.response.status);
548
+ console.error('[ModelScope Proxy] Response data:', error.response.data);
549
+ }
550
+ res.status(error.response?.status || 500).json({
551
+ error: error.response?.data?.message || error.message,
552
+ status: error.response?.status,
553
+ details: error.response?.data
554
+ });
555
+ }
556
+ });
557
+ app.get('/api/modelscope/proxy', async (req, res) => {
558
+ try {
559
+ const { url, headers } = req.query;
560
+ // Make request to ModelScope API
561
+ const axios = (await Promise.resolve().then(() => __importStar(require('axios')))).default;
562
+ const response = await axios({
563
+ method: 'GET',
564
+ url: url,
565
+ headers: JSON.parse(headers || '{}')
566
+ });
567
+ res.json(response.data);
568
+ }
569
+ catch (error) {
570
+ console.error('[ModelScope Proxy] Error:', error.message);
571
+ res.status(error.response?.status || 500).json({
572
+ error: error.message,
573
+ status: error.response?.status
574
+ });
575
+ }
576
+ });
577
+ // Fallback for SPA routing - use regex pattern for Express 5 compatibility
578
+ app.use((req, res, next) => {
579
+ // Skip API routes and WebSocket
580
+ if (req.path.startsWith('/api') || req.path === '/ws') {
581
+ return next();
582
+ }
583
+ // Skip static asset requests (favicon, images, etc.)
584
+ const staticExtensions = ['.ico', '.png', '.jpg', '.jpeg', '.gif', '.svg', '.webp', '.css', '.js', '.map', '.woff', '.woff2', '.ttf', '.eot'];
585
+ if (staticExtensions.some(ext => req.path.endsWith(ext))) {
586
+ return res.status(404).end();
587
+ }
588
+ // Only serve index.html for HTML requests (browser navigation)
589
+ const acceptHeader = req.get('Accept') || '';
590
+ if (!acceptHeader.includes('text/html')) {
591
+ return res.status(404).end();
592
+ }
593
+ // Serve inline terminal HTML since public folder no longer exists
594
+ res.send(`
595
+ <!DOCTYPE html>
596
+ <html>
597
+ <head>
598
+ <meta charset="UTF-8">
599
+ <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
600
+ <title>Locus Terminal</title>
601
+ <script src="https://cdn.jsdelivr.net/npm/xterm@5.3.0/lib/xterm.js"></script>
602
+ <script src="https://cdn.jsdelivr.net/npm/@xterm/addon-fit@0.10.0/lib/addon-fit.js"></script>
603
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/xterm@5.3.0/css/xterm.css">
604
+ <style>
605
+ * {
606
+ margin: 0;
607
+ padding: 0;
608
+ box-sizing: border-box;
609
+ }
610
+ html, body {
611
+ height: 100%;
612
+ width: 100%;
613
+ background: #0a0a0a;
614
+ overflow: hidden;
615
+ /* Prevent iOS edge swipe gestures */
616
+ overscroll-behavior: none;
617
+ -webkit-overflow-scrolling: auto;
618
+ }
619
+ /* Prevent swipe-to-go-back on iOS */
620
+ body {
621
+ position: fixed;
622
+ width: 100%;
623
+ height: 100%;
624
+ }
625
+ /* Terminal area - leave space for input at bottom */
626
+ #terminal-container {
627
+ position: absolute;
628
+ top: 0;
629
+ left: 0;
630
+ right: 0;
631
+ bottom: 50px; /* Space for input */
632
+ padding: 8px;
633
+ /* Prevent all default touch behaviors */
634
+ touch-action: none;
635
+ -webkit-touch-callout: none;
636
+ -webkit-user-select: none;
637
+ user-select: none;
638
+ /* Prevent iOS edge swipe gestures */
639
+ -webkit-overflow-scrolling: touch;
640
+ overscroll-behavior: contain;
641
+ }
642
+ /* xterm viewport handles its own scrolling */
643
+ .xterm-viewport {
644
+ overflow-y: auto !important;
645
+ scrollbar-width: none; /* Firefox */
646
+ -ms-overflow-style: none; /* IE/Edge */
647
+ /* Ensure custom touch handling works */
648
+ touch-action: none;
649
+ -webkit-overflow-scrolling: auto; /* Disable iOS momentum scrolling */
650
+ }
651
+ .xterm-viewport::-webkit-scrollbar {
652
+ display: none; /* Chrome/Safari */
653
+ }
654
+ /* Prevent text selection on mobile during scrolling */
655
+ .xterm-screen {
656
+ user-select: none;
657
+ -webkit-user-select: none;
658
+ }
659
+ /* Scroll to bottom button */
660
+ #scroll-to-bottom {
661
+ position: fixed;
662
+ bottom: 60px; /* Above input area */
663
+ right: 12px;
664
+ width: 40px;
665
+ height: 40px;
666
+ border-radius: 50%;
667
+ background: rgba(59, 130, 246, 0.9);
668
+ border: none;
669
+ color: white;
670
+ cursor: pointer;
671
+ z-index: 999;
672
+ display: flex;
673
+ align-items: center;
674
+ justify-content: center;
675
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
676
+ opacity: 0;
677
+ transform: scale(0.8);
678
+ transition: all 0.2s ease;
679
+ pointer-events: none;
680
+ }
681
+ #scroll-to-bottom.visible {
682
+ opacity: 1;
683
+ transform: scale(1);
684
+ pointer-events: auto;
685
+ }
686
+ #scroll-to-bottom:hover {
687
+ background: rgba(59, 130, 246, 1);
688
+ transform: scale(1.1);
689
+ }
690
+ #scroll-to-bottom:active {
691
+ transform: scale(0.95);
692
+ }
693
+ #scroll-to-bottom svg {
694
+ width: 20px;
695
+ height: 20px;
696
+ fill: currentColor;
697
+ }
698
+ /* Floating status dot - top right */
699
+ #status-dot {
700
+ position: fixed;
701
+ top: 12px;
702
+ right: 12px;
703
+ width: 12px;
704
+ height: 12px;
705
+ border-radius: 50%;
706
+ background: #22c55e;
707
+ z-index: 1000;
708
+ box-shadow: 0 0 8px rgba(34, 197, 94, 0.5);
709
+ transition: all 0.3s ease;
710
+ }
711
+ #status-dot.disconnected {
712
+ background: #ef4444;
713
+ box-shadow: 0 0 8px rgba(239, 68, 68, 0.5);
714
+ }
715
+ #status-dot.connecting {
716
+ background: #f59e0b;
717
+ box-shadow: 0 0 8px rgba(245, 158, 11, 0.5);
718
+ animation: pulse 1s infinite;
719
+ }
720
+ @keyframes pulse {
721
+ 0%, 100% { opacity: 1; }
722
+ 50% { opacity: 0.5; }
723
+ }
724
+ /* Fixed bottom input area */
725
+ #input-area {
726
+ position: fixed;
727
+ bottom: 0;
728
+ left: 0;
729
+ right: 0;
730
+ padding: 8px 12px;
731
+ background: rgba(26, 26, 26, 0.95);
732
+ border-top: 1px solid #333;
733
+ z-index: 1000;
734
+ }
735
+ #input-wrapper {
736
+ display: flex;
737
+ align-items: flex-end;
738
+ gap: 8px;
739
+ }
740
+ #input {
741
+ flex: 1;
742
+ min-height: 34px;
743
+ max-height: 100px;
744
+ background: #0a0a0a;
745
+ border: 1px solid #444;
746
+ border-radius: 6px;
747
+ padding: 8px 12px;
748
+ color: #fff;
749
+ font-size: 16px;
750
+ font-family: inherit;
751
+ outline: none;
752
+ resize: none;
753
+ overflow-y: auto;
754
+ scrollbar-width: none;
755
+ -ms-overflow-style: none;
756
+ }
757
+ #input::-webkit-scrollbar { display: none; }
758
+ #input:focus { border-color: #3b82f6; }
759
+
760
+ /* Special keys button */
761
+ #special-keys-btn {
762
+ width: 40px;
763
+ height: 40px;
764
+ background: #1a1a1a;
765
+ border: 1px solid #444;
766
+ border-radius: 6px;
767
+ color: #888;
768
+ cursor: pointer;
769
+ transition: all 0.2s ease;
770
+ display: flex;
771
+ align-items: center;
772
+ justify-content: center;
773
+ font-size: 18px;
774
+ flex-shrink: 0;
775
+ }
776
+ #special-keys-btn:hover {
777
+ background: #2a2a2a;
778
+ color: #fff;
779
+ border-color: #555;
780
+ }
781
+ #special-keys-btn:active {
782
+ transform: scale(0.95);
783
+ }
784
+
785
+ /* Special keys popup */
786
+ #special-keys-popup {
787
+ position: fixed;
788
+ bottom: 60px;
789
+ right: 12px;
790
+ background: rgba(26, 26, 26, 0.98);
791
+ border: 1px solid #444;
792
+ border-radius: 8px;
793
+ padding: 8px;
794
+ display: none;
795
+ z-index: 1001;
796
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
797
+ min-width: 200px;
798
+ }
799
+ #special-keys-popup.show {
800
+ display: block;
801
+ }
802
+ .key-group {
803
+ margin-bottom: 8px;
804
+ }
805
+ .key-group:last-child {
806
+ margin-bottom: 0;
807
+ }
808
+ .key-group-title {
809
+ color: #888;
810
+ font-size: 11px;
811
+ text-transform: uppercase;
812
+ margin-bottom: 4px;
813
+ padding: 0 4px;
814
+ }
815
+ .key-buttons {
816
+ display: flex;
817
+ flex-wrap: wrap;
818
+ gap: 4px;
819
+ }
820
+ .special-key {
821
+ padding: 6px 10px;
822
+ background: #0a0a0a;
823
+ border: 1px solid #333;
824
+ border-radius: 4px;
825
+ color: #fff;
826
+ cursor: pointer;
827
+ font-size: 12px;
828
+ transition: all 0.15s ease;
829
+ white-space: nowrap;
830
+ min-width: 40px;
831
+ text-align: center;
832
+ }
833
+ .special-key:hover {
834
+ background: #1a1a1a;
835
+ border-color: #3b82f6;
836
+ }
837
+ .special-key:active {
838
+ transform: scale(0.95);
839
+ background: #2a2a2a;
840
+ }
841
+ </style>
842
+ </head>
843
+ <body>
844
+ <div id="terminal-container"></div>
845
+
846
+ <!-- Floating status dot -->
847
+ <div id="status-dot"></div>
848
+
849
+ <!-- Scroll to bottom button -->
850
+ <button id="scroll-to-bottom" aria-label="Scroll to bottom">
851
+ <svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
852
+ <path d="M7 10l5 5 5-5H7z"/>
853
+ <path d="M7 14l5 5 5-5H7z"/>
854
+ </svg>
855
+ </button>
856
+
857
+ <!-- Fixed bottom input -->
858
+ <div id="input-area">
859
+ <div id="input-wrapper">
860
+ <textarea id="input" rows="1" placeholder="Type command..." autocomplete="off" autocorrect="off" autocapitalize="off"></textarea>
861
+ <button id="special-keys-btn" aria-label="Special keys">⌘</button>
862
+ </div>
863
+ </div>
864
+
865
+ <!-- Special keys popup -->
866
+ <div id="special-keys-popup">
867
+ <div class="key-group">
868
+ <div class="key-group-title">Control Keys</div>
869
+ <div class="key-buttons">
870
+ <button class="special-key" data-key="Escape">ESC</button>
871
+ <button class="special-key" data-key="Tab">TAB</button>
872
+ <button class="special-key" data-key="Enter">ENTER</button>
873
+ <button class="special-key" data-key="Backspace">⌫</button>
874
+ </div>
875
+ </div>
876
+ <div class="key-group">
877
+ <div class="key-group-title">Modifiers</div>
878
+ <div class="key-buttons">
879
+ <button class="special-key" data-key="Control">CTRL</button>
880
+ <button class="special-key" data-key="Alt">ALT</button>
881
+ <button class="special-key" data-key="Shift">SHIFT</button>
882
+ <button class="special-key" data-key="Meta">CMD</button>
883
+ </div>
884
+ </div>
885
+ <div class="key-group">
886
+ <div class="key-group-title">Function Keys</div>
887
+ <div class="key-buttons">
888
+ <button class="special-key" data-key="F1">F1</button>
889
+ <button class="special-key" data-key="F2">F2</button>
890
+ <button class="special-key" data-key="F3">F3</button>
891
+ <button class="special-key" data-key="F4">F4</button>
892
+ </div>
893
+ </div>
894
+ <div class="key-group">
895
+ <div class="key-group-title">Navigation</div>
896
+ <div class="key-buttons">
897
+ <button class="special-key" data-key="ArrowUp">↑</button>
898
+ <button class="special-key" data-key="ArrowDown">↓</button>
899
+ <button class="special-key" data-key="ArrowLeft">←</button>
900
+ <button class="special-key" data-key="ArrowRight">→</button>
901
+ <button class="special-key" data-key="Home">HOME</button>
902
+ <button class="special-key" data-key="End">END</button>
903
+ <button class="special-key" data-key="PageUp">PgUp</button>
904
+ <button class="special-key" data-key="PageDown">PgDn</button>
905
+ </div>
906
+ </div>
907
+ <div class="key-group">
908
+ <div class="key-group-title">Shortcuts</div>
909
+ <div class="key-buttons">
910
+ <button class="special-key" data-combo="ctrl+c">Ctrl+C</button>
911
+ <button class="special-key" data-combo="ctrl+v">Ctrl+V</button>
912
+ <button class="special-key" data-combo="ctrl+z">Ctrl+Z</button>
913
+ <button class="special-key" data-combo="ctrl+d">Ctrl+D</button>
914
+ <button class="special-key" data-combo="ctrl+l">Ctrl+L</button>
915
+ </div>
916
+ </div>
917
+ </div>
918
+
919
+ <script>
920
+ const term = new Terminal({
921
+ cursorBlink: true,
922
+ fontSize: 13,
923
+ theme: { background: '#0a0a0a', foreground: '#ededed' },
924
+ scrollback: 10000,
925
+ allowTransparency: false,
926
+ });
927
+
928
+ const fitAddon = new FitAddon.FitAddon();
929
+ term.loadAddon(fitAddon);
930
+ term.open(document.getElementById('terminal-container'));
931
+ fitAddon.fit();
932
+
933
+ const statusDot = document.getElementById('status-dot');
934
+ const input = document.getElementById('input');
935
+
936
+ let ws = null;
937
+ let pendingInputs = [];
938
+ let reconnectAttempts = 0;
939
+ const MAX_RECONNECT_ATTEMPTS = 3;
940
+ let reconnectTimeoutId = null;
941
+ let wasHidden = false;
942
+ let isReconnecting = false;
943
+ let pendingMessage = '';
944
+
945
+ function setInputEnabled(enabled) {
946
+ input.disabled = !enabled;
947
+ input.style.opacity = enabled ? '1' : '0.5';
948
+ input.style.cursor = enabled ? 'text' : 'not-allowed';
949
+ if (!enabled) {
950
+ input.placeholder = 'Reconnecting...';
951
+ } else {
952
+ input.placeholder = 'Type command...';
953
+ }
954
+ }
955
+
956
+ function updateStatus(state) {
957
+ statusDot.className = '';
958
+ if (state === 'disconnected') {
959
+ statusDot.classList.add('disconnected');
960
+ isReconnecting = true;
961
+ // Don't disable input during reconnection
962
+ } else if (state === 'connecting') {
963
+ statusDot.classList.add('connecting');
964
+ isReconnecting = true;
965
+ // Don't disable input during reconnection
966
+ } else if (state === 'connected') {
967
+ isReconnecting = false;
968
+ }
969
+ // 'connected' - input enabled after history sync
970
+ }
971
+
972
+ function closeExistingConnection() {
973
+ if (ws) {
974
+ // Remove handlers to prevent triggering reconnect logic
975
+ ws.onclose = null;
976
+ ws.onerror = null;
977
+ ws.onopen = null;
978
+ ws.onmessage = null;
979
+ try {
980
+ ws.close();
981
+ } catch (e) {}
982
+ ws = null;
983
+ }
984
+ }
985
+
986
+ function startReconnect() {
987
+ // Clear any pending reconnect
988
+ if (reconnectTimeoutId) {
989
+ clearTimeout(reconnectTimeoutId);
990
+ reconnectTimeoutId = null;
991
+ }
992
+
993
+ // Close existing connection first
994
+ closeExistingConnection();
995
+
996
+ // Reset attempts counter
997
+ reconnectAttempts = 0;
998
+
999
+ // Start reconnecting
1000
+ // Don't disable input during reconnection
1001
+ doReconnect();
1002
+ }
1003
+
1004
+ function doReconnect() {
1005
+ if (reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
1006
+ console.log('Max reconnect attempts reached (' + MAX_RECONNECT_ATTEMPTS + ')');
1007
+ updateStatus('disconnected');
1008
+ isReconnecting = false; // Stop reconnecting
1009
+ setInputEnabled(false); // Disable input since connection failed
1010
+ input.placeholder = 'Connection failed. Refresh page.';
1011
+ return;
1012
+ }
1013
+
1014
+ reconnectAttempts++;
1015
+ console.log('Reconnect attempt ' + reconnectAttempts + '/' + MAX_RECONNECT_ATTEMPTS);
1016
+ updateStatus('connecting');
1017
+
1018
+ const wsUrl = location.protocol.replace('http', 'ws') + '//' + location.host + '/ws';
1019
+ ws = new WebSocket(wsUrl);
1020
+
1021
+ ws.onopen = () => {
1022
+ console.log('WebSocket connected');
1023
+ updateStatus('connected');
1024
+ reconnectAttempts = 0;
1025
+ fitAddon.fit();
1026
+ ws.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }));
1027
+ };
1028
+
1029
+ ws.onclose = () => {
1030
+ console.log('WebSocket closed, attempt ' + reconnectAttempts);
1031
+ updateStatus('disconnected');
1032
+ ws = null;
1033
+ // If we haven't hit max attempts, schedule retry
1034
+ if (reconnectAttempts < MAX_RECONNECT_ATTEMPTS) {
1035
+ reconnectTimeoutId = setTimeout(() => {
1036
+ doReconnect();
1037
+ }, 500);
1038
+ } else {
1039
+ isReconnecting = false; // Stop reconnecting
1040
+ setInputEnabled(false); // Disable input since connection failed
1041
+ input.placeholder = 'Connection failed. Refresh page.';
1042
+ }
1043
+ };
1044
+
1045
+ ws.onerror = (err) => {
1046
+ console.log('WebSocket error');
1047
+ // onclose will be called after onerror
1048
+ };
1049
+
1050
+ ws.onmessage = (e) => {
1051
+ const msg = JSON.parse(e.data);
1052
+ if (msg.type === 'output') term.write(msg.data);
1053
+ if (msg.type === 'history') {
1054
+ // Clear terminal before writing history to avoid duplication
1055
+ term.clear();
1056
+ msg.data.forEach(d => term.write(d));
1057
+ // History received, sync complete - enable input and scroll to bottom
1058
+ console.log('History received, enabling input');
1059
+ setInputEnabled(true);
1060
+
1061
+ // Force scroll to bottom using both xterm and viewport methods
1062
+ term.scrollToBottom();
1063
+ setTimeout(() => {
1064
+ const viewport = document.querySelector('.xterm-viewport');
1065
+ if (viewport) {
1066
+ viewport.scrollTop = viewport.scrollHeight;
1067
+ }
1068
+ // Also reset the user scrolling flag
1069
+ isUserScrolling = false;
1070
+ }, 100);
1071
+
1072
+ // If there was a pending message, send it now
1073
+ if (pendingMessage) {
1074
+ ws.send(JSON.stringify({ type: 'input', data: pendingMessage }));
1075
+ ws.send(JSON.stringify({ type: 'input', data: String.fromCharCode(13) }));
1076
+ pendingMessage = '';
1077
+ // Re-enable input after sending
1078
+ setInputEnabled(true);
1079
+ // Scroll to bottom again after sending pending message
1080
+ setTimeout(() => scrollToBottom(), 150);
1081
+ }
1082
+
1083
+ while (pendingInputs.length > 0) {
1084
+ ws.send(JSON.stringify({ type: 'input', data: pendingInputs.shift() }));
1085
+ }
1086
+ }
1087
+ };
1088
+ }
1089
+
1090
+ // Track when page is hidden
1091
+ document.addEventListener('visibilitychange', () => {
1092
+ if (document.visibilityState === 'hidden') {
1093
+ console.log('Page hidden');
1094
+ wasHidden = true;
1095
+ } else if (document.visibilityState === 'visible' && wasHidden) {
1096
+ console.log('Page became visible after being hidden');
1097
+ wasHidden = false;
1098
+ // Only reconnect if the connection is actually broken
1099
+ if (!ws || ws.readyState !== 1) {
1100
+ console.log('WebSocket disconnected while hidden, reconnecting...');
1101
+ startReconnect();
1102
+ } else {
1103
+ console.log('WebSocket still connected, no reconnection needed');
1104
+ }
1105
+ }
1106
+ });
1107
+
1108
+ function sendInput(data) {
1109
+ if (ws && ws.readyState === 1) {
1110
+ ws.send(JSON.stringify({ type: 'input', data }));
1111
+ } else {
1112
+ pendingInputs.push(data);
1113
+ }
1114
+ }
1115
+
1116
+ function send() {
1117
+ // If reconnecting, store the message and disable input
1118
+ if (isReconnecting) {
1119
+ const text = input.value;
1120
+ if (text) {
1121
+ pendingMessage = text;
1122
+ input.value = '';
1123
+ input.style.height = 'auto'; // Reset height
1124
+ // Disable input until reconnection completes
1125
+ setInputEnabled(false);
1126
+ input.placeholder = 'Sending after reconnection...';
1127
+ }
1128
+ return;
1129
+ }
1130
+
1131
+ if (input.disabled) return;
1132
+
1133
+ const text = input.value;
1134
+ input.value = '';
1135
+ input.style.height = 'auto'; // Reset height
1136
+ if (text) {
1137
+ sendInput(text);
1138
+ setTimeout(() => {
1139
+ sendInput(String.fromCharCode(13));
1140
+ // Scroll to bottom after sending command
1141
+ scrollToBottom();
1142
+ }, 50);
1143
+ } else {
1144
+ sendInput(String.fromCharCode(13));
1145
+ // Scroll to bottom after sending empty command
1146
+ scrollToBottom();
1147
+ }
1148
+ }
1149
+
1150
+ // Auto-resize textarea
1151
+ input.addEventListener('input', () => {
1152
+ input.style.height = 'auto';
1153
+ input.style.height = Math.min(input.scrollHeight, 100) + 'px';
1154
+ });
1155
+
1156
+ // Enter to send, Shift+Enter for newline
1157
+ input.onkeydown = (e) => {
1158
+ if (e.key === 'Enter' && !e.shiftKey) {
1159
+ e.preventDefault();
1160
+ send();
1161
+ }
1162
+ };
1163
+
1164
+ // Focus input when clicking terminal area
1165
+ document.getElementById('terminal-container').addEventListener('click', () => {
1166
+ if (!input.disabled) {
1167
+ input.focus();
1168
+ }
1169
+ });
1170
+
1171
+ let resizeTimeout;
1172
+ window.addEventListener('resize', () => {
1173
+ clearTimeout(resizeTimeout);
1174
+ resizeTimeout = setTimeout(() => {
1175
+ fitAddon.fit();
1176
+ if (ws && ws.readyState === 1) {
1177
+ ws.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }));
1178
+ }
1179
+ }, 100);
1180
+ });
1181
+
1182
+ // Touch scrolling state
1183
+ let isUserScrolling = false;
1184
+ const terminalContainer = document.getElementById('terminal-container');
1185
+ const isTouchDevice = 'ontouchstart' in window || navigator.maxTouchPoints > 0;
1186
+
1187
+ // Initialize touch scrolling for mobile devices
1188
+ if (isTouchDevice) {
1189
+ initTouchScrolling(terminalContainer, () => { isUserScrolling = true; });
1190
+ }
1191
+
1192
+ // Touch scrolling implementation
1193
+ function initTouchScrolling(container, onScrollStart) {
1194
+ const touchState = {
1195
+ startY: 0, lastY: 0, lastTime: 0,
1196
+ velocity: 0, identifier: null,
1197
+ touching: false, velocityHistory: [],
1198
+ accumulator: 0, inertiaId: null
1199
+ };
1200
+
1201
+ // Create touch overlay
1202
+ const overlay = createTouchOverlay(container);
1203
+
1204
+ // Attach event handlers
1205
+ overlay.addEventListener('touchstart', handleTouchStart, { passive: false });
1206
+ overlay.addEventListener('touchmove', handleTouchMove, { passive: false });
1207
+ overlay.addEventListener('touchend', handleTouchEnd, { passive: false });
1208
+ overlay.addEventListener('touchcancel', handleTouchCancel, { passive: false });
1209
+
1210
+ // Prevent conflicts with input area
1211
+ document.getElementById('input-area').addEventListener('touchstart', e => e.stopPropagation(), { passive: true });
1212
+
1213
+ function createTouchOverlay(parent) {
1214
+ const div = document.createElement('div');
1215
+ Object.assign(div.style, {
1216
+ position: 'absolute', top: '0', left: '0', right: '0', bottom: '0',
1217
+ zIndex: '1', touchAction: 'none', webkitTouchCallout: 'none',
1218
+ webkitUserSelect: 'none', userSelect: 'none', pointerEvents: 'auto'
1219
+ });
1220
+ parent.appendChild(div);
1221
+ return div;
1222
+ }
1223
+
1224
+ function performScroll(deltaY) {
1225
+ const viewport = container.querySelector('.xterm-viewport');
1226
+ if (!viewport) return;
1227
+ viewport.scrollTop += deltaY;
1228
+ viewport.dispatchEvent(new WheelEvent('wheel', {
1229
+ deltaY, deltaMode: 0, bubbles: true, cancelable: true
1230
+ }));
1231
+ }
1232
+
1233
+ function handleTouchStart(e) {
1234
+ e.preventDefault();
1235
+ cancelInertia();
1236
+ touchState.accumulator = 0;
1237
+
1238
+ if (e.touches.length > 0) {
1239
+ const touch = e.touches[0];
1240
+ Object.assign(touchState, {
1241
+ identifier: touch.identifier,
1242
+ startY: touch.clientY,
1243
+ lastY: touch.clientY,
1244
+ lastTime: performance.now(),
1245
+ velocity: 0,
1246
+ velocityHistory: [],
1247
+ touching: true
1248
+ });
1249
+ onScrollStart();
1250
+ }
1251
+ }
1252
+
1253
+ function handleTouchMove(e) {
1254
+ e.preventDefault();
1255
+ if (!touchState.touching || e.touches.length === 0) return;
1256
+
1257
+ const touch = findTrackedTouch(e.touches) || e.touches[0];
1258
+ const currentY = touch.clientY;
1259
+ const deltaY = touchState.lastY - currentY;
1260
+ const currentTime = performance.now();
1261
+ const timeDelta = Math.max(1, currentTime - touchState.lastTime);
1262
+
1263
+ // Update velocity
1264
+ updateVelocity(deltaY / timeDelta);
1265
+
1266
+ touchState.lastY = currentY;
1267
+ touchState.lastTime = currentTime;
1268
+ touchState.accumulator += deltaY;
1269
+
1270
+ // Apply scroll when threshold reached
1271
+ if (Math.abs(touchState.accumulator) >= 0.5) {
1272
+ performScroll(touchState.accumulator * 1.8);
1273
+ touchState.accumulator = touchState.accumulator % 0.5;
1274
+ }
1275
+ }
1276
+
1277
+ function handleTouchEnd(e) {
1278
+ e.preventDefault();
1279
+ if (!isTouchEnded(e.touches)) return;
1280
+
1281
+ touchState.touching = false;
1282
+ touchState.identifier = null;
1283
+
1284
+ // Apply remaining scroll
1285
+ if (Math.abs(touchState.accumulator) > 0) {
1286
+ performScroll(touchState.accumulator * 1.8);
1287
+ touchState.accumulator = 0;
1288
+ }
1289
+
1290
+ // Start inertia if needed
1291
+ if (Math.abs(touchState.velocity) > 0.01) {
1292
+ startInertia();
1293
+ }
1294
+ }
1295
+
1296
+ function handleTouchCancel(e) {
1297
+ e.preventDefault();
1298
+ resetTouchState();
1299
+ cancelInertia();
1300
+ }
1301
+
1302
+ function findTrackedTouch(touches) {
1303
+ for (let i = 0; i < touches.length; i++) {
1304
+ if (touches[i].identifier === touchState.identifier) {
1305
+ return touches[i];
1306
+ }
1307
+ }
1308
+ return null;
1309
+ }
1310
+
1311
+ function isTouchEnded(touches) {
1312
+ return !findTrackedTouch(touches);
1313
+ }
1314
+
1315
+ function updateVelocity(instant) {
1316
+ touchState.velocityHistory.push(instant);
1317
+ if (touchState.velocityHistory.length > 5) {
1318
+ touchState.velocityHistory.shift();
1319
+ }
1320
+
1321
+ // Calculate weighted average
1322
+ let weightedSum = 0, totalWeight = 0;
1323
+ touchState.velocityHistory.forEach((v, i) => {
1324
+ const weight = i + 1;
1325
+ weightedSum += v * weight;
1326
+ totalWeight += weight;
1327
+ });
1328
+ touchState.velocity = totalWeight ? weightedSum / totalWeight : 0;
1329
+ }
1330
+
1331
+ function startInertia() {
1332
+ const friction = 0.95;
1333
+ const minVelocity = 0.01;
1334
+
1335
+ function animate() {
1336
+ if (Math.abs(touchState.velocity) < minVelocity || touchState.touching) {
1337
+ touchState.inertiaId = null;
1338
+ touchState.velocity = 0;
1339
+ return;
1340
+ }
1341
+
1342
+ performScroll(touchState.velocity * 25);
1343
+ touchState.velocity *= friction;
1344
+ touchState.inertiaId = requestAnimationFrame(animate);
1345
+ }
1346
+ animate();
1347
+ }
1348
+
1349
+ function cancelInertia() {
1350
+ if (touchState.inertiaId) {
1351
+ cancelAnimationFrame(touchState.inertiaId);
1352
+ touchState.inertiaId = null;
1353
+ }
1354
+ }
1355
+
1356
+ function resetTouchState() {
1357
+ Object.assign(touchState, {
1358
+ touching: false, identifier: null,
1359
+ velocity: 0, velocityHistory: [],
1360
+ accumulator: 0
1361
+ });
1362
+ }
1363
+ }
1364
+
1365
+ // Scroll to bottom button functionality
1366
+ const scrollToBottomBtn = document.getElementById('scroll-to-bottom');
1367
+ let scrollCheckTimer = null;
1368
+
1369
+ function isAtBottom() {
1370
+ const viewport = document.querySelector('.xterm-viewport');
1371
+ if (!viewport) return true;
1372
+
1373
+ // Check if scrolled to bottom (with 50px tolerance)
1374
+ return viewport.scrollTop >= (viewport.scrollHeight - viewport.clientHeight - 50);
1375
+ }
1376
+
1377
+ function updateScrollButton() {
1378
+ if (isAtBottom()) {
1379
+ scrollToBottomBtn.classList.remove('visible');
1380
+ isUserScrolling = false;
1381
+ } else {
1382
+ scrollToBottomBtn.classList.add('visible');
1383
+ isUserScrolling = true;
1384
+ }
1385
+ }
1386
+
1387
+ function scrollToBottom() {
1388
+ const viewport = document.querySelector('.xterm-viewport');
1389
+ if (viewport) {
1390
+ viewport.scrollTo({
1391
+ top: viewport.scrollHeight,
1392
+ behavior: 'smooth'
1393
+ });
1394
+ }
1395
+ // Hide button immediately when clicked
1396
+ scrollToBottomBtn.classList.remove('visible');
1397
+ isUserScrolling = false;
1398
+ }
1399
+
1400
+ // Click handler for scroll to bottom button
1401
+ scrollToBottomBtn.addEventListener('click', scrollToBottom);
1402
+
1403
+ // Monitor scroll events on terminal viewport
1404
+ function attachScrollListener() {
1405
+ const viewport = document.querySelector('.xterm-viewport');
1406
+ if (viewport) {
1407
+ viewport.addEventListener('scroll', () => {
1408
+ // Debounce scroll check
1409
+ clearTimeout(scrollCheckTimer);
1410
+ scrollCheckTimer = setTimeout(updateScrollButton, 100);
1411
+ });
1412
+
1413
+ // Also listen for wheel events to detect user scrolling
1414
+ viewport.addEventListener('wheel', () => {
1415
+ // Quick check without debounce for wheel events
1416
+ updateScrollButton();
1417
+ });
1418
+ }
1419
+ }
1420
+
1421
+ // Attach scroll listener after terminal is initialized
1422
+ setTimeout(attachScrollListener, 100);
1423
+
1424
+ // Special keys functionality
1425
+ const specialKeysBtn = document.getElementById('special-keys-btn');
1426
+ const specialKeysPopup = document.getElementById('special-keys-popup');
1427
+ // 'input' already declared above
1428
+
1429
+ // Toggle popup visibility
1430
+ specialKeysBtn.addEventListener('click', (e) => {
1431
+ e.stopPropagation();
1432
+ specialKeysPopup.classList.toggle('show');
1433
+ });
1434
+
1435
+ // Close popup when clicking outside
1436
+ document.addEventListener('click', (e) => {
1437
+ if (!specialKeysPopup.contains(e.target) && e.target !== specialKeysBtn) {
1438
+ specialKeysPopup.classList.remove('show');
1439
+ }
1440
+ });
1441
+
1442
+ // Handle special key clicks
1443
+ document.querySelectorAll('.special-key').forEach(button => {
1444
+ button.addEventListener('click', (e) => {
1445
+ e.stopPropagation();
1446
+
1447
+ const key = button.dataset.key;
1448
+ const combo = button.dataset.combo;
1449
+
1450
+ if (combo) {
1451
+ // Handle key combinations
1452
+ handleKeyCombo(combo);
1453
+ } else if (key) {
1454
+ // Handle single keys
1455
+ handleSpecialKey(key);
1456
+ }
1457
+
1458
+ // Keep popup open for modifier keys
1459
+ if (!['Control', 'Alt', 'Shift', 'Meta'].includes(key)) {
1460
+ // Close popup after non-modifier key press
1461
+ setTimeout(() => {
1462
+ specialKeysPopup.classList.remove('show');
1463
+ }, 100);
1464
+ }
1465
+ });
1466
+ });
1467
+
1468
+ function handleSpecialKey(key) {
1469
+ const currentInput = input.value;
1470
+
1471
+ switch(key) {
1472
+ case 'Escape':
1473
+ // Send ESC sequence
1474
+ ws.send(JSON.stringify({ type: 'input', data: '\\x1b' }));
1475
+ break;
1476
+ case 'Tab':
1477
+ // Send TAB
1478
+ ws.send(JSON.stringify({ type: 'input', data: '\\t' }));
1479
+ break;
1480
+ case 'Enter':
1481
+ // Send current input
1482
+ if (currentInput) {
1483
+ ws.send(JSON.stringify({ type: 'input', data: currentInput + '\\n' }));
1484
+ addToHistory(currentInput);
1485
+ input.value = '';
1486
+ input.style.height = 'auto';
1487
+ }
1488
+ break;
1489
+ case 'Backspace':
1490
+ // Remove last character from input
1491
+ input.value = currentInput.slice(0, -1);
1492
+ break;
1493
+ case 'Control':
1494
+ case 'Alt':
1495
+ case 'Shift':
1496
+ case 'Meta':
1497
+ // These are modifiers, could be used to set a state
1498
+ // For now, just show visual feedback
1499
+ break;
1500
+ case 'ArrowUp':
1501
+ // Navigate history up
1502
+ navigateHistory('up');
1503
+ break;
1504
+ case 'ArrowDown':
1505
+ // Navigate history down
1506
+ navigateHistory('down');
1507
+ break;
1508
+ case 'ArrowLeft':
1509
+ // Move cursor left in input
1510
+ const cursorPos = input.selectionStart;
1511
+ if (cursorPos > 0) {
1512
+ input.setSelectionRange(cursorPos - 1, cursorPos - 1);
1513
+ }
1514
+ break;
1515
+ case 'ArrowRight':
1516
+ // Move cursor right in input
1517
+ const cursorPosRight = input.selectionStart;
1518
+ if (cursorPosRight < input.value.length) {
1519
+ input.setSelectionRange(cursorPosRight + 1, cursorPosRight + 1);
1520
+ }
1521
+ break;
1522
+ case 'Home':
1523
+ // Move to start of input
1524
+ input.setSelectionRange(0, 0);
1525
+ input.focus();
1526
+ break;
1527
+ case 'End':
1528
+ // Move to end of input
1529
+ input.setSelectionRange(input.value.length, input.value.length);
1530
+ input.focus();
1531
+ break;
1532
+ case 'PageUp':
1533
+ // Scroll terminal up
1534
+ const viewportUp = document.querySelector('.xterm-viewport');
1535
+ if (viewportUp) {
1536
+ viewportUp.scrollBy(0, -viewportUp.clientHeight);
1537
+ }
1538
+ break;
1539
+ case 'PageDown':
1540
+ // Scroll terminal down
1541
+ const viewportDown = document.querySelector('.xterm-viewport');
1542
+ if (viewportDown) {
1543
+ viewportDown.scrollBy(0, viewportDown.clientHeight);
1544
+ }
1545
+ break;
1546
+ case 'F1':
1547
+ case 'F2':
1548
+ case 'F3':
1549
+ case 'F4':
1550
+ // Send function key sequences
1551
+ const fKeyMap = {
1552
+ 'F1': '\\x1bOP',
1553
+ 'F2': '\\x1bOQ',
1554
+ 'F3': '\\x1bOR',
1555
+ 'F4': '\\x1bOS'
1556
+ };
1557
+ ws.send(JSON.stringify({ type: 'input', data: fKeyMap[key] }));
1558
+ break;
1559
+ }
1560
+
1561
+ // Focus back to input for most keys
1562
+ if (!['PageUp', 'PageDown'].includes(key)) {
1563
+ input.focus();
1564
+ }
1565
+ }
1566
+
1567
+ function handleKeyCombo(combo) {
1568
+ switch(combo) {
1569
+ case 'ctrl+c':
1570
+ // Send Ctrl+C (interrupt)
1571
+ ws.send(JSON.stringify({ type: 'input', data: '\\x03' }));
1572
+ break;
1573
+ case 'ctrl+v':
1574
+ // Paste from clipboard
1575
+ navigator.clipboard.readText().then(text => {
1576
+ const cursorPos = input.selectionStart;
1577
+ const currentValue = input.value;
1578
+ input.value = currentValue.slice(0, cursorPos) + text + currentValue.slice(cursorPos);
1579
+ input.setSelectionRange(cursorPos + text.length, cursorPos + text.length);
1580
+ input.focus();
1581
+ }).catch(() => {
1582
+ // Fallback: let user know paste is not available
1583
+ console.log('Clipboard access denied');
1584
+ });
1585
+ break;
1586
+ case 'ctrl+z':
1587
+ // Send Ctrl+Z (suspend)
1588
+ ws.send(JSON.stringify({ type: 'input', data: '\\x1a' }));
1589
+ break;
1590
+ case 'ctrl+d':
1591
+ // Send Ctrl+D (EOF)
1592
+ ws.send(JSON.stringify({ type: 'input', data: '\\x04' }));
1593
+ break;
1594
+ case 'ctrl+l':
1595
+ // Send Ctrl+L (clear screen)
1596
+ ws.send(JSON.stringify({ type: 'input', data: '\\x0c' }));
1597
+ break;
1598
+ }
1599
+ input.focus();
1600
+ }
1601
+
1602
+ // Also update button visibility when new content arrives
1603
+ const originalTermWrite = term.write.bind(term);
1604
+ term.write = function(data) {
1605
+ originalTermWrite(data);
1606
+ // Only auto-scroll if user is not manually scrolling
1607
+ if (!isUserScrolling) {
1608
+ setTimeout(() => {
1609
+ const viewport = document.querySelector('.xterm-viewport');
1610
+ if (viewport) {
1611
+ viewport.scrollTop = viewport.scrollHeight;
1612
+ }
1613
+ }, 0);
1614
+ }
1615
+ // Update button visibility
1616
+ setTimeout(updateScrollButton, 50);
1617
+ };
1618
+
1619
+ doReconnect();
1620
+ input.focus();
1621
+ </script>
1622
+ </body>
1623
+ </html>
1624
+ `);
1625
+ });
1626
+ httpServer = (0, http_1.createServer)(app);
1627
+ // WebSocket server - handle authentication in connection event
1628
+ wss = new ws_1.WebSocketServer({
1629
+ maxPayload: 64 * 1024 * 1024, // 64MB max message size for large file transfer
1630
+ server: httpServer,
1631
+ path: '/ws'
1632
+ });
1633
+ wss.on('connection', (ws) => {
1634
+ const clientId = generateClientId();
1635
+ // Client connected silently
1636
+ // Initialize client with default size and ASR state
1637
+ const clientInfo = { cols: 80, rows: 24, id: clientId };
1638
+ connectedClients.set(ws, clientInfo);
1639
+ // Send buffered history
1640
+ if (outputBuffer.length > 0) {
1641
+ ws.send(JSON.stringify({ type: 'history', data: outputBuffer }));
1642
+ }
1643
+ ws.on('message', async (data) => {
1644
+ try {
1645
+ const msg = JSON.parse(data.toString());
1646
+ if (msg.type === 'input' && msg.data) {
1647
+ // Debug logging commented out for production
1648
+ // console.log(' [WebServer] Input received:', JSON.stringify(msg.data), 'charCodes:', [...msg.data].map(c => c.charCodeAt(0)));
1649
+ (0, exec_1.writeToExec)(msg.data);
1650
+ }
1651
+ // New exec command with request_id for reliable command-output correlation
1652
+ if (msg.type === 'exec' && msg.command && msg.request_id) {
1653
+ const requestId = msg.request_id;
1654
+ const command = msg.command;
1655
+ const startMarker = `<<EXEC_START_${requestId}>>`;
1656
+ const endMarker = `<<EXEC_END_${requestId}>>`;
1657
+ // Clear any existing pending command with same ID
1658
+ const existing = pendingCommands.get(requestId);
1659
+ if (existing) {
1660
+ clearTimeout(existing.timeoutId);
1661
+ pendingCommands.delete(requestId);
1662
+ }
1663
+ // Set up timeout
1664
+ const timeoutId = setTimeout(() => {
1665
+ const pending = pendingCommands.get(requestId);
1666
+ if (pending) {
1667
+ // Send timeout response
1668
+ pending.ws.send(JSON.stringify({
1669
+ type: 'exec_result',
1670
+ request_id: requestId,
1671
+ output: pending.output || '(command timed out)',
1672
+ exit_code: -1,
1673
+ error: 'timeout'
1674
+ }));
1675
+ pendingCommands.delete(requestId);
1676
+ }
1677
+ }, COMMAND_TIMEOUT);
1678
+ // Register pending command
1679
+ pendingCommands.set(requestId, {
1680
+ requestId,
1681
+ ws,
1682
+ output: '',
1683
+ startTime: Date.now(),
1684
+ timeoutId
1685
+ });
1686
+ // Send wrapped command with markers
1687
+ // Format: echo START; (command); EXIT_CODE=$?; echo END_$EXIT_CODE
1688
+ const wrappedCommand = `echo '${startMarker}'; ${command}; echo '${endMarker}_'$?\n`;
1689
+ (0, exec_1.writeToExec)(wrappedCommand);
1690
+ }
1691
+ // Handle ASR messages - Connect to ASR Gateway instead of DashScope directly
1692
+ // This reduces latency by keeping audio processing local before sending to cloud
1693
+ if (msg.type === 'asr_start') {
1694
+ // Start ASR session via ASR Gateway
1695
+ asrLog('[ASR] Starting ASR session via Gateway');
1696
+ // Connect to ASR Gateway (handles DashScope + Claude correction)
1697
+ const gatewayUrl = 'wss://voice.futuretech.social';
1698
+ const WebSocketClient = require('ws');
1699
+ clientInfo.asrWs = new WebSocketClient(gatewayUrl);
1700
+ clientInfo.sessionReady = false;
1701
+ clientInfo.audioChunkCount = 0;
1702
+ clientInfo.terminalContext = msg.context || '';
1703
+ clientInfo.asrWs.on('open', () => {
1704
+ asrLog('[ASR] Connected to ASR Gateway');
1705
+ // Send start_asr message to gateway
1706
+ const startMessage = {
1707
+ type: 'start_asr',
1708
+ config: {
1709
+ language: msg.language || 'zh',
1710
+ model: msg.model || 'qwen3-asr-flash-realtime'
1711
+ }
1712
+ };
1713
+ clientInfo.asrWs.send(JSON.stringify(startMessage));
1714
+ asrLog('[ASR] Sent start_asr to Gateway');
1715
+ // Send context if available
1716
+ if (clientInfo.terminalContext) {
1717
+ clientInfo.asrWs.send(JSON.stringify({
1718
+ type: 'context_update',
1719
+ context: clientInfo.terminalContext
1720
+ }));
1721
+ asrLog('[ASR] Sent context to Gateway');
1722
+ }
1723
+ });
1724
+ clientInfo.asrWs.on('message', (gatewayData) => {
1725
+ // Forward Gateway responses to client
1726
+ const response = JSON.parse(gatewayData.toString());
1727
+ asrLog('[ASR] Received from Gateway:', response.type);
1728
+ // Handle different gateway message types
1729
+ switch (response.type) {
1730
+ case 'connected':
1731
+ asrLog('[ASR] Gateway connected, client ID:', response.clientId);
1732
+ break;
1733
+ case 'asr_connected':
1734
+ asrLog('[ASR] ASR backend ready');
1735
+ clientInfo.sessionReady = true;
1736
+ // Notify client that ASR is ready
1737
+ ws.send(JSON.stringify({
1738
+ type: 'asr_response',
1739
+ data: { type: 'asr_ready' }
1740
+ }));
1741
+ break;
1742
+ case 'asr_disconnected':
1743
+ asrLog('[ASR] ASR backend disconnected');
1744
+ clientInfo.sessionReady = false;
1745
+ break;
1746
+ case 'partial_result':
1747
+ // Partial transcription result
1748
+ asrLog('[ASR] Partial result:', response.text);
1749
+ ws.send(JSON.stringify({
1750
+ type: 'asr_response',
1751
+ data: {
1752
+ type: 'partial',
1753
+ text: response.text,
1754
+ transcript: response.text
1755
+ }
1756
+ }));
1757
+ break;
1758
+ case 'final_result':
1759
+ // Final transcription result
1760
+ asrLog('[ASR] Final result:', response.text);
1761
+ ws.send(JSON.stringify({
1762
+ type: 'asr_response',
1763
+ data: {
1764
+ type: 'conversation.item.input_audio_transcription.completed',
1765
+ transcript: response.text,
1766
+ text: response.text
1767
+ }
1768
+ }));
1769
+ break;
1770
+ case 'correction_result':
1771
+ // Claude correction result
1772
+ asrLog('[ASR] Claude correction:', response.original, '->', response.corrected);
1773
+ ws.send(JSON.stringify({
1774
+ type: 'asr_response',
1775
+ data: {
1776
+ type: 'correction_result',
1777
+ original: response.original,
1778
+ corrected: response.corrected
1779
+ }
1780
+ }));
1781
+ break;
1782
+ case 'error':
1783
+ asrLog('[ASR] Gateway error:', response.message);
1784
+ ws.send(JSON.stringify({
1785
+ type: 'asr_response',
1786
+ data: { error: response.message }
1787
+ }));
1788
+ break;
1789
+ case 'pong':
1790
+ // Gateway responded to ping, connection is alive
1791
+ break;
1792
+ default:
1793
+ // Forward any other messages as-is for compatibility
1794
+ ws.send(JSON.stringify({
1795
+ type: 'asr_response',
1796
+ data: response
1797
+ }));
1798
+ }
1799
+ });
1800
+ clientInfo.asrWs.on('error', (error) => {
1801
+ asrLog('[ASR] Gateway error:', error);
1802
+ ws.send(JSON.stringify({
1803
+ type: 'asr_response',
1804
+ data: { error: error.message || 'Gateway connection error' }
1805
+ }));
1806
+ });
1807
+ clientInfo.asrWs.on('close', (code, reason) => {
1808
+ const reasonText = reason ? reason.toString() : 'Unknown';
1809
+ asrLog('[ASR] Gateway connection closed. Code:', code, 'Reason:', reasonText);
1810
+ clientInfo.asrWs = null;
1811
+ clientInfo.sessionReady = false;
1812
+ });
1813
+ }
1814
+ if (msg.type === 'asr_audio' && clientInfo.asrWs) {
1815
+ // Forward audio to Gateway only if session is ready
1816
+ if (clientInfo.asrWs.readyState === ws_1.WebSocket.OPEN && clientInfo.sessionReady) {
1817
+ // Log first few chunks for debugging
1818
+ if (!clientInfo.audioChunkCount) {
1819
+ clientInfo.audioChunkCount = 0;
1820
+ asrLog('[ASR] First audio chunk length:', msg.audio?.length || 0);
1821
+ }
1822
+ // Send audio to gateway in its expected format
1823
+ const audioMessage = {
1824
+ type: 'audio_data',
1825
+ audio: msg.audio
1826
+ };
1827
+ clientInfo.asrWs.send(JSON.stringify(audioMessage));
1828
+ if (clientInfo.audioChunkCount++ < 5) {
1829
+ asrLog('[ASR] Sent audio chunk to Gateway', clientInfo.audioChunkCount);
1830
+ }
1831
+ }
1832
+ else if (!clientInfo.sessionReady) {
1833
+ asrLog('[ASR] Buffering audio - session not ready yet');
1834
+ // Gateway handles buffering internally
1835
+ }
1836
+ }
1837
+ if (msg.type === 'asr_commit' && clientInfo.asrWs) {
1838
+ // Gateway handles commit internally based on VAD, but we can forward if needed
1839
+ asrLog('[ASR] Commit request (gateway handles VAD automatically)');
1840
+ }
1841
+ if (msg.type === 'asr_stop') {
1842
+ // Stop ASR session
1843
+ if (clientInfo.asrWs) {
1844
+ if (clientInfo.asrWs.readyState === ws_1.WebSocket.OPEN) {
1845
+ // Send stop command to gateway
1846
+ clientInfo.asrWs.send(JSON.stringify({
1847
+ type: 'stop_asr'
1848
+ }));
1849
+ asrLog('[ASR] Sent stop_asr to Gateway');
1850
+ // Close after a delay to receive final results
1851
+ setTimeout(() => {
1852
+ if (clientInfo.asrWs) {
1853
+ clientInfo.asrWs.close(1000, 'Recording stopped normally');
1854
+ clientInfo.asrWs = null;
1855
+ clientInfo.audioChunkCount = 0;
1856
+ }
1857
+ }, 1000); // Longer delay for gateway to process
1858
+ }
1859
+ else {
1860
+ clientInfo.asrWs = null;
1861
+ clientInfo.audioChunkCount = 0;
1862
+ }
1863
+ }
1864
+ }
1865
+ // Handle Claude correction request (now goes through gateway)
1866
+ if (msg.type === 'claude_process') {
1867
+ asrLog('[Claude] Processing request via Gateway');
1868
+ // If we have an active gateway connection, use it for Claude correction
1869
+ if (clientInfo.asrWs && clientInfo.asrWs.readyState === ws_1.WebSocket.OPEN) {
1870
+ clientInfo.asrWs.send(JSON.stringify({
1871
+ type: 'correct_text',
1872
+ text: msg.transcript,
1873
+ context: msg.context || clientInfo.terminalContext
1874
+ }));
1875
+ }
1876
+ else {
1877
+ // Fallback: connect to gateway just for correction
1878
+ const WebSocketClient = require('ws');
1879
+ const correctionWs = new WebSocketClient('wss://voice.futuretech.social');
1880
+ correctionWs.on('open', () => {
1881
+ correctionWs.send(JSON.stringify({
1882
+ type: 'correct_text',
1883
+ text: msg.transcript,
1884
+ context: msg.context
1885
+ }));
1886
+ });
1887
+ correctionWs.on('message', (data) => {
1888
+ const response = JSON.parse(data.toString());
1889
+ if (response.type === 'correction_result') {
1890
+ ws.send(JSON.stringify({
1891
+ type: 'claude_response',
1892
+ data: {
1893
+ text: response.corrected,
1894
+ done: true
1895
+ }
1896
+ }));
1897
+ correctionWs.close();
1898
+ }
1899
+ });
1900
+ correctionWs.on('error', (error) => {
1901
+ asrLog('[Claude] Gateway correction error:', error);
1902
+ ws.send(JSON.stringify({
1903
+ type: 'claude_response',
1904
+ data: { error: error.message, fallback: msg.transcript }
1905
+ }));
1906
+ });
1907
+ }
1908
+ }
1909
+ // File Read Support - Added for file transfer
1910
+ if (msg.type === 'file_read') {
1911
+ const { file_path, request_id } = msg;
1912
+ console.log(`[FileTransfer] Received file_read request: ${file_path}`);
1913
+ try {
1914
+ const fs = require('fs');
1915
+ const path = require('path');
1916
+ // Handle ~ path
1917
+ let resolvedPath = file_path;
1918
+ if (file_path.startsWith('~')) {
1919
+ resolvedPath = file_path.replace('~', require('os').homedir());
1920
+ }
1921
+ // Check if file exists
1922
+ if (!fs.existsSync(resolvedPath)) {
1923
+ throw new Error(`File not found: ${file_path}`);
1924
+ }
1925
+ // Read file
1926
+ const fileBuffer = fs.readFileSync(resolvedPath);
1927
+ const fileName = path.basename(resolvedPath);
1928
+ const fileSize = fileBuffer.length;
1929
+ const fileExt = path.extname(fileName).slice(1) || 'txt';
1930
+ const contentBase64 = fileBuffer.toString('base64');
1931
+ console.log(`[FileTransfer] File read successfully: ${fileName} (${fileSize} bytes)`);
1932
+ // Send response
1933
+ const response = {
1934
+ type: 'file_read_response',
1935
+ request_id: request_id,
1936
+ success: true,
1937
+ file_name: fileName,
1938
+ file_size: fileSize,
1939
+ file_type: fileExt,
1940
+ content_base64: contentBase64
1941
+ };
1942
+ ws.send(JSON.stringify(response));
1943
+ console.log(`[FileTransfer] Response sent for request: ${request_id}`);
1944
+ }
1945
+ catch (error) {
1946
+ console.error(`[FileTransfer] Error reading file:`, error);
1947
+ // Send error response
1948
+ const errorResponse = {
1949
+ type: 'file_read_response',
1950
+ request_id: request_id,
1951
+ success: false,
1952
+ error: error.message || 'Failed to read file'
1953
+ };
1954
+ ws.send(JSON.stringify(errorResponse));
1955
+ }
1956
+ }
1957
+ // Handle ping/pong for connection keep-alive
1958
+ if (msg.type === 'ping') {
1959
+ ws.send(JSON.stringify({
1960
+ type: 'pong',
1961
+ timestamp: msg.timestamp || Date.now()
1962
+ }));
1963
+ }
1964
+ if (msg.type === 'resize' && msg.cols && msg.rows) {
1965
+ // Update this client's dimensions
1966
+ const clientInfo = connectedClients.get(ws);
1967
+ if (clientInfo) {
1968
+ clientInfo.cols = msg.cols;
1969
+ clientInfo.rows = msg.rows;
1970
+ // Client resized silently
1971
+ }
1972
+ applyMinSize();
1973
+ }
1974
+ }
1975
+ catch (e) {
1976
+ console.error(' [WebServer] Invalid message:', e);
1977
+ }
1978
+ });
1979
+ ws.on('close', () => {
1980
+ const clientInfo = connectedClients.get(ws);
1981
+ if (clientInfo) {
1982
+ // Clean up ASR WebSocket if exists
1983
+ if (clientInfo.asrWs) {
1984
+ clientInfo.asrWs.close(1001, 'Client disconnected');
1985
+ clientInfo.asrWs = null;
1986
+ }
1987
+ // Client disconnected silently
1988
+ connectedClients.delete(ws);
1989
+ // Recalculate minimum size after client disconnection
1990
+ applyMinSize();
1991
+ }
1992
+ });
1993
+ });
1994
+ // Forward exec output to all clients
1995
+ (0, exec_1.onExecData)((data) => {
1996
+ outputBuffer.push(data);
1997
+ if (outputBuffer.length > MAX_BUFFER_SIZE) {
1998
+ outputBuffer = outputBuffer.slice(-3000);
1999
+ }
2000
+ // Check for exec command markers and route output accordingly
2001
+ let remainingData = data;
2002
+ // Process any pending commands that might have output in this chunk
2003
+ pendingCommands.forEach((pending, requestId) => {
2004
+ const startMarker = `<<EXEC_START_${requestId}>>`;
2005
+ const endMarkerPrefix = `<<EXEC_END_${requestId}>>_`;
2006
+ // Check if this data contains our markers
2007
+ if (remainingData.includes(startMarker)) {
2008
+ // Found start marker - extract content after it
2009
+ const startIdx = remainingData.indexOf(startMarker);
2010
+ remainingData = remainingData.substring(startIdx + startMarker.length);
2011
+ // Remove the newline after marker if present
2012
+ if (remainingData.startsWith('\n')) {
2013
+ remainingData = remainingData.substring(1);
2014
+ }
2015
+ }
2016
+ // Check for end marker
2017
+ const endIdx = remainingData.indexOf(endMarkerPrefix);
2018
+ if (endIdx !== -1) {
2019
+ // Found end marker - extract output and exit code
2020
+ const outputPart = remainingData.substring(0, endIdx);
2021
+ pending.output += outputPart;
2022
+ // Parse exit code from end marker
2023
+ const afterEnd = remainingData.substring(endIdx + endMarkerPrefix.length);
2024
+ const exitCodeMatch = afterEnd.match(/^(\d+)/);
2025
+ const exitCode = exitCodeMatch ? parseInt(exitCodeMatch[1], 10) : 0;
2026
+ // Clear timeout and send response
2027
+ clearTimeout(pending.timeoutId);
2028
+ // Clean up output (remove trailing newline)
2029
+ let cleanOutput = pending.output.trim();
2030
+ pending.ws.send(JSON.stringify({
2031
+ type: 'exec_result',
2032
+ request_id: requestId,
2033
+ output: cleanOutput || '(no output)',
2034
+ exit_code: exitCode
2035
+ }));
2036
+ pendingCommands.delete(requestId);
2037
+ // Remove processed markers from remaining data
2038
+ const fullEndMarker = endMarkerPrefix + (exitCodeMatch ? exitCodeMatch[0] : '');
2039
+ remainingData = remainingData.substring(endIdx + fullEndMarker.length);
2040
+ if (remainingData.startsWith('\n')) {
2041
+ remainingData = remainingData.substring(1);
2042
+ }
2043
+ }
2044
+ else if (pending.output !== '' || remainingData.length > 0) {
2045
+ // No end marker yet, accumulate output (only if we've seen start)
2046
+ // Check if we've already started collecting (output is non-empty) or start marker was in previous chunk
2047
+ if (pending.output !== '' || !data.includes(`<<EXEC_START_${requestId}>>`)) {
2048
+ pending.output += remainingData;
2049
+ remainingData = ''; // Consumed by this command
2050
+ }
2051
+ }
2052
+ });
2053
+ // Broadcast remaining output (for web terminal and non-exec messages)
2054
+ const msg = JSON.stringify({ type: 'output', data });
2055
+ connectedClients.forEach((clientInfo, client) => {
2056
+ if (client.readyState === ws_1.WebSocket.OPEN) {
2057
+ client.send(msg);
2058
+ }
2059
+ });
2060
+ });
2061
+ // Notify clients on exec exit
2062
+ (0, exec_1.onExecExit)((code) => {
2063
+ const msg = JSON.stringify({ type: 'exit', code });
2064
+ connectedClients.forEach((clientInfo, client) => {
2065
+ if (client.readyState === ws_1.WebSocket.OPEN) {
2066
+ client.send(msg);
2067
+ }
2068
+ });
2069
+ });
2070
+ httpServer.listen(port, '0.0.0.0', () => {
2071
+ // Add a small delay to ensure the server is fully ready
2072
+ setTimeout(() => {
2073
+ resolve();
2074
+ }, 100);
2075
+ });
2076
+ httpServer.on('error', (err) => {
2077
+ console.error(' Failed to start server:', err);
2078
+ reject(err);
2079
+ });
2080
+ });
2081
+ }
2082
+ function stopWebServer() {
2083
+ if (wss) {
2084
+ wss.clients.forEach((client) => client.close());
2085
+ wss.close();
2086
+ wss = null;
2087
+ }
2088
+ if (httpServer) {
2089
+ httpServer.close();
2090
+ httpServer = null;
2091
+ }
2092
+ connectedClients.clear();
2093
+ outputBuffer = [];
2094
+ clientIdCounter = 0;
2095
+ }
2096
+ //# sourceMappingURL=web-server.js.map