zames_pro 2.7.0 → 2.9.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 +53 -2
- package/dist/attachments.js +332 -0
- package/dist/browser.js +99 -9
- package/dist/i18n.js +20 -0
- package/dist/index.js +164 -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;
|
|
@@ -61,13 +80,24 @@ export async function runAgentLoop({ browser, tools, task, workdir, maxIteration
|
|
|
61
80
|
// chatty model.
|
|
62
81
|
let looksDoneRetries = 0;
|
|
63
82
|
const MAX_LOOKSDONE_RETRIES = 3;
|
|
83
|
+
// Watchdog against the agent emitting a tool call and then going silent.
|
|
84
|
+
// After a tool result the expected next answer is a fresh tool call; if we
|
|
85
|
+
// instead get an EMPTY answer or the EXACT same answer as the previous turn
|
|
86
|
+
// (a sign the new message was not sent), we nudge instead of stopping.
|
|
87
|
+
let lastRaw = '';
|
|
88
|
+
let justRanTool = false;
|
|
89
|
+
let watchdogRetries = 0;
|
|
90
|
+
const MAX_WATCHDOG_RETRIES = 3;
|
|
64
91
|
for (let i = 0; i < maxIterations; i++) {
|
|
65
92
|
onThinking();
|
|
66
93
|
// The first message (task) is user input: no throttle.
|
|
67
94
|
// Subsequent ones (tool-result and resend requests) are agent sends:
|
|
68
95
|
// throttled so we don't hit the rate limit.
|
|
69
96
|
const isFirst = i === 0;
|
|
70
|
-
const rawResponse = await browser.ask(message, {
|
|
97
|
+
const rawResponse = await browser.ask(message, {
|
|
98
|
+
agent: !isFirst,
|
|
99
|
+
attachments: isFirst ? attachments : [],
|
|
100
|
+
});
|
|
71
101
|
await reportChat();
|
|
72
102
|
transcript?.log('assistant_raw', { response: rawResponse });
|
|
73
103
|
// The user aborted generation (Esc/Ctrl+C).
|
|
@@ -75,6 +105,23 @@ export async function runAgentLoop({ browser, tools, task, workdir, maxIteration
|
|
|
75
105
|
transcript?.log('user_aborted');
|
|
76
106
|
return rawResponse;
|
|
77
107
|
}
|
|
108
|
+
// Watchdog: after a tool result we expect a FRESH tool call. If the answer
|
|
109
|
+
// is empty or identical to the previous turn (the new message was not
|
|
110
|
+
// sent), nudge instead of stopping.
|
|
111
|
+
const wdEmpty = !String(rawResponse || '').trim();
|
|
112
|
+
const wdStale = justRanTool && lastRaw.trim() !== '' && rawResponse.trim() === lastRaw.trim();
|
|
113
|
+
if (!isFirst && (wdEmpty || wdStale) && watchdogRetries < MAX_WATCHDOG_RETRIES) {
|
|
114
|
+
watchdogRetries++;
|
|
115
|
+
transcript?.log('watchdog_nudge', {
|
|
116
|
+
attempt: watchdogRetries,
|
|
117
|
+
empty: wdEmpty,
|
|
118
|
+
stale: wdStale,
|
|
119
|
+
response: String(rawResponse || '').slice(0, 200),
|
|
120
|
+
});
|
|
121
|
+
await new Promise((r) => setTimeout(r, 1500));
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
lastRaw = rawResponse;
|
|
78
125
|
const parsed = parseToolCall(rawResponse);
|
|
79
126
|
if (parsed) {
|
|
80
127
|
const thought = extractPreToolText(rawResponse);
|
|
@@ -237,6 +284,10 @@ export async function runAgentLoop({ browser, tools, task, workdir, maxIteration
|
|
|
237
284
|
});
|
|
238
285
|
results.push({ tool: call.tool, result });
|
|
239
286
|
}
|
|
287
|
+
// A tool just ran — the next answer is expected to be a fresh tool call.
|
|
288
|
+
// Reset the watchdog so the next empty/repeated answer is nudged.
|
|
289
|
+
justRanTool = true;
|
|
290
|
+
watchdogRetries = 0;
|
|
240
291
|
if (results.length === 1) {
|
|
241
292
|
const r = results[0];
|
|
242
293
|
const resultStr = typeof r.result === 'string' ? r.result : JSON.stringify(r.result);
|
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
import fs from 'fs/promises';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import os from 'os';
|
|
4
|
+
import { execFileSync } from 'child_process';
|
|
5
|
+
export const IMAGE_EXTS = ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.svg', '.ico'];
|
|
6
|
+
export const MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024;
|
|
7
|
+
function sanitize(name) {
|
|
8
|
+
return String(name || '')
|
|
9
|
+
.replace(/[\u0000-\u001f\u007f]/g, '')
|
|
10
|
+
.replace(/[\\/:*?"<>|]/g, '_')
|
|
11
|
+
.replace(/\s+/g, '_')
|
|
12
|
+
.slice(0, 80) || 'file';
|
|
13
|
+
}
|
|
14
|
+
export function guessMime(name) {
|
|
15
|
+
const ext = path.extname(String(name || '')).toLowerCase();
|
|
16
|
+
const map = {
|
|
17
|
+
'.png': 'image/png',
|
|
18
|
+
'.jpg': 'image/jpeg',
|
|
19
|
+
'.jpeg': 'image/jpeg',
|
|
20
|
+
'.gif': 'image/gif',
|
|
21
|
+
'.webp': 'image/webp',
|
|
22
|
+
'.bmp': 'image/bmp',
|
|
23
|
+
'.svg': 'image/svg+xml',
|
|
24
|
+
'.ico': 'image/x-icon',
|
|
25
|
+
'.txt': 'text/plain',
|
|
26
|
+
'.md': 'text/markdown',
|
|
27
|
+
'.json': 'application/json',
|
|
28
|
+
'.js': 'text/javascript',
|
|
29
|
+
'.ts': 'text/plain',
|
|
30
|
+
'.css': 'text/css',
|
|
31
|
+
'.html': 'text/html',
|
|
32
|
+
'.pdf': 'application/pdf',
|
|
33
|
+
'.zip': 'application/zip',
|
|
34
|
+
'.csv': 'text/csv',
|
|
35
|
+
'.log': 'text/plain',
|
|
36
|
+
};
|
|
37
|
+
return map[ext] || 'application/octet-stream';
|
|
38
|
+
}
|
|
39
|
+
export function extForMime(mime) {
|
|
40
|
+
const m = String(mime || '').toLowerCase();
|
|
41
|
+
if (m.includes('png'))
|
|
42
|
+
return '.png';
|
|
43
|
+
if (m.includes('jpeg') || m.includes('jpg'))
|
|
44
|
+
return '.jpg';
|
|
45
|
+
if (m.includes('gif'))
|
|
46
|
+
return '.gif';
|
|
47
|
+
if (m.includes('webp'))
|
|
48
|
+
return '.webp';
|
|
49
|
+
if (m.includes('svg'))
|
|
50
|
+
return '.svg';
|
|
51
|
+
if (m.includes('text/plain'))
|
|
52
|
+
return '.txt';
|
|
53
|
+
if (m.includes('json'))
|
|
54
|
+
return '.json';
|
|
55
|
+
if (m.includes('pdf'))
|
|
56
|
+
return '.pdf';
|
|
57
|
+
return '';
|
|
58
|
+
}
|
|
59
|
+
export function formatSize(bytes) {
|
|
60
|
+
const n = Number(bytes) || 0;
|
|
61
|
+
if (n < 1024)
|
|
62
|
+
return n + ' B';
|
|
63
|
+
if (n < 1024 * 1024)
|
|
64
|
+
return (n / 1024).toFixed(1) + ' KB';
|
|
65
|
+
return (n / (1024 * 1024)).toFixed(1) + ' MB';
|
|
66
|
+
}
|
|
67
|
+
export function isImageName(name) {
|
|
68
|
+
return IMAGE_EXTS.includes(path.extname(String(name || '')).toLowerCase());
|
|
69
|
+
}
|
|
70
|
+
export function parseDataUrl(text) {
|
|
71
|
+
const m = String(text || '').match(/^data:([^;,]*);base64,([\s\S]*)$/);
|
|
72
|
+
if (!m)
|
|
73
|
+
return null;
|
|
74
|
+
try {
|
|
75
|
+
return { mime: m[1] || 'application/octet-stream', data: Buffer.from(m[2].replace(/\s+/g, ''), 'base64') };
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
export function sniffMime(data) {
|
|
82
|
+
if (!data || data.length < 4)
|
|
83
|
+
return '';
|
|
84
|
+
const b = data;
|
|
85
|
+
if (b[0] === 0x89 && b[1] === 0x50 && b[2] === 0x4e && b[3] === 0x47)
|
|
86
|
+
return 'image/png';
|
|
87
|
+
if (b[0] === 0xff && b[1] === 0xd8)
|
|
88
|
+
return 'image/jpeg';
|
|
89
|
+
if (b[0] === 0x47 && b[1] === 0x49 && b[2] === 0x46)
|
|
90
|
+
return 'image/gif';
|
|
91
|
+
if (b[0] === 0x52 && b[1] === 0x49 && b[2] === 0x46 && b[3] === 0x46)
|
|
92
|
+
return 'image/webp';
|
|
93
|
+
if (b[0] === 0x42 && b[1] === 0x4d)
|
|
94
|
+
return 'image/bmp';
|
|
95
|
+
return '';
|
|
96
|
+
}
|
|
97
|
+
export function parseImagePaste(raw) {
|
|
98
|
+
const text = String(raw || '').trim();
|
|
99
|
+
if (!text)
|
|
100
|
+
return null;
|
|
101
|
+
const dataUrl = parseDataUrl(text);
|
|
102
|
+
if (dataUrl)
|
|
103
|
+
return dataUrl;
|
|
104
|
+
const onlyB64 = /^[A-Za-z0-9+/\s=]+$/;
|
|
105
|
+
if (onlyB64.test(text) && text.length >= 64) {
|
|
106
|
+
try {
|
|
107
|
+
const data = Buffer.from(text.replace(/\s+/g, ''), 'base64');
|
|
108
|
+
const mime = sniffMime(data);
|
|
109
|
+
if (mime && mime.startsWith('image/'))
|
|
110
|
+
return { mime, data };
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
export class AttachmentStore {
|
|
119
|
+
items = [];
|
|
120
|
+
imageCount = 0;
|
|
121
|
+
fileCount = 0;
|
|
122
|
+
reset() {
|
|
123
|
+
this.items = [];
|
|
124
|
+
this.imageCount = 0;
|
|
125
|
+
this.fileCount = 0;
|
|
126
|
+
}
|
|
127
|
+
add(item) {
|
|
128
|
+
const image = isImageName(item.name) || String(item.mime).startsWith('image/');
|
|
129
|
+
const index = image ? ++this.imageCount : ++this.fileCount;
|
|
130
|
+
const marker = image ? '[image#' + index + ']' : '[file#' + index + ']';
|
|
131
|
+
const att = {
|
|
132
|
+
index,
|
|
133
|
+
path: item.path,
|
|
134
|
+
name: item.name,
|
|
135
|
+
mime: item.mime,
|
|
136
|
+
image,
|
|
137
|
+
marker,
|
|
138
|
+
size: Number(item.size) || 0,
|
|
139
|
+
};
|
|
140
|
+
this.items.push(att);
|
|
141
|
+
return att;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
// True if the pasted text is a single file path (e.g. '/tmp/a.png', './img.jpg',
|
|
145
|
+
// 'C:\\pics\\a.png' or a bare 'photo.png'). We do NOT require the file to
|
|
146
|
+
// exist yet — the caller checks that.
|
|
147
|
+
export function looksLikeFilePath(text) {
|
|
148
|
+
const s = String(text || '').trim();
|
|
149
|
+
if (!s || s.length > 500)
|
|
150
|
+
return false;
|
|
151
|
+
if (/[\r\n]/.test(s))
|
|
152
|
+
return false;
|
|
153
|
+
// A bare file name with an image/known extension.
|
|
154
|
+
if (looksLikeFileName(s))
|
|
155
|
+
return true;
|
|
156
|
+
// A path with separators and an extension.
|
|
157
|
+
if (/[\\/]/.test(s) && /\.[A-Za-z0-9]{1,8}$/.test(s))
|
|
158
|
+
return true;
|
|
159
|
+
return false;
|
|
160
|
+
}
|
|
161
|
+
export function looksLikeFileName(name) {
|
|
162
|
+
const n = String(name || '').trim();
|
|
163
|
+
if (!n)
|
|
164
|
+
return false;
|
|
165
|
+
if (/[\r\n]/.test(n))
|
|
166
|
+
return false;
|
|
167
|
+
return /^[\w.\- ]+\.[A-Za-z0-9]{1,8}$/.test(n);
|
|
168
|
+
}
|
|
169
|
+
export async function saveToTemp(tmpDir, name, data) {
|
|
170
|
+
await fs.mkdir(tmpDir, { recursive: true });
|
|
171
|
+
const base = sanitize(name);
|
|
172
|
+
let target = path.join(tmpDir, base);
|
|
173
|
+
if (await fs.stat(target).catch(() => null)) {
|
|
174
|
+
const ext = path.extname(base);
|
|
175
|
+
const stem = base.slice(0, base.length - ext.length);
|
|
176
|
+
let i = 1;
|
|
177
|
+
while (await fs.stat(target).catch(() => null)) {
|
|
178
|
+
target = path.join(tmpDir, stem + '-' + i + ext);
|
|
179
|
+
i++;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
await fs.writeFile(target, data);
|
|
183
|
+
return target;
|
|
184
|
+
}
|
|
185
|
+
// Read an image from the OS clipboard. Tries, in order, every known tool for
|
|
186
|
+
// the current platform. Returns the raw bytes plus a short note about which
|
|
187
|
+
// tool was used (or the reason nothing was found) — the caller shows that note
|
|
188
|
+
// to the user so a failed paste is never silent.
|
|
189
|
+
// True when running inside WSL. There the X11 clipboard is a SEPARATE
|
|
190
|
+
// clipboard from the Windows one: the user copies an image in Windows, so we
|
|
191
|
+
// must read it through powershell.exe, not xclip.
|
|
192
|
+
export function isWsl() {
|
|
193
|
+
if (process.platform !== 'linux')
|
|
194
|
+
return false;
|
|
195
|
+
if (process.env.WSL_DISTRO_NAME || process.env.WSL_INTEROP)
|
|
196
|
+
return true;
|
|
197
|
+
try {
|
|
198
|
+
const rel = os.release().toLowerCase();
|
|
199
|
+
if (rel.includes('microsoft') || rel.includes('wsl'))
|
|
200
|
+
return true;
|
|
201
|
+
}
|
|
202
|
+
catch { }
|
|
203
|
+
return false;
|
|
204
|
+
}
|
|
205
|
+
// PowerShell one-liner: write the clipboard image as raw PNG bytes to stdout.
|
|
206
|
+
const PS_GET_IMAGE = 'Add-Type -AssemblyName System.Windows.Forms,System.Drawing; ' +
|
|
207
|
+
'$img=[System.Windows.Forms.Clipboard]::GetImage(); ' +
|
|
208
|
+
'if($img){$ms=New-Object System.IO.MemoryStream; ' +
|
|
209
|
+
'$img.Save($ms,[System.Drawing.Imaging.ImageFormat]::Png); ' +
|
|
210
|
+
'[Console]::OpenStandardOutput().Write($ms.ToArray(),0,$ms.Length)}';
|
|
211
|
+
// PowerShell one-liner: write the clipboard file paths (CF_HDROP) to stdout.
|
|
212
|
+
const PS_GET_FILE_DROP = 'Add-Type -AssemblyName System.Windows.Forms; ' +
|
|
213
|
+
'$d=[System.Windows.Forms.Clipboard]::GetDataObject(); ' +
|
|
214
|
+
'if($d -and $d.GetDataPresent([System.Windows.Forms.DataFormats]::FileDrop)){' +
|
|
215
|
+
'$f=$d.GetData([System.Windows.Forms.DataFormats]::FileDrop); $f -join [Environment]::NewLine}';
|
|
216
|
+
export function readClipboardImageDetailed() {
|
|
217
|
+
if (process.platform === 'linux' || process.platform === 'freebsd' || process.platform === 'openbsd') {
|
|
218
|
+
const wayland = process.env.WAYLAND_DISPLAY;
|
|
219
|
+
const x11 = process.env.DISPLAY;
|
|
220
|
+
const attempts = [];
|
|
221
|
+
// On WSL the Windows clipboard is what the user copies into — read it first.
|
|
222
|
+
if (isWsl()) {
|
|
223
|
+
attempts.push({ bin: 'powershell.exe', args: ['-NoProfile', '-NonInteractive', '-Command', PS_GET_IMAGE], via: 'powershell.exe' });
|
|
224
|
+
}
|
|
225
|
+
if (wayland || !x11)
|
|
226
|
+
attempts.push({ bin: 'wl-paste', args: ['--type', 'image/png', '--no-newline'], via: 'wl-paste' });
|
|
227
|
+
if (x11 || !wayland) {
|
|
228
|
+
attempts.push({ bin: 'xclip', args: ['-selection', 'clipboard', '-t', 'image/png', '-o'], via: 'xclip' });
|
|
229
|
+
attempts.push({ bin: 'xsel', args: ['--clipboard', '--output'], via: 'xsel' });
|
|
230
|
+
}
|
|
231
|
+
// Even if the session env is missing, try the other family as a fallback.
|
|
232
|
+
attempts.push({ bin: 'wl-paste', args: ['--type', 'image/png', '--no-newline'], via: 'wl-paste' });
|
|
233
|
+
attempts.push({ bin: 'xclip', args: ['-selection', 'clipboard', '-t', 'image/png', '-o'], via: 'xclip' });
|
|
234
|
+
const tried = [];
|
|
235
|
+
for (const a of attempts) {
|
|
236
|
+
if (tried.includes(a.via))
|
|
237
|
+
continue;
|
|
238
|
+
tried.push(a.via);
|
|
239
|
+
const data = tryCommand(a.bin, a.args);
|
|
240
|
+
if (data && sniffMime(data).startsWith('image/'))
|
|
241
|
+
return { data, via: a.via };
|
|
242
|
+
if (data && data.length)
|
|
243
|
+
return { data, via: a.via };
|
|
244
|
+
}
|
|
245
|
+
return { data: null, via: 'no-tool:' + tried.join(',') };
|
|
246
|
+
}
|
|
247
|
+
if (process.platform === 'darwin') {
|
|
248
|
+
const data = tryCommand('pngpaste', ['-']);
|
|
249
|
+
return { data: data && data.length ? data : null, via: data ? 'pngpaste' : 'no-tool:pngpaste' };
|
|
250
|
+
}
|
|
251
|
+
if (process.platform === 'win32') {
|
|
252
|
+
// PowerShell reads the clipboard image and writes raw PNG bytes to stdout.
|
|
253
|
+
const ps = 'Add-Type -AssemblyName System.Windows.Forms,System.Drawing; ' +
|
|
254
|
+
'$img=[System.Windows.Forms.Clipboard]::GetImage(); ' +
|
|
255
|
+
'if($img){$ms=New-Object System.IO.MemoryStream; ' +
|
|
256
|
+
'$img.Save($ms,[System.Drawing.Imaging.ImageFormat]::Png); ' +
|
|
257
|
+
'[Console]::OpenStandardOutput().Write($ms.ToArray(),0,$ms.Length)}';
|
|
258
|
+
const data = tryCommand('powershell', ['-NoProfile', '-NonInteractive', '-Command', ps]);
|
|
259
|
+
return { data: data && data.length ? data : null, via: data ? 'powershell' : 'no-tool:powershell' };
|
|
260
|
+
}
|
|
261
|
+
return { data: null, via: 'unsupported-platform' };
|
|
262
|
+
}
|
|
263
|
+
// Backwards-compatible wrapper used elsewhere.
|
|
264
|
+
export function readClipboardImage() {
|
|
265
|
+
return readClipboardImageDetailed().data;
|
|
266
|
+
}
|
|
267
|
+
function tryCommand(bin, args) {
|
|
268
|
+
try {
|
|
269
|
+
const out = execFileSync(bin, args, {
|
|
270
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
271
|
+
maxBuffer: MAX_ATTACHMENT_BYTES,
|
|
272
|
+
timeout: 8000,
|
|
273
|
+
windowsHide: true,
|
|
274
|
+
});
|
|
275
|
+
if (!out || !out.length)
|
|
276
|
+
return null;
|
|
277
|
+
return out;
|
|
278
|
+
}
|
|
279
|
+
catch {
|
|
280
|
+
return null;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
export async function findFileByName(workdir, name) {
|
|
284
|
+
const candidate = path.resolve(workdir, name);
|
|
285
|
+
if (await fs.stat(candidate).catch(() => null))
|
|
286
|
+
return candidate;
|
|
287
|
+
try {
|
|
288
|
+
const { glob } = await import('fs/promises');
|
|
289
|
+
for await (const f of glob('**/' + name, { cwd: workdir })) {
|
|
290
|
+
const abs = path.resolve(workdir, f);
|
|
291
|
+
const rel = path.relative(workdir, abs);
|
|
292
|
+
if (rel.startsWith('..') || path.isAbsolute(rel))
|
|
293
|
+
continue;
|
|
294
|
+
return abs;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
catch { }
|
|
298
|
+
return null;
|
|
299
|
+
}
|
|
300
|
+
// Read file paths from the Windows clipboard (copied files, CF_HDROP) and
|
|
301
|
+
// translate them to WSL paths. On WSL the user often copies a FILE, not an
|
|
302
|
+
// image. Returns [] on non-WSL / when nothing is there.
|
|
303
|
+
export function readWindowsClipboardFiles() {
|
|
304
|
+
if (!isWsl())
|
|
305
|
+
return [];
|
|
306
|
+
const out = tryCommand('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', PS_GET_FILE_DROP]);
|
|
307
|
+
if (!out)
|
|
308
|
+
return [];
|
|
309
|
+
const text = out.toString('utf-8');
|
|
310
|
+
return text
|
|
311
|
+
.split(/\r?\n/)
|
|
312
|
+
.map((s) => s.trim())
|
|
313
|
+
.filter(Boolean)
|
|
314
|
+
.map(winPathToWsl)
|
|
315
|
+
.filter((p) => !!p);
|
|
316
|
+
}
|
|
317
|
+
// Convert a Windows path (C:\\Users\\me\\a.png) into a WSL path
|
|
318
|
+
// (/mnt/c/Users/me/a.png). Leaves already-POSIX paths untouched.
|
|
319
|
+
export function winPathToWsl(winPath) {
|
|
320
|
+
let p = String(winPath || '').trim();
|
|
321
|
+
if (p.startsWith('"') && p.endsWith('"'))
|
|
322
|
+
p = p.slice(1, -1);
|
|
323
|
+
if (!p)
|
|
324
|
+
return null;
|
|
325
|
+
if (p.startsWith('/'))
|
|
326
|
+
return p;
|
|
327
|
+
if (p.charAt(1) !== ':' || (p.charAt(2) !== '/' && p.charAt(2) !== '\\'))
|
|
328
|
+
return null;
|
|
329
|
+
const drive = p.charAt(0).toLowerCase();
|
|
330
|
+
const rest = p.slice(3).split('\\').join('/');
|
|
331
|
+
return '/mnt/' + drive + '/' + rest;
|
|
332
|
+
}
|
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': {
|