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,68 @@
|
|
|
1
|
+
// Source NodePath + types from @babel/core so they share one module identity. Mixing
|
|
2
|
+
// @babel/traverse's NodePath with a standalone @babel/types import pins them to
|
|
3
|
+
// different @babel/types copies when versions resolve (JSX node variance error).
|
|
4
|
+
import type { NodePath, PluginObj, types as BabelTypes } from '@babel/core'
|
|
5
|
+
import path from 'path'
|
|
6
|
+
|
|
7
|
+
type JSXOpeningElement = BabelTypes.JSXOpeningElement
|
|
8
|
+
|
|
9
|
+
// Babel plugin: stamp every host JSX element with a `data-insp-path` source-location
|
|
10
|
+
// attribute — the same attribute aihand's client already consumes (client.ts inspPath /
|
|
11
|
+
// scopeRoot) to print `@File.tsx:line` and to power `/dom?scope=`. aihand now produces the
|
|
12
|
+
// marker it reads, so one plugin covers both click-to-source (the overlay in client.ts) and
|
|
13
|
+
// the AI's source-aware DOM — no separate source-location plugin needed.
|
|
14
|
+
//
|
|
15
|
+
// Format is "relative/path.tsx:line:col:Name". aihand only reads
|
|
16
|
+
// segs[0] (file) + segs[1] (line); the trailing name is the file basename, informational only.
|
|
17
|
+
//
|
|
18
|
+
// Only HOST elements (lowercase tag → a real DOM node) get the attribute — a component
|
|
19
|
+
// element (<Foo>) renders to its own host children, which carry their own marks. Fragments
|
|
20
|
+
// and elements that already declare data-insp-path are skipped.
|
|
21
|
+
//
|
|
22
|
+
// Takes the babel `types` builder as a param (the standard plugin-API form) instead of a
|
|
23
|
+
// static `import * as t from '@babel/types'`. That keeps this module free of any runtime
|
|
24
|
+
// babel import, so plugin.ts (which IS bundled into dist) can import it without pulling
|
|
25
|
+
// @babel/types into both output formats and breaking the dual-format contract.
|
|
26
|
+
export function sourceLocPlugin(t: typeof BabelTypes): PluginObj {
|
|
27
|
+
return {
|
|
28
|
+
name: 'aihand-source-loc',
|
|
29
|
+
visitor: {
|
|
30
|
+
JSXOpeningElement(nodePath: NodePath<JSXOpeningElement>, state) {
|
|
31
|
+
const filename: string = state.file?.opts?.filename ?? ''
|
|
32
|
+
if (!filename || filename.includes('node_modules'))
|
|
33
|
+
return
|
|
34
|
+
if (filename.includes('packages/aidev/'))
|
|
35
|
+
return
|
|
36
|
+
|
|
37
|
+
const nameNode = nodePath.node.name
|
|
38
|
+
// host element only: <div>, <span> — a lowercase JSXIdentifier. Skip <Foo>,
|
|
39
|
+
// <Foo.Bar> (member expression), and namespaced names.
|
|
40
|
+
if (!t.isJSXIdentifier(nameNode))
|
|
41
|
+
return
|
|
42
|
+
const tag = nameNode.name
|
|
43
|
+
if (tag[0] === tag[0].toUpperCase())
|
|
44
|
+
return
|
|
45
|
+
|
|
46
|
+
if (hasInspAttr(t, nodePath.node))
|
|
47
|
+
return
|
|
48
|
+
|
|
49
|
+
const loc = nodePath.node.loc
|
|
50
|
+
if (!loc)
|
|
51
|
+
return
|
|
52
|
+
|
|
53
|
+
const root: string = state.file?.opts?.root ?? ''
|
|
54
|
+
const rel = root ? path.relative(root, filename) : filename
|
|
55
|
+
const value = `${rel}:${loc.start.line}:${loc.start.column + 1}:${path.basename(rel, path.extname(rel))}`
|
|
56
|
+
|
|
57
|
+
nodePath.node.attributes.push(
|
|
58
|
+
t.jsxAttribute(t.jsxIdentifier('data-insp-path'), t.stringLiteral(value)),
|
|
59
|
+
)
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function hasInspAttr(t: typeof BabelTypes, node: JSXOpeningElement): boolean {
|
|
66
|
+
return node.attributes.some(a =>
|
|
67
|
+
t.isJSXAttribute(a) && t.isJSXIdentifier(a.name) && a.name.name === 'data-insp-path')
|
|
68
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import type { ServerBridge } from './transport'
|
|
2
|
+
|
|
3
|
+
// 出口2 桥:把一个 playwright Page 包成 ServerBridge。与 viteBridge 同构,但单页直连——
|
|
4
|
+
// 没有多 tab、没有 requireVisible、没有 twoPhase 两段投递。是 twoPhase 的退化版「evaluate 派发 + 超时」。
|
|
5
|
+
//
|
|
6
|
+
// server→browser 发请求 = page.evaluate(window.__aihandRecv, reqEvent, {id,...})
|
|
7
|
+
// browser→server 回回复 = 探针 window.__aihandSend(replyEvent, {id,...}),由 page.exposeBinding
|
|
8
|
+
// 注册的 binding 收,按 event 分发到等同一 replyEvent + id 的 request。
|
|
9
|
+
//
|
|
10
|
+
// exposeBinding('__aihandSend') 必须在 addInitScript(探针) 之前注册(见 probe/server.ts)。
|
|
11
|
+
|
|
12
|
+
// playwright Page 的最小依赖面——只用 evaluate(传 [event,payload] 元组进页面),不引整个
|
|
13
|
+
// playwright 类型(出口2 独占)。exposeBinding 接线在 probe/server.ts 做,bridge 不碰。
|
|
14
|
+
// settle:整页导航把旧文档执行上下文销毁,evaluate 撞 "Execution context was destroyed";
|
|
15
|
+
// bridge 调 settle()(server 注入 = waitForLoadState)等新文档就绪后重试一次。
|
|
16
|
+
export interface ProbePage {
|
|
17
|
+
evaluate: (fn: (a: [string, object]) => unknown, arg: [string, object]) => Promise<unknown>
|
|
18
|
+
settle?: () => Promise<void>
|
|
19
|
+
// 主帧「导航意图」订阅(出口2 由 CDP Page.frameRequestedNavigation 接线,出口1 不实现 → 恒不 fire)。
|
|
20
|
+
// 实测:锚点 click 的 reply 在 ~62ms 先回(探针 handler 已跑完),framenavigated(commit)要等
|
|
21
|
+
// ~1500ms 网络往返才到——太晚抓不住。frameRequestedNavigation(intent)在 click handler 同步设
|
|
22
|
+
// location 时即 fire(~61.7ms,先于 reply),带目标 URL。这是「动作触发整页导航」唯一与动作同源、
|
|
23
|
+
// 先于 reply 的地面真值。竞速语义:fire 既置 navigated 标记又 settle request(合成 {ok:true})——
|
|
24
|
+
// 导航场景 reply 从被销毁的旧文档发出必丢,不竞速就死等超时(见 request)。request 飞行期订阅一次,
|
|
25
|
+
// settle 时退订;reply 先到则退订本闩,迟到 intent 落空。返回退订 fn。
|
|
26
|
+
onMainFrameNavIntent?: (cb: () => void) => () => void
|
|
27
|
+
// 主帧「同文档路由切换」订阅(SPA pushState:Turbo/React Router,URL 变但文档不销毁)。与上面的
|
|
28
|
+
// 整页导航闩对偶——整页导航销毁上下文让 reply 丢失故能竞速,同文档导航上下文存活、reply 快速带残缺
|
|
29
|
+
// screen 先回赢竞速,真正的换页 morph 在 reply 后 ~0.2~1.4s 才到(CDP Page.navigatedWithinDocument)。
|
|
30
|
+
// 故这个信号必须 outlive reply:不接进 request 竞速,由 dispatch 在动作前 arm、reply 后等(见
|
|
31
|
+
// NavProbe.awaitSameDocNav)。回调仅在主帧 ∧ URL path 真变(非纯 #hash 锚点滚动)时触发,实测对
|
|
32
|
+
// 普通非导航 click 零误报。出口1 不实现 → 恒不 fire,零回归。返回退订 fn。
|
|
33
|
+
onMainFrameSameDocNav?: (cb: () => void) => () => void
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
type Waiter = { id?: number, resolve: (v: any) => void }
|
|
37
|
+
|
|
38
|
+
// 整页导航瞬态:旧文档上下文销毁 / 页面正导航中。撞上就 settle + 重试一次,不是真失败。
|
|
39
|
+
function isNavTransient(err: unknown): boolean {
|
|
40
|
+
const m = (err as Error)?.message ?? ''
|
|
41
|
+
return m.includes('Execution context was destroyed') || m.includes('because of a navigation')
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// evaluate 派发,撞导航瞬态(派发撞上一个正在 settle 的页面)则 settle(等新文档) + 重试一次。
|
|
45
|
+
// 一次足够:settle 后探针随 init script 已在新文档重注入,第二次必落在稳定上下文。
|
|
46
|
+
// 此处 onNav 是次要信号(派发本身撞销毁);「动作触发整页导航」的主信号走 request 的 onceMainFrameNav
|
|
47
|
+
// 闩——因为派发消息的那次 evaluate 干净返回(消息已落地),才轮到导航销毁上下文,这里不会抛。
|
|
48
|
+
async function evalWithSettle(page: ProbePage, arg: [string, object], onNav?: () => void): Promise<void> {
|
|
49
|
+
const fn = ([e, p]: [string, object]) => (window as { __aihandRecv?: (e: string, p: unknown) => void }).__aihandRecv?.(e, p)
|
|
50
|
+
try {
|
|
51
|
+
await page.evaluate(fn, arg)
|
|
52
|
+
}
|
|
53
|
+
catch (err) {
|
|
54
|
+
if (!isNavTransient(err) || !page.settle)
|
|
55
|
+
throw err
|
|
56
|
+
onNav?.()
|
|
57
|
+
await page.settle()
|
|
58
|
+
await page.evaluate(fn, arg)
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// page 与 exposeBinding 的接线在 probe/server.ts 启动时做一次;这里只需 page + 一个
|
|
63
|
+
// 共享的 waiters 表(server 把 __aihandSend 收到的回复喂进 deliver)。
|
|
64
|
+
export function cdpBridge(page: ProbePage): { bridge: ServerBridge, deliver: (event: string, payload: { id?: number, [k: string]: unknown }) => void } {
|
|
65
|
+
// replyEvent → 等待者集合。一次 request 装一个 waiter,deliver 命中即 resolve + 摘除。
|
|
66
|
+
const waiters = new Map<string, Set<Waiter>>()
|
|
67
|
+
|
|
68
|
+
const deliver = (event: string, payload: { id?: number, [k: string]: unknown }) => {
|
|
69
|
+
const set = waiters.get(event)
|
|
70
|
+
if (!set)
|
|
71
|
+
return
|
|
72
|
+
for (const w of set) {
|
|
73
|
+
if (w.id !== undefined && payload.id !== w.id)
|
|
74
|
+
continue
|
|
75
|
+
set.delete(w)
|
|
76
|
+
w.resolve(payload)
|
|
77
|
+
return
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const request = <T>(
|
|
82
|
+
reqEvent: string,
|
|
83
|
+
replyEvent: string,
|
|
84
|
+
payload: object,
|
|
85
|
+
opts?: { timeoutMs?: number, onNav?: () => void },
|
|
86
|
+
): Promise<T> => {
|
|
87
|
+
const fullMs = opts?.timeoutMs ?? 3000
|
|
88
|
+
const id = (payload as { id?: number }).id
|
|
89
|
+
return new Promise<T>((resolve, reject) => {
|
|
90
|
+
const waiter: Waiter = { id, resolve }
|
|
91
|
+
let set = waiters.get(replyEvent)
|
|
92
|
+
if (!set) {
|
|
93
|
+
set = new Set()
|
|
94
|
+
waiters.set(replyEvent, set)
|
|
95
|
+
}
|
|
96
|
+
set.add(waiter)
|
|
97
|
+
// reply / 超时 / 派发异常 汇进一个幂等 finish:恰好一个 settle,全路径 clearTimeout + 摘
|
|
98
|
+
// waiter + 退订导航意图闩,无定时器/监听器泄漏。导航不参与 settle(见下,锁存非竞速)。
|
|
99
|
+
let unsubNav: (() => void) | undefined
|
|
100
|
+
let done = false
|
|
101
|
+
const finish = (fn: () => void) => {
|
|
102
|
+
if (done)
|
|
103
|
+
return
|
|
104
|
+
done = true
|
|
105
|
+
clearTimeout(timer)
|
|
106
|
+
set!.delete(waiter)
|
|
107
|
+
unsubNav?.()
|
|
108
|
+
fn()
|
|
109
|
+
}
|
|
110
|
+
const timer = setTimeout(() => finish(() => reject(new Error(`probe: no reply on ${replyEvent} within ${fullMs}ms`))), fullMs)
|
|
111
|
+
waiter.resolve = v => finish(() => resolve(v))
|
|
112
|
+
// 导航意图闩:三方竞速(reply / 导航意图 / 超时),谁先到谁 settle。动作触发整页导航时,
|
|
113
|
+
// 探针的 reply 从旧文档发出而上下文已被导航销毁 → reply 必丢。若闩只置位不 settle,request
|
|
114
|
+
// 就死等丢失的 reply 到 5s 超时(实测 example→iana 5027ms 假超时,而页面其实已导航成功)。
|
|
115
|
+
// 故 intent fire 既调 onNav 置位 navigated、又合成 {ok:true} 走 finish settle:reply 丢了也不死等,
|
|
116
|
+
// dispatch 读 result.ok && navigated → renavScreen 回填新页全貌。reply 先到则透传真 reply 并退订
|
|
117
|
+
// 本闩(intent 后到落空,done 已 true);无导航则 intent 永不 fire,零延迟。合成体仅 {ok:true} 不带
|
|
118
|
+
// detail/screen——renavScreen 的新页全貌已是 changed,加 detail 会与 navigated 行语义重复。
|
|
119
|
+
// 仅 opts.onNav 在时武装(只 dispatchAction 要 nav-backfill;collect-screen/emit/renavScreen
|
|
120
|
+
// 内部采帧都不传 → 不武装,纯 reply 等待,新文档 settle 期采帧不被误合成)。
|
|
121
|
+
unsubNav = opts?.onNav
|
|
122
|
+
? page.onMainFrameNavIntent?.(() => finish(() => { opts.onNav!(); resolve({ ok: true } as T) }))
|
|
123
|
+
: undefined
|
|
124
|
+
// 派发进页面:探针的 onMsg(reqEvent) handler 收。撞整页导航瞬态(派发撞已在 settle 的页面)
|
|
125
|
+
// 则 settle + 重试一次,并经 onNav 把导航信号交给调用方(与意图闩幂等双置无害)。
|
|
126
|
+
evalWithSettle(page, [reqEvent, payload], opts?.onNav).catch(err => finish(() => reject(err)))
|
|
127
|
+
})
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const emit = (event: string, payload: object) => {
|
|
131
|
+
page.evaluate(
|
|
132
|
+
([e, p]: [string, object]) => (window as { __aihandRecv?: (e: string, p: unknown) => void }).__aihandRecv?.(e, p),
|
|
133
|
+
[event, payload],
|
|
134
|
+
).catch(() => {})
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return { bridge: { request, emit }, deliver }
|
|
138
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { resolve } from 'node:path'
|
|
2
|
+
import { existsSync } from 'node:fs'
|
|
3
|
+
import { buildSync } from 'esbuild'
|
|
4
|
+
|
|
5
|
+
// 出口2 探针字符串:把 client.ts 的 installProbe(连 core/* 一起)bundle 成自包含 IIFE,
|
|
6
|
+
// 注入任意外部站点。对偶出口1(Vite dev server 内联随模块图加载)——出口2 没有 Vite,
|
|
7
|
+
// 没有 HMR,没有 virtual:aihand-knobs,所以:
|
|
8
|
+
// · define import.meta.hot = undefined → client.ts 末尾的 Vite shim(if(import.meta.hot){…})
|
|
9
|
+
// 整段 dead-code 消除(hello 报到、HMR 自愈、accept 字面量、virtual import 全不进 bundle)。
|
|
10
|
+
// · bundle 入口 = 一段 stdin 合成代码,import { installProbe } 后拼 CDP transport bootstrap:
|
|
11
|
+
// send → window.__aihandSend(event, payload)(cdpBridge 的 exposeBinding 收)
|
|
12
|
+
// onMsg → 注册进本地 handler map,window.__aihandRecv(event,payload) 由 server 经
|
|
13
|
+
// page.evaluate 调进来分发。
|
|
14
|
+
// · knobSem 留空——外部站无 app schema,/screen knobs 退回纯 label+坐标(行为不变)。
|
|
15
|
+
// compilePatch(plugin.ts)是 transformSync 单文件;这里必须 buildSync bundle(client.ts 引 core/*)。
|
|
16
|
+
|
|
17
|
+
// client.ts 源码位置随运行形态变:dev(jiti 跑 src,本文件在 src/ui/bridge)= ../client;
|
|
18
|
+
// built(tsup 把本文件打进 dist/ui/probe/server.js)__dirname=dist/ui/probe,而源码经 package.json
|
|
19
|
+
// files:["dist","src"] 一并发布在 <root>/src/ui/client。从 __dirname 向上逐层找 src/ui/client/client.ts。
|
|
20
|
+
function findClientDir(): string {
|
|
21
|
+
const local = [resolve(__dirname, '../client'), resolve(__dirname, 'client')]
|
|
22
|
+
for (const d of local) {
|
|
23
|
+
if (existsSync(resolve(d, 'client.ts')))
|
|
24
|
+
return d
|
|
25
|
+
}
|
|
26
|
+
// 向上找含 src/ui/client/client.ts 的祖先(dist 与 src 同处 package root)。
|
|
27
|
+
let dir = __dirname
|
|
28
|
+
for (let i = 0; i < 8; i++) {
|
|
29
|
+
const cand = resolve(dir, 'src/ui/client')
|
|
30
|
+
if (existsSync(resolve(cand, 'client.ts')))
|
|
31
|
+
return cand
|
|
32
|
+
const parent = resolve(dir, '..')
|
|
33
|
+
if (parent === dir)
|
|
34
|
+
break
|
|
35
|
+
dir = parent
|
|
36
|
+
}
|
|
37
|
+
return resolve(__dirname, '../client')
|
|
38
|
+
}
|
|
39
|
+
const clientDir = findClientDir()
|
|
40
|
+
const clientPath = resolve(clientDir, 'client.ts')
|
|
41
|
+
|
|
42
|
+
const BOOTSTRAP = `
|
|
43
|
+
import { installProbe } from ${JSON.stringify(clientPath)}
|
|
44
|
+
const handlers = new Map()
|
|
45
|
+
const transport = {
|
|
46
|
+
send: (event, payload) => window.__aihandSend(event, payload),
|
|
47
|
+
onMsg: (event, handler) => {
|
|
48
|
+
let list = handlers.get(event)
|
|
49
|
+
if (!list) { list = new Set(); handlers.set(event, list) }
|
|
50
|
+
list.add(handler)
|
|
51
|
+
return () => list.delete(handler)
|
|
52
|
+
},
|
|
53
|
+
}
|
|
54
|
+
window.__aihandRecv = (event, payload) => {
|
|
55
|
+
const list = handlers.get(event)
|
|
56
|
+
if (list) for (const h of list) h(payload)
|
|
57
|
+
}
|
|
58
|
+
installProbe(transport)
|
|
59
|
+
`
|
|
60
|
+
|
|
61
|
+
let cached: string | undefined
|
|
62
|
+
|
|
63
|
+
export function compileProbe(): string {
|
|
64
|
+
if (cached !== undefined)
|
|
65
|
+
return cached
|
|
66
|
+
const result = buildSync({
|
|
67
|
+
stdin: { contents: BOOTSTRAP, resolveDir: clientDir, loader: 'ts' },
|
|
68
|
+
bundle: true,
|
|
69
|
+
format: 'iife',
|
|
70
|
+
target: 'es2020',
|
|
71
|
+
define: { 'import.meta.hot': 'undefined' },
|
|
72
|
+
// minifySyntax 开 DCE/常量折叠(不改名,bundle 仍可读):没它 define 把 import.meta.hot
|
|
73
|
+
// 换成 undefined 后 `if (undefined){…}` 仍原样留在 bundle——Vite shim(HMR 自愈、accept、
|
|
74
|
+
// hello)全被打进外部站点。开了才把整段 if(undefined) 死代码消除。
|
|
75
|
+
minifySyntax: true,
|
|
76
|
+
write: false,
|
|
77
|
+
})
|
|
78
|
+
cached = result.outputFiles[0].text
|
|
79
|
+
return cached
|
|
80
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// 内核与出口之间的唯一耦合面。两层,只为已确定的两个 transport 抽,不预造第三个:
|
|
2
|
+
// Transport —— 探针端(浏览器内)持有,双向消息的最小公约数,fire-and-forget,id 匹配不在这层。
|
|
3
|
+
// ServerBridge —— server 端持有,发一个 event 等一个 id 匹配的回复。
|
|
4
|
+
//
|
|
5
|
+
// 出口1(Vite)与出口2(CDP)是同一内核的两个适配器:viteBridge 把回复经 HMR WebSocket 广播
|
|
6
|
+
// 按 id 收敛,cdpBridge 把回复经 page.exposeBinding 单页直连按 id 收敛——两者实现 ServerBridge,
|
|
7
|
+
// dispatch 对二者无感知。
|
|
8
|
+
|
|
9
|
+
export interface Transport {
|
|
10
|
+
send: (event: string, payload: unknown) => void
|
|
11
|
+
onMsg: (event: string, handler: (payload: any) => void) => () => void // 返回 off()
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface ServerBridge {
|
|
15
|
+
// 发 reqEvent(带 server 生成的 id),等探针回 replyEvent 中 id 匹配的那条。
|
|
16
|
+
request: <T>(reqEvent: string, replyEvent: string, payload: object, opts?: {
|
|
17
|
+
tab?: string
|
|
18
|
+
timeoutMs?: number
|
|
19
|
+
lastProgress?: () => number | undefined
|
|
20
|
+
// 动作派发撞整页导航(旧文档上下文销毁)时调一次——出口2 的导航信号副产物。
|
|
21
|
+
// 出口1(Vite)SPA 路由无整页导航销毁,viteBridge 永不调,navigated 恒 false。
|
|
22
|
+
onNav?: () => void
|
|
23
|
+
}) => Promise<T>
|
|
24
|
+
// 单向广播,不等回复(perf-reset 等)。
|
|
25
|
+
emit: (event: string, payload: object) => void
|
|
26
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import type { ViteDevServer } from 'vite'
|
|
2
|
+
import type { TabInfo } from '../core/types'
|
|
3
|
+
import type { ServerBridge } from './transport'
|
|
4
|
+
import { isLive } from '../core/util'
|
|
5
|
+
|
|
6
|
+
// 出口1 桥:把 Vite HMR WebSocket 包成 ServerBridge。dispatch 对它无感知,只调 request/emit。
|
|
7
|
+
//
|
|
8
|
+
// twoPhase 双段投递逐字搬自 plugin.ts(行为不变):
|
|
9
|
+
// 有 tab(?tab= 寻址)→ 跳过 requireVisible 段,只那个 tab 答;live 静默过 fullMs = 真 miss 快拒,
|
|
10
|
+
// absent(server 刚重启/页面自愈中)→ 每 RETRY_MS 重投到 re-register,封顶 ABSENT_CEILING。
|
|
11
|
+
// 无 tab → 先 requireVisible 段(只前台 tab 答),VISIBLE_MS 内没人答 → 第二段问所有人。
|
|
12
|
+
// 回复匹配:有 id 的(action/eval/semantic/knob)按 id 收;无 id 的(collect/dom/screen/state-path)
|
|
13
|
+
// 收第一条匹配 replyEvent 的回复(原 plugin.ts 单 pending 槽 last-write-wins 的等价收敛)。
|
|
14
|
+
// 每次 request 装一次性 server.hot.on(replyEvent) listener,settle 即自摘,不再散落常驻 listener + 命名槽。
|
|
15
|
+
|
|
16
|
+
const VISIBLE_MS = 400
|
|
17
|
+
|
|
18
|
+
export interface ViteBridgeDeps {
|
|
19
|
+
tabs: Map<string, TabInfo>
|
|
20
|
+
diagnose: (tab?: string) => string
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function viteBridge(server: ViteDevServer, deps: ViteBridgeDeps): ServerBridge {
|
|
24
|
+
// 异步流(发消息→流式→结束)合法地把动作开很久,客户端每帧 ping aihand:action-progress;
|
|
25
|
+
// 在此盖最新时戳,twoPhase 的 live-tab deadline 从最后生命迹象起算,而非派发起算——真挂的
|
|
26
|
+
// handler 仍会(无 ping → deadline 触发)拒。progress 收敛进桥,dispatch 无需手传 lastProgress。
|
|
27
|
+
const progressAt = new Map<number, number>()
|
|
28
|
+
server.hot.on('aihand:action-progress', (data: { id?: number }) => {
|
|
29
|
+
if (typeof data.id === 'number')
|
|
30
|
+
progressAt.set(data.id, Date.now())
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
const request = <T>(
|
|
34
|
+
reqEvent: string,
|
|
35
|
+
replyEvent: string,
|
|
36
|
+
payload: object,
|
|
37
|
+
opts?: { tab?: string, timeoutMs?: number, lastProgress?: () => number | undefined },
|
|
38
|
+
): Promise<T> => {
|
|
39
|
+
const tab = opts?.tab
|
|
40
|
+
const fullMs = opts?.timeoutMs ?? 3000
|
|
41
|
+
const id = (payload as { id?: number }).id
|
|
42
|
+
const lastProgress = opts?.lastProgress ?? (id !== undefined ? () => progressAt.get(id) : undefined)
|
|
43
|
+
return new Promise<T>((resolve, reject) => {
|
|
44
|
+
let settled = false
|
|
45
|
+
const onReply = (data: T & { id?: number, tab?: string }) => {
|
|
46
|
+
if (settled)
|
|
47
|
+
return
|
|
48
|
+
// id-bearing replies match by id; id-less replies (collect/dom/screen/state-path)
|
|
49
|
+
// take the first reply on this event — the prior single-slot last-write-wins.
|
|
50
|
+
if (id !== undefined && data.id !== id)
|
|
51
|
+
return
|
|
52
|
+
settled = true
|
|
53
|
+
cleanup()
|
|
54
|
+
resolve(data)
|
|
55
|
+
}
|
|
56
|
+
server.hot.on(replyEvent, onReply)
|
|
57
|
+
const cleanup = () => {
|
|
58
|
+
server.hot.off(replyEvent, onReply)
|
|
59
|
+
if (id !== undefined)
|
|
60
|
+
progressAt.delete(id)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Addressed at one tab: skip the requireVisible round entirely — only that tab answers.
|
|
64
|
+
if (tab) {
|
|
65
|
+
const RETRY_MS = 500
|
|
66
|
+
const ABSENT_CEILING_MS = 10000
|
|
67
|
+
const startedAt = Date.now()
|
|
68
|
+
const deliver = () => server.hot.send(reqEvent, { ...payload, tab })
|
|
69
|
+
deliver()
|
|
70
|
+
const iv = setInterval(() => {
|
|
71
|
+
if (settled) {
|
|
72
|
+
clearInterval(iv)
|
|
73
|
+
return
|
|
74
|
+
}
|
|
75
|
+
const t = deps.tabs.get(tab)
|
|
76
|
+
const live = !!t && isLive(t, Date.now())
|
|
77
|
+
const progress = lastProgress?.()
|
|
78
|
+
const sinceLife = Date.now() - Math.max(startedAt, progress ?? 0)
|
|
79
|
+
const elapsed = Date.now() - startedAt
|
|
80
|
+
if (live) {
|
|
81
|
+
if (sinceLife > fullMs) {
|
|
82
|
+
clearInterval(iv)
|
|
83
|
+
cleanup()
|
|
84
|
+
reject(new Error(deps.diagnose(tab)))
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
else if (elapsed > ABSENT_CEILING_MS) {
|
|
88
|
+
clearInterval(iv)
|
|
89
|
+
cleanup()
|
|
90
|
+
reject(new Error(deps.diagnose(tab)))
|
|
91
|
+
}
|
|
92
|
+
else {
|
|
93
|
+
deliver()
|
|
94
|
+
}
|
|
95
|
+
}, RETRY_MS)
|
|
96
|
+
return
|
|
97
|
+
}
|
|
98
|
+
server.hot.send(reqEvent, { ...payload, requireVisible: true })
|
|
99
|
+
setTimeout(() => {
|
|
100
|
+
if (settled)
|
|
101
|
+
return
|
|
102
|
+
server.hot.send(reqEvent, { ...payload, requireVisible: false })
|
|
103
|
+
setTimeout(() => {
|
|
104
|
+
if (settled)
|
|
105
|
+
return
|
|
106
|
+
cleanup()
|
|
107
|
+
reject(new Error(deps.diagnose(tab)))
|
|
108
|
+
}, fullMs)
|
|
109
|
+
}, VISIBLE_MS)
|
|
110
|
+
})
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const emit = (event: string, payload: object) => server.hot.send(event, payload)
|
|
114
|
+
|
|
115
|
+
return { request, emit }
|
|
116
|
+
}
|