@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.
- package/LICENSE +21 -0
- package/README.md +165 -0
- package/dist/capture.d.ts +3 -0
- package/dist/capture.d.ts.map +1 -0
- package/dist/capture.js +134 -0
- package/dist/capture.js.map +1 -0
- package/dist/cloudflare-tunnel.d.ts +9 -0
- package/dist/cloudflare-tunnel.d.ts.map +1 -0
- package/dist/cloudflare-tunnel.js +218 -0
- package/dist/cloudflare-tunnel.js.map +1 -0
- package/dist/config.d.ts +7 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +84 -0
- package/dist/config.js.map +1 -0
- package/dist/context-extractor.d.ts +17 -0
- package/dist/context-extractor.d.ts.map +1 -0
- package/dist/context-extractor.js +118 -0
- package/dist/context-extractor.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +45 -0
- package/dist/index.js.map +1 -0
- package/dist/pty.d.ts +20 -0
- package/dist/pty.d.ts.map +1 -0
- package/dist/pty.js +148 -0
- package/dist/pty.js.map +1 -0
- package/dist/relay.d.ts +5 -0
- package/dist/relay.d.ts.map +1 -0
- package/dist/relay.js +131 -0
- package/dist/relay.js.map +1 -0
- package/dist/session.d.ts +5 -0
- package/dist/session.d.ts.map +1 -0
- package/dist/session.js +257 -0
- package/dist/session.js.map +1 -0
- package/dist/voice-recognition-modelscope.d.ts +50 -0
- package/dist/voice-recognition-modelscope.d.ts.map +1 -0
- package/dist/voice-recognition-modelscope.js +171 -0
- package/dist/voice-recognition-modelscope.js.map +1 -0
- package/dist/web-server.d.ts +6 -0
- package/dist/web-server.d.ts.map +1 -0
- package/dist/web-server.js +1971 -0
- package/dist/web-server.js.map +1 -0
- package/package.json +66 -0
- package/public/index.html +639 -0
- package/public/js/terminal-asr.js +508 -0
- package/public/js/terminal.js +514 -0
- package/public/js/voice-input.js +422 -0
- package/scripts/postinstall.js +66 -0
- 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
|