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/agent-loop.js +138 -12
- package/dist/browser.js +469 -169
- package/dist/commands.js +106 -2
- package/dist/compact.js +178 -0
- package/dist/config-menu.js +22 -5
- package/dist/config.js +52 -0
- package/dist/confirm.js +30 -6
- package/dist/context.js +3 -2
- package/dist/gitTools.js +3 -2
- package/dist/i18n.js +106 -8
- package/dist/index.js +223 -105
- package/dist/input.js +86 -11
- package/package.json +1 -1
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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.
|
|
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
|
|
1058
|
-
//
|
|
1059
|
-
//
|
|
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
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
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
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
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|продолжить|продолжение)
|
|
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
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
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
|
|
1209
|
-
//
|
|
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.
|
|
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|продолжить|продолжение)
|
|
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
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
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
|
-
|
|
1658
|
+
const aborted = await this._sleepInterruptible(this.serverBusyWaitMs);
|
|
1659
|
+
if (aborted)
|
|
1660
|
+
return '(прервано пользователем)';
|
|
1661
|
+
continue;
|
|
1441
1662
|
}
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
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
|
-
|
|
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.
|
|
1466
|
-
attempt:
|
|
1467
|
-
max: this.
|
|
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
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
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
|
-
|
|
1491
|
-
|
|
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
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
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
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
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 ||
|
|
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
|
-
'
|
|
1894
|
-
|
|
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
|
-
'
|
|
1925
|
-
|
|
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
|
|
1984
|
-
|
|
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 (
|
|
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
|
-
|
|
2237
|
-
|
|
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
|
-
|
|
2247
|
-
|
|
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) {
|