zames_pro 2.16.0 → 2.18.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"]',
@@ -130,6 +256,16 @@ export class DeepSeekBrowser {
130
256
  // is never read/shown). webSearch: the "Smart search" toggle.
131
257
  deepThinking;
132
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;
133
269
  _lastSentAt;
134
270
  _abort;
135
271
  // The user pressed Esc/Ctrl+C — a "stop" for the WHOLE current batch of
@@ -141,6 +277,13 @@ export class DeepSeekBrowser {
141
277
  _netCapture;
142
278
  _netCaptureAt;
143
279
  _netChatId;
280
+ // Authorization / PoW headers sniffed from DeepSeek's own API requests, so
281
+ // fetchChatMessages can replay them (a bare fetch does not get them).
282
+ _apiAuth;
283
+ _apiPow;
284
+ // Last error from fetchChatMessages ('' on success). Surfaced under --debug
285
+ // so a missing restored history can be diagnosed.
286
+ _lastHistoryError;
144
287
  _netSniff;
145
288
  _netSniffLimit;
146
289
  _netHookInstalled;
@@ -149,7 +292,15 @@ export class DeepSeekBrowser {
149
292
  // a generation really begins, not during the pre-send phase (chat open,
150
293
  // throttle wait), which used to show a spinner with no work in flight.
151
294
  onSendStart;
152
- 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, } = {}) {
295
+ // Fired when the agent starts waiting out the send-interval pause, with the
296
+ // remaining seconds. Lets the UI animate the pause status instead of
297
+ // printing a static line (the dots used to be frozen during the pause).
298
+ onSendPause;
299
+ // User-Agent sent by the browser. In headless mode the "Headless" marker
300
+ // must be stripped (DeepSeek's CDN answers 403 to it); computed in the
301
+ // constructor, overridable by an explicit UA in the options/config.
302
+ userAgent;
303
+ 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, } = {}) {
153
304
  this.headless = headless;
154
305
  this.debug = debug;
155
306
  this.channel = channel;
@@ -164,16 +315,36 @@ export class DeepSeekBrowser {
164
315
  this.serverBusyWaitMs = serverBusyWaitMs;
165
316
  this.deepThinking = deepThinking;
166
317
  this.webSearch = webSearch;
318
+ this.auth = {
319
+ username: (auth?.username || '').trim(),
320
+ password: auth?.password || '',
321
+ saveSession: auth?.saveSession !== false,
322
+ };
323
+ this.locale = locale || DEFAULT_LOCALE;
324
+ this.onAuthSave = onAuthSave || null;
167
325
  this._lastSentAt = 0;
168
326
  this._abort = false;
169
327
  this._stopped = false;
170
328
  this._netCapture = '';
171
329
  this._netCaptureAt = 0;
172
330
  this._netChatId = null;
331
+ this._apiAuth = '';
332
+ this._apiPow = '';
333
+ this._lastHistoryError = '';
173
334
  this._netSniff = [];
174
335
  this._netSniffLimit = 5;
175
336
  this._netHookInstalled = false;
176
337
  this.onSendStart = null;
338
+ this.onSendPause = null;
339
+ // An explicit UA (config/options) is respected as-is. Otherwise it stays
340
+ // empty here and is derived from the real engine UA after launch — see
341
+ // _fixHeadlessUserAgent (a headless "HeadlessChrome/..." UA gets 403 from
342
+ // DeepSeek's CDN).
343
+ this.userAgent = userAgent ? sanitizeHeadlessUA(userAgent) : '';
344
+ }
345
+ /** Translation bound to this browser's locale. */
346
+ _t(key, params) {
347
+ return translate(this.locale)(key, params);
177
348
  }
178
349
  async launch() {
179
350
  await killStaleChrome();
@@ -183,6 +354,35 @@ export class DeepSeekBrowser {
183
354
  await cleanSingletonFiles();
184
355
  }
185
356
  await this._launchOnce();
357
+ await this._fixHeadlessUserAgent();
358
+ }
359
+ /**
360
+ * A headless Chromium advertises "HeadlessChrome/<v>" in its User-Agent, and
361
+ * DeepSeek's CDN (CloudFront/WAF) answers that UA with a plain "403 ERROR"
362
+ * page before the app is served — so sign-in worked with a visible window and
363
+ * silently failed headless. Read the UA the engine really reports and, when
364
+ * it carries the "Headless" marker, relaunch once with a sanitized
365
+ * `--user-agent` (the marker removed, the real version kept). A UA passed
366
+ * explicitly in the options/config is never touched.
367
+ */
368
+ async _fixHeadlessUserAgent() {
369
+ if (!this.headless)
370
+ return;
371
+ try {
372
+ const ua = await this.page.evaluate(() => navigator.userAgent);
373
+ const fixed = sanitizeHeadlessUA(ua);
374
+ if (fixed === ua)
375
+ return;
376
+ this.userAgent = fixed;
377
+ if (this.debug)
378
+ console.error('profile: headless UA → перезапуск с обычным Chrome UA');
379
+ await this.context.close().catch(() => { });
380
+ await this._launchOnce();
381
+ }
382
+ catch (e) {
383
+ if (this.debug)
384
+ console.error('profile: UA-фикс не удался:', e.message);
385
+ }
186
386
  }
187
387
  async _launchOnce() {
188
388
  const options = {
@@ -195,6 +395,8 @@ export class DeepSeekBrowser {
195
395
  };
196
396
  if (this.channel)
197
397
  options.channel = this.channel;
398
+ if (this.userAgent)
399
+ options.userAgent = this.userAgent;
198
400
  try {
199
401
  this.context = await chromium.launchPersistentContext(USER_DATA_DIR, options);
200
402
  }
@@ -232,6 +434,23 @@ export class DeepSeekBrowser {
232
434
  pg.on('response', (resp) => {
233
435
  void this._onResponse(resp).catch(() => { });
234
436
  });
437
+ // DeepSeek's own API calls carry an Authorization header (the app reads a
438
+ // token from its storage and sets it explicitly). A bare fetch from our
439
+ // code does NOT get it, so history_messages would answer 401/empty.
440
+ // Sniff the header off the real requests and replay it later.
441
+ pg.on('request', (req) => {
442
+ try {
443
+ const url = req.url();
444
+ if (!/deepseek\.com\/api\//i.test(url))
445
+ return;
446
+ const h = req.headers();
447
+ if (h['authorization'])
448
+ this._apiAuth = h['authorization'];
449
+ if (h['x-ds-pow-response'])
450
+ this._apiPow = h['x-ds-pow-response'];
451
+ }
452
+ catch { }
453
+ });
235
454
  }
236
455
  async _onResponse(resp) {
237
456
  try {
@@ -273,11 +492,47 @@ export class DeepSeekBrowser {
273
492
  await this.launch();
274
493
  }
275
494
  async waitForLogin() {
276
- const loggedIn = await this.isLoggedIn();
277
- if (loggedIn)
495
+ // 1) Already signed in (session cookie in the persistent profile)?
496
+ if (await this.isLoggedIn()) {
497
+ if (this.debug)
498
+ console.error('auth: сессия уже активна');
278
499
  return;
279
- console.log('\n🔐 Залогиньтесь в DeepSeek в открытом браузере.');
280
- console.log(' После входа нажмите Enter в терминале...\n');
500
+ }
501
+ // 2) Try to sign in automatically with the saved credentials. The
502
+ // password lives in the config (~/.zames/config.json) and is only used
503
+ // when the session is logged out — i.e. exactly the "re-login without
504
+ // asking" case. If the login form is absent (e.g. DeepSeek uses a
505
+ // captcha / another provider), this fails and we fall through.
506
+ if (this.auth.username && this.auth.password) {
507
+ console.log(theme.system(this._t('auth.auto_login')));
508
+ const ok = await this._autoLogin().catch((e) => {
509
+ console.log(theme.warn(this._t('auth.auto_login_failed', { v: e.message })));
510
+ return false;
511
+ });
512
+ if (ok) {
513
+ console.log(theme.assistant(this._t('auth.auto_login_ok')));
514
+ return;
515
+ }
516
+ }
517
+ // 3) Ask the operator for credentials in the terminal, try them, and
518
+ // only then fall back to the manual hint. Interactive only: in a
519
+ // pipe/redirect (or headless without credentials) we print a hint.
520
+ const asked = await this._promptAndLogin().catch((e) => {
521
+ if (this.debug)
522
+ console.error('auth: prompt error:', e.message);
523
+ return false;
524
+ });
525
+ if (asked)
526
+ return;
527
+ // 4) Last resort: manual sign-in in the browser window.
528
+ console.log('\n' + theme.warn(this._t('auth.need_login')));
529
+ if (this.headless) {
530
+ console.log(' ' + this._t('auth.manual_hint_headless'));
531
+ }
532
+ else {
533
+ console.log(' ' + this._t('auth.manual_hint'));
534
+ }
535
+ console.log('\n');
281
536
  const readline = await import('readline');
282
537
  const rl = readline.createInterface({
283
538
  input: process.stdin,
@@ -289,20 +544,286 @@ export class DeepSeekBrowser {
289
544
  resolve();
290
545
  });
291
546
  });
547
+ if (await this.isLoggedIn()) {
548
+ await this._persistSessionIfNeeded();
549
+ console.log(theme.assistant(this._t('auth.auto_login_ok')));
550
+ }
292
551
  }
293
- async isLoggedIn() {
294
- for (const sel of INPUT_SELECTORS) {
295
- try {
296
- await this.page.locator(sel).first().waitFor({
297
- state: 'visible',
298
- timeout: 3000,
299
- });
552
+ /**
553
+ * Try to sign in with this.auth. Returns true when the login form was
554
+ * filled and the session became active. Never throws for a missing form —
555
+ * the caller decides whether to fall back.
556
+ */
557
+ async _autoLogin() {
558
+ const filled = await this._fillLoginForm(this.auth.username, this.auth.password);
559
+ if (!filled)
560
+ return false;
561
+ const ok = await this._waitLoggedIn(20_000);
562
+ if (ok)
563
+ await this._persistSessionIfNeeded();
564
+ return ok;
565
+ }
566
+ /**
567
+ * Ask the operator for login/password in the terminal (only in a TTY) and
568
+ * try them. An empty password falls back to the saved one. Returns true
569
+ * when the sign-in succeeded.
570
+ */
571
+ async _promptAndLogin() {
572
+ if (!process.stdin.isTTY || !process.stdout.isTTY)
573
+ return false;
574
+ const hasSaved = !!(this.auth.username && this.auth.password);
575
+ // Prompt only when we have a login field to fill.
576
+ if (!(await this._loginFormVisible())) {
577
+ if (this.debug)
578
+ console.error('auth: форма входа не найдена');
579
+ return false;
580
+ }
581
+ console.log('\n' + theme.warn(this._t('auth.need_login')));
582
+ // Username via a normal readline question (echoed).
583
+ let user = this.auth.username;
584
+ if (!user) {
585
+ const readline = await import('readline');
586
+ const rl = readline.createInterface({
587
+ input: process.stdin,
588
+ output: process.stdout,
589
+ terminal: true,
590
+ });
591
+ user = (await new Promise((resolve) => rl.question(this._t('auth.prompt_login'), (a) => resolve(a)))).trim();
592
+ rl.close();
593
+ }
594
+ // Password via raw keystroke reading, echoed as `*` (readline would echo
595
+ // it in clear or swallow the prompt — see askPassword).
596
+ const passPromptKey = hasSaved
597
+ ? 'auth.prompt_password_saved'
598
+ : 'auth.prompt_password';
599
+ const pass = await askPassword(this._t(passPromptKey));
600
+ const password = pass || this.auth.password;
601
+ if (!user || !password)
602
+ return false;
603
+ console.log(theme.system(this._t('auth.auto_login')));
604
+ const filled = await this._fillLoginForm(user, password);
605
+ if (!filled) {
606
+ console.log(theme.warn(this._t('auth.form_not_found')));
607
+ return false;
608
+ }
609
+ const ok = await this._waitLoggedIn(20_000);
610
+ if (ok) {
611
+ // Remember the credentials so the next launch can re-login silently.
612
+ this.auth.username = user;
613
+ this.auth.password = password;
614
+ await this._persistSessionIfNeeded();
615
+ this.onAuthSave?.(user, password);
616
+ console.log(theme.assistant(this._t('auth.auto_login_ok')));
617
+ }
618
+ else {
619
+ // Detect a credentials error shown by DeepSeek, otherwise report a
620
+ // generic failure with the page hint so the operator can react.
621
+ const reason = await this._readLoginError();
622
+ if (reason) {
623
+ console.log(theme.error(this._t('auth.login_rejected', { v: reason })));
624
+ }
625
+ else {
626
+ console.log(theme.warn(this._t('auth.auto_login_failed', { v: this._t('auth.no_reason') })));
627
+ }
628
+ }
629
+ return ok;
630
+ }
631
+ /**
632
+ * Read a visible login/credentials error message from the page, if any.
633
+ * Best-effort: returns '' when nothing recognizable is found.
634
+ */
635
+ async _readLoginError() {
636
+ try {
637
+ const txt = await this.page.evaluate(() => {
638
+ const sels = [
639
+ '[role="alert"]',
640
+ '[class*="error" i]',
641
+ '[class*="toast" i]',
642
+ '[class*="notification" i]',
643
+ ];
644
+ let out = '';
645
+ for (const s of sels) {
646
+ for (const el of Array.from(document.querySelectorAll(s))) {
647
+ const e = el;
648
+ const st = getComputedStyle(e);
649
+ if (st.display === 'none' || st.visibility === 'hidden')
650
+ continue;
651
+ const t = (e.innerText || '').trim();
652
+ if (t)
653
+ out += ' ' + t;
654
+ }
655
+ }
656
+ return out.replace(/\s+/g, ' ').trim();
657
+ });
658
+ return txt.slice(0, 200);
659
+ }
660
+ catch {
661
+ return '';
662
+ }
663
+ }
664
+ /** Is a login form (password field) present on the page? */
665
+ async _loginFormVisible() {
666
+ return await this.page
667
+ .locator(PASSWORD_SELECTORS.join(', '))
668
+ .first()
669
+ .isVisible({ timeout: 2000 })
670
+ .catch(() => false);
671
+ }
672
+ /**
673
+ * Fill the DeepSeek sign-in form with the given credentials and submit it.
674
+ * Returns false if the form (or the submit button) is not found.
675
+ */
676
+ async _fillLoginForm(username, password) {
677
+ const pwd = await this._findVisible(PASSWORD_SELECTORS, 4000);
678
+ if (!pwd)
679
+ return false;
680
+ // The email/phone field is the text input above the password field. Try
681
+ // the known selectors first, then fall back to the nearest text input.
682
+ let userInput = await this._findVisible(LOGIN_SELECTORS, 2000);
683
+ if (!userInput) {
684
+ // Find the index of the password input and use the closest preceding
685
+ // text/email/tel input as the login field.
686
+ const idx = await this.page.evaluate((pwdSel) => {
687
+ const p = document.querySelector(pwdSel);
688
+ if (!p)
689
+ return -1;
690
+ const inputs = Array.from(document.querySelectorAll('input'));
691
+ const pi = inputs.indexOf(p);
692
+ for (let i = pi - 1; i >= 0; i--) {
693
+ const t = (inputs[i].type || 'text').toLowerCase();
694
+ if (t === 'text' || t === 'email' || t === 'tel')
695
+ return i;
696
+ }
697
+ return -1;
698
+ }, PASSWORD_SELECTORS[0]);
699
+ if (idx >= 0)
700
+ userInput = this.page.locator('input').nth(idx);
701
+ }
702
+ if (!userInput)
703
+ return false;
704
+ await this._setInputText(userInput, username);
705
+ await this._setInputText(pwd, password);
706
+ await this.page.waitForTimeout(150);
707
+ // Submit. DeepSeek's login form is a React form; the button text is
708
+ // localized and classes change, so we try, in order:
709
+ // 1. known submit selectors;
710
+ // 2. a submit/primary button inside the same <form> as the password;
711
+ // 3. pressing Enter in the password field;
712
+ // 4. clicking the password field's closest button sibling.
713
+ // After each attempt we give the login a short window and stop as soon as
714
+ // it succeeds, so a stray click does not fire on an already-logged-in page.
715
+ const tryLogin = async () => {
716
+ if (await this._waitLoggedIn(3000))
300
717
  return true;
718
+ return false;
719
+ };
720
+ // Precise first attempt: DeepSeek's button is
721
+ // <div role="button" class="ds-button ds-button--primary ...">
722
+ // <span class="ds-button__content">Log in</span></div>
723
+ // Click exactly the one whose visible label is a login word, so we never
724
+ // hit "Log in with Google" or another primary button by mistake.
725
+ const precise = this.page
726
+ .locator('div[role="button"].ds-button--primary, button')
727
+ .filter({ hasText: /^\s*(log ?in|sign ?in|войти)\s*$/i });
728
+ const pc = await precise.count().catch(() => 0);
729
+ if (pc > 0) {
730
+ try {
731
+ await precise.last().click({ timeout: 2000 });
732
+ if (await tryLogin())
733
+ return true;
301
734
  }
302
735
  catch { }
303
736
  }
737
+ for (const sel of LOGIN_SUBMIT_SELECTORS) {
738
+ const btn = this.page.locator(sel).last();
739
+ try {
740
+ if ((await btn.count()) === 0)
741
+ continue;
742
+ if (!(await btn.isVisible().catch(() => false)))
743
+ continue;
744
+ await btn.click({ timeout: 2000 });
745
+ if (await tryLogin())
746
+ return true;
747
+ }
748
+ catch { }
749
+ }
750
+ // Button inside the same form as the password field.
751
+ const formClicked = await this.page
752
+ .evaluate((pwdSel) => {
753
+ const p = document.querySelector(pwdSel);
754
+ if (!p)
755
+ return false;
756
+ const form = p.closest('form');
757
+ const scope = form || document;
758
+ const btns = Array.from(scope.querySelectorAll('button[type="submit"], button, div[role="button"]'));
759
+ for (const b of btns) {
760
+ const txt = (b.textContent || '').trim().toLowerCase();
761
+ const aria = (b.getAttribute('aria-label') || '').toLowerCase();
762
+ if (/log ?in|sign ?in|войти|continue|продолж/.test(txt + ' ' + aria)) {
763
+ b.click();
764
+ return true;
765
+ }
766
+ }
767
+ // Fallback: the primary-looking button in the scope.
768
+ const primary = btns.find((b) => {
769
+ const cls = (b.className || '').toString();
770
+ return /primary|submit|ds-button--primary/i.test(cls);
771
+ });
772
+ if (primary) {
773
+ primary.click();
774
+ return true;
775
+ }
776
+ return false;
777
+ }, PASSWORD_SELECTORS[0])
778
+ .catch(() => false);
779
+ if (formClicked && (await tryLogin()))
780
+ return true;
781
+ // Enter in the password field.
782
+ await pwd.press('Enter').catch(() => { });
783
+ return true;
784
+ }
785
+ /** Poll isLoggedIn until it succeeds or the deadline passes. */
786
+ async _waitLoggedIn(timeoutMs) {
787
+ const deadline = Date.now() + timeoutMs;
788
+ while (Date.now() < deadline) {
789
+ if (await this.isLoggedIn().catch(() => false))
790
+ return true;
791
+ await this.page.waitForTimeout(500);
792
+ }
304
793
  return false;
305
794
  }
795
+ /**
796
+ * Persist the authenticated session. The persistent profile already keeps
797
+ * cookies on disk; `saveSession` (config) is kept as an explicit toggle for
798
+ * the operator. We additionally save a small marker so /doctor and the next
799
+ * launch can tell that a session exists.
800
+ */
801
+ async _persistSessionIfNeeded() {
802
+ if (!this.auth.saveSession)
803
+ return;
804
+ if (!(await this.isLoggedIn().catch(() => false)))
805
+ return;
806
+ try {
807
+ await fs.mkdir(path.join(os.homedir(), '.zames'), { recursive: true });
808
+ await fs.writeFile(AUTH_MARKER_FILE, JSON.stringify({ at: new Date().toISOString() }, null, 2), 'utf-8');
809
+ }
810
+ catch { }
811
+ }
812
+ async isLoggedIn() {
813
+ // Single combined query with a short timeout: the old loop waited up to
814
+ // 3s PER selector (9s total), and _waitLoggedIn calls this repeatedly,
815
+ // which made login feel like it "hangs".
816
+ try {
817
+ await this.page
818
+ .locator(INPUT_SELECTORS.join(', '))
819
+ .first()
820
+ .waitFor({ state: 'visible', timeout: 2000 });
821
+ return true;
822
+ }
823
+ catch {
824
+ return false;
825
+ }
826
+ }
306
827
  async newChat() {
307
828
  const newChatBtn = this.page
308
829
  .locator('button, a')
@@ -523,10 +1044,17 @@ export class DeepSeekBrowser {
523
1044
  if (e instanceof RateLimitError) {
524
1045
  rateLimitRetries++;
525
1046
  if (rateLimitRetries > this.maxRateLimitRetries) {
526
- console.error(theme.error(`✖ DeepSeek не принял сообщение после ${rateLimitRetries} пауз по ${Math.ceil(this.rateLimitWaitMs / 60000)} мин.`));
1047
+ console.error(theme.error(this._t('ds.rate_limit_give_up', {
1048
+ attempt: rateLimitRetries,
1049
+ min: Math.ceil(this.rateLimitWaitMs / 60000),
1050
+ })));
527
1051
  throw e;
528
1052
  }
529
- console.error(theme.warn(`⏳ DeepSeek: «слишком часто». Жду ${Math.ceil(this.rateLimitWaitMs / 60000)} мин (${rateLimitRetries}/${this.maxRateLimitRetries}) и повторю...`));
1053
+ console.error(theme.warn(this._t('ds.rate_limit_wait', {
1054
+ min: Math.ceil(this.rateLimitWaitMs / 60000),
1055
+ attempt: rateLimitRetries,
1056
+ max: this.maxRateLimitRetries,
1057
+ })));
530
1058
  // Interruptible: Esc/Ctrl+C must cancel this long wait too,
531
1059
  // otherwise "stop" stays frozen for up to 5 minutes.
532
1060
  const aborted = await this._sleepInterruptible(this.rateLimitWaitMs);
@@ -534,15 +1062,44 @@ export class DeepSeekBrowser {
534
1062
  return '(прервано пользователем)';
535
1063
  continue;
536
1064
  }
537
- console.error(`\n⚠ ask() попытка ${attempt}/${this.askRetries} провалилась: ${e.message}`);
1065
+ // Server busy / overloaded: clear in seconds, so we retry quickly
1066
+ // (unlike the rate limit). Without this branch the error fell into the
1067
+ // generic ask() retry and the operator saw a bare message with no
1068
+ // explanation of what DeepSeek is doing.
1069
+ if (e instanceof ServerBusyError) {
1070
+ serverBusyRetries++;
1071
+ if (serverBusyRetries > this.maxServerBusyRetries) {
1072
+ console.error(theme.error(this._t('ds.server_busy_give_up', {
1073
+ attempt: serverBusyRetries,
1074
+ })));
1075
+ throw e;
1076
+ }
1077
+ console.error(theme.warn(this._t('ds.server_busy_wait', {
1078
+ sec: Math.ceil(this.serverBusyWaitMs / 1000),
1079
+ attempt: serverBusyRetries,
1080
+ max: this.maxServerBusyRetries,
1081
+ })));
1082
+ const aborted = await this._sleepInterruptible(this.serverBusyWaitMs);
1083
+ if (aborted)
1084
+ return '(прервано пользователем)';
1085
+ continue;
1086
+ }
1087
+ console.error('\n' +
1088
+ theme.warn(this._t('ds.ask_retry', {
1089
+ attempt,
1090
+ max: this.askRetries,
1091
+ error: e.message,
1092
+ })));
538
1093
  if (/closed|crash|Target page|browser/i.test(e.message)) {
539
- console.error('⚠ перезапускаю браузер...');
1094
+ console.error(theme.warn(this._t('ds.ask_restart_browser')));
540
1095
  try {
541
1096
  await this.restart();
542
1097
  await this.waitForLogin();
543
1098
  }
544
1099
  catch (re) {
545
- console.error(`⚠ не удалось перезапустить: ${re.message}`);
1100
+ console.error(theme.warn(this._t('ds.ask_restart_failed', {
1101
+ error: re.message,
1102
+ })));
546
1103
  }
547
1104
  }
548
1105
  if (attempt < this.askRetries) {
@@ -550,7 +1107,10 @@ export class DeepSeekBrowser {
550
1107
  }
551
1108
  }
552
1109
  }
553
- throw new Error(`ask() провалился после ${this.askRetries} попыток: ${lastErr?.message}`);
1110
+ throw new Error(this._t('ds.ask_failed', {
1111
+ max: this.askRetries,
1112
+ error: lastErr?.message || '',
1113
+ }));
554
1114
  }
555
1115
  async _setInputText(input, text) {
556
1116
  const tag = await input.evaluate((el) => el.tagName.toLowerCase());
@@ -606,7 +1166,10 @@ export class DeepSeekBrowser {
606
1166
  return el.innerText || el.textContent || "";
607
1167
  });
608
1168
  if (norm(got2) !== norm(text)) {
609
- throw new Error("Не удалось вставить текст в поле ввода DeepSeek целиком (вставлено " + norm(got2).length + " из " + norm(text).length + " символов). Сообщение не отправлено, чтобы не отправить обрезанный текст.");
1169
+ throw new Error(this._t('ds.input_partial', {
1170
+ got: norm(got2).length,
1171
+ want: norm(text).length,
1172
+ }));
610
1173
  }
611
1174
  }
612
1175
  }
@@ -690,20 +1253,47 @@ export class DeepSeekBrowser {
690
1253
  const gap = this.minSendIntervalMs - (Date.now() - this._lastSentAt);
691
1254
  if (gap <= 0)
692
1255
  return;
693
- console.error(theme.warn(`⏳ пауза ${Math.ceil(gap / 1000)}с перед отправкой`));
694
- await this._sleepInterruptible(gap);
1256
+ // Report the pause to the UI as an ANIMATED status (with the remaining
1257
+ // seconds) instead of the old static console line — the dots used to be
1258
+ // frozen here, which looked like the spinner had hung. The seconds are
1259
+ // refreshed once per second, so the status visibly counts down.
1260
+ const report = (leftMs) => {
1261
+ const secs = Math.max(0, Math.ceil(leftMs / 1000));
1262
+ if (this.onSendPause) {
1263
+ try {
1264
+ this.onSendPause(secs);
1265
+ }
1266
+ catch { }
1267
+ }
1268
+ else {
1269
+ console.error(theme.warn(`⏳ send pause ${secs}s`));
1270
+ }
1271
+ };
1272
+ report(gap);
1273
+ await this._sleepInterruptible(gap, report);
695
1274
  }
696
1275
  // Sleep in small slices so Esc/Ctrl+C can cancel the wait promptly.
697
1276
  // Returns true if the sleep was cut short by _abort.
698
- async _sleepInterruptible(ms) {
1277
+ //
1278
+ // onTick (optional) is called about once per second with the remaining time,
1279
+ // so the UI can show a live countdown during the pause.
1280
+ async _sleepInterruptible(ms, onTick) {
699
1281
  const step = 100;
700
1282
  let left = ms;
1283
+ let sinceTick = 0;
701
1284
  while (left > 0) {
702
1285
  if (this._abort)
703
1286
  return true;
704
1287
  const chunk = Math.min(step, left);
705
1288
  await this.page.waitForTimeout(chunk);
706
1289
  left -= chunk;
1290
+ if (onTick) {
1291
+ sinceTick += chunk;
1292
+ if (sinceTick >= 1000) {
1293
+ sinceTick = 0;
1294
+ onTick(left);
1295
+ }
1296
+ }
707
1297
  }
708
1298
  return this._abort;
709
1299
  }
@@ -729,7 +1319,7 @@ export class DeepSeekBrowser {
729
1319
  return '(прервано пользователем)';
730
1320
  const input = await this._findVisible(INPUT_SELECTORS, 10_000);
731
1321
  if (!input) {
732
- throw new Error('Не найдено поле ввода. Запустите /debug-dom и поправьте INPUT_SELECTORS.');
1322
+ throw new Error(this._t('ds.input_missing'));
733
1323
  }
734
1324
  const beforeText = await this._readLastAnswerTextClean().catch(() => '');
735
1325
  this._askDebug('SEND agent=' + agent + ' len=' + prompt.length + ' beforeLen=' + beforeText.length + ' beforeHead=' + JSON.stringify(beforeText.slice(0, 60)));
@@ -890,8 +1480,7 @@ export class DeepSeekBrowser {
890
1480
  }
891
1481
  }
892
1482
  if (!started) {
893
- throw new Error('Ответ не начал генерироваться за 35с даже после повторной отправки. ' +
894
- 'Проверьте чат DeepSeek вручную.');
1483
+ throw new Error(this._t('ds.send_no_start'));
895
1484
  }
896
1485
  // Wait until the answer stops changing. We check the "not generating"
897
1486
  // condition via text growth, NOT via _isGenerating().
@@ -961,12 +1550,11 @@ export class DeepSeekBrowser {
961
1550
  return this._netCapture;
962
1551
  }
963
1552
  this._askDebug('THROW no-new-answer lastLen=' + last.length + ' beforeLen=' + beforeText.length);
964
- throw new Error('Новый ответ не получен (на странице остался прежний текст). ' +
965
- 'Возможно, сообщение не отправилось.');
1553
+ throw new Error(this._t('ds.send_no_new_answer'));
966
1554
  }
967
1555
  async dumpDom(filePath) {
968
1556
  if (!this.page)
969
- throw new Error('браузер не запущен');
1557
+ throw new Error(this._t('ds.not_launched'));
970
1558
  const html = await this.page.content();
971
1559
  await fs.writeFile(filePath, html, 'utf-8');
972
1560
  const report = await this.page.evaluate((sels) => {
@@ -1060,9 +1648,220 @@ export class DeepSeekBrowser {
1060
1648
  return true;
1061
1649
  }
1062
1650
  catch (e) {
1063
- throw new Error(`Не удалось открыть чат ${id}: ${e.message}`);
1651
+ throw new Error(this._t('ds.open_chat_failed', { id, error: e.message }));
1652
+ }
1653
+ }
1654
+ // Fetch the WHOLE dialogue from DeepSeek's own history endpoint instead of
1655
+ // scraping the DOM. The rendered page only shows a part of the history (the
1656
+ // list is virtualized), while /api/v0/chat/history_messages returns every
1657
+ // message of the chat as JSON. The request is made INSIDE the page, so the
1658
+ // auth cookie and the CSRF/PoW handling are the browser's.
1659
+ //
1660
+ // Shape of data.biz_data.chat_messages[]: { role: 'USER'|'ASSISTANT',
1661
+ // fragments: [{ type: 'REQUEST'|'RESPONSE'|'THINK'|'FILE', content }] }.
1662
+ // We keep REQUEST (user) and RESPONSE (assistant); THINK (reasoning) is
1663
+ // skipped and FILE fragments carry no text.
1664
+ async fetchChatMessages(id) {
1665
+ this._lastHistoryError = '';
1666
+ if (!this.page || !id)
1667
+ return [];
1668
+ // The Authorization header is sniffed off DeepSeek's own API requests. On
1669
+ // a freshly opened chat that request may not have fired yet — wait a bit
1670
+ // (up to 3s) so the replay is authorized instead of answering 401/empty.
1671
+ for (let i = 0; i < 15 && !this._apiAuth; i++) {
1672
+ await this.page.waitForTimeout(200);
1673
+ }
1674
+ const auth = this._apiAuth;
1675
+ const pow = this._apiPow;
1676
+ const res = await this.page
1677
+ .evaluate(async (opts) => {
1678
+ try {
1679
+ const url = 'https://chat.deepseek.com/api/v0/chat/history_messages?chat_session_id=' +
1680
+ encodeURIComponent(opts.chatId);
1681
+ const headers = {
1682
+ accept: 'application/json',
1683
+ };
1684
+ if (opts.auth)
1685
+ headers['authorization'] = opts.auth;
1686
+ if (opts.pow)
1687
+ headers['x-ds-pow-response'] = opts.pow;
1688
+ const resp = await fetch(url, {
1689
+ credentials: 'include',
1690
+ headers,
1691
+ });
1692
+ if (!resp.ok)
1693
+ return { error: 'HTTP ' + resp.status, list: [] };
1694
+ const json = await resp.json();
1695
+ const messages = json && json.data && json.data.biz_data
1696
+ ? json.data.biz_data.chat_messages
1697
+ : null;
1698
+ if (!Array.isArray(messages)) {
1699
+ return { error: 'no chat_messages in response', list: [] };
1700
+ }
1701
+ const NL = String.fromCharCode(10);
1702
+ const out = [];
1703
+ for (const m of messages) {
1704
+ const role = m && m.role === 'ASSISTANT' ? 'assistant' : 'user';
1705
+ const want = role === 'assistant' ? 'RESPONSE' : 'REQUEST';
1706
+ let text = '';
1707
+ for (const fr of (m && m.fragments) || []) {
1708
+ if (!fr || fr.type !== want)
1709
+ continue;
1710
+ if (typeof fr.content === 'string')
1711
+ text += fr.content;
1712
+ }
1713
+ text = text.replace(new RegExp(NL + '{3,}', 'g'), NL + NL).trim();
1714
+ if (text)
1715
+ out.push({ role, text });
1716
+ }
1717
+ return { error: '', list: out };
1718
+ }
1719
+ catch (e) {
1720
+ return { error: 'fetch failed: ' + e.message, list: [] };
1721
+ }
1722
+ }, { chatId: id, auth, pow })
1723
+ .catch((e) => ({
1724
+ error: 'evaluate failed: ' + e.message,
1725
+ list: [],
1726
+ }));
1727
+ this._lastHistoryError = res?.error || '';
1728
+ const list = (res?.list || []);
1729
+ if (list.length || !res?.error)
1730
+ return list;
1731
+ // Fallback: Playwright's own request context (shares the browser cookies)
1732
+ // when the in-page fetch was blocked (CSP, CORS, a page error).
1733
+ this._lastHistoryError = res.error;
1734
+ try {
1735
+ const resp = await this.page.request.get('https://chat.deepseek.com/api/v0/chat/history_messages?chat_session_id=' +
1736
+ encodeURIComponent(id), { headers: { accept: 'application/json' } });
1737
+ if (!resp.ok())
1738
+ return list;
1739
+ const json = (await resp.json());
1740
+ const messages = json?.data?.biz_data?.chat_messages;
1741
+ if (!Array.isArray(messages))
1742
+ return list;
1743
+ const NL = String.fromCharCode(10);
1744
+ const out = [];
1745
+ for (const m of messages) {
1746
+ const role = m && m.role === 'ASSISTANT' ? 'assistant' : 'user';
1747
+ const want = role === 'assistant' ? 'RESPONSE' : 'REQUEST';
1748
+ let text = '';
1749
+ for (const fr of (m && m.fragments) || []) {
1750
+ if (!fr || fr.type !== want)
1751
+ continue;
1752
+ if (typeof fr.content === 'string')
1753
+ text += fr.content;
1754
+ }
1755
+ text = text.replace(new RegExp(NL + '{3,}', 'g'), NL + NL).trim();
1756
+ if (text)
1757
+ out.push({ role, text });
1758
+ }
1759
+ this._lastHistoryError = '';
1760
+ return out;
1761
+ }
1762
+ catch (e) {
1763
+ this._lastHistoryError = 'request failed: ' + e.message;
1764
+ return list;
1064
1765
  }
1065
1766
  }
1767
+ // Read the WHOLE visible dialogue of the currently open chat, top to
1768
+ // bottom. Used by /resume and /resume-id so the operator sees the restored
1769
+ // context in the terminal instead of just "Chat opened.".
1770
+ //
1771
+ // Best-effort DOM scraping: DeepSeek renders every message as a markdown
1772
+ // block (assistant) or a plain bubble (user). Role detection relies on the
1773
+ // ds-assistant-message-* class; anything that is not an assistant block is
1774
+ // treated as a user message. The model's reasoning (.ds-think-content) is
1775
+ // skipped, exactly like in _readLastAnswerText.
1776
+ async readChatMessages() {
1777
+ if (!this.page)
1778
+ return [];
1779
+ const raw = await this.page
1780
+ .evaluate(() => {
1781
+ const inThink = (e) => {
1782
+ let n = e;
1783
+ while (n) {
1784
+ const cls = (n.className || '').toString();
1785
+ if (/ds-think-content|thinking-content/i.test(cls))
1786
+ return true;
1787
+ n = n.parentElement;
1788
+ }
1789
+ return false;
1790
+ };
1791
+ const textOf = (e) => {
1792
+ const h = e;
1793
+ const t = h.innerText || h.textContent || '';
1794
+ const NL = String.fromCharCode(10);
1795
+ return t.replace(new RegExp(NL + '{3,}', 'g'), NL + NL).trim();
1796
+ };
1797
+ // A message block is "assistant" when it contains an answer markdown
1798
+ // wrapper (ds-markdown / ds-assistant-message), otherwise it is a
1799
+ // user bubble. DeepSeek's class names drift between builds, so the
1800
+ // detection is content-based, not class-prefix-based.
1801
+ const looksAssistant = (e) => {
1802
+ const cls = (e.className || '').toString();
1803
+ if (/ds-assistant-message|assistant-message/i.test(cls))
1804
+ return true;
1805
+ if (e.querySelector('[class*="ds-assistant-message"]'))
1806
+ return true;
1807
+ // A user bubble has no rendered markdown; an answer does.
1808
+ if (e.querySelector('[class*="ds-markdown"]'))
1809
+ return true;
1810
+ return false;
1811
+ };
1812
+ // Message-level containers first (broad), then the answer wrappers.
1813
+ const containerSels = [
1814
+ '[data-message-id]',
1815
+ '[class*="ds-message"]',
1816
+ '[class*="chat-message"]',
1817
+ '[class*="message-item"]',
1818
+ '[class*="_message"]',
1819
+ ];
1820
+ let blocks = [];
1821
+ for (const s of containerSels) {
1822
+ const found = Array.from(document.querySelectorAll(s)).filter((e) => !inThink(e) && textOf(e).length > 0);
1823
+ if (found.length) {
1824
+ blocks = found;
1825
+ break;
1826
+ }
1827
+ }
1828
+ const out = [];
1829
+ if (blocks.length) {
1830
+ for (const b of blocks) {
1831
+ const t = textOf(b);
1832
+ if (!t)
1833
+ continue;
1834
+ out.push({
1835
+ role: looksAssistant(b) ? 'assistant' : 'user',
1836
+ text: t,
1837
+ });
1838
+ }
1839
+ if (out.length)
1840
+ return out;
1841
+ }
1842
+ // Last resort: assistant answers only (no user turns) — better than
1843
+ // nothing when no message container matched.
1844
+ const sels = [
1845
+ 'div.ds-assistant-message-main-content',
1846
+ 'div[class*="ds-assistant-message-main-content"]',
1847
+ 'div[class*="ds-markdown"]',
1848
+ ];
1849
+ for (const s of sels) {
1850
+ const list = Array.from(document.querySelectorAll(s)).filter((e) => !inThink(e));
1851
+ if (!list.length)
1852
+ continue;
1853
+ for (const el of list) {
1854
+ const t = textOf(el);
1855
+ if (t)
1856
+ out.push({ role: 'assistant', text: t });
1857
+ }
1858
+ break;
1859
+ }
1860
+ return out;
1861
+ })
1862
+ .catch(() => []);
1863
+ return raw;
1864
+ }
1066
1865
  async getCurrentChatId() {
1067
1866
  try {
1068
1867
  const url = this.page.url();