zames_pro 2.6.0 → 2.8.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.
@@ -0,0 +1,267 @@
1
+ import fs from 'fs/promises';
2
+ import path from 'path';
3
+ import { execFileSync } from 'child_process';
4
+ export const IMAGE_EXTS = ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.svg', '.ico'];
5
+ export const MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024;
6
+ function sanitize(name) {
7
+ return String(name || '')
8
+ .replace(/[\u0000-\u001f\u007f]/g, '')
9
+ .replace(/[\\/:*?"<>|]/g, '_')
10
+ .replace(/\s+/g, '_')
11
+ .slice(0, 80) || 'file';
12
+ }
13
+ export function guessMime(name) {
14
+ const ext = path.extname(String(name || '')).toLowerCase();
15
+ const map = {
16
+ '.png': 'image/png',
17
+ '.jpg': 'image/jpeg',
18
+ '.jpeg': 'image/jpeg',
19
+ '.gif': 'image/gif',
20
+ '.webp': 'image/webp',
21
+ '.bmp': 'image/bmp',
22
+ '.svg': 'image/svg+xml',
23
+ '.ico': 'image/x-icon',
24
+ '.txt': 'text/plain',
25
+ '.md': 'text/markdown',
26
+ '.json': 'application/json',
27
+ '.js': 'text/javascript',
28
+ '.ts': 'text/plain',
29
+ '.css': 'text/css',
30
+ '.html': 'text/html',
31
+ '.pdf': 'application/pdf',
32
+ '.zip': 'application/zip',
33
+ '.csv': 'text/csv',
34
+ '.log': 'text/plain',
35
+ };
36
+ return map[ext] || 'application/octet-stream';
37
+ }
38
+ export function extForMime(mime) {
39
+ const m = String(mime || '').toLowerCase();
40
+ if (m.includes('png'))
41
+ return '.png';
42
+ if (m.includes('jpeg') || m.includes('jpg'))
43
+ return '.jpg';
44
+ if (m.includes('gif'))
45
+ return '.gif';
46
+ if (m.includes('webp'))
47
+ return '.webp';
48
+ if (m.includes('svg'))
49
+ return '.svg';
50
+ if (m.includes('text/plain'))
51
+ return '.txt';
52
+ if (m.includes('json'))
53
+ return '.json';
54
+ if (m.includes('pdf'))
55
+ return '.pdf';
56
+ return '';
57
+ }
58
+ export function formatSize(bytes) {
59
+ const n = Number(bytes) || 0;
60
+ if (n < 1024)
61
+ return n + ' B';
62
+ if (n < 1024 * 1024)
63
+ return (n / 1024).toFixed(1) + ' KB';
64
+ return (n / (1024 * 1024)).toFixed(1) + ' MB';
65
+ }
66
+ export function isImageName(name) {
67
+ return IMAGE_EXTS.includes(path.extname(String(name || '')).toLowerCase());
68
+ }
69
+ export function parseDataUrl(text) {
70
+ const m = String(text || '').match(/^data:([^;,]*);base64,([\s\S]*)$/);
71
+ if (!m)
72
+ return null;
73
+ try {
74
+ return { mime: m[1] || 'application/octet-stream', data: Buffer.from(m[2].replace(/\s+/g, ''), 'base64') };
75
+ }
76
+ catch {
77
+ return null;
78
+ }
79
+ }
80
+ export function sniffMime(data) {
81
+ if (!data || data.length < 4)
82
+ return '';
83
+ const b = data;
84
+ if (b[0] === 0x89 && b[1] === 0x50 && b[2] === 0x4e && b[3] === 0x47)
85
+ return 'image/png';
86
+ if (b[0] === 0xff && b[1] === 0xd8)
87
+ return 'image/jpeg';
88
+ if (b[0] === 0x47 && b[1] === 0x49 && b[2] === 0x46)
89
+ return 'image/gif';
90
+ if (b[0] === 0x52 && b[1] === 0x49 && b[2] === 0x46 && b[3] === 0x46)
91
+ return 'image/webp';
92
+ if (b[0] === 0x42 && b[1] === 0x4d)
93
+ return 'image/bmp';
94
+ return '';
95
+ }
96
+ export function parseImagePaste(raw) {
97
+ const text = String(raw || '').trim();
98
+ if (!text)
99
+ return null;
100
+ const dataUrl = parseDataUrl(text);
101
+ if (dataUrl)
102
+ return dataUrl;
103
+ const onlyB64 = /^[A-Za-z0-9+/\s=]+$/;
104
+ if (onlyB64.test(text) && text.length >= 64) {
105
+ try {
106
+ const data = Buffer.from(text.replace(/\s+/g, ''), 'base64');
107
+ const mime = sniffMime(data);
108
+ if (mime && mime.startsWith('image/'))
109
+ return { mime, data };
110
+ }
111
+ catch {
112
+ return null;
113
+ }
114
+ }
115
+ return null;
116
+ }
117
+ export class AttachmentStore {
118
+ items = [];
119
+ imageCount = 0;
120
+ fileCount = 0;
121
+ reset() {
122
+ this.items = [];
123
+ this.imageCount = 0;
124
+ this.fileCount = 0;
125
+ }
126
+ add(item) {
127
+ const image = isImageName(item.name) || String(item.mime).startsWith('image/');
128
+ const index = image ? ++this.imageCount : ++this.fileCount;
129
+ const marker = image ? '[image#' + index + ']' : '[file#' + index + ']';
130
+ const att = {
131
+ index,
132
+ path: item.path,
133
+ name: item.name,
134
+ mime: item.mime,
135
+ image,
136
+ marker,
137
+ size: Number(item.size) || 0,
138
+ };
139
+ this.items.push(att);
140
+ return att;
141
+ }
142
+ }
143
+ // True if the pasted text is a single file path (e.g. '/tmp/a.png', './img.jpg',
144
+ // 'C:\\pics\\a.png' or a bare 'photo.png'). We do NOT require the file to
145
+ // exist yet — the caller checks that.
146
+ export function looksLikeFilePath(text) {
147
+ const s = String(text || '').trim();
148
+ if (!s || s.length > 500)
149
+ return false;
150
+ if (/[\r\n]/.test(s))
151
+ return false;
152
+ // A bare file name with an image/known extension.
153
+ if (looksLikeFileName(s))
154
+ return true;
155
+ // A path with separators and an extension.
156
+ if (/[\\/]/.test(s) && /\.[A-Za-z0-9]{1,8}$/.test(s))
157
+ return true;
158
+ return false;
159
+ }
160
+ export function looksLikeFileName(name) {
161
+ const n = String(name || '').trim();
162
+ if (!n)
163
+ return false;
164
+ if (/[\r\n]/.test(n))
165
+ return false;
166
+ return /^[\w.\- ]+\.[A-Za-z0-9]{1,8}$/.test(n);
167
+ }
168
+ export async function saveToTemp(tmpDir, name, data) {
169
+ await fs.mkdir(tmpDir, { recursive: true });
170
+ const base = sanitize(name);
171
+ let target = path.join(tmpDir, base);
172
+ if (await fs.stat(target).catch(() => null)) {
173
+ const ext = path.extname(base);
174
+ const stem = base.slice(0, base.length - ext.length);
175
+ let i = 1;
176
+ while (await fs.stat(target).catch(() => null)) {
177
+ target = path.join(tmpDir, stem + '-' + i + ext);
178
+ i++;
179
+ }
180
+ }
181
+ await fs.writeFile(target, data);
182
+ return target;
183
+ }
184
+ // Read an image from the OS clipboard. Tries, in order, every known tool for
185
+ // the current platform. Returns the raw bytes plus a short note about which
186
+ // tool was used (or the reason nothing was found) — the caller shows that note
187
+ // to the user so a failed paste is never silent.
188
+ export function readClipboardImageDetailed() {
189
+ if (process.platform === 'linux' || process.platform === 'freebsd' || process.platform === 'openbsd') {
190
+ const wayland = process.env.WAYLAND_DISPLAY;
191
+ const x11 = process.env.DISPLAY;
192
+ const attempts = [];
193
+ if (wayland || !x11)
194
+ attempts.push({ bin: 'wl-paste', args: ['--type', 'image/png', '--no-newline'], via: 'wl-paste' });
195
+ if (x11 || !wayland) {
196
+ attempts.push({ bin: 'xclip', args: ['-selection', 'clipboard', '-t', 'image/png', '-o'], via: 'xclip' });
197
+ attempts.push({ bin: 'xsel', args: ['--clipboard', '--output'], via: 'xsel' });
198
+ }
199
+ // Even if the session env is missing, try the other family as a fallback.
200
+ attempts.push({ bin: 'wl-paste', args: ['--type', 'image/png', '--no-newline'], via: 'wl-paste' });
201
+ attempts.push({ bin: 'xclip', args: ['-selection', 'clipboard', '-t', 'image/png', '-o'], via: 'xclip' });
202
+ const tried = [];
203
+ for (const a of attempts) {
204
+ if (tried.includes(a.via))
205
+ continue;
206
+ tried.push(a.via);
207
+ const data = tryCommand(a.bin, a.args);
208
+ if (data && sniffMime(data).startsWith('image/'))
209
+ return { data, via: a.via };
210
+ if (data && data.length)
211
+ return { data, via: a.via };
212
+ }
213
+ return { data: null, via: 'no-tool:' + tried.join(',') };
214
+ }
215
+ if (process.platform === 'darwin') {
216
+ const data = tryCommand('pngpaste', ['-']);
217
+ return { data: data && data.length ? data : null, via: data ? 'pngpaste' : 'no-tool:pngpaste' };
218
+ }
219
+ if (process.platform === 'win32') {
220
+ // PowerShell reads the clipboard image and writes raw PNG bytes to stdout.
221
+ const ps = 'Add-Type -AssemblyName System.Windows.Forms,System.Drawing; ' +
222
+ '$img=[System.Windows.Forms.Clipboard]::GetImage(); ' +
223
+ 'if($img){$ms=New-Object System.IO.MemoryStream; ' +
224
+ '$img.Save($ms,[System.Drawing.Imaging.ImageFormat]::Png); ' +
225
+ '[Console]::OpenStandardOutput().Write($ms.ToArray(),0,$ms.Length)}';
226
+ const data = tryCommand('powershell', ['-NoProfile', '-NonInteractive', '-Command', ps]);
227
+ return { data: data && data.length ? data : null, via: data ? 'powershell' : 'no-tool:powershell' };
228
+ }
229
+ return { data: null, via: 'unsupported-platform' };
230
+ }
231
+ // Backwards-compatible wrapper used elsewhere.
232
+ export function readClipboardImage() {
233
+ return readClipboardImageDetailed().data;
234
+ }
235
+ function tryCommand(bin, args) {
236
+ try {
237
+ const out = execFileSync(bin, args, {
238
+ stdio: ['ignore', 'pipe', 'ignore'],
239
+ maxBuffer: MAX_ATTACHMENT_BYTES,
240
+ timeout: 8000,
241
+ windowsHide: true,
242
+ });
243
+ if (!out || !out.length)
244
+ return null;
245
+ return out;
246
+ }
247
+ catch {
248
+ return null;
249
+ }
250
+ }
251
+ export async function findFileByName(workdir, name) {
252
+ const candidate = path.resolve(workdir, name);
253
+ if (await fs.stat(candidate).catch(() => null))
254
+ return candidate;
255
+ try {
256
+ const { glob } = await import('fs/promises');
257
+ for await (const f of glob('**/' + name, { cwd: workdir })) {
258
+ const abs = path.resolve(workdir, f);
259
+ const rel = path.relative(workdir, abs);
260
+ if (rel.startsWith('..') || path.isAbsolute(rel))
261
+ continue;
262
+ return abs;
263
+ }
264
+ }
265
+ catch { }
266
+ return null;
267
+ }
package/dist/browser.js CHANGED
@@ -25,10 +25,9 @@ const SEND_SELECTORS = [
25
25
  'button[aria-label*="send" i]',
26
26
  'button[aria-label*="отправ" i]',
27
27
  ];
28
- // ВАЖНО: сюда НЕЛЬЗЯ добавлять общий 'div[role="button"][class*="ds-button--primary"]'
29
- // — под него попадает кнопка отправки, которая видна всегда, и тогда
30
- // _isGenerating() вечно возвращает true, из-за чего ответ никогда не
31
- // считается готовым.
28
+ // IMPORTANT: you MUST NOT add the generic 'div[role="button"][class*="ds-button--primary"]'
29
+ // here — it matches the send button, which is always visible, and then
30
+ // _isGenerating() always returns true, so the answer is never considered ready.
32
31
  const STOP_SELECTORS = [
33
32
  'div[role="button"][aria-label*="stop" i]',
34
33
  'div[role="button"][aria-label*="останов" i]',
@@ -36,16 +35,24 @@ const STOP_SELECTORS = [
36
35
  'button:has-text("Остановить")',
37
36
  'button[aria-label*="Stop" i]',
38
37
  ];
39
- // Служебные статусы интерфейса DeepSeek, которые НЕ являются ответом модели.
40
- // Иначе агент принимает статус (Reading...) за ответ и ломает разбор.
38
+ // DeepSeek UI service statuses that are NOT the model's answer.
39
+ // Otherwise the agent takes a status (Reading...) for an answer and breaks parsing.
41
40
  const STATUS_RE = /^(reading|thinking|searching|analyzing|generating|stop|остановить|читаю|думаю|поиск|анализ)[\s.…]*$/i;
42
- // Ответ DeepSeek при превышении лимита частоты.
41
+ // DeepSeek's answer when the rate limit is exceeded.
43
42
  const RATE_LIMIT_RE = /(messages? too frequent|too many requests|rate limit|слишком часто|повторите позже|try again later)/i;
44
43
  export function isRateLimitText(text) {
45
44
  return RATE_LIMIT_RE.test(String(text || ''));
46
45
  }
47
- // Ошибка «слишком часто»: отличается от прочих, чтобы ask() ждал долго
48
- // (лимиты DeepSeek сбрасываются за минуты) и повторял отправку сам.
46
+ // Normalize text for comparison: collapse whitespace so that tiny DOM
47
+ // differences (nbsp, trailing spaces) don't count as "a new answer".
48
+ export function normText(s) {
49
+ return String(s || '')
50
+ .replace(/\u00a0/g, ' ')
51
+ .replace(/\s+/g, ' ')
52
+ .trim();
53
+ }
54
+ // The "too frequent" error: distinct from others so ask() waits a long time
55
+ // (DeepSeek limits reset over minutes) and retries the send itself.
49
56
  export class RateLimitError extends Error {
50
57
  constructor(detail) {
51
58
  super('Messages too frequent. Try again later. ' + detail);
@@ -100,9 +107,9 @@ export class DeepSeekBrowser {
100
107
  maxRateLimitRetries;
101
108
  _lastSentAt;
102
109
  _abort;
103
- // Пользователь нажал Esc/Ctrl+C — «стоп» для ВСЕЙ текущей пачки задач
104
- // (включая очередь). В отличие от _abort (сбрасывается на каждую
105
- // отправку), этот флаг живёт до явного запуска новой задачи с промпта.
110
+ // The user pressed Esc/Ctrl+C — a "stop" for the WHOLE current batch of
111
+ // tasks (including the queue). Unlike _abort (reset on every send), this
112
+ // flag lives until a new task is explicitly started from the prompt.
106
113
  _stopped;
107
114
  context;
108
115
  page;
@@ -284,9 +291,9 @@ export class DeepSeekBrowser {
284
291
  return null;
285
292
  }
286
293
  async _readLastAnswerText() {
287
- // Если удалось перехватить сырой текст ответа по сети (без рендер-
288
- // искажений DeepSeek) и он относится к текущему ответу — отдаём его.
289
- // Это защищает $, экранированные переводы строк и т.п. в аргументах.
294
+ // If we managed to intercept the raw answer text over the network
295
+ // (without DeepSeek's render distortions) and it belongs to the current
296
+ // answer — we return it. This protects $, escaped newlines, etc. in arguments.
290
297
  if (this._netCapture && this._netCaptureAt >= this._lastSentAt) {
291
298
  return this._netCapture;
292
299
  }
@@ -304,9 +311,9 @@ export class DeepSeekBrowser {
304
311
  return out.replace(new RegExp(NL + '{3,}', 'g'), NL + NL).trim();
305
312
  }, ANSWER_SELECTORS);
306
313
  }
307
- // Читаем ТОЛЬКО видимые тосты/уведомления/ошибки, а не весь текст
308
- // страницы. Иначе ловим «try again later» из служебных/скрытых блоков
309
- // и уходим в ложное ожидание лимита на 5 минут.
314
+ // We read ONLY visible toasts/notifications/errors, not the whole page
315
+ // text. Otherwise we catch "try again later" from service/hidden blocks
316
+ // and go into a false 5-minute rate-limit wait.
310
317
  async _readPageText() {
311
318
  return await this.page
312
319
  .evaluate(() => {
@@ -340,9 +347,9 @@ export class DeepSeekBrowser {
340
347
  return '';
341
348
  return raw;
342
349
  }
343
- // Найти кнопку Stop в интерфейсе DeepSeek. Полагаться только на класс
344
- // нельзя: во время генерации кнопка отправки (та же circle-кнопка)
345
- // меняет иконку на «квадрат» (stop), сохраняя классы.
350
+ // Find the Stop button in the DeepSeek UI. We can't rely on the class
351
+ // alone: during generation the send button (the same circle button)
352
+ // changes its icon to a "square" (stop) while keeping the classes.
346
353
  async _stopButtonVisible() {
347
354
  const explicit = await this._findVisible(STOP_SELECTORS, 250);
348
355
  if (explicit)
@@ -361,7 +368,7 @@ export class DeepSeekBrowser {
361
368
  (b.textContent || '')).toLowerCase();
362
369
  if (/stop|останов/.test(label))
363
370
  return true;
364
- // Иконка-квадрат = кнопка Stop; стрелка (path без rect) = отправка.
371
+ // A square icon = Stop button; an arrow (path without rect) = send.
365
372
  const svg = b.querySelector('svg');
366
373
  if (svg && svg.querySelector('rect'))
367
374
  return true;
@@ -409,20 +416,20 @@ export class DeepSeekBrowser {
409
416
  catch { }
410
417
  return false;
411
418
  }
412
- async ask(prompt, { timeout = this.answerTimeoutMs, agent = false, } = {}) {
419
+ async ask(prompt, { timeout = this.answerTimeoutMs, agent = false, attachments = [], } = {}) {
413
420
  let lastErr = null;
414
421
  let attempt = 0;
415
422
  let rateLimitRetries = 0;
416
423
  while (attempt < this.askRetries) {
417
424
  attempt++;
418
425
  try {
419
- return await this._askOnce(prompt, { timeout, agent });
426
+ return await this._askOnce(prompt, { timeout, agent, attachments });
420
427
  }
421
428
  catch (e) {
422
429
  lastErr = e;
423
- // Лимит частоты: DeepSeek не принял сообщение. Ждём долго и
424
- // повторяем отправку в ТОТ ЖЕ чат (без newChat — иначе теряется
425
- // контекст). Паузы не расходуют обычные попытки ask().
430
+ // Rate limit: DeepSeek did not accept the message. We wait a long time
431
+ // and resend into the SAME chat (without newChat — otherwise the
432
+ // context is lost). The waits don't consume the regular ask() attempts.
426
433
  if (e instanceof RateLimitError) {
427
434
  rateLimitRetries++;
428
435
  if (rateLimitRetries > this.maxRateLimitRetries) {
@@ -495,9 +502,72 @@ export class DeepSeekBrowser {
495
502
  await this.page.keyboard.insertText(text);
496
503
  }
497
504
  }
498
- // Пауза между отправками. Применяется ТОЛЬКО к сообщениям агента
499
- // (tool-result, system-prompt), чтобы не упираться в лимит частоты.
500
- // Пользовательский ввод отправляется без задержки.
505
+ // Attach files/images to the chat via the hidden <input type=file> of the
506
+ // DeepSeek upload widget. The element is not visible, so we set the files
507
+ // programmatically (Playwright setInputFiles works on hidden inputs too).
508
+ async _attachFiles(files) {
509
+ const bufPayload = [];
510
+ for (const f of files) {
511
+ try {
512
+ const buffer = await fs.readFile(f.path);
513
+ bufPayload.push({ name: f.name, mimeType: f.mime, buffer });
514
+ }
515
+ catch (e) {
516
+ console.error(theme.warn('⚠ не удалось прочитать вложение ' +
517
+ f.name +
518
+ ': ' +
519
+ e.message));
520
+ }
521
+ }
522
+ if (!bufPayload.length)
523
+ return;
524
+ // Preferred path: click the attach button and let the file chooser event
525
+ // carry the files. This is how a real user attaches a file and it reliably
526
+ // triggers DeepSeek's upload handler.
527
+ const ATTACH_SELECTORS = [
528
+ 'div[role="button"][aria-label*="attach" i]',
529
+ 'div[role="button"][aria-label*="влож" i]',
530
+ 'button[aria-label*="attach" i]',
531
+ 'button[aria-label*="влож" i]',
532
+ '[class*="upload"]',
533
+ '[class*="attach"]',
534
+ ];
535
+ for (const sel of ATTACH_SELECTORS) {
536
+ const btn = this.page.locator(sel).last();
537
+ try {
538
+ if ((await btn.count()) === 0)
539
+ continue;
540
+ if (!(await btn.isVisible().catch(() => false)))
541
+ continue;
542
+ const [chooser] = await Promise.all([
543
+ this.page.waitForEvent('filechooser', { timeout: 3000 }),
544
+ btn.click({ timeout: 1500 }),
545
+ ]);
546
+ await chooser.setFiles(bufPayload);
547
+ await this.page.waitForTimeout(2500);
548
+ return;
549
+ }
550
+ catch { }
551
+ }
552
+ // Fallback: set the files directly on the hidden <input type=file>.
553
+ const input = this.page.locator('input[type="file"]').first();
554
+ if ((await input.count()) === 0) {
555
+ console.error(theme.warn('⚠ не найдено поле загрузки файлов на странице — вложения не прикреплены'));
556
+ return;
557
+ }
558
+ try {
559
+ await input.setInputFiles(bufPayload, { timeout: 15_000 });
560
+ }
561
+ catch (e) {
562
+ console.error(theme.warn('⚠ не удалось прикрепить файлы: ' + e.message));
563
+ return;
564
+ }
565
+ // Wait for the upload to finish (the attach preview to appear).
566
+ await this.page.waitForTimeout(2000);
567
+ }
568
+ // Pause between sends. Applied ONLY to agent messages
569
+ // (tool-result, system-prompt) so we don't hit the rate limit.
570
+ // User input is sent without delay.
501
571
  async _waitForSendSlot(agent) {
502
572
  if (!agent)
503
573
  return;
@@ -509,8 +579,8 @@ export class DeepSeekBrowser {
509
579
  console.error(theme.warn(`⏳ пауза ${Math.ceil(gap / 1000)}с перед отправкой`));
510
580
  await this.page.waitForTimeout(gap);
511
581
  }
512
- async _askOnce(prompt, { timeout, agent }) {
513
- // Сбрасываем флаг прерывания ТОЛЬКО в самом начале отправки.
582
+ async _askOnce(prompt, { timeout, agent, attachments = [], }) {
583
+ // We reset the abort flag ONLY at the very start of the send.
514
584
  this._abort = false;
515
585
  const input = await this._findVisible(INPUT_SELECTORS, 10_000);
516
586
  if (!input) {
@@ -520,6 +590,11 @@ export class DeepSeekBrowser {
520
590
  await this._waitForSendSlot(agent);
521
591
  this._netCapture = '';
522
592
  this._netCaptureAt = 0;
593
+ // Attach files/images FIRST (before the text): the DeepSeek upload widget
594
+ // shows them above the input, and only then the message can be sent.
595
+ if (attachments.length) {
596
+ await this._attachFiles(attachments);
597
+ }
523
598
  await this._setInputText(input, prompt);
524
599
  await this.page.waitForTimeout(200);
525
600
  let sent = false;
@@ -540,9 +615,9 @@ export class DeepSeekBrowser {
540
615
  await this.page.keyboard.press('Enter');
541
616
  }
542
617
  this._lastSentAt = Date.now();
543
- // Ждём старта: либо появился Stop, либо изменился текст ответа,
544
- // либо вырос общий объём текста на странице. Параллельно ловим
545
- // тост о превышении лимита частоты (только тосты, не весь body).
618
+ // Wait for the start: either Stop appeared, or the answer text changed,
619
+ // or the total amount of text on the page grew. In parallel we catch
620
+ // the rate-limit toast (only toasts, not the whole body).
546
621
  const startDeadline = Date.now() + 15_000;
547
622
  const startBodyLen = await this.page
548
623
  .evaluate(() => document.body.innerText.length)
@@ -555,27 +630,34 @@ export class DeepSeekBrowser {
555
630
  if (isRateLimitText(pageText)) {
556
631
  throw new RateLimitError(pageText.slice(0, 300));
557
632
  }
558
- const gen = await this._isGenerating();
559
633
  const cur = await this._readLastAnswerTextClean().catch(() => '');
560
634
  const bodyLen = await this.page
561
635
  .evaluate(() => document.body.innerText.length)
562
636
  .catch(() => 0);
563
- if (gen || (cur && cur !== beforeText) || bodyLen > startBodyLen) {
637
+ // A NEW answer is the only reliable sign that the message was actually
638
+ // sent: the text on the page must differ from what was there before the
639
+ // send, OR the page must have grown. The Stop-button heuristic
640
+ // (_isGenerating) is NOT used here: right after a tool result the stop
641
+ // button may briefly linger from the previous generation, which used to
642
+ // make us think the new answer had started when in fact nothing was sent
643
+ // — and then the agent silently "stopped".
644
+ const changed = cur && normText(cur) !== normText(beforeText);
645
+ if (changed || bodyLen > startBodyLen) {
564
646
  started = true;
565
647
  break;
566
648
  }
567
649
  await this.page.waitForTimeout(300);
568
650
  }
569
651
  if (!started) {
570
- // Больше НЕ проверяем лимит по всему тексту страницы — это давало
571
- // ложные срабатывания и 5-минутные паузы. Просто сообщаем, что
572
- // генерация не началась.
652
+ // We NO LONGER check the limit over the whole page text — that caused
653
+ // false positives and 5-minute waits. We just report that
654
+ // generation did not start.
573
655
  throw new Error('Ответ не начал генерироваться за 15с. Возможно, сообщение не отправилось.');
574
656
  }
575
- // Ждём, пока ответ перестанет меняться. Условие "не генерируется"
576
- // проверяем через рост текста, а НЕ через _isGenerating().
577
- // Параллельно ловим тост лимита частоты, если он всплывёт во время
578
- // генерации (читаем только тосты, ложных срабатываний нет).
657
+ // Wait until the answer stops changing. We check the "not generating"
658
+ // condition via text growth, NOT via _isGenerating().
659
+ // In parallel we catch the rate-limit toast if it pops up during
660
+ // generation (we read only toasts, so there are no false positives).
579
661
  const deadline = Date.now() + timeout;
580
662
  let last = '';
581
663
  let stable = 0;
@@ -584,8 +666,8 @@ export class DeepSeekBrowser {
584
666
  if (this._abort) {
585
667
  return last || '(прервано пользователем)';
586
668
  }
587
- // Тост лимита проверяем не каждый тик, а раз в ~5 тиков, чтобы не
588
- // дёргать DOM лишний раз.
669
+ // We check the limit toast not every tick but about once per 5 ticks,
670
+ // so we don't poke the DOM unnecessarily.
589
671
  if (tick++ % 5 === 0) {
590
672
  const pageText = await this._readPageText();
591
673
  if (isRateLimitText(pageText)) {
@@ -593,7 +675,12 @@ export class DeepSeekBrowser {
593
675
  }
594
676
  }
595
677
  const cur = await this._readLastAnswerTextClean().catch(() => '');
596
- if (cur && cur === last) {
678
+ // Ignore an "answer" that is identical to what was on the page BEFORE we
679
+ // sent the message: that is the previous answer, not a new one. Returning
680
+ // it would make the agent re-process the old tool call (or silently
681
+ // stop). We keep waiting instead.
682
+ const isNew = cur && normText(cur) !== normText(beforeText);
683
+ if (isNew && cur === last) {
597
684
  stable++;
598
685
  if (stable >= 2)
599
686
  return cur;
@@ -601,12 +688,14 @@ export class DeepSeekBrowser {
601
688
  else {
602
689
  stable = 0;
603
690
  }
604
- last = cur;
691
+ if (isNew)
692
+ last = cur;
605
693
  await this.page.waitForTimeout(800);
606
694
  }
607
- if (last)
695
+ if (last && normText(last) !== normText(beforeText))
608
696
  return last;
609
- throw new Error('Таймаут ожидания ответа. Попробуйте /debug-dom.');
697
+ throw new Error('Новый ответ не получен (на странице остался прежний текст). ' +
698
+ 'Возможно, сообщение не отправилось.');
610
699
  }
611
700
  async dumpDom(filePath) {
612
701
  if (!this.page)