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/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 header = `platform=${value?.platform ?? 'unknown'} page_control=${value?.page_control === true ? 'yes' : 'no'} panel_open=${value?.panel_open === true ? 'yes' : 'no'}`;
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) => `- ${element.selector ?? ''} ${element.text ?? ''}`).join('\n'));
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 for click, eval runs JavaScript in the page and returns its JSON value, click clicks a selector or the element containing text, type fills a field (optionally submitting), scroll scrolls, wait waits for a selector or text.",
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
- submit: { type: 'boolean', description: 'type: submit the form afterwards.' },
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) => [{ type: 'text', text: actionText(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.1.0",
4
- "description": "Drive DSH's own built-in browser (the right-hand Sidebar's Browser tab) from the agent: list tabs, open, navigate, back/forward, reload, read the page, run JavaScript, click and type. No second Chromium process.",
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",