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.
- package/README.md +21 -0
- package/dist/agent-loop.js +141 -83
- package/dist/attachments.js +267 -0
- package/dist/browser.js +139 -50
- package/dist/config-menu.js +25 -25
- package/dist/config.js +5 -5
- package/dist/gitTools.js +4 -4
- package/dist/i18n.js +31 -7
- package/dist/index.js +295 -168
- package/dist/input.js +237 -83
- package/dist/markdown.js +1 -1
- package/dist/net-capture.js +20 -20
- package/dist/self-review.js +16 -16
- package/dist/sessions.js +11 -11
- package/dist/spinner.js +20 -16
- package/dist/system-prompt.js +29 -2
- package/dist/theme.js +26 -26
- package/dist/tools.js +9 -9
- package/dist/transcript.js +2 -2
- package/dist/types.js +2 -2
- package/dist/web.js +16 -16
- package/dist/xml-toolcall.js +89 -14
- package/package.json +1 -1
- package/scripts/postinstall.mjs +78 -51
|
@@ -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
|
-
//
|
|
29
|
-
// —
|
|
30
|
-
// _isGenerating()
|
|
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
|
-
//
|
|
40
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
48
|
-
// (
|
|
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
|
-
//
|
|
104
|
-
// (
|
|
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
|
-
//
|
|
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
|
-
//
|
|
309
|
-
//
|
|
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
|
-
//
|
|
344
|
-
//
|
|
345
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
424
|
-
//
|
|
425
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
544
|
-
//
|
|
545
|
-
//
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
588
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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('
|
|
697
|
+
throw new Error('Новый ответ не получен (на странице остался прежний текст). ' +
|
|
698
|
+
'Возможно, сообщение не отправилось.');
|
|
610
699
|
}
|
|
611
700
|
async dumpDom(filePath) {
|
|
612
701
|
if (!this.page)
|