zames_pro 2.29.15 → 2.31.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
@@ -170,7 +174,17 @@ const STOP_SELECTORS = [
170
174
  // The button is found by Playwright's accessible name (getByRole) with an
171
175
  // EXACT match, falling back to a raw DOM scan. The class names change between
172
176
  // builds, so we do not rely on them.
173
- const CONTINUE_NAMES = ['Continue', 'Продолжить', 'Продолжение'];
177
+ // Reasoning (Deep thinking) mode uses a LONGER label: the server pauses the
178
+ // THINK phase and offers «Продолжить размышление» / «Continue thinking», NOT a
179
+ // bare «Continue». The old exact list missed those, so with thinking ON the
180
+ // button was never found — neither for the auto-click NOR for the "is the turn
181
+ // paused?" guard — and the operator had to press it by hand.
182
+ //
183
+ // We match the accessible name with a REGEX: a bare continue word OR the same
184
+ // word followed by a short reasoning/answer suffix. It is anchored and
185
+ // length-limited, so a "Continue" inside rendered prose is never clicked (the
186
+ // old non-exact `getByRole` matched any button CONTAINING the word).
187
+ export const CONTINUE_NAME_RE = /^(?:continue|продолжить|продолжение)(?:\s+(?:think(?:ing)?|reason(?:ing)?|размышлени[ея]|генераци[юя]|ответ))?\s*[.!…]?$/i;
174
188
  // DeepSeek UI service statuses that are NOT the model's answer.
175
189
  // Otherwise the agent takes a status (Reading...) for an answer and breaks parsing.
176
190
  const STATUS_RE = /^(reading|thinking|searching|analyzing|generating|stop|остановить|читаю|думаю|поиск|анализ)[\s.…]*$/i;
@@ -297,6 +311,9 @@ export class DeepSeekBrowser {
297
311
  // Click DeepSeek's "Continue" button automatically when the reasoning phase
298
312
  // is paused by the server (deep thinking mode).
299
313
  autoContinue;
314
+ // Min gap between Continue clicks in thinking mode (ms). Smaller than
315
+ // minSendIntervalMs so the resume does not feel sluggish.
316
+ continueMinGapMs;
300
317
  // Desired state of the DeepSeek chat toggles, applied before each send.
301
318
  // deepThinking: the "Deep thinking" toggle (reasoning; the reasoning text
302
319
  // is never read/shown). webSearch: the "Smart search" toggle.
@@ -319,6 +336,17 @@ export class DeepSeekBrowser {
319
336
  // rate limit. The click path now respects `minSendIntervalMs` like a send.
320
337
  _lastContinueAt;
321
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;
322
350
  // The user pressed Esc/Ctrl+C — a "stop" for the WHOLE current batch of
323
351
  // tasks (including the queue). Unlike _abort (reset on every send), this
324
352
  // flag lives until a new task is explicitly started from the prompt.
@@ -331,6 +359,13 @@ export class DeepSeekBrowser {
331
359
  // (only reasoning). The DOM keeps showing reasoning/old text, so the loop
332
360
  // must not wait it out — it clicks Continue / resends. Reset before a send.
333
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;
334
369
  _netChatId;
335
370
  // The latest CONTEXT size (in tokens) DeepSeek reported in the current
336
371
  // chat. It is the `accumulated_token_usage` counter taken from the SSE
@@ -348,8 +383,6 @@ export class DeepSeekBrowser {
348
383
  // Last error from fetchChatMessages ('' on success). Surfaced under --debug
349
384
  // so a missing restored history can be diagnosed.
350
385
  _lastHistoryError;
351
- _netSniff;
352
- _netSniffLimit;
353
386
  // The RAW SSE body of the LAST answer response. Unlike `_netCapture`, this
354
387
  // is kept even when the body carries no RESPONSE text (a truncated turn:
355
388
  // generation_err / INCOMPLETE, or FINISHED-with-only-reasoning). The
@@ -378,7 +411,7 @@ export class DeepSeekBrowser {
378
411
  // must be stripped (DeepSeek's CDN answers 403 to it); computed in the
379
412
  // constructor, overridable by an explicit UA in the options/config.
380
413
  userAgent;
381
- constructor({ headless = false, debug = false, channel = 'chrome', answerTimeoutMs = 180000, askRetries = 3, stabilityChecks = 2, stabilityDelayMs = 400, minSendIntervalMs = 15000, thinkingExtraMs = 2000, rateLimitWaitMs = 300000, maxRateLimitRetries = 6, maxServerBusyRetries = 5, serverBusyWaitMs = 3000, maxIncompleteRetries = 4, incompleteWaitMs = 2000, autoContinue = true, deepThinking = false, webSearch = true, auth, locale, onAuthSave, userAgent, } = {}) {
414
+ constructor({ headless = false, debug = false, channel = 'chrome', answerTimeoutMs = 180000, askRetries = 3, stabilityChecks = 2, stabilityDelayMs = 400, minSendIntervalMs = 15000, thinkingExtraMs = 2000, rateLimitWaitMs = 300000, maxRateLimitRetries = 6, maxServerBusyRetries = 5, serverBusyWaitMs = 3000, maxIncompleteRetries = 4, incompleteWaitMs = 2000, autoContinue = true, continueMinGapMs = 1500, deepThinking = false, webSearch = true, auth, locale, onAuthSave, userAgent, } = {}) {
382
415
  this.headless = headless;
383
416
  this.debug = debug;
384
417
  this.channel = channel;
@@ -395,6 +428,7 @@ export class DeepSeekBrowser {
395
428
  this.maxIncompleteRetries = maxIncompleteRetries;
396
429
  this.incompleteWaitMs = incompleteWaitMs;
397
430
  this.autoContinue = autoContinue;
431
+ this.continueMinGapMs = continueMinGapMs;
398
432
  this.deepThinking = deepThinking;
399
433
  this.webSearch = webSearch;
400
434
  this.auth = {
@@ -407,18 +441,19 @@ export class DeepSeekBrowser {
407
441
  this._lastSentAt = 0;
408
442
  this._lastContinueAt = 0;
409
443
  this._abort = false;
444
+ this._askCancelled = false;
445
+ this._askInFlight = 0;
410
446
  this._stopped = false;
411
447
  this._netCapture = '';
412
448
  this._netCaptureAt = 0;
413
449
  this._netNoAnswer = false;
450
+ this._netRateLimited = false;
414
451
  this._netChatId = null;
415
452
  this._lastTokenUsage = null;
416
453
  this._toggles = { deepThinking, webSearch };
417
454
  this._apiAuth = '';
418
455
  this._apiPow = '';
419
456
  this._lastHistoryError = '';
420
- this._netSniff = [];
421
- this._netSniffLimit = 5;
422
457
  this._netBody = '';
423
458
  this._netHookInstalled = false;
424
459
  this.onSendStart = null;
@@ -576,9 +611,6 @@ export class DeepSeekBrowser {
576
611
  const body = await resp.text().catch(() => '');
577
612
  if (!body)
578
613
  return;
579
- this._netSniff.push({ url, contentType: ct, body });
580
- if (this._netSniff.length > this._netSniffLimit)
581
- this._netSniff.shift();
582
614
  void dumpNetBody(url, body);
583
615
  // Context size (tokens) reported by DeepSeek for this answer. Kept
584
616
  // even when extractAnswer() returns nothing (a history_messages
@@ -586,8 +618,13 @@ export class DeepSeekBrowser {
586
618
  const usage = extractTokenUsage(body);
587
619
  if (usage !== null)
588
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);
589
626
  const extracted = extractAnswer(body);
590
- if (extracted) {
627
+ if (extracted && isAnswerUrl) {
591
628
  this._netCapture = extracted;
592
629
  this._netNoAnswer = false;
593
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}/);
@@ -598,7 +635,15 @@ export class DeepSeekBrowser {
598
635
  // Store the RAW body for the CURRENT answer (completion/continue) so the
599
636
  // truncated/no-answer detectors can inspect it even when there is no
600
637
  // RESPONSE text (extractAnswer() returns '' for those bodies).
601
- 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
+ }
602
647
  this._netBody = body;
603
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}/);
604
649
  if (cid)
@@ -618,6 +663,15 @@ export class DeepSeekBrowser {
618
663
  if (isGenerationIncompleteText(body)) {
619
664
  this._netCapture = '';
620
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
+ }
621
675
  this._netCaptureAt = Date.now();
622
676
  }
623
677
  }
@@ -971,18 +1025,61 @@ export class DeepSeekBrowser {
971
1025
  return false;
972
1026
  }
973
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.
974
1052
  async newChat() {
1053
+ const before = await this.getCurrentChatId();
975
1054
  const newChatBtn = this.page
976
1055
  .locator('button, a')
977
1056
  .filter({ hasText: /new chat|новый чат|новый диалог/i })
978
1057
  .first();
1058
+ let clicked = false;
979
1059
  try {
980
1060
  await newChatBtn.click({ timeout: 2000 });
981
- await this.page.waitForTimeout(1000);
1061
+ clicked = true;
982
1062
  }
983
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) {
984
1081
  await this.page.goto(CHAT_URL, { waitUntil: 'domcontentloaded' });
985
- await this.page.waitForTimeout(1000);
1082
+ await this._awaitInput(5000);
986
1083
  }
987
1084
  }
988
1085
  async _findVisible(selectors, timeout = 1000) {
@@ -1054,9 +1151,12 @@ export class DeepSeekBrowser {
1054
1151
  return out.replace(new RegExp(NL + '{3,}', 'g'), NL + NL).trim();
1055
1152
  }, ANSWER_SELECTORS);
1056
1153
  }
1057
- // We read ONLY visible toasts/notifications/errors, not the whole page
1058
- // text. Otherwise we catch "try again later" from service/hidden blocks
1059
- // 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.
1060
1160
  async _readPageText() {
1061
1161
  return await this.page
1062
1162
  .evaluate(() => {
@@ -1064,8 +1164,6 @@ export class DeepSeekBrowser {
1064
1164
  '[role="alert"]',
1065
1165
  '[class*="toast" i]',
1066
1166
  '[class*="notification" i]',
1067
- '[class*="alert" i]',
1068
- '[class*="error" i]',
1069
1167
  ];
1070
1168
  let out = '';
1071
1169
  for (const s of sels) {
@@ -1081,13 +1179,9 @@ export class DeepSeekBrowser {
1081
1179
  })
1082
1180
  .catch(() => '');
1083
1181
  }
1084
- async _readLastAnswerTextClean() {
1085
- const raw = await this._readLastAnswerText().catch(() => '');
1086
- return this._cleanAnswer(raw);
1087
- }
1088
- // The same "clean" filter as _readLastAnswerTextClean, but read from the
1089
- // DOM only. Used by _askOnce for the before/after comparison: the network
1090
- // 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).
1091
1185
  async _readLastAnswerTextCleanDom() {
1092
1186
  const raw = await this._readLastAnswerTextDom().catch(() => '');
1093
1187
  return this._cleanAnswer(raw);
@@ -1136,17 +1230,73 @@ export class DeepSeekBrowser {
1136
1230
  async _isGenerating() {
1137
1231
  return await this._stopButtonVisible();
1138
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
+ }
1139
1283
  // Is DeepSeek's "Continue" button currently visible? Used to BLOCK the
1140
1284
  // "answer settled" early-return in the finish loop: while Continue is on
1141
1285
  // screen the generation is PAUSED, not finished, so returning the partial
1142
1286
  // text there is exactly the "agent stopped with a Continue button" bug.
1143
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;
1144
1292
  try {
1145
- for (const name of CONTINUE_NAMES) {
1146
- const loc = this.page.getByRole('button', { name }).first();
1147
- if ((await loc.count().catch(() => 0)) === 0)
1148
- continue;
1149
- if (await loc.isVisible().catch(() => false))
1293
+ const loc = this.page.getByRole('button', { name: CONTINUE_NAME_RE });
1294
+ const n = await loc.count().catch(() => 0);
1295
+ for (let i = 0; i < n; i++) {
1296
+ if (await loc
1297
+ .nth(i)
1298
+ .isVisible()
1299
+ .catch(() => false))
1150
1300
  return true;
1151
1301
  }
1152
1302
  }
@@ -1158,7 +1308,7 @@ export class DeepSeekBrowser {
1158
1308
  const labelOf = (b) => ((b.textContent || '') + ' ' + (b.getAttribute('aria-label') || ''))
1159
1309
  .replace(/\s+/g, ' ')
1160
1310
  .trim();
1161
- const isExact = (t) => /^(continue|продолжить|продолжение)\s*[.!…]?$/i.test(t);
1311
+ const isExact = (t) => /^(?:continue|продолжить|продолжение)(?:\s+(?:think(?:ing)?|reason(?:ing)?|размышлени[ея]|генераци[юя]|ответ))?\s*[.!…]?$/i.test(t);
1162
1312
  const cands = Array.from(document.querySelectorAll('div[role="button"], button'));
1163
1313
  for (const e of cands) {
1164
1314
  if (!isExact(labelOf(e)))
@@ -1183,6 +1333,11 @@ export class DeepSeekBrowser {
1183
1333
  // only act on an EXACT short label so a random "Continue" in prose (a button
1184
1334
  // inside a rendered answer, etc.) is never clicked.
1185
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;
1186
1341
  // 1) Playwright's OWN click on the accessible name — this is a TRUSTED
1187
1342
  // event and reliably triggers React handlers. The old in-page
1188
1343
  // dispatchEvent/click often did nothing, which is exactly why the
@@ -1192,26 +1347,34 @@ export class DeepSeekBrowser {
1192
1347
  // probing this every tick must stay cheap, and there is no point
1193
1348
  // throttling a click that will not happen.
1194
1349
  let found = null;
1195
- for (const name of CONTINUE_NAMES) {
1196
- try {
1197
- const loc = this.page.getByRole('button', { name }).first();
1198
- if ((await loc.count().catch(() => 0)) === 0)
1199
- continue;
1200
- if (!(await loc.isVisible().catch(() => false)))
1201
- continue;
1202
- found = loc;
1203
- break;
1350
+ try {
1351
+ const loc = this.page.getByRole('button', { name: CONTINUE_NAME_RE });
1352
+ const n = await loc.count().catch(() => 0);
1353
+ for (let i = 0; i < n; i++) {
1354
+ if (await loc
1355
+ .nth(i)
1356
+ .isVisible()
1357
+ .catch(() => false)) {
1358
+ found = loc.nth(i);
1359
+ break;
1360
+ }
1204
1361
  }
1205
- catch { }
1206
1362
  }
1363
+ catch { }
1207
1364
  if (found) {
1208
- // One Continue click per effective send interval: a `chat/continue`
1209
- // request hits the rate limit like a send, and a run that keeps getting
1365
+ // One Continue click per `continueMinGapMs`: a `chat/continue` request
1366
+ // hits the rate limit like a send, and a run that keeps getting
1210
1367
  // truncated could otherwise fire clicks back-to-back. The gap counts
1211
1368
  // from the LATEST of the last send and the last Continue click.
1369
+ //
1370
+ // NOTE: the gap is DELIBERATELY smaller than `sendIntervalMs` (15s): the
1371
+ // reasoning pause shows the button immediately, and waiting out the full
1372
+ // send interval made every resume feel sluggish (the operator saw a
1373
+ // frozen turn for ~15s before the click). 1.5s is enough to avoid
1374
+ // hammering `chat/continue` while resuming almost immediately.
1212
1375
  const since = Math.max(this._lastSentAt, this._lastContinueAt);
1213
1376
  if (since) {
1214
- const gap = this.sendIntervalMs() - (Date.now() - since);
1377
+ const gap = this.continueMinGapMs - (Date.now() - since);
1215
1378
  if (gap > 0) {
1216
1379
  const aborted = await this._sleepInterruptible(gap, (leftMs) => {
1217
1380
  if (this.onSendPause) {
@@ -1228,6 +1391,10 @@ export class DeepSeekBrowser {
1228
1391
  try {
1229
1392
  await found.click({ timeout: 2000 });
1230
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'));
1231
1398
  return true;
1232
1399
  }
1233
1400
  catch { }
@@ -1239,7 +1406,7 @@ export class DeepSeekBrowser {
1239
1406
  const labelOf = (b) => ((b.textContent || '') + ' ' + (b.getAttribute('aria-label') || ''))
1240
1407
  .replace(/\s+/g, ' ')
1241
1408
  .trim();
1242
- const isExact = (t) => /^(continue|продолжить|продолжение)\s*[.!…]?$/i.test(t);
1409
+ const isExact = (t) => /^(?:continue|продолжить|продолжение)(?:\s+(?:think(?:ing)?|reason(?:ing)?|размышлени[ея]|генераци[юя]|ответ))?\s*[.!…]?$/i.test(t);
1243
1410
  const cands = Array.from(document.querySelectorAll('div[role="button"], button'));
1244
1411
  for (const e of cands) {
1245
1412
  const t = labelOf(e);
@@ -1273,6 +1440,10 @@ export class DeepSeekBrowser {
1273
1440
  }
1274
1441
  return false;
1275
1442
  });
1443
+ if (clicked) {
1444
+ this._lastContinueAt = Date.now();
1445
+ this._notice(this._t('ds.continue_clicked'));
1446
+ }
1276
1447
  return !!clicked;
1277
1448
  }
1278
1449
  catch {
@@ -1346,9 +1517,29 @@ export class DeepSeekBrowser {
1346
1517
  webSearch: await read(/поиск|search/i, this.webSearch),
1347
1518
  };
1348
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.
1349
1526
  async stopGeneration() {
1350
1527
  this._abort = true;
1351
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.
1352
1543
  const btn = await this._findVisible(STOP_SELECTORS, 500);
1353
1544
  if (btn) {
1354
1545
  try {
@@ -1357,6 +1548,8 @@ export class DeepSeekBrowser {
1357
1548
  }
1358
1549
  catch { }
1359
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).
1360
1553
  const clicked = await this.page
1361
1554
  .evaluate(() => {
1362
1555
  const btns = Array.from(document.querySelectorAll('div[role="button"], button'));
@@ -1366,6 +1559,15 @@ export class DeepSeekBrowser {
1366
1559
  continue;
1367
1560
  const svg = b.querySelector('svg');
1368
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
+ }
1369
1571
  b.click();
1370
1572
  return true;
1371
1573
  }
@@ -1392,116 +1594,139 @@ export class DeepSeekBrowser {
1392
1594
  // hiccup, not a failed send, and it is more likely in the reasoning
1393
1595
  // (deep thinking) mode, where the long THINK phase gets cut off.
1394
1596
  let incompleteRetries = 0;
1395
- while (attempt < this.askRetries) {
1396
- attempt++;
1397
- try {
1398
- return await this._askOnce(prompt, { timeout, agent, attachments });
1399
- }
1400
- catch (e) {
1401
- lastErr = e;
1402
- // Rate limit: DeepSeek did not accept the message. We wait a long time
1403
- // and resend into the SAME chat (without newChat — otherwise the
1404
- // context is lost). The waits don't consume the regular ask() attempts.
1405
- if (e instanceof RateLimitError) {
1406
- rateLimitRetries++;
1407
- if (rateLimitRetries > this.maxRateLimitRetries) {
1408
- this._notice(theme.error(this._t('ds.rate_limit_give_up', {
1409
- 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', {
1410
1626
  min: Math.ceil(this.rateLimitWaitMs / 60000),
1627
+ attempt: rateLimitRetries,
1628
+ max: this.maxRateLimitRetries,
1411
1629
  })));
1412
- 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;
1413
1640
  }
1414
- this._notice(theme.warn(this._t('ds.rate_limit_wait', {
1415
- min: Math.ceil(this.rateLimitWaitMs / 60000),
1416
- attempt: rateLimitRetries,
1417
- max: this.maxRateLimitRetries,
1418
- })));
1419
- // Interruptible: Esc/Ctrl+C must cancel this long wait too,
1420
- // otherwise "stop" stays frozen for up to 5 minutes.
1421
- // The wait is ANIMATED through onSendPause (a live countdown), so the
1422
- // operator sees the agent is waiting on the rate limit instead of a
1423
- // frozen spinner. `_lastSentAt` is bumped so the countdown renders.
1424
- const waitSec = Math.ceil(this.rateLimitWaitMs / 1000);
1425
- const aborted = await this._waitRateLimit(waitSec);
1426
- if (aborted)
1427
- return '(прервано пользователем)';
1428
- continue;
1429
- }
1430
- // Server busy / overloaded: clear in seconds, so we retry quickly
1431
- // (unlike the rate limit). Without this branch the error fell into the
1432
- // generic ask() retry and the operator saw a bare message with no
1433
- // explanation of what DeepSeek is doing.
1434
- if (e instanceof ServerBusyError) {
1435
- serverBusyRetries++;
1436
- if (serverBusyRetries > this.maxServerBusyRetries) {
1437
- 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),
1438
1655
  attempt: serverBusyRetries,
1656
+ max: this.maxServerBusyRetries,
1439
1657
  })));
1440
- throw e;
1658
+ const aborted = await this._sleepInterruptible(this.serverBusyWaitMs);
1659
+ if (aborted)
1660
+ return '(прервано пользователем)';
1661
+ continue;
1441
1662
  }
1442
- this._notice(theme.warn(this._t('ds.server_busy_wait', {
1443
- sec: Math.ceil(this.serverBusyWaitMs / 1000),
1444
- attempt: serverBusyRetries,
1445
- max: this.maxServerBusyRetries,
1446
- })));
1447
- const aborted = await this._sleepInterruptible(this.serverBusyWaitMs);
1448
- if (aborted)
1449
- return '(прервано пользователем)';
1450
- continue;
1451
- }
1452
- // The turn was TRUNCATED by the server (`generation_err`, INCOMPLETE):
1453
- // DeepSeek answered with a partial (often empty) answer and shows a
1454
- // "Continue" button. In reasoning mode this is much more likely. We
1455
- // resend the SAME prompt into the chat (that is what the Continue
1456
- // button does) instead of waiting for an answer that will never come.
1457
- if (e instanceof GenerationIncompleteError) {
1458
- incompleteRetries++;
1459
- if (incompleteRetries > this.maxIncompleteRetries) {
1460
- 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', {
1461
1677
  attempt: incompleteRetries,
1678
+ max: this.maxIncompleteRetries,
1462
1679
  })));
1463
- throw e;
1680
+ const aborted = await this._sleepInterruptible(this.incompleteWaitMs);
1681
+ if (aborted)
1682
+ return '(прервано пользователем)';
1683
+ continue;
1464
1684
  }
1465
- this._notice(theme.warn(this._t('ds.incomplete_retry', {
1466
- attempt: incompleteRetries,
1467
- 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,
1468
1689
  })));
1469
- const aborted = await this._sleepInterruptible(this.incompleteWaitMs);
1470
- if (aborted)
1471
- return '(прервано пользователем)';
1472
- continue;
1473
- }
1474
- this._notice(theme.warn(this._t('ds.ask_retry', {
1475
- attempt,
1476
- max: this.askRetries,
1477
- error: e.message,
1478
- })));
1479
- // Only a genuinely LOST page justifies a restart. "Target page,
1480
- // context or browser has been closed" means the page is gone for good;
1481
- // a bare "browser" mention (e.g. a selector error containing the word)
1482
- // must NOT tear the window down — that was the "a new window opened"
1483
- // surprise during a resumed chat. We re-open the SAME context instead.
1484
- if (/target page, context or browser has been closed|page.*has been closed/i.test(e.message)) {
1485
- this._notice(theme.warn(this._t('ds.ask_restart_browser')));
1486
- try {
1487
- await this.restart();
1488
- 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
+ }
1489
1706
  }
1490
- catch (re) {
1491
- this._notice(theme.warn(this._t('ds.ask_restart_failed', {
1492
- error: re.message,
1493
- })));
1707
+ if (attempt < this.askRetries - 1) {
1708
+ await new Promise((r) => setTimeout(r, 2000 * (attempt + 1)));
1494
1709
  }
1495
- }
1496
- if (attempt < this.askRetries) {
1497
- await new Promise((r) => setTimeout(r, 2000 * attempt));
1710
+ attempt++;
1498
1711
  }
1499
1712
  }
1713
+ throw new Error(this._t('ds.ask_failed', {
1714
+ max: this.askRetries,
1715
+ error: lastErr?.message || '',
1716
+ }));
1717
+ }
1718
+ finally {
1719
+ this._askInFlight = Math.max(0, this._askInFlight - 1);
1500
1720
  }
1501
- throw new Error(this._t('ds.ask_failed', {
1502
- max: this.askRetries,
1503
- error: lastErr?.message || '',
1504
- }));
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;
1505
1730
  }
1506
1731
  async _setInputText(input, text) {
1507
1732
  const tag = await input.evaluate((el) => el.tagName.toLowerCase());
@@ -1607,7 +1832,8 @@ export class DeepSeekBrowser {
1607
1832
  btn.click({ timeout: 1500 }),
1608
1833
  ]);
1609
1834
  await chooser.setFiles(bufPayload);
1610
- 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);
1611
1837
  return;
1612
1838
  }
1613
1839
  catch { }
@@ -1626,7 +1852,32 @@ export class DeepSeekBrowser {
1626
1852
  return;
1627
1853
  }
1628
1854
  // Wait for the upload to finish (the attach preview to appear).
1629
- 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
+ }
1630
1881
  }
1631
1882
  // Wait out a rate-limit pause, showing an animated countdown through
1632
1883
  // onSendPause so the operator sees the agent is alive (not frozen). Returns
@@ -1659,7 +1910,7 @@ export class DeepSeekBrowser {
1659
1910
  // chance of hitting the rate limit. `deepThinking` is the DESIRED state read
1660
1911
  // from the config (set in the constructor / on /config change).
1661
1912
  sendIntervalMs() {
1662
- return this.minSendIntervalMs + (this.deepThinking ? this.thinkingExtraMs : 0);
1913
+ return (this.minSendIntervalMs + (this.deepThinking ? this.thinkingExtraMs : 0));
1663
1914
  }
1664
1915
  async _waitForSendSlot(agent) {
1665
1916
  if (!agent)
@@ -1700,6 +1951,10 @@ export class DeepSeekBrowser {
1700
1951
  while (left > 0) {
1701
1952
  if (this._abort)
1702
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;
1703
1958
  const chunk = Math.min(step, left);
1704
1959
  await this.page.waitForTimeout(chunk);
1705
1960
  left -= chunk;
@@ -1731,6 +1986,8 @@ export class DeepSeekBrowser {
1731
1986
  // set while a previous send was waiting in the pause. If it is set, the
1732
1987
  // user asked to stop and we must not start a new generation at all.
1733
1988
  this._abort = false;
1989
+ // A fresh send clears a stale cancellation from a previous, abandoned ask.
1990
+ this._askCancelled = false;
1734
1991
  if (this._stopped)
1735
1992
  return '(прервано пользователем)';
1736
1993
  const input = await this._findVisible(INPUT_SELECTORS, 10_000);
@@ -1768,6 +2025,7 @@ export class DeepSeekBrowser {
1768
2025
  this._netCapture = '';
1769
2026
  this._netCaptureAt = 0;
1770
2027
  this._netNoAnswer = false;
2028
+ this._netRateLimited = false;
1771
2029
  this._netBody = '';
1772
2030
  // Align the DeepSeek chat toggles (deep thinking / web search) with the
1773
2031
  // configured state BEFORE typing. Doing it here (after the send-pause) it
@@ -1779,7 +2037,9 @@ export class DeepSeekBrowser {
1779
2037
  await this._attachFiles(attachments);
1780
2038
  }
1781
2039
  await this._setInputText(input, prompt);
1782
- 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).
1783
2043
  let sent = false;
1784
2044
  for (const sel of SEND_SELECTORS) {
1785
2045
  const btn = this.page.locator(sel).last();
@@ -1800,12 +2060,12 @@ export class DeepSeekBrowser {
1800
2060
  this._lastSentAt = Date.now();
1801
2061
  this._askDebug('SENT at=' + this._lastSentAt);
1802
2062
  // Wait for the start: either Stop appeared, or the answer text changed,
1803
- // or the total amount of text on the page grew. In parallel we catch
1804
- // 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).
1805
2065
  const startDeadline = Date.now() + 30_000;
1806
- const startBodyLen = await this.page
1807
- .evaluate(() => document.body.innerText.length)
1808
- .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();
1809
2069
  let started = false;
1810
2070
  // A stale/echo answer (the model repeats the previous text, or the answer
1811
2071
  // legitimately equals it) does NOT change `cur`. In that case the old loop
@@ -1816,7 +2076,7 @@ export class DeepSeekBrowser {
1816
2076
  let settledTicks = 0;
1817
2077
  let lastStartCur = '';
1818
2078
  while (Date.now() < startDeadline) {
1819
- if (this._abort)
2079
+ if (this._abort || this._askCancelled)
1820
2080
  return '(прервано пользователем)';
1821
2081
  const pageText = await this._readPageText();
1822
2082
  if (isRateLimitText(pageText)) {
@@ -1825,6 +2085,12 @@ export class DeepSeekBrowser {
1825
2085
  if (isServerBusyText(pageText)) {
1826
2086
  throw new ServerBusyError(pageText.slice(0, 300));
1827
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
+ }
1828
2094
  // The turn was truncated by the server: the SSE body carries
1829
2095
  // finish_reason=generation_err / quasi_status=INCOMPLETE and the UI
1830
2096
  // shows "Continue". Retry instead of waiting out the whole timeout.
@@ -1867,9 +2133,9 @@ export class DeepSeekBrowser {
1867
2133
  this._askDebug('CLICKED Continue (start-loop)');
1868
2134
  }
1869
2135
  const cur = await this._readLastAnswerTextCleanDom().catch(() => '');
1870
- const bodyLen = await this.page
1871
- .evaluate(() => document.body.innerText.length)
1872
- .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;
1873
2139
  // A NEW answer is the only reliable sign that the message was actually
1874
2140
  // sent: the text on the page must differ from what was there before the
1875
2141
  // send, OR the page must have grown. The Stop-button heuristic
@@ -1884,14 +2150,14 @@ export class DeepSeekBrowser {
1884
2150
  // 'changed' can stay false for a while — without this check ask() used
1885
2151
  // to hang until the full timeout and the agent appeared to "stop".
1886
2152
  const netStarted = !!this._netCapture && this._netCaptureAt >= this._lastSentAt;
1887
- if (changed || netStarted || bodyLen > startBodyLen) {
2153
+ if (changed || netStarted || grew) {
1888
2154
  started = true;
1889
2155
  this._askDebug('STARTED changed=' +
1890
2156
  changed +
1891
2157
  ' netStarted=' +
1892
2158
  netStarted +
1893
- ' bodyGrew=' +
1894
- (bodyLen > startBodyLen));
2159
+ ' grew=' +
2160
+ grew);
1895
2161
  break;
1896
2162
  }
1897
2163
  // Fallback for an echo: the text equals beforeText, so it is the OLD
@@ -1921,8 +2187,8 @@ export class DeepSeekBrowser {
1921
2187
  changed +
1922
2188
  ' netStarted=' +
1923
2189
  netStarted +
1924
- ' bodyLen=' +
1925
- bodyLen +
2190
+ ' grew=' +
2191
+ grew +
1926
2192
  ' settled=' +
1927
2193
  settledTicks +
1928
2194
  ' generating=' +
@@ -1951,7 +2217,6 @@ export class DeepSeekBrowser {
1951
2217
  await this.page.waitForTimeout(500);
1952
2218
  started = true;
1953
2219
  // fall through to the finish loop by NOT returning here
1954
- lastStartCur = cur;
1955
2220
  }
1956
2221
  else {
1957
2222
  throw new GenerationIncompleteError(this._netBody.slice(-300));
@@ -1976,13 +2241,12 @@ export class DeepSeekBrowser {
1976
2241
  this._lastSentAt = Date.now();
1977
2242
  const retryDeadline = Date.now() + 20_000;
1978
2243
  while (Date.now() < retryDeadline) {
1979
- if (this._abort)
2244
+ if (this._abort || this._askCancelled)
1980
2245
  return '(прервано пользователем)';
1981
2246
  const cur2 = await this._readLastAnswerTextCleanDom().catch(() => '');
1982
2247
  const net2 = !!this._netCapture && this._netCaptureAt >= this._lastSentAt;
1983
- const grew2 = (await this.page
1984
- .evaluate(() => document.body.innerText.length)
1985
- .catch(() => 0)) > startBodyLen;
2248
+ const sig2 = await this._chatSignal();
2249
+ const grew2 = sig2.nodes > startSignal.nodes || sig2.lastLen > startSignal.lastLen;
1986
2250
  if (net2 ||
1987
2251
  grew2 ||
1988
2252
  (!!cur2 && normText(cur2) !== normText(beforeText))) {
@@ -2004,7 +2268,7 @@ export class DeepSeekBrowser {
2004
2268
  let last = '';
2005
2269
  let stable = 0;
2006
2270
  while (Date.now() < deadline) {
2007
- if (this._abort) {
2271
+ if (this._abort || this._askCancelled) {
2008
2272
  return last || '(прервано пользователем)';
2009
2273
  }
2010
2274
  // Rate-limit / server-busy toast. The old code only read the toasts
@@ -2023,6 +2287,12 @@ export class DeepSeekBrowser {
2023
2287
  throw new ServerBusyError(pageText.slice(0, 300));
2024
2288
  }
2025
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
+ }
2026
2296
  // The server truncated the turn (generation_err / INCOMPLETE) or ended
2027
2297
  // it with only reasoning. PREFER the Continue button: it RESUMES the
2028
2298
  // same turn (`chat/continue`). Resending the prompt creates a NEW turn
@@ -2097,7 +2367,11 @@ export class DeepSeekBrowser {
2097
2367
  }
2098
2368
  stable = 0;
2099
2369
  }
2100
- 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.
2101
2375
  this._askDebug('RETURN stable curLen=' + cur.length);
2102
2376
  return cur;
2103
2377
  }
@@ -2226,6 +2500,8 @@ export class DeepSeekBrowser {
2226
2500
  }, limit);
2227
2501
  }
2228
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).
2229
2505
  const candidates = [`a[href$="/chat/s/${id}"]`, `a[href*="${id}"]`];
2230
2506
  for (const sel of candidates) {
2231
2507
  try {
@@ -2233,8 +2509,14 @@ export class DeepSeekBrowser {
2233
2509
  if ((await loc.count()) === 0)
2234
2510
  continue;
2235
2511
  await loc.click({ timeout: 3000 });
2236
- await this.page.waitForTimeout(1500);
2237
- 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
+ }
2238
2520
  }
2239
2521
  catch { }
2240
2522
  }
@@ -2243,8 +2525,18 @@ export class DeepSeekBrowser {
2243
2525
  waitUntil: 'domcontentloaded',
2244
2526
  timeout: 20_000,
2245
2527
  });
2246
- await this.page.waitForTimeout(1500);
2247
- 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);
2248
2540
  }
2249
2541
  catch (e) {
2250
2542
  throw new Error(this._t('ds.open_chat_failed', { id, error: e.message }));
@@ -2438,6 +2730,14 @@ export class DeepSeekBrowser {
2438
2730
  break;
2439
2731
  }
2440
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
+ }
2441
2741
  const out = [];
2442
2742
  if (blocks.length) {
2443
2743
  for (const b of blocks) {