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/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
- 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, } = {}) {
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
- const loggedIn = await this.isLoggedIn();
270
- if (loggedIn)
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
- console.log('\n🔐 Залогиньтесь в DeepSeek в открытом браузере.');
273
- console.log(' После входа нажмите Enter в терминале...\n');
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
- async isLoggedIn() {
287
- for (const sel of INPUT_SELECTORS) {
288
- try {
289
- await this.page.locator(sel).first().waitFor({
290
- state: 'visible',
291
- timeout: 3000,
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
- el = list[list.length - 1];
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
- console.error(theme.warn(`⏳ пауза ${Math.ceil(gap / 1000)}с перед отправкой`));
636
- await this._sleepInterruptible(gap);
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
- async _sleepInterruptible(ms) {
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) {