agentgui 1.0.208 → 1.0.210
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/CLAUDE.md +50 -0
- package/lib/windows-pocket-tts-setup.js +100 -23
- package/package.json +1 -1
- package/server.js +91 -51
- package/static/js/agent-auth.js +99 -0
package/CLAUDE.md
CHANGED
|
@@ -89,3 +89,53 @@ Server broadcasts:
|
|
|
89
89
|
- `streaming_complete` - Execution finished
|
|
90
90
|
- `streaming_error` - Execution failed
|
|
91
91
|
- `conversation_created`, `conversation_updated`, `conversation_deleted`
|
|
92
|
+
- `tts_setup_progress` - Windows pocket-tts setup progress (step, status, message)
|
|
93
|
+
|
|
94
|
+
## Pocket-TTS Windows Setup (Reliability for Slow/Bad Internet)
|
|
95
|
+
|
|
96
|
+
On Windows, text-to-speech uses pocket-tts which requires Python and pip install. The setup process is now resilient to slow/unreliable connections:
|
|
97
|
+
|
|
98
|
+
### Features
|
|
99
|
+
- **Extended timeouts**: 120s for pip install (accommodates slow connections)
|
|
100
|
+
- **Retry logic**: 3 attempts with exponential backoff (1s, 2s delays)
|
|
101
|
+
- **Progress reporting**: Real-time updates via WebSocket to UI
|
|
102
|
+
- **Partial install cleanup**: Failed venvs are removed to allow retry
|
|
103
|
+
- **Installation verification**: Binary validation via `--version` check
|
|
104
|
+
- **Concurrent waiting**: Multiple simultaneous requests wait for single setup (600s timeout)
|
|
105
|
+
|
|
106
|
+
### Configuration (lib/windows-pocket-tts-setup.js)
|
|
107
|
+
```javascript
|
|
108
|
+
const CONFIG = {
|
|
109
|
+
PIP_TIMEOUT: 120000, // 2 minutes
|
|
110
|
+
VENV_CREATION_TIMEOUT: 30000, // 30 seconds
|
|
111
|
+
MAX_RETRIES: 3, // 3 attempts
|
|
112
|
+
RETRY_DELAY_MS: 1000, // 1 second initial
|
|
113
|
+
RETRY_BACKOFF_MULTIPLIER: 2, // 2x exponential
|
|
114
|
+
};
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
### Network Requirements
|
|
118
|
+
- **Minimum**: 50 kbps sustained, < 5s latency, < 10% packet loss
|
|
119
|
+
- **Recommended**: 256+ kbps, < 2s latency, < 1% packet loss
|
|
120
|
+
- **Expected time on slow connection**: 2-6 minutes with retries
|
|
121
|
+
|
|
122
|
+
### Progress Messages
|
|
123
|
+
During TTS setup on first use, WebSocket broadcasts:
|
|
124
|
+
```json
|
|
125
|
+
{
|
|
126
|
+
"type": "tts_setup_progress",
|
|
127
|
+
"step": "detecting-python|creating-venv|installing|verifying",
|
|
128
|
+
"status": "in-progress|success|error",
|
|
129
|
+
"message": "descriptive status message with retry count if applicable"
|
|
130
|
+
}
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
### Recovery Behavior
|
|
134
|
+
1. Network timeout → auto-retry with backoff
|
|
135
|
+
2. Partial venv → auto-cleanup before retry
|
|
136
|
+
3. Failed verification → auto-cleanup and error
|
|
137
|
+
4. Concurrent requests → first starts setup, others wait up to 600s
|
|
138
|
+
5. Interrupted setup → cleanup allows fresh retry
|
|
139
|
+
|
|
140
|
+
### Testing
|
|
141
|
+
Setup validates by running pocket-tts binary with `--version` flag to confirm functional installation, not just file existence.
|
|
@@ -8,6 +8,14 @@ const VENV_DIR = path.join(os.homedir(), '.gmgui', 'pocket-venv');
|
|
|
8
8
|
const isWin = process.platform === 'win32';
|
|
9
9
|
const EXECUTABLE_NAME = isWin ? 'pocket-tts.exe' : 'pocket-tts';
|
|
10
10
|
|
|
11
|
+
const CONFIG = {
|
|
12
|
+
PIP_TIMEOUT: 120000,
|
|
13
|
+
VENV_CREATION_TIMEOUT: 30000,
|
|
14
|
+
MAX_RETRIES: 3,
|
|
15
|
+
RETRY_DELAY_MS: 1000,
|
|
16
|
+
RETRY_BACKOFF_MULTIPLIER: 2,
|
|
17
|
+
};
|
|
18
|
+
|
|
11
19
|
function getPocketTtsPath() {
|
|
12
20
|
if (isWin) {
|
|
13
21
|
return path.join(VENV_DIR, 'Scripts', EXECUTABLE_NAME);
|
|
@@ -17,7 +25,7 @@ function getPocketTtsPath() {
|
|
|
17
25
|
|
|
18
26
|
function detectPython() {
|
|
19
27
|
try {
|
|
20
|
-
const versionOutput = execSync('python --version', { encoding: 'utf-8' }).trim();
|
|
28
|
+
const versionOutput = execSync('python --version', { encoding: 'utf-8', timeout: 10000 }).trim();
|
|
21
29
|
const match = versionOutput.match(/(\d+)\.(\d+)/);
|
|
22
30
|
if (!match) return { found: false, version: null, error: 'Could not parse version' };
|
|
23
31
|
|
|
@@ -40,6 +48,53 @@ function isSetup() {
|
|
|
40
48
|
return fs.existsSync(exePath);
|
|
41
49
|
}
|
|
42
50
|
|
|
51
|
+
function cleanupPartialInstall() {
|
|
52
|
+
try {
|
|
53
|
+
if (fs.existsSync(VENV_DIR)) {
|
|
54
|
+
fs.rmSync(VENV_DIR, { recursive: true, force: true });
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
57
|
+
} catch (e) {
|
|
58
|
+
console.error(`Failed to cleanup partial install: ${e.message}`);
|
|
59
|
+
}
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function verifyInstallation() {
|
|
64
|
+
const exePath = getPocketTtsPath();
|
|
65
|
+
if (!fs.existsSync(exePath)) {
|
|
66
|
+
return { valid: false, error: `Binary not found at ${exePath}` };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
try {
|
|
70
|
+
const versionOutput = execSync(`"${exePath}" --version`, { encoding: 'utf-8', timeout: 10000, stdio: 'pipe' });
|
|
71
|
+
return { valid: true, version: versionOutput.trim() };
|
|
72
|
+
} catch (e) {
|
|
73
|
+
return { valid: false, error: `Binary exists but failed verification: ${e.message}` };
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function executeWithRetry(fn, stepName, maxRetries = CONFIG.MAX_RETRIES) {
|
|
78
|
+
let lastError = null;
|
|
79
|
+
let delayMs = CONFIG.RETRY_DELAY_MS;
|
|
80
|
+
|
|
81
|
+
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
|
82
|
+
try {
|
|
83
|
+
return await fn(attempt);
|
|
84
|
+
} catch (e) {
|
|
85
|
+
lastError = e;
|
|
86
|
+
if (attempt < maxRetries) {
|
|
87
|
+
console.log(`Attempt ${attempt}/${maxRetries} failed for ${stepName}, retrying in ${delayMs}ms`);
|
|
88
|
+
await new Promise(r => setTimeout(r, delayMs));
|
|
89
|
+
delayMs *= CONFIG.RETRY_BACKOFF_MULTIPLIER;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const msg = `${stepName} failed after ${maxRetries} attempts: ${lastError.message || lastError}`;
|
|
95
|
+
throw new Error(msg);
|
|
96
|
+
}
|
|
97
|
+
|
|
43
98
|
async function install(onProgress) {
|
|
44
99
|
const pythonDetect = detectPython();
|
|
45
100
|
|
|
@@ -57,56 +112,79 @@ async function install(onProgress) {
|
|
|
57
112
|
if (onProgress) onProgress({ step: 'detecting-python', status: 'success', message: `Found Python ${pythonDetect.version}` });
|
|
58
113
|
|
|
59
114
|
if (isSetup()) {
|
|
60
|
-
|
|
61
|
-
|
|
115
|
+
const verify = verifyInstallation();
|
|
116
|
+
if (verify.valid) {
|
|
117
|
+
if (onProgress) onProgress({ step: 'verifying', status: 'success', message: 'pocket-tts already installed' });
|
|
118
|
+
return { success: true };
|
|
119
|
+
}
|
|
62
120
|
}
|
|
63
121
|
|
|
64
122
|
if (onProgress) onProgress({ step: 'creating-venv', status: 'in-progress', message: `Creating virtual environment at ${VENV_DIR}` });
|
|
65
123
|
|
|
66
124
|
try {
|
|
67
|
-
|
|
125
|
+
await executeWithRetry(async (attempt) => {
|
|
126
|
+
return execSync(`python -m venv "${VENV_DIR}"`, {
|
|
127
|
+
encoding: 'utf-8',
|
|
128
|
+
stdio: 'pipe',
|
|
129
|
+
timeout: CONFIG.VENV_CREATION_TIMEOUT,
|
|
130
|
+
});
|
|
131
|
+
}, 'venv creation', 2);
|
|
132
|
+
|
|
68
133
|
if (onProgress) onProgress({ step: 'creating-venv', status: 'success', message: 'Virtual environment created' });
|
|
69
134
|
} catch (e) {
|
|
70
|
-
const msg = `Failed to create venv: ${e.message || e
|
|
135
|
+
const msg = `Failed to create venv: ${e.message || e}`;
|
|
71
136
|
if (onProgress) onProgress({ step: 'creating-venv', status: 'error', message: msg });
|
|
137
|
+
cleanupPartialInstall();
|
|
72
138
|
return { success: false, error: msg };
|
|
73
139
|
}
|
|
74
140
|
|
|
75
|
-
if (onProgress) onProgress({ step: 'installing', status: 'in-progress', message: 'Installing pocket-tts via pip (this may take
|
|
141
|
+
if (onProgress) onProgress({ step: 'installing', status: 'in-progress', message: 'Installing pocket-tts via pip (this may take 2-5 minutes on slow connections)' });
|
|
76
142
|
|
|
77
143
|
try {
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
144
|
+
await executeWithRetry(async (attempt) => {
|
|
145
|
+
if (attempt > 1 && onProgress) {
|
|
146
|
+
onProgress({ step: 'installing', status: 'in-progress', message: `Installing pocket-tts (attempt ${attempt}/${CONFIG.MAX_RETRIES})` });
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const pipCmd = isWin
|
|
150
|
+
? `"${path.join(VENV_DIR, 'Scripts', 'pip')}" install --no-cache-dir pocket-tts`
|
|
151
|
+
: `"${path.join(VENV_DIR, 'bin', 'pip')}" install --no-cache-dir pocket-tts`;
|
|
152
|
+
|
|
153
|
+
return execSync(pipCmd, {
|
|
154
|
+
encoding: 'utf-8',
|
|
155
|
+
stdio: 'pipe',
|
|
156
|
+
timeout: CONFIG.PIP_TIMEOUT,
|
|
157
|
+
env: { ...process.env, PIP_DEFAULT_TIMEOUT: '120' },
|
|
158
|
+
});
|
|
159
|
+
}, 'pip install', CONFIG.MAX_RETRIES);
|
|
81
160
|
|
|
82
|
-
const installResult = execSync(pipCmd, { encoding: 'utf-8', stdio: 'pipe', timeout: 300000 });
|
|
83
161
|
if (onProgress) onProgress({ step: 'installing', status: 'success', message: 'pocket-tts installed successfully' });
|
|
84
162
|
} catch (e) {
|
|
85
|
-
const msg = `Failed to install pocket-tts: ${e.message || e
|
|
163
|
+
const msg = `Failed to install pocket-tts: ${e.message || e}`;
|
|
86
164
|
if (onProgress) onProgress({ step: 'installing', status: 'error', message: msg });
|
|
165
|
+
cleanupPartialInstall();
|
|
87
166
|
return { success: false, error: msg };
|
|
88
167
|
}
|
|
89
168
|
|
|
90
169
|
if (onProgress) onProgress({ step: 'verifying', status: 'in-progress', message: 'Verifying installation' });
|
|
91
170
|
|
|
92
|
-
const
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
if (!fs.existsSync(exePath)) {
|
|
97
|
-
const msg = `pocket-tts binary not found at ${exePath}`;
|
|
171
|
+
const verify = verifyInstallation();
|
|
172
|
+
if (!verify.valid) {
|
|
173
|
+
const msg = verify.error || 'Installation verification failed';
|
|
98
174
|
if (onProgress) onProgress({ step: 'verifying', status: 'error', message: msg });
|
|
175
|
+
cleanupPartialInstall();
|
|
99
176
|
return { success: false, error: msg };
|
|
100
177
|
}
|
|
101
178
|
|
|
102
|
-
|
|
103
|
-
|
|
179
|
+
const exePath = getPocketTtsPath();
|
|
180
|
+
const binDir = path.join(VENV_DIR, 'bin');
|
|
181
|
+
const binExePath = path.join(binDir, 'pocket-tts');
|
|
182
|
+
|
|
104
183
|
if (isWin) {
|
|
105
184
|
try {
|
|
106
185
|
fs.mkdirSync(binDir, { recursive: true });
|
|
107
186
|
} catch (e) {}
|
|
108
187
|
|
|
109
|
-
// Copy pocket-tts.exe to bin folder
|
|
110
188
|
const exeWithExt = path.join(binDir, 'pocket-tts.exe');
|
|
111
189
|
if (fs.existsSync(exePath) && !fs.existsSync(exeWithExt)) {
|
|
112
190
|
try {
|
|
@@ -114,7 +192,6 @@ async function install(onProgress) {
|
|
|
114
192
|
} catch (e) {}
|
|
115
193
|
}
|
|
116
194
|
|
|
117
|
-
// Create a batch file wrapper for Node.js spawn compatibility
|
|
118
195
|
const batchFile = path.join(binDir, 'pocket-tts.bat');
|
|
119
196
|
if (!fs.existsSync(batchFile) && fs.existsSync(exeWithExt)) {
|
|
120
197
|
try {
|
|
@@ -124,9 +201,9 @@ async function install(onProgress) {
|
|
|
124
201
|
}
|
|
125
202
|
}
|
|
126
203
|
|
|
127
|
-
if (onProgress) onProgress({ step: 'verifying', status: 'success', message:
|
|
204
|
+
if (onProgress) onProgress({ step: 'verifying', status: 'success', message: `pocket-tts ready (${verify.version})` });
|
|
128
205
|
|
|
129
206
|
return { success: true };
|
|
130
207
|
}
|
|
131
208
|
|
|
132
|
-
export { detectPython, isSetup, install, getPocketTtsPath, VENV_DIR };
|
|
209
|
+
export { detectPython, isSetup, install, getPocketTtsPath, VENV_DIR, CONFIG, cleanupPartialInstall, verifyInstallation };
|
package/package.json
CHANGED
package/server.js
CHANGED
|
@@ -27,7 +27,8 @@ async function ensurePocketTtsSetup(onProgress) {
|
|
|
27
27
|
|
|
28
28
|
if (pocketTtsSetupState.inProgress) {
|
|
29
29
|
let waited = 0;
|
|
30
|
-
|
|
30
|
+
const MAX_WAIT = 600000;
|
|
31
|
+
while (pocketTtsSetupState.inProgress && waited < MAX_WAIT) {
|
|
31
32
|
await new Promise(r => setTimeout(r, 100));
|
|
32
33
|
waited += 100;
|
|
33
34
|
}
|
|
@@ -332,11 +333,11 @@ function geminiOAuthResultPage(title, message, success) {
|
|
|
332
333
|
</div></body></html>`;
|
|
333
334
|
}
|
|
334
335
|
|
|
335
|
-
async function startGeminiOAuth(
|
|
336
|
+
async function startGeminiOAuth() {
|
|
336
337
|
const creds = getGeminiOAuthCreds();
|
|
337
338
|
if (!creds) throw new Error('Could not find Gemini CLI OAuth credentials. Install gemini CLI first.');
|
|
338
339
|
|
|
339
|
-
const redirectUri =
|
|
340
|
+
const redirectUri = `http://localhost:${PORT}${BASE_URL}/oauth2callback`;
|
|
340
341
|
const state = crypto.randomBytes(32).toString('hex');
|
|
341
342
|
|
|
342
343
|
const client = new OAuth2Client({
|
|
@@ -364,71 +365,74 @@ async function startGeminiOAuth(baseUrl) {
|
|
|
364
365
|
return authUrl;
|
|
365
366
|
}
|
|
366
367
|
|
|
367
|
-
async function
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
if (!geminiOAuthPending) {
|
|
371
|
-
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
372
|
-
res.end(geminiOAuthResultPage('Authentication Failed', 'No pending OAuth flow.', false));
|
|
373
|
-
return;
|
|
374
|
-
}
|
|
375
|
-
|
|
376
|
-
const error = reqUrl.searchParams.get('error');
|
|
377
|
-
if (error) {
|
|
378
|
-
const desc = reqUrl.searchParams.get('error_description') || error;
|
|
379
|
-
geminiOAuthState = { status: 'error', error: desc, email: null };
|
|
380
|
-
geminiOAuthPending = null;
|
|
381
|
-
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
382
|
-
res.end(geminiOAuthResultPage('Authentication Failed', desc, false));
|
|
383
|
-
return;
|
|
384
|
-
}
|
|
368
|
+
async function exchangeGeminiOAuthCode(code, state) {
|
|
369
|
+
if (!geminiOAuthPending) throw new Error('No pending OAuth flow. Please start authentication again.');
|
|
385
370
|
|
|
386
371
|
const { client, redirectUri, state: expectedState } = geminiOAuthPending;
|
|
387
372
|
|
|
388
|
-
if (
|
|
373
|
+
if (state !== expectedState) {
|
|
389
374
|
geminiOAuthState = { status: 'error', error: 'State mismatch', email: null };
|
|
390
375
|
geminiOAuthPending = null;
|
|
391
|
-
|
|
392
|
-
res.end(geminiOAuthResultPage('Authentication Failed', 'State mismatch.', false));
|
|
393
|
-
return;
|
|
376
|
+
throw new Error('State mismatch - possible CSRF attack.');
|
|
394
377
|
}
|
|
395
378
|
|
|
396
|
-
const code = reqUrl.searchParams.get('code');
|
|
397
379
|
if (!code) {
|
|
398
|
-
geminiOAuthState = { status: 'error', error: 'No authorization code', email: null };
|
|
380
|
+
geminiOAuthState = { status: 'error', error: 'No authorization code received', email: null };
|
|
399
381
|
geminiOAuthPending = null;
|
|
382
|
+
throw new Error('No authorization code received.');
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
const { tokens } = await client.getToken({ code, redirect_uri: redirectUri });
|
|
386
|
+
client.setCredentials(tokens);
|
|
387
|
+
|
|
388
|
+
let email = '';
|
|
389
|
+
try {
|
|
390
|
+
const { token } = await client.getAccessToken();
|
|
391
|
+
if (token) {
|
|
392
|
+
const resp = await fetch('https://www.googleapis.com/oauth2/v2/userinfo', {
|
|
393
|
+
headers: { Authorization: `Bearer ${token}` }
|
|
394
|
+
});
|
|
395
|
+
if (resp.ok) {
|
|
396
|
+
const info = await resp.json();
|
|
397
|
+
email = info.email || '';
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
} catch (_) {}
|
|
401
|
+
|
|
402
|
+
saveGeminiCredentials(tokens, email);
|
|
403
|
+
geminiOAuthState = { status: 'success', error: null, email };
|
|
404
|
+
geminiOAuthPending = null;
|
|
405
|
+
|
|
406
|
+
return email;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
async function handleGeminiOAuthCallback(req, res) {
|
|
410
|
+
const reqUrl = new URL(req.url, `http://localhost:${PORT}`);
|
|
411
|
+
|
|
412
|
+
if (!geminiOAuthPending) {
|
|
400
413
|
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
401
|
-
res.end(geminiOAuthResultPage('Authentication Failed', 'No
|
|
414
|
+
res.end(geminiOAuthResultPage('Authentication Failed', 'No pending OAuth flow.', false));
|
|
402
415
|
return;
|
|
403
416
|
}
|
|
404
417
|
|
|
405
418
|
try {
|
|
406
|
-
const
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
});
|
|
416
|
-
if (resp.ok) {
|
|
417
|
-
const info = await resp.json();
|
|
418
|
-
email = info.email || '';
|
|
419
|
-
}
|
|
420
|
-
}
|
|
421
|
-
} catch (_) {}
|
|
419
|
+
const error = reqUrl.searchParams.get('error');
|
|
420
|
+
if (error) {
|
|
421
|
+
const desc = reqUrl.searchParams.get('error_description') || error;
|
|
422
|
+
geminiOAuthState = { status: 'error', error: desc, email: null };
|
|
423
|
+
geminiOAuthPending = null;
|
|
424
|
+
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
425
|
+
res.end(geminiOAuthResultPage('Authentication Failed', desc, false));
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
422
428
|
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
429
|
+
const code = reqUrl.searchParams.get('code');
|
|
430
|
+
const state = reqUrl.searchParams.get('state');
|
|
431
|
+
const email = await exchangeGeminiOAuthCode(code, state);
|
|
426
432
|
|
|
427
433
|
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
428
434
|
res.end(geminiOAuthResultPage('Authentication Successful', email ? `Signed in as ${email}` : 'Gemini CLI credentials saved.', true));
|
|
429
435
|
} catch (e) {
|
|
430
|
-
geminiOAuthState = { status: 'error', error: e.message, email: null };
|
|
431
|
-
geminiOAuthPending = null;
|
|
432
436
|
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
433
437
|
res.end(geminiOAuthResultPage('Authentication Failed', e.message, false));
|
|
434
438
|
}
|
|
@@ -1126,7 +1130,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
1126
1130
|
|
|
1127
1131
|
if (pathOnly === '/api/gemini-oauth/start' && req.method === 'POST') {
|
|
1128
1132
|
try {
|
|
1129
|
-
const authUrl = await startGeminiOAuth(
|
|
1133
|
+
const authUrl = await startGeminiOAuth();
|
|
1130
1134
|
sendJSON(req, res, 200, { authUrl });
|
|
1131
1135
|
} catch (e) {
|
|
1132
1136
|
console.error('[gemini-oauth] /api/gemini-oauth/start failed:', e);
|
|
@@ -1140,6 +1144,42 @@ const server = http.createServer(async (req, res) => {
|
|
|
1140
1144
|
return;
|
|
1141
1145
|
}
|
|
1142
1146
|
|
|
1147
|
+
if (pathOnly === '/api/gemini-oauth/complete' && req.method === 'POST') {
|
|
1148
|
+
try {
|
|
1149
|
+
const body = await parseBody(req);
|
|
1150
|
+
const pastedUrl = (body.url || '').trim();
|
|
1151
|
+
if (!pastedUrl) {
|
|
1152
|
+
sendJSON(req, res, 400, { error: 'No URL provided' });
|
|
1153
|
+
return;
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
let parsed;
|
|
1157
|
+
try { parsed = new URL(pastedUrl); } catch (_) {
|
|
1158
|
+
sendJSON(req, res, 400, { error: 'Invalid URL. Paste the full URL from the browser address bar.' });
|
|
1159
|
+
return;
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
const error = parsed.searchParams.get('error');
|
|
1163
|
+
if (error) {
|
|
1164
|
+
const desc = parsed.searchParams.get('error_description') || error;
|
|
1165
|
+
geminiOAuthState = { status: 'error', error: desc, email: null };
|
|
1166
|
+
geminiOAuthPending = null;
|
|
1167
|
+
sendJSON(req, res, 200, { error: desc });
|
|
1168
|
+
return;
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
const code = parsed.searchParams.get('code');
|
|
1172
|
+
const state = parsed.searchParams.get('state');
|
|
1173
|
+
const email = await exchangeGeminiOAuthCode(code, state);
|
|
1174
|
+
sendJSON(req, res, 200, { success: true, email });
|
|
1175
|
+
} catch (e) {
|
|
1176
|
+
geminiOAuthState = { status: 'error', error: e.message, email: null };
|
|
1177
|
+
geminiOAuthPending = null;
|
|
1178
|
+
sendJSON(req, res, 400, { error: e.message });
|
|
1179
|
+
}
|
|
1180
|
+
return;
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1143
1183
|
const agentAuthMatch = pathOnly.match(/^\/api\/agents\/([^/]+)\/auth$/);
|
|
1144
1184
|
if (agentAuthMatch && req.method === 'POST') {
|
|
1145
1185
|
const agentId = agentAuthMatch[1];
|
|
@@ -1148,7 +1188,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
1148
1188
|
|
|
1149
1189
|
if (agentId === 'gemini') {
|
|
1150
1190
|
try {
|
|
1151
|
-
const authUrl = await startGeminiOAuth(
|
|
1191
|
+
const authUrl = await startGeminiOAuth();
|
|
1152
1192
|
const conversationId = '__agent_auth__';
|
|
1153
1193
|
broadcastSync({ type: 'script_started', conversationId, script: 'auth-gemini', agentId: 'gemini', timestamp: Date.now() });
|
|
1154
1194
|
broadcastSync({ type: 'script_output', conversationId, data: `\x1b[36mOpening Google OAuth in your browser...\x1b[0m\r\n\r\nIf it doesn't open automatically, visit:\r\n${authUrl}\r\n`, stream: 'stdout', timestamp: Date.now() });
|
package/static/js/agent-auth.js
CHANGED
|
@@ -130,6 +130,81 @@
|
|
|
130
130
|
|
|
131
131
|
function closeDropdown() { dropdown.classList.remove('open'); editingProvider = null; }
|
|
132
132
|
|
|
133
|
+
var oauthPollInterval = null, oauthPollTimeout = null;
|
|
134
|
+
|
|
135
|
+
function cleanupOAuthPolling() {
|
|
136
|
+
if (oauthPollInterval) { clearInterval(oauthPollInterval); oauthPollInterval = null; }
|
|
137
|
+
if (oauthPollTimeout) { clearTimeout(oauthPollTimeout); oauthPollTimeout = null; }
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function showOAuthPasteModal() {
|
|
141
|
+
removeOAuthPasteModal();
|
|
142
|
+
var overlay = document.createElement('div');
|
|
143
|
+
overlay.id = 'oauthPasteModal';
|
|
144
|
+
overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.7);display:flex;align-items:center;justify-content:center;z-index:9999;';
|
|
145
|
+
var s = function(c) { return 'font-size:0.8rem;color:var(--color-text-secondary,#d1d5db);margin:0 0 ' + (c ? '0' : '0.5rem') + ';'; };
|
|
146
|
+
overlay.innerHTML = '<div style="background:var(--color-bg-secondary,#1f2937);border-radius:1rem;padding:2rem;max-width:28rem;width:calc(100% - 2rem);box-shadow:0 25px 50px rgba(0,0,0,0.5);color:var(--color-text-primary,white);font-family:system-ui,sans-serif;" onclick="event.stopPropagation()">' +
|
|
147
|
+
'<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:1rem;">' +
|
|
148
|
+
'<h2 style="font-size:1.125rem;font-weight:700;margin:0;">Complete Google Sign-In</h2>' +
|
|
149
|
+
'<button id="oauthPasteClose" style="background:none;border:none;color:var(--color-text-secondary,#9ca3af);font-size:1.5rem;cursor:pointer;padding:0;line-height:1;">\u00d7</button></div>' +
|
|
150
|
+
'<div style="margin-bottom:1rem;padding:1rem;background:var(--color-bg-tertiary,rgba(255,255,255,0.05));border-radius:0.5rem;">' +
|
|
151
|
+
'<p style="' + s() + '">1. A Google sign-in page has opened in a new tab.</p>' +
|
|
152
|
+
'<p style="' + s() + '">2. Complete the sign-in process with Google.</p>' +
|
|
153
|
+
'<p style="' + s() + '">3. After signing in, you will be redirected to a page that <span style="color:#facc15;font-weight:600;">may not load</span> (this is expected).</p>' +
|
|
154
|
+
'<p style="' + s(1) + '">4. Copy the <span style="color:white;font-weight:600;">entire URL</span> from the address bar and paste it below.</p></div>' +
|
|
155
|
+
'<label style="display:block;font-size:0.8rem;color:var(--color-text-secondary,#d1d5db);margin-bottom:0.5rem;">Paste the redirect URL here:</label>' +
|
|
156
|
+
'<input type="text" id="oauthPasteInput" placeholder="http://localhost:3000/gm/oauth2callback?code=..." style="width:100%;box-sizing:border-box;padding:0.75rem 1rem;background:var(--color-bg-primary,#374151);border:1px solid var(--color-border,#4b5563);border-radius:0.5rem;color:var(--color-text-primary,white);font-size:0.8rem;font-family:monospace;outline:none;" />' +
|
|
157
|
+
'<p id="oauthPasteError" style="font-size:0.75rem;color:#ef4444;margin:0.5rem 0 0;display:none;"></p>' +
|
|
158
|
+
'<div style="display:flex;gap:0.75rem;margin-top:1.25rem;">' +
|
|
159
|
+
'<button id="oauthPasteCancel" style="flex:1;padding:0.625rem;border-radius:0.5rem;border:1px solid var(--color-border,#4b5563);background:transparent;color:var(--color-text-primary,white);font-size:0.8rem;cursor:pointer;font-weight:600;">Cancel</button>' +
|
|
160
|
+
'<button id="oauthPasteSubmit" style="flex:1;padding:0.625rem;border-radius:0.5rem;border:none;background:var(--color-primary,#3b82f6);color:white;font-size:0.8rem;cursor:pointer;font-weight:600;">Complete Sign-In</button></div>' +
|
|
161
|
+
'<p style="font-size:0.7rem;color:var(--color-text-secondary,#6b7280);margin-top:1rem;text-align:center;">If the redirect page loaded successfully, this dialog will close automatically.</p></div>';
|
|
162
|
+
document.body.appendChild(overlay);
|
|
163
|
+
var dismiss = function() { cleanupOAuthPolling(); authRunning = false; removeOAuthPasteModal(); };
|
|
164
|
+
document.getElementById('oauthPasteClose').addEventListener('click', dismiss);
|
|
165
|
+
document.getElementById('oauthPasteCancel').addEventListener('click', dismiss);
|
|
166
|
+
document.getElementById('oauthPasteSubmit').addEventListener('click', submitOAuthPasteUrl);
|
|
167
|
+
document.getElementById('oauthPasteInput').addEventListener('keydown', function(e) { if (e.key === 'Enter') submitOAuthPasteUrl(); });
|
|
168
|
+
setTimeout(function() { var i = document.getElementById('oauthPasteInput'); if (i) i.focus(); }, 100);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function removeOAuthPasteModal() {
|
|
172
|
+
var el = document.getElementById('oauthPasteModal');
|
|
173
|
+
if (el) el.remove();
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function submitOAuthPasteUrl() {
|
|
177
|
+
var input = document.getElementById('oauthPasteInput');
|
|
178
|
+
var errorEl = document.getElementById('oauthPasteError');
|
|
179
|
+
var submitBtn = document.getElementById('oauthPasteSubmit');
|
|
180
|
+
if (!input) return;
|
|
181
|
+
var url = input.value.trim();
|
|
182
|
+
if (!url) {
|
|
183
|
+
if (errorEl) { errorEl.textContent = 'Please paste the URL from the redirected page.'; errorEl.style.display = 'block'; }
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
if (submitBtn) { submitBtn.disabled = true; submitBtn.textContent = 'Verifying...'; }
|
|
187
|
+
if (errorEl) errorEl.style.display = 'none';
|
|
188
|
+
|
|
189
|
+
fetch(BASE + '/api/gemini-oauth/complete', {
|
|
190
|
+
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
191
|
+
body: JSON.stringify({ url: url })
|
|
192
|
+
}).then(function(r) { return r.json(); }).then(function(data) {
|
|
193
|
+
if (data.success) {
|
|
194
|
+
cleanupOAuthPolling();
|
|
195
|
+
authRunning = false;
|
|
196
|
+
removeOAuthPasteModal();
|
|
197
|
+
refresh();
|
|
198
|
+
} else {
|
|
199
|
+
if (errorEl) { errorEl.textContent = data.error || 'Failed to complete authentication.'; errorEl.style.display = 'block'; }
|
|
200
|
+
if (submitBtn) { submitBtn.disabled = false; submitBtn.textContent = 'Complete Sign-In'; }
|
|
201
|
+
}
|
|
202
|
+
}).catch(function(e) {
|
|
203
|
+
if (errorEl) { errorEl.textContent = e.message; errorEl.style.display = 'block'; }
|
|
204
|
+
if (submitBtn) { submitBtn.disabled = false; submitBtn.textContent = 'Complete Sign-In'; }
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
|
|
133
208
|
function triggerAuth(agentId) {
|
|
134
209
|
if (authRunning) return;
|
|
135
210
|
fetch(BASE + '/api/agents/' + agentId + '/auth', {
|
|
@@ -141,6 +216,28 @@
|
|
|
141
216
|
if (term) { term.clear(); term.writeln('\x1b[36m[authenticating ' + agentId + ']\x1b[0m\r\n'); }
|
|
142
217
|
if (data.authUrl) {
|
|
143
218
|
window.open(data.authUrl, '_blank');
|
|
219
|
+
if (agentId === 'gemini') {
|
|
220
|
+
showOAuthPasteModal();
|
|
221
|
+
cleanupOAuthPolling();
|
|
222
|
+
oauthPollInterval = setInterval(function() {
|
|
223
|
+
fetch(BASE + '/api/gemini-oauth/status').then(function(r) { return r.json(); }).then(function(status) {
|
|
224
|
+
if (status.status === 'success') {
|
|
225
|
+
cleanupOAuthPolling();
|
|
226
|
+
authRunning = false;
|
|
227
|
+
removeOAuthPasteModal();
|
|
228
|
+
refresh();
|
|
229
|
+
} else if (status.status === 'error') {
|
|
230
|
+
cleanupOAuthPolling();
|
|
231
|
+
authRunning = false;
|
|
232
|
+
removeOAuthPasteModal();
|
|
233
|
+
}
|
|
234
|
+
}).catch(function() {});
|
|
235
|
+
}, 1500);
|
|
236
|
+
oauthPollTimeout = setTimeout(function() {
|
|
237
|
+
cleanupOAuthPolling();
|
|
238
|
+
if (authRunning) { authRunning = false; removeOAuthPasteModal(); }
|
|
239
|
+
}, 5 * 60 * 1000);
|
|
240
|
+
}
|
|
144
241
|
}
|
|
145
242
|
}
|
|
146
243
|
}).catch(function() {});
|
|
@@ -159,6 +256,8 @@
|
|
|
159
256
|
if (term) term.write(data.data);
|
|
160
257
|
} else if (data.type === 'script_stopped') {
|
|
161
258
|
authRunning = false;
|
|
259
|
+
removeOAuthPasteModal();
|
|
260
|
+
cleanupOAuthPolling();
|
|
162
261
|
var term = getTerminal();
|
|
163
262
|
var msg = data.error ? data.error : ('exited with code ' + (data.code || 0));
|
|
164
263
|
if (term) term.writeln('\r\n\x1b[90m[auth ' + msg + ']\x1b[0m');
|