dsh-inner-browser-use 0.34.28 → 0.34.95
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 +53 -18
- package/README.zh.md +26 -14
- package/client.js +161 -22
- package/docs/ARCHITECTURE.md +15 -12
- package/docs/ARTIFACTS.md +1 -1
- package/docs/BENCH.md +209 -34
- package/docs/FLASH.md +84 -0
- package/docs/ROADMAP.md +21 -15
- package/docs/VERIFY.md +20 -10
- package/index.js +144 -24
- package/package.json +17 -3
- package/read.js +633 -29
- package/runtime/flash-app/main.js +232 -0
- package/runtime/flash-app/package.json +1 -0
- package/runtime/flash-setup.mjs +151 -0
- package/runtime/swf.mjs +233 -0
- package/scripts/check-flash-local.mjs +104 -0
- package/scripts/check-flash-mirror.mjs +73 -0
- package/scripts/check-flash-tools.mjs +74 -0
- package/scripts/check-flash-window.mjs +58 -0
- package/scripts/doctor.mjs +3 -1
- package/sidecar.js +1076 -96
- package/tools.js +1068 -144
package/README.md
CHANGED
|
@@ -10,17 +10,22 @@ live picture of that browser, and your own cursor keeps working on the picture.
|
|
|
10
10
|
|
|
11
11
|
| Tool | What it does |
|
|
12
12
|
| --- | --- |
|
|
13
|
-
| `
|
|
14
|
-
| `
|
|
15
|
-
| `
|
|
16
|
-
| `
|
|
17
|
-
| `
|
|
18
|
-
| `
|
|
19
|
-
| `
|
|
20
|
-
| `
|
|
21
|
-
| `
|
|
22
|
-
|
|
23
|
-
`
|
|
13
|
+
| `browser_open` | Starts the browser (or reuses the running one) and opens an address. `headless: false` shows a real window on the desktop instead. |
|
|
14
|
+
| `browser_view` | Returns the address, the title, every control as `1 … 120`, and the visible text. |
|
|
15
|
+
| `browser_read` | Reads the page the way a person does: one Markdown block of headings, paragraphs, lists and links, no controls. Far fewer tokens than a snapshot on long pages, and the links stay usable. Give it a `query` and only the passages that answer it come back, ranked (BM25 over Readability-style blocks, link-heavy menus down-weighted). |
|
|
16
|
+
| `browser_find` | Finds words (or a regex) anywhere on the page: matching controls come back with a clickable number, matching text with its point, each saying whether it is on screen. |
|
|
17
|
+
| `browser_extract` | Returns the items a CSS selector matches, one compact line each with its link, instead of the whole page. |
|
|
18
|
+
| `browser_click` | Clicks a numbered control, or an `x`/`y` point. `double: true` double-clicks. A cookie banner or modal over the control is cleared first, then the click happens. |
|
|
19
|
+
| `browser_type` | Types text into the focused field, or clicks a numbered control first. Chinese goes in as one string. `submit: true` presses Enter. |
|
|
20
|
+
| `browser_key` | Presses one key: Enter, Tab, Escape, Backspace, Delete, Insert, arrows, Home, End, PageUp, PageDown, F1-F12, the numeric keypad, letters, digits, punctuation and every shifted symbol, plus ContextMenu, CapsLock, PrintScreen and media keys. `Ctrl`/`Alt`/`Shift`/`Meta`/`Mod` modifiers, chords with `+`, and `Ctrl+C/X/V/A/Z/Y` run the real editing commands. |
|
|
21
|
+
| `browser_scroll` | Scrolls the wheel at the centre of the page, or at `x`/`y`. `dy: 600` is about a screen down. |
|
|
22
|
+
| `browser_shot` | Saves a full-size PNG into `~/.dsh-inner-browser-use/screenshots` and returns the path. |
|
|
23
|
+
| `browser_state` | Reports whether the browser runs, which profile it uses, its address and title, and how many columns are showing it. |
|
|
24
|
+
|
|
25
|
+
`browser_read` on the same page is the words only, in Markdown (measured: 4,520
|
|
26
|
+
characters of an about page against 2,987 characters of snapshot — and the
|
|
27
|
+
snapshot spends most of its budget on controls; on a long article the gap grows
|
|
28
|
+
with the number of controls skipped). `browser_view` answers with one page of text:
|
|
24
29
|
|
|
25
30
|
```
|
|
26
31
|
https://example.com
|
|
@@ -34,8 +39,9 @@ Numbers stay valid until the next read. Anything that changes the page returns a
|
|
|
34
39
|
|
|
35
40
|
## The column
|
|
36
41
|
|
|
37
|
-
The pane is a picture of the browser, sent as JPEG over the session's own stream:
|
|
38
|
-
|
|
42
|
+
The pane is a picture of the browser, sent as JPEG over the session's own stream: quality 72,
|
|
43
|
+
every frame, about 82 KB and 25 per second on a page at rest. The page is laid out at the size
|
|
44
|
+
of the column, so the picture fills the frame instead of sitting letterboxed inside it.
|
|
39
45
|
|
|
40
46
|
Two pointers live on that picture:
|
|
41
47
|
|
|
@@ -82,7 +88,9 @@ Screenshots are the only thing written outside the profile:
|
|
|
82
88
|
| Read, dense page | 444 tokens (1510 chars, 90 controls) |
|
|
83
89
|
| Read round trip | 1–4 ms |
|
|
84
90
|
| Click round trip | 2 ms |
|
|
85
|
-
|
|
|
91
|
+
| Key round trip | 7 ms |
|
|
92
|
+
| Key to next frame | 15–18 ms |
|
|
93
|
+
| Screenshot | 75 ms, 115 KB |
|
|
86
94
|
|
|
87
95
|
## Install
|
|
88
96
|
|
|
@@ -102,6 +110,22 @@ dsh plugin install ./dsh-inner-browser-use-0.16.0.tgz
|
|
|
102
110
|
The plugin appears under **Plugins** as *Background browser*. It needs Google Chrome or
|
|
103
111
|
Chromium on the machine; see [docs/COMPATIBILITY.md](docs/COMPATIBILITY.md).
|
|
104
112
|
|
|
113
|
+
## When it opens
|
|
114
|
+
|
|
115
|
+
Only when the person asks for it by name: "in the browser", "on the web page",
|
|
116
|
+
"open this site", "click it for me". A link on its own is not a request.
|
|
117
|
+
|
|
118
|
+
- A public article, one link's text, docs, a GitHub repo or PR: read it with
|
|
119
|
+
`web_fetch` or Envoy. No browser, no Chrome process.
|
|
120
|
+
- If the page needs a login, is an empty shell to `web_fetch`, or needs a click,
|
|
121
|
+
say why and ask once instead of starting a browser on your own.
|
|
122
|
+
- Asked for, it starts without a question and never opens a second, external
|
|
123
|
+
Chrome. The picture stays in this chat's right column and stays open when the
|
|
124
|
+
work is done.
|
|
125
|
+
|
|
126
|
+
One browser is shared by every chat (see [Sharing](#sharing)), so opening one is
|
|
127
|
+
a tab, not a second Chrome.
|
|
128
|
+
|
|
105
129
|
## Use
|
|
106
130
|
|
|
107
131
|
1. Open the chat you want the agent to browse for.
|
|
@@ -110,24 +134,35 @@ Chromium on the machine; see [docs/COMPATIBILITY.md](docs/COMPATIBILITY.md).
|
|
|
110
134
|
The first call starts the browser and puts its mirror in this chat's right column. Nothing
|
|
111
135
|
opens before a call: a chat that never asked for a page keeps its column as it was.
|
|
112
136
|
|
|
137
|
+
## Sharing
|
|
138
|
+
|
|
139
|
+
Every chat uses the same background Chrome: one process, one tab per chat, and
|
|
140
|
+
each tab's address saved so it can be closed and reopened cheaply. Past six
|
|
141
|
+
chats the oldest tab nobody is watching is closed (a watched tab — the one on
|
|
142
|
+
someone's screen — never is). Tabs an earlier plugin load left behind are closed
|
|
143
|
+
on first use: one profile used for weeks otherwise keeps a renderer per chat that
|
|
144
|
+
ever opened it. Nothing is launched per chat, and the browser itself is closed 15
|
|
145
|
+
minutes after the last mirror is detached.
|
|
146
|
+
|
|
113
147
|
## Frames, downloads, network
|
|
114
148
|
|
|
115
149
|
- **Frames are read, including cross-site ones.** Controls inside an iframe are numbered like
|
|
116
150
|
the page's own, whether Chrome shares the page's process or runs the frame apart; a click in
|
|
117
151
|
either lands where the frame actually sits.
|
|
118
152
|
- **A download lands on disk.** A click that saves a file writes it into the browser's own
|
|
119
|
-
profile folder, and `
|
|
120
|
-
- **The network is visible.** `
|
|
153
|
+
profile folder, and `browser_downloads` names it, its size and when it arrived.
|
|
154
|
+
- **The network is visible.** `browser_network` lists what the page asked for, newest first,
|
|
121
155
|
so a page that fills itself in from an address can be understood without a screenshot.
|
|
122
156
|
- **An overlay does not eat the action.** A cookie banner over the button is cleared the way a
|
|
123
157
|
person would clear it, then the click lands — in the same call, and in every step of a
|
|
124
|
-
`
|
|
158
|
+
`browser_do` batch. Anything offering to delete, pay or sign out is reported instead.
|
|
125
159
|
|
|
126
160
|
## What it does not do
|
|
127
161
|
|
|
128
162
|
- **It does not touch your Chrome.** The browser runs from a separate profile with no
|
|
129
163
|
connection to the window you are using.
|
|
130
|
-
- **The viewport is
|
|
164
|
+
- **The layout viewport is the column, nothing more.** Desktop layout, no mobile emulation, no
|
|
165
|
+
touch emulation; a very narrow column is laid out narrow rather than zoomed out.
|
|
131
166
|
- **One page at a time.** A link that opens a tab is followed — the mirror lands on the new
|
|
132
167
|
page and the popup closes — so there is never a second page to choose between.
|
|
133
168
|
|
package/README.zh.md
CHANGED
|
@@ -9,17 +9,21 @@ profile,不是产品自带的那个浏览页签:右栏放的是它的画面
|
|
|
9
9
|
|
|
10
10
|
| 工具 | 作用 |
|
|
11
11
|
| --- | --- |
|
|
12
|
-
| `
|
|
13
|
-
| `
|
|
14
|
-
| `
|
|
15
|
-
| `
|
|
16
|
-
| `
|
|
17
|
-
| `
|
|
18
|
-
| `
|
|
19
|
-
| `
|
|
20
|
-
| `
|
|
21
|
-
|
|
22
|
-
`
|
|
12
|
+
| `browser_open` | 启动浏览器(已在跑就复用)并打开一个地址。传 `headless: false` 会改为在桌面上开真实窗口。 |
|
|
13
|
+
| `browser_view` | 返回地址、标题、编号成 `1 … 120` 的控件,以及可见文字。 |
|
|
14
|
+
| `browser_read` | 按人读文章的方式给 Markdown(标题、段落、列表、链接)。带 `query` 只回能回答的段落;问不到时自动改为整页,不再空手回来。页面和上次读的没变就只回一行,不重发正文(`force: true` 强制重发)。 |
|
|
15
|
+
| `browser_extract` | 按 CSS 选择器把列表项读成一行一条、带链接,比整页读省得多。 |
|
|
16
|
+
| `browser_view` 的 `map` | 大页面先看地标:各类控件各多少个、去重后的控件和编号、正文多少字,但不带回正文。 |
|
|
17
|
+
| `browser_click` | 点编号控件,或点 `x`/`y` 坐标。`double: true` 双击。控件被横幅或弹层挡着时,先关掉它再点。 |
|
|
18
|
+
| `browser_type` | 往焦点输入框输入文字,也可以先点一个编号控件。中文整段送进去。`submit: true` 回车提交。 |
|
|
19
|
+
| `browser_key` | 按一个键:Enter、Tab、Escape、Backspace、Delete、上下左右、Home、End、PageUp、PageDown。 |
|
|
20
|
+
| `browser_scroll` | 在页面中心滚轮,也可以指定 `x`/`y`。`dy: 600` 约一屏。 |
|
|
21
|
+
| `browser_shot` | 存一张原尺寸 PNG 到 `~/.dsh-inner-browser-use/screenshots`,返回路径。 |
|
|
22
|
+
| `browser_state` | 报浏览器在不在跑、用的哪个 profile、地址、标题,以及有几个右栏在看它。 |
|
|
23
|
+
|
|
24
|
+
点、输入、按键之后的那一行会多一句结果:`· navigated to …`(换页了)、`· content changed`(页内有变化)、`· no visible change`(点了没动)、Flash 页则是 `· screen updated` / `· no frame change`;`read: true` 时不再多说。
|
|
25
|
+
|
|
26
|
+
`browser_view` 一次给一页文本:
|
|
23
27
|
|
|
24
28
|
```
|
|
25
29
|
https://example.com
|
|
@@ -72,6 +76,7 @@ profile 之外只写一样东西:`~/.dsh-inner-browser-use/screenshots/` 里
|
|
|
72
76
|
| 单帧 | 1440×900 JPEG,约 82 KB |
|
|
73
77
|
| 画面 | 约 25 帧每秒 |
|
|
74
78
|
| 指针移动到箭头跟上 | 0.8–7 ms |
|
|
79
|
+
| 右栏里手动点击到画面更新 | 中位 13 ms,最坏 17 ms(点击派发 2 ms、悬停 8 ms) |
|
|
75
80
|
|
|
76
81
|
## 安装
|
|
77
82
|
|
|
@@ -98,12 +103,19 @@ dsh plugin install ./dsh-inner-browser-use-0.12.1.tgz
|
|
|
98
103
|
第一次调用会启动浏览器,并把镜像放进这个会话的右栏。没有调用就不会有东西自己打开:没要过
|
|
99
104
|
页面的会话,右栏保持原样。
|
|
100
105
|
|
|
106
|
+
## Flash 页面
|
|
107
|
+
|
|
108
|
+
`browser_open` 带 `flash: true` 时,这个地址改用插件自带的 Flash 运行时(Electron 87 + Pepper Flash)在**离屏窗口**里播放:窗口一次都不显示,Dock 与菜单栏都不出现,画面只在发起会话的右栏里。切换回来再调一次 `flash: false`,选择会记住。运行时首次使用时下载到 `~/.dsh/inner-browser-use/flash/`,不需要单独安装播放器。
|
|
109
|
+
|
|
110
|
+
`npm run flash-hidden` 会真起一次运行时并断言「窗口未显示 / 帧持续产出 / 两帧不同」;`npm run flash-mirror` 量镜像帧率与按下/抬起/按键耗时;`npm run flash-tools` 量工具层成本(奥拉星登录页实测:点击 28 ms、输入 14 ms、`browser_view` 31 ms;同一页后续回话只说标题,点击 22 字符、输入 13 字符);`npm run flash-local` 用仓库自带的 SWF 在本机起页,不依赖网络,量首帧与输入直达。
|
|
111
|
+
|
|
101
112
|
## 框架、下载、网络
|
|
102
113
|
|
|
103
114
|
- **iframe 能读,跨站也能。** 不管是和页面共用进程,还是 Chrome 单独给一个进程,里面的控件都和页面自己的控件一样有编号;点击按框架在页面上的实际位置落点。
|
|
104
|
-
- **下载会落到磁盘。** 点一下就下载的文件写在这个浏览器自己的 profile 目录里,`
|
|
105
|
-
-
|
|
106
|
-
-
|
|
115
|
+
- **下载会落到磁盘。** 点一下就下载的文件写在这个浏览器自己的 profile 目录里,`browser_downloads` 报出文件名、大小和到达时间。
|
|
116
|
+
- **PDF 有去处。** Chrome 无头进程画不出 PDF,右栏只会是白图;读到 PDF 时把文件取回归档到该浏览器 profile 的 `downloads/`,结果里直接给绝对路径,交给 `envoy_read` 或本地阅读器。
|
|
117
|
+
- **能看到网络。** `browser_network` 倒序列出页面请求了什么,自己用接口填内容、屏幕上又看不出原因的页面,不用截图去猜。
|
|
118
|
+
- **遮罩不吞动作。** 盖在按钮上的 cookie 横幅会按人的做法先关掉再点下去,同一次调用里完成;`browser_do` 的每一步都一样。写着删除、支付、退出的遮罩只报告,不代按。
|
|
107
119
|
|
|
108
120
|
## 不做什么
|
|
109
121
|
|
package/client.js
CHANGED
|
@@ -32,7 +32,7 @@ window.__ModuleLoader__.load({
|
|
|
32
32
|
const DICT = {
|
|
33
33
|
zh: {
|
|
34
34
|
'side.label': '后台浏览器',
|
|
35
|
-
'side.empty': '
|
|
35
|
+
'side.empty': '后台的独立 Chrome 还没有页面,在上方地址栏输入网址',
|
|
36
36
|
'side.restore': '恢复上次的页面',
|
|
37
37
|
'side.connecting': '正在连接…',
|
|
38
38
|
'side.failed': '画面没有连上',
|
|
@@ -45,7 +45,7 @@ window.__ModuleLoader__.load({
|
|
|
45
45
|
},
|
|
46
46
|
en: {
|
|
47
47
|
'side.label': 'Background browser',
|
|
48
|
-
'side.empty': '
|
|
48
|
+
'side.empty': 'The background Chrome has no page yet; type an address above',
|
|
49
49
|
'side.restore': 'Reopen the last page',
|
|
50
50
|
'side.connecting': 'Connecting…',
|
|
51
51
|
'side.failed': 'The picture did not connect',
|
|
@@ -167,6 +167,12 @@ window.__ModuleLoader__.load({
|
|
|
167
167
|
single: true,
|
|
168
168
|
priority: 'extension',
|
|
169
169
|
title: () => L('side.label'),
|
|
170
|
+
/* The tab strip's close control is not the pane's own button: a
|
|
171
|
+
tab closed there never told the host, the ask stayed alive, and
|
|
172
|
+
the keepalive put the mirror back a few seconds later — a tab
|
|
173
|
+
the person closed that would not stay closed. Every close path
|
|
174
|
+
ends here. */
|
|
175
|
+
onClose: (_tab, where) => closeHere(String(where?.sessionId ?? mountedOf())),
|
|
170
176
|
})))
|
|
171
177
|
disposers.push(scope.slots.inject(SLOT, () => scope.slots.register({
|
|
172
178
|
name: SLOT, // ui-copy-ignore
|
|
@@ -227,7 +233,10 @@ window.__ModuleLoader__.load({
|
|
|
227
233
|
const watchAsk = () => {
|
|
228
234
|
try {
|
|
229
235
|
if (typeof surface?.mounted?.subscribe === 'function') {
|
|
230
|
-
const off = surface.mounted.subscribe(
|
|
236
|
+
const off = surface.mounted.subscribe(() => {
|
|
237
|
+
syncAsk()
|
|
238
|
+
restore()
|
|
239
|
+
})
|
|
231
240
|
if (typeof off === 'function') return off
|
|
232
241
|
}
|
|
233
242
|
} catch {
|
|
@@ -236,10 +245,41 @@ window.__ModuleLoader__.load({
|
|
|
236
245
|
return null
|
|
237
246
|
}
|
|
238
247
|
syncAsk()
|
|
248
|
+
/* An app restart leaves the browser alive but no ask on the wire, so
|
|
249
|
+
the column would come back empty in a chat that never closed it.
|
|
250
|
+
This chat asking for the state of the browser it still owns is what
|
|
251
|
+
puts the mirror back without anyone pressing anything. */
|
|
252
|
+
const restoreTimers = []
|
|
253
|
+
const restore = (tries = 0) => {
|
|
254
|
+
const who = mountedOf()
|
|
255
|
+
if (who === '') return
|
|
256
|
+
const again = () => {
|
|
257
|
+
if (tries >= 2) return
|
|
258
|
+
restoreTimers.push(setTimeout(() => restore(tries + 1), 1500))
|
|
259
|
+
}
|
|
260
|
+
try {
|
|
261
|
+
fetch(`${ROUTE}/sidecar/state?session=${encodeURIComponent(who)}`) // ui-copy-ignore
|
|
262
|
+
.then((said) => (said.ok === true ? said.json() : null))
|
|
263
|
+
.then((state) => {
|
|
264
|
+
if (state?.owner !== who) return
|
|
265
|
+
const live = state?.alive === true || state?.chat?.alive === true
|
|
266
|
+
if (live !== true) {
|
|
267
|
+
again()
|
|
268
|
+
return
|
|
269
|
+
}
|
|
270
|
+
ask(who)
|
|
271
|
+
})
|
|
272
|
+
.catch(again)
|
|
273
|
+
} catch {
|
|
274
|
+
again()
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
restore()
|
|
239
278
|
const offAsk = watchAsk()
|
|
240
279
|
const pollAsk = setInterval(syncAsk, 1000)
|
|
241
280
|
const stopAsk = () => {
|
|
242
281
|
clearInterval(pollAsk)
|
|
282
|
+
for (const timer of restoreTimers) clearTimeout(timer)
|
|
243
283
|
if (typeof offAsk === 'function') {
|
|
244
284
|
try {
|
|
245
285
|
offAsk()
|
|
@@ -274,6 +314,32 @@ window.__ModuleLoader__.load({
|
|
|
274
314
|
function SidecarBody(props) {
|
|
275
315
|
const [frame, setFrame] = React.useState(null)
|
|
276
316
|
const [ptr, setPtr] = React.useState(null)
|
|
317
|
+
/* The page is laid out at the size of this column, so the picture it
|
|
318
|
+
sends already has the right shape; these are its pixels. */
|
|
319
|
+
const [view, setView] = React.useState({ w: SIDE_VIEW.width, h: SIDE_VIEW.height })
|
|
320
|
+
/* The person at the mirror drives with their own hand: while they are,
|
|
321
|
+
the agent's arrow stays out of the way, and it comes back by itself
|
|
322
|
+
once their hold runs out. */
|
|
323
|
+
const [person, setPerson] = React.useState(false)
|
|
324
|
+
const personTimer = React.useRef(0)
|
|
325
|
+
/* The person's own hand hides the arrow the moment it lands, without
|
|
326
|
+
waiting for the browser to report a hold: the arrow is a claim about
|
|
327
|
+
what the agent did, and a claim that outlives the agent's turn is a lie
|
|
328
|
+
the person has to look past. It returns when their hand stops. */
|
|
329
|
+
const [hand, setHand] = React.useState(false)
|
|
330
|
+
const handTimer = React.useRef(0)
|
|
331
|
+
const touched = () => {
|
|
332
|
+
setHand(true)
|
|
333
|
+
clearTimeout(handTimer.current)
|
|
334
|
+
handTimer.current = setTimeout(() => setHand(false), 1500)
|
|
335
|
+
}
|
|
336
|
+
const hearHold = (message) => {
|
|
337
|
+
const hold = Number(message?.hold ?? 0)
|
|
338
|
+
if (!Number.isFinite(hold) || hold <= 0) return
|
|
339
|
+
setPerson(true)
|
|
340
|
+
clearTimeout(personTimer.current)
|
|
341
|
+
personTimer.current = setTimeout(() => setPerson(false), hold + 120)
|
|
342
|
+
}
|
|
277
343
|
const [phase, setPhase] = React.useState('connecting')
|
|
278
344
|
const [note, setNote] = React.useState('')
|
|
279
345
|
/* The address this chat had before its tab went away: the pane reopens
|
|
@@ -384,11 +450,15 @@ window.__ModuleLoader__.load({
|
|
|
384
450
|
pending = null
|
|
385
451
|
setFrame(message)
|
|
386
452
|
setPtr(message.pointer ?? null)
|
|
453
|
+
if (Number.isFinite(Number(message.w)) && Number.isFinite(Number(message.h))) {
|
|
454
|
+
setView({ w: Number(message.w), h: Number(message.h) })
|
|
455
|
+
}
|
|
387
456
|
setPhase('live')
|
|
388
457
|
drawnAt.current = Date.now()
|
|
389
458
|
if (typeof message.url === 'string' && message.url !== '') setUrl(message.url)
|
|
390
459
|
}
|
|
391
460
|
const draw = (message) => {
|
|
461
|
+
hearHold(message)
|
|
392
462
|
pending = message
|
|
393
463
|
if (raf !== 0) return
|
|
394
464
|
if (typeof requestAnimationFrame === 'function') raf = requestAnimationFrame(paint)
|
|
@@ -417,6 +487,7 @@ window.__ModuleLoader__.load({
|
|
|
417
487
|
} else if (message.type === 'pointer') {
|
|
418
488
|
/* The arrow is drawn the moment the agent moves, not when the page
|
|
419
489
|
next repaints: it stays honest even on a page that is idle. */
|
|
490
|
+
hearHold(message)
|
|
420
491
|
setPtr(message.pointer ?? null)
|
|
421
492
|
} else if (typeof message.jpeg === 'string') {
|
|
422
493
|
draw(message)
|
|
@@ -425,6 +496,8 @@ window.__ModuleLoader__.load({
|
|
|
425
496
|
source.onerror = () => setPhase('lost')
|
|
426
497
|
return () => {
|
|
427
498
|
clearInterval(stuck)
|
|
499
|
+
clearTimeout(personTimer.current)
|
|
500
|
+
clearTimeout(handTimer.current)
|
|
428
501
|
if (raf !== 0 && typeof cancelAnimationFrame === 'function') cancelAnimationFrame(raf)
|
|
429
502
|
source.close()
|
|
430
503
|
}
|
|
@@ -448,9 +521,49 @@ window.__ModuleLoader__.load({
|
|
|
448
521
|
return () => observer.disconnect()
|
|
449
522
|
}, [])
|
|
450
523
|
|
|
451
|
-
const scale = size.w > 0 && size.h > 0 ? Math.min(size.w /
|
|
452
|
-
const padX = (size.w -
|
|
453
|
-
const padY = (size.h -
|
|
524
|
+
const scale = size.w > 0 && size.h > 0 ? Math.min(size.w / view.w, size.h / view.h) : 0
|
|
525
|
+
const padX = (size.w - view.w * scale) / 2
|
|
526
|
+
const padY = (size.h - view.h * scale) / 2
|
|
527
|
+
|
|
528
|
+
/* The column tells the page how big it is, a beat after the resize stops:
|
|
529
|
+
a page that relayouts on every pointer step would never settle. */
|
|
530
|
+
React.useEffect(() => {
|
|
531
|
+
if (mine !== true || size.w < 200 || size.h < 200) return undefined
|
|
532
|
+
/* The page already has that shape: a resize that lands between two
|
|
533
|
+
viewport sizes would only cost a relayout and another frame. */
|
|
534
|
+
if (Math.abs(Number(view?.w ?? 0) - size.w) < 1 && Math.abs(Number(view?.h ?? 0) - size.h) < 1) return undefined
|
|
535
|
+
/* The page is told the column's size a beat after the resize stops, and
|
|
536
|
+
again until the browser says it took it: a request that arrives while
|
|
537
|
+
the page is still starting is refused, and a pane that gives up then
|
|
538
|
+
keeps a letterboxed picture for as long as it stays open. */
|
|
539
|
+
let live = true
|
|
540
|
+
let tries = 0
|
|
541
|
+
let timer = 0
|
|
542
|
+
const send = () => {
|
|
543
|
+
if (!live) return
|
|
544
|
+
fetch(`${ROUTE}/sidecar/viewport`, {
|
|
545
|
+
method: 'POST',
|
|
546
|
+
headers: { 'content-type': 'application/json' },
|
|
547
|
+
body: JSON.stringify({ session: String(props.sessionId ?? ''), w: Math.round(size.w), h: Math.round(size.h) }),
|
|
548
|
+
})
|
|
549
|
+
.then((answer) => (answer.ok === true ? answer.json() : { ok: false }))
|
|
550
|
+
.then((said) => {
|
|
551
|
+
if (!live || said?.ok === true || tries >= 5) return
|
|
552
|
+
tries += 1
|
|
553
|
+
timer = setTimeout(send, 400)
|
|
554
|
+
})
|
|
555
|
+
.catch(() => {
|
|
556
|
+
if (!live || tries >= 5) return
|
|
557
|
+
tries += 1
|
|
558
|
+
timer = setTimeout(send, 600)
|
|
559
|
+
})
|
|
560
|
+
}
|
|
561
|
+
timer = setTimeout(send, 250)
|
|
562
|
+
return () => {
|
|
563
|
+
live = false
|
|
564
|
+
clearTimeout(timer)
|
|
565
|
+
}
|
|
566
|
+
}, [mine, size.w, size.h, view?.w, view?.h])
|
|
454
567
|
|
|
455
568
|
const who = String(props.sessionId ?? '')
|
|
456
569
|
const post = (path, body) => {
|
|
@@ -487,7 +600,7 @@ window.__ModuleLoader__.load({
|
|
|
487
600
|
const box = el.getBoundingClientRect()
|
|
488
601
|
const x = (event.clientX - box.left - padX) / scale
|
|
489
602
|
const y = (event.clientY - box.top - padY) / scale
|
|
490
|
-
if (x < 0 || y < 0 || x >
|
|
603
|
+
if (x < 0 || y < 0 || x > view.w || y > view.h) return null
|
|
491
604
|
return { x: Math.round(x), y: Math.round(y) }
|
|
492
605
|
}
|
|
493
606
|
const ime = React.useRef(null)
|
|
@@ -503,6 +616,7 @@ window.__ModuleLoader__.load({
|
|
|
503
616
|
}
|
|
504
617
|
const stageRef = stage
|
|
505
618
|
const onPointerDown = (event) => {
|
|
619
|
+
touched()
|
|
506
620
|
const point = toView(event)
|
|
507
621
|
if (point === null) return
|
|
508
622
|
const field = ime.current
|
|
@@ -515,6 +629,7 @@ window.__ModuleLoader__.load({
|
|
|
515
629
|
}
|
|
516
630
|
const movedAt = React.useRef(0)
|
|
517
631
|
const onPointerMove = (event) => {
|
|
632
|
+
touched()
|
|
518
633
|
const point = toView(event)
|
|
519
634
|
if (point === null) return
|
|
520
635
|
/* Hover is part of a page: menus open under the hand, so the movement
|
|
@@ -526,24 +641,46 @@ window.__ModuleLoader__.load({
|
|
|
526
641
|
hover(point)
|
|
527
642
|
}
|
|
528
643
|
const onWheel = (event) => {
|
|
644
|
+
touched()
|
|
529
645
|
event.preventDefault()
|
|
530
|
-
const point = toView(event) ?? { x: Math.round(
|
|
646
|
+
const point = toView(event) ?? { x: Math.round(view.w / 2), y: Math.round(view.h / 2) }
|
|
531
647
|
post('/sidecar/input', { kind: 'wheel', x: point.x, y: point.y, dx: event.deltaX, dy: event.deltaY })
|
|
532
648
|
}
|
|
649
|
+
/* The page's keys are the person's keys: shift makes a capital, a
|
|
650
|
+
shortcut stays a shortcut so Ctrl+V pastes and Ctrl+A selects, and the
|
|
651
|
+
key that goes down also goes up so a game can hold it. */
|
|
652
|
+
const modsOf = (event) => (event.altKey === true ? 1 : 0)
|
|
653
|
+
| (event.ctrlKey === true ? 2 : 0)
|
|
654
|
+
| (event.metaKey === true ? 4 : 0)
|
|
655
|
+
| (event.shiftKey === true ? 8 : 0)
|
|
656
|
+
const MODIFIER_KEYS = ['Alt', 'Control', 'Meta', 'Shift', 'AltGraph', 'OS', 'Super', 'Hyper']
|
|
657
|
+
const keyBody = (event, down) => {
|
|
658
|
+
const modifiers = modsOf(event)
|
|
659
|
+
const shortcut = (modifiers & 7) !== 0
|
|
660
|
+
const typing = down === true && event.key.length === 1 && (modifiers & 7) === 0 && event.repeat !== true
|
|
661
|
+
return {
|
|
662
|
+
kind: 'key',
|
|
663
|
+
key: event.key,
|
|
664
|
+
code: event.code || event.key,
|
|
665
|
+
keyCode: event.keyCode || 0,
|
|
666
|
+
modifiers,
|
|
667
|
+
...(typing ? { text: event.key } : {}),
|
|
668
|
+
...(down === false ? { down: false } : {}),
|
|
669
|
+
}
|
|
670
|
+
}
|
|
533
671
|
const onKeyDown = (event) => {
|
|
672
|
+
touched()
|
|
534
673
|
/* A key still being composed belongs to the IME, not to the page. */
|
|
535
674
|
if (event.isComposing === true) return
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
if (event.
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
post('/sidecar/input', { kind: 'text', text: event.key })
|
|
546
|
-
}
|
|
675
|
+
if (event.key == null || MODIFIER_KEYS.includes(event.key) === true) return
|
|
676
|
+
event.preventDefault()
|
|
677
|
+
post('/sidecar/input', event.repeat === true ? { ...keyBody(event, true), repeat: true } : keyBody(event, true))
|
|
678
|
+
}
|
|
679
|
+
const onKeyUp = (event) => {
|
|
680
|
+
if (event.isComposing === true) return
|
|
681
|
+
if (event.key == null || MODIFIER_KEYS.includes(event.key) === true) return
|
|
682
|
+
event.preventDefault()
|
|
683
|
+
post('/sidecar/input', keyBody(event, false))
|
|
547
684
|
}
|
|
548
685
|
const goto = () => {
|
|
549
686
|
const address = String(draft ?? '').trim()
|
|
@@ -557,7 +694,7 @@ window.__ModuleLoader__.load({
|
|
|
557
694
|
return h('div', { className: 'dshib-side' },
|
|
558
695
|
h('div', { className: 'dshib-side-note' }, L('side.foreign')))
|
|
559
696
|
}
|
|
560
|
-
const pointer = ptr
|
|
697
|
+
const pointer = person === true || hand === true ? null : ptr
|
|
561
698
|
const noteText = phase === 'failed' ? note || L('side.failed') : phase === 'live' ? L('side.empty') : L('side.connecting')
|
|
562
699
|
return h('div', { className: 'dshib-side' },
|
|
563
700
|
h('div', { className: 'dshib-side-bar' },
|
|
@@ -587,6 +724,7 @@ window.__ModuleLoader__.load({
|
|
|
587
724
|
onPointerMove,
|
|
588
725
|
onWheel,
|
|
589
726
|
onKeyDown,
|
|
727
|
+
onKeyUp,
|
|
590
728
|
},
|
|
591
729
|
frame === null && back !== ''
|
|
592
730
|
? h('button', {
|
|
@@ -627,12 +765,13 @@ window.__ModuleLoader__.load({
|
|
|
627
765
|
clearIme()
|
|
628
766
|
},
|
|
629
767
|
onKeyDown,
|
|
768
|
+
onKeyUp,
|
|
630
769
|
}),
|
|
631
770
|
frame === null ? null : h('img', {
|
|
632
771
|
className: 'dshib-side-frame',
|
|
633
772
|
src: `data:image/jpeg;base64,${frame.jpeg}`,
|
|
634
|
-
width: Math.max(1, Math.round(
|
|
635
|
-
height: Math.max(1, Math.round(
|
|
773
|
+
width: Math.max(1, Math.round(view.w * scale)),
|
|
774
|
+
height: Math.max(1, Math.round(view.h * scale)),
|
|
636
775
|
alt: '', // ui-copy-ignore
|
|
637
776
|
draggable: false,
|
|
638
777
|
}),
|
package/docs/ARCHITECTURE.md
CHANGED
|
@@ -4,7 +4,7 @@ Two halves in two processes, and a Chrome that neither of them is.
|
|
|
4
4
|
|
|
5
5
|
```
|
|
6
6
|
server side (index.js, in the DSH process)
|
|
7
|
-
|
|
7
|
+
browser_* tools ──> Sidecar ── CDP over a socket ──> headless Chrome
|
|
8
8
|
│
|
|
9
9
|
HTTP + SSE routes <────┘
|
|
10
10
|
│
|
|
@@ -41,7 +41,7 @@ Other launch flags:
|
|
|
41
41
|
| `--use-mock-keychain`, `--password-store=basic` | No keychain prompt on macOS. |
|
|
42
42
|
| `--remote-allow-origins=*` | The CDP client connects over a plain WebSocket. |
|
|
43
43
|
| `--disable-blink-features=AutomationControlled` | Sites that sniff for automation get the ordinary path. |
|
|
44
|
-
| `--window-size=1440,900`, `--lang=zh-CN` | The
|
|
44
|
+
| `--window-size=1440,900`, `--lang=zh-CN` | The window the browser starts in, and a Chinese page locale; the layout viewport is set to the column instead. |
|
|
45
45
|
|
|
46
46
|
The binary comes from a fixed list: Google Chrome, Chromium or Canary under `/Applications`,
|
|
47
47
|
then `/usr/bin/google-chrome` and `/usr/bin/chromium`. Nothing else is probed and there is no
|
|
@@ -58,7 +58,7 @@ The browser outlives the plugin:
|
|
|
58
58
|
`/json/new?about:blank` as a fallback) and a client connects to it.
|
|
59
59
|
|
|
60
60
|
One page per browser. `apply()` only constructs the `Sidecar`; no process exists until the
|
|
61
|
-
first tool call, and `
|
|
61
|
+
first tool call, and `browser_state` reports a stopped browser without starting one.
|
|
62
62
|
|
|
63
63
|
Shutdown asks Chrome to close (`Browser.close`), waits briefly, then sends `SIGTERM`, which is
|
|
64
64
|
the point where Chrome flushes cookies. An idle timer closes the browser after 15 minutes; a
|
|
@@ -85,13 +85,16 @@ the CDP socket stays in the server half.
|
|
|
85
85
|
## Mirror
|
|
86
86
|
|
|
87
87
|
The server half starts `Page.startScreencast` with JPEG quality 72, `maxWidth` and `maxHeight`
|
|
88
|
-
at
|
|
88
|
+
at the pane's size, `optimizeForSpeed`, `everyNthFrame: 1`, and acknowledges every frame it receives
|
|
89
89
|
so Chrome keeps sending. Frames are never queued: with two frames in flight, further frames
|
|
90
90
|
are dropped and the next one that fits takes their place. A slow reader loses frames, never
|
|
91
91
|
freshness.
|
|
92
92
|
|
|
93
|
-
The viewport
|
|
94
|
-
|
|
93
|
+
The layout viewport follows the column: the pane measures itself, posts the size to
|
|
94
|
+
`/sidecar/viewport` a beat after the resize settles, and the server pins
|
|
95
|
+
`Emulation.setDeviceMetricsOverride` to that size and restarts the screencast at it. So a frame
|
|
96
|
+
is the whole page as laid out at the shape it is shown in — no letterbox bands, no crop. Sizes
|
|
97
|
+
below 320 px or above 1920×1600 are ignored rather than applied as a sliver.
|
|
95
98
|
|
|
96
99
|
If `Browser.getVersion` reports `HeadlessChrome`, the user agent is overridden with an ordinary
|
|
97
100
|
Chrome string, `acceptLanguage: 'zh-CN,zh;q=0.9,en;q=0.8'` and `platform: 'MacIntel'`, so a site
|
|
@@ -133,7 +136,7 @@ browser without pulling the column away from the person reading it.
|
|
|
133
136
|
|
|
134
137
|
`sidecarTools()` in `tools.js` builds the 28 definitions and `index.js` registers them on the
|
|
135
138
|
plugin's tool scope. All of them return one text block. A page read numbers the controls and
|
|
136
|
-
keeps those numbers across later reads, so `
|
|
139
|
+
keeps those numbers across later reads, so `browser_click` by ref means the element the model
|
|
137
140
|
last saw; navigation drops them and the model gets `REF_GONE` rather than a wrong click.
|
|
138
141
|
|
|
139
142
|
Before a ref click or a ref focus, one page-side probe asks what is on top of that control.
|
|
@@ -145,7 +148,7 @@ reported, never accepted on the person's behalf. The whole exchange is one `Runt
|
|
|
145
148
|
and a possible `mousePressed`/`mouseReleased` pair on an already-open CDP connection, so an
|
|
146
149
|
uncovered click pays nothing.
|
|
147
150
|
|
|
148
|
-
`
|
|
151
|
+
`browser_do` runs the same probe before a step that names a control — by `ref`, by
|
|
149
152
|
text, or by field label — so a banner the first step clears is gone for the rest of
|
|
150
153
|
the batch, and the step's own line says it in one clause (`after Accept on a banner,`).
|
|
151
154
|
A batch therefore pays for one overlay, not one per step.
|
|
@@ -154,7 +157,7 @@ An action answers with one line — address, title, focused field — and reads
|
|
|
154
157
|
`read: true` is passed. That is what keeps a long batch cheap (see `BENCH.md`).
|
|
155
158
|
|
|
156
159
|
Calls that touch the browser are marked not concurrency-safe; reading state, console and failed
|
|
157
|
-
requests is. `
|
|
160
|
+
requests is. `browser_open`, `browser_view`, `browser_wait` and `browser_shot` carry longer
|
|
158
161
|
timeouts than the rest.
|
|
159
162
|
|
|
160
163
|
Sign-in state lives in the profile, and session cookies — the kind a sign-in usually sets,
|
|
@@ -169,7 +172,7 @@ without asking first when someone wants something done on a site.
|
|
|
169
172
|
`Network.enable` is already on for the mirror. A request is kept when it is
|
|
170
173
|
sent and settled when its response arrives, so the list holds method, status,
|
|
171
174
|
type and address of what the page actually got — 120 rows, oldest dropped.
|
|
172
|
-
`
|
|
175
|
+
`browser_network` reads that list without touching the page, which is why it
|
|
173
176
|
can answer while a dialog is open.
|
|
174
177
|
|
|
175
178
|
## Downloads
|
|
@@ -177,7 +180,7 @@ can answer while a dialog is open.
|
|
|
177
180
|
Chrome is pointed at `<profile>/downloads` when the page starts, so a click
|
|
178
181
|
that saves a file writes it there instead of nowhere. The folder belongs to
|
|
179
182
|
the profile: it is the same one on the next launch, and nothing outside the
|
|
180
|
-
profile is touched. `
|
|
183
|
+
profile is touched. `browser_downloads` lists what is on disk, newest first;
|
|
181
184
|
a `.crdownload` still in flight is not listed.
|
|
182
185
|
|
|
183
186
|
## Frames and popups
|
|
@@ -215,4 +218,4 @@ and there is still one page to drive.
|
|
|
215
218
|
| `locale/*.json` | Page: pane labels. |
|
|
216
219
|
|
|
217
220
|
Data on disk is confined to `~/.dsh/inner-browser-use/`: `chrome/<profile>` for the browser,
|
|
218
|
-
`screenshots/` for `
|
|
221
|
+
`screenshots/` for `browser_shot`.
|
package/docs/ARTIFACTS.md
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
- **项目**:会话工作目录的文件夹名,加路径哈希前 6 位,例如 `dsh-inner-browser-use-a1b2c3`。同名文件夹、不同路径不会混。
|
|
10
10
|
- **会话**:会话 id 去掉 `session-` 后的前 8 位,例如 `72557d6e`。同一项目里两个会话各存各的。
|
|
11
|
-
- **种类**:`shots/` 是 `
|
|
11
|
+
- **种类**:`shots/` 是 `browser_shot` 的截图,`pictures/` 是 `browser_grab` 抓的图,`pdf/` 是 `browser_pdf` 打印的文档。
|
|
12
12
|
|
|
13
13
|
文件名默认带时间戳(`shot-20261028-124500.png`),给了 `name` 就用它;名字里的分隔符、连续点、开头点都会被去掉,跳不出这个目录。
|
|
14
14
|
|