zames_pro 2.15.5 → 2.17.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/README.md +39 -1
- package/dist/agent-loop.js +5 -1
- package/dist/browser.js +600 -17
- package/dist/commands.js +1 -0
- package/dist/config-menu.js +4 -0
- package/dist/config.js +15 -1
- package/dist/i18n.js +38 -1
- package/dist/index.js +53 -4
- package/dist/input.js +51 -9
- package/dist/net-capture.js +32 -4
- package/dist/spinner.js +39 -6
- package/package.json +1 -1
package/dist/browser.js
CHANGED
|
@@ -5,8 +5,134 @@ import os from 'os';
|
|
|
5
5
|
import fs from 'fs/promises';
|
|
6
6
|
import { execSync } from 'child_process';
|
|
7
7
|
import { theme } from './theme.js';
|
|
8
|
+
import { translate, DEFAULT_LOCALE } from './i18n.js';
|
|
9
|
+
// Muted terminal input for passwords. We do NOT use readline here: readline
|
|
10
|
+
// echoes the typed text through its own internal _writeToOutput, which cannot
|
|
11
|
+
// be reliably overridden from outside (a real bug: the login prompt swallowed
|
|
12
|
+
// the password step). Instead we read raw keystrokes from stdin and echo one
|
|
13
|
+
// `*` per typed character. In a non-TTY (pipe/redirect) we fall back to a
|
|
14
|
+
// plain line read.
|
|
15
|
+
export function askPassword(question) {
|
|
16
|
+
return new Promise((resolve) => {
|
|
17
|
+
process.stdout.write(question);
|
|
18
|
+
// Non-interactive: read a single line from stdin as-is.
|
|
19
|
+
if (!process.stdin.isTTY) {
|
|
20
|
+
void import('readline').then(({ createInterface }) => {
|
|
21
|
+
const rl = createInterface({
|
|
22
|
+
input: process.stdin,
|
|
23
|
+
output: process.stdout,
|
|
24
|
+
});
|
|
25
|
+
rl.question('', (answer) => {
|
|
26
|
+
rl.close();
|
|
27
|
+
resolve(answer);
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
const stdin = process.stdin;
|
|
33
|
+
const wasRaw = stdin.isRaw;
|
|
34
|
+
stdin.setRawMode(true);
|
|
35
|
+
stdin.resume();
|
|
36
|
+
let value = '';
|
|
37
|
+
const onData = (buf) => {
|
|
38
|
+
const s = buf.toString('utf8');
|
|
39
|
+
for (const ch of s) {
|
|
40
|
+
const code = ch.charCodeAt(0);
|
|
41
|
+
// Enter / Ctrl+J -> done.
|
|
42
|
+
if (ch === '\r' || ch === '\n') {
|
|
43
|
+
done();
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
// Ctrl+C -> abort the whole process (same as elsewhere in the CLI).
|
|
47
|
+
if (code === 3) {
|
|
48
|
+
cleanup();
|
|
49
|
+
process.stdout.write('\n');
|
|
50
|
+
process.exit(130);
|
|
51
|
+
}
|
|
52
|
+
// Ctrl+D -> finish with whatever we have.
|
|
53
|
+
if (code === 4) {
|
|
54
|
+
done();
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
// Backspace (0x7f or 0x08): remove the last char and one star.
|
|
58
|
+
if (code === 127 || code === 8) {
|
|
59
|
+
if (value.length > 0) {
|
|
60
|
+
value = value.slice(0, -1);
|
|
61
|
+
process.stdout.write('\b \b');
|
|
62
|
+
}
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
// Ignore other control characters (arrows, etc.).
|
|
66
|
+
if (code < 32)
|
|
67
|
+
continue;
|
|
68
|
+
value += ch;
|
|
69
|
+
process.stdout.write('*');
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
const cleanup = () => {
|
|
73
|
+
stdin.removeListener('data', onData);
|
|
74
|
+
try {
|
|
75
|
+
stdin.setRawMode(wasRaw || false);
|
|
76
|
+
}
|
|
77
|
+
catch { }
|
|
78
|
+
stdin.pause();
|
|
79
|
+
};
|
|
80
|
+
const done = () => {
|
|
81
|
+
cleanup();
|
|
82
|
+
process.stdout.write('\n');
|
|
83
|
+
resolve(value);
|
|
84
|
+
};
|
|
85
|
+
stdin.on('data', onData);
|
|
86
|
+
});
|
|
87
|
+
}
|
|
8
88
|
const USER_DATA_DIR = path.join(os.homedir(), '.zames', 'profile');
|
|
9
89
|
const CHAT_URL = 'https://chat.deepseek.com/';
|
|
90
|
+
// A headless Chrome advertises "HeadlessChrome/..." in its User-Agent, and
|
|
91
|
+
// DeepSeek's CDN (CloudFront/WAF) rejects that UA with a plain "403 ERROR"
|
|
92
|
+
// page before the app is even served — so a headless login looked broken while
|
|
93
|
+
// a headed one worked (headed Chrome sends "Chrome/..."). We keep the real
|
|
94
|
+
// engine version and only drop the "Headless" marker. When the operator
|
|
95
|
+
// explicitly set a UA in the config, that one wins (see _launchOnce).
|
|
96
|
+
export function sanitizeHeadlessUA(ua) {
|
|
97
|
+
return String(ua || '').replace(/HeadlessChrome/g, 'Chrome');
|
|
98
|
+
}
|
|
99
|
+
// Written after a successful sign-in so the next launch knows a session was
|
|
100
|
+
// stored in the persistent profile (used by /doctor and diagnostics).
|
|
101
|
+
const AUTH_MARKER_FILE = path.join(os.homedir(), '.zames', 'auth.json');
|
|
102
|
+
// DeepSeek sign-in form. The login page is served on the same origin and
|
|
103
|
+
// swaps in a password field; the exact classes change, so we match loosely on
|
|
104
|
+
// input types and the known placeholder/autocomplete attributes.
|
|
105
|
+
const PASSWORD_SELECTORS = [
|
|
106
|
+
'input[type="password"]',
|
|
107
|
+
'input[autocomplete="current-password"]',
|
|
108
|
+
'input[autocomplete="new-password"]',
|
|
109
|
+
];
|
|
110
|
+
const LOGIN_SELECTORS = [
|
|
111
|
+
'input[type="email"]',
|
|
112
|
+
'input[type="tel"]',
|
|
113
|
+
'input[name="email"]',
|
|
114
|
+
'input[name="phone"]',
|
|
115
|
+
'input[name="username"]',
|
|
116
|
+
'input[placeholder*="email" i]',
|
|
117
|
+
'input[placeholder*="phone" i]',
|
|
118
|
+
'input[placeholder*="телефон" i]',
|
|
119
|
+
'input[placeholder*="почт" i]',
|
|
120
|
+
'input[autocomplete="username"]',
|
|
121
|
+
'input[autocomplete="email"]',
|
|
122
|
+
];
|
|
123
|
+
const LOGIN_SUBMIT_SELECTORS = [
|
|
124
|
+
// DeepSeek's real button: a div[role=button] with these classes and a
|
|
125
|
+
// <span class="ds-button__content">Log in</span> inside.
|
|
126
|
+
'div[role="button"].ds-button--primary.ds-button--filled',
|
|
127
|
+
'div[role="button"].ds-button--primary',
|
|
128
|
+
'button[type="submit"]',
|
|
129
|
+
'button:has-text("Log in")',
|
|
130
|
+
'button:has-text("Sign in")',
|
|
131
|
+
'button:has-text("Войти")',
|
|
132
|
+
'div[role="button"]:has-text("Log in")',
|
|
133
|
+
'div[role="button"]:has-text("Sign in")',
|
|
134
|
+
'div[role="button"]:has-text("Войти")',
|
|
135
|
+
];
|
|
10
136
|
const INPUT_SELECTORS = [
|
|
11
137
|
'textarea',
|
|
12
138
|
'div[contenteditable="true"]',
|
|
@@ -125,6 +251,21 @@ export class DeepSeekBrowser {
|
|
|
125
251
|
maxRateLimitRetries;
|
|
126
252
|
maxServerBusyRetries;
|
|
127
253
|
serverBusyWaitMs;
|
|
254
|
+
// Desired state of the DeepSeek chat toggles, applied before each send.
|
|
255
|
+
// deepThinking: the "Deep thinking" toggle (reasoning; the reasoning text
|
|
256
|
+
// is never read/shown). webSearch: the "Smart search" toggle.
|
|
257
|
+
deepThinking;
|
|
258
|
+
webSearch;
|
|
259
|
+
// Credentials for automatic sign-in. The password is stored in the config
|
|
260
|
+
// file (~/.zames/config.json) and is used only when DeepSeek has logged the
|
|
261
|
+
// session out. `saveSession` keeps the profile (cookies) so a fresh launch
|
|
262
|
+
// reuses the session instead of asking the operator every time.
|
|
263
|
+
auth;
|
|
264
|
+
// Interface language for the login prompts printed by the browser itself.
|
|
265
|
+
locale;
|
|
266
|
+
// Called after a successful interactive login so index.ts can persist the
|
|
267
|
+
// credentials into the user config (the browser does not write config).
|
|
268
|
+
onAuthSave;
|
|
128
269
|
_lastSentAt;
|
|
129
270
|
_abort;
|
|
130
271
|
// The user pressed Esc/Ctrl+C — a "stop" for the WHOLE current batch of
|
|
@@ -144,7 +285,15 @@ export class DeepSeekBrowser {
|
|
|
144
285
|
// a generation really begins, not during the pre-send phase (chat open,
|
|
145
286
|
// throttle wait), which used to show a spinner with no work in flight.
|
|
146
287
|
onSendStart;
|
|
147
|
-
|
|
288
|
+
// Fired when the agent starts waiting out the send-interval pause, with the
|
|
289
|
+
// remaining seconds. Lets the UI animate the pause status instead of
|
|
290
|
+
// printing a static line (the dots used to be frozen during the pause).
|
|
291
|
+
onSendPause;
|
|
292
|
+
// User-Agent sent by the browser. In headless mode the "Headless" marker
|
|
293
|
+
// must be stripped (DeepSeek's CDN answers 403 to it); computed in the
|
|
294
|
+
// constructor, overridable by an explicit UA in the options/config.
|
|
295
|
+
userAgent;
|
|
296
|
+
constructor({ headless = false, debug = false, channel = 'chrome', answerTimeoutMs = 180000, askRetries = 3, stabilityChecks = 2, stabilityDelayMs = 400, minSendIntervalMs = 15000, rateLimitWaitMs = 300000, maxRateLimitRetries = 6, maxServerBusyRetries = 5, serverBusyWaitMs = 3000, deepThinking = false, webSearch = true, auth, locale, onAuthSave, userAgent, } = {}) {
|
|
148
297
|
this.headless = headless;
|
|
149
298
|
this.debug = debug;
|
|
150
299
|
this.channel = channel;
|
|
@@ -157,6 +306,15 @@ export class DeepSeekBrowser {
|
|
|
157
306
|
this.maxRateLimitRetries = maxRateLimitRetries;
|
|
158
307
|
this.maxServerBusyRetries = maxServerBusyRetries;
|
|
159
308
|
this.serverBusyWaitMs = serverBusyWaitMs;
|
|
309
|
+
this.deepThinking = deepThinking;
|
|
310
|
+
this.webSearch = webSearch;
|
|
311
|
+
this.auth = {
|
|
312
|
+
username: (auth?.username || '').trim(),
|
|
313
|
+
password: auth?.password || '',
|
|
314
|
+
saveSession: auth?.saveSession !== false,
|
|
315
|
+
};
|
|
316
|
+
this.locale = locale || DEFAULT_LOCALE;
|
|
317
|
+
this.onAuthSave = onAuthSave || null;
|
|
160
318
|
this._lastSentAt = 0;
|
|
161
319
|
this._abort = false;
|
|
162
320
|
this._stopped = false;
|
|
@@ -167,6 +325,16 @@ export class DeepSeekBrowser {
|
|
|
167
325
|
this._netSniffLimit = 5;
|
|
168
326
|
this._netHookInstalled = false;
|
|
169
327
|
this.onSendStart = null;
|
|
328
|
+
this.onSendPause = null;
|
|
329
|
+
// An explicit UA (config/options) is respected as-is. Otherwise it stays
|
|
330
|
+
// empty here and is derived from the real engine UA after launch — see
|
|
331
|
+
// _fixHeadlessUserAgent (a headless "HeadlessChrome/..." UA gets 403 from
|
|
332
|
+
// DeepSeek's CDN).
|
|
333
|
+
this.userAgent = userAgent ? sanitizeHeadlessUA(userAgent) : '';
|
|
334
|
+
}
|
|
335
|
+
/** Translation bound to this browser's locale. */
|
|
336
|
+
_t(key, params) {
|
|
337
|
+
return translate(this.locale)(key, params);
|
|
170
338
|
}
|
|
171
339
|
async launch() {
|
|
172
340
|
await killStaleChrome();
|
|
@@ -176,6 +344,35 @@ export class DeepSeekBrowser {
|
|
|
176
344
|
await cleanSingletonFiles();
|
|
177
345
|
}
|
|
178
346
|
await this._launchOnce();
|
|
347
|
+
await this._fixHeadlessUserAgent();
|
|
348
|
+
}
|
|
349
|
+
/**
|
|
350
|
+
* A headless Chromium advertises "HeadlessChrome/<v>" in its User-Agent, and
|
|
351
|
+
* DeepSeek's CDN (CloudFront/WAF) answers that UA with a plain "403 ERROR"
|
|
352
|
+
* page before the app is served — so sign-in worked with a visible window and
|
|
353
|
+
* silently failed headless. Read the UA the engine really reports and, when
|
|
354
|
+
* it carries the "Headless" marker, relaunch once with a sanitized
|
|
355
|
+
* `--user-agent` (the marker removed, the real version kept). A UA passed
|
|
356
|
+
* explicitly in the options/config is never touched.
|
|
357
|
+
*/
|
|
358
|
+
async _fixHeadlessUserAgent() {
|
|
359
|
+
if (!this.headless)
|
|
360
|
+
return;
|
|
361
|
+
try {
|
|
362
|
+
const ua = await this.page.evaluate(() => navigator.userAgent);
|
|
363
|
+
const fixed = sanitizeHeadlessUA(ua);
|
|
364
|
+
if (fixed === ua)
|
|
365
|
+
return;
|
|
366
|
+
this.userAgent = fixed;
|
|
367
|
+
if (this.debug)
|
|
368
|
+
console.error('profile: headless UA → перезапуск с обычным Chrome UA');
|
|
369
|
+
await this.context.close().catch(() => { });
|
|
370
|
+
await this._launchOnce();
|
|
371
|
+
}
|
|
372
|
+
catch (e) {
|
|
373
|
+
if (this.debug)
|
|
374
|
+
console.error('profile: UA-фикс не удался:', e.message);
|
|
375
|
+
}
|
|
179
376
|
}
|
|
180
377
|
async _launchOnce() {
|
|
181
378
|
const options = {
|
|
@@ -188,6 +385,8 @@ export class DeepSeekBrowser {
|
|
|
188
385
|
};
|
|
189
386
|
if (this.channel)
|
|
190
387
|
options.channel = this.channel;
|
|
388
|
+
if (this.userAgent)
|
|
389
|
+
options.userAgent = this.userAgent;
|
|
191
390
|
try {
|
|
192
391
|
this.context = await chromium.launchPersistentContext(USER_DATA_DIR, options);
|
|
193
392
|
}
|
|
@@ -266,11 +465,47 @@ export class DeepSeekBrowser {
|
|
|
266
465
|
await this.launch();
|
|
267
466
|
}
|
|
268
467
|
async waitForLogin() {
|
|
269
|
-
|
|
270
|
-
if (
|
|
468
|
+
// 1) Already signed in (session cookie in the persistent profile)?
|
|
469
|
+
if (await this.isLoggedIn()) {
|
|
470
|
+
if (this.debug)
|
|
471
|
+
console.error('auth: сессия уже активна');
|
|
472
|
+
return;
|
|
473
|
+
}
|
|
474
|
+
// 2) Try to sign in automatically with the saved credentials. The
|
|
475
|
+
// password lives in the config (~/.zames/config.json) and is only used
|
|
476
|
+
// when the session is logged out — i.e. exactly the "re-login without
|
|
477
|
+
// asking" case. If the login form is absent (e.g. DeepSeek uses a
|
|
478
|
+
// captcha / another provider), this fails and we fall through.
|
|
479
|
+
if (this.auth.username && this.auth.password) {
|
|
480
|
+
console.log(theme.system(this._t('auth.auto_login')));
|
|
481
|
+
const ok = await this._autoLogin().catch((e) => {
|
|
482
|
+
console.log(theme.warn(this._t('auth.auto_login_failed', { v: e.message })));
|
|
483
|
+
return false;
|
|
484
|
+
});
|
|
485
|
+
if (ok) {
|
|
486
|
+
console.log(theme.assistant(this._t('auth.auto_login_ok')));
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
// 3) Ask the operator for credentials in the terminal, try them, and
|
|
491
|
+
// only then fall back to the manual hint. Interactive only: in a
|
|
492
|
+
// pipe/redirect (or headless without credentials) we print a hint.
|
|
493
|
+
const asked = await this._promptAndLogin().catch((e) => {
|
|
494
|
+
if (this.debug)
|
|
495
|
+
console.error('auth: prompt error:', e.message);
|
|
496
|
+
return false;
|
|
497
|
+
});
|
|
498
|
+
if (asked)
|
|
271
499
|
return;
|
|
272
|
-
|
|
273
|
-
console.log('
|
|
500
|
+
// 4) Last resort: manual sign-in in the browser window.
|
|
501
|
+
console.log('\n' + theme.warn(this._t('auth.need_login')));
|
|
502
|
+
if (this.headless) {
|
|
503
|
+
console.log(' ' + this._t('auth.manual_hint_headless'));
|
|
504
|
+
}
|
|
505
|
+
else {
|
|
506
|
+
console.log(' ' + this._t('auth.manual_hint'));
|
|
507
|
+
}
|
|
508
|
+
console.log('\n');
|
|
274
509
|
const readline = await import('readline');
|
|
275
510
|
const rl = readline.createInterface({
|
|
276
511
|
input: process.stdin,
|
|
@@ -282,20 +517,286 @@ export class DeepSeekBrowser {
|
|
|
282
517
|
resolve();
|
|
283
518
|
});
|
|
284
519
|
});
|
|
520
|
+
if (await this.isLoggedIn()) {
|
|
521
|
+
await this._persistSessionIfNeeded();
|
|
522
|
+
console.log(theme.assistant(this._t('auth.auto_login_ok')));
|
|
523
|
+
}
|
|
285
524
|
}
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
525
|
+
/**
|
|
526
|
+
* Try to sign in with this.auth. Returns true when the login form was
|
|
527
|
+
* filled and the session became active. Never throws for a missing form —
|
|
528
|
+
* the caller decides whether to fall back.
|
|
529
|
+
*/
|
|
530
|
+
async _autoLogin() {
|
|
531
|
+
const filled = await this._fillLoginForm(this.auth.username, this.auth.password);
|
|
532
|
+
if (!filled)
|
|
533
|
+
return false;
|
|
534
|
+
const ok = await this._waitLoggedIn(20_000);
|
|
535
|
+
if (ok)
|
|
536
|
+
await this._persistSessionIfNeeded();
|
|
537
|
+
return ok;
|
|
538
|
+
}
|
|
539
|
+
/**
|
|
540
|
+
* Ask the operator for login/password in the terminal (only in a TTY) and
|
|
541
|
+
* try them. An empty password falls back to the saved one. Returns true
|
|
542
|
+
* when the sign-in succeeded.
|
|
543
|
+
*/
|
|
544
|
+
async _promptAndLogin() {
|
|
545
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY)
|
|
546
|
+
return false;
|
|
547
|
+
const hasSaved = !!(this.auth.username && this.auth.password);
|
|
548
|
+
// Prompt only when we have a login field to fill.
|
|
549
|
+
if (!(await this._loginFormVisible())) {
|
|
550
|
+
if (this.debug)
|
|
551
|
+
console.error('auth: форма входа не найдена');
|
|
552
|
+
return false;
|
|
553
|
+
}
|
|
554
|
+
console.log('\n' + theme.warn(this._t('auth.need_login')));
|
|
555
|
+
// Username via a normal readline question (echoed).
|
|
556
|
+
let user = this.auth.username;
|
|
557
|
+
if (!user) {
|
|
558
|
+
const readline = await import('readline');
|
|
559
|
+
const rl = readline.createInterface({
|
|
560
|
+
input: process.stdin,
|
|
561
|
+
output: process.stdout,
|
|
562
|
+
terminal: true,
|
|
563
|
+
});
|
|
564
|
+
user = (await new Promise((resolve) => rl.question(this._t('auth.prompt_login'), (a) => resolve(a)))).trim();
|
|
565
|
+
rl.close();
|
|
566
|
+
}
|
|
567
|
+
// Password via raw keystroke reading, echoed as `*` (readline would echo
|
|
568
|
+
// it in clear or swallow the prompt — see askPassword).
|
|
569
|
+
const passPromptKey = hasSaved
|
|
570
|
+
? 'auth.prompt_password_saved'
|
|
571
|
+
: 'auth.prompt_password';
|
|
572
|
+
const pass = await askPassword(this._t(passPromptKey));
|
|
573
|
+
const password = pass || this.auth.password;
|
|
574
|
+
if (!user || !password)
|
|
575
|
+
return false;
|
|
576
|
+
console.log(theme.system(this._t('auth.auto_login')));
|
|
577
|
+
const filled = await this._fillLoginForm(user, password);
|
|
578
|
+
if (!filled) {
|
|
579
|
+
console.log(theme.warn(this._t('auth.form_not_found')));
|
|
580
|
+
return false;
|
|
581
|
+
}
|
|
582
|
+
const ok = await this._waitLoggedIn(20_000);
|
|
583
|
+
if (ok) {
|
|
584
|
+
// Remember the credentials so the next launch can re-login silently.
|
|
585
|
+
this.auth.username = user;
|
|
586
|
+
this.auth.password = password;
|
|
587
|
+
await this._persistSessionIfNeeded();
|
|
588
|
+
this.onAuthSave?.(user, password);
|
|
589
|
+
console.log(theme.assistant(this._t('auth.auto_login_ok')));
|
|
590
|
+
}
|
|
591
|
+
else {
|
|
592
|
+
// Detect a credentials error shown by DeepSeek, otherwise report a
|
|
593
|
+
// generic failure with the page hint so the operator can react.
|
|
594
|
+
const reason = await this._readLoginError();
|
|
595
|
+
if (reason) {
|
|
596
|
+
console.log(theme.error(this._t('auth.login_rejected', { v: reason })));
|
|
597
|
+
}
|
|
598
|
+
else {
|
|
599
|
+
console.log(theme.warn(this._t('auth.auto_login_failed', { v: this._t('auth.no_reason') })));
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
return ok;
|
|
603
|
+
}
|
|
604
|
+
/**
|
|
605
|
+
* Read a visible login/credentials error message from the page, if any.
|
|
606
|
+
* Best-effort: returns '' when nothing recognizable is found.
|
|
607
|
+
*/
|
|
608
|
+
async _readLoginError() {
|
|
609
|
+
try {
|
|
610
|
+
const txt = await this.page.evaluate(() => {
|
|
611
|
+
const sels = [
|
|
612
|
+
'[role="alert"]',
|
|
613
|
+
'[class*="error" i]',
|
|
614
|
+
'[class*="toast" i]',
|
|
615
|
+
'[class*="notification" i]',
|
|
616
|
+
];
|
|
617
|
+
let out = '';
|
|
618
|
+
for (const s of sels) {
|
|
619
|
+
for (const el of Array.from(document.querySelectorAll(s))) {
|
|
620
|
+
const e = el;
|
|
621
|
+
const st = getComputedStyle(e);
|
|
622
|
+
if (st.display === 'none' || st.visibility === 'hidden')
|
|
623
|
+
continue;
|
|
624
|
+
const t = (e.innerText || '').trim();
|
|
625
|
+
if (t)
|
|
626
|
+
out += ' ' + t;
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
return out.replace(/\s+/g, ' ').trim();
|
|
630
|
+
});
|
|
631
|
+
return txt.slice(0, 200);
|
|
632
|
+
}
|
|
633
|
+
catch {
|
|
634
|
+
return '';
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
/** Is a login form (password field) present on the page? */
|
|
638
|
+
async _loginFormVisible() {
|
|
639
|
+
return await this.page
|
|
640
|
+
.locator(PASSWORD_SELECTORS.join(', '))
|
|
641
|
+
.first()
|
|
642
|
+
.isVisible({ timeout: 2000 })
|
|
643
|
+
.catch(() => false);
|
|
644
|
+
}
|
|
645
|
+
/**
|
|
646
|
+
* Fill the DeepSeek sign-in form with the given credentials and submit it.
|
|
647
|
+
* Returns false if the form (or the submit button) is not found.
|
|
648
|
+
*/
|
|
649
|
+
async _fillLoginForm(username, password) {
|
|
650
|
+
const pwd = await this._findVisible(PASSWORD_SELECTORS, 4000);
|
|
651
|
+
if (!pwd)
|
|
652
|
+
return false;
|
|
653
|
+
// The email/phone field is the text input above the password field. Try
|
|
654
|
+
// the known selectors first, then fall back to the nearest text input.
|
|
655
|
+
let userInput = await this._findVisible(LOGIN_SELECTORS, 2000);
|
|
656
|
+
if (!userInput) {
|
|
657
|
+
// Find the index of the password input and use the closest preceding
|
|
658
|
+
// text/email/tel input as the login field.
|
|
659
|
+
const idx = await this.page.evaluate((pwdSel) => {
|
|
660
|
+
const p = document.querySelector(pwdSel);
|
|
661
|
+
if (!p)
|
|
662
|
+
return -1;
|
|
663
|
+
const inputs = Array.from(document.querySelectorAll('input'));
|
|
664
|
+
const pi = inputs.indexOf(p);
|
|
665
|
+
for (let i = pi - 1; i >= 0; i--) {
|
|
666
|
+
const t = (inputs[i].type || 'text').toLowerCase();
|
|
667
|
+
if (t === 'text' || t === 'email' || t === 'tel')
|
|
668
|
+
return i;
|
|
669
|
+
}
|
|
670
|
+
return -1;
|
|
671
|
+
}, PASSWORD_SELECTORS[0]);
|
|
672
|
+
if (idx >= 0)
|
|
673
|
+
userInput = this.page.locator('input').nth(idx);
|
|
674
|
+
}
|
|
675
|
+
if (!userInput)
|
|
676
|
+
return false;
|
|
677
|
+
await this._setInputText(userInput, username);
|
|
678
|
+
await this._setInputText(pwd, password);
|
|
679
|
+
await this.page.waitForTimeout(150);
|
|
680
|
+
// Submit. DeepSeek's login form is a React form; the button text is
|
|
681
|
+
// localized and classes change, so we try, in order:
|
|
682
|
+
// 1. known submit selectors;
|
|
683
|
+
// 2. a submit/primary button inside the same <form> as the password;
|
|
684
|
+
// 3. pressing Enter in the password field;
|
|
685
|
+
// 4. clicking the password field's closest button sibling.
|
|
686
|
+
// After each attempt we give the login a short window and stop as soon as
|
|
687
|
+
// it succeeds, so a stray click does not fire on an already-logged-in page.
|
|
688
|
+
const tryLogin = async () => {
|
|
689
|
+
if (await this._waitLoggedIn(3000))
|
|
293
690
|
return true;
|
|
691
|
+
return false;
|
|
692
|
+
};
|
|
693
|
+
// Precise first attempt: DeepSeek's button is
|
|
694
|
+
// <div role="button" class="ds-button ds-button--primary ...">
|
|
695
|
+
// <span class="ds-button__content">Log in</span></div>
|
|
696
|
+
// Click exactly the one whose visible label is a login word, so we never
|
|
697
|
+
// hit "Log in with Google" or another primary button by mistake.
|
|
698
|
+
const precise = this.page
|
|
699
|
+
.locator('div[role="button"].ds-button--primary, button')
|
|
700
|
+
.filter({ hasText: /^\s*(log ?in|sign ?in|войти)\s*$/i });
|
|
701
|
+
const pc = await precise.count().catch(() => 0);
|
|
702
|
+
if (pc > 0) {
|
|
703
|
+
try {
|
|
704
|
+
await precise.last().click({ timeout: 2000 });
|
|
705
|
+
if (await tryLogin())
|
|
706
|
+
return true;
|
|
294
707
|
}
|
|
295
708
|
catch { }
|
|
296
709
|
}
|
|
710
|
+
for (const sel of LOGIN_SUBMIT_SELECTORS) {
|
|
711
|
+
const btn = this.page.locator(sel).last();
|
|
712
|
+
try {
|
|
713
|
+
if ((await btn.count()) === 0)
|
|
714
|
+
continue;
|
|
715
|
+
if (!(await btn.isVisible().catch(() => false)))
|
|
716
|
+
continue;
|
|
717
|
+
await btn.click({ timeout: 2000 });
|
|
718
|
+
if (await tryLogin())
|
|
719
|
+
return true;
|
|
720
|
+
}
|
|
721
|
+
catch { }
|
|
722
|
+
}
|
|
723
|
+
// Button inside the same form as the password field.
|
|
724
|
+
const formClicked = await this.page
|
|
725
|
+
.evaluate((pwdSel) => {
|
|
726
|
+
const p = document.querySelector(pwdSel);
|
|
727
|
+
if (!p)
|
|
728
|
+
return false;
|
|
729
|
+
const form = p.closest('form');
|
|
730
|
+
const scope = form || document;
|
|
731
|
+
const btns = Array.from(scope.querySelectorAll('button[type="submit"], button, div[role="button"]'));
|
|
732
|
+
for (const b of btns) {
|
|
733
|
+
const txt = (b.textContent || '').trim().toLowerCase();
|
|
734
|
+
const aria = (b.getAttribute('aria-label') || '').toLowerCase();
|
|
735
|
+
if (/log ?in|sign ?in|войти|continue|продолж/.test(txt + ' ' + aria)) {
|
|
736
|
+
b.click();
|
|
737
|
+
return true;
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
// Fallback: the primary-looking button in the scope.
|
|
741
|
+
const primary = btns.find((b) => {
|
|
742
|
+
const cls = (b.className || '').toString();
|
|
743
|
+
return /primary|submit|ds-button--primary/i.test(cls);
|
|
744
|
+
});
|
|
745
|
+
if (primary) {
|
|
746
|
+
primary.click();
|
|
747
|
+
return true;
|
|
748
|
+
}
|
|
749
|
+
return false;
|
|
750
|
+
}, PASSWORD_SELECTORS[0])
|
|
751
|
+
.catch(() => false);
|
|
752
|
+
if (formClicked && (await tryLogin()))
|
|
753
|
+
return true;
|
|
754
|
+
// Enter in the password field.
|
|
755
|
+
await pwd.press('Enter').catch(() => { });
|
|
756
|
+
return true;
|
|
757
|
+
}
|
|
758
|
+
/** Poll isLoggedIn until it succeeds or the deadline passes. */
|
|
759
|
+
async _waitLoggedIn(timeoutMs) {
|
|
760
|
+
const deadline = Date.now() + timeoutMs;
|
|
761
|
+
while (Date.now() < deadline) {
|
|
762
|
+
if (await this.isLoggedIn().catch(() => false))
|
|
763
|
+
return true;
|
|
764
|
+
await this.page.waitForTimeout(500);
|
|
765
|
+
}
|
|
297
766
|
return false;
|
|
298
767
|
}
|
|
768
|
+
/**
|
|
769
|
+
* Persist the authenticated session. The persistent profile already keeps
|
|
770
|
+
* cookies on disk; `saveSession` (config) is kept as an explicit toggle for
|
|
771
|
+
* the operator. We additionally save a small marker so /doctor and the next
|
|
772
|
+
* launch can tell that a session exists.
|
|
773
|
+
*/
|
|
774
|
+
async _persistSessionIfNeeded() {
|
|
775
|
+
if (!this.auth.saveSession)
|
|
776
|
+
return;
|
|
777
|
+
if (!(await this.isLoggedIn().catch(() => false)))
|
|
778
|
+
return;
|
|
779
|
+
try {
|
|
780
|
+
await fs.mkdir(path.join(os.homedir(), '.zames'), { recursive: true });
|
|
781
|
+
await fs.writeFile(AUTH_MARKER_FILE, JSON.stringify({ at: new Date().toISOString() }, null, 2), 'utf-8');
|
|
782
|
+
}
|
|
783
|
+
catch { }
|
|
784
|
+
}
|
|
785
|
+
async isLoggedIn() {
|
|
786
|
+
// Single combined query with a short timeout: the old loop waited up to
|
|
787
|
+
// 3s PER selector (9s total), and _waitLoggedIn calls this repeatedly,
|
|
788
|
+
// which made login feel like it "hangs".
|
|
789
|
+
try {
|
|
790
|
+
await this.page
|
|
791
|
+
.locator(INPUT_SELECTORS.join(', '))
|
|
792
|
+
.first()
|
|
793
|
+
.waitFor({ state: 'visible', timeout: 2000 });
|
|
794
|
+
return true;
|
|
795
|
+
}
|
|
796
|
+
catch {
|
|
797
|
+
return false;
|
|
798
|
+
}
|
|
799
|
+
}
|
|
299
800
|
async newChat() {
|
|
300
801
|
const newChatBtn = this.page
|
|
301
802
|
.locator('button, a')
|
|
@@ -329,11 +830,34 @@ export class DeepSeekBrowser {
|
|
|
329
830
|
return this._netCapture;
|
|
330
831
|
}
|
|
331
832
|
return await this.page.evaluate((sels) => {
|
|
833
|
+
// DeepSeek stores the model's reasoning in .ds-think-content blocks.
|
|
834
|
+
// They are NOT the answer and must never be picked up as the answer
|
|
835
|
+
// (otherwise the terminal would show the long reasoning). We also
|
|
836
|
+
// prefer the first (most specific) selector with a hit.
|
|
837
|
+
const inThink = (e) => {
|
|
838
|
+
let n = e;
|
|
839
|
+
while (n) {
|
|
840
|
+
const cls = (n.className || '').toString();
|
|
841
|
+
if (/ds-think-content|thinking-content/i.test(cls))
|
|
842
|
+
return true;
|
|
843
|
+
n = n.parentElement;
|
|
844
|
+
}
|
|
845
|
+
return false;
|
|
846
|
+
};
|
|
332
847
|
let el = null;
|
|
333
848
|
for (const s of sels) {
|
|
334
849
|
const list = document.querySelectorAll(s);
|
|
335
|
-
if (list.length)
|
|
336
|
-
|
|
850
|
+
if (!list.length)
|
|
851
|
+
continue;
|
|
852
|
+
for (let i = list.length - 1; i >= 0; i--) {
|
|
853
|
+
const cand = list[i];
|
|
854
|
+
if (inThink(cand))
|
|
855
|
+
continue;
|
|
856
|
+
el = cand;
|
|
857
|
+
break;
|
|
858
|
+
}
|
|
859
|
+
if (el)
|
|
860
|
+
break;
|
|
337
861
|
}
|
|
338
862
|
if (!el)
|
|
339
863
|
return '';
|
|
@@ -411,6 +935,34 @@ export class DeepSeekBrowser {
|
|
|
411
935
|
async _isGenerating() {
|
|
412
936
|
return await this._stopButtonVisible();
|
|
413
937
|
}
|
|
938
|
+
// DeepSeek exposes two toggle buttons above the input: "Deep thinking"
|
|
939
|
+
// (reasoning) and "Smart search" (web search). Their labels are localized,
|
|
940
|
+
// so we match by a loose regex on the visible text and read the state from
|
|
941
|
+
// aria-pressed. We click ONLY when the state differs, so a send does not
|
|
942
|
+
// flip a toggle the operator set by hand.
|
|
943
|
+
async _setToggle(labelRe, want) {
|
|
944
|
+
try {
|
|
945
|
+
const btns = this.page.locator('.ds-toggle-button');
|
|
946
|
+
const count = await btns.count().catch(() => 0);
|
|
947
|
+
for (let i = 0; i < count; i++) {
|
|
948
|
+
const b = btns.nth(i);
|
|
949
|
+
const txt = ((await b.textContent().catch(() => '')) || '').trim();
|
|
950
|
+
if (!labelRe.test(txt))
|
|
951
|
+
continue;
|
|
952
|
+
const pressed = (await b.getAttribute('aria-pressed').catch(() => null)) === 'true';
|
|
953
|
+
if (pressed !== want) {
|
|
954
|
+
await b.click({ timeout: 2000 }).catch(() => { });
|
|
955
|
+
await this.page.waitForTimeout(150);
|
|
956
|
+
}
|
|
957
|
+
return;
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
catch { }
|
|
961
|
+
}
|
|
962
|
+
async _applyToggles() {
|
|
963
|
+
await this._setToggle(/глубок|deep\s*think/i, this.deepThinking);
|
|
964
|
+
await this._setToggle(/поиск|search/i, this.webSearch);
|
|
965
|
+
}
|
|
414
966
|
async stopGeneration() {
|
|
415
967
|
this._abort = true;
|
|
416
968
|
this._stopped = true;
|
|
@@ -632,20 +1184,47 @@ export class DeepSeekBrowser {
|
|
|
632
1184
|
const gap = this.minSendIntervalMs - (Date.now() - this._lastSentAt);
|
|
633
1185
|
if (gap <= 0)
|
|
634
1186
|
return;
|
|
635
|
-
|
|
636
|
-
|
|
1187
|
+
// Report the pause to the UI as an ANIMATED status (with the remaining
|
|
1188
|
+
// seconds) instead of the old static console line — the dots used to be
|
|
1189
|
+
// frozen here, which looked like the spinner had hung. The seconds are
|
|
1190
|
+
// refreshed once per second, so the status visibly counts down.
|
|
1191
|
+
const report = (leftMs) => {
|
|
1192
|
+
const secs = Math.max(0, Math.ceil(leftMs / 1000));
|
|
1193
|
+
if (this.onSendPause) {
|
|
1194
|
+
try {
|
|
1195
|
+
this.onSendPause(secs);
|
|
1196
|
+
}
|
|
1197
|
+
catch { }
|
|
1198
|
+
}
|
|
1199
|
+
else {
|
|
1200
|
+
console.error(theme.warn(`⏳ send pause ${secs}s`));
|
|
1201
|
+
}
|
|
1202
|
+
};
|
|
1203
|
+
report(gap);
|
|
1204
|
+
await this._sleepInterruptible(gap, report);
|
|
637
1205
|
}
|
|
638
1206
|
// Sleep in small slices so Esc/Ctrl+C can cancel the wait promptly.
|
|
639
1207
|
// Returns true if the sleep was cut short by _abort.
|
|
640
|
-
|
|
1208
|
+
//
|
|
1209
|
+
// onTick (optional) is called about once per second with the remaining time,
|
|
1210
|
+
// so the UI can show a live countdown during the pause.
|
|
1211
|
+
async _sleepInterruptible(ms, onTick) {
|
|
641
1212
|
const step = 100;
|
|
642
1213
|
let left = ms;
|
|
1214
|
+
let sinceTick = 0;
|
|
643
1215
|
while (left > 0) {
|
|
644
1216
|
if (this._abort)
|
|
645
1217
|
return true;
|
|
646
1218
|
const chunk = Math.min(step, left);
|
|
647
1219
|
await this.page.waitForTimeout(chunk);
|
|
648
1220
|
left -= chunk;
|
|
1221
|
+
if (onTick) {
|
|
1222
|
+
sinceTick += chunk;
|
|
1223
|
+
if (sinceTick >= 1000) {
|
|
1224
|
+
sinceTick = 0;
|
|
1225
|
+
onTick(left);
|
|
1226
|
+
}
|
|
1227
|
+
}
|
|
649
1228
|
}
|
|
650
1229
|
return this._abort;
|
|
651
1230
|
}
|
|
@@ -691,6 +1270,10 @@ export class DeepSeekBrowser {
|
|
|
691
1270
|
}
|
|
692
1271
|
this._netCapture = '';
|
|
693
1272
|
this._netCaptureAt = 0;
|
|
1273
|
+
// Align the DeepSeek chat toggles (deep thinking / web search) with the
|
|
1274
|
+
// configured state BEFORE typing. Doing it here (after the send-pause) it
|
|
1275
|
+
// does not flip toggles for a generation that is not going to happen.
|
|
1276
|
+
await this._applyToggles();
|
|
694
1277
|
// Attach files/images FIRST (before the text): the DeepSeek upload widget
|
|
695
1278
|
// shows them above the input, and only then the message can be sent.
|
|
696
1279
|
if (attachments.length) {
|