dsh-sidebar-browser-control 0.0.0-stage → 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/LICENSE +21 -0
- package/README.md +202 -2
- package/README.zh.md +149 -0
- package/client.js +1365 -0
- package/cordis.patch.yml +12 -0
- package/icon.svg +7 -0
- package/index.js +912 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +70 -4
package/index.js
ADDED
|
@@ -0,0 +1,912 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-sidebar-browser-control — Host half.
|
|
3
|
+
*
|
|
4
|
+
* Gives the agent (and a `/browser` slash command) control over DSH's own
|
|
5
|
+
* built-in browser: the right-hand Sidebar's Browser tab
|
|
6
|
+
* (`@deepseek-ai/dsh-client-ui-sidebar-browser`).
|
|
7
|
+
*
|
|
8
|
+
* Architecture: the browser lives in the Web renderer, so the Host half owns
|
|
9
|
+
* the *operations* and the Client half (client.js) owns the *presentation*.
|
|
10
|
+
* They meet on four loopback routes served by `ctx.webServer`:
|
|
11
|
+
*
|
|
12
|
+
* POST /sidebar-browser-control/hello client announces itself
|
|
13
|
+
* GET /sidebar-browser-control/poll long-poll: Host -> Client commands
|
|
14
|
+
* POST /sidebar-browser-control/result client settles one command
|
|
15
|
+
* GET /sidebar-browser-control/status diagnostics
|
|
16
|
+
*
|
|
17
|
+
* A tool call enqueues a command for the most recently active client, the held
|
|
18
|
+
* long-poll delivers it immediately, and the call resolves when the client
|
|
19
|
+
* settles it — so one operation has exactly one implementation and two callers
|
|
20
|
+
* (tools and the slash command), while neither direction polls on a timer.
|
|
21
|
+
*
|
|
22
|
+
* Only the standard library is imported: a profile-installed bundle resolves
|
|
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.
|
|
30
|
+
*/
|
|
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
|
+
|
|
37
|
+
/** Route prefix owned by this plugin. */
|
|
38
|
+
const BASE = '/sidebar-browser-control';
|
|
39
|
+
/** How long one long-poll is held open before answering with no commands. */
|
|
40
|
+
const POLL_HOLD_MS = 10_000;
|
|
41
|
+
/** How long a command waits for the client to settle it. */
|
|
42
|
+
const CALL_TIMEOUT_MS = 30_000;
|
|
43
|
+
/** Largest request body accepted on the control routes. */
|
|
44
|
+
const MAX_BODY_BYTES = 2 * 1024 * 1024;
|
|
45
|
+
/** A client that has not spoken for this long stops receiving commands. */
|
|
46
|
+
const CLIENT_IDLE_MS = 120_000;
|
|
47
|
+
/** Requests may only come from the DSH application origin. */
|
|
48
|
+
const APPLICATION_ORIGIN = /^(dsh-app:\/\/app|https?:\/\/(?:127\.0\.0\.1|localhost|\[::1\])(?::\d+)?)$/iu;
|
|
49
|
+
|
|
50
|
+
/** Loader entry name for this plugin. */
|
|
51
|
+
export const name = 'sidebar-browser-control';
|
|
52
|
+
/** Hard dependencies: the tool registry and the HTTP carrier. */
|
|
53
|
+
export const inject = ['tools', 'webServer'];
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Answer one request with JSON.
|
|
57
|
+
* @param res - node response owning the socket.
|
|
58
|
+
* @param status - HTTP status code.
|
|
59
|
+
* @param value - JSON-serializable body.
|
|
60
|
+
*/
|
|
61
|
+
function sendJson(res, status, value) {
|
|
62
|
+
const body = JSON.stringify(value ?? null);
|
|
63
|
+
res.writeHead(status, {
|
|
64
|
+
'content-type': 'application/json; charset=utf-8',
|
|
65
|
+
'cache-control': 'no-store',
|
|
66
|
+
'content-length': Buffer.byteLength(body)
|
|
67
|
+
});
|
|
68
|
+
res.end(body);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Read and parse a JSON request body.
|
|
73
|
+
* @param req - node request.
|
|
74
|
+
* @returns the parsed body, or an empty object for an empty body.
|
|
75
|
+
*/
|
|
76
|
+
function readJson(req) {
|
|
77
|
+
return new Promise((resolve, reject) => {
|
|
78
|
+
const chunks = [];
|
|
79
|
+
let size = 0;
|
|
80
|
+
req.on('data', (chunk) => {
|
|
81
|
+
size += chunk.length;
|
|
82
|
+
if (size > MAX_BODY_BYTES) {
|
|
83
|
+
reject(new Error('request body is too large'));
|
|
84
|
+
req.destroy();
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
chunks.push(chunk);
|
|
88
|
+
});
|
|
89
|
+
req.on('end', () => {
|
|
90
|
+
if (chunks.length === 0) {
|
|
91
|
+
resolve({});
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
try {
|
|
95
|
+
resolve(JSON.parse(Buffer.concat(chunks).toString('utf8')));
|
|
96
|
+
} catch {
|
|
97
|
+
reject(new Error('request body is not JSON'));
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
req.on('error', reject);
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Admit one request by its browser-supplied origin signals, so a random page in
|
|
106
|
+
* the user's browser cannot drive the application's own browser panel.
|
|
107
|
+
* @param req - node request.
|
|
108
|
+
* @returns whether the request may proceed.
|
|
109
|
+
*/
|
|
110
|
+
function admitted(req) {
|
|
111
|
+
const site = req.headers['sec-fetch-site'];
|
|
112
|
+
if (typeof site === 'string' && site !== 'same-origin' && site !== 'none') return false;
|
|
113
|
+
const origin = req.headers.origin;
|
|
114
|
+
if (typeof origin === 'string' && origin.length > 0 && !APPLICATION_ORIGIN.test(origin)) return false;
|
|
115
|
+
return true;
|
|
116
|
+
}
|
|
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
|
+
|
|
364
|
+
/** One connected browser client and its pending long-poll. */
|
|
365
|
+
class Client {
|
|
366
|
+
/** @param id - opaque per-page identity. */
|
|
367
|
+
constructor(id) {
|
|
368
|
+
this.id = id;
|
|
369
|
+
this.platform = 'unknown';
|
|
370
|
+
this.capabilities = [];
|
|
371
|
+
this.href = undefined;
|
|
372
|
+
this.seenAt = Date.now();
|
|
373
|
+
this.focused = false;
|
|
374
|
+
this.queue = [];
|
|
375
|
+
this.waiter = undefined;
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/** Host-side command router: the single implementation behind every caller. */
|
|
380
|
+
class Pilot {
|
|
381
|
+
constructor() {
|
|
382
|
+
/** @type {Map<string, Client>} */
|
|
383
|
+
this.clients = new Map();
|
|
384
|
+
/** @type {Map<string, {resolve: Function, reject: Function}>} */
|
|
385
|
+
this.pending = new Map();
|
|
386
|
+
this.sequence = 0;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* @param prefix - identifier prefix.
|
|
391
|
+
* @returns a fresh opaque identifier.
|
|
392
|
+
*/
|
|
393
|
+
nextId(prefix) {
|
|
394
|
+
this.sequence += 1;
|
|
395
|
+
return `${prefix}${this.sequence}-${Math.random().toString(36).slice(2, 10)}`;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/** Forget clients that stopped talking. */
|
|
399
|
+
prune() {
|
|
400
|
+
const cutoff = Date.now() - CLIENT_IDLE_MS;
|
|
401
|
+
for (const [id, client] of this.clients) {
|
|
402
|
+
if (client.seenAt < cutoff && client.waiter === undefined) this.clients.delete(id);
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* Register or refresh one client page.
|
|
408
|
+
* @param input - client announcement.
|
|
409
|
+
* @returns the client record.
|
|
410
|
+
*/
|
|
411
|
+
hello(input) {
|
|
412
|
+
this.prune();
|
|
413
|
+
const claimed = typeof input.clientId === 'string' ? input.clientId : undefined;
|
|
414
|
+
const client = claimed !== undefined && this.clients.has(claimed) ? this.clients.get(claimed) : new Client(this.nextId('client-'));
|
|
415
|
+
client.platform = typeof input.platform === 'string' ? input.platform.slice(0, 32) : client.platform;
|
|
416
|
+
client.capabilities = Array.isArray(input.capabilities) ? input.capabilities.filter((entry) => typeof entry === 'string').slice(0, 32) : [];
|
|
417
|
+
client.href = typeof input.href === 'string' ? input.href.slice(0, 400) : undefined;
|
|
418
|
+
client.seenAt = Date.now();
|
|
419
|
+
this.clients.set(client.id, client);
|
|
420
|
+
return client;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/** @returns the client that most deserves the next command: focused first, then most recent. */
|
|
424
|
+
active() {
|
|
425
|
+
this.prune();
|
|
426
|
+
let best;
|
|
427
|
+
for (const client of this.clients.values()) {
|
|
428
|
+
if (best === undefined) {
|
|
429
|
+
best = client;
|
|
430
|
+
continue;
|
|
431
|
+
}
|
|
432
|
+
if (client.focused !== best.focused) {
|
|
433
|
+
if (client.focused === true) best = client;
|
|
434
|
+
continue;
|
|
435
|
+
}
|
|
436
|
+
if (client.seenAt > best.seenAt) best = client;
|
|
437
|
+
}
|
|
438
|
+
return best;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* Hold one long-poll open for a client until a command arrives.
|
|
443
|
+
* @param clientId - announcing client.
|
|
444
|
+
* @param res - node response to keep open.
|
|
445
|
+
* @param focused - whether the page reported window focus.
|
|
446
|
+
* @returns whether the client is known.
|
|
447
|
+
*/
|
|
448
|
+
hold(clientId, res, focused) {
|
|
449
|
+
const client = this.clients.get(clientId);
|
|
450
|
+
if (client === undefined) return false;
|
|
451
|
+
client.seenAt = Date.now();
|
|
452
|
+
client.focused = focused === true;
|
|
453
|
+
const waiter = { res, timer: undefined, closed: false, finish: undefined };
|
|
454
|
+
waiter.finish = (value) => {
|
|
455
|
+
if (waiter.closed) return;
|
|
456
|
+
waiter.closed = true;
|
|
457
|
+
if (client.waiter === waiter) client.waiter = undefined;
|
|
458
|
+
clearTimeout(waiter.timer);
|
|
459
|
+
sendJson(res, 200, value);
|
|
460
|
+
};
|
|
461
|
+
res.on('close', () => {
|
|
462
|
+
if (waiter.closed) return;
|
|
463
|
+
waiter.closed = true;
|
|
464
|
+
clearTimeout(waiter.timer);
|
|
465
|
+
if (client.waiter === waiter) client.waiter = undefined;
|
|
466
|
+
});
|
|
467
|
+
waiter.timer = setTimeout(() => waiter.finish({ ok: true, commands: [] }), POLL_HOLD_MS);
|
|
468
|
+
client.waiter = waiter;
|
|
469
|
+
this.flush(client);
|
|
470
|
+
return true;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/**
|
|
474
|
+
* Deliver queued commands to a held poll, if both exist.
|
|
475
|
+
* @param client - the client to flush.
|
|
476
|
+
*/
|
|
477
|
+
flush(client) {
|
|
478
|
+
const waiter = client.waiter;
|
|
479
|
+
if (waiter === undefined || client.queue.length === 0) return;
|
|
480
|
+
const commands = client.queue.splice(0, client.queue.length);
|
|
481
|
+
waiter.finish({ ok: true, commands });
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* Run one operation in the connected browser page.
|
|
486
|
+
* @param op - client operation name.
|
|
487
|
+
* @param args - operation arguments.
|
|
488
|
+
* @param signal - caller cancellation.
|
|
489
|
+
* @returns the operation result.
|
|
490
|
+
*/
|
|
491
|
+
async call(op, args, signal) {
|
|
492
|
+
const client = this.active();
|
|
493
|
+
if (client === undefined) {
|
|
494
|
+
throw new Error('DSH 内置浏览器的控制端未连接:请打开 DSH 的 GUI 页面(插件的客户端半随页面加载),然后重试。');
|
|
495
|
+
}
|
|
496
|
+
const id = this.nextId('cmd-');
|
|
497
|
+
client.queue.push({ id, op, args: args ?? {} });
|
|
498
|
+
this.flush(client);
|
|
499
|
+
return await new Promise((resolve, reject) => {
|
|
500
|
+
const timer = setTimeout(() => {
|
|
501
|
+
settle();
|
|
502
|
+
reject(new Error(`内置浏览器在 ${Math.round(CALL_TIMEOUT_MS / 1000)} 秒内没有响应;GUI 可能已关闭、正在刷新,或被模态框阻塞。`));
|
|
503
|
+
}, CALL_TIMEOUT_MS);
|
|
504
|
+
const abort = () => {
|
|
505
|
+
settle();
|
|
506
|
+
reject(new Error('内置浏览器操作已取消。'));
|
|
507
|
+
};
|
|
508
|
+
const settle = () => {
|
|
509
|
+
clearTimeout(timer);
|
|
510
|
+
this.pending.delete(id);
|
|
511
|
+
signal?.removeEventListener('abort', abort);
|
|
512
|
+
};
|
|
513
|
+
if (signal !== undefined) {
|
|
514
|
+
if (signal.aborted) {
|
|
515
|
+
abort();
|
|
516
|
+
return;
|
|
517
|
+
}
|
|
518
|
+
signal.addEventListener('abort', abort, { once: true });
|
|
519
|
+
}
|
|
520
|
+
this.pending.set(id, {
|
|
521
|
+
resolve: (value) => {
|
|
522
|
+
settle();
|
|
523
|
+
resolve(value);
|
|
524
|
+
},
|
|
525
|
+
reject: (error) => {
|
|
526
|
+
settle();
|
|
527
|
+
reject(error);
|
|
528
|
+
}
|
|
529
|
+
});
|
|
530
|
+
});
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/**
|
|
534
|
+
* Settle one command from a client.
|
|
535
|
+
* @param input - client report.
|
|
536
|
+
* @returns whether a pending call was waiting for it.
|
|
537
|
+
*/
|
|
538
|
+
settle(input) {
|
|
539
|
+
const id = typeof input.id === 'string' ? input.id : undefined;
|
|
540
|
+
if (id === undefined) return false;
|
|
541
|
+
const entry = this.pending.get(id);
|
|
542
|
+
if (entry === undefined) return false;
|
|
543
|
+
if (input.ok === true) entry.resolve(input.value ?? null);
|
|
544
|
+
else entry.reject(new Error(typeof input.error === 'string' && input.error.length > 0 ? input.error : '内置浏览器操作失败。'));
|
|
545
|
+
return true;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/** @returns a JSON snapshot for diagnostics. */
|
|
549
|
+
status() {
|
|
550
|
+
this.prune();
|
|
551
|
+
return {
|
|
552
|
+
clients: [...this.clients.values()].map((client) => ({
|
|
553
|
+
id: client.id,
|
|
554
|
+
platform: client.platform,
|
|
555
|
+
capabilities: client.capabilities,
|
|
556
|
+
href: client.href,
|
|
557
|
+
held: client.waiter !== undefined,
|
|
558
|
+
queued: client.queue.length,
|
|
559
|
+
idleMs: Date.now() - client.seenAt
|
|
560
|
+
})),
|
|
561
|
+
pendingCalls: this.pending.size
|
|
562
|
+
};
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
/** Render the tab list as tool text. */
|
|
567
|
+
function tabsText(value) {
|
|
568
|
+
const tabs = Array.isArray(value?.tabs) ? value.tabs : [];
|
|
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}`;
|
|
574
|
+
if (tabs.length === 0) return `${header}\n内置浏览器当前没有打开任何标签页。`;
|
|
575
|
+
const lines = tabs.map((tab) => {
|
|
576
|
+
const marks = [tab.active === true ? 'active' : '', tab.on_screen === true ? 'on-screen' : 'other-session'].filter((mark) => mark.length > 0).join(',');
|
|
577
|
+
const address = typeof tab.url === 'string' && tab.url.length > 0 ? ` — ${tab.url}` : '';
|
|
578
|
+
return `- ${tab.tab_id}${marks.length > 0 ? ` [${marks}]` : ''} ${tab.title ?? ''}${address}`;
|
|
579
|
+
});
|
|
580
|
+
return `${header}\n${tabs.length} 个内置浏览器标签页:\n${lines.join('\n')}`;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/** Render one control/page result as tool text. */
|
|
584
|
+
function actionText(value) {
|
|
585
|
+
if (value === null || typeof value !== 'object') return String(value ?? '完成');
|
|
586
|
+
const parts = [];
|
|
587
|
+
if (typeof value.action === 'string') parts.push(value.action);
|
|
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}`);
|
|
590
|
+
if (typeof value.url === 'string' && value.url.length > 0) parts.push(value.url);
|
|
591
|
+
if (typeof value.title === 'string' && value.title.length > 0) parts.push(`title=${value.title}`);
|
|
592
|
+
const head = parts.length > 0 ? parts.join(' ') : '完成';
|
|
593
|
+
const body = [];
|
|
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})` : ''}`);
|
|
597
|
+
if (typeof value.note === 'string' && value.note.length > 0) body.push(`注意:${value.note}`);
|
|
598
|
+
if (value.value !== undefined && value.value !== null) body.push(JSON.stringify(value.value));
|
|
599
|
+
if (Array.isArray(value.links) && value.links.length > 0) {
|
|
600
|
+
body.push(value.links.map((link) => `- ${link.text ?? ''} ${link.href ?? ''}`).join('\n'));
|
|
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
|
+
}
|
|
605
|
+
if (Array.isArray(value.elements) && value.elements.length > 0) {
|
|
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'));
|
|
617
|
+
}
|
|
618
|
+
return body.length === 0 ? head : `${head}\n${body.join('\n')}`;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
/**
|
|
622
|
+
* Register the browser control surface.
|
|
623
|
+
* @param ctx - the plugin context.
|
|
624
|
+
*/
|
|
625
|
+
export function apply(ctx) {
|
|
626
|
+
const pilot = new Pilot();
|
|
627
|
+
|
|
628
|
+
ctx.effect(() => ctx.webServer.register({
|
|
629
|
+
kind: 'exact',
|
|
630
|
+
path: `${BASE}/hello`,
|
|
631
|
+
handler: async (req, res) => {
|
|
632
|
+
if (!admitted(req)) {
|
|
633
|
+
sendJson(res, 403, { ok: false, error: 'origin not allowed' });
|
|
634
|
+
return;
|
|
635
|
+
}
|
|
636
|
+
try {
|
|
637
|
+
const body = await readJson(req);
|
|
638
|
+
const client = pilot.hello(body);
|
|
639
|
+
sendJson(res, 200, { ok: true, clientId: client.id, holdMs: POLL_HOLD_MS });
|
|
640
|
+
} catch (error) {
|
|
641
|
+
sendJson(res, 400, { ok: false, error: String(error?.message ?? error) });
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
}), 'sidebar-browser-control.hello');
|
|
645
|
+
|
|
646
|
+
ctx.effect(() => ctx.webServer.register({
|
|
647
|
+
kind: 'exact',
|
|
648
|
+
path: `${BASE}/poll`,
|
|
649
|
+
handler: (req, res) => {
|
|
650
|
+
if (!admitted(req)) {
|
|
651
|
+
sendJson(res, 403, { ok: false, error: 'origin not allowed' });
|
|
652
|
+
return;
|
|
653
|
+
}
|
|
654
|
+
const clientId = new URL(req.url ?? '/', 'http://x').searchParams.get('clientId');
|
|
655
|
+
const focused = new URL(req.url ?? '/', 'http://x').searchParams.get('focus') === '1';
|
|
656
|
+
if (clientId === null || !pilot.hold(clientId, res, focused)) sendJson(res, 410, { ok: false, error: 'unknown-client' });
|
|
657
|
+
}
|
|
658
|
+
}), 'sidebar-browser-control.poll');
|
|
659
|
+
|
|
660
|
+
ctx.effect(() => ctx.webServer.register({
|
|
661
|
+
kind: 'exact',
|
|
662
|
+
path: `${BASE}/result`,
|
|
663
|
+
handler: async (req, res) => {
|
|
664
|
+
if (!admitted(req)) {
|
|
665
|
+
sendJson(res, 403, { ok: false, error: 'origin not allowed' });
|
|
666
|
+
return;
|
|
667
|
+
}
|
|
668
|
+
try {
|
|
669
|
+
const body = await readJson(req);
|
|
670
|
+
if (typeof body.clientId === 'string') {
|
|
671
|
+
const client = pilot.clients.get(body.clientId);
|
|
672
|
+
if (client !== undefined) client.seenAt = Date.now();
|
|
673
|
+
}
|
|
674
|
+
sendJson(res, 200, { ok: true, settled: pilot.settle(body) });
|
|
675
|
+
} catch (error) {
|
|
676
|
+
sendJson(res, 400, { ok: false, error: String(error?.message ?? error) });
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
}), 'sidebar-browser-control.result');
|
|
680
|
+
|
|
681
|
+
ctx.effect(() => ctx.webServer.register({
|
|
682
|
+
kind: 'exact',
|
|
683
|
+
path: `${BASE}/status`,
|
|
684
|
+
handler: (req, res) => {
|
|
685
|
+
if (!admitted(req)) {
|
|
686
|
+
sendJson(res, 403, { ok: false, error: 'origin not allowed' });
|
|
687
|
+
return;
|
|
688
|
+
}
|
|
689
|
+
sendJson(res, 200, { ok: true, ...pilot.status() });
|
|
690
|
+
}
|
|
691
|
+
}), 'sidebar-browser-control.status');
|
|
692
|
+
|
|
693
|
+
const sharedOutput = {
|
|
694
|
+
schema: {
|
|
695
|
+
type: 'object',
|
|
696
|
+
properties: {
|
|
697
|
+
action: { type: 'string' },
|
|
698
|
+
tab_id: { type: 'string' },
|
|
699
|
+
url: { type: 'string' },
|
|
700
|
+
title: { type: 'string' },
|
|
701
|
+
text: { type: 'string' },
|
|
702
|
+
note: { type: 'string' },
|
|
703
|
+
platform: { type: 'string' },
|
|
704
|
+
page_control: { type: 'boolean' }
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
};
|
|
708
|
+
|
|
709
|
+
ctx.effect(() => ctx.tools.register({
|
|
710
|
+
name: 'browser_tabs',
|
|
711
|
+
description: "List the tabs open in DSH's own built-in browser (the right-hand Sidebar's Browser panel), with each tab's id, title, URL and owning session. Also reports whether page-level control (read/click/type/eval) is available — it is on the Desktop app, not in a plain browser tab. Call this first when you need a tab id for browser_control or browser_page.",
|
|
712
|
+
parameters: { type: 'object', properties: {}, additionalProperties: false },
|
|
713
|
+
output: { ...sharedOutput, render: (_args, value) => [{ type: 'text', text: tabsText(value) }] },
|
|
714
|
+
async execute(_args, exec) {
|
|
715
|
+
return await pilot.call('tabs', {}, exec.signal);
|
|
716
|
+
}
|
|
717
|
+
}), 'sidebar-browser-control.tool.tabs');
|
|
718
|
+
|
|
719
|
+
ctx.effect(() => ctx.tools.register({
|
|
720
|
+
name: 'browser_control',
|
|
721
|
+
description: "Drive DSH's own built-in browser: open a URL in the right-hand Sidebar Browser, navigate an existing tab, go back/forward, reload, close or focus a tab, control the panel (expand/collapse/fullscreen/split/float/dock), or hand the current page to the system browser. Actions apply to the session currently on screen. Use browser_tabs to obtain a tab id; omitting tab_id acts on the active tab.",
|
|
722
|
+
parameters: {
|
|
723
|
+
type: 'object',
|
|
724
|
+
additionalProperties: false,
|
|
725
|
+
properties: {
|
|
726
|
+
action: {
|
|
727
|
+
type: 'string',
|
|
728
|
+
enum: ['open', 'navigate', 'back', 'forward', 'reload', 'close', 'focus', 'expand', 'collapse', 'fullscreen', 'split', 'float', 'dock', 'external'],
|
|
729
|
+
description: 'open: load a URL (a new tab unless tab_id is given, in which case that tab is replaced). navigate: load a URL in the given tab. back/forward/reload: drive the page. close/focus: tab lifecycle. expand/collapse/fullscreen: the Sidebar panel. split/float/dock: pane layout. external: open the current page in the system browser.'
|
|
730
|
+
},
|
|
731
|
+
url: { type: 'string', description: 'HTTP(S) address for open/navigate. A bare host is completed with https://' },
|
|
732
|
+
tab_id: { type: 'string', description: 'Target tab id from browser_tabs; omit for the active tab.' }
|
|
733
|
+
},
|
|
734
|
+
required: ['action']
|
|
735
|
+
},
|
|
736
|
+
output: { ...sharedOutput, render: (_args, value) => [{ type: 'text', text: actionText(value) }] },
|
|
737
|
+
async execute(args, exec) {
|
|
738
|
+
return await pilot.call('control', args, exec.signal);
|
|
739
|
+
}
|
|
740
|
+
}), 'sidebar-browser-control.tool.control');
|
|
741
|
+
|
|
742
|
+
ctx.effect(() => ctx.tools.register({
|
|
743
|
+
name: 'browser_page',
|
|
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.",
|
|
745
|
+
parameters: {
|
|
746
|
+
type: 'object',
|
|
747
|
+
additionalProperties: false,
|
|
748
|
+
properties: {
|
|
749
|
+
action: {
|
|
750
|
+
type: 'string',
|
|
751
|
+
enum: ['text', 'links', 'elements', 'eval', 'click', 'type', 'fill', 'key', 'login', 'scroll', 'wait', 'image', 'images', 'screenshot'],
|
|
752
|
+
description: 'What to do with the page.'
|
|
753
|
+
},
|
|
754
|
+
tab_id: { type: 'string', description: 'Target tab id from browser_tabs; omit for the active tab.' },
|
|
755
|
+
selector: { type: 'string', description: 'CSS selector for click/type/scroll/wait/image.' },
|
|
756
|
+
text: { type: 'string', description: 'Text to type, or text to match for click/wait when no selector is given.' },
|
|
757
|
+
code: { type: 'string', description: 'JavaScript for eval. Use an expression, or statements ending in a return.' },
|
|
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.' },
|
|
783
|
+
ms: { type: 'number', description: 'wait: timeout in milliseconds (default 10000). scroll: pixels to scroll (default one viewport).' },
|
|
784
|
+
max_chars: { type: 'number', description: 'text/links: truncate the returned text (default 8000 characters).' }
|
|
785
|
+
},
|
|
786
|
+
required: ['action']
|
|
787
|
+
},
|
|
788
|
+
output: {
|
|
789
|
+
schema: {
|
|
790
|
+
type: 'object',
|
|
791
|
+
properties: {
|
|
792
|
+
action: { type: 'string' },
|
|
793
|
+
tab_id: { type: 'string' },
|
|
794
|
+
url: { type: 'string' },
|
|
795
|
+
title: { type: 'string' },
|
|
796
|
+
text: { type: 'string' },
|
|
797
|
+
note: { type: 'string' },
|
|
798
|
+
path: { type: 'string' },
|
|
799
|
+
bytes: { type: 'number' },
|
|
800
|
+
image: { type: 'object' }
|
|
801
|
+
}
|
|
802
|
+
},
|
|
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
|
+
}
|
|
808
|
+
},
|
|
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
|
+
}
|
|
838
|
+
return await pilot.call('page', args, exec.signal);
|
|
839
|
+
}
|
|
840
|
+
}), 'sidebar-browser-control.tool.page');
|
|
841
|
+
|
|
842
|
+
ctx.inject(['commands'], (scope) => {
|
|
843
|
+
scope.effect(() => scope.commands.register({
|
|
844
|
+
name: 'browser',
|
|
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' },
|
|
847
|
+
async handler(invocation) {
|
|
848
|
+
try {
|
|
849
|
+
return { kind: 'success', text: await runCommand(pilot, invocation) };
|
|
850
|
+
} catch (error) {
|
|
851
|
+
return { kind: 'error', text: String(error?.message ?? error) };
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
}), 'sidebar-browser-control.command');
|
|
855
|
+
});
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
/**
|
|
859
|
+
* Run one `/browser` line against the same operations the tools use.
|
|
860
|
+
* @param pilot - command router.
|
|
861
|
+
* @param invocation - command invocation.
|
|
862
|
+
* @returns human-readable report text.
|
|
863
|
+
*/
|
|
864
|
+
async function runCommand(pilot, invocation) {
|
|
865
|
+
const [word, ...rest] = invocation.rawInput.trim().split(/\s+/u);
|
|
866
|
+
const verb = (word ?? '').toLowerCase() === '' ? 'tabs' : word.toLowerCase();
|
|
867
|
+
const argument = rest.join(' ').trim();
|
|
868
|
+
const signal = invocation.signal;
|
|
869
|
+
switch (verb) {
|
|
870
|
+
case 'tabs':
|
|
871
|
+
case 'list':
|
|
872
|
+
return tabsText(await pilot.call('tabs', {}, signal));
|
|
873
|
+
case 'open':
|
|
874
|
+
case 'go': {
|
|
875
|
+
if (argument.length === 0) throw new Error('用法:/browser open <url>');
|
|
876
|
+
return actionText(await pilot.call('control', { action: 'open', url: argument }, signal));
|
|
877
|
+
}
|
|
878
|
+
case 'read':
|
|
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));
|
|
895
|
+
case 'back':
|
|
896
|
+
case 'forward':
|
|
897
|
+
case 'reload':
|
|
898
|
+
case 'close':
|
|
899
|
+
case 'focus':
|
|
900
|
+
case 'expand':
|
|
901
|
+
case 'collapse':
|
|
902
|
+
case 'fullscreen':
|
|
903
|
+
case 'split':
|
|
904
|
+
case 'float':
|
|
905
|
+
case 'dock':
|
|
906
|
+
case 'external':
|
|
907
|
+
case 'navigate':
|
|
908
|
+
return actionText(await pilot.call('control', { action: verb, ...argument.length > 0 ? { tab_id: argument } : {} }, signal));
|
|
909
|
+
default:
|
|
910
|
+
throw new Error(`未知的 /browser 子命令:${verb}(可用:open、tabs、back、forward、reload、close、focus、expand、collapse、fullscreen、split、float、dock、external、read)`);
|
|
911
|
+
}
|
|
912
|
+
}
|