dsh-sidebar-browser-control 0.1.0 → 0.2.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 +58 -12
- package/README.zh.md +13 -5
- package/client.js +611 -29
- package/index.js +361 -10
- package/package.json +2 -2
package/index.js
CHANGED
|
@@ -21,8 +21,19 @@
|
|
|
21
21
|
*
|
|
22
22
|
* Only the standard library is imported: a profile-installed bundle resolves
|
|
23
23
|
* outside the DSH installation, so shipped packages are not on its import path.
|
|
24
|
+
*
|
|
25
|
+
* Screenshots never touch a native Electron capture API: `webview.capturePage()`
|
|
26
|
+
* crashed the renderer (docs/architecture.md), so the Host half asks the Client
|
|
27
|
+
* half for the tab's geometry and then captures the DSH window itself with
|
|
28
|
+
* PrintWindow through PowerShell, cropping to that rectangle. It works even when
|
|
29
|
+
* the window is covered by other applications.
|
|
24
30
|
*/
|
|
25
31
|
|
|
32
|
+
import { execFile } from 'node:child_process';
|
|
33
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
34
|
+
import { tmpdir } from 'node:os';
|
|
35
|
+
import { dirname, isAbsolute, join, resolve as resolvePath } from 'node:path';
|
|
36
|
+
|
|
26
37
|
/** Route prefix owned by this plugin. */
|
|
27
38
|
const BASE = '/sidebar-browser-control';
|
|
28
39
|
/** How long one long-poll is held open before answering with no commands. */
|
|
@@ -104,6 +115,252 @@ function admitted(req) {
|
|
|
104
115
|
return true;
|
|
105
116
|
}
|
|
106
117
|
|
|
118
|
+
/**
|
|
119
|
+
* PowerShell that captures the DSH window itself (PrintWindow) and crops it.
|
|
120
|
+
*
|
|
121
|
+
* PrintWindow renders the window's own pixels, so the result does not depend on
|
|
122
|
+
* the window being foreground or unoccluded — which is exactly why it is used
|
|
123
|
+
* instead of a screen copy. The script is ASCII only and handed to
|
|
124
|
+
* `powershell.exe` as -EncodedCommand: Windows PowerShell reads .ps1 files as
|
|
125
|
+
* ANSI, so a non-ASCII script file (or `-Command` string) corrupts silently.
|
|
126
|
+
* @param outPath - PNG destination.
|
|
127
|
+
* @param crop - pixel rectangle inside the window, or zero-size for the whole window.
|
|
128
|
+
* @param restore - restore a minimized window before capturing.
|
|
129
|
+
* @returns the script source.
|
|
130
|
+
*/
|
|
131
|
+
export function captureScript(outPath, crop, restore = false) {
|
|
132
|
+
return [
|
|
133
|
+
'$ProgressPreference = "SilentlyContinue"',
|
|
134
|
+
'Add-Type -AssemblyName System.Drawing',
|
|
135
|
+
'Add-Type @"',
|
|
136
|
+
'using System;',
|
|
137
|
+
'using System.Runtime.InteropServices;',
|
|
138
|
+
'public class DshShot {',
|
|
139
|
+
' [DllImport("user32.dll")] public static extern bool SetProcessDPIAware();',
|
|
140
|
+
' [DllImport("user32.dll")] public static extern bool GetWindowRect(IntPtr hWnd, out RECT rect);',
|
|
141
|
+
' [DllImport("user32.dll")] public static extern bool IsIconic(IntPtr hWnd);',
|
|
142
|
+
' [DllImport("user32.dll")] public static extern bool IsWindowVisible(IntPtr hWnd);',
|
|
143
|
+
' [DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr hWnd, int command);',
|
|
144
|
+
' [DllImport("user32.dll")] public static extern bool PrintWindow(IntPtr hWnd, IntPtr hdc, uint flags);',
|
|
145
|
+
' [StructLayout(LayoutKind.Sequential)] public struct RECT { public int Left; public int Top; public int Right; public int Bottom; }',
|
|
146
|
+
'}',
|
|
147
|
+
'"@',
|
|
148
|
+
'[void][DshShot]::SetProcessDPIAware()',
|
|
149
|
+
'# Several processes share the app name, and a hidden helper window can be the',
|
|
150
|
+
'# first one with a handle: pick the visible, titled, un-minimised, largest one.',
|
|
151
|
+
'$best = $null',
|
|
152
|
+
'$bestScore = -1',
|
|
153
|
+
'$bestRect = $null',
|
|
154
|
+
'$bestIconic = $false',
|
|
155
|
+
"$bestTitle = ''",
|
|
156
|
+
"foreach ($candidate in (Get-Process -Name 'DeepSeek Harness' -ErrorAction SilentlyContinue)) {",
|
|
157
|
+
' $handle = $candidate.MainWindowHandle',
|
|
158
|
+
' if ($handle -eq 0) { continue }',
|
|
159
|
+
' if (-not [DshShot]::IsWindowVisible($handle)) { continue }',
|
|
160
|
+
' $rect = New-Object DshShot+RECT',
|
|
161
|
+
' [void][DshShot]::GetWindowRect($handle, [ref]$rect)',
|
|
162
|
+
' $area = ($rect.Right - $rect.Left) * ($rect.Bottom - $rect.Top)',
|
|
163
|
+
' if ($area -le 0) { continue }',
|
|
164
|
+
' $titled = $candidate.MainWindowTitle.Length -gt 0',
|
|
165
|
+
' $iconic = [DshShot]::IsIconic($handle)',
|
|
166
|
+
' $score = $area',
|
|
167
|
+
' if ($titled) { $score = $score + 100000000 }',
|
|
168
|
+
' if (-not $iconic) { $score = $score + 10000000 }',
|
|
169
|
+
' if ($score -gt $bestScore) { $bestScore = $score; $best = $candidate; $bestRect = $rect; $bestIconic = $iconic; $bestTitle = $candidate.MainWindowTitle }',
|
|
170
|
+
'}',
|
|
171
|
+
'if ($null -eq $best) { Write-Output "error=no-window"; exit 2 }',
|
|
172
|
+
'$h = $best.MainWindowHandle',
|
|
173
|
+
'if ($bestIconic) {',
|
|
174
|
+
` if (${restore === true ? '$true' : '$false'}) {`,
|
|
175
|
+
' # A minimized window has no rendered surface: restore it, then re-measure.',
|
|
176
|
+
' [void][DshShot]::ShowWindow($h, 9)',
|
|
177
|
+
' Start-Sleep -Milliseconds 600',
|
|
178
|
+
' [void][DshShot]::GetWindowRect($h, [ref]$bestRect)',
|
|
179
|
+
' $bestIconic = $false',
|
|
180
|
+
' } else {',
|
|
181
|
+
' Write-Output "error=minimized"',
|
|
182
|
+
' exit 4',
|
|
183
|
+
' }',
|
|
184
|
+
'}',
|
|
185
|
+
'$r = $bestRect',
|
|
186
|
+
'$w = $r.Right - $r.Left',
|
|
187
|
+
'$t = $r.Bottom - $r.Top',
|
|
188
|
+
'if ($w -le 0 -or $t -le 0) { Write-Output "error=bad-rect"; exit 5 }',
|
|
189
|
+
'$bmp = New-Object System.Drawing.Bitmap $w, $t',
|
|
190
|
+
'$g = [System.Drawing.Graphics]::FromImage($bmp)',
|
|
191
|
+
'$hdc = $g.GetHdc()',
|
|
192
|
+
'$ok = [DshShot]::PrintWindow($h, $hdc, 2)',
|
|
193
|
+
'$g.ReleaseHdc($hdc)',
|
|
194
|
+
'$g.Dispose()',
|
|
195
|
+
'if (-not $ok) { Write-Output "error=printwindow"; exit 3 }',
|
|
196
|
+
`$cx = ${Math.round(crop.x)}`,
|
|
197
|
+
`$cy = ${Math.round(crop.y)}`,
|
|
198
|
+
`$cw = ${Math.round(crop.width)}`,
|
|
199
|
+
`$ch = ${Math.round(crop.height)}`,
|
|
200
|
+
'if ($cw -gt 0 -and $ch -gt 0) {',
|
|
201
|
+
' if ($cx -lt 0) { $cx = 0 }',
|
|
202
|
+
' if ($cy -lt 0) { $cy = 0 }',
|
|
203
|
+
' if ($cx + $cw -gt $w) { $cw = $w - $cx }',
|
|
204
|
+
' if ($cy + $ch -gt $t) { $ch = $t - $cy }',
|
|
205
|
+
' if ($cw -gt 0 -and $ch -gt 0) {',
|
|
206
|
+
' $c = New-Object System.Drawing.Bitmap $cw, $ch',
|
|
207
|
+
' $g2 = [System.Drawing.Graphics]::FromImage($c)',
|
|
208
|
+
' $g2.DrawImage($bmp, (New-Object System.Drawing.Rectangle 0, 0, $cw, $ch), (New-Object System.Drawing.Rectangle $cx, $cy, $cw, $ch), [System.Drawing.GraphicsUnit]::Pixel)',
|
|
209
|
+
' $g2.Dispose()',
|
|
210
|
+
' $bmp.Dispose()',
|
|
211
|
+
' $bmp = $c',
|
|
212
|
+
' }',
|
|
213
|
+
'}',
|
|
214
|
+
`$bmp.Save('${outPath.replace(/'/gu, "''")}', [System.Drawing.Imaging.ImageFormat]::Png)`,
|
|
215
|
+
'$bmp.Dispose()',
|
|
216
|
+
'Write-Output ("ok " + $w + "x" + $t + " title=" + $bestTitle)'
|
|
217
|
+
].join('\n');
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Run one PowerShell script without touching a file or a shell.
|
|
222
|
+
*
|
|
223
|
+
* The script is handed over as -EncodedCommand (UTF-16LE base64) so no file
|
|
224
|
+
* encoding, quoting or code page can corrupt it. CRLF line endings are required:
|
|
225
|
+
* a here-string terminator is only recognised at the start of a line, and
|
|
226
|
+
* Windows PowerShell 5.1 does not accept an LF-only terminator when the script
|
|
227
|
+
* arrives this way.
|
|
228
|
+
* @param script - ASCII script source.
|
|
229
|
+
* @param timeoutMs - kill the process after this long.
|
|
230
|
+
* @returns the trimmed standard output.
|
|
231
|
+
*/
|
|
232
|
+
export function runPowerShell(script, timeoutMs = 20_000) {
|
|
233
|
+
const encoded = Buffer.from(script.replace(/\r?\n/gu, '\r\n'), 'utf16le').toString('base64');
|
|
234
|
+
return new Promise((resolve, reject) => {
|
|
235
|
+
execFile('powershell.exe', ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-EncodedCommand', encoded], {
|
|
236
|
+
timeout: timeoutMs,
|
|
237
|
+
windowsHide: true,
|
|
238
|
+
maxBuffer: 8 * 1024 * 1024
|
|
239
|
+
}, (error, stdout, stderr) => {
|
|
240
|
+
if (error !== null && error !== undefined) {
|
|
241
|
+
const detail = [String(stdout || '').trim(), String(stderr || '').trim()].filter((part) => part.length > 0).join(' | ').slice(0, 400);
|
|
242
|
+
const killed = error.killed === true ? '(进程超时被终止)' : '';
|
|
243
|
+
reject(new Error(`窗口截图失败${killed}:${detail.length > 0 ? detail : error.message}`));
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
resolve(String(stdout).trim());
|
|
247
|
+
});
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Map the Client's reported page geometry to pixels inside the captured window.
|
|
253
|
+
* @param geometry - `{ rect, devicePixelRatio, outer }` from the Client half.
|
|
254
|
+
* @returns `{ x, y, width, height }` in window pixels.
|
|
255
|
+
*/
|
|
256
|
+
export function geometryToCrop(geometry) {
|
|
257
|
+
const dpr = Number.isFinite(geometry?.devicePixelRatio) && geometry.devicePixelRatio > 0 ? geometry.devicePixelRatio : 1;
|
|
258
|
+
const rect = geometry?.rect ?? {};
|
|
259
|
+
const outer = geometry?.outer ?? {};
|
|
260
|
+
const frameX = Number.isFinite(outer.width) && Number.isFinite(outer.innerWidth) ? Math.max(0, (outer.width - outer.innerWidth) / 2) : 0;
|
|
261
|
+
const frameY = Number.isFinite(outer.height) && Number.isFinite(outer.innerHeight) ? Math.max(0, outer.height - outer.innerHeight - frameX) : 0;
|
|
262
|
+
return {
|
|
263
|
+
x: (frameX + (Number(rect.left) || 0)) * dpr,
|
|
264
|
+
y: (frameY + (Number(rect.top) || 0)) * dpr,
|
|
265
|
+
width: (Number(rect.width) || 0) * dpr,
|
|
266
|
+
height: (Number(rect.height) || 0) * dpr
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Capture the DSH window (optionally cropped to the Browser pane).
|
|
272
|
+
* @param crop - pixel rectangle from {@link geometryToCrop}.
|
|
273
|
+
* @param timeoutMs - capture timeout.
|
|
274
|
+
* @param restore - restore a minimized window first (it becomes visible again).
|
|
275
|
+
* @returns PNG bytes.
|
|
276
|
+
*/
|
|
277
|
+
export async function captureWindow(crop, timeoutMs = 20_000, restore = false) {
|
|
278
|
+
if (process.platform !== 'win32') {
|
|
279
|
+
throw new Error('截图目前只在 Windows 上实现(PrintWindow)。其他平台请用 browser_control 的 external 在系统浏览器里查看,或用 browser_page 的 text/elements。');
|
|
280
|
+
}
|
|
281
|
+
const outPath = join(tmpdir(), `dsh-sidebar-browser-shot-${Date.now()}.png`);
|
|
282
|
+
const output = await runPowerShell(captureScript(outPath, crop, restore), timeoutMs);
|
|
283
|
+
if (output.startsWith('error=')) {
|
|
284
|
+
const reason = output.slice('error='.length);
|
|
285
|
+
const explain = {
|
|
286
|
+
'no-window': '找不到 DSH 窗口(应用可能刚启动或已退出)。',
|
|
287
|
+
minimized: 'DSH 窗口最小化了:最小化的窗口没有可渲染表面,抓不到。请还原窗口,或带 restore: true 让插件先还原它(窗口会重新出现在屏幕上)。',
|
|
288
|
+
'bad-rect': 'DSH 窗口尺寸无效,可能正处于最小化或切换状态。',
|
|
289
|
+
printwindow: '系统拒绝了窗口抓取(PrintWindow 返回失败)。'
|
|
290
|
+
};
|
|
291
|
+
throw new Error(explain[reason] ?? `窗口截图失败:${reason}`);
|
|
292
|
+
}
|
|
293
|
+
return await readFile(outPath);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Persist a picture in the workspace and attach it to the tool result.
|
|
298
|
+
* @param ctx - plugin context.
|
|
299
|
+
* @param exec - execution whose agent owns the workspace.
|
|
300
|
+
* @param bytes - image bytes.
|
|
301
|
+
* @param label - file name stem.
|
|
302
|
+
* @param mediaType - image media type.
|
|
303
|
+
* @returns `{ path, bytes, image?, note? }`.
|
|
304
|
+
*/
|
|
305
|
+
async function saveScreenshot(ctx, exec, bytes, label, mediaType = 'image/png') {
|
|
306
|
+
const cwd = exec?.agent?.session?.header?.cwd;
|
|
307
|
+
const root = typeof cwd === 'string' && cwd.length > 0 ? cwd : process.cwd();
|
|
308
|
+
const stamp = new Date().toISOString().replace(/[:.]/gu, '-');
|
|
309
|
+
const safe = String(label ?? 'tab').replace(/[^A-Za-z0-9_-]/gu, '');
|
|
310
|
+
const extension = mediaType === 'image/jpeg' ? 'jpg' : mediaType === 'image/webp' ? 'webp' : mediaType === 'image/gif' ? 'gif' : 'png';
|
|
311
|
+
const relative = join('browser-shots', `${stamp}-${safe.length > 0 ? safe : 'tab'}.${extension}`);
|
|
312
|
+
const absolute = isAbsolute(relative) ? relative : resolvePath(root, relative);
|
|
313
|
+
const saved = { path: relative, bytes: bytes.length };
|
|
314
|
+
try {
|
|
315
|
+
await mkdir(dirname(absolute), { recursive: true });
|
|
316
|
+
await writeFile(absolute, bytes);
|
|
317
|
+
} catch (error) {
|
|
318
|
+
return { ...saved, note: `图片已获取但写入工作区失败:${String(error?.message ?? error)}` };
|
|
319
|
+
}
|
|
320
|
+
const attachments = ctx.get('attachments');
|
|
321
|
+
if (attachments === undefined || typeof attachments.saveImage !== 'function') return saved;
|
|
322
|
+
const attachable = ['image/png', 'image/jpeg', 'image/webp', 'image/gif'];
|
|
323
|
+
if (!attachable.includes(mediaType)) {
|
|
324
|
+
return { ...saved, note: `图片已存到工作区,但 ${mediaType} 不能作为附件显示(原图请在文件里打开)。` };
|
|
325
|
+
}
|
|
326
|
+
try {
|
|
327
|
+
const image = await attachments.saveImage({ data: bytes, mediaType, name: relative });
|
|
328
|
+
return { ...saved, image };
|
|
329
|
+
} catch (error) {
|
|
330
|
+
return { ...saved, note: `图片已保存到工作区,但无法附加到结果:${String(error?.message ?? error)}` };
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Turn the Client's picture reference into bytes.
|
|
336
|
+
*
|
|
337
|
+
* The Host fetches HTTP(S) sources because the page's own `fetch` is bound by
|
|
338
|
+
* CORS; `data:` and `blob:` sources arrive already decoded.
|
|
339
|
+
* @param found - value from the Client's `image` operation.
|
|
340
|
+
* @param signal - caller cancellation.
|
|
341
|
+
* @returns `{ bytes, media_type }`.
|
|
342
|
+
*/
|
|
343
|
+
async function loadPicture(found, signal) {
|
|
344
|
+
const fallbackType = typeof found?.media_type === 'string' ? found.media_type : 'image/png';
|
|
345
|
+
if (typeof found?.inline === 'string') {
|
|
346
|
+
const bytes = Buffer.from(found.inline, 'base64');
|
|
347
|
+
if (bytes.length === 0) throw new Error('内联图片是空的。');
|
|
348
|
+
return { bytes, media_type: fallbackType };
|
|
349
|
+
}
|
|
350
|
+
/* `image_url` is the picture's own address; `url` stays the page's address. */
|
|
351
|
+
const url = typeof found?.image_url === 'string' ? found.image_url : undefined;
|
|
352
|
+
if (url === undefined) throw new Error('没有拿到图片地址。');
|
|
353
|
+
if (!/^https?:/u.test(url)) throw new Error(`不支持的图片来源:${url.slice(0, 80)}`);
|
|
354
|
+
const response = await fetch(url, { signal, redirect: 'follow' });
|
|
355
|
+
if (!response.ok) throw new Error(`下载图片失败:HTTP ${response.status}(该地址可能需要登录,或禁止直接访问)。`);
|
|
356
|
+
const bytes = Buffer.from(await response.arrayBuffer());
|
|
357
|
+
const limit = 8 * 1024 * 1024;
|
|
358
|
+
if (bytes.length > limit) throw new Error(`图片太大(${Math.round(bytes.length / 1024)} KB),超过 8 MB 上限。`);
|
|
359
|
+
const header = response.headers.get('content-type') ?? '';
|
|
360
|
+
const type = /^image\//u.test(header) ? header.split(';')[0] : fallbackType;
|
|
361
|
+
return { bytes, media_type: type };
|
|
362
|
+
}
|
|
363
|
+
|
|
107
364
|
/** One connected browser client and its pending long-poll. */
|
|
108
365
|
class Client {
|
|
109
366
|
/** @param id - opaque per-page identity. */
|
|
@@ -309,7 +566,11 @@ class Pilot {
|
|
|
309
566
|
/** Render the tab list as tool text. */
|
|
310
567
|
function tabsText(value) {
|
|
311
568
|
const tabs = Array.isArray(value?.tabs) ? value.tabs : [];
|
|
312
|
-
const
|
|
569
|
+
const capture = value?.capture;
|
|
570
|
+
const captureLine = capture === undefined || capture === null
|
|
571
|
+
? ' capture=n/a'
|
|
572
|
+
: `\ncapture: guest=${capture.guest === true ? 'yes' : 'no'} desktopCapturer=${capture.desktop_capturer === true ? 'yes' : 'no'} displayMedia=${capture.display_media === true ? 'yes' : 'no'}${Array.isArray(capture.carrier_keys) && capture.carrier_keys.length > 0 ? ` carrier=[${capture.carrier_keys.join(',')}]` : ''}`;
|
|
573
|
+
const header = `platform=${value?.platform ?? 'unknown'} page_control=${value?.page_control === true ? 'yes' : 'no'} panel_open=${value?.panel_open === true ? 'yes' : 'no'}${captureLine}`;
|
|
313
574
|
if (tabs.length === 0) return `${header}\n内置浏览器当前没有打开任何标签页。`;
|
|
314
575
|
const lines = tabs.map((tab) => {
|
|
315
576
|
const marks = [tab.active === true ? 'active' : '', tab.on_screen === true ? 'on-screen' : 'other-session'].filter((mark) => mark.length > 0).join(',');
|
|
@@ -325,18 +586,34 @@ function actionText(value) {
|
|
|
325
586
|
const parts = [];
|
|
326
587
|
if (typeof value.action === 'string') parts.push(value.action);
|
|
327
588
|
if (typeof value.tab_id === 'string') parts.push(`tab=${value.tab_id}`);
|
|
589
|
+
if (typeof value.opened_tab === 'string') parts.push(`opened=${value.opened_tab}`);
|
|
328
590
|
if (typeof value.url === 'string' && value.url.length > 0) parts.push(value.url);
|
|
329
591
|
if (typeof value.title === 'string' && value.title.length > 0) parts.push(`title=${value.title}`);
|
|
330
592
|
const head = parts.length > 0 ? parts.join(' ') : '完成';
|
|
331
593
|
const body = [];
|
|
332
594
|
if (typeof value.text === 'string' && value.text.length > 0) body.push(value.text);
|
|
595
|
+
if (typeof value.opened_url === 'string' && value.opened_url.length > 0) body.push(`新标签页地址:${value.opened_url}`);
|
|
596
|
+
if (typeof value.path === 'string' && value.path.length > 0) body.push(`图片已保存:${value.path}${typeof value.width === 'number' && typeof value.height === 'number' ? `(${value.width}×${value.height})` : ''}`);
|
|
333
597
|
if (typeof value.note === 'string' && value.note.length > 0) body.push(`注意:${value.note}`);
|
|
334
598
|
if (value.value !== undefined && value.value !== null) body.push(JSON.stringify(value.value));
|
|
335
599
|
if (Array.isArray(value.links) && value.links.length > 0) {
|
|
336
600
|
body.push(value.links.map((link) => `- ${link.text ?? ''} ${link.href ?? ''}`).join('\n'));
|
|
337
601
|
}
|
|
602
|
+
if (Array.isArray(value.images) && value.images.length > 0) {
|
|
603
|
+
body.push(value.images.map((picture) => `- [${picture.index ?? '?'}] ${picture.tag ?? 'img'} ${picture.width ?? '?'}×${picture.height ?? '?'}${picture.alt !== undefined ? ` alt="${picture.alt}"` : ''} ${picture.src ?? ''}`).join('\n'));
|
|
604
|
+
}
|
|
338
605
|
if (Array.isArray(value.elements) && value.elements.length > 0) {
|
|
339
|
-
body.push(value.elements.map((element) =>
|
|
606
|
+
body.push(value.elements.map((element) => {
|
|
607
|
+
const meta = [
|
|
608
|
+
element.type !== undefined && element.type !== element.tag ? `type=${element.type}` : '',
|
|
609
|
+
element.name !== undefined ? `name=${element.name}` : '',
|
|
610
|
+
element.autocomplete !== undefined ? `autocomplete=${element.autocomplete}` : '',
|
|
611
|
+
element.checked === true ? 'checked' : ''
|
|
612
|
+
].filter((part) => part.length > 0).join(' ');
|
|
613
|
+
const label = element.label !== undefined && element.label.length > 0 ? ` “${element.label}”` : '';
|
|
614
|
+
const href = typeof element.href === 'string' && element.href.length > 0 ? ` (${element.href})` : '';
|
|
615
|
+
return `- ${element.selector ?? ''}${meta.length > 0 ? ` [${meta}]` : ''} — ${element.text ?? ''}${label}${href}`;
|
|
616
|
+
}).join('\n'));
|
|
340
617
|
}
|
|
341
618
|
return body.length === 0 ? head : `${head}\n${body.join('\n')}`;
|
|
342
619
|
}
|
|
@@ -464,21 +741,45 @@ export function apply(ctx) {
|
|
|
464
741
|
|
|
465
742
|
ctx.effect(() => ctx.tools.register({
|
|
466
743
|
name: 'browser_page',
|
|
467
|
-
description: "Read and act on the page inside DSH's built-in browser tab (Desktop app only; browser_tabs reports page_control). text returns the page's visible text, links lists anchors, elements lists interactive elements with a usable selector
|
|
744
|
+
description: "Read and act on the page inside DSH's built-in browser tab (Desktop app only; browser_tabs reports page_control). text returns the page's visible text, links lists anchors, elements lists interactive elements (with type/name/label/placeholder/autocomplete for form fields) and a usable selector, eval runs JavaScript and returns its JSON value, click clicks a selector or the element containing text, type fills one field, fill fills several fields by label/name/placeholder/selector and can submit, key presses Enter/Tab/Escape, login finds the account and password fields (including inside a same-origin iframe), fills and submits them, scroll scrolls, wait waits for a selector, text or url_contains, image returns a picture from the page so you can look at it, screenshot captures the browser pane itself with vision. A link that opens a new window becomes a NEW sidebar tab: click reports it as opened_tab, and you must use that id afterwards. A page that failed to load is reported as such instead of returning empty content. Passwords are never echoed in a result.",
|
|
468
745
|
parameters: {
|
|
469
746
|
type: 'object',
|
|
470
747
|
additionalProperties: false,
|
|
471
748
|
properties: {
|
|
472
749
|
action: {
|
|
473
750
|
type: 'string',
|
|
474
|
-
enum: ['text', 'links', 'elements', 'eval', 'click', 'type', 'scroll', 'wait'],
|
|
751
|
+
enum: ['text', 'links', 'elements', 'eval', 'click', 'type', 'fill', 'key', 'login', 'scroll', 'wait', 'image', 'images', 'screenshot'],
|
|
475
752
|
description: 'What to do with the page.'
|
|
476
753
|
},
|
|
477
754
|
tab_id: { type: 'string', description: 'Target tab id from browser_tabs; omit for the active tab.' },
|
|
478
|
-
selector: { type: 'string', description: 'CSS selector for click/type/scroll/wait.' },
|
|
755
|
+
selector: { type: 'string', description: 'CSS selector for click/type/scroll/wait/image.' },
|
|
479
756
|
text: { type: 'string', description: 'Text to type, or text to match for click/wait when no selector is given.' },
|
|
480
757
|
code: { type: 'string', description: 'JavaScript for eval. Use an expression, or statements ending in a return.' },
|
|
481
|
-
|
|
758
|
+
fields: {
|
|
759
|
+
type: 'array',
|
|
760
|
+
description: 'fill: one entry per field. Each entry matches a field by selector, or by label/name/placeholder/type, and carries the value to set.',
|
|
761
|
+
items: {
|
|
762
|
+
type: 'object',
|
|
763
|
+
additionalProperties: false,
|
|
764
|
+
properties: {
|
|
765
|
+
selector: { type: 'string' },
|
|
766
|
+
label: { type: 'string' },
|
|
767
|
+
name: { type: 'string' },
|
|
768
|
+
placeholder: { type: 'string' },
|
|
769
|
+
type: { type: 'string' },
|
|
770
|
+
value: { type: 'string' }
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
},
|
|
774
|
+
user: { type: 'string', description: 'login: account name, email or phone to fill in.' },
|
|
775
|
+
password: { type: 'string', description: 'login: password to fill in. It is used once and never returned.' },
|
|
776
|
+
user_selector: { type: 'string', description: 'login: explicit CSS selector for the account field.' },
|
|
777
|
+
password_selector: { type: 'string', description: 'login: explicit CSS selector for the password field.' },
|
|
778
|
+
key: { type: 'string', description: 'key: Enter (default), Tab, Escape, ArrowDown…' },
|
|
779
|
+
index: { type: 'number', description: 'image: 1-based index from the images action.' },
|
|
780
|
+
restore: { type: 'boolean', description: 'screenshot: restore the window first when it is minimized (it becomes visible again). Refused by default, because the capture cannot render a minimized window.' },
|
|
781
|
+
submit: { type: 'boolean', description: 'type/fill: submit the form afterwards.' },
|
|
782
|
+
url_contains: { type: 'string', description: 'wait: wait until the address contains this text.' },
|
|
482
783
|
ms: { type: 'number', description: 'wait: timeout in milliseconds (default 10000). scroll: pixels to scroll (default one viewport).' },
|
|
483
784
|
max_chars: { type: 'number', description: 'text/links: truncate the returned text (default 8000 characters).' }
|
|
484
785
|
},
|
|
@@ -493,12 +794,47 @@ export function apply(ctx) {
|
|
|
493
794
|
url: { type: 'string' },
|
|
494
795
|
title: { type: 'string' },
|
|
495
796
|
text: { type: 'string' },
|
|
496
|
-
note: { type: 'string' }
|
|
797
|
+
note: { type: 'string' },
|
|
798
|
+
path: { type: 'string' },
|
|
799
|
+
bytes: { type: 'number' },
|
|
800
|
+
image: { type: 'object' }
|
|
497
801
|
}
|
|
498
802
|
},
|
|
499
|
-
render: (_args, value) =>
|
|
803
|
+
render: (_args, value) => {
|
|
804
|
+
const blocks = [{ type: 'text', text: actionText(value) }];
|
|
805
|
+
if (value?.image !== undefined && value.image !== null) blocks.push({ type: 'image', attachment: value.image });
|
|
806
|
+
return blocks;
|
|
807
|
+
}
|
|
500
808
|
},
|
|
501
809
|
async execute(args, exec) {
|
|
810
|
+
if (args?.action === 'screenshot') {
|
|
811
|
+
const geometry = await pilot.call('geometry', { tab_id: args.tab_id }, exec.signal);
|
|
812
|
+
const crop = geometryToCrop(geometry);
|
|
813
|
+
const restore = args?.restore === true;
|
|
814
|
+
const bytes = await captureWindow(crop, 20_000, restore);
|
|
815
|
+
const saved = await saveScreenshot(ctx, exec, bytes, geometry?.tab_id ?? 'browser');
|
|
816
|
+
return {
|
|
817
|
+
action: 'screenshot',
|
|
818
|
+
tab_id: geometry?.tab_id,
|
|
819
|
+
url: geometry?.url,
|
|
820
|
+
title: geometry?.title,
|
|
821
|
+
width: Math.round(crop.width),
|
|
822
|
+
height: Math.round(crop.height),
|
|
823
|
+
restored: restore ? true : undefined,
|
|
824
|
+
...saved,
|
|
825
|
+
note: saved.note ?? (restore
|
|
826
|
+
? '这是内置浏览器面板本身的截图,已附在结果里;截图前窗口是被还原过的。'
|
|
827
|
+
: '这是内置浏览器面板本身的截图,已附在结果里。')
|
|
828
|
+
};
|
|
829
|
+
}
|
|
830
|
+
if (args?.action === 'image') {
|
|
831
|
+
const found = await pilot.call('image', args, exec.signal);
|
|
832
|
+
const loaded = await loadPicture(found, exec.signal);
|
|
833
|
+
const named = { ...found, ...loaded };
|
|
834
|
+
const saved = await saveScreenshot(ctx, exec, loaded.bytes, `image-${found.width ?? 0}x${found.height ?? 0}`);
|
|
835
|
+
const { bytes: _bytes, ...rest } = named;
|
|
836
|
+
return { ...rest, ...saved, note: saved.note ?? (found.alt !== undefined ? `页面里的图片:${found.alt}` : '页面里的图片,已附在结果里。') };
|
|
837
|
+
}
|
|
502
838
|
return await pilot.call('page', args, exec.signal);
|
|
503
839
|
}
|
|
504
840
|
}), 'sidebar-browser-control.tool.page');
|
|
@@ -506,8 +842,8 @@ export function apply(ctx) {
|
|
|
506
842
|
ctx.inject(['commands'], (scope) => {
|
|
507
843
|
scope.effect(() => scope.commands.register({
|
|
508
844
|
name: 'browser',
|
|
509
|
-
description: '驱动 DSH 内置浏览器:/browser open <url> | tabs | back | forward | reload | close | expand | collapse | fullscreen | split | read',
|
|
510
|
-
input: { hint: 'open <url> | tabs | back | forward | reload | close | expand | collapse | fullscreen | split | read' },
|
|
845
|
+
description: '驱动 DSH 内置浏览器:/browser open <url> | tabs | back | forward | reload | close | expand | collapse | fullscreen | split | read | images | image <N> | screenshot',
|
|
846
|
+
input: { hint: 'open <url> | tabs | back | forward | reload | close | expand | collapse | fullscreen | split | read | images | image <N> | screenshot' },
|
|
511
847
|
async handler(invocation) {
|
|
512
848
|
try {
|
|
513
849
|
return { kind: 'success', text: await runCommand(pilot, invocation) };
|
|
@@ -541,6 +877,21 @@ async function runCommand(pilot, invocation) {
|
|
|
541
877
|
}
|
|
542
878
|
case 'read':
|
|
543
879
|
return actionText(await pilot.call('page', { action: 'text', ...argument.length > 0 ? { tab_id: argument } : {} }, signal));
|
|
880
|
+
case 'screenshot': {
|
|
881
|
+
const geometry = await pilot.call('geometry', argument.length > 0 ? { tab_id: argument } : {}, signal);
|
|
882
|
+
const crop = geometryToCrop(geometry);
|
|
883
|
+
const bytes = await captureWindow(crop);
|
|
884
|
+
const saved = await saveScreenshot(ctx, { agent: invocation.agent }, bytes, geometry?.tab_id ?? 'browser');
|
|
885
|
+
return actionText({ action: 'screenshot', tab_id: geometry?.tab_id, url: geometry?.url, title: geometry?.title, width: Math.round(crop.width), height: Math.round(crop.height), ...saved });
|
|
886
|
+
}
|
|
887
|
+
case 'image': {
|
|
888
|
+
const found = await pilot.call('image', { index: Number.parseInt(argument, 10) || 1 }, signal);
|
|
889
|
+
const loaded = await loadPicture(found, signal);
|
|
890
|
+
const saved = await saveScreenshot(ctx, { agent: invocation.agent }, loaded.bytes, `image-${found.width ?? 0}x${found.height ?? 0}`, loaded.media_type);
|
|
891
|
+
return actionText({ ...found, ...saved });
|
|
892
|
+
}
|
|
893
|
+
case 'images':
|
|
894
|
+
return actionText(await pilot.call('page', { action: 'images', ...argument.length > 0 ? { tab_id: argument } : {} }, signal));
|
|
544
895
|
case 'back':
|
|
545
896
|
case 'forward':
|
|
546
897
|
case 'reload':
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-sidebar-browser-control",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Drive DSH's own built-in browser (the right-hand Sidebar's Browser tab) from the agent: list tabs, open, navigate,
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Drive DSH's own built-in browser (the right-hand Sidebar's Browser tab) from the agent: list tabs, open, navigate, read the page, run JavaScript, click, fill forms, log in, and look at pictures or the pane itself with vision. No second Chromium process.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"dsh",
|
|
7
7
|
"dsh-plugin",
|