zames_pro 2.29.16 → 2.32.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
@@ -160,6 +160,10 @@ const STOP_SELECTORS = [
160
160
  'button:has-text("Остановить")',
161
161
  'button[aria-label*="Stop" i]',
162
162
  ];
163
+ // Accessible name of the Stop button, for a TRUSTED Playwright click. It is a
164
+ // bare stop word (optionally with a short suffix), anchored and length-limited
165
+ // so a "Stop" inside rendered prose is never clicked.
166
+ export const STOP_NAME_RE = /^(?:stop|остановить)(?:\s+\S{0,20})?\s*[.!…]?$/i;
163
167
  // DeepSeek's "Continue" button. With the reasoning ("Deep thinking") toggle
164
168
  // on, the server caps the THINK phase: the reasoning stops mid-way and the UI
165
169
  // offers a Continue button (this is NOT the same as a truncated turn with
@@ -332,6 +336,17 @@ export class DeepSeekBrowser {
332
336
  // rate limit. The click path now respects `minSendIntervalMs` like a send.
333
337
  _lastContinueAt;
334
338
  _abort;
339
+ // Set by cancelPendingAsk(): the agent loop's watchdog raced ask() against a
340
+ // deadline and the timer won. The underlying ask() may still be inside a
341
+ // rate-limit wait or the finish loop; this flag makes it bail out so the
342
+ // next iteration does not run TWO asks against the same page (two sends /
343
+ // two Continue clicks). Reset at the start of every _askOnce.
344
+ _askCancelled;
345
+ // Number of ask() calls currently in flight from OUR code. A
346
+ // completion/continue body that arrives while this is 0 was NOT initiated
347
+ // by us — it is the operator's own turn in the chat (or a manual resume in
348
+ // the web UI), and our next agent send must wait out the throttle from it.
349
+ _askInFlight;
335
350
  // The user pressed Esc/Ctrl+C — a "stop" for the WHOLE current batch of
336
351
  // tasks (including the queue). Unlike _abort (reset on every send), this
337
352
  // flag lives until a new task is explicitly started from the prompt.
@@ -344,6 +359,13 @@ export class DeepSeekBrowser {
344
359
  // (only reasoning). The DOM keeps showing reasoning/old text, so the loop
345
360
  // must not wait it out — it clicks Continue / resends. Reset before a send.
346
361
  _netNoAnswer;
362
+ // True when the LAST completion/continue body carried the rate limit
363
+ // ("Messages too frequent", finish_reason rate_limit_reached). The toast
364
+ // may be missed (it is a short-lived DOM node), so we ALSO recognise the
365
+ // error from the SSE body — otherwise ask() waited out the deadline and
366
+ // threw ds.send_no_new_answer while the chat plainly showed the rate limit.
367
+ // Reset before a send.
368
+ _netRateLimited;
347
369
  _netChatId;
348
370
  // The latest CONTEXT size (in tokens) DeepSeek reported in the current
349
371
  // chat. It is the `accumulated_token_usage` counter taken from the SSE
@@ -361,8 +383,6 @@ export class DeepSeekBrowser {
361
383
  // Last error from fetchChatMessages ('' on success). Surfaced under --debug
362
384
  // so a missing restored history can be diagnosed.
363
385
  _lastHistoryError;
364
- _netSniff;
365
- _netSniffLimit;
366
386
  // The RAW SSE body of the LAST answer response. Unlike `_netCapture`, this
367
387
  // is kept even when the body carries no RESPONSE text (a truncated turn:
368
388
  // generation_err / INCOMPLETE, or FINISHED-with-only-reasoning). The
@@ -421,18 +441,19 @@ export class DeepSeekBrowser {
421
441
  this._lastSentAt = 0;
422
442
  this._lastContinueAt = 0;
423
443
  this._abort = false;
444
+ this._askCancelled = false;
445
+ this._askInFlight = 0;
424
446
  this._stopped = false;
425
447
  this._netCapture = '';
426
448
  this._netCaptureAt = 0;
427
449
  this._netNoAnswer = false;
450
+ this._netRateLimited = false;
428
451
  this._netChatId = null;
429
452
  this._lastTokenUsage = null;
430
453
  this._toggles = { deepThinking, webSearch };
431
454
  this._apiAuth = '';
432
455
  this._apiPow = '';
433
456
  this._lastHistoryError = '';
434
- this._netSniff = [];
435
- this._netSniffLimit = 5;
436
457
  this._netBody = '';
437
458
  this._netHookInstalled = false;
438
459
  this.onSendStart = null;
@@ -590,9 +611,6 @@ export class DeepSeekBrowser {
590
611
  const body = await resp.text().catch(() => '');
591
612
  if (!body)
592
613
  return;
593
- this._netSniff.push({ url, contentType: ct, body });
594
- if (this._netSniff.length > this._netSniffLimit)
595
- this._netSniff.shift();
596
614
  void dumpNetBody(url, body);
597
615
  // Context size (tokens) reported by DeepSeek for this answer. Kept
598
616
  // even when extractAnswer() returns nothing (a history_messages
@@ -600,8 +618,13 @@ export class DeepSeekBrowser {
600
618
  const usage = extractTokenUsage(body);
601
619
  if (usage !== null)
602
620
  this._lastTokenUsage = usage;
621
+ // Only a completion/continue turn carries a real answer. Gate the
622
+ // capture assignment to those URLs: a future endpoint returning a
623
+ // `content`/`text`/`response` string must not clobber the answer and
624
+ // look fresh (it would be returned as the final answer).
625
+ const isAnswerUrl = /chat\/(completion|continue)/i.test(url);
603
626
  const extracted = extractAnswer(body);
604
- if (extracted) {
627
+ if (extracted && isAnswerUrl) {
605
628
  this._netCapture = extracted;
606
629
  this._netNoAnswer = false;
607
630
  const cid = url.match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/);
@@ -612,7 +635,15 @@ export class DeepSeekBrowser {
612
635
  // Store the RAW body for the CURRENT answer (completion/continue) so the
613
636
  // truncated/no-answer detectors can inspect it even when there is no
614
637
  // RESPONSE text (extractAnswer() returns '' for those bodies).
615
- if (/chat\/(completion|continue)/i.test(url)) {
638
+ if (isAnswerUrl) {
639
+ // A completion/continue body that arrives while WE have no ask in
640
+ // flight was the OPERATOR's own turn (they sent a message by hand or
641
+ // resumed in the web UI). Record that moment so our next agent send
642
+ // waits out the throttle from it instead of firing too soon after the
643
+ // manual turn (which caused a rate limit). Do NOT touch the interval.
644
+ if (this._askInFlight === 0) {
645
+ this._lastSentAt = Date.now();
646
+ }
616
647
  this._netBody = body;
617
648
  const cid = url.match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/);
618
649
  if (cid)
@@ -632,6 +663,15 @@ export class DeepSeekBrowser {
632
663
  if (isGenerationIncompleteText(body)) {
633
664
  this._netCapture = '';
634
665
  }
666
+ // The rate limit can arrive ONLY in the SSE body (the DOM toast is
667
+ // short-lived and easy to miss). Recognise it here so ask() waits
668
+ // instead of waiting out the deadline and throwing
669
+ // ds.send_no_new_answer while the chat shows "Messages too
670
+ // frequent".
671
+ if (isRateLimitText(body)) {
672
+ this._netRateLimited = true;
673
+ this._netCapture = '';
674
+ }
635
675
  this._netCaptureAt = Date.now();
636
676
  }
637
677
  }
@@ -985,18 +1025,61 @@ export class DeepSeekBrowser {
985
1025
  return false;
986
1026
  }
987
1027
  }
1028
+ // Wait for the INPUT to be present and visible, polling instead of a
1029
+ // fixed sleep. Returns true as soon as it appears (or false on timeout).
1030
+ async _awaitInput(timeoutMs = 5000) {
1031
+ const deadline = Date.now() + timeoutMs;
1032
+ while (Date.now() < deadline) {
1033
+ const found = await this._findVisible(INPUT_SELECTORS, 400);
1034
+ if (found)
1035
+ return true;
1036
+ await this.page.waitForTimeout(100);
1037
+ }
1038
+ return false;
1039
+ }
1040
+ // Did the chat actually CHANGE? Compares the current chat id (URL or the
1041
+ // one sniffed off the network) with a baseline. Used to VERIFY newChat /
1042
+ // openChat instead of blindly trusting a click that may have done nothing.
1043
+ async _chatIdChangedFrom(baseline) {
1044
+ const now = await this.getCurrentChatId();
1045
+ return !!now && now !== baseline;
1046
+ }
1047
+ // Open a fresh chat. Returns as soon as the new-chat input is ready
1048
+ // (polling), and VERIFIES the chat actually changed. A failed click falls
1049
+ // back to navigating to the base URL, and we still wait for a positive
1050
+ // signal before returning, so the next prompt is never sent into the OLD
1051
+ // chat silently.
988
1052
  async newChat() {
1053
+ const before = await this.getCurrentChatId();
989
1054
  const newChatBtn = this.page
990
1055
  .locator('button, a')
991
1056
  .filter({ hasText: /new chat|новый чат|новый диалог/i })
992
1057
  .first();
1058
+ let clicked = false;
993
1059
  try {
994
1060
  await newChatBtn.click({ timeout: 2000 });
995
- await this.page.waitForTimeout(1000);
1061
+ clicked = true;
996
1062
  }
997
1063
  catch {
1064
+ // fall through to the goto fallback below
1065
+ }
1066
+ // Give the app a moment to react to the click, then poll for the positive
1067
+ // signal (input ready AND chat changed). If the click did nothing, fall
1068
+ // back to navigating to the base URL.
1069
+ let ok = false;
1070
+ const deadline = Date.now() + 5000;
1071
+ while (Date.now() < deadline) {
1072
+ if (await this._awaitInput(500)) {
1073
+ if (!clicked || (await this._chatIdChangedFrom(before))) {
1074
+ ok = true;
1075
+ break;
1076
+ }
1077
+ }
1078
+ await this.page.waitForTimeout(100);
1079
+ }
1080
+ if (!ok) {
998
1081
  await this.page.goto(CHAT_URL, { waitUntil: 'domcontentloaded' });
999
- await this.page.waitForTimeout(1000);
1082
+ await this._awaitInput(5000);
1000
1083
  }
1001
1084
  }
1002
1085
  async _findVisible(selectors, timeout = 1000) {
@@ -1068,9 +1151,12 @@ export class DeepSeekBrowser {
1068
1151
  return out.replace(new RegExp(NL + '{3,}', 'g'), NL + NL).trim();
1069
1152
  }, ANSWER_SELECTORS);
1070
1153
  }
1071
- // We read ONLY visible toasts/notifications/errors, not the whole page
1072
- // text. Otherwise we catch "try again later" from service/hidden blocks
1073
- // and go into a false 5-minute rate-limit wait.
1154
+ // We read ONLY visible toasts/notifications, not the whole page text.
1155
+ // Otherwise we catch "try again later" from service/hidden blocks and go
1156
+ // into a false 5-minute rate-limit wait. The old list also matched a broad
1157
+ // `[class*="error" i]` / `[class*="alert" i]`, which picked up leftover
1158
+ // text from the page and produced a FALSE rate limit (the agent stopped
1159
+ // while the chat had no error). Keep only real toast/alert containers.
1074
1160
  async _readPageText() {
1075
1161
  return await this.page
1076
1162
  .evaluate(() => {
@@ -1078,8 +1164,6 @@ export class DeepSeekBrowser {
1078
1164
  '[role="alert"]',
1079
1165
  '[class*="toast" i]',
1080
1166
  '[class*="notification" i]',
1081
- '[class*="alert" i]',
1082
- '[class*="error" i]',
1083
1167
  ];
1084
1168
  let out = '';
1085
1169
  for (const s of sels) {
@@ -1095,13 +1179,9 @@ export class DeepSeekBrowser {
1095
1179
  })
1096
1180
  .catch(() => '');
1097
1181
  }
1098
- async _readLastAnswerTextClean() {
1099
- const raw = await this._readLastAnswerText().catch(() => '');
1100
- return this._cleanAnswer(raw);
1101
- }
1102
- // The same "clean" filter as _readLastAnswerTextClean, but read from the
1103
- // DOM only. Used by _askOnce for the before/after comparison: the network
1104
- // capture must not be compared against itself (see _readLastAnswerTextDom).
1182
+ // The "clean" filter, but read from the DOM only. Used by _askOnce for the
1183
+ // before/after comparison: the network capture must not be compared against
1184
+ // itself (see _readLastAnswerTextDom).
1105
1185
  async _readLastAnswerTextCleanDom() {
1106
1186
  const raw = await this._readLastAnswerTextDom().catch(() => '');
1107
1187
  return this._cleanAnswer(raw);
@@ -1150,11 +1230,65 @@ export class DeepSeekBrowser {
1150
1230
  async _isGenerating() {
1151
1231
  return await this._stopButtonVisible();
1152
1232
  }
1233
+ // A CHEAP growth signal for the "did the answer start?" loop. The old
1234
+ // code read `document.body.innerText.length` every ~300ms tick, which
1235
+ // serializes the ENTIRE page text (sidebar, history, menus) and forces a
1236
+ // layout — expensive on a long chat. This returns only the number of
1237
+ // message/answer nodes plus the length of the LAST answer element, which is
1238
+ // all we need to tell "the page grew / a new answer is streaming".
1239
+ async _chatSignal() {
1240
+ return await this.page
1241
+ .evaluate(() => {
1242
+ const nodes = document.querySelectorAll('[class*="ds-message"], [class*="chat-message"], .ds-markdown').length;
1243
+ let lastLen = 0;
1244
+ const answers = document.querySelectorAll('.ds-markdown');
1245
+ if (answers.length) {
1246
+ const lastEl = answers[answers.length - 1];
1247
+ lastLen = (lastEl.innerText || '').length;
1248
+ }
1249
+ return { nodes, lastLen };
1250
+ })
1251
+ .catch(() => ({ nodes: 0, lastLen: 0 }));
1252
+ }
1253
+ // Cheap first probe for the Continue button. Runs ONE page.evaluate
1254
+ // that scans buttons/role-buttons for a matching label and returns a boolean.
1255
+ // The full Playwright getByRole scan (count + isVisible per element) plus the
1256
+ // DOM fallback are two round-trips; on the COMMON path (no button on screen,
1257
+ // probed every ~300ms tick) this single evaluate replaces them. Only when it
1258
+ // HITS do we run the full scan to get a clickable Locator.
1259
+ async _continueVisibleCheap() {
1260
+ return await this.page
1261
+ .evaluate(() => {
1262
+ const re = /^(?:continue|продолжить|продолжение)(?:\s+(?:think(?:ing)?|reason(?:ing)?|размышлени[ея]|генераци[юя]|ответ))?\s*[.!…]?$/i;
1263
+ const cands = Array.from(document.querySelectorAll('div[role="button"], button'));
1264
+ for (const e of cands) {
1265
+ const label = ((e.textContent || '') +
1266
+ ' ' +
1267
+ (e.getAttribute('aria-label') || ''))
1268
+ .replace(/\s+/g, ' ')
1269
+ .trim();
1270
+ if (!re.test(label))
1271
+ continue;
1272
+ const st = getComputedStyle(e);
1273
+ if (st.display === 'none' || st.visibility === 'hidden')
1274
+ continue;
1275
+ const r = e.getBoundingClientRect();
1276
+ if (r.width > 0 && r.height > 0)
1277
+ return true;
1278
+ }
1279
+ return false;
1280
+ })
1281
+ .catch(() => false);
1282
+ }
1153
1283
  // Is DeepSeek's "Continue" button currently visible? Used to BLOCK the
1154
1284
  // "answer settled" early-return in the finish loop: while Continue is on
1155
1285
  // screen the generation is PAUSED, not finished, so returning the partial
1156
1286
  // text there is exactly the "agent stopped with a Continue button" bug.
1157
1287
  async _continueButtonVisible() {
1288
+ // Cheap first probe: on the common path (no button) this single evaluate
1289
+ // replaces the getByRole count+isVisible scan and the DOM fallback.
1290
+ if (!(await this._continueVisibleCheap()))
1291
+ return false;
1158
1292
  try {
1159
1293
  const loc = this.page.getByRole('button', { name: CONTINUE_NAME_RE });
1160
1294
  const n = await loc.count().catch(() => 0);
@@ -1199,6 +1333,11 @@ export class DeepSeekBrowser {
1199
1333
  // only act on an EXACT short label so a random "Continue" in prose (a button
1200
1334
  // inside a rendered answer, etc.) is never clicked.
1201
1335
  async _clickContinueIfVisible() {
1336
+ // Cheap first probe. The common case is "no button" and this is
1337
+ // called every tick from the start-loop; the single evaluate replaces the
1338
+ // getByRole count+isVisible scan plus the DOM fallback on that path.
1339
+ if (!(await this._continueVisibleCheap()))
1340
+ return false;
1202
1341
  // 1) Playwright's OWN click on the accessible name — this is a TRUSTED
1203
1342
  // event and reliably triggers React handlers. The old in-page
1204
1343
  // dispatchEvent/click often did nothing, which is exactly why the
@@ -1252,6 +1391,10 @@ export class DeepSeekBrowser {
1252
1391
  try {
1253
1392
  await found.click({ timeout: 2000 });
1254
1393
  this._lastContinueAt = Date.now();
1394
+ // The auto-click is otherwise invisible. Emit ONE notice per
1395
+ // click (the continueMinGapMs above already limits how often this
1396
+ // can fire) so the operator sees why the turn resumed.
1397
+ this._notice(this._t('ds.continue_clicked'));
1255
1398
  return true;
1256
1399
  }
1257
1400
  catch { }
@@ -1297,6 +1440,10 @@ export class DeepSeekBrowser {
1297
1440
  }
1298
1441
  return false;
1299
1442
  });
1443
+ if (clicked) {
1444
+ this._lastContinueAt = Date.now();
1445
+ this._notice(this._t('ds.continue_clicked'));
1446
+ }
1300
1447
  return !!clicked;
1301
1448
  }
1302
1449
  catch {
@@ -1370,9 +1517,29 @@ export class DeepSeekBrowser {
1370
1517
  webSearch: await read(/поиск|search/i, this.webSearch),
1371
1518
  };
1372
1519
  }
1520
+ // Stop the current generation. Used by Esc/Ctrl+C. Prefers a TRUSTED
1521
+ // Playwright click on the Stop button (matched by its accessible name, the
1522
+ // same technique as _clickContinueIfVisible): an in-page `e.click()` /
1523
+ // dispatchEvent is IGNORED by DeepSeek's React button, so the old DOM-only
1524
+ // path often did not stop anything. Falls back to a raw DOM scan and finally
1525
+ // to Escape.
1373
1526
  async stopGeneration() {
1374
1527
  this._abort = true;
1375
1528
  this._stopped = true;
1529
+ // 1) Trusted click on the accessible name.
1530
+ try {
1531
+ const loc = this.page.getByRole('button', { name: STOP_NAME_RE });
1532
+ const n = await loc.count().catch(() => 0);
1533
+ for (let i = 0; i < n; i++) {
1534
+ const b = loc.nth(i);
1535
+ if (await b.isVisible().catch(() => false)) {
1536
+ await b.click({ timeout: 2000 });
1537
+ return true;
1538
+ }
1539
+ }
1540
+ }
1541
+ catch { }
1542
+ // 2) Legacy selector path.
1376
1543
  const btn = await this._findVisible(STOP_SELECTORS, 500);
1377
1544
  if (btn) {
1378
1545
  try {
@@ -1381,6 +1548,8 @@ export class DeepSeekBrowser {
1381
1548
  }
1382
1549
  catch { }
1383
1550
  }
1551
+ // 3) Raw DOM scan + the full pointer/mouse sequence (older builds without
1552
+ // a proper role, or a click intercepted by an overlay).
1384
1553
  const clicked = await this.page
1385
1554
  .evaluate(() => {
1386
1555
  const btns = Array.from(document.querySelectorAll('div[role="button"], button'));
@@ -1390,6 +1559,15 @@ export class DeepSeekBrowser {
1390
1559
  continue;
1391
1560
  const svg = b.querySelector('svg');
1392
1561
  if (svg && svg.querySelector('rect')) {
1562
+ for (const type of [
1563
+ 'pointerdown',
1564
+ 'mousedown',
1565
+ 'pointerup',
1566
+ 'mouseup',
1567
+ 'click',
1568
+ ]) {
1569
+ b.dispatchEvent(new MouseEvent(type, { bubbles: true, cancelable: true }));
1570
+ }
1393
1571
  b.click();
1394
1572
  return true;
1395
1573
  }
@@ -1416,116 +1594,139 @@ export class DeepSeekBrowser {
1416
1594
  // hiccup, not a failed send, and it is more likely in the reasoning
1417
1595
  // (deep thinking) mode, where the long THINK phase gets cut off.
1418
1596
  let incompleteRetries = 0;
1419
- while (attempt < this.askRetries) {
1420
- attempt++;
1421
- try {
1422
- return await this._askOnce(prompt, { timeout, agent, attachments });
1423
- }
1424
- catch (e) {
1425
- lastErr = e;
1426
- // Rate limit: DeepSeek did not accept the message. We wait a long time
1427
- // and resend into the SAME chat (without newChat — otherwise the
1428
- // context is lost). The waits don't consume the regular ask() attempts.
1429
- if (e instanceof RateLimitError) {
1430
- rateLimitRetries++;
1431
- if (rateLimitRetries > this.maxRateLimitRetries) {
1432
- this._notice(theme.error(this._t('ds.rate_limit_give_up', {
1433
- attempt: rateLimitRetries,
1597
+ // Mark that OUR code has an ask in flight. A completion/continue body
1598
+ // that arrives with _askInFlight === 0 was the operator's own turn, and
1599
+ // _onResponse uses that to bump _lastSentAt so the next agent send waits
1600
+ // out the throttle.
1601
+ this._askInFlight++;
1602
+ try {
1603
+ // NOTE: `attempt` counts only GENUINE failures. The three retryable
1604
+ // classes below (rate limit / server busy / truncated turn) have their OWN
1605
+ // budgets and must NOT consume an ask() attempt — otherwise a rate limit
1606
+ // at the start burns the whole askRetries budget and ask() gives up early.
1607
+ while (attempt < this.askRetries) {
1608
+ try {
1609
+ return await this._askOnce(prompt, { timeout, agent, attachments });
1610
+ }
1611
+ catch (e) {
1612
+ lastErr = e;
1613
+ // Rate limit: DeepSeek did not accept the message. We wait a long time
1614
+ // and resend into the SAME chat (without newChat — otherwise the
1615
+ // context is lost). The waits don't consume the regular ask() attempts.
1616
+ if (e instanceof RateLimitError) {
1617
+ rateLimitRetries++;
1618
+ if (rateLimitRetries > this.maxRateLimitRetries) {
1619
+ this._notice(theme.error(this._t('ds.rate_limit_give_up', {
1620
+ attempt: rateLimitRetries,
1621
+ min: Math.ceil(this.rateLimitWaitMs / 60000),
1622
+ })));
1623
+ throw e;
1624
+ }
1625
+ this._notice(theme.warn(this._t('ds.rate_limit_wait', {
1434
1626
  min: Math.ceil(this.rateLimitWaitMs / 60000),
1627
+ attempt: rateLimitRetries,
1628
+ max: this.maxRateLimitRetries,
1435
1629
  })));
1436
- throw e;
1630
+ // Interruptible: Esc/Ctrl+C must cancel this long wait too,
1631
+ // otherwise "stop" stays frozen for up to 5 minutes.
1632
+ // The wait is ANIMATED through onSendPause (a live countdown), so the
1633
+ // operator sees the agent is waiting on the rate limit instead of a
1634
+ // frozen spinner. `_lastSentAt` is bumped so the countdown renders.
1635
+ const waitSec = Math.ceil(this.rateLimitWaitMs / 1000);
1636
+ const aborted = await this._waitRateLimit(waitSec);
1637
+ if (aborted)
1638
+ return '(прервано пользователем)';
1639
+ continue;
1437
1640
  }
1438
- this._notice(theme.warn(this._t('ds.rate_limit_wait', {
1439
- min: Math.ceil(this.rateLimitWaitMs / 60000),
1440
- attempt: rateLimitRetries,
1441
- max: this.maxRateLimitRetries,
1442
- })));
1443
- // Interruptible: Esc/Ctrl+C must cancel this long wait too,
1444
- // otherwise "stop" stays frozen for up to 5 minutes.
1445
- // The wait is ANIMATED through onSendPause (a live countdown), so the
1446
- // operator sees the agent is waiting on the rate limit instead of a
1447
- // frozen spinner. `_lastSentAt` is bumped so the countdown renders.
1448
- const waitSec = Math.ceil(this.rateLimitWaitMs / 1000);
1449
- const aborted = await this._waitRateLimit(waitSec);
1450
- if (aborted)
1451
- return '(прервано пользователем)';
1452
- continue;
1453
- }
1454
- // Server busy / overloaded: clear in seconds, so we retry quickly
1455
- // (unlike the rate limit). Without this branch the error fell into the
1456
- // generic ask() retry and the operator saw a bare message with no
1457
- // explanation of what DeepSeek is doing.
1458
- if (e instanceof ServerBusyError) {
1459
- serverBusyRetries++;
1460
- if (serverBusyRetries > this.maxServerBusyRetries) {
1461
- this._notice(theme.error(this._t('ds.server_busy_give_up', {
1641
+ // Server busy / overloaded: clear in seconds, so we retry quickly
1642
+ // (unlike the rate limit). Without this branch the error fell into the
1643
+ // generic ask() retry and the operator saw a bare message with no
1644
+ // explanation of what DeepSeek is doing.
1645
+ if (e instanceof ServerBusyError) {
1646
+ serverBusyRetries++;
1647
+ if (serverBusyRetries > this.maxServerBusyRetries) {
1648
+ this._notice(theme.error(this._t('ds.server_busy_give_up', {
1649
+ attempt: serverBusyRetries,
1650
+ })));
1651
+ throw e;
1652
+ }
1653
+ this._notice(theme.warn(this._t('ds.server_busy_wait', {
1654
+ sec: Math.ceil(this.serverBusyWaitMs / 1000),
1462
1655
  attempt: serverBusyRetries,
1656
+ max: this.maxServerBusyRetries,
1463
1657
  })));
1464
- throw e;
1658
+ const aborted = await this._sleepInterruptible(this.serverBusyWaitMs);
1659
+ if (aborted)
1660
+ return '(прервано пользователем)';
1661
+ continue;
1465
1662
  }
1466
- this._notice(theme.warn(this._t('ds.server_busy_wait', {
1467
- sec: Math.ceil(this.serverBusyWaitMs / 1000),
1468
- attempt: serverBusyRetries,
1469
- max: this.maxServerBusyRetries,
1470
- })));
1471
- const aborted = await this._sleepInterruptible(this.serverBusyWaitMs);
1472
- if (aborted)
1473
- return '(прервано пользователем)';
1474
- continue;
1475
- }
1476
- // The turn was TRUNCATED by the server (`generation_err`, INCOMPLETE):
1477
- // DeepSeek answered with a partial (often empty) answer and shows a
1478
- // "Continue" button. In reasoning mode this is much more likely. We
1479
- // resend the SAME prompt into the chat (that is what the Continue
1480
- // button does) instead of waiting for an answer that will never come.
1481
- if (e instanceof GenerationIncompleteError) {
1482
- incompleteRetries++;
1483
- if (incompleteRetries > this.maxIncompleteRetries) {
1484
- this._notice(theme.error(this._t('ds.incomplete_give_up', {
1663
+ // The turn was TRUNCATED by the server (`generation_err`, INCOMPLETE):
1664
+ // DeepSeek answered with a partial (often empty) answer and shows a
1665
+ // "Continue" button. In reasoning mode this is much more likely. We
1666
+ // resend the SAME prompt into the chat (that is what the Continue
1667
+ // button does) instead of waiting for an answer that will never come.
1668
+ if (e instanceof GenerationIncompleteError) {
1669
+ incompleteRetries++;
1670
+ if (incompleteRetries > this.maxIncompleteRetries) {
1671
+ this._notice(theme.error(this._t('ds.incomplete_give_up', {
1672
+ attempt: incompleteRetries,
1673
+ })));
1674
+ throw e;
1675
+ }
1676
+ this._notice(theme.warn(this._t('ds.incomplete_retry', {
1485
1677
  attempt: incompleteRetries,
1678
+ max: this.maxIncompleteRetries,
1486
1679
  })));
1487
- throw e;
1680
+ const aborted = await this._sleepInterruptible(this.incompleteWaitMs);
1681
+ if (aborted)
1682
+ return '(прервано пользователем)';
1683
+ continue;
1488
1684
  }
1489
- this._notice(theme.warn(this._t('ds.incomplete_retry', {
1490
- attempt: incompleteRetries,
1491
- max: this.maxIncompleteRetries,
1685
+ this._notice(theme.warn(this._t('ds.ask_retry', {
1686
+ attempt: attempt + 1,
1687
+ max: this.askRetries,
1688
+ error: e.message,
1492
1689
  })));
1493
- const aborted = await this._sleepInterruptible(this.incompleteWaitMs);
1494
- if (aborted)
1495
- return '(прервано пользователем)';
1496
- continue;
1497
- }
1498
- this._notice(theme.warn(this._t('ds.ask_retry', {
1499
- attempt,
1500
- max: this.askRetries,
1501
- error: e.message,
1502
- })));
1503
- // Only a genuinely LOST page justifies a restart. "Target page,
1504
- // context or browser has been closed" means the page is gone for good;
1505
- // a bare "browser" mention (e.g. a selector error containing the word)
1506
- // must NOT tear the window down — that was the "a new window opened"
1507
- // surprise during a resumed chat. We re-open the SAME context instead.
1508
- if (/target page, context or browser has been closed|page.*has been closed/i.test(e.message)) {
1509
- this._notice(theme.warn(this._t('ds.ask_restart_browser')));
1510
- try {
1511
- await this.restart();
1512
- await this.waitForLogin();
1690
+ // Only a genuinely LOST page justifies a restart. "Target page,
1691
+ // context or browser has been closed" means the page is gone for good;
1692
+ // a bare "browser" mention (e.g. a selector error containing the word)
1693
+ // must NOT tear the window down — that was the "a new window opened"
1694
+ // surprise during a resumed chat. We re-open the SAME context instead.
1695
+ if (/target page, context or browser has been closed|page.*has been closed/i.test(e.message)) {
1696
+ this._notice(theme.warn(this._t('ds.ask_restart_browser')));
1697
+ try {
1698
+ await this.restart();
1699
+ await this.waitForLogin();
1700
+ }
1701
+ catch (re) {
1702
+ this._notice(theme.warn(this._t('ds.ask_restart_failed', {
1703
+ error: re.message,
1704
+ })));
1705
+ }
1513
1706
  }
1514
- catch (re) {
1515
- this._notice(theme.warn(this._t('ds.ask_restart_failed', {
1516
- error: re.message,
1517
- })));
1707
+ if (attempt < this.askRetries - 1) {
1708
+ await new Promise((r) => setTimeout(r, 2000 * (attempt + 1)));
1518
1709
  }
1519
- }
1520
- if (attempt < this.askRetries) {
1521
- await new Promise((r) => setTimeout(r, 2000 * attempt));
1710
+ attempt++;
1522
1711
  }
1523
1712
  }
1713
+ throw new Error(this._t('ds.ask_failed', {
1714
+ max: this.askRetries,
1715
+ error: lastErr?.message || '',
1716
+ }));
1524
1717
  }
1525
- throw new Error(this._t('ds.ask_failed', {
1526
- max: this.askRetries,
1527
- error: lastErr?.message || '',
1528
- }));
1718
+ finally {
1719
+ this._askInFlight = Math.max(0, this._askInFlight - 1);
1720
+ }
1721
+ }
1722
+ // Cancel an in-flight ask() whose caller (the agent-loop watchdog) already
1723
+ // gave up on it. WITHOUT this the abandoned ask() kept running — it could
1724
+ // still be inside `_waitRateLimit` (up to 300s) or the finish loop — while
1725
+ // the NEXT iteration started a second ask() against the same page. Two asks
1726
+ // then both send / both click Continue: a duplicate message and possible
1727
+ // rate-limit hammering. The flag is reset at the start of every _askOnce.
1728
+ cancelPendingAsk() {
1729
+ this._askCancelled = true;
1529
1730
  }
1530
1731
  async _setInputText(input, text) {
1531
1732
  const tag = await input.evaluate((el) => el.tagName.toLowerCase());
@@ -1631,7 +1832,8 @@ export class DeepSeekBrowser {
1631
1832
  btn.click({ timeout: 1500 }),
1632
1833
  ]);
1633
1834
  await chooser.setFiles(bufPayload);
1634
- await this.page.waitForTimeout(2500);
1835
+ // Wait for the upload preview instead of a fixed 2.5s sleep.
1836
+ await this._awaitUploadPreview(files.map((f) => f.name), 2500);
1635
1837
  return;
1636
1838
  }
1637
1839
  catch { }
@@ -1650,7 +1852,32 @@ export class DeepSeekBrowser {
1650
1852
  return;
1651
1853
  }
1652
1854
  // Wait for the upload to finish (the attach preview to appear).
1653
- await this.page.waitForTimeout(2000);
1855
+ await this._awaitUploadPreview(files.map((f) => f.name), 2000);
1856
+ }
1857
+ // Wait for the upload preview (the attached-file chip above the
1858
+ // input) to appear, instead of a fixed 2.5s/2s sleep. The signal is the
1859
+ // FILE NAME showing up on the page: we attach files BEFORE typing the text,
1860
+ // so the name cannot appear there for any other reason. The old fixed value
1861
+ // stays as the TIMEOUT CEILING, so a build where the name is not detected
1862
+ // behaves exactly as before.
1863
+ async _awaitUploadPreview(names, timeoutMs) {
1864
+ const clean = names.map((n) => String(n || '').trim()).filter(Boolean);
1865
+ if (!clean.length) {
1866
+ await this.page.waitForTimeout(timeoutMs);
1867
+ return;
1868
+ }
1869
+ const deadline = Date.now() + timeoutMs;
1870
+ while (Date.now() < deadline) {
1871
+ for (const name of clean) {
1872
+ const n = await this.page
1873
+ .getByText(name, { exact: false })
1874
+ .count()
1875
+ .catch(() => 0);
1876
+ if (n > 0)
1877
+ return;
1878
+ }
1879
+ await this.page.waitForTimeout(150);
1880
+ }
1654
1881
  }
1655
1882
  // Wait out a rate-limit pause, showing an animated countdown through
1656
1883
  // onSendPause so the operator sees the agent is alive (not frozen). Returns
@@ -1724,6 +1951,10 @@ export class DeepSeekBrowser {
1724
1951
  while (left > 0) {
1725
1952
  if (this._abort)
1726
1953
  return true;
1954
+ // The watchdog abandoned this ask(): stop sleeping so the next ask()
1955
+ // can start without two asks competing for the same page.
1956
+ if (this._askCancelled)
1957
+ return true;
1727
1958
  const chunk = Math.min(step, left);
1728
1959
  await this.page.waitForTimeout(chunk);
1729
1960
  left -= chunk;
@@ -1755,6 +1986,8 @@ export class DeepSeekBrowser {
1755
1986
  // set while a previous send was waiting in the pause. If it is set, the
1756
1987
  // user asked to stop and we must not start a new generation at all.
1757
1988
  this._abort = false;
1989
+ // A fresh send clears a stale cancellation from a previous, abandoned ask.
1990
+ this._askCancelled = false;
1758
1991
  if (this._stopped)
1759
1992
  return '(прервано пользователем)';
1760
1993
  const input = await this._findVisible(INPUT_SELECTORS, 10_000);
@@ -1792,6 +2025,7 @@ export class DeepSeekBrowser {
1792
2025
  this._netCapture = '';
1793
2026
  this._netCaptureAt = 0;
1794
2027
  this._netNoAnswer = false;
2028
+ this._netRateLimited = false;
1795
2029
  this._netBody = '';
1796
2030
  // Align the DeepSeek chat toggles (deep thinking / web search) with the
1797
2031
  // configured state BEFORE typing. Doing it here (after the send-pause) it
@@ -1803,7 +2037,9 @@ export class DeepSeekBrowser {
1803
2037
  await this._attachFiles(attachments);
1804
2038
  }
1805
2039
  await this._setInputText(input, prompt);
1806
- await this.page.waitForTimeout(50);
2040
+ // Send immediately: the input is already verified by _setInputText, and a
2041
+ // clickable send button normally appears right away. The old code slept a
2042
+ // fixed 50ms here, which only added latency (the button does not need it).
1807
2043
  let sent = false;
1808
2044
  for (const sel of SEND_SELECTORS) {
1809
2045
  const btn = this.page.locator(sel).last();
@@ -1824,12 +2060,12 @@ export class DeepSeekBrowser {
1824
2060
  this._lastSentAt = Date.now();
1825
2061
  this._askDebug('SENT at=' + this._lastSentAt);
1826
2062
  // Wait for the start: either Stop appeared, or the answer text changed,
1827
- // or the total amount of text on the page grew. In parallel we catch
1828
- // the rate-limit toast (only toasts, not the whole body).
2063
+ // or the page grew (a new answer node / longer last answer). In parallel we
2064
+ // catch the rate-limit toast (only toasts, not the whole body).
1829
2065
  const startDeadline = Date.now() + 30_000;
1830
- const startBodyLen = await this.page
1831
- .evaluate(() => document.body.innerText.length)
1832
- .catch(() => 0);
2066
+ // A CHEAP growth signal (message-node count + last-answer length)
2067
+ // instead of document.body.innerText.length, which walked the whole page.
2068
+ const startSignal = await this._chatSignal();
1833
2069
  let started = false;
1834
2070
  // A stale/echo answer (the model repeats the previous text, or the answer
1835
2071
  // legitimately equals it) does NOT change `cur`. In that case the old loop
@@ -1840,7 +2076,7 @@ export class DeepSeekBrowser {
1840
2076
  let settledTicks = 0;
1841
2077
  let lastStartCur = '';
1842
2078
  while (Date.now() < startDeadline) {
1843
- if (this._abort)
2079
+ if (this._abort || this._askCancelled)
1844
2080
  return '(прервано пользователем)';
1845
2081
  const pageText = await this._readPageText();
1846
2082
  if (isRateLimitText(pageText)) {
@@ -1849,6 +2085,12 @@ export class DeepSeekBrowser {
1849
2085
  if (isServerBusyText(pageText)) {
1850
2086
  throw new ServerBusyError(pageText.slice(0, 300));
1851
2087
  }
2088
+ // The rate limit may be visible ONLY in the SSE body (the DOM toast is
2089
+ // short-lived and easy to miss). The old code threw ds.send_no_new_answer
2090
+ // at the deadline while the chat plainly showed "Messages too frequent".
2091
+ if (this._netRateLimited && this._netCaptureAt >= this._lastSentAt) {
2092
+ throw new RateLimitError(this._netBody.slice(0, 300) || 'rate limit (from network)');
2093
+ }
1852
2094
  // The turn was truncated by the server: the SSE body carries
1853
2095
  // finish_reason=generation_err / quasi_status=INCOMPLETE and the UI
1854
2096
  // shows "Continue". Retry instead of waiting out the whole timeout.
@@ -1891,9 +2133,9 @@ export class DeepSeekBrowser {
1891
2133
  this._askDebug('CLICKED Continue (start-loop)');
1892
2134
  }
1893
2135
  const cur = await this._readLastAnswerTextCleanDom().catch(() => '');
1894
- const bodyLen = await this.page
1895
- .evaluate(() => document.body.innerText.length)
1896
- .catch(() => 0);
2136
+ // Cheap growth signal instead of the whole-body length.
2137
+ const sig = await this._chatSignal();
2138
+ const grew = sig.nodes > startSignal.nodes || sig.lastLen > startSignal.lastLen;
1897
2139
  // A NEW answer is the only reliable sign that the message was actually
1898
2140
  // sent: the text on the page must differ from what was there before the
1899
2141
  // send, OR the page must have grown. The Stop-button heuristic
@@ -1908,14 +2150,14 @@ export class DeepSeekBrowser {
1908
2150
  // 'changed' can stay false for a while — without this check ask() used
1909
2151
  // to hang until the full timeout and the agent appeared to "stop".
1910
2152
  const netStarted = !!this._netCapture && this._netCaptureAt >= this._lastSentAt;
1911
- if (changed || netStarted || bodyLen > startBodyLen) {
2153
+ if (changed || netStarted || grew) {
1912
2154
  started = true;
1913
2155
  this._askDebug('STARTED changed=' +
1914
2156
  changed +
1915
2157
  ' netStarted=' +
1916
2158
  netStarted +
1917
- ' bodyGrew=' +
1918
- (bodyLen > startBodyLen));
2159
+ ' grew=' +
2160
+ grew);
1919
2161
  break;
1920
2162
  }
1921
2163
  // Fallback for an echo: the text equals beforeText, so it is the OLD
@@ -1945,8 +2187,8 @@ export class DeepSeekBrowser {
1945
2187
  changed +
1946
2188
  ' netStarted=' +
1947
2189
  netStarted +
1948
- ' bodyLen=' +
1949
- bodyLen +
2190
+ ' grew=' +
2191
+ grew +
1950
2192
  ' settled=' +
1951
2193
  settledTicks +
1952
2194
  ' generating=' +
@@ -1999,13 +2241,12 @@ export class DeepSeekBrowser {
1999
2241
  this._lastSentAt = Date.now();
2000
2242
  const retryDeadline = Date.now() + 20_000;
2001
2243
  while (Date.now() < retryDeadline) {
2002
- if (this._abort)
2244
+ if (this._abort || this._askCancelled)
2003
2245
  return '(прервано пользователем)';
2004
2246
  const cur2 = await this._readLastAnswerTextCleanDom().catch(() => '');
2005
2247
  const net2 = !!this._netCapture && this._netCaptureAt >= this._lastSentAt;
2006
- const grew2 = (await this.page
2007
- .evaluate(() => document.body.innerText.length)
2008
- .catch(() => 0)) > startBodyLen;
2248
+ const sig2 = await this._chatSignal();
2249
+ const grew2 = sig2.nodes > startSignal.nodes || sig2.lastLen > startSignal.lastLen;
2009
2250
  if (net2 ||
2010
2251
  grew2 ||
2011
2252
  (!!cur2 && normText(cur2) !== normText(beforeText))) {
@@ -2027,7 +2268,7 @@ export class DeepSeekBrowser {
2027
2268
  let last = '';
2028
2269
  let stable = 0;
2029
2270
  while (Date.now() < deadline) {
2030
- if (this._abort) {
2271
+ if (this._abort || this._askCancelled) {
2031
2272
  return last || '(прервано пользователем)';
2032
2273
  }
2033
2274
  // Rate-limit / server-busy toast. The old code only read the toasts
@@ -2046,6 +2287,12 @@ export class DeepSeekBrowser {
2046
2287
  throw new ServerBusyError(pageText.slice(0, 300));
2047
2288
  }
2048
2289
  }
2290
+ // The rate limit may exist only in the SSE body (the DOM toast is
2291
+ // short-lived). Without this the loop waited out the deadline and threw
2292
+ // ds.send_no_new_answer while the chat showed "Messages too frequent".
2293
+ if (this._netRateLimited && this._netCaptureAt >= this._lastSentAt) {
2294
+ throw new RateLimitError(this._netBody.slice(0, 300) || 'rate limit (from network)');
2295
+ }
2049
2296
  // The server truncated the turn (generation_err / INCOMPLETE) or ended
2050
2297
  // it with only reasoning. PREFER the Continue button: it RESUMES the
2051
2298
  // same turn (`chat/continue`). Resending the prompt creates a NEW turn
@@ -2120,7 +2367,11 @@ export class DeepSeekBrowser {
2120
2367
  }
2121
2368
  stable = 0;
2122
2369
  }
2123
- else if (isNew || !(await this._isGenerating())) {
2370
+ else if (!(await this._isGenerating())) {
2371
+ // Only return when the generation has REALLY ended — no Stop
2372
+ // button AND no Continue button (checked above). The old condition
2373
+ // `isNew || !_isGenerating()` returned a still-streaming answer
2374
+ // merely because it looked "new", cutting a long answer short.
2124
2375
  this._askDebug('RETURN stable curLen=' + cur.length);
2125
2376
  return cur;
2126
2377
  }
@@ -2249,6 +2500,8 @@ export class DeepSeekBrowser {
2249
2500
  }, limit);
2250
2501
  }
2251
2502
  async openChat(id) {
2503
+ // Click the chat link and VERIFY the URL actually points at the
2504
+ // requested chat before returning (polling, no fixed 1.5s sleep).
2252
2505
  const candidates = [`a[href$="/chat/s/${id}"]`, `a[href*="${id}"]`];
2253
2506
  for (const sel of candidates) {
2254
2507
  try {
@@ -2256,8 +2509,14 @@ export class DeepSeekBrowser {
2256
2509
  if ((await loc.count()) === 0)
2257
2510
  continue;
2258
2511
  await loc.click({ timeout: 3000 });
2259
- await this.page.waitForTimeout(1500);
2260
- return true;
2512
+ const deadline = Date.now() + 8000;
2513
+ while (Date.now() < deadline) {
2514
+ if ((await this.getCurrentChatId()) === id) {
2515
+ await this._awaitInput(2000);
2516
+ return true;
2517
+ }
2518
+ await this.page.waitForTimeout(100);
2519
+ }
2261
2520
  }
2262
2521
  catch { }
2263
2522
  }
@@ -2266,8 +2525,18 @@ export class DeepSeekBrowser {
2266
2525
  waitUntil: 'domcontentloaded',
2267
2526
  timeout: 20_000,
2268
2527
  });
2269
- await this.page.waitForTimeout(1500);
2270
- return true;
2528
+ // Verify the navigation really landed on the requested chat; otherwise
2529
+ // report the failure instead of sending the next prompt into the wrong
2530
+ // chat silently.
2531
+ const deadline = Date.now() + 10_000;
2532
+ while (Date.now() < deadline) {
2533
+ if ((await this.getCurrentChatId()) === id) {
2534
+ await this._awaitInput(2000);
2535
+ return true;
2536
+ }
2537
+ await this.page.waitForTimeout(100);
2538
+ }
2539
+ throw new Error('chat id did not change to ' + id);
2271
2540
  }
2272
2541
  catch (e) {
2273
2542
  throw new Error(this._t('ds.open_chat_failed', { id, error: e.message }));
@@ -2461,6 +2730,14 @@ export class DeepSeekBrowser {
2461
2730
  break;
2462
2731
  }
2463
2732
  }
2733
+ // De-duplicate by node identity and drop a block that is NESTED
2734
+ // inside another block. Some builds match both an outer message
2735
+ // container and an inner one; without this the restored dialogue
2736
+ // printed each turn twice. We keep only the OUTERMOST blocks.
2737
+ if (blocks.length > 1) {
2738
+ const unique = Array.from(new Set(blocks));
2739
+ blocks = unique.filter((b) => !unique.some((other) => other !== b && other.contains(b)));
2740
+ }
2464
2741
  const out = [];
2465
2742
  if (blocks.length) {
2466
2743
  for (const b of blocks) {