aihand 0.0.1 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +136 -2
- package/dist/chunk-2NTK7H4W.js +10 -0
- package/dist/chunk-3X4FTHLC.cjs +369 -0
- package/dist/chunk-BXVNR4E2.js +399 -0
- package/dist/chunk-C7DGE6MY.cjs +1456 -0
- package/dist/chunk-DUUCVLC3.cjs +254 -0
- package/dist/chunk-FAHI53KO.cjs +125 -0
- package/dist/chunk-G7KVJ7NF.js +369 -0
- package/dist/chunk-GNEUSRGP.js +52 -0
- package/dist/chunk-IGNEAOLT.cjs +130 -0
- package/dist/chunk-IS5XFUDB.js +125 -0
- package/dist/chunk-JLYC76XL.js +2448 -0
- package/dist/chunk-KQOABC2O.cjs +52 -0
- package/dist/chunk-OVMK33AC.cjs +104 -0
- package/dist/chunk-OWYK2IGV.js +250 -0
- package/dist/chunk-PQSQN4CN.js +126 -0
- package/dist/chunk-QF6AG3M5.cjs +410 -0
- package/dist/chunk-QSAMLXML.js +1456 -0
- package/dist/chunk-VEKYRKPF.cjs +399 -0
- package/dist/chunk-Y6H7W7PI.cjs +2451 -0
- package/dist/chunk-YKSYW77R.js +410 -0
- package/dist/chunk-Z2Y65YOY.cjs +7 -0
- package/dist/chunk-ZJQRNIK7.js +104 -0
- package/dist/cli-FDS2C2CZ.cjs +651 -0
- package/dist/cli-HHRGYPSM.js +649 -0
- package/dist/cli-JQEIE7RQ.js +120 -0
- package/dist/cli-K3OS2QQH.cjs +122 -0
- package/dist/cli-OSYG6LJD.cjs +89 -0
- package/dist/cli-TXRW5PG6.js +89 -0
- package/dist/cli.cjs +81 -0
- package/dist/cli.js +81 -0
- package/dist/config-5KEQLN6L.cjs +13 -0
- package/dist/config-PJPYKDLQ.js +13 -0
- package/dist/graph-IH56SCPK.js +8 -0
- package/dist/graph-ZUXXCJ5A.cjs +8 -0
- package/dist/index.cjs +481 -0
- package/dist/index.d.cts +461 -0
- package/dist/index.d.ts +461 -0
- package/dist/index.js +479 -0
- package/dist/locate-5XFSXJ5J.cjs +15 -0
- package/dist/locate-NKSUGL3A.js +15 -0
- package/dist/refactor-5FWSZIBN.cjs +19 -0
- package/dist/refactor-BOB3SZSA.js +19 -0
- package/dist/scan-4R7GQG2W.cjs +9 -0
- package/dist/scan-VF54GAAX.js +9 -0
- package/dist/ui/probe/server.cjs +505 -0
- package/dist/ui/probe/server.js +507 -0
- package/dist/vite.cjs +12 -0
- package/dist/vite.d.cts +12 -0
- package/dist/vite.d.ts +12 -0
- package/dist/vite.js +12 -0
- package/package.json +82 -9
- package/src/cli.ts +107 -0
- package/src/index.ts +54 -0
- package/src/read/cli.ts +650 -0
- package/src/read/compact.ts +286 -0
- package/src/read/config.ts +62 -0
- package/src/read/graph.ts +182 -0
- package/src/read/index.ts +12 -0
- package/src/read/inject.ts +121 -0
- package/src/read/locate.ts +104 -0
- package/src/read/panel.ts +335 -0
- package/src/read/pipeline.ts +78 -0
- package/src/read/refactor.ts +576 -0
- package/src/read/render.ts +1118 -0
- package/src/read/scan.ts +61 -0
- package/src/read/seam.ts +0 -0
- package/src/read/security.ts +171 -0
- package/src/read/signals.ts +333 -0
- package/src/read/state.ts +71 -0
- package/src/read/stategraph.ts +205 -0
- package/src/read/types.ts +162 -0
- package/src/read/vite.ts +77 -0
- package/src/ui/babel/line-profiler.ts +197 -0
- package/src/ui/babel/source-loc.ts +68 -0
- package/src/ui/bridge/cdp-bridge.ts +138 -0
- package/src/ui/bridge/compile-probe.ts +80 -0
- package/src/ui/bridge/transport.ts +26 -0
- package/src/ui/bridge/vite-bridge.ts +116 -0
- package/src/ui/client/client-patch.ts +899 -0
- package/src/ui/client/client.ts +2562 -0
- package/src/ui/core/action.ts +747 -0
- package/src/ui/core/candidates.ts +348 -0
- package/src/ui/core/canvas.ts +305 -0
- package/src/ui/core/check.ts +34 -0
- package/src/ui/core/compact.ts +314 -0
- package/src/ui/core/detail.ts +244 -0
- package/src/ui/core/diff.ts +253 -0
- package/src/ui/core/emit.ts +198 -0
- package/src/ui/core/knob-exec.ts +137 -0
- package/src/ui/core/perf.ts +254 -0
- package/src/ui/core/types.ts +164 -0
- package/src/ui/core/util.ts +221 -0
- package/src/ui/index.ts +5 -0
- package/src/ui/probe/cli.ts +139 -0
- package/src/ui/probe/server.ts +468 -0
- package/src/ui/self/act.ts +47 -0
- package/src/ui/self/discover.ts +101 -0
- package/src/ui/self/grow.ts +121 -0
- package/src/ui/self/install.ts +100 -0
- package/src/ui/self/probe.ts +105 -0
- package/src/ui/self/screen-hook.ts +44 -0
- package/src/ui/self/self.ts +48 -0
- package/src/ui/self/store-refs.ts +123 -0
- package/src/ui/self/store-schema.ts +65 -0
- package/src/ui/self/synth.ts +37 -0
- package/src/ui/server/cli.ts +102 -0
- package/src/ui/server/dispatch.ts +276 -0
- package/src/ui/server/help-text.ts +237 -0
- package/src/ui/server/knob-schema.ts +87 -0
- package/src/ui/server/plugin.ts +1151 -0
- package/src/vite.ts +39 -0
- package/index.js +0 -2
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/* eslint-disable no-console -- this is a CLI; stdout is its output channel */
|
|
2
|
+
import { spawn } from 'node:child_process'
|
|
3
|
+
import process from 'node:process'
|
|
4
|
+
import { createRequire } from 'node:module'
|
|
5
|
+
import { existsSync } from 'node:fs'
|
|
6
|
+
import { fileURLToPath } from 'node:url'
|
|
7
|
+
import { dirname, resolve } from 'node:path'
|
|
8
|
+
import pc from 'picocolors'
|
|
9
|
+
|
|
10
|
+
// 出口2 CLI:`aihand probe open <url>` / `screen` / `click --text=…`。
|
|
11
|
+
// open 确保常驻 server(probe/server.ts)起着——多次 CLI 连同一 server = 同一 page(会话保持)。
|
|
12
|
+
// screen/click 只 curl 常驻 server,与出口1 的 cli.ts 同形(positional 子命令 + --flag query)。
|
|
13
|
+
|
|
14
|
+
const PORT = 5179
|
|
15
|
+
const base = `http://localhost:${PORT}`
|
|
16
|
+
|
|
17
|
+
function parseFlags(a: string[]) {
|
|
18
|
+
return Object.fromEntries(
|
|
19
|
+
a.filter(x => x.startsWith('--')).map((x) => {
|
|
20
|
+
const body = x.slice(2)
|
|
21
|
+
const eq = body.indexOf('=')
|
|
22
|
+
return eq === -1 ? [body, 'true'] : [body.slice(0, eq), body.slice(eq + 1)]
|
|
23
|
+
}),
|
|
24
|
+
)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function alive(): Promise<boolean> {
|
|
28
|
+
try {
|
|
29
|
+
await fetch(`${base}/__nope`)
|
|
30
|
+
return true // 任何回复(含 404)= server 在
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return false
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function waitAlive(ms: number): Promise<boolean> {
|
|
38
|
+
const deadline = Date.now() + ms
|
|
39
|
+
while (Date.now() < deadline) {
|
|
40
|
+
if (await alive())
|
|
41
|
+
return true
|
|
42
|
+
await new Promise(r => setTimeout(r, 200))
|
|
43
|
+
}
|
|
44
|
+
return false
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function spawnServer() {
|
|
48
|
+
// server 入口在哪取决于运行形态:dev(jiti 跑 src)= 同目录 server.ts;built = tsup 把
|
|
49
|
+
// 本 cli 打进 dist 根的 chunk(here=dist/),server 单列 entry 落 dist/ui/probe/server.js。
|
|
50
|
+
// 两种都试,第一个存在的赢——不赌单一相对路径(tsup chunk 名不稳)。
|
|
51
|
+
const here = dirname(fileURLToPath(import.meta.url))
|
|
52
|
+
const candidates = [
|
|
53
|
+
resolve(here, 'server.ts'), // dev:同目录源码
|
|
54
|
+
resolve(here, 'server.js'), // built(若 cli 与 server 同目录)
|
|
55
|
+
resolve(here, 'ui/probe/server.js'), // built:cli chunk 在 dist 根,server 在 dist/ui/probe
|
|
56
|
+
]
|
|
57
|
+
const entry = candidates.find(existsSync)
|
|
58
|
+
if (!entry)
|
|
59
|
+
throw new Error('probe server entry not found near ' + here)
|
|
60
|
+
// node 自身不解析 TS:entry 是 .ts(dev,jiti 跑 src)时,跑 node + jiti CLI 入口转译;
|
|
61
|
+
// .js(built)裸 node 直跑。jiti CLI 路径从包根解析(dev 下 jiti 必在——它就是用 jiti 起的)。
|
|
62
|
+
const argv = entry.endsWith('.ts') ? [jitiCli(here), entry] : [entry]
|
|
63
|
+
const child = spawn(process.execPath, argv, { detached: true, stdio: 'ignore' })
|
|
64
|
+
child.unref()
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// jiti 的可执行 CLI 入口(lib/jiti-cli.mjs):exports 不导出子路径,故从 package.json 解析其目录再拼。
|
|
68
|
+
function jitiCli(near: string): string {
|
|
69
|
+
const req = createRequire(resolve(near, 'cli.ts'))
|
|
70
|
+
return resolve(dirname(req.resolve('jiti/package.json')), 'lib/jiti-cli.mjs')
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function main() {
|
|
74
|
+
const argv = process.argv.slice(2)
|
|
75
|
+
const sub = argv.filter(a => !a.startsWith('--'))[0] || ''
|
|
76
|
+
const positional = argv.filter(a => !a.startsWith('--'))
|
|
77
|
+
const flags = parseFlags(argv)
|
|
78
|
+
|
|
79
|
+
if (!sub || flags.help) {
|
|
80
|
+
console.log(`
|
|
81
|
+
${pc.bold('aihand probe')} — drive ANY external website (out-of-app, self-contained)
|
|
82
|
+
|
|
83
|
+
aihand probe open <url> Launch chromium, parachute the probe, navigate
|
|
84
|
+
(--headed to see a real browser window pop up)
|
|
85
|
+
aihand probe screen Semantic layout + view/modal/focus + region fingerprint
|
|
86
|
+
aihand probe click --text=… Click by visible text (also fill/press/hover/wait)
|
|
87
|
+
aihand probe eval --code=… Run JS in the page, auto-return the value (read exact
|
|
88
|
+
text/numbers the sketch clips, query closures)
|
|
89
|
+
aihand probe screenshot Save a PNG (pixels, not the sketch) — returns the path
|
|
90
|
+
(--sel=… one element · --fullPage=1 whole scroll height)
|
|
91
|
+
|
|
92
|
+
The page handle lives in a resident server (:${PORT}) — repeated commands hit the same
|
|
93
|
+
page (login/scroll/menus persist). Same kernel as the in-app probe, CDP transport.
|
|
94
|
+
`)
|
|
95
|
+
process.exit(0)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (sub === 'open') {
|
|
99
|
+
const target = positional[1]
|
|
100
|
+
if (!target) {
|
|
101
|
+
console.error(pc.red('open needs a url: aihand probe open https://example.com'))
|
|
102
|
+
process.exit(1)
|
|
103
|
+
}
|
|
104
|
+
if (!(await alive())) {
|
|
105
|
+
spawnServer()
|
|
106
|
+
if (!(await waitAlive(8000))) {
|
|
107
|
+
console.error(pc.red('probe server failed to start on :' + PORT))
|
|
108
|
+
process.exit(1)
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
// --headed:弹出真浏览器窗口,人眼盯着看 aihand 驱动(默认无头,后台跑不画像素)。
|
|
112
|
+
const headed = flags.headed ? '&headed=1' : ''
|
|
113
|
+
const r = await fetch(`${base}/open?url=${encodeURIComponent(target)}${headed}`)
|
|
114
|
+
console.log(await r.text())
|
|
115
|
+
process.exit(r.ok ? 0 : 1)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// screen / click / fill / press / hover / wait → curl resident server
|
|
119
|
+
const reserved = new Set(['port', 'help'])
|
|
120
|
+
const query = Object.entries(flags)
|
|
121
|
+
.filter(([k]) => !reserved.has(k))
|
|
122
|
+
.map(([k, v]) => `${k}=${encodeURIComponent(v)}`)
|
|
123
|
+
.join('&')
|
|
124
|
+
const r = await fetch(`${base}/${sub}${query ? `?${query}` : ''}`).catch(() => null)
|
|
125
|
+
if (!r) {
|
|
126
|
+
console.error(pc.red(`no probe server on :${PORT} — run \`aihand probe open <url>\` first`))
|
|
127
|
+
process.exit(1)
|
|
128
|
+
}
|
|
129
|
+
console.log(await r.text())
|
|
130
|
+
process.exit(r.ok ? 0 : 1)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export async function run(argv: string[]) {
|
|
134
|
+
process.argv = [process.argv[0], process.argv[1], ...argv]
|
|
135
|
+
await main().catch((err) => {
|
|
136
|
+
console.error(pc.red(`Error: ${err.message}`))
|
|
137
|
+
process.exit(1)
|
|
138
|
+
})
|
|
139
|
+
}
|
|
@@ -0,0 +1,468 @@
|
|
|
1
|
+
/* eslint-disable no-console -- resident server; stdout is its log channel */
|
|
2
|
+
import http from 'node:http'
|
|
3
|
+
import os from 'node:os'
|
|
4
|
+
import path from 'node:path'
|
|
5
|
+
import { chromium } from 'playwright-core'
|
|
6
|
+
import type { Browser, Page } from 'playwright-core'
|
|
7
|
+
import type { ServerBridge } from '../bridge/transport'
|
|
8
|
+
import { cdpBridge } from '../bridge/cdp-bridge'
|
|
9
|
+
import { compileProbe } from '../bridge/compile-probe'
|
|
10
|
+
import { diagnoseEval } from '../core/action'
|
|
11
|
+
import { isRouteChange } from '../core/util'
|
|
12
|
+
import { argsFromQuery, dispatchAction, dispatchChainStep, dispatchScreen, isDispatchAction, runChain } from '../server/dispatch'
|
|
13
|
+
import type { ChainStep } from '../server/dispatch'
|
|
14
|
+
|
|
15
|
+
// 出口2 常驻 server:launch chromium、空降探针、serve HTTP。复用 dispatch(cdpBridge)——与出口1
|
|
16
|
+
// (Vite)同一内核。page 句柄活在本进程不退出 = 保持会话(登录态/滚动/打开的菜单都在),多次 CLI
|
|
17
|
+
// 连同一 server = 同一 page。这是「驱动运行中的浏览器」语义,不是「每命令 launch 一次」。
|
|
18
|
+
|
|
19
|
+
const PORT = 5179
|
|
20
|
+
|
|
21
|
+
let browser: Browser | undefined
|
|
22
|
+
let page: Page | undefined
|
|
23
|
+
let bridge: ServerBridge | undefined
|
|
24
|
+
// 主帧同文档路由切换订阅(随 page 重建而重赋,navProbe 的 awaitSameDocNav 经此 arm)。
|
|
25
|
+
let onSameDocNav: ((cb: () => void) => () => void) | undefined
|
|
26
|
+
let actionId = 0
|
|
27
|
+
|
|
28
|
+
async function launchChromium(headed: boolean): Promise<Browser> {
|
|
29
|
+
// 优先 playwright 自带 chromium;没下载(`playwright install chromium` 未跑)就退回系统 Chrome
|
|
30
|
+
// (channel:'chrome')——真实用户装了 aihand 不该被强制下一份浏览器。两者都没才抛。
|
|
31
|
+
// headed:`probe open --headed` 时为真,弹出真窗口让人眼盯着看 aihand 驱动;默认无头。
|
|
32
|
+
const headless = !headed
|
|
33
|
+
try {
|
|
34
|
+
return await chromium.launch({ headless })
|
|
35
|
+
}
|
|
36
|
+
catch (err) {
|
|
37
|
+
try {
|
|
38
|
+
return await chromium.launch({ headless, channel: 'chrome' })
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
throw err
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function ensurePage(url: string, headed: boolean): Promise<void> {
|
|
47
|
+
// browser/page 崩了(用户关窗、chromium 进程被杀)但引用还在 → 引用非空但已死。playwright 的
|
|
48
|
+
// isConnected() 是 browser 活性地面真值;page.isClosed() 判页。死了就清空三引用,落到下面重启分支
|
|
49
|
+
// 透明重 launch——否则复用死句柄,newPage()/goto() 抛 "Target closed",每个 open 都炸。
|
|
50
|
+
if (browser && !browser.isConnected())
|
|
51
|
+
browser = page = bridge = undefined
|
|
52
|
+
if (page && page.isClosed())
|
|
53
|
+
page = bridge = undefined
|
|
54
|
+
if (!browser)
|
|
55
|
+
browser = await launchChromium(headed)
|
|
56
|
+
if (!page) {
|
|
57
|
+
page = await browser.newPage()
|
|
58
|
+
// CDP session:拿 Page.frameRequestedNavigation(导航意图,先于 reply)。整页一份,随 page 活。
|
|
59
|
+
const cdp = await page.context().newCDPSession(page)
|
|
60
|
+
await cdp.send('Page.enable')
|
|
61
|
+
// 主帧 id 取自 getFrameTree 根帧(CDP 公开,非内部字段);用于过滤子 iframe 导航。
|
|
62
|
+
const mainFrameId = (await cdp.send('Page.getFrameTree')).frameTree.frame.id
|
|
63
|
+
// 主帧导航意图监听器表:request 飞行期注册一个,intent fire 时全调一遍(锁存,不 settle)。
|
|
64
|
+
const navIntentCbs = new Set<() => void>()
|
|
65
|
+
cdp.on('Page.frameRequestedNavigation', (e: { frameId: string }) => {
|
|
66
|
+
if (e.frameId === mainFrameId)
|
|
67
|
+
for (const cb of navIntentCbs) cb()
|
|
68
|
+
})
|
|
69
|
+
// 主帧同文档路由切换(SPA pushState)监听器表:dispatch 在动作前 arm 一个(见 awaitSameDocNav),
|
|
70
|
+
// navigatedWithinDocument fire 且 isRouteChange(URL path/search 真变)时全调一遍。lastMainUrl
|
|
71
|
+
// 随事件滚动,作下一次 isRouteChange 的 from——CDP 不带 from,要自己记。整页导航(frameNavigated)
|
|
72
|
+
// 也滚一下,免得换页后第一次 SPA 跳被拿旧 path 比错。
|
|
73
|
+
const sameDocNavCbs = new Set<() => void>()
|
|
74
|
+
let lastMainUrl = url
|
|
75
|
+
cdp.on('Page.navigatedWithinDocument', (e: { frameId: string, url: string }) => {
|
|
76
|
+
if (e.frameId !== mainFrameId)
|
|
77
|
+
return
|
|
78
|
+
const changed = isRouteChange(lastMainUrl, e.url)
|
|
79
|
+
lastMainUrl = e.url
|
|
80
|
+
if (changed)
|
|
81
|
+
for (const cb of sameDocNavCbs) cb()
|
|
82
|
+
})
|
|
83
|
+
cdp.on('Page.frameNavigated', (e: { frame: { id: string, url: string } }) => {
|
|
84
|
+
if (e.frame.id === mainFrameId)
|
|
85
|
+
lastMainUrl = e.frame.url
|
|
86
|
+
})
|
|
87
|
+
// bridge 的 waiters 表与 binding 的 deliver 必须共享同一实例,整页生命周期一份。
|
|
88
|
+
const wired = cdpBridge({
|
|
89
|
+
evaluate: (fn, arg) => page!.evaluate(fn, arg),
|
|
90
|
+
// 整页导航撞上下文销毁时,bridge 调这个等新文档真正可查询(body 挂上)再重试。
|
|
91
|
+
// 只等 domcontentloaded 不够:旧文档刚销毁、新文档 body 尚未 attach 的瞬间,
|
|
92
|
+
// collectScreen 的 body.querySelectorAll 仍 NPE。waitForFunction 等到 body 在。
|
|
93
|
+
settle: async () => {
|
|
94
|
+
await page!.waitForLoadState('domcontentloaded')
|
|
95
|
+
await page!.waitForFunction(() => Boolean(document.body))
|
|
96
|
+
},
|
|
97
|
+
// 主帧导航意图地面真值(先于 reply ~0.3ms):动作触发整页导航时 CDP 同步 fire,锁存置位
|
|
98
|
+
// 调用方 navigated 标记,不 settle request(reply 仍唯一 settle)。同文档动作永不 fire → 零延迟。
|
|
99
|
+
onMainFrameNavIntent: (cb) => {
|
|
100
|
+
navIntentCbs.add(cb)
|
|
101
|
+
return () => navIntentCbs.delete(cb)
|
|
102
|
+
},
|
|
103
|
+
// 同文档路由切换订阅:dispatch 在动作前 arm,reply 后等(awaitSameDocNav)。出口1 无此事件。
|
|
104
|
+
onMainFrameSameDocNav: (cb) => {
|
|
105
|
+
sameDocNavCbs.add(cb)
|
|
106
|
+
return () => sameDocNavCbs.delete(cb)
|
|
107
|
+
},
|
|
108
|
+
})
|
|
109
|
+
bridge = wired.bridge
|
|
110
|
+
// navProbe 的 awaitSameDocNav 经此 arm(sameDocNavCbs 随 page 生命周期一份)。
|
|
111
|
+
onSameDocNav = (cb) => {
|
|
112
|
+
sameDocNavCbs.add(cb)
|
|
113
|
+
return () => sameDocNavCbs.delete(cb)
|
|
114
|
+
}
|
|
115
|
+
// exposeBinding 必须在 addInitScript 之前——binding 是探针 send 的落点,先注册才能在新文档可用。
|
|
116
|
+
await page.exposeBinding('__aihandSend', (_src, event: string, payload: { id?: number }) => {
|
|
117
|
+
wired.deliver(event, payload)
|
|
118
|
+
})
|
|
119
|
+
await page.addInitScript(compileProbe())
|
|
120
|
+
}
|
|
121
|
+
await page.goto(url, { waitUntil: 'domcontentloaded' })
|
|
122
|
+
// addInitScript 只在新文档导航前注入;goto 后再补一发当前页注入,保证首个 open 即活。
|
|
123
|
+
await page.evaluate(compileProbe())
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function send(res: http.ServerResponse, status: number, body: string) {
|
|
127
|
+
res.writeHead(status, { 'content-type': 'text/plain; charset=utf-8' })
|
|
128
|
+
res.end(body)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function readBody(req: http.IncomingMessage): Promise<string> {
|
|
132
|
+
return new Promise((resolve) => {
|
|
133
|
+
let body = ''
|
|
134
|
+
req.on('data', chunk => (body += chunk))
|
|
135
|
+
req.on('end', () => resolve(body))
|
|
136
|
+
})
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// page.evaluate 有 page 句柄但**无 per-call 超时**——若被驱动页面的主线程/JS 上下文被某段死循环或
|
|
140
|
+
// 重 SPA(实测 tanstack 嵌入式沙箱 example 把主线程转死)卡住,`await page.evaluate` 永不 resolve,
|
|
141
|
+
// /eval 的 curl 无限挂死(exit 28),与 /screen 探针有 3s reply cap 形成不对称。in-page 探针卡了会
|
|
142
|
+
// 超时回诊断,page.evaluate 卡了却静默吊死。这里把它 race 一个计时器:卡过 cap 就 reject 一个**可
|
|
143
|
+
// 诊断**的错误(refine 到动作:页面主线程可能被阻塞),而非吊死——ker(诊断)⊆ker(动作)。
|
|
144
|
+
function withTimeout<T>(p: Promise<T>, ms: number, label: string): Promise<T> {
|
|
145
|
+
let timer: ReturnType<typeof setTimeout>
|
|
146
|
+
const timeout = new Promise<never>((_, reject) => {
|
|
147
|
+
timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms — page main thread may be blocked (heavy SPA / infinite loop). try a fresh \`open <url>\` to recover.`)), ms)
|
|
148
|
+
})
|
|
149
|
+
return Promise.race([p, timeout]).finally(() => clearTimeout(timer))
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// CDP 可信拖拽(出口2 独有)。SortableJS/dnd-kit/原生 DnD 用 isTrusted 门控:合成 PointerEvent
|
|
153
|
+
// (doDrag)无法满足——in-page dispatchEvent 的 isTrusted 恒 false,这些库的 activation 判据/原生
|
|
154
|
+
// drag 都不响应(live:SortableJS 合成拖拽不 reorder)。出口2 握着 playwright page 句柄,page.mouse
|
|
155
|
+
// 产**真实**指针事件(isTrusted=true,经浏览器输入管线),正是 trusted 门控动作需要的。先 in-page
|
|
156
|
+
// 解析 src/dst 视口中心坐标(复用 findElement 同款 sel/text 命中,一次 evaluate),再 page.mouse
|
|
157
|
+
// move→down→分步 move→up 驱动(8 步与 doDrag 同,清 dnd activation distance),最后采 screen delta。
|
|
158
|
+
// realclick 同理(trusted click)走 page.mouse.click。两者是仅有的真需 trusted 的动作,其余合成路径不动。
|
|
159
|
+
async function resolveDragCoords(srcSel: string | undefined, srcText: string | undefined, toSel: string): Promise<{ ok: true, sx: number, sy: number, dx: number, dy: number, srcLabel: string, dstLabel: string } | { ok: false, error: string }> {
|
|
160
|
+
return page!.evaluate(({ sel, text, to }) => {
|
|
161
|
+
const w = window as unknown as { __aihandFindForDrag?: (s?: string, t?: string) => Element | null }
|
|
162
|
+
// 探针已注入 findElement/elementLabel 到 window?用现成的;否则退一个最小 sel/text 命中。
|
|
163
|
+
const pick = (s?: string, t?: string): Element | null => {
|
|
164
|
+
if (s) return document.querySelector(s)
|
|
165
|
+
if (t) {
|
|
166
|
+
const els = Array.from(document.querySelectorAll('a,button,li,div,span,[role],[draggable]'))
|
|
167
|
+
return els.find(e => (e.textContent || '').trim().includes(t)) ?? null
|
|
168
|
+
}
|
|
169
|
+
return null
|
|
170
|
+
}
|
|
171
|
+
const finder = w.__aihandFindForDrag ?? pick
|
|
172
|
+
const src = finder(sel, text)
|
|
173
|
+
if (!src) return { ok: false as const, error: `no source element for ${sel || text}` }
|
|
174
|
+
const dst = document.querySelector(to)
|
|
175
|
+
if (!dst) return { ok: false as const, error: `no destination element for to=${to}` }
|
|
176
|
+
// page.mouse 用视口坐标:元素在视口外(scrollY 偏移)时 rect 是负的/越界,真实指针落到空处。
|
|
177
|
+
// 先把 source 滚进视口中部再读 rect —— trusted 拖拽必须落在可见坐标上(合成 doDrag 直接 dispatch
|
|
178
|
+
// 到元素绕过坐标,所以没暴露这个;真实指针没有这条捷径)。
|
|
179
|
+
src.scrollIntoView({ block: 'center', inline: 'center' })
|
|
180
|
+
const sr = src.getBoundingClientRect()
|
|
181
|
+
const dr = dst.getBoundingClientRect()
|
|
182
|
+
const lbl = (e: Element) => (e.getAttribute('aria-label') || (e.textContent || '').trim() || e.tagName.toLowerCase()).slice(0, 30)
|
|
183
|
+
return { ok: true as const, sx: sr.left + sr.width / 2, sy: sr.top + sr.height / 2, dx: dr.left + dr.width / 2, dy: dr.top + dr.height / 2, srcLabel: lbl(src), dstLabel: lbl(dst) }
|
|
184
|
+
}, { sel: srcSel, text: srcText, to: toSel })
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async function trustedDrag(srcSel: string | undefined, srcText: string | undefined, toSel: string): Promise<{ status: number, body: string }> {
|
|
188
|
+
const c = await resolveDragCoords(srcSel, srcText, toSel)
|
|
189
|
+
if (!c.ok)
|
|
190
|
+
return { status: 422, body: c.error }
|
|
191
|
+
// SortableJS/dnd-kit 的拖拽检测需要:按下后先有一个**小幅初动**越过 activation 阈值,再逐帧
|
|
192
|
+
// 移动(每帧之间留 dispatch 间隙让库的 mousemove handler 跑),到目标后**停一拍**让 onMove/insert
|
|
193
|
+
// 计算落位,最后才松手。无间隙的瞬时移动会被多数 dnd 库判为「没动」或越过整段不触发中间 onMove。
|
|
194
|
+
const pause = (ms: number) => new Promise(r => setTimeout(r, ms))
|
|
195
|
+
await page!.mouse.move(c.sx, c.sy)
|
|
196
|
+
await page!.mouse.down()
|
|
197
|
+
await pause(30)
|
|
198
|
+
// 初动:朝目标方向先挪 6px,越过 SortableJS/dnd-kit 的 activation distance。
|
|
199
|
+
const len = Math.hypot(c.dx - c.sx, c.dy - c.sy) || 1
|
|
200
|
+
await page!.mouse.move(c.sx + (c.dx - c.sx) / len * 6, c.sy + (c.dy - c.sy) / len * 6)
|
|
201
|
+
await pause(20)
|
|
202
|
+
const steps = 12
|
|
203
|
+
for (let i = 1; i <= steps; i++) {
|
|
204
|
+
await page!.mouse.move(c.sx + (c.dx - c.sx) * (i / steps), c.sy + (c.dy - c.sy) * (i / steps))
|
|
205
|
+
await pause(16)
|
|
206
|
+
}
|
|
207
|
+
await pause(60) // 停一拍:让 dnd 库在目标位算好插入点
|
|
208
|
+
await page!.mouse.up()
|
|
209
|
+
const { result } = await dispatchScreen(bridge!)
|
|
210
|
+
return { status: 200, body: `dragged ${c.srcLabel} → ${c.dstLabel} (trusted page.mouse)\n\n--- changed ---\n${result.body}` }
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// afterNav:出口2 独有的跨整页导航回填(见动作端点注释)。/action 与 /chain 共用同一份——
|
|
214
|
+
// 链里的某步点链接触发换页,同样采新页全貌回填,不漏。
|
|
215
|
+
function navProbe() {
|
|
216
|
+
return {
|
|
217
|
+
url: async () => page!.url(),
|
|
218
|
+
// 同文档路由切换事后等待。两段:arm 必须在动作派发前调(此刻订阅 → 监听就位,免得 @0.2s 的快
|
|
219
|
+
// 信号在 reply 后才订阅而漏);wait(capMs) 在 reply 后调,与 capMs 竞速。SPA 换页(URL 真变)在
|
|
220
|
+
// capMs 内 fire → resolve(true) 立即短路;否则到 capMs → resolve(false)。两条都退订 + 清定时器
|
|
221
|
+
// (幂等,无泄漏)。无订阅能力(出口1/未接线)时恒返回一个立刻 false 的 wait。
|
|
222
|
+
armSameDocNav: (): { wait: (capMs: number) => Promise<boolean>, cancel: () => void } => {
|
|
223
|
+
if (!onSameDocNav)
|
|
224
|
+
return { wait: () => Promise.resolve(false), cancel: () => {} }
|
|
225
|
+
let fired = false
|
|
226
|
+
let onFire: (() => void) | undefined // wait() 装上;arm→wait 之间若已 fire 走 fired 标志
|
|
227
|
+
const unsub = onSameDocNav(() => { fired = true; onFire?.() })
|
|
228
|
+
return {
|
|
229
|
+
wait: (capMs: number) => new Promise<boolean>((resolve) => {
|
|
230
|
+
let done = false
|
|
231
|
+
let cap: ReturnType<typeof setTimeout> | undefined
|
|
232
|
+
const finish = (v: boolean) => { if (done) return; done = true; if (cap) clearTimeout(cap); unsub(); resolve(v) }
|
|
233
|
+
if (fired) return finish(true) // arm 后、wait 前就 fire 了(快信号),立即结清(cap 尚未装,finish 守 undefined)
|
|
234
|
+
onFire = () => finish(true)
|
|
235
|
+
cap = setTimeout(() => finish(false), capMs)
|
|
236
|
+
}),
|
|
237
|
+
// 门控未命中(整页导航 / 非平凡 diff)→ 不等,直接退订,不留悬挂监听。
|
|
238
|
+
cancel: unsub,
|
|
239
|
+
}
|
|
240
|
+
},
|
|
241
|
+
renavScreen: async (fromUrl: string): Promise<string | undefined> => {
|
|
242
|
+
await page!.waitForFunction(() => Boolean(document.body))
|
|
243
|
+
// body attach ≠ 内容就绪:MPA(服务端渲染)body 在时内容已在,但 SPA 结果页(DDG/Google/
|
|
244
|
+
// 现代站)body attach 时内容还在异步 fetch,立刻采帧得空壳(实战:press Enter 搜索后 afterNav
|
|
245
|
+
// 采到空树,2s 后手动 screen 才有 20 结果)。等 DOM 子树节点数趋稳再采 —— 通用判据不写死
|
|
246
|
+
// 选择器,同 traceFlow 的 DOM 静默律;有上限防长轮询/分析心跳站永不静而挂死。
|
|
247
|
+
// SPA 导航后 body 先挂空骨架(几节点)再 JS fetch 灌数据。「增量趋稳」会在骨架期假稳
|
|
248
|
+
// (5 节点两帧不变),要等内容真到:节点数涨过空壳门槛(>50)且两帧趋稳。networkidle 不可靠
|
|
249
|
+
// (分析心跳/长轮询站永不 idle),DOM 门槛 + 趋稳是纯结构判据,有上限防永不满足而挂死。
|
|
250
|
+
await page!.waitForFunction(() => {
|
|
251
|
+
const n = document.body.querySelectorAll('*').length
|
|
252
|
+
const w = window as unknown as { __aihandPrevN?: number }
|
|
253
|
+
const stable = n > 50 && w.__aihandPrevN !== undefined && Math.abs(n - w.__aihandPrevN) < 5
|
|
254
|
+
w.__aihandPrevN = n
|
|
255
|
+
return stable
|
|
256
|
+
}, { timeout: 3000, polling: 150 }).catch(() => {})
|
|
257
|
+
await page!.evaluate(compileProbe())
|
|
258
|
+
const toUrl = page!.url()
|
|
259
|
+
const { result } = await dispatchScreen(bridge!)
|
|
260
|
+
return `navigated: ${fromUrl} → ${toUrl}\n${result.body}`
|
|
261
|
+
},
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const server = http.createServer(async (req, res) => {
|
|
266
|
+
try {
|
|
267
|
+
const url = new URL(req.url || '/', `http://localhost:${PORT}`)
|
|
268
|
+
const parts = url.pathname.split('/').filter(Boolean)
|
|
269
|
+
|
|
270
|
+
if (parts[0] === 'open') {
|
|
271
|
+
const target = url.searchParams.get('url') || ''
|
|
272
|
+
if (!target) {
|
|
273
|
+
send(res, 400, 'open needs ?url=')
|
|
274
|
+
return
|
|
275
|
+
}
|
|
276
|
+
// headed 由首个 open 决定(browser 整进程一份):后续 open 复用已起的 browser,headed 不变。
|
|
277
|
+
await ensurePage(target, url.searchParams.get('headed') === '1')
|
|
278
|
+
// open 落到的结果就是页面本身——回执直接带语义树+@kN,不回一句空转的 "opened"。
|
|
279
|
+
// 「打开→看页面」坍缩成一轮:agent 一轮就看到页面、拿到可寻址旋钮,下一步直接 click。
|
|
280
|
+
const { result } = await dispatchScreen(bridge!)
|
|
281
|
+
send(res, 200, `opened ${target}\n${result.body}`)
|
|
282
|
+
return
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
if (!page || !bridge) {
|
|
286
|
+
send(res, 409, 'no page open — run `aihand probe open <url>` first')
|
|
287
|
+
return
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
if (parts[0] === 'screen') {
|
|
291
|
+
const { result } = await dispatchScreen(bridge, { form: url.searchParams.get('form') || undefined })
|
|
292
|
+
send(res, 200, result.body)
|
|
293
|
+
return
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// /eval — the escape hatch for any value the spatial /screen can't surface: read exact
|
|
297
|
+
// text/numbers, query closures, install listeners. Runs in the REAL page via playwright's
|
|
298
|
+
// page.evaluate (not the parachuted probe, not Node) — same auto-return as exit-1's /eval:
|
|
299
|
+
// try the code as a single expression first (`document.title`, `qsa('.x').length` yield a
|
|
300
|
+
// value without `return`); on compile failure fall back to a statement block. diagnoseEval
|
|
301
|
+
// (pure, shared with exit-1) refines the eval fiber → typed twin on the two strong signals.
|
|
302
|
+
if (parts[0] === 'eval') {
|
|
303
|
+
let code = url.searchParams.get('code') || ''
|
|
304
|
+
if (!code && req.method === 'POST')
|
|
305
|
+
code = await readBody(req)
|
|
306
|
+
if (!code) {
|
|
307
|
+
send(res, 400, 'eval needs ?code= or a POST body')
|
|
308
|
+
return
|
|
309
|
+
}
|
|
310
|
+
let r: { ok: boolean, value?: string, error?: string }
|
|
311
|
+
// Expression-first, statement-block fallback — but the two wrappers must be SEPARATE
|
|
312
|
+
// page.evaluate calls. A single string with a runtime try/catch can't recover: the
|
|
313
|
+
// whole string is ONE parse unit, so a multi-statement body (`const n=5; n*2`) makes
|
|
314
|
+
// the expression branch `(async()=>(const n=5; n*2))()` a *syntax* error that aborts
|
|
315
|
+
// parsing before the catch can ever run (mirrors exit-1 runEval's two new Function()s).
|
|
316
|
+
const fmt = (value: unknown) => value === undefined ? undefined : typeof value === 'string' ? value : JSON.stringify(value, null, 2)
|
|
317
|
+
// 8s cap:足够慢页面真算完(在线 demo/重渲染),又不会让卡死页面把 curl 吊到天荒地老。
|
|
318
|
+
// timeout error 的 message 含 'timed out' 不含 'SyntaxError' → 不会触发语句块重试(重跑卡死
|
|
319
|
+
// 页面只会再挂一次),直接当 422 报回可诊断信息。
|
|
320
|
+
try {
|
|
321
|
+
// page.evaluate JSON-clones the return so objects round-trip.
|
|
322
|
+
r = { ok: true, value: fmt(await withTimeout(page!.evaluate(`(async () => (${code}))()`), 8000, 'eval')) }
|
|
323
|
+
}
|
|
324
|
+
catch (e1) {
|
|
325
|
+
const m1 = e1 instanceof Error ? e1.message : String(e1)
|
|
326
|
+
if (m1.includes('SyntaxError')) {
|
|
327
|
+
try {
|
|
328
|
+
r = { ok: true, value: fmt(await withTimeout(page!.evaluate(`(async () => { ${code} })()`), 8000, 'eval')) }
|
|
329
|
+
}
|
|
330
|
+
catch (e2) {
|
|
331
|
+
r = { ok: false, error: e2 instanceof Error ? e2.message : String(e2) }
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
else {
|
|
335
|
+
r = { ok: false, error: m1 }
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
const hint = diagnoseEval(code, r.ok, r.error)
|
|
339
|
+
const body = r.ok ? (r.value ?? 'undefined') : `error: ${r.error}`
|
|
340
|
+
send(res, r.ok ? 200 : 422, hint ? `${body}\n\nhint: ${hint}` : body)
|
|
341
|
+
return
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// /screenshot — pixels, not the character sketch. The escape hatch for visual verification
|
|
345
|
+
// (a chart, a captcha, a layout the降采样 sketch clips). Writes a PNG to a temp file and
|
|
346
|
+
// returns the path — the agent reads it, a human opens it. ?sel= shoots one element;
|
|
347
|
+
// ?fullPage=1 captures the whole scroll height (default: viewport only).
|
|
348
|
+
if (parts[0] === 'screenshot') {
|
|
349
|
+
const sel = url.searchParams.get('sel')
|
|
350
|
+
const fullPage = url.searchParams.get('fullPage') === '1'
|
|
351
|
+
const file = path.join(os.tmpdir(), `aihand-shot-${++actionId}.png`)
|
|
352
|
+
try {
|
|
353
|
+
if (sel) {
|
|
354
|
+
const el = page!.locator(sel).first()
|
|
355
|
+
if (await el.count() === 0) {
|
|
356
|
+
send(res, 404, `screenshot: no element matches ${sel}`)
|
|
357
|
+
return
|
|
358
|
+
}
|
|
359
|
+
await el.screenshot({ path: file })
|
|
360
|
+
}
|
|
361
|
+
else {
|
|
362
|
+
await page!.screenshot({ path: file, fullPage })
|
|
363
|
+
}
|
|
364
|
+
send(res, 200, `screenshot saved: ${file}`)
|
|
365
|
+
}
|
|
366
|
+
catch (e) {
|
|
367
|
+
send(res, 500, `screenshot failed: ${e instanceof Error ? e.message : String(e)}`)
|
|
368
|
+
}
|
|
369
|
+
return
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// /chain — POST 一串动作,一往返跑完整个交互(每步 settle 后再下一步,fail-fast)。
|
|
373
|
+
// 驱动外部站点的已知多步序列(登录:fill→fill→click;搜索:fill→press)免逐步往返。
|
|
374
|
+
// 循环/渲染/skipped 汇报走内核 runChain;出口2 的单步执行器纯 dispatchAction(无 knob 特例)。
|
|
375
|
+
if (parts[0] === 'chain') {
|
|
376
|
+
let steps: ChainStep[]
|
|
377
|
+
try {
|
|
378
|
+
steps = JSON.parse(await readBody(req))
|
|
379
|
+
if (!Array.isArray(steps))
|
|
380
|
+
throw new Error('body must be a JSON array')
|
|
381
|
+
}
|
|
382
|
+
catch (e) {
|
|
383
|
+
send(res, 400, `invalid chain body: ${e instanceof Error ? e.message : String(e)}`)
|
|
384
|
+
return
|
|
385
|
+
}
|
|
386
|
+
const dr = await runChain(steps, (type, args) =>
|
|
387
|
+
dispatchChainStep(bridge!, type, args, { nextId: () => ++actionId, afterNav: navProbe() }))
|
|
388
|
+
send(res, dr.status, dr.body)
|
|
389
|
+
return
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// trusted 门控动作(drag/realclick)走 page.mouse 真实指针事件,不下沉到合成 in-page 路径
|
|
393
|
+
// —— SortableJS/dnd-kit/原生 DnD/isTrusted-gated handler 只认真实事件。出口2 独有(握 page 句柄)。
|
|
394
|
+
if (parts[0] === 'drag') {
|
|
395
|
+
if (!page || !bridge) {
|
|
396
|
+
send(res, 400, 'no page open — run `aihand probe open <url>` first')
|
|
397
|
+
return
|
|
398
|
+
}
|
|
399
|
+
const to = url.searchParams.get('to')
|
|
400
|
+
if (!to) {
|
|
401
|
+
send(res, 400, 'drag needs to= (destination selector)')
|
|
402
|
+
return
|
|
403
|
+
}
|
|
404
|
+
const dr = await trustedDrag(url.searchParams.get('sel') || undefined, url.searchParams.get('text') || undefined, to)
|
|
405
|
+
send(res, dr.status, dr.body)
|
|
406
|
+
return
|
|
407
|
+
}
|
|
408
|
+
if (parts[0] === 'realclick') {
|
|
409
|
+
if (!page || !bridge) {
|
|
410
|
+
send(res, 400, 'no page open — run `aihand probe open <url>` first')
|
|
411
|
+
return
|
|
412
|
+
}
|
|
413
|
+
const c = await resolveDragCoords(url.searchParams.get('sel') || undefined, url.searchParams.get('text') || undefined, url.searchParams.get('sel') || url.searchParams.get('text') || '')
|
|
414
|
+
// resolveDragCoords 的 to 这里复用 src(realclick 只需一个目标);src 解析失败即报。
|
|
415
|
+
if (!c.ok) {
|
|
416
|
+
send(res, 422, c.error)
|
|
417
|
+
return
|
|
418
|
+
}
|
|
419
|
+
await page.mouse.click(c.sx, c.sy, { button: url.searchParams.get('button') === 'right' ? 'right' : 'left' })
|
|
420
|
+
const { result } = await dispatchScreen(bridge)
|
|
421
|
+
send(res, 200, `realclicked ${c.srcLabel} (trusted page.mouse)\n\n--- changed ---\n${result.body}`)
|
|
422
|
+
return
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
if (isDispatchAction(parts[0])) {
|
|
426
|
+
const args = argsFromQuery(url.searchParams)
|
|
427
|
+
// afterNav:出口2 独有。点链接触发整页导航时,探针随旧文档死,它的 after 帧采的是旧页残影。
|
|
428
|
+
// 导航信号由 cdpBridge 派发动作撞上下文销毁时经 onNav 交给 dispatch(零轮询),这里只在
|
|
429
|
+
// 确实导航后被调:url() 读新页 URL、renavScreen 采新页全貌,换页确认 + 新页并回这一往返
|
|
430
|
+
// (对齐出口1 的「行动+确认一次结清」)。同文档动作 onNav 不触发 → 这俩都不被调,零延迟。
|
|
431
|
+
const dr = await dispatchAction(bridge, parts[0], args, { nextId: () => ++actionId, afterNav: navProbe() })
|
|
432
|
+
send(res, dr.status, dr.body)
|
|
433
|
+
return
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// 绿色通道(/action?knob=)是 app 自驱能力——replay app 自己的 store 态射,依赖 app 暴露
|
|
437
|
+
// __AIPEEK_STORES__ + build-time knobSem schema。CDP 空降到外部站零合作,二者皆不存在,
|
|
438
|
+
// @kN 旋钮也不存在(numberKnobs 只编号 kind:'knob' 的 Box)。误导消息会让 agent 看不懂
|
|
439
|
+
// 又不知替代;细分到动作:点名 knob 专属性 + 指向外部站该用的 DOM 寻址(ker 诊断⊆ker 动作)。
|
|
440
|
+
if (parts[0] === 'action' || parts[0] === 'knob') {
|
|
441
|
+
send(res, 422, 'knob green channel replays the app\'s own store morphisms — '
|
|
442
|
+
+ 'unavailable when driving an external site (no app store schema). '
|
|
443
|
+
+ 'use click/fill/press with text= or sel= instead.')
|
|
444
|
+
return
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
send(res, 404, `unknown probe command: ${parts[0]}`)
|
|
448
|
+
}
|
|
449
|
+
catch (err) {
|
|
450
|
+
send(res, 500, `probe error: ${(err as Error).message}`)
|
|
451
|
+
}
|
|
452
|
+
})
|
|
453
|
+
|
|
454
|
+
server.listen(PORT, () => console.log(`aihand probe server on :${PORT}`))
|
|
455
|
+
|
|
456
|
+
// 优雅退出:kill/Ctrl-C 时关 browser,不留 chromium 僵尸进程(否则反复 spawn server 会堆积无头 chromium)。
|
|
457
|
+
// browser?.close() 对一个已断连的 browser 可能永不 resolve(playwright 等关闭确认等不到),故 1s
|
|
458
|
+
// 兜底无论如何强制 exit——清不掉总比挂死强(实测:不兜底则首个 SIGTERM 被 close() 吞住,进程不退)。
|
|
459
|
+
let exiting = false
|
|
460
|
+
for (const sig of ['SIGTERM', 'SIGINT'] as const) {
|
|
461
|
+
process.on(sig, () => {
|
|
462
|
+
if (exiting)
|
|
463
|
+
process.exit(0) // 第二次信号:别再等,立刻走
|
|
464
|
+
exiting = true
|
|
465
|
+
setTimeout(() => process.exit(0), 1000).unref()
|
|
466
|
+
browser?.close().catch(() => {}).finally(() => process.exit(0))
|
|
467
|
+
})
|
|
468
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// self-act —— 第三原语。delta = 两次 self-rep 投影的差。
|
|
2
|
+
//
|
|
3
|
+
// self-act 不是新机制:一次动作发生后,self 从 before 投影变到 after 投影,delta 就是
|
|
4
|
+
// 「这次动作引起了什么」。这正是 aihand 的 diffState 在做的事 —— view/modal/focus 转移
|
|
5
|
+
// + domain 状态机转移。但 diffState diff 的是 panel/knob 投影,act diff 的是 Self;
|
|
6
|
+
// 两者语义不同,故内联同一套 stringify+逐键比较,而非复用 diff.ts(强合=耦合两个不同的 diff)。
|
|
7
|
+
//
|
|
8
|
+
// 贡献不是这个 diff,而是「两次投影差」这个接法 + 被 diff 的 domain 来自静态引擎长出的
|
|
9
|
+
// 声明(见 screen-hook),不是手挂。三原语的第三个:self-rep(感知)/ self-log(物理)/ self-act(变化)。
|
|
10
|
+
|
|
11
|
+
import type { Self } from './self'
|
|
12
|
+
|
|
13
|
+
// 一行、有界、比较稳定的 domain 值字符串化。既用于探测变化(字符串相等),也用于渲染转移。
|
|
14
|
+
function stringifyDomain(v: unknown): string {
|
|
15
|
+
if (v === null || v === undefined)
|
|
16
|
+
return String(v)
|
|
17
|
+
if (typeof v === 'object')
|
|
18
|
+
return JSON.stringify(v).slice(0, 80)
|
|
19
|
+
return String(v).slice(0, 80)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// before → after 的 self-rep 投影差。view/modal/focus 转移 + state(domain)逐键转移。
|
|
23
|
+
// 返回人类/AI 可读的 delta 行;无变化返回空数组(「这次动作什么也没动」)。
|
|
24
|
+
export function act(before: Self, after: Self): string[] {
|
|
25
|
+
const lines: string[] = []
|
|
26
|
+
if (after.view !== before.view)
|
|
27
|
+
lines.push(`view: ${before.view} → ${after.view}`)
|
|
28
|
+
if (after.modal !== before.modal) {
|
|
29
|
+
if (after.modal === null)
|
|
30
|
+
lines.push(`modal: closed (${before.modal})`)
|
|
31
|
+
else if (before.modal === null)
|
|
32
|
+
lines.push(`modal: opened ${after.modal}`)
|
|
33
|
+
else
|
|
34
|
+
lines.push(`modal: ${before.modal} → ${after.modal}`)
|
|
35
|
+
}
|
|
36
|
+
if (after.focus !== before.focus)
|
|
37
|
+
lines.push(`focus: ${after.focus}`)
|
|
38
|
+
// state 即 domain —— app 自己的语义状态机。这些转移(如 流式 false→true)
|
|
39
|
+
// 是纯 DOM 投影看不见的,正是 self-rep 让它们可见。
|
|
40
|
+
for (const key of new Set([...Object.keys(before.state), ...Object.keys(after.state)])) {
|
|
41
|
+
const b = stringifyDomain(before.state[key])
|
|
42
|
+
const a = stringifyDomain(after.state[key])
|
|
43
|
+
if (b !== a)
|
|
44
|
+
lines.push(`${key}: ${b} → ${a}`)
|
|
45
|
+
}
|
|
46
|
+
return lines
|
|
47
|
+
}
|