@leverageaiapps/theseus-server 1.0.0

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