zames_pro 2.7.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 +24 -2
- package/dist/attachments.js +267 -0
- package/dist/browser.js +99 -9
- package/dist/i18n.js +20 -0
- package/dist/index.js +144 -20
- package/dist/input.js +155 -6
- package/dist/system-prompt.js +6 -2
- package/dist/xml-toolcall.js +75 -0
- package/package.json +1 -1
- package/scripts/postinstall.mjs +78 -51
package/README.md
CHANGED
|
@@ -41,6 +41,27 @@ While the agent is working you can keep typing: press Enter to queue a message
|
|
|
41
41
|
abort the current generation. This mirrors typing during generation on the
|
|
42
42
|
DeepSeek website.
|
|
43
43
|
|
|
44
|
+
### Images and files
|
|
45
|
+
|
|
46
|
+
You can paste an image or a file into the input line (Ctrl+Shift+V / Shift+Insert
|
|
47
|
+
or the terminal's own paste). zames saves it under `<project>/tmp` and shows a
|
|
48
|
+
marker in the line - `[image#1]` for images, `[file#1]` for other files - then
|
|
49
|
+
uploads the real file to the chat together with your message. These are the
|
|
50
|
+
same formats the DeepSeek web chat accepts (PNG, JPEG, GIF, WEBP, BMP, SVG and
|
|
51
|
+
the usual document types).
|
|
52
|
+
|
|
53
|
+
Pasting an image works when the terminal sends it as a `data:` URL or as a
|
|
54
|
+
base64 blob with a recognizable image signature. You can also paste a path to a
|
|
55
|
+
local file (drag a file into the terminal or copy its path); if the file exists
|
|
56
|
+
it is attached, otherwise the text is inserted as usual.
|
|
57
|
+
|
|
58
|
+
Press Ctrl+V (or use an empty paste) and zames reads the image straight from
|
|
59
|
+
the clipboard: Linux via wl-paste (Wayland) or xclip/xsel (X11), macOS
|
|
60
|
+
via pngpaste, Windows via PowerShell. The needed Linux tools are installed
|
|
61
|
+
automatically on npm install (best-effort, through the detected package
|
|
62
|
+
manager). If no image is found, zames says so and reports which tool it tried,
|
|
63
|
+
instead of staying silent.
|
|
64
|
+
|
|
44
65
|
### Options
|
|
45
66
|
|
|
46
67
|
```
|
package/dist/agent-loop.js
CHANGED
|
@@ -2,7 +2,7 @@ import { buildSystemPrompt } from './system-prompt.js';
|
|
|
2
2
|
import { getGitContext, formatGitContext } from './gitTools.js';
|
|
3
3
|
import { parseXmlToolCalls } from './xml-toolcall.js';
|
|
4
4
|
import { translate } from './i18n.js';
|
|
5
|
-
export async function runAgentLoop({ browser, tools, task, workdir, maxIterations = 40, freshChat = false, sendSystemPrompt = false, transcript = null, onThinking = () => { }, onAssistantThought = () => { }, onToolCall = () => { }, onToolResult = () => { }, onAssistantMessage = () => { }, onChatReady = () => { }, onWarning = () => { }, debugLog = false, locale = 'ru', }) {
|
|
5
|
+
export async function runAgentLoop({ browser, tools, task, workdir, maxIterations = 40, freshChat = false, sendSystemPrompt = false, transcript = null, attachments = [], onThinking = () => { }, onAssistantThought = () => { }, onToolCall = () => { }, onToolResult = () => { }, onAssistantMessage = () => { }, onChatReady = () => { }, onWarning = () => { }, debugLog = false, locale = 'ru', }) {
|
|
6
6
|
if (freshChat) {
|
|
7
7
|
await browser.newChat();
|
|
8
8
|
transcript?.log('new_chat');
|
|
@@ -48,6 +48,25 @@ export async function runAgentLoop({ browser, tools, task, workdir, maxIteration
|
|
|
48
48
|
await reportChat();
|
|
49
49
|
}
|
|
50
50
|
let message = task;
|
|
51
|
+
if (attachments.length) {
|
|
52
|
+
// Tell the model what the markers mean even when the system-prompt is not
|
|
53
|
+
// (re)sent (resumed chat). The files are uploaded to the chat by the
|
|
54
|
+
// browser; this note just explains the [image#N] / [file#N] markers.
|
|
55
|
+
const markers = attachments.map((a, i) => {
|
|
56
|
+
const ext = String(a.name || '');
|
|
57
|
+
const isImg = /\.(png|jpe?g|gif|webp|bmp|svg|ico)$/i.test(ext);
|
|
58
|
+
return (isImg ? '[image#' : '[file#') + (i + 1) + ']';
|
|
59
|
+
});
|
|
60
|
+
message =
|
|
61
|
+
task +
|
|
62
|
+
String.fromCharCode(10) +
|
|
63
|
+
String.fromCharCode(10) +
|
|
64
|
+
'(The user attached ' +
|
|
65
|
+
attachments.length +
|
|
66
|
+
' file(s) to this message: ' +
|
|
67
|
+
markers.join(', ') +
|
|
68
|
+
'. Look at the uploaded images in the chat; file copies are in <project>/tmp.)';
|
|
69
|
+
}
|
|
51
70
|
transcript?.log('task', { task });
|
|
52
71
|
let malformedRetries = 0;
|
|
53
72
|
const MAX_MALFORMED_RETRIES = 3;
|
|
@@ -67,7 +86,10 @@ export async function runAgentLoop({ browser, tools, task, workdir, maxIteration
|
|
|
67
86
|
// Subsequent ones (tool-result and resend requests) are agent sends:
|
|
68
87
|
// throttled so we don't hit the rate limit.
|
|
69
88
|
const isFirst = i === 0;
|
|
70
|
-
const rawResponse = await browser.ask(message, {
|
|
89
|
+
const rawResponse = await browser.ask(message, {
|
|
90
|
+
agent: !isFirst,
|
|
91
|
+
attachments: isFirst ? attachments : [],
|
|
92
|
+
});
|
|
71
93
|
await reportChat();
|
|
72
94
|
transcript?.log('assistant_raw', { response: rawResponse });
|
|
73
95
|
// The user aborted generation (Esc/Ctrl+C).
|
|
@@ -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
|
@@ -43,6 +43,14 @@ const RATE_LIMIT_RE = /(messages? too frequent|too many requests|rate limit|сл
|
|
|
43
43
|
export function isRateLimitText(text) {
|
|
44
44
|
return RATE_LIMIT_RE.test(String(text || ''));
|
|
45
45
|
}
|
|
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
|
+
}
|
|
46
54
|
// The "too frequent" error: distinct from others so ask() waits a long time
|
|
47
55
|
// (DeepSeek limits reset over minutes) and retries the send itself.
|
|
48
56
|
export class RateLimitError extends Error {
|
|
@@ -408,14 +416,14 @@ export class DeepSeekBrowser {
|
|
|
408
416
|
catch { }
|
|
409
417
|
return false;
|
|
410
418
|
}
|
|
411
|
-
async ask(prompt, { timeout = this.answerTimeoutMs, agent = false, } = {}) {
|
|
419
|
+
async ask(prompt, { timeout = this.answerTimeoutMs, agent = false, attachments = [], } = {}) {
|
|
412
420
|
let lastErr = null;
|
|
413
421
|
let attempt = 0;
|
|
414
422
|
let rateLimitRetries = 0;
|
|
415
423
|
while (attempt < this.askRetries) {
|
|
416
424
|
attempt++;
|
|
417
425
|
try {
|
|
418
|
-
return await this._askOnce(prompt, { timeout, agent });
|
|
426
|
+
return await this._askOnce(prompt, { timeout, agent, attachments });
|
|
419
427
|
}
|
|
420
428
|
catch (e) {
|
|
421
429
|
lastErr = e;
|
|
@@ -494,6 +502,69 @@ export class DeepSeekBrowser {
|
|
|
494
502
|
await this.page.keyboard.insertText(text);
|
|
495
503
|
}
|
|
496
504
|
}
|
|
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
|
+
}
|
|
497
568
|
// Pause between sends. Applied ONLY to agent messages
|
|
498
569
|
// (tool-result, system-prompt) so we don't hit the rate limit.
|
|
499
570
|
// User input is sent without delay.
|
|
@@ -508,7 +579,7 @@ export class DeepSeekBrowser {
|
|
|
508
579
|
console.error(theme.warn(`⏳ пауза ${Math.ceil(gap / 1000)}с перед отправкой`));
|
|
509
580
|
await this.page.waitForTimeout(gap);
|
|
510
581
|
}
|
|
511
|
-
async _askOnce(prompt, { timeout, agent }) {
|
|
582
|
+
async _askOnce(prompt, { timeout, agent, attachments = [], }) {
|
|
512
583
|
// We reset the abort flag ONLY at the very start of the send.
|
|
513
584
|
this._abort = false;
|
|
514
585
|
const input = await this._findVisible(INPUT_SELECTORS, 10_000);
|
|
@@ -519,6 +590,11 @@ export class DeepSeekBrowser {
|
|
|
519
590
|
await this._waitForSendSlot(agent);
|
|
520
591
|
this._netCapture = '';
|
|
521
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
|
+
}
|
|
522
598
|
await this._setInputText(input, prompt);
|
|
523
599
|
await this.page.waitForTimeout(200);
|
|
524
600
|
let sent = false;
|
|
@@ -554,12 +630,19 @@ export class DeepSeekBrowser {
|
|
|
554
630
|
if (isRateLimitText(pageText)) {
|
|
555
631
|
throw new RateLimitError(pageText.slice(0, 300));
|
|
556
632
|
}
|
|
557
|
-
const gen = await this._isGenerating();
|
|
558
633
|
const cur = await this._readLastAnswerTextClean().catch(() => '');
|
|
559
634
|
const bodyLen = await this.page
|
|
560
635
|
.evaluate(() => document.body.innerText.length)
|
|
561
636
|
.catch(() => 0);
|
|
562
|
-
|
|
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) {
|
|
563
646
|
started = true;
|
|
564
647
|
break;
|
|
565
648
|
}
|
|
@@ -592,7 +675,12 @@ export class DeepSeekBrowser {
|
|
|
592
675
|
}
|
|
593
676
|
}
|
|
594
677
|
const cur = await this._readLastAnswerTextClean().catch(() => '');
|
|
595
|
-
|
|
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) {
|
|
596
684
|
stable++;
|
|
597
685
|
if (stable >= 2)
|
|
598
686
|
return cur;
|
|
@@ -600,12 +688,14 @@ export class DeepSeekBrowser {
|
|
|
600
688
|
else {
|
|
601
689
|
stable = 0;
|
|
602
690
|
}
|
|
603
|
-
|
|
691
|
+
if (isNew)
|
|
692
|
+
last = cur;
|
|
604
693
|
await this.page.waitForTimeout(800);
|
|
605
694
|
}
|
|
606
|
-
if (last)
|
|
695
|
+
if (last && normText(last) !== normText(beforeText))
|
|
607
696
|
return last;
|
|
608
|
-
throw new Error('
|
|
697
|
+
throw new Error('Новый ответ не получен (на странице остался прежний текст). ' +
|
|
698
|
+
'Возможно, сообщение не отправилось.');
|
|
609
699
|
}
|
|
610
700
|
async dumpDom(filePath) {
|
|
611
701
|
if (!this.page)
|
package/dist/i18n.js
CHANGED
|
@@ -59,6 +59,10 @@ const CATALOG = {
|
|
|
59
59
|
'help.key.slash': { ru: '/ + Tab подсказка и автодополнение slash-команд', en: '/ + Tab slash-command hints and completion' },
|
|
60
60
|
'help.key.newline': { ru: 'Ctrl+J / Ctrl+Enter новая строка (Shift+Enter в терминалах с поддержкой)', en: 'Ctrl+J / Ctrl+Enter new line (Shift+Enter in capable terminals)' },
|
|
61
61
|
'help.key.backslash': { ru: '\\ + Enter тоже новая строка', en: '\\ + Enter also a new line' },
|
|
62
|
+
'help.key.attach': {
|
|
63
|
+
ru: 'Ctrl+Shift+V картинки вставить изображение/файл (сохранится в tmp)',
|
|
64
|
+
en: 'Ctrl+Shift+V image paste an image/file (saved to tmp)',
|
|
65
|
+
},
|
|
62
66
|
'help.key.esc': { ru: 'Esc, Ctrl+C прервать текущую генерацию', en: 'Esc, Ctrl+C abort current generation' },
|
|
63
67
|
'help.commands': { ru: 'Обычные команды:', en: 'Commands:' },
|
|
64
68
|
'help.cmd.new': { ru: '/new, /clear новый чат (сброс контекста)', en: '/new, /clear new chat (reset context)' },
|
|
@@ -133,6 +137,22 @@ const CATALOG = {
|
|
|
133
137
|
'msg.abort_gen': { ru: '⏹ Esc — прерываю генерацию...', en: '⏹ Esc — aborting generation...' },
|
|
134
138
|
'msg.abort_ctrlc': { ru: '⏹ Ctrl+C — прерываю генерацию...', en: '⏹ Ctrl+C — aborting generation...' },
|
|
135
139
|
'msg.queued': { ru: '📨 В очередь ({n}): ', en: '📨 Queued ({n}): ' },
|
|
140
|
+
'msg.attached_image': {
|
|
141
|
+
ru: '🖼 Вложено изображение {marker} ({size}) — сохранено в tmp',
|
|
142
|
+
en: '🖼 Attached image {marker} ({size}) — saved to tmp',
|
|
143
|
+
},
|
|
144
|
+
'msg.attached_file': {
|
|
145
|
+
ru: '📎 Вложен файл {marker} {name} ({size}) — сохранён в tmp',
|
|
146
|
+
en: '📎 Attached file {marker} {name} ({size}) — saved to tmp',
|
|
147
|
+
},
|
|
148
|
+
'msg.clip_empty': {
|
|
149
|
+
ru: '⚠ В буфере обмена нет картинки (проверено: {via}).',
|
|
150
|
+
en: '⚠ No image found in the clipboard (tried: {via}).',
|
|
151
|
+
},
|
|
152
|
+
'msg.clip_hint': {
|
|
153
|
+
ru: ' Установите утилиту клипборда: Linux/X11 — xclip или xsel, Wayland — wl-clipboard; Windows/macOS — работает из коробки. Либо вставьте путь к файлу.',
|
|
154
|
+
en: ' Install a clipboard tool: Linux/X11 — xclip or xsel, Wayland — wl-clipboard; Windows/macOS — works out of the box. Or paste a file path.',
|
|
155
|
+
},
|
|
136
156
|
'msg.from_queue': { ru: '▶ Из очереди: ', en: '▶ From queue: ' },
|
|
137
157
|
'msg.agent_error': { ru: '✖ Ошибка агента:', en: '✖ Agent error:' },
|
|
138
158
|
'msg.suspicious_stop': {
|