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,2562 @@
|
|
|
1
|
+
/// <reference types="vite/client" />
|
|
2
|
+
// aihand client — collectors + HMR channel (module)
|
|
3
|
+
// The patch code (console/fetch/XHR/errors) is in client-patch.ts,
|
|
4
|
+
// injected as a synchronous inline script before any modules execute.
|
|
5
|
+
|
|
6
|
+
import type { ActionArgs, ActionResult, ActionType } from '../core/action'
|
|
7
|
+
import type { ActionEntry, ErrorEntry, LogEntry, NetworkRequest, PerformanceData, PerfBucketData, RegionDigest, ScreenSnap } from '../core/types'
|
|
8
|
+
import { elementLabel, getDirectText, INTERACTIVE, isSensitive, performAction, reachable, runEval, safeValue, withDialogGuard } from '../core/action'
|
|
9
|
+
import { isBusyState, isSecretKey, redactSecretValue } from '../core/candidates'
|
|
10
|
+
import type { Box, BoxKind } from '../core/canvas'
|
|
11
|
+
import { numberKnobs, renderCanvas, renderKnobs, renderSemanticTree } from '../core/canvas'
|
|
12
|
+
import { diffScreen, diffState, redactDomain, traceFlow } from '../core/diff'
|
|
13
|
+
import { executeKnob, parseLiteral, resolveKnobRef } from '../core/knob-exec'
|
|
14
|
+
import { formatActions } from '../core/util'
|
|
15
|
+
import type { KnobProjection } from '../server/knob-schema'
|
|
16
|
+
import type { Transport } from '../bridge/transport'
|
|
17
|
+
|
|
18
|
+
// 旋钮态射的 build 时投影(virtual:aihand-knobs)。join 键 = label+file。给 live DOM 候选叠加语义:
|
|
19
|
+
// 拨它 → store.{field=to}。出口1 shim 从 virtual 模块拉来填进这里(lookupSem/collectKnobs 在
|
|
20
|
+
// 模块作用域读它,渲染 /screen 的旋钮态射);出口2(外部站无 schema)留空——/screen knobs 退回纯
|
|
21
|
+
// label+坐标,行为不变(元真理:没它只是少个 → 后缀)。installProbe 默认也读它,出口1 二者同一份。
|
|
22
|
+
let knobSem: Record<string, KnobProjection> = {}
|
|
23
|
+
|
|
24
|
+
declare global {
|
|
25
|
+
interface Window {
|
|
26
|
+
__AIPEEK_STORES__?: Record<string, unknown>
|
|
27
|
+
// The app's own domain projection — collapses the UI to a few domain variables
|
|
28
|
+
// that don't live in the DOM (是否流式 / 选了哪个模型 / 未读数). Optional; when
|
|
29
|
+
// absent /screen falls back to the generic view/modal/focus projection unchanged.
|
|
30
|
+
__AIPEEK_SCREEN__?: () => Record<string, unknown>
|
|
31
|
+
// The view axis — the app's top-level mode/route. Only the app knows which of its
|
|
32
|
+
// many enum fields is the *top-level* view, so it declares it (the probe never guesses
|
|
33
|
+
// a store field). Absent → view falls back to '(unknown)'. App-agnostic seam.
|
|
34
|
+
__AIPEEK_VIEW__?: () => string
|
|
35
|
+
// Idempotency for shortest-path delivery: the server re-broadcasts an addressed action
|
|
36
|
+
// while a tab is mid self-heal, so the same action id can arrive more than once. Map id →
|
|
37
|
+
// its final result; on a repeat we replay the cached result instead of re-executing.
|
|
38
|
+
__AIPEEK_DONE_ACTIONS__?: Map<number, ActionResult>
|
|
39
|
+
// The green channel — named semantic actions the app exposes (sendPrompt, …). Each is
|
|
40
|
+
// the store-write + real semantic fn a button's onClick runs, minus the DOM; it returns
|
|
41
|
+
// its own before/after delta. The probe calls these by name, bypassing selector resolution
|
|
42
|
+
// and disabled/covered gating. App-declared (the app knows its own actions); absent → none.
|
|
43
|
+
__AIHAND_ACTIONS__?: Record<string, (...args: any[]) => unknown>
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Internal perf buffer shape (from client-patch.ts) — differs from PerformanceData
|
|
48
|
+
interface PerfStatBuf { total: number, n: number, max: number }
|
|
49
|
+
interface PerfBucketBuf {
|
|
50
|
+
components: Record<string, PerfStatBuf>
|
|
51
|
+
frames: { total: number, long: number, max: number, samples: number[] }
|
|
52
|
+
lines: Record<string, PerfStatBuf>
|
|
53
|
+
}
|
|
54
|
+
interface PerfBuffer {
|
|
55
|
+
startedAt: number
|
|
56
|
+
hiddenFrames: number
|
|
57
|
+
mobxPatched: boolean
|
|
58
|
+
buckets: Record<string, PerfBucketBuf>
|
|
59
|
+
active: string
|
|
60
|
+
longtasks: { count: number, max: number, lastEndAt: number }
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
interface AipeekBuffers {
|
|
64
|
+
consoleLogs: LogEntry[]
|
|
65
|
+
networkRequests: NetworkRequest[]
|
|
66
|
+
errorEntries: ErrorEntry[]
|
|
67
|
+
actionEntries: ActionEntry[]
|
|
68
|
+
perf: PerfBuffer
|
|
69
|
+
actionsDropped: number // monotonic head-eviction count from the action ring (getter on buffers)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Read buffers from the synchronous patch script
|
|
73
|
+
const buffers = (window as any).__AIPEEK_BUFFERS__ as AipeekBuffers | undefined
|
|
74
|
+
const consoleLogs: LogEntry[] = buffers?.consoleLogs ?? []
|
|
75
|
+
const networkRequests: NetworkRequest[] = buffers?.networkRequests ?? []
|
|
76
|
+
const errorEntries: ErrorEntry[] = buffers?.errorEntries ?? []
|
|
77
|
+
const actionEntries: ActionEntry[] = buffers?.actionEntries ?? []
|
|
78
|
+
const perfBuffer: PerfBuffer | undefined = buffers?.perf
|
|
79
|
+
|
|
80
|
+
// Project the runtime perf buffer into PerformanceData for collection.
|
|
81
|
+
// Top-20 components by total, capped samples already in buffer.
|
|
82
|
+
function collectPerformance(): PerformanceData | undefined {
|
|
83
|
+
if (!perfBuffer) return undefined
|
|
84
|
+
const buckets: PerfBucketData[] = []
|
|
85
|
+
// __all__ first, then named buckets
|
|
86
|
+
const names = Object.keys(perfBuffer.buckets).sort((a, b) => (a === '__all__' ? -1 : b === '__all__' ? 1 : a.localeCompare(b)))
|
|
87
|
+
for (const name of names) {
|
|
88
|
+
const b = perfBuffer.buckets[name]
|
|
89
|
+
const comps: Record<string, { total: number, n: number, max: number }> = {}
|
|
90
|
+
const sorted = Object.entries(b.components).sort((a, b) => b[1].total - a[1].total).slice(0, 20)
|
|
91
|
+
for (const [cname, stat] of sorted) {
|
|
92
|
+
comps[cname] = { total: stat.total, n: stat.n, max: stat.max }
|
|
93
|
+
}
|
|
94
|
+
const lines: Record<string, { total: number, n: number, max: number }> = {}
|
|
95
|
+
const sortedLines = Object.entries(b.lines ?? {}).sort((a, b) => b[1].total - a[1].total).slice(0, 30)
|
|
96
|
+
for (const [label, stat] of sortedLines) {
|
|
97
|
+
lines[label] = { total: stat.total, n: stat.n, max: stat.max }
|
|
98
|
+
}
|
|
99
|
+
buckets.push({
|
|
100
|
+
name,
|
|
101
|
+
components: comps,
|
|
102
|
+
frames: { total: b.frames.total, long: b.frames.long, max: b.frames.max, samples: [...b.frames.samples] },
|
|
103
|
+
lines,
|
|
104
|
+
})
|
|
105
|
+
}
|
|
106
|
+
return {
|
|
107
|
+
windowMs: Date.now() - perfBuffer.startedAt,
|
|
108
|
+
hiddenFrames: perfBuffer.hiddenFrames,
|
|
109
|
+
mobxPatched: perfBuffer.mobxPatched,
|
|
110
|
+
buckets,
|
|
111
|
+
longtasks: { ...perfBuffer.longtasks },
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Reset the perf buffer (clear window, start fresh)
|
|
116
|
+
function resetPerf() {
|
|
117
|
+
if (!perfBuffer) return
|
|
118
|
+
perfBuffer.startedAt = Date.now()
|
|
119
|
+
perfBuffer.hiddenFrames = 0
|
|
120
|
+
perfBuffer.buckets = {}
|
|
121
|
+
perfBuffer.longtasks = { count: 0, max: 0, lastEndAt: 0 }
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Stable per-tab id so commands can address one tab among many (?tab=). Lives in
|
|
125
|
+
// sessionStorage: one browser tab = one id, survives HMR/refresh, cleared on close.
|
|
126
|
+
// Falls back to an in-memory id if sessionStorage is unavailable (degrades to old
|
|
127
|
+
// broadcast behavior, never throws).
|
|
128
|
+
const TAB_ID = (() => {
|
|
129
|
+
try {
|
|
130
|
+
const k = '__aihand_tab_id__'
|
|
131
|
+
let id = sessionStorage.getItem(k)
|
|
132
|
+
if (!id) {
|
|
133
|
+
id = `t${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`
|
|
134
|
+
sessionStorage.setItem(k, id)
|
|
135
|
+
}
|
|
136
|
+
return id
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
return `t${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`
|
|
140
|
+
}
|
|
141
|
+
})()
|
|
142
|
+
|
|
143
|
+
// --- On-demand collectors ---
|
|
144
|
+
|
|
145
|
+
const SKIP_TAGS = new Set(['script', 'style', 'noscript', 'link', 'meta', 'head'])
|
|
146
|
+
|
|
147
|
+
const IMPLICIT_ROLES: Record<string, string> = {
|
|
148
|
+
a: 'link',
|
|
149
|
+
button: 'button',
|
|
150
|
+
input: 'textbox',
|
|
151
|
+
textarea: 'textbox',
|
|
152
|
+
select: 'combobox',
|
|
153
|
+
img: 'img',
|
|
154
|
+
nav: 'navigation',
|
|
155
|
+
main: 'main',
|
|
156
|
+
header: 'banner',
|
|
157
|
+
footer: 'contentinfo',
|
|
158
|
+
aside: 'complementary',
|
|
159
|
+
form: 'form',
|
|
160
|
+
dialog: 'dialog',
|
|
161
|
+
ul: 'list',
|
|
162
|
+
ol: 'list',
|
|
163
|
+
li: 'listitem',
|
|
164
|
+
table: 'table',
|
|
165
|
+
h1: 'heading',
|
|
166
|
+
h2: 'heading',
|
|
167
|
+
h3: 'heading',
|
|
168
|
+
h4: 'heading',
|
|
169
|
+
h5: 'heading',
|
|
170
|
+
h6: 'heading',
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// The implicit role of an element, refined by input type. An <input> alone maps to 'textbox',
|
|
174
|
+
// but that collapses a password/email/checkbox field into the same token as a plain text box —
|
|
175
|
+
// and an EMPTY secret field (no redacted value to give it away) then reads identically to an
|
|
176
|
+
// empty text field, so an AI consuming /dom can't tell which box takes the API key. type=text is
|
|
177
|
+
// the default (no suffix); any other type is a real distinguishing event the projection must carry.
|
|
178
|
+
// The single chokepoint for role derivation, shared by /ui, /dom-fallback, and /dom.
|
|
179
|
+
function roleOf(el: Element): string {
|
|
180
|
+
const explicit = el.getAttribute('role')
|
|
181
|
+
if (explicit)
|
|
182
|
+
return explicit
|
|
183
|
+
const tag = el.tagName.toLowerCase()
|
|
184
|
+
const base = IMPLICIT_ROLES[tag] || ''
|
|
185
|
+
if (tag === 'input') {
|
|
186
|
+
const type = (el.getAttribute('type') || 'text').toLowerCase()
|
|
187
|
+
if (type !== 'text')
|
|
188
|
+
return `${base}:${type}`
|
|
189
|
+
}
|
|
190
|
+
return base
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function collectUI(): string {
|
|
194
|
+
const root = document.getElementById('root')
|
|
195
|
+
if (!root)
|
|
196
|
+
return collectDomFallback()
|
|
197
|
+
|
|
198
|
+
const fiberKey = Object.keys(root).find(k =>
|
|
199
|
+
k.startsWith('__reactContainer$') || k.startsWith('__reactFiber$'),
|
|
200
|
+
)
|
|
201
|
+
if (!fiberKey)
|
|
202
|
+
return collectDomFallback()
|
|
203
|
+
|
|
204
|
+
const rootFiber = (root as any)[fiberKey]
|
|
205
|
+
if (!rootFiber)
|
|
206
|
+
return collectDomFallback()
|
|
207
|
+
|
|
208
|
+
const COMPONENT_TAGS = new Set([0, 1, 14, 15])
|
|
209
|
+
const SKIP_EXACT = new Set([
|
|
210
|
+
'Fragment',
|
|
211
|
+
'Suspense',
|
|
212
|
+
'StrictMode',
|
|
213
|
+
'Context',
|
|
214
|
+
'Outlet',
|
|
215
|
+
'Routes',
|
|
216
|
+
'RenderedRoute',
|
|
217
|
+
'DataRoutes',
|
|
218
|
+
'RemoveScrollBar',
|
|
219
|
+
'Sheet',
|
|
220
|
+
'Router',
|
|
221
|
+
'RenderErrorBoundary',
|
|
222
|
+
'Tooltip',
|
|
223
|
+
'Popover',
|
|
224
|
+
'Dialog',
|
|
225
|
+
'DropdownMenu',
|
|
226
|
+
'ContextMenu',
|
|
227
|
+
'HoverCard',
|
|
228
|
+
'Select',
|
|
229
|
+
'Command',
|
|
230
|
+
'Tabs',
|
|
231
|
+
'RadioGroup',
|
|
232
|
+
'Markdown',
|
|
233
|
+
'Sonner',
|
|
234
|
+
])
|
|
235
|
+
// wrapper components carry no UI semantics — skip by name shape
|
|
236
|
+
const SKIP_CONTAINS = ['Provider', 'Consumer', 'Portal', 'Popper', 'Presence', 'SideCar']
|
|
237
|
+
const SKIP_ENDS = ['Warning', 'Header', 'Footer', 'Menu', 'Root']
|
|
238
|
+
const isSkipped = (name: string) =>
|
|
239
|
+
SKIP_CONTAINS.some(s => name.includes(s)) || SKIP_ENDS.some(s => name.endsWith(s))
|
|
240
|
+
const lines: string[] = []
|
|
241
|
+
|
|
242
|
+
// strip a trailing run of digits (React dedup suffix: Foo2 → Foo)
|
|
243
|
+
function cleanName(name: string): string {
|
|
244
|
+
let end = name.length
|
|
245
|
+
while (end > 0 && name[end - 1] >= '0' && name[end - 1] <= '9') end--
|
|
246
|
+
return name.slice(0, end)
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function getDomDesc(fiber: any): string {
|
|
250
|
+
const el = fiber.stateNode
|
|
251
|
+
if (!(el instanceof Element))
|
|
252
|
+
return ''
|
|
253
|
+
|
|
254
|
+
const parts: string[] = []
|
|
255
|
+
const tag = el.tagName.toLowerCase()
|
|
256
|
+
const role = roleOf(el)
|
|
257
|
+
if (role)
|
|
258
|
+
parts.push(role)
|
|
259
|
+
|
|
260
|
+
const ariaLabel = el.getAttribute('aria-label') || ''
|
|
261
|
+
const text = ariaLabel || getDirectText(el)
|
|
262
|
+
if (text)
|
|
263
|
+
parts.push(`"${text.slice(0, 80)}"`)
|
|
264
|
+
|
|
265
|
+
for (const s of baseState(el)) parts.push(`[${s}]`)
|
|
266
|
+
|
|
267
|
+
return parts.join(' ')
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function getComponentDomDesc(fiber: any): string {
|
|
271
|
+
let node = fiber.child
|
|
272
|
+
while (node) {
|
|
273
|
+
if (node.tag === 5)
|
|
274
|
+
return getDomDesc(node)
|
|
275
|
+
if (node.tag === 6) {
|
|
276
|
+
const text = node.memoizedProps
|
|
277
|
+
if (typeof text === 'string' && text.trim())
|
|
278
|
+
return `"${text.trim().slice(0, 80)}"`
|
|
279
|
+
}
|
|
280
|
+
node = node.child
|
|
281
|
+
}
|
|
282
|
+
return ''
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function walkFiber(fiber: any, depth: number) {
|
|
286
|
+
if (!fiber || depth > 40)
|
|
287
|
+
return
|
|
288
|
+
|
|
289
|
+
const isComponent = COMPONENT_TAGS.has(fiber.tag)
|
|
290
|
+
const rawName = fiber.type?.displayName || fiber.type?.name
|
|
291
|
+
|
|
292
|
+
if (isComponent && rawName && rawName.length > 1 && rawName[0] !== '_'
|
|
293
|
+
&& !SKIP_EXACT.has(rawName) && !isSkipped(rawName)) {
|
|
294
|
+
const name = cleanName(rawName)
|
|
295
|
+
const indent = ' '.repeat(depth)
|
|
296
|
+
let desc = name
|
|
297
|
+
|
|
298
|
+
const domDesc = getComponentDomDesc(fiber)
|
|
299
|
+
if (domDesc)
|
|
300
|
+
desc += ` — ${domDesc}`
|
|
301
|
+
|
|
302
|
+
const props = fiber.memoizedProps
|
|
303
|
+
if (props?.generating)
|
|
304
|
+
desc += ' [generating]'
|
|
305
|
+
if (props?.loading)
|
|
306
|
+
desc += ' [loading]'
|
|
307
|
+
|
|
308
|
+
lines.push(`${indent}${desc}`)
|
|
309
|
+
walkChildren(fiber, depth + 1)
|
|
310
|
+
}
|
|
311
|
+
else {
|
|
312
|
+
walkChildren(fiber, depth)
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function walkChildren(fiber: any, depth: number) {
|
|
317
|
+
let child = fiber.child
|
|
318
|
+
while (child) {
|
|
319
|
+
walkFiber(child, depth)
|
|
320
|
+
child = child.sibling
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
walkFiber(rootFiber, 0)
|
|
325
|
+
if (!lines.length)
|
|
326
|
+
return collectDomFallback()
|
|
327
|
+
|
|
328
|
+
const minIndent = Math.min(...lines.map(l => l.length - l.trimStart().length))
|
|
329
|
+
if (minIndent > 0)
|
|
330
|
+
return lines.map(l => l.slice(minIndent)).join('\n')
|
|
331
|
+
return lines.join('\n')
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function collectDomFallback(): string {
|
|
335
|
+
const lines: string[] = []
|
|
336
|
+
|
|
337
|
+
function walk(el: Element, depth: number) {
|
|
338
|
+
if (depth > 12)
|
|
339
|
+
return
|
|
340
|
+
const tag = el.tagName.toLowerCase()
|
|
341
|
+
if (SKIP_TAGS.has(tag))
|
|
342
|
+
return
|
|
343
|
+
if (tag === 'svg') {
|
|
344
|
+
lines.push(`${' '.repeat(depth)}img: [svg]`)
|
|
345
|
+
return
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const role = roleOf(el)
|
|
349
|
+
const ariaLabel = el.getAttribute('aria-label') || ''
|
|
350
|
+
const directText = getDirectText(el)
|
|
351
|
+
|
|
352
|
+
let desc = role || tag
|
|
353
|
+
if (ariaLabel)
|
|
354
|
+
desc += `: ${ariaLabel}`
|
|
355
|
+
else if (directText)
|
|
356
|
+
desc += `: ${directText.slice(0, 80)}`
|
|
357
|
+
|
|
358
|
+
for (const s of baseState(el)) desc += ` [${s}]`
|
|
359
|
+
|
|
360
|
+
lines.push(' '.repeat(depth) + desc)
|
|
361
|
+
for (const child of el.children) walk(child, depth + 1)
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
walk(document.body, 0)
|
|
365
|
+
return lines.join('\n')
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// --- Semantic DOM collector ---
|
|
369
|
+
// UI-as-text: strip Tailwind atomic classes, keep only what carries meaning —
|
|
370
|
+
// tag, role, semantic classes, data-*/aria, text, interactive state, a usable selector.
|
|
371
|
+
|
|
372
|
+
// Tailwind atomic-class prefixes. A class is "noise" if it starts with one of
|
|
373
|
+
// these (with - or end), or contains a variant separator (hover:, md:, dark:).
|
|
374
|
+
const TW_PREFIXES = new Set([
|
|
375
|
+
'flex',
|
|
376
|
+
'grid',
|
|
377
|
+
'block',
|
|
378
|
+
'inline',
|
|
379
|
+
'hidden',
|
|
380
|
+
'table',
|
|
381
|
+
'contents',
|
|
382
|
+
'p',
|
|
383
|
+
'px',
|
|
384
|
+
'py',
|
|
385
|
+
'pt',
|
|
386
|
+
'pb',
|
|
387
|
+
'pl',
|
|
388
|
+
'pr',
|
|
389
|
+
'm',
|
|
390
|
+
'mx',
|
|
391
|
+
'my',
|
|
392
|
+
'mt',
|
|
393
|
+
'mb',
|
|
394
|
+
'ml',
|
|
395
|
+
'mr',
|
|
396
|
+
'w',
|
|
397
|
+
'h',
|
|
398
|
+
'min',
|
|
399
|
+
'max',
|
|
400
|
+
'size',
|
|
401
|
+
'gap',
|
|
402
|
+
'space',
|
|
403
|
+
'text',
|
|
404
|
+
'font',
|
|
405
|
+
'leading',
|
|
406
|
+
'tracking',
|
|
407
|
+
'whitespace',
|
|
408
|
+
'truncate',
|
|
409
|
+
'break',
|
|
410
|
+
'bg',
|
|
411
|
+
'border',
|
|
412
|
+
'rounded',
|
|
413
|
+
'ring',
|
|
414
|
+
'shadow',
|
|
415
|
+
'outline',
|
|
416
|
+
'divide',
|
|
417
|
+
'opacity',
|
|
418
|
+
'items',
|
|
419
|
+
'justify',
|
|
420
|
+
'self',
|
|
421
|
+
'place',
|
|
422
|
+
'content',
|
|
423
|
+
'order',
|
|
424
|
+
'col',
|
|
425
|
+
'row',
|
|
426
|
+
'absolute',
|
|
427
|
+
'relative',
|
|
428
|
+
'fixed',
|
|
429
|
+
'sticky',
|
|
430
|
+
'static',
|
|
431
|
+
'top',
|
|
432
|
+
'bottom',
|
|
433
|
+
'left',
|
|
434
|
+
'right',
|
|
435
|
+
'z',
|
|
436
|
+
'inset',
|
|
437
|
+
'overflow',
|
|
438
|
+
'overscroll',
|
|
439
|
+
'cursor',
|
|
440
|
+
'select',
|
|
441
|
+
'pointer',
|
|
442
|
+
'resize',
|
|
443
|
+
'scroll',
|
|
444
|
+
'transition',
|
|
445
|
+
'duration',
|
|
446
|
+
'ease',
|
|
447
|
+
'delay',
|
|
448
|
+
'animate',
|
|
449
|
+
'transform',
|
|
450
|
+
'translate',
|
|
451
|
+
'rotate',
|
|
452
|
+
'scale',
|
|
453
|
+
'skew',
|
|
454
|
+
'origin',
|
|
455
|
+
'fill',
|
|
456
|
+
'stroke',
|
|
457
|
+
'object',
|
|
458
|
+
'aspect',
|
|
459
|
+
'basis',
|
|
460
|
+
'shrink',
|
|
461
|
+
'grow',
|
|
462
|
+
'flex',
|
|
463
|
+
'antialiased',
|
|
464
|
+
'uppercase',
|
|
465
|
+
'lowercase',
|
|
466
|
+
'capitalize',
|
|
467
|
+
'italic',
|
|
468
|
+
'underline',
|
|
469
|
+
'line',
|
|
470
|
+
'list',
|
|
471
|
+
'align',
|
|
472
|
+
'backdrop',
|
|
473
|
+
'blur',
|
|
474
|
+
'brightness',
|
|
475
|
+
'contrast',
|
|
476
|
+
'saturate',
|
|
477
|
+
'invert',
|
|
478
|
+
'sepia',
|
|
479
|
+
'grayscale',
|
|
480
|
+
])
|
|
481
|
+
|
|
482
|
+
function isNoiseClass(cls: string): boolean {
|
|
483
|
+
if (cls.includes(':')) // variant: hover:, md:, dark:, group-hover:
|
|
484
|
+
return true
|
|
485
|
+
if (cls[0] === '[') // arbitrary value: [mask-type:luminance]
|
|
486
|
+
return true
|
|
487
|
+
const head = cls.split('-')[0]
|
|
488
|
+
return TW_PREFIXES.has(head)
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
export function semanticClasses(el: Element): string {
|
|
492
|
+
const cls = el.getAttribute('class')
|
|
493
|
+
if (!cls)
|
|
494
|
+
return ''
|
|
495
|
+
return cls.split(' ').map(c => c.trim()).filter(c => c && !isNoiseClass(c)).slice(0, 6).join('.')
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// data-insp-path looks like "src/features/Sidebar/index.tsx:78:17:App"
|
|
499
|
+
// → compact to "Sidebar/index.tsx:78" (last dir + file + line)
|
|
500
|
+
export function compactInsp(raw: string): string {
|
|
501
|
+
if (!raw)
|
|
502
|
+
return ''
|
|
503
|
+
const segs = raw.split(':')
|
|
504
|
+
const tail = segs[0].split('/').slice(-2).join('/')
|
|
505
|
+
return segs[1] ? `${tail}:${segs[1]}` : tail
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
export function inspPath(el: Element): string {
|
|
509
|
+
return compactInsp(el.getAttribute('data-insp-path') || '')
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
// Full path + line (drop the :col:Name suffix) — for the click-to-source popover, where the
|
|
513
|
+
// whole point is reading exactly which file you'll jump into.
|
|
514
|
+
export function fullInsp(raw: string): string {
|
|
515
|
+
if (!raw)
|
|
516
|
+
return ''
|
|
517
|
+
const segs = raw.split(':')
|
|
518
|
+
return segs[1] ? `${segs[0]}:${segs[1]}` : segs[0]
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// The owner ladder: who *rendered* this element, with each component's source location.
|
|
522
|
+
// data-insp-path on a host node is its DEFINITION point (where <button> is written). But the
|
|
523
|
+
// element you usually want to edit is the component that placed it — its owner. React's dev
|
|
524
|
+
// fiber keeps `_debugOwner` (the component whose render produced this element) even in React 19,
|
|
525
|
+
// where _debugSource / __source are gone. So we walk the owner chain for the component NAMES,
|
|
526
|
+
// and recover each owner's source location from the first host in its render subtree (that host
|
|
527
|
+
// carries the babel-stamped data-insp-path = the component's own definition file:line). The
|
|
528
|
+
// result is a climbable ladder: [leaf, NavItem, SidebarNav, …]. level 0 = the host itself.
|
|
529
|
+
function fiberOf(el: Element): { _debugOwner?: unknown, _debugStack?: unknown } | null {
|
|
530
|
+
for (const k in el) {
|
|
531
|
+
if (k.startsWith('__reactFiber$'))
|
|
532
|
+
return (el as unknown as Record<string, never>)[k]
|
|
533
|
+
}
|
|
534
|
+
return null
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
// React 19 dropped _debugSource, but each dev fiber keeps `_debugStack` — an Error captured at
|
|
538
|
+
// jsxDEV() time. Its first user (/src/, non-node_modules) frame is the line where THIS element's
|
|
539
|
+
// JSX tag is written: the call site. For the <ActionBtn/> fiber that frame is Sidebar:554 — where
|
|
540
|
+
// the developer wrote it, which is what you want to edit (not ActionBtn's own definition).
|
|
541
|
+
// Returns "src/…/file.tsx:line:col" (origin + ?t= HMR query stripped), or '' if no user frame.
|
|
542
|
+
function ownerCallSite(fiber: { _debugStack?: unknown }): string {
|
|
543
|
+
const ds = fiber?._debugStack as { stack?: string } | string | undefined
|
|
544
|
+
const stack = !ds ? '' : typeof ds === 'string' ? ds : (ds.stack || '')
|
|
545
|
+
if (!stack)
|
|
546
|
+
return ''
|
|
547
|
+
for (const line of stack.split('\n')) {
|
|
548
|
+
if (line.includes('node_modules'))
|
|
549
|
+
continue
|
|
550
|
+
const at = line.indexOf('/src/')
|
|
551
|
+
if (at < 0)
|
|
552
|
+
continue
|
|
553
|
+
let seg = line.slice(at + 1) // "src/…/file.tsx?t=1:554:20" maybe trailing ')'
|
|
554
|
+
const close = seg.indexOf(')')
|
|
555
|
+
if (close >= 0)
|
|
556
|
+
seg = seg.slice(0, close)
|
|
557
|
+
const q = seg.indexOf('?') // strip HMR ?t=… query before :line:col
|
|
558
|
+
if (q >= 0) {
|
|
559
|
+
const colon = seg.indexOf(':', q)
|
|
560
|
+
seg = seg.slice(0, q) + (colon >= 0 ? seg.slice(colon) : '')
|
|
561
|
+
}
|
|
562
|
+
return seg
|
|
563
|
+
}
|
|
564
|
+
return ''
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
// _debugStack line:col are in Vite-TRANSFORMED coordinates: the served module is rewritten by
|
|
568
|
+
// babel (source-loc stamping) + the mobx plugin, inflating Sidebar/index.tsx from 533 source lines
|
|
569
|
+
// to 1077. So a raw frame like ":554" is a real position in the served file but a nonexistent line
|
|
570
|
+
// in the source — opening it lands nowhere. Each served module carries an inline sourcemap; we
|
|
571
|
+
// fetch it once per file, decode the VLQ mappings (no regex — charcode scan per the repo rule), and
|
|
572
|
+
// map transformed (line,col) back to the source (line,col). Verified live: transformed 554:20 →
|
|
573
|
+
// source 312:16 = `<ActionBtn`, 881:33 → 460:20 = `<ChatList />`.
|
|
574
|
+
const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
|
|
575
|
+
function decodeVlq(seg: string): number[] {
|
|
576
|
+
const out: number[] = []
|
|
577
|
+
let shift = 0
|
|
578
|
+
let value = 0
|
|
579
|
+
for (const ch of seg) {
|
|
580
|
+
let digit = B64.indexOf(ch)
|
|
581
|
+
if (digit < 0)
|
|
582
|
+
break
|
|
583
|
+
const cont = digit & 32
|
|
584
|
+
digit &= 31
|
|
585
|
+
value += digit << shift
|
|
586
|
+
if (cont) {
|
|
587
|
+
shift += 5
|
|
588
|
+
}
|
|
589
|
+
else {
|
|
590
|
+
const neg = value & 1
|
|
591
|
+
value >>= 1
|
|
592
|
+
out.push(neg ? -value : value)
|
|
593
|
+
value = 0
|
|
594
|
+
shift = 0
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
return out
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
// Decoded mappings as line → sorted [genCol, srcLine, srcCol][], original (0-based) coords.
|
|
601
|
+
const mapCache = new Map<string, Promise<Array<Array<[number, number, number]>>>>()
|
|
602
|
+
|
|
603
|
+
async function loadMappings(modUrl: string): Promise<Array<Array<[number, number, number]>>> {
|
|
604
|
+
const txt = await (await fetch(modUrl)).text()
|
|
605
|
+
const key = 'sourceMappingURL=data:application/json;base64,'
|
|
606
|
+
const at = txt.lastIndexOf(key)
|
|
607
|
+
if (at < 0)
|
|
608
|
+
return []
|
|
609
|
+
const b64 = txt.slice(at + key.length).trim()
|
|
610
|
+
const map = JSON.parse(atob(b64)) as { mappings: string }
|
|
611
|
+
let srcLine = 0
|
|
612
|
+
let srcCol = 0
|
|
613
|
+
return map.mappings.split(';').map((line) => {
|
|
614
|
+
let genCol = 0
|
|
615
|
+
const segs: Array<[number, number, number]> = []
|
|
616
|
+
for (const seg of line.split(',')) {
|
|
617
|
+
if (!seg)
|
|
618
|
+
continue
|
|
619
|
+
const d = decodeVlq(seg)
|
|
620
|
+
genCol += d[0]
|
|
621
|
+
if (d.length >= 4) {
|
|
622
|
+
srcLine += d[2]
|
|
623
|
+
srcCol += d[3]
|
|
624
|
+
segs.push([genCol, srcLine, srcCol])
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
return segs
|
|
628
|
+
})
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
// Map a "src/…/file.tsx:line:col" (transformed coords) to source coords via the module's inline
|
|
632
|
+
// sourcemap. Returns the same shape; falls back to the input unchanged when no map / no mapping.
|
|
633
|
+
async function mapToSource(raw: string): Promise<string> {
|
|
634
|
+
const segs = raw.split(':')
|
|
635
|
+
const file = segs[0]
|
|
636
|
+
const genLine = Number(segs[1])
|
|
637
|
+
const genCol = Number(segs[2]) || 0
|
|
638
|
+
if (!file || !genLine)
|
|
639
|
+
return raw
|
|
640
|
+
if (!mapCache.has(file))
|
|
641
|
+
mapCache.set(file, loadMappings(`/${file}`).catch(() => []))
|
|
642
|
+
const lines = await mapCache.get(file)!
|
|
643
|
+
const row = lines[genLine - 1]
|
|
644
|
+
if (!row || !row.length)
|
|
645
|
+
return raw
|
|
646
|
+
// largest genCol ≤ target (least-upper-bound style): the segment covering this column
|
|
647
|
+
let best = row[0]
|
|
648
|
+
for (const s of row) {
|
|
649
|
+
if (s[0] <= genCol)
|
|
650
|
+
best = s
|
|
651
|
+
else break
|
|
652
|
+
}
|
|
653
|
+
return `${file}:${best[1] + 1}:${best[2]}`
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
export interface LadderRung { name: string, raw: string }
|
|
657
|
+
|
|
658
|
+
// The owner ladder: who *placed* this element, climbing the chain, each rung pointing at its CALL
|
|
659
|
+
// SITE (where its <Tag/> is written). data-insp-path on a host is its definition; the element you
|
|
660
|
+
// usually want to edit is where its owner component instantiated it. React 19 keeps _debugOwner
|
|
661
|
+
// (the component whose render produced this element) and _debugStack (an Error captured at
|
|
662
|
+
// jsxDEV → its first /src/ frame is THE call site where this element's <Tag/> is written). We walk
|
|
663
|
+
// the owner chain: each rung's jump target is its own call site. Radix internals
|
|
664
|
+
// (Primitive/Slot/PopperAnchor/…) have no /src/ frame → call site '' → dropped as noise. Then
|
|
665
|
+
// collapse consecutive rungs sharing a call site. Result for an ActionBtn button:
|
|
666
|
+
// [div @ ActionBtn:30, ActionBtn @ Sidebar:554, ChatList @ Sidebar:881, SideBar @ App:97, App @ main:45]
|
|
667
|
+
// — every rung is a real authored line, the 10-row Radix mess gone, ActionBtn's call site reachable.
|
|
668
|
+
export function ownerLadder(el: Element): LadderRung[] {
|
|
669
|
+
const fiber = fiberOf(el)
|
|
670
|
+
const chain: LadderRung[] = [{ name: el.tagName.toLowerCase(), raw: ownerCallSite(fiber || {}) || el.getAttribute('data-insp-path') || '' }]
|
|
671
|
+
let o = fiber?._debugOwner as { type?: { displayName?: string, name?: string }, _debugOwner?: unknown, _debugStack?: unknown } | undefined
|
|
672
|
+
let guard = 0
|
|
673
|
+
while (o && guard++ < 24) {
|
|
674
|
+
const cs = ownerCallSite(o)
|
|
675
|
+
if (cs) {
|
|
676
|
+
const name = o.type?.displayName || o.type?.name || '?'
|
|
677
|
+
chain.push({ name, raw: cs })
|
|
678
|
+
}
|
|
679
|
+
o = o._debugOwner as typeof o
|
|
680
|
+
}
|
|
681
|
+
const out: LadderRung[] = []
|
|
682
|
+
for (let i = 0; i < chain.length; i++) {
|
|
683
|
+
// collapse consecutive rungs that resolve to one authored line (host + same-line owner)
|
|
684
|
+
if (i + 1 < chain.length && chain[i].raw && fullInsp(chain[i + 1].raw) === fullInsp(chain[i].raw))
|
|
685
|
+
continue
|
|
686
|
+
out.push(chain[i])
|
|
687
|
+
}
|
|
688
|
+
return out
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
// The "上层调用点": the first rung whose source FILE differs from the leaf's — i.e. where this
|
|
692
|
+
// element's component was instantiated from the outside, not the component rendering its own
|
|
693
|
+
// internals. For an ActionBtn button the leaf is the inner <div> (ActionBtn.tsx:30); rungs still
|
|
694
|
+
// in ActionBtn.tsx (TooltipTrigger:68, …) are ActionBtn's own guts — skip them; the first rung in
|
|
695
|
+
// another file is ActionBtn @ Sidebar:554, the place the developer wrote <ActionBtn/>. Used as the
|
|
696
|
+
// /dom owner tag and the overlay default. Returns null when nothing escapes the leaf's file.
|
|
697
|
+
export function callSite(l: LadderRung[]): LadderRung | null {
|
|
698
|
+
const leafFile = (l[0]?.raw || '').split(':')[0]
|
|
699
|
+
for (let i = 1; i < l.length; i++) {
|
|
700
|
+
if (l[i].raw && l[i].raw.split(':')[0] !== leafFile)
|
|
701
|
+
return l[i]
|
|
702
|
+
}
|
|
703
|
+
return null
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
// focused/disabled/expanded — the three states shared by every DOM describer
|
|
707
|
+
function baseState(el: Element): string[] {
|
|
708
|
+
const parts: string[] = []
|
|
709
|
+
if (document.activeElement === el)
|
|
710
|
+
parts.push('focused')
|
|
711
|
+
if ((el as HTMLButtonElement).disabled)
|
|
712
|
+
parts.push('disabled')
|
|
713
|
+
if (el.getAttribute('aria-expanded') === 'true')
|
|
714
|
+
parts.push('expanded')
|
|
715
|
+
return parts
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
// Heuristic: does opening this control need a *trusted* event (realclick), not a synthetic
|
|
719
|
+
// one? Synthetic dispatchEvent can't open a Radix portal popup reliably (and never a native
|
|
720
|
+
// right-click menu). aria-haspopup is the W3C marker Radix puts on menu/dropdown/popover/
|
|
721
|
+
// select triggers — the honest, low-false-positive signal. Tag it so the AI picks realclick
|
|
722
|
+
// up front instead of discovering it by a failed synthetic click. The `?` keeps it honest:
|
|
723
|
+
// a hint, not a guarantee (right-click-only handlers carry no DOM attribute, so they can't be
|
|
724
|
+
// detected here — those still surface only on a miss).
|
|
725
|
+
function needsTrusted(el: Element): boolean {
|
|
726
|
+
const hp = el.getAttribute('aria-haspopup')
|
|
727
|
+
return hp === 'menu' || hp === 'dialog' || hp === 'listbox' || hp === 'true'
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
function domState(el: Element): string {
|
|
731
|
+
const parts = baseState(el)
|
|
732
|
+
if (el.getAttribute('aria-selected') === 'true' || el.getAttribute('aria-checked') === 'true')
|
|
733
|
+
parts.push('selected')
|
|
734
|
+
if ((el as HTMLInputElement).value)
|
|
735
|
+
parts.push(`value="${safeValue(el).slice(0, 40)}"`)
|
|
736
|
+
if (el.hasAttribute('contenteditable'))
|
|
737
|
+
parts.push('editable')
|
|
738
|
+
// aria-activedescendant is the W3C combobox/listbox mechanism for "which option is currently
|
|
739
|
+
// highlighted" under keyboard nav — the focus stays on the input while ArrowDown/Up moves a
|
|
740
|
+
// *virtual* cursor over options (no DOM focus move). Without surfacing it, ArrowDown reports
|
|
741
|
+
// "(no state change)" though the active suggestion just moved (live: Wikipedia search combobox),
|
|
742
|
+
// blinding the agent to keyboard navigation. Resolve the pointed option's label so the focus
|
|
743
|
+
// line changes as the cursor moves — the single semantic signal, not a noisy attribute dump.
|
|
744
|
+
const activeId = el.getAttribute('aria-activedescendant')
|
|
745
|
+
const activeOpt = activeId ? document.getElementById(activeId) : null
|
|
746
|
+
if (activeOpt)
|
|
747
|
+
parts.push(`active="${elementLabel(activeOpt).slice(0, 40)}"`)
|
|
748
|
+
if (needsTrusted(el))
|
|
749
|
+
parts.push('needs-trusted?')
|
|
750
|
+
return parts.length ? ` {${parts.join(' ')}}` : ''
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
// Find the scope root: a CSS selector, or a component name matched against the
|
|
754
|
+
// data-insp-path. Returns body when no scope given, null on miss.
|
|
755
|
+
function scopeRoot(scope?: string, sel?: string): Element | null {
|
|
756
|
+
if (sel)
|
|
757
|
+
return document.querySelector(sel)
|
|
758
|
+
if (scope) {
|
|
759
|
+
const all = document.querySelectorAll('[data-insp-path]')
|
|
760
|
+
const lower = scope.toLowerCase()
|
|
761
|
+
for (const el of all) {
|
|
762
|
+
if ((el.getAttribute('data-insp-path') || '').toLowerCase().includes(lower))
|
|
763
|
+
return el
|
|
764
|
+
}
|
|
765
|
+
return null
|
|
766
|
+
}
|
|
767
|
+
return document.body
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
export function collectDom(scope?: string, sel?: string): string {
|
|
771
|
+
const root = scopeRoot(scope, sel)
|
|
772
|
+
if (!root)
|
|
773
|
+
return `(no element for scope="${scope || sel}")`
|
|
774
|
+
const lines: string[] = []
|
|
775
|
+
|
|
776
|
+
function walk(el: Element, depth: number) {
|
|
777
|
+
if (depth > 20)
|
|
778
|
+
return
|
|
779
|
+
const tag = el.tagName.toLowerCase()
|
|
780
|
+
if (SKIP_TAGS.has(tag))
|
|
781
|
+
return
|
|
782
|
+
if (tag === 'svg') {
|
|
783
|
+
if (el.getBoundingClientRect().width > 0)
|
|
784
|
+
lines.push(`${' '.repeat(depth)}svg [icon]`)
|
|
785
|
+
return
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
const role = roleOf(el)
|
|
789
|
+
const sem = semanticClasses(el)
|
|
790
|
+
// data-* minus framework noise. data-insp-path is the source
|
|
791
|
+
// location — keep it, but compacted to its file:line tail, shown only when the
|
|
792
|
+
// node is itself meaningful (carries role/text/state), not on bare wrappers.
|
|
793
|
+
// On a secret field, redact data-* values too — an app could mirror the value into
|
|
794
|
+
// a data attr, and we never want that echoed (same stance as the value redaction).
|
|
795
|
+
// Redact a data-* value when the element is a secret field (mirrored value) OR the attr
|
|
796
|
+
// NAME itself names a secret (data-token / data-api-key) — same isSecretKey chokepoint as
|
|
797
|
+
// /state and /network. Element-level alone missed the per-attr case: a plain div carrying
|
|
798
|
+
// data-token leaked plaintext because the div isn't a sensitive input.
|
|
799
|
+
const secret = isSensitive(el)
|
|
800
|
+
const dataAttrs = Array.from(el.attributes)
|
|
801
|
+
.filter(a => a.name.startsWith('data-') && a.name !== 'data-testid' && a.name !== 'data-insp-path')
|
|
802
|
+
.map(a => a.value ? `${a.name}="${secret || isSecretKey(a.name) ? `‹redacted ${a.value.length}›` : a.value.slice(0, 30)}"` : a.name)
|
|
803
|
+
.slice(0, 3)
|
|
804
|
+
const aria = el.getAttribute('aria-label') || ''
|
|
805
|
+
const text = aria || getDirectText(el)
|
|
806
|
+
const state = domState(el)
|
|
807
|
+
|
|
808
|
+
// collapse an element whose only child is an icon → one line
|
|
809
|
+
const onlyIcon = el.children.length === 1 && el.children[0].tagName.toLowerCase() === 'svg'
|
|
810
|
+
|
|
811
|
+
// a node is meaningful if it carries semantics a layout div doesn't
|
|
812
|
+
const meaningful = !!(role || sem || dataAttrs.length || text || el.id || state || el.hasAttribute('contenteditable'))
|
|
813
|
+
|
|
814
|
+
if (meaningful) {
|
|
815
|
+
const parts = [role ? `${tag}[${role}]` : tag]
|
|
816
|
+
if (sem)
|
|
817
|
+
parts.push(`.${sem}`)
|
|
818
|
+
if (el.id)
|
|
819
|
+
parts.push(`#${el.id}`)
|
|
820
|
+
for (const d of dataAttrs)
|
|
821
|
+
parts.push(`[${d}]`)
|
|
822
|
+
if (text)
|
|
823
|
+
parts.push(`"${text.slice(0, 60)}"`)
|
|
824
|
+
if (onlyIcon)
|
|
825
|
+
parts.push('[icon]')
|
|
826
|
+
const src = inspPath(el)
|
|
827
|
+
// The component that placed this host. Its name is reliable, but its call-site LINE
|
|
828
|
+
// comes from _debugStack in TRANSFORMED coords (babel + mobx inflate the served module),
|
|
829
|
+
// and collectDom is sync so it can't fetch the sourcemap to map it back. Show name +
|
|
830
|
+
// file only — the file is correct; the precise mapped line lives in the jumpable overlay
|
|
831
|
+
// (paint/openRung map it via the inline sourcemap). Never print a transformed line here.
|
|
832
|
+
const owner = callSite(ownerLadder(el))
|
|
833
|
+
const ownerFile = owner ? (owner.raw.split(':')[0].split('/').slice(-2).join('/')) : ''
|
|
834
|
+
const ownerTag = owner && ownerFile && ownerFile !== (src.split(':')[0]) ? ` ↟${owner.name}@${ownerFile}` : (owner ? ` ↟${owner.name}` : '')
|
|
835
|
+
lines.push(' '.repeat(depth) + parts.join(' ') + state + (src ? ` @${src}` : '') + ownerTag)
|
|
836
|
+
depth += 1
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
if (!onlyIcon) {
|
|
840
|
+
for (const child of el.children) walk(child, depth)
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
walk(root, 0)
|
|
845
|
+
return lines.join('\n')
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
export function collectState(): Record<string, unknown> {
|
|
849
|
+
const stores = window.__AIPEEK_STORES__
|
|
850
|
+
if (!stores)
|
|
851
|
+
return {}
|
|
852
|
+
|
|
853
|
+
const result: Record<string, unknown> = {}
|
|
854
|
+
for (const [name, store] of Object.entries(stores)) {
|
|
855
|
+
try {
|
|
856
|
+
result[name] = boundedSnapshot(store)
|
|
857
|
+
}
|
|
858
|
+
catch {
|
|
859
|
+
result[name] = '[error]'
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
return result
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
// The point-drill twin of /dom?sel=. /state gives the overview, /state/<store> the domain;
|
|
866
|
+
// both go through boundedSnapshot, which collapses every array to "Array(N)" and stops at
|
|
867
|
+
// depth 3 — so a nested list (imStore.conversations) is unreachable from the bulk dump, the
|
|
868
|
+
// gap that used to force a hand-rolled /eval. collectStatePath walks the *raw* live store
|
|
869
|
+
// along a dotted path (no truncation during the walk, so depth is no obstacle), then expands
|
|
870
|
+
// the leaf ONE level: an array becomes its bounded elements, an object its bounded fields —
|
|
871
|
+
// exactly one bound deeper than where the bulk snapshot stopped. Numeric segments index
|
|
872
|
+
// arrays; everything else is an object key. isSecretKey redaction still applies at the leaf.
|
|
873
|
+
export function collectStatePath(path: string): { ok: boolean, value?: unknown, error?: string } {
|
|
874
|
+
const stores = window.__AIPEEK_STORES__ as Record<string, any> | undefined
|
|
875
|
+
if (!stores)
|
|
876
|
+
return { ok: false, error: 'no stores registered' }
|
|
877
|
+
const segs = path.split('.').filter(Boolean)
|
|
878
|
+
if (!segs.length)
|
|
879
|
+
return { ok: false, error: 'empty path' }
|
|
880
|
+
|
|
881
|
+
let node: unknown = stores
|
|
882
|
+
const walked: string[] = []
|
|
883
|
+
for (const seg of segs) {
|
|
884
|
+
if (node === null || typeof node !== 'object')
|
|
885
|
+
return { ok: false, error: `${walked.join('.') || '<root>'} is ${node === null ? 'null' : typeof node}, cannot read '${seg}'` }
|
|
886
|
+
const container = node as Record<string, unknown>
|
|
887
|
+
if (!(seg in container))
|
|
888
|
+
return { ok: false, error: `'${seg}' not found under ${walked.join('.') || '<root>'}` }
|
|
889
|
+
node = container[seg]
|
|
890
|
+
walked.push(seg)
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
// Expand the leaf one level: arrays show their (bounded) elements, objects their (bounded)
|
|
894
|
+
// fields — instead of the bulk dump's "Array(N)" / depth-3 cliff. Scalars pass through.
|
|
895
|
+
if (Array.isArray(node))
|
|
896
|
+
return { ok: true, value: node.map(el => boundedSnapshot(el)) }
|
|
897
|
+
if (node !== null && typeof node === 'object') {
|
|
898
|
+
const out: Record<string, unknown> = {}
|
|
899
|
+
for (const [k, v] of Object.entries(node as Record<string, unknown>)) {
|
|
900
|
+
if (typeof v === 'function')
|
|
901
|
+
continue
|
|
902
|
+
out[k] = isSecretKey(k) && (typeof v === 'string' || typeof v === 'number')
|
|
903
|
+
? redactSecretValue(String(v))
|
|
904
|
+
: boundedSnapshot(v)
|
|
905
|
+
}
|
|
906
|
+
return { ok: true, value: out }
|
|
907
|
+
}
|
|
908
|
+
return { ok: true, value: node }
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
// The DOM-only view signal: a digest of the page's big structural blocks. Walk every element,
|
|
912
|
+
// keep the visible ones covering >5% of the viewport, and key each by tag + quantized rect +
|
|
913
|
+
// child count. A human knows "the page changed" not by reading a store but by seeing whole
|
|
914
|
+
// regions get replaced — this captures exactly that, for ANY page, zero app cooperation.
|
|
915
|
+
// rect is snapped to a ~16px grid so reflow jitter doesn't register; only wholesale replacement
|
|
916
|
+
// (one big region's key gone, another appeared) survives the diff. Reuses rectOf/isVisible.
|
|
917
|
+
const REGION_MIN_AREA_FRAC = 0.05
|
|
918
|
+
const REGION_GRID = 16
|
|
919
|
+
function collectRegions(): RegionDigest[] {
|
|
920
|
+
const minArea = innerWidth * innerHeight * REGION_MIN_AREA_FRAC
|
|
921
|
+
const out: RegionDigest[] = []
|
|
922
|
+
for (const el of document.querySelectorAll('*')) {
|
|
923
|
+
const r = rectOf(el)
|
|
924
|
+
if (!isVisible(r) || r.width * r.height < minArea)
|
|
925
|
+
continue
|
|
926
|
+
const tag = el.tagName.toLowerCase()
|
|
927
|
+
const q = (n: number) => Math.round(n / REGION_GRID)
|
|
928
|
+
const role = el.getAttribute('role')
|
|
929
|
+
const label = `${role || tag}, ${el.childElementCount} blocks`
|
|
930
|
+
out.push({ tag, key: `${tag}@${q(r.left)},${q(r.top)},${q(r.width)},${q(r.height)}#${el.childElementCount}`, label })
|
|
931
|
+
}
|
|
932
|
+
return out
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
// State-machine projection: collapse the whole UI to {view, modal, focus, knobs}
|
|
936
|
+
// — the few variables a human reads off a washing-machine panel to act in O(1).
|
|
937
|
+
// knobs are clipped to what's reachable *now*: when a modal is open, only its
|
|
938
|
+
// subtree counts (the layer beneath is unclickable).
|
|
939
|
+
// The state-machine projection — just the three vars that move when the UI transitions.
|
|
940
|
+
// Shared by collectScreen (the /screen header) and the action handler's before/after diff.
|
|
941
|
+
export function screenSnap(): ScreenSnap {
|
|
942
|
+
let view = '(unknown)'
|
|
943
|
+
try { view = window.__AIPEEK_VIEW__?.() ?? '(unknown)' } catch { /* app hook threw — keep fallback */ }
|
|
944
|
+
|
|
945
|
+
const modalEl = document.querySelector('[role="dialog"][data-state="open"]')
|
|
946
|
+
const modalTitle = modalEl
|
|
947
|
+
? (modalEl.querySelector('h1, h2, [id^="radix"]')?.textContent || '').trim().slice(0, 40)
|
|
948
|
+
: ''
|
|
949
|
+
const modal = modalEl ? `${modalTitle || 'untitled'} @${inspPath(modalEl) || 'dialog'}` : 'none'
|
|
950
|
+
|
|
951
|
+
const active = document.activeElement
|
|
952
|
+
const focus = active && active !== document.body
|
|
953
|
+
? `${elementLabel(active).slice(0, 30) || active.tagName.toLowerCase()} [${active.tagName.toLowerCase()}]${domState(active)}`
|
|
954
|
+
: 'none'
|
|
955
|
+
|
|
956
|
+
let domain: Record<string, unknown> = {}
|
|
957
|
+
try {
|
|
958
|
+
domain = window.__AIPEEK_SCREEN__?.() ?? {}
|
|
959
|
+
}
|
|
960
|
+
catch {
|
|
961
|
+
domain = { '[error]': '__AIPEEK_SCREEN__ threw' }
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
return { view, modal, focus, domain, regions: collectRegions() }
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
export function collectScreen(): string {
|
|
968
|
+
// 整页导航刚切到新文档、或空白文档时,document.body 可能尚未挂上 —— 任何 body.querySelectorAll
|
|
969
|
+
// 都会 NPE。collectScreen 在无 body 时无意义,响亮报「页面未就绪」而非崩(出口2 整页导航会撞)。
|
|
970
|
+
if (!document.body)
|
|
971
|
+
return 'page not ready (no document.body — mid-navigation or blank document)'
|
|
972
|
+
const { view, modal, focus, domain } = screenSnap()
|
|
973
|
+
// 按 store 前缀分组:键是 `store.field` 点路径,每行重复 `appUIStore.` 是纯噪音 —— store 名读一次
|
|
974
|
+
// 当组头,字段缩进列在下。无点的键(罕见)留作裸顶层行。禁正则,indexOf 取首个 '.'。
|
|
975
|
+
const domainLines: string[] = []
|
|
976
|
+
let curStore = ''
|
|
977
|
+
for (const [k, v] of Object.entries(domain)) {
|
|
978
|
+
const dot = k.indexOf('.')
|
|
979
|
+
if (dot < 0) {
|
|
980
|
+
domainLines.push(` ${k}: ${redactDomain(k, v)}`)
|
|
981
|
+
curStore = ''
|
|
982
|
+
continue
|
|
983
|
+
}
|
|
984
|
+
const store = k.slice(0, dot)
|
|
985
|
+
if (store !== curStore) {
|
|
986
|
+
domainLines.push(` ${store}:`)
|
|
987
|
+
curStore = store
|
|
988
|
+
}
|
|
989
|
+
domainLines.push(` ${k.slice(dot + 1)}: ${redactDomain(k, v)}`)
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
const modalEl = document.querySelector('[role="dialog"][data-state="open"]')
|
|
993
|
+
|
|
994
|
+
// Group by source location. A source rendered many times is a *list* (content),
|
|
995
|
+
// not a control — collapse it to one "source ×N" line. The washing-machine
|
|
996
|
+
// panel shows "the laundry", not every garment.
|
|
997
|
+
const root = modalEl ?? document.body
|
|
998
|
+
const bySource = new Map<string, Element[]>()
|
|
999
|
+
for (const el of root.querySelectorAll(INTERACTIVE)) {
|
|
1000
|
+
// reachable, not just isVisible: only list knobs a click can actually land on now —
|
|
1001
|
+
// same predicate /click uses, so read and write agree (no "listed but unclickable").
|
|
1002
|
+
if (!reachable(el))
|
|
1003
|
+
continue
|
|
1004
|
+
const loc = inspPath(el) || '?'
|
|
1005
|
+
if (!bySource.has(loc))
|
|
1006
|
+
bySource.set(loc, [])
|
|
1007
|
+
bySource.get(loc)!.push(el)
|
|
1008
|
+
}
|
|
1009
|
+
const knobs: string[] = []
|
|
1010
|
+
for (const [loc, els] of bySource) {
|
|
1011
|
+
if (els.length > 2) {
|
|
1012
|
+
knobs.push(` ${loc} ×${els.length} (list)`)
|
|
1013
|
+
continue
|
|
1014
|
+
}
|
|
1015
|
+
for (const el of els) {
|
|
1016
|
+
const full = elementLabel(el)
|
|
1017
|
+
const tag = el.tagName.toLowerCase()
|
|
1018
|
+
const label = full.slice(0, 40)
|
|
1019
|
+
const sem = lookupSem(el, full || `<${tag}>`)
|
|
1020
|
+
const morphism = sem ? ` → ${sem.store}.{ ${sem.transitions} }` : ''
|
|
1021
|
+
knobs.push(` ${label || `<${tag}>`} [${tag}]${loc !== '?' ? ` ${loc}` : ''}${domState(el)}${morphism}`)
|
|
1022
|
+
}
|
|
1023
|
+
if (knobs.length >= 40)
|
|
1024
|
+
break
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
return [
|
|
1028
|
+
`view: ${view}`,
|
|
1029
|
+
...(domainLines.length ? [`domain:`, ...domainLines] : []),
|
|
1030
|
+
`modal: ${modal}`,
|
|
1031
|
+
`focus: ${focus}`,
|
|
1032
|
+
`knobs (${knobs.length}):`,
|
|
1033
|
+
...knobs,
|
|
1034
|
+
].join('\n')
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
// 运行时旋钮采集:供 `aihand read panel --live` 的合缝(seam.ts)消费。返回每个可交互元素的
|
|
1038
|
+
// (label, file)—— label 走命中路同一 elementLabel(全 accname 信号),file 走 knobFile(调用点
|
|
1039
|
+
// 文件,共享组件正确归位)。这是 seam 的唯一真理来源:seam 不再自带弱化的内联 label/file 副本
|
|
1040
|
+
// (那会与 elementLabel 漂移、且按 host 定义文件 join 漏掉共享组件旋钮)。挂到 window 供 /eval 抠。
|
|
1041
|
+
export function collectKnobs(): Array<{ label: string, files: string[], tag: string }> {
|
|
1042
|
+
const out: Array<{ label: string, files: string[], tag: string }> = []
|
|
1043
|
+
for (const el of document.querySelectorAll(INTERACTIVE)) {
|
|
1044
|
+
if (!reachable(el))
|
|
1045
|
+
continue
|
|
1046
|
+
const label = elementLabel(el)
|
|
1047
|
+
if (!label)
|
|
1048
|
+
continue
|
|
1049
|
+
// The static knob's filePath is where its on* handler is WRITTEN — which is one of two
|
|
1050
|
+
// files depending on how the developer wrote it, and the runtime can't tell which:
|
|
1051
|
+
// - inline host (`<button onClick>` in Sidebar): handler is on the leaf → LEAF file.
|
|
1052
|
+
// - shared component (`<ActionBtn onClick>` in MsgItem): handler is a prop on the
|
|
1053
|
+
// <ActionBtn> call → the OWNER CALL-SITE file (the leaf is ActionBtn's inner div,
|
|
1054
|
+
// whose insp-path is ActionBtn.tsx, useless for the join).
|
|
1055
|
+
// callSite over-climbs the inline case (NavItem's leaf is in Sidebar but callSite jumps
|
|
1056
|
+
// to App), and the leaf file misses the shared case. Rather than guess, emit BOTH as join
|
|
1057
|
+
// candidates — the static panel is the oracle; whichever file it recorded, the join finds.
|
|
1058
|
+
const files = [...new Set([knobFile(el, 'leaf'), knobFile(el, 'callsite')].filter(f => f && f !== '?'))]
|
|
1059
|
+
out.push({ label, files, tag: el.tagName.toLowerCase() })
|
|
1060
|
+
}
|
|
1061
|
+
return out
|
|
1062
|
+
}
|
|
1063
|
+
// Expose for the seam's /eval probe (read panel --live). On window so a self-contained eval
|
|
1064
|
+
// string can reach the canonical collector instead of forking a weaker label/file derivation.
|
|
1065
|
+
;(window as any).__aihandCollectKnobs = collectKnobs
|
|
1066
|
+
|
|
1067
|
+
// 界面字符画的采集半:DOM 包含树 → Region 树(纯渲染半 renderCanvas 在 core/canvas.ts)。
|
|
1068
|
+
// 代替视觉 —— 把人眼看到的结构(侧边栏哪些按钮、主区几条消息、输入框、disabled 态)摊成
|
|
1069
|
+
// AI 一眼可读的嵌套框。语义结构优先(用户拍板):区域边界 = DOM 语义 landmark 的包含关系,
|
|
1070
|
+
// 零 getBoundingClientRect 几何 —— 留在数学端(离散可枚举),不掉进像素坍缩。
|
|
1071
|
+
//
|
|
1072
|
+
// landmark = 有语义的容器:role / nav·main·header·footer·aside·form·dialog / 带 aria-label。
|
|
1073
|
+
// 布局 div(无语义)被穿透,孩子上提到最近的 landmark 祖先 —— 同 selection.ts 穿透 TRANSPARENT。
|
|
1074
|
+
const LANDMARK_TAGS = new Set(['nav', 'main', 'header', 'footer', 'aside', 'form', 'dialog', 'section'])
|
|
1075
|
+
|
|
1076
|
+
function isLandmark(el: Element): boolean {
|
|
1077
|
+
const tag = el.tagName.toLowerCase()
|
|
1078
|
+
if (LANDMARK_TAGS.has(tag))
|
|
1079
|
+
return true
|
|
1080
|
+
const role = el.getAttribute('role') || ''
|
|
1081
|
+
if (role && role !== 'button' && role !== 'link' && role !== 'textbox' && role !== 'listitem')
|
|
1082
|
+
return true
|
|
1083
|
+
return false
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
// 区域名:只取「人给的名字」(aria-label 或首个 heading 文本),如「角色设置」。
|
|
1087
|
+
// 没有人给的名字就返回空 —— 摆位本身已说明这是哪栏,不写 ARIA 角色英文名(complementary/region
|
|
1088
|
+
// 是给屏幕阅读器的噪音,人画草图不标它)。
|
|
1089
|
+
function landmarkLabel(el: Element): string {
|
|
1090
|
+
const named = (el.getAttribute('aria-label')
|
|
1091
|
+
|| (el.querySelector('h1, h2, h3, [role="heading"]')?.textContent || '')).trim()
|
|
1092
|
+
return named ? named.slice(0, 24) : ''
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
// 生成 id 不是人眼读的「名字」—— radix/uuid 那种 `id-19ea…` 或 32+ 位无空格 hex/dash 串,
|
|
1096
|
+
// 当 label 是纯噪音。人眼看按钮看的是文字/图标,看不见 DOM id。这类 label 降级成 <tag>。
|
|
1097
|
+
// 禁正则:纯字符扫描 —— 单 token(无空格)+ 以 id- 开头 或 够长且只含 hex/dash/下划线。
|
|
1098
|
+
function isNoiseLabel(s: string): boolean {
|
|
1099
|
+
if (!s || s.includes(' '))
|
|
1100
|
+
return false
|
|
1101
|
+
if (s.startsWith('id-') || s.startsWith('radix-') || s.startsWith('«'))
|
|
1102
|
+
return true
|
|
1103
|
+
if (s.length < 24)
|
|
1104
|
+
return false
|
|
1105
|
+
for (const ch of s) {
|
|
1106
|
+
const ok = (ch >= '0' && ch <= '9') || (ch >= 'a' && ch <= 'f') || ch === '-' || ch === '_'
|
|
1107
|
+
if (!ok)
|
|
1108
|
+
return false
|
|
1109
|
+
}
|
|
1110
|
+
return true
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
// 旋钮语义:label(含状态后缀) + 态射(共用 knobSem/morphism)。供 build 装进 Box。
|
|
1114
|
+
// The file a knob JOINS to the static control panel on. The static side (panel.ts) records a
|
|
1115
|
+
// knob at the file where its on* handler is WRITTEN — for `<ActionBtn onClick={() => store.x=…}>`
|
|
1116
|
+
// that's the CALLER (Sidebar/index.tsx), not ActionBtn.tsx. But a host element's raw
|
|
1117
|
+
// data-insp-path is its DEFINITION file (ActionBtn.tsx). For any shared component (ActionBtn,
|
|
1118
|
+
// NavItem-as-import, …) these differ, so joining on the definition file silently misses the knob:
|
|
1119
|
+
// label matches, file doesn't → no `→ store.{}` morphism in /screen, ○ "not live" in --live.
|
|
1120
|
+
// callSite(ownerLadder) already recovers where `<Tag/>` was instantiated (the `↟` owner tag); use
|
|
1121
|
+
// its file. Fall back to the insp-path definition file for inline knobs (host file == handler
|
|
1122
|
+
// file, e.g. a raw <button onClick> written in place), where the two coincide.
|
|
1123
|
+
// The file a knob joins to the static control panel on, in one of three resolutions:
|
|
1124
|
+
// - 'leaf' → the host element's OWN insp-path file (where an inline `<button onClick>` is
|
|
1125
|
+
// written). Full relative path. Correct for inline knobs.
|
|
1126
|
+
// - 'callsite' → the OWNER CALL-SITE file (where `<ActionBtn onClick>` is written). Full path.
|
|
1127
|
+
// Correct for shared-component knobs whose handler arrives via props.
|
|
1128
|
+
// - 'compact' → the leaf-or-callsite file in last-dir/file form (matches knob-schema's
|
|
1129
|
+
// filePath.slice(-2) key, used by /screen's knobSem lookup).
|
|
1130
|
+
// The static side records the handler's authored file, which is leaf OR callsite depending on how
|
|
1131
|
+
// the developer wrote it; the runtime can't tell which, so collectKnobs emits both candidates.
|
|
1132
|
+
function knobFile(el: Element, form: 'leaf' | 'callsite' | 'compact'): string {
|
|
1133
|
+
const leafRaw = el.getAttribute('data-insp-path') || ''
|
|
1134
|
+
if (form === 'leaf')
|
|
1135
|
+
return leafRaw ? fullInsp(leafRaw).split(':')[0] : '?'
|
|
1136
|
+
const cs = callSite(ownerLadder(el))
|
|
1137
|
+
if (form === 'callsite')
|
|
1138
|
+
return cs ? fullInsp(cs.raw).split(':')[0] : '?'
|
|
1139
|
+
// compact: prefer the call-site (shared component), else the leaf — last-dir/file form.
|
|
1140
|
+
const raw = cs ? cs.raw : leafRaw
|
|
1141
|
+
return raw ? compactInsp(raw).split(':')[0] : '?'
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
// Look up a knob's static morphism (→ store.{…}), trying BOTH candidate files (leaf + call-site,
|
|
1145
|
+
// compact form) since the developer's handler file is one of them and the runtime can't tell which
|
|
1146
|
+
// (see collectKnobs). knobSem keys are `label file` in last-dir/file form. First hit wins.
|
|
1147
|
+
function lookupSem(el: Element, label: string): { transitions: string, store: string, line: number } | undefined {
|
|
1148
|
+
const leafRaw = el.getAttribute('data-insp-path') || ''
|
|
1149
|
+
const cs = callSite(ownerLadder(el))
|
|
1150
|
+
const files = [...new Set([
|
|
1151
|
+
leafRaw ? compactInsp(leafRaw).split(':')[0] : '',
|
|
1152
|
+
cs ? compactInsp(cs.raw).split(':')[0] : '',
|
|
1153
|
+
].filter(Boolean))]
|
|
1154
|
+
for (const f of files) {
|
|
1155
|
+
const sem = knobSem[`${label} ${f}`]
|
|
1156
|
+
if (sem)
|
|
1157
|
+
return sem
|
|
1158
|
+
}
|
|
1159
|
+
return undefined
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
function knobSemOf(el: Element): { label: string, morphism: string } {
|
|
1163
|
+
const tag = el.tagName.toLowerCase()
|
|
1164
|
+
const raw = elementLabel(el)
|
|
1165
|
+
const full = isNoiseLabel(raw) ? '' : raw
|
|
1166
|
+
const sem = lookupSem(el, full || `<${tag}>`)
|
|
1167
|
+
// 状态:disabled / checked / 空文本框 —— 人眼一眼看见的「这个能不能用、填没填」。
|
|
1168
|
+
// domState 产 ` {a b}`(或空串),换成字符画的 ` (a b)`。纯字符切片,禁正则。
|
|
1169
|
+
const st = domState(el) // ' {disabled selected}' | ''
|
|
1170
|
+
const dom = st ? ` (${st.slice(2, -1)})` : ''
|
|
1171
|
+
const name = full.slice(0, 40) || `<${tag}>`
|
|
1172
|
+
return {
|
|
1173
|
+
label: `${name}${dom}`,
|
|
1174
|
+
morphism: sem ? ` → ${sem.store}.{ ${sem.transitions} }` : '',
|
|
1175
|
+
}
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
// 元素几何 —— 单次 getBoundingClientRect(可见性 + axis/lane/colWidth 都复用这一次)。
|
|
1179
|
+
function rectOf(el: Element): DOMRect {
|
|
1180
|
+
return el.getBoundingClientRect()
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
// 在视口里吗 —— 肉眼所见的物理边界。元素有盒子、且盒子和视口矩形相交才算「看得见」;
|
|
1184
|
+
// 滚动出去的消息肉眼看不见,草绘也不画(用户拍板的边界:视口可见,非「采样前 N 条」)。
|
|
1185
|
+
function isVisible(r: DOMRect): boolean {
|
|
1186
|
+
if (r.width <= 0 || r.height <= 0)
|
|
1187
|
+
return false
|
|
1188
|
+
return r.bottom > 0 && r.right > 0 && r.top < innerHeight && r.left < innerWidth
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
// 元素自己的直接文本(不含后代 —— 后代由递归各自成行,保留肉眼看到的层次,不拼平)。
|
|
1192
|
+
// getDirectText 已是「只取直接 TEXT_NODE」,这里复用。
|
|
1193
|
+
function ownText(el: Element): string {
|
|
1194
|
+
const aria = el.getAttribute('aria-label') || ''
|
|
1195
|
+
return (aria || getDirectText(el)).trim()
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
const MAX_CANVAS_COLS = 160 // 与 canvas.ts MAX_COLS 一致:整页降采样到 ≤160 列
|
|
1199
|
+
const MAX_CANVAS_ROWS = 80 // 与 canvas.ts MAX_ROWS 一致:画布行高上限
|
|
1200
|
+
// 一画布行 ≈ N 真实像素高。28:行现在是真 2D 高度(纵向 gap 成空行),要比旧 48 更细的分辨率
|
|
1201
|
+
// —— round(199/28)=7 高 bot 气泡、round(44/28)=2 矮 user 气泡,高矮分明又不炸满屏。
|
|
1202
|
+
const PX_PER_ROW = 28
|
|
1203
|
+
// 一字符列 ≈ N 真实像素宽,使整页 innerWidth 映射到 ≤MAX_CANVAS_COLS 列。≈7.17 @1148px。
|
|
1204
|
+
function colScale(): number {
|
|
1205
|
+
return Math.max(innerWidth, 1) / MAX_CANVAS_COLS
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
// 把绝对 DOMRect 降采样成「相对父框内壁」的整数网格矩形。-1 抵父边框(父内部原点缩进 1 格,
|
|
1209
|
+
// 故子贴父左壁时网格 col=0)。w/h 含 2 列/行边框。纯几何,全在采集端,renderer 零像素。
|
|
1210
|
+
function gridRect(child: DOMRect, parent: DOMRect): { col: number, row: number, w: number, h: number } {
|
|
1211
|
+
const s = colScale()
|
|
1212
|
+
return {
|
|
1213
|
+
col: Math.max(0, Math.round((child.left - parent.left) / s) - 1),
|
|
1214
|
+
row: Math.max(0, Math.round((child.top - parent.top) / PX_PER_ROW) - 1),
|
|
1215
|
+
w: Math.max(1, Math.round(child.width / s)),
|
|
1216
|
+
h: Math.max(1, Math.round(child.height / PX_PER_ROW)),
|
|
1217
|
+
}
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
// 行四舍五入会让相邻竖排框的下边框行 == 下一个框的上边框行,两条边框线挤成一条(读起来像框
|
|
1221
|
+
// 互相穿插)。对水平有重叠(同一竖列)的画框兄弟,强制下一个的 row 落到上一个底边之下,留 1 行
|
|
1222
|
+
// 边框带各自独立。纯整数几何,按 row 稳定排序后线性下推。横向并排的栏(不重叠)互不影响。
|
|
1223
|
+
function decollide(boxes: Box[]): Box[] {
|
|
1224
|
+
const order = boxes.map((_, i) => i).sort((a, b) => boxes[a].row - boxes[b].row || a - b)
|
|
1225
|
+
for (let i = 0; i < order.length; i++) {
|
|
1226
|
+
const cur = boxes[order[i]]
|
|
1227
|
+
if (!cur.drawBorder)
|
|
1228
|
+
continue
|
|
1229
|
+
for (let j = 0; j < i; j++) {
|
|
1230
|
+
const prev = boxes[order[j]]
|
|
1231
|
+
if (!prev.drawBorder)
|
|
1232
|
+
continue
|
|
1233
|
+
// 重叠列数 > 较窄框一半才算「真竖排撞车」;1-2 列舍入溢出(并排栏边缘擦碰)不算,
|
|
1234
|
+
// 否则相邻栏因取整微叠被错误下推到对方整高之下(三栏坍成一栏)。
|
|
1235
|
+
const overlap = Math.min(cur.col + cur.w, prev.col + prev.w) - Math.max(cur.col, prev.col)
|
|
1236
|
+
const stacked = overlap > Math.min(cur.w, prev.w) / 2
|
|
1237
|
+
if (stacked && cur.row < prev.row + prev.h)
|
|
1238
|
+
cur.row = prev.row + prev.h
|
|
1239
|
+
}
|
|
1240
|
+
}
|
|
1241
|
+
return boxes
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
// 框中心落在整页哪三分之一 → 中性方位名。不臆造语义(成员栏/详情栏),只标方位;
|
|
1245
|
+
// renderSemanticTree 另加 (左/中/右) band tag,这里给身份名足够 reader 区分三栏。
|
|
1246
|
+
export function bandLabel(col: number, w: number, rootW: number): string {
|
|
1247
|
+
const center = col + w / 2
|
|
1248
|
+
if (center < rootW / 3)
|
|
1249
|
+
return '侧边栏'
|
|
1250
|
+
if (center > (rootW * 2) / 3)
|
|
1251
|
+
return '侧栏'
|
|
1252
|
+
return '主区'
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
// landmark 间隙聚栏。DOM 缺 <main> 时,对话线散落框被平摊到 root 下,丢了「主内容区」这层 frame。
|
|
1256
|
+
// 纯几何兜底:landmark frame 没覆盖的横向区间各收成一个虚拟内容 frame,散落子框按中心归入。
|
|
1257
|
+
// 元真理守门:无 landmark / 全覆盖 / 空间隙 → 一框不造,老页零变化。
|
|
1258
|
+
const MIN_COL_GAP = 8 // 窄于此的 landmark 间缝隙不造空栏(舍入毛刺)
|
|
1259
|
+
export function gatherContentColumns(root: Box): void {
|
|
1260
|
+
const frames = root.children.filter(c => c.kind === 'frame')
|
|
1261
|
+
if (frames.length === 0)
|
|
1262
|
+
return // 无 landmark:整页本就是一个内容区,不聚(零变化)
|
|
1263
|
+
// 无名 landmark 补 band 名(两个无 aria/heading 的 aside 现在显示「侧边栏/侧栏」而非 <frame>)
|
|
1264
|
+
for (const f of frames)
|
|
1265
|
+
if (!f.label)
|
|
1266
|
+
f.label = bandLabel(f.col, f.w, root.w)
|
|
1267
|
+
// [0, root.w) 减去所有 covered 区间 = 补集间隙。排序后扫描,纯数值比较。
|
|
1268
|
+
const covered = frames.map(f => [f.col, f.col + f.w] as [number, number]).sort((a, b) => a[0] - b[0])
|
|
1269
|
+
const gaps: [number, number][] = []
|
|
1270
|
+
let cursor = 0
|
|
1271
|
+
for (const [lo, hi] of covered) {
|
|
1272
|
+
if (lo - cursor >= MIN_COL_GAP)
|
|
1273
|
+
gaps.push([cursor, lo])
|
|
1274
|
+
cursor = Math.max(cursor, hi)
|
|
1275
|
+
}
|
|
1276
|
+
if (root.w - cursor >= MIN_COL_GAP)
|
|
1277
|
+
gaps.push([cursor, root.w])
|
|
1278
|
+
if (gaps.length === 0)
|
|
1279
|
+
return // 全覆盖
|
|
1280
|
+
const strays = root.children.filter(c => c.kind !== 'frame')
|
|
1281
|
+
const newCols: Box[] = []
|
|
1282
|
+
const claimed = new Set<Box>()
|
|
1283
|
+
for (const [lo, hi] of gaps) {
|
|
1284
|
+
const kids = strays.filter(s => {
|
|
1285
|
+
const center = s.col + s.w / 2
|
|
1286
|
+
return center >= lo && center < hi
|
|
1287
|
+
})
|
|
1288
|
+
if (kids.length === 0)
|
|
1289
|
+
continue // 空间隙不造 frame
|
|
1290
|
+
for (const k of kids) {
|
|
1291
|
+
claimed.add(k)
|
|
1292
|
+
k.col -= lo // 平移到相对新 frame 内壁
|
|
1293
|
+
}
|
|
1294
|
+
newCols.push({
|
|
1295
|
+
kind: 'frame', col: lo, row: 0, w: hi - lo, h: root.h,
|
|
1296
|
+
label: bandLabel(lo, hi - lo, root.w), morphism: '', drawBorder: true,
|
|
1297
|
+
children: kids,
|
|
1298
|
+
})
|
|
1299
|
+
}
|
|
1300
|
+
if (newCols.length === 0)
|
|
1301
|
+
return
|
|
1302
|
+
// 重建 root.children = landmark frames + 新虚拟栏 + 未被任何栏认领的散落框,按 col 排序
|
|
1303
|
+
const leftover = strays.filter(s => !claimed.has(s))
|
|
1304
|
+
root.children = [...frames, ...newCols, ...leftover].sort((a, b) => a.col - b.col)
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
// 几何偏移块 —— 复用 deriveLane 信号。窄于父 0.85 且偏移(lane≠left,即居中/靠右),
|
|
1308
|
+
// 或虽不偏移但明显窄于父并承载文字 = 无 CSS 框的聊天气泡。全宽块(wfrac>0.85)非气泡 → 递归。
|
|
1309
|
+
function isOffsetBubble(el: Element, r: DOMRect): boolean {
|
|
1310
|
+
const p = el.parentElement
|
|
1311
|
+
if (!p)
|
|
1312
|
+
return false
|
|
1313
|
+
const parent = rectOf(p)
|
|
1314
|
+
if (parent.width <= 0)
|
|
1315
|
+
return false
|
|
1316
|
+
const wfrac = r.width / parent.width
|
|
1317
|
+
if (wfrac > 0.85)
|
|
1318
|
+
return false // 全宽 wrapper:递归下降到内层窄块,别抓 wrapper
|
|
1319
|
+
const lane = deriveLane(el, r)
|
|
1320
|
+
if (lane !== 'left')
|
|
1321
|
+
return true // 居中(日期)/ 靠右(用户气泡)
|
|
1322
|
+
return wfrac < 0.7 && ownText(el).length > 0 // 偏左但明显窄且有字:bot 气泡
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
|
+
// 孩子在纵向列表里的水平车道(被 isOffsetBubble 复用)。框 = el.parentElement 盒子,量相对
|
|
1326
|
+
// 自己 DOM 父非被上提进的 region。占满整行(wfrac>0.8)非信号。左 0.40 / 右 0.60 三分。
|
|
1327
|
+
function deriveLane(el: Element, child: DOMRect): 'left' | 'center' | 'right' {
|
|
1328
|
+
const p = el.parentElement
|
|
1329
|
+
if (!p)
|
|
1330
|
+
return 'left'
|
|
1331
|
+
const parent = rectOf(p)
|
|
1332
|
+
if (parent.width <= 0)
|
|
1333
|
+
return 'left'
|
|
1334
|
+
const wfrac = child.width / parent.width
|
|
1335
|
+
if (wfrac > 0.8)
|
|
1336
|
+
return 'left'
|
|
1337
|
+
const cx = (child.left + child.right) / 2
|
|
1338
|
+
const frac = (cx - parent.left) / parent.width
|
|
1339
|
+
if (frac < 0.40)
|
|
1340
|
+
return 'left'
|
|
1341
|
+
if (frac > 0.60)
|
|
1342
|
+
return 'right'
|
|
1343
|
+
return 'center'
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
// 一个元素该不该成框,成什么框。三源:landmark(栏) / interactive(旋钮) / 几何偏移块(气泡)。
|
|
1347
|
+
// CSS 背景/边框不成框 —— 人画草图不框脚手架,框只给内容(栏/气泡/按钮)。
|
|
1348
|
+
// 都不是 → null(由 emit 决定:有字成 text 叶,否则穿透递归)。
|
|
1349
|
+
function classify(el: Element, r: DOMRect): BoxKind | null {
|
|
1350
|
+
if (isLandmark(el))
|
|
1351
|
+
return 'frame'
|
|
1352
|
+
if (el.matches(INTERACTIVE) && reachable(el))
|
|
1353
|
+
return 'knob'
|
|
1354
|
+
if (isOffsetBubble(el, r))
|
|
1355
|
+
return 'bubble'
|
|
1356
|
+
return null
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1359
|
+
// read 投影:抠正文。文本密集页(文章/评论树/README)的字符画全是导航外壳噪音,要正文得再发一轮
|
|
1360
|
+
// /eval 手写选择器 —— 杀掉这轮往返。DOM 找文本密度最高的主内容子树,剥导航/页脚,块边界保留串化。
|
|
1361
|
+
// 浏览器原生,零 app 合作(外部站 CDP 空降也跑),与字符画同源 Box 采集外的第四条渲染路。
|
|
1362
|
+
|
|
1363
|
+
// 块级标签:每个成正文里独立一行。div 也算块(README/评论容器常用裸 div 分段);行内标签(a/span/
|
|
1364
|
+
// strong/em/code)不在内,它们的字并进父块行。
|
|
1365
|
+
const BLOCK_TAGS = new Set(['p', 'li', 'blockquote', 'tr', 'article', 'section', 'pre', 'div',
|
|
1366
|
+
'h1', 'h2', 'h3', 'h4', 'h5', 'h6'])
|
|
1367
|
+
|
|
1368
|
+
// read 的可见性 ≠ 字符画的 isVisible:字符画画「视口里」(滚动出去的不画),但读正文要整篇文档
|
|
1369
|
+
// —— 文章大半滚在视口外,按视口门控只剩首屏一截。read 用「在布局里」(盒子非 0×0 = 非 display:none)
|
|
1370
|
+
// 当门:剥掉折叠菜单/隐藏导航(0×0),保留视口外的正文。这是 read 与 visual 投影的语义分水岭。
|
|
1371
|
+
function isRendered(r: DOMRect): boolean {
|
|
1372
|
+
return r.width > 0 && r.height > 0
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1375
|
+
// 页面外壳标签:nav/header/footer/aside 按 HTML 语义就是导航/页眉页脚/边栏,永不是正文 —— read
|
|
1376
|
+
// 整子树跳过(连 <main> 里嵌套的 <nav> 工具菜单也剥,wiki 的「What links here / Read / View source」
|
|
1377
|
+
// 都在 <main> 内的 <nav> 里)。这就是「剥导航/页脚/边栏」的落点,比 link-density 启发式简单且确定。
|
|
1378
|
+
const CHROME_TAGS = new Set(['nav', 'header', 'footer', 'aside'])
|
|
1379
|
+
|
|
1380
|
+
// 容器渲染后代文本总字数 —— Readability 密度评分:正文容器 = 字最多的子树。跳 SKIP_TAGS(script/
|
|
1381
|
+
// style)和未渲染(display:none)子树,但不按视口裁(整篇文档都算,见 isRendered)。
|
|
1382
|
+
function descendantTextLen(el: Element): number {
|
|
1383
|
+
let n = 0
|
|
1384
|
+
function walk(e: Element): void {
|
|
1385
|
+
const tag = e.tagName.toLowerCase()
|
|
1386
|
+
if (SKIP_TAGS.has(tag) || CHROME_TAGS.has(tag) || !isRendered(rectOf(e)))
|
|
1387
|
+
return
|
|
1388
|
+
for (const node of e.childNodes)
|
|
1389
|
+
if (node.nodeType === 3)
|
|
1390
|
+
n += node.textContent?.trim().length ?? 0
|
|
1391
|
+
for (const c of e.children)
|
|
1392
|
+
walk(c)
|
|
1393
|
+
}
|
|
1394
|
+
walk(el)
|
|
1395
|
+
return n
|
|
1396
|
+
}
|
|
1397
|
+
|
|
1398
|
+
// 选正文根:先认语义 landmark(<main>/<article>,过 200 字门避开空壳),否则下沉到「装大部分字(≥60%)
|
|
1399
|
+
// 的最深容器」—— 经典 Readability「最紧的装着主体文字的容器」,天然排除小导航条(导航子树字数远低于阈)。
|
|
1400
|
+
// 下沉只穿包装容器(div/section/…),撞 TEXT_BLOCK(p/li/h*/…)就停 —— 段落本身是内容,再下沉会把
|
|
1401
|
+
// 标题/兄弟段落甩在外。一个大段落字数超阈也不能当根:它的兄弟会丢。
|
|
1402
|
+
const TEXT_BLOCK = new Set(['p', 'li', 'blockquote', 'tr', 'pre', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6'])
|
|
1403
|
+
|
|
1404
|
+
export function pickReadableRoot(body: Element): Element {
|
|
1405
|
+
for (const el of body.querySelectorAll('main, article, [role="main"], [role="article"]'))
|
|
1406
|
+
if (descendantTextLen(el) >= 200)
|
|
1407
|
+
return el
|
|
1408
|
+
const total = descendantTextLen(body)
|
|
1409
|
+
let best = body
|
|
1410
|
+
function walk(e: Element): void {
|
|
1411
|
+
for (const c of e.children)
|
|
1412
|
+
if (!TEXT_BLOCK.has(c.tagName.toLowerCase()) && descendantTextLen(c) >= total * 0.6) {
|
|
1413
|
+
best = c
|
|
1414
|
+
walk(c)
|
|
1415
|
+
}
|
|
1416
|
+
}
|
|
1417
|
+
if (total > 0)
|
|
1418
|
+
walk(body)
|
|
1419
|
+
return best
|
|
1420
|
+
}
|
|
1421
|
+
|
|
1422
|
+
// 行内空白折叠:对**渲染后**文本的字节级归一(HTML 布局注入的空格/换行/nbsp → 人见单空格),
|
|
1423
|
+
// 非对结构化语法 pattern match —— 同 candidates.ts normWs 先例,此处正则正当。
|
|
1424
|
+
function collapseWs(s: string): string {
|
|
1425
|
+
return s.replace(/\s+/g, ' ').trim()
|
|
1426
|
+
}
|
|
1427
|
+
|
|
1428
|
+
// 块边界保留串化:DFS,每个 BLOCK_TAGS 元素的「直接文本 + 行内后代文字」成一行(撞嵌套块子节点就停,
|
|
1429
|
+
// 它们各自递归成行,不重复计数);heading 前缀 #;超预算尾截 + 响亮报丢弃字数(no-silent-cap)。
|
|
1430
|
+
export function serializeReadable(root: Element, budget: number): string {
|
|
1431
|
+
const lines: string[] = []
|
|
1432
|
+
// 收一个块自身的行文字:并入行内后代,撞块子节点停(留给它们自己的行)。
|
|
1433
|
+
function inlineText(e: Element): string {
|
|
1434
|
+
let t = ''
|
|
1435
|
+
for (const node of e.childNodes) {
|
|
1436
|
+
if (node.nodeType === 3)
|
|
1437
|
+
t += node.textContent ?? ''
|
|
1438
|
+
else if (node.nodeType === 1) {
|
|
1439
|
+
const ch = node as Element
|
|
1440
|
+
const ct = ch.tagName.toLowerCase()
|
|
1441
|
+
if (BLOCK_TAGS.has(ct) || SKIP_TAGS.has(ct) || CHROME_TAGS.has(ct))
|
|
1442
|
+
continue
|
|
1443
|
+
t += inlineText(ch)
|
|
1444
|
+
}
|
|
1445
|
+
}
|
|
1446
|
+
return t
|
|
1447
|
+
}
|
|
1448
|
+
function walk(e: Element): void {
|
|
1449
|
+
const tag = e.tagName.toLowerCase()
|
|
1450
|
+
if (SKIP_TAGS.has(tag) || CHROME_TAGS.has(tag) || !isRendered(rectOf(e)))
|
|
1451
|
+
return
|
|
1452
|
+
if (BLOCK_TAGS.has(tag)) {
|
|
1453
|
+
const txt = collapseWs(inlineText(e))
|
|
1454
|
+
if (txt)
|
|
1455
|
+
lines.push(tag[0] === 'h' && tag.length === 2 ? `# ${txt}` : txt)
|
|
1456
|
+
}
|
|
1457
|
+
for (const c of e.children)
|
|
1458
|
+
walk(c)
|
|
1459
|
+
}
|
|
1460
|
+
walk(root)
|
|
1461
|
+
const text = lines.join('\n')
|
|
1462
|
+
if (text.length <= budget)
|
|
1463
|
+
return text
|
|
1464
|
+
// 头尾保留截断:limitations / conclusion / future work 永远在文末,纯头截恰好丢掉最有价值的部分
|
|
1465
|
+
// (实战:arXiv Section 5「threats to validity」被 8000 头截砍掉,被迫 eval 兜底)。留头 70% + 尾 30%。
|
|
1466
|
+
const head = Math.floor(budget * 0.7)
|
|
1467
|
+
const tail = budget - head
|
|
1468
|
+
return `${text.slice(0, head)}\n\n… +${text.length - budget} chars truncated(中段省略,已保留结尾)…\n\n${text.slice(-tail)}`
|
|
1469
|
+
}
|
|
1470
|
+
|
|
1471
|
+
export function renderReadable(): string {
|
|
1472
|
+
return serializeReadable(pickReadableRoot(document.body), 40000)
|
|
1473
|
+
}
|
|
1474
|
+
|
|
1475
|
+
// form 决定用哪个纯渲染器投影同一棵 Box 树:tree=语义布局树(默认,eval 证帕累托最优)、
|
|
1476
|
+
// visual=ASCII 草图(旧默认,空间直觉对人眼好对 AI 差)、knobs=纯控制面旋钮表、read=抠正文(剥外壳)。
|
|
1477
|
+
// C 关系式不开口(误导率最高)。
|
|
1478
|
+
export function collectCanvas(form: 'tree' | 'visual' | 'knobs' | 'read' = 'tree'): string {
|
|
1479
|
+
const modalEl = document.querySelector('[role="dialog"][data-state="open"]')
|
|
1480
|
+
const rootEl = modalEl ?? document.body
|
|
1481
|
+
|
|
1482
|
+
// 等价草图:每个框按真实 (x,y,w,h) 降采样成相对父内壁的整数网格,renderer 画矩形。
|
|
1483
|
+
// 单递归 + 几何停止:框宽塞得下字 → 叶(填字);塞不下且是容器 → 递归子框。三档从框宽涌现。
|
|
1484
|
+
type Kid = { box: Box, el: Element }
|
|
1485
|
+
|
|
1486
|
+
// knob Box → 其源元素的 join 候选文件(leaf+callsite,同 collectKnobs)。Box 是纯几何不带 el,
|
|
1487
|
+
// 但 @kN→{label,file} 翻译要 file 消歧同名旋钮 —— 在 build 时旁路记下,return 时与 numberKnobs 拼。
|
|
1488
|
+
const knobFiles = new Map<Box, string[]>()
|
|
1489
|
+
|
|
1490
|
+
// 为 el 造一个框(kind 由 classify 给),坐标相对 parentRect 内壁。几何停止决定叶 or 容器。
|
|
1491
|
+
function build(el: Element, r: DOMRect, parentRect: DOMRect, kind: BoxKind): Box {
|
|
1492
|
+
const g = gridRect(r, parentRect)
|
|
1493
|
+
const box: Box = { kind, ...g, label: '', morphism: '', drawBorder: kind !== 'text', children: [] }
|
|
1494
|
+
const innerCols = box.w - 2
|
|
1495
|
+
|
|
1496
|
+
if (kind === 'knob') {
|
|
1497
|
+
const sem = knobSemOf(el)
|
|
1498
|
+
box.label = sem.label
|
|
1499
|
+
box.morphism = sem.morphism
|
|
1500
|
+
knobFiles.set(box, [...new Set([knobFile(el, 'leaf'), knobFile(el, 'callsite')].filter(f => f && f !== '?'))])
|
|
1501
|
+
return box // 旋钮永远是叶
|
|
1502
|
+
}
|
|
1503
|
+
if (kind === 'text') {
|
|
1504
|
+
box.label = ownText(el).slice(0, 80)
|
|
1505
|
+
return box
|
|
1506
|
+
}
|
|
1507
|
+
// frame / bubble:有字且塞得下 → 叶填字;否则当容器递归子框。
|
|
1508
|
+
// bubble 用 elementLabel(命中路同一真理):它会下沉到包裹的 form control 的 title/value,
|
|
1509
|
+
// 于是会话项(div 包 pointer-events-none input)在 /screen 显示真名而非 <bubble> —— 显示
|
|
1510
|
+
// 路和 /click text= 命中路名字一致(读得出=点得中)。frame 仍走 landmark/ownText(容器名)。
|
|
1511
|
+
const label = kind === 'bubble' ? elementLabel(el) : (kind === 'frame' && isLandmark(el)) ? landmarkLabel(el) : ownText(el)
|
|
1512
|
+
const fits = label !== '' && innerCols >= 1 && [...label].length <= innerCols
|
|
1513
|
+
if (kind === 'bubble' && fits) {
|
|
1514
|
+
box.label = label
|
|
1515
|
+
box.h = Math.max(box.h, 3) // 画框的带字叶需内部行放 label,h<3 会把字写到下边框上
|
|
1516
|
+
return box
|
|
1517
|
+
}
|
|
1518
|
+
const kids: Kid[] = []
|
|
1519
|
+
for (const child of el.children)
|
|
1520
|
+
emit(child, kids, r)
|
|
1521
|
+
if (kids.length === 0) {
|
|
1522
|
+
// 无子框:landmark 头作 label,否则该框自身的文字作叶(不画空框)。
|
|
1523
|
+
box.label = label.slice(0, 80)
|
|
1524
|
+
return box
|
|
1525
|
+
}
|
|
1526
|
+
const landmark = kind === 'frame' && isLandmark(el)
|
|
1527
|
+
box.label = landmark ? landmarkLabel(el) : ''
|
|
1528
|
+
box.children = decollide(kids.map(k => k.box))
|
|
1529
|
+
// 同心框坍缩:非 landmark 的 CSS 壳框,若无 label 且唯一子框几乎填满它(脚手架嵌套),
|
|
1530
|
+
// 不画自己的墙 —— 只贡献布局,免去 │││ 同心噪音墙(元真理:墙不带信息就去掉)。
|
|
1531
|
+
if (!landmark && !box.label && box.children.length === 1) {
|
|
1532
|
+
const c = box.children[0]
|
|
1533
|
+
if (c.drawBorder && c.w >= box.w - 3 && c.h >= box.h - 1)
|
|
1534
|
+
box.drawBorder = false
|
|
1535
|
+
}
|
|
1536
|
+
return box
|
|
1537
|
+
}
|
|
1538
|
+
|
|
1539
|
+
// 把 child 摊进 kids:成框 → build;svg/img → alt 文字叶;有自己的字 → text 叶;否则穿透递归。
|
|
1540
|
+
function emit(el: Element, kids: Kid[], parentRect: DOMRect): void {
|
|
1541
|
+
const tag = el.tagName.toLowerCase()
|
|
1542
|
+
if (SKIP_TAGS.has(tag))
|
|
1543
|
+
return
|
|
1544
|
+
const r = rectOf(el)
|
|
1545
|
+
if (tag === 'svg' || tag === 'img') {
|
|
1546
|
+
const alt = (el.getAttribute('aria-label') || el.getAttribute('alt') || '').trim()
|
|
1547
|
+
if (alt && isVisible(r)) {
|
|
1548
|
+
const g = gridRect(r, parentRect)
|
|
1549
|
+
kids.push({ box: { kind: 'text', ...g, label: alt.slice(0, 80), morphism: '', drawBorder: false, children: [] }, el })
|
|
1550
|
+
}
|
|
1551
|
+
return
|
|
1552
|
+
}
|
|
1553
|
+
if (!isVisible(r))
|
|
1554
|
+
return // 视口外,肉眼看不见
|
|
1555
|
+
|
|
1556
|
+
const kind = classify(el, r)
|
|
1557
|
+
if (kind) {
|
|
1558
|
+
kids.push({ box: build(el, r, parentRect, kind), el })
|
|
1559
|
+
return
|
|
1560
|
+
}
|
|
1561
|
+
const text = ownText(el)
|
|
1562
|
+
if (text) {
|
|
1563
|
+
const g = gridRect(r, parentRect)
|
|
1564
|
+
kids.push({ box: { kind: 'text', ...g, label: text.slice(0, 80), morphism: '', drawBorder: false, children: [] }, el })
|
|
1565
|
+
return
|
|
1566
|
+
}
|
|
1567
|
+
for (const c of el.children)
|
|
1568
|
+
emit(c, kids, parentRect) // 穿透:孩子坐标仍相对同一父框(脚手架不占框)
|
|
1569
|
+
}
|
|
1570
|
+
|
|
1571
|
+
const rootRect = rectOf(rootEl)
|
|
1572
|
+
const root = build(rootEl, rootRect, rootRect, 'frame')
|
|
1573
|
+
// root 填满画布(不画整视口框)。h 按真实高降采样,cap MAX_CANVAS_ROWS。
|
|
1574
|
+
root.col = 0
|
|
1575
|
+
root.row = 0
|
|
1576
|
+
root.w = MAX_CANVAS_COLS
|
|
1577
|
+
root.h = Math.min(MAX_CANVAS_ROWS, Math.max(1, Math.round(rootRect.height / PX_PER_ROW)))
|
|
1578
|
+
root.drawBorder = false
|
|
1579
|
+
root.label = ''
|
|
1580
|
+
gatherContentColumns(root) // landmark 间隙聚成虚拟内容栏 + 无名 landmark 补 band 名
|
|
1581
|
+
// 把同一棵 Box 树挂到 window,供实验台从 /eval 抠出,机械派生 B/C/D 形态(四形态同源,零手写)。
|
|
1582
|
+
;(window as any).__aihandScreenTree = root
|
|
1583
|
+
// @kN 编号:单一权威源 numberKnobs(root),四投影都读这一份 → 号必一致(轴间对称)。
|
|
1584
|
+
const refs = numberKnobs(root)
|
|
1585
|
+
// 把 @kN→{label,files} 随 /screen 带回服务端,供 /action?knob=@kN 翻译。files 是 leaf+callsite
|
|
1586
|
+
// 两个 join 候选(同 collectKnobs):runtime 分不清 handler 写在哪,两个都给,resolveKnob 命中任一。
|
|
1587
|
+
const knobRefs: Record<string, { label: string, files: string[] }> = {}
|
|
1588
|
+
for (const [box, ref] of refs)
|
|
1589
|
+
knobRefs[ref] = { label: box.label, files: knobFiles.get(box) ?? [] }
|
|
1590
|
+
;(window as any).__aihandKnobRefs = knobRefs
|
|
1591
|
+
return form === 'read' ? renderReadable()
|
|
1592
|
+
: form === 'visual' ? renderCanvas(root, refs)
|
|
1593
|
+
: form === 'knobs' ? renderKnobs(root, refs)
|
|
1594
|
+
: renderSemanticTree(root, refs)
|
|
1595
|
+
}
|
|
1596
|
+
|
|
1597
|
+
// Called from the action handler after the DOM has settled. The synthetic event aihand
|
|
1598
|
+
// just fired was captured into actionEntries by client-patch; find that entry (latest
|
|
1599
|
+
// synthetic) and back-fill its snapshot synchronously off the now-settled DOM — not via
|
|
1600
|
+
// client-patch's async 180ms stamp, which may not have run yet (Risk 1). Then render the
|
|
1601
|
+
// timeline anchored on it, so the model sees its own action between before/after context.
|
|
1602
|
+
function attachTimeline(): string {
|
|
1603
|
+
const cur = [...actionEntries].reverse().find(e => !e.trusted)
|
|
1604
|
+
if (cur) {
|
|
1605
|
+
try { cur.view = window.__AIPEEK_VIEW__?.() ?? '(unknown)' } catch { cur.view = '(unknown)' }
|
|
1606
|
+
const modalEl = document.querySelector('[role="dialog"][data-state="open"]')
|
|
1607
|
+
cur.modal = modalEl ? (modalEl.querySelector('h1, h2, [id^="radix"]')?.textContent || 'untitled').trim().slice(0, 40) : 'none'
|
|
1608
|
+
}
|
|
1609
|
+
return formatActions([...actionEntries], cur?.ts, buffers?.actionsDropped ?? 0)
|
|
1610
|
+
}
|
|
1611
|
+
|
|
1612
|
+
function boundedSnapshot(obj: unknown, depth = 0): unknown {
|
|
1613
|
+
if (depth > 3)
|
|
1614
|
+
return '[…]'
|
|
1615
|
+
if (obj === null || obj === undefined)
|
|
1616
|
+
return obj
|
|
1617
|
+
if (typeof obj === 'function')
|
|
1618
|
+
return undefined
|
|
1619
|
+
if (typeof obj === 'string')
|
|
1620
|
+
return obj.length > 100 ? `${obj.slice(0, 100)}…` : obj
|
|
1621
|
+
if (typeof obj !== 'object')
|
|
1622
|
+
return obj
|
|
1623
|
+
if (Array.isArray(obj))
|
|
1624
|
+
return `Array(${obj.length})`
|
|
1625
|
+
|
|
1626
|
+
const result: Record<string, unknown> = {}
|
|
1627
|
+
for (const [k, v] of Object.entries(obj as Record<string, unknown>).slice(0, 20)) {
|
|
1628
|
+
if (typeof v === 'function')
|
|
1629
|
+
continue
|
|
1630
|
+
// Redact at capture, not at echo: a secret-named field (sessionToken/apiKey/…) never
|
|
1631
|
+
// crosses the wire into raw.state, so no downstream reader (/state dump, diffState) can
|
|
1632
|
+
// re-leak it. The third consumer of the isSecretKey chokepoint, after DOM input + domain.
|
|
1633
|
+
if (isSecretKey(k) && (typeof v === 'string' || typeof v === 'number'))
|
|
1634
|
+
result[k] = redactSecretValue(String(v))
|
|
1635
|
+
else
|
|
1636
|
+
result[k] = boundedSnapshot(v, depth + 1)
|
|
1637
|
+
}
|
|
1638
|
+
return result
|
|
1639
|
+
}
|
|
1640
|
+
|
|
1641
|
+
// --- HMR channel ---
|
|
1642
|
+
|
|
1643
|
+
// Resolve once the DOM goes quiet (no mutations for `quiet`ms) or `timeout` hits,
|
|
1644
|
+
// then return the fresh UI tree. Lets a click/fill report its own outcome.
|
|
1645
|
+
// quiet=50: settle now only waits for the *state machine* (view/modal/mode) to settle — that
|
|
1646
|
+
// happens in the first React commit (<20ms); 50 gives streaming render a little headroom and the
|
|
1647
|
+
// MutationObserver resets the timer on every mutation, so a still-rendering tree extends the wait on
|
|
1648
|
+
// its own. Click TARGETING used to force this higher (a Radix dialog's exit-animation overlay covered
|
|
1649
|
+
// the next target, so a close→open sequence at low quiet hit "hit <div> on top"), but that's now
|
|
1650
|
+
// handled where it belongs — doClick (action.ts) waits for any covering overlay to clear before it
|
|
1651
|
+
// clicks. So settle no longer pays the overlay tax for every action; the floor is the state machine's.
|
|
1652
|
+
// Is any domain field asserting in-flight work right now? (流式中: true, isLoading: true, …)
|
|
1653
|
+
// Generic over the app — isBusyState is a key-level classifier, never a hard-coded field.
|
|
1654
|
+
function domainBusy(): boolean {
|
|
1655
|
+
let dom: Record<string, unknown> = {}
|
|
1656
|
+
try {
|
|
1657
|
+
dom = window.__AIPEEK_SCREEN__?.() ?? {}
|
|
1658
|
+
}
|
|
1659
|
+
catch { return false }
|
|
1660
|
+
return Object.entries(dom).some(([k, v]) => isBusyState(k, v))
|
|
1661
|
+
}
|
|
1662
|
+
|
|
1663
|
+
// In-flight, action-scoped: a fetch/stream that's still open (status 0, not failed) AND was not
|
|
1664
|
+
// already open when the action began. Scoping to new requests excludes a pre-existing long-poll /
|
|
1665
|
+
// persistent stream (which stays status 0 forever and would otherwise trap the settle until the cap).
|
|
1666
|
+
function newInFlight(baseline: Set<NetworkRequest>): boolean {
|
|
1667
|
+
return networkRequests.some(r =>
|
|
1668
|
+
r.status === 0 && !r.failed
|
|
1669
|
+
&& (r.resourceType === 'fetch' || r.resourceType === 'eventsource')
|
|
1670
|
+
&& !baseline.has(r))
|
|
1671
|
+
}
|
|
1672
|
+
|
|
1673
|
+
// Main thread just busy? A long task (>50ms block) only dispatches to its observer AFTER it ends,
|
|
1674
|
+
// so longtasks.lastEndAt is "the main thread was blocking until at least here" (page-relative ms).
|
|
1675
|
+
// Freshness = the last long task ended within MAIN_THREAD_FRESH_MS of now → still grinding. This is
|
|
1676
|
+
// the third, fully app-agnostic signal: PerformanceObserver('longtask') is a browser native, needs
|
|
1677
|
+
// ZERO app cooperation (unlike domainBusy, which reads the app-injected __AIPEEK_SCREEN__ hook). It
|
|
1678
|
+
// covers the one gap the other two miss — a compute flow with NO network and NO domain field: an
|
|
1679
|
+
// IndexedDB query, a Worker round-trip, a heavy synchronous render pass. On a bare app with no hook
|
|
1680
|
+
// and no fetch, this is the only thing that still says "working".
|
|
1681
|
+
const MAIN_THREAD_FRESH_MS = 120
|
|
1682
|
+
function mainThreadBusy(): boolean {
|
|
1683
|
+
if (!perfBuffer) return false
|
|
1684
|
+
const last = perfBuffer.longtasks.lastEndAt
|
|
1685
|
+
if (!last) return false
|
|
1686
|
+
return performance.now() - last < MAIN_THREAD_FRESH_MS
|
|
1687
|
+
}
|
|
1688
|
+
|
|
1689
|
+
// True while an async flow is still running — the settle terminus for a flow. Three generic signals,
|
|
1690
|
+
// gapless together: (1) a NEW request in flight covers send→response-start, the false-silence gap
|
|
1691
|
+
// where the DOM is quiet but the first token hasn't arrived; (2) a busy domain field (流式中/isLoading…)
|
|
1692
|
+
// covers response-start→done, where the body streams without a fetch state change; (3) a fresh long
|
|
1693
|
+
// task covers a no-network, no-domain compute flow (IndexedDB / Worker / heavy render) that neither
|
|
1694
|
+
// (1) nor (2) can see. None hard-codes an app field. The fetch wrapper flips status to the real code
|
|
1695
|
+
// the moment headers arrive, so signal (1) is exactly the pre-stream window, not the whole stream.
|
|
1696
|
+
function flowInFlight(baseline: Set<NetworkRequest>): boolean {
|
|
1697
|
+
return domainBusy() || newInFlight(baseline) || mainThreadBusy()
|
|
1698
|
+
}
|
|
1699
|
+
|
|
1700
|
+
// onFrame: same MutationObserver, one extra exit. When the caller wants to trace an async flow
|
|
1701
|
+
// (send a message → think → stream → done), it passes onFrame; we sample screenSnap() on every
|
|
1702
|
+
// DOM-change burst — the trajectory's frames, each stamped with ms since the action began.
|
|
1703
|
+
//
|
|
1704
|
+
// The hard part is the settle TERMINUS for an async flow. "DOM quiet for `quiet`ms" is the right
|
|
1705
|
+
// terminus for a synchronous action, but WRONG mid-flow: after Enter the input clears (one DOM burst)
|
|
1706
|
+
// then the DOM falls silent for ~700ms while the network fetches the first token — a *false* rest. So
|
|
1707
|
+
// in flow mode the terminus is the STATE MACHINE, not the DOM: stable = DOM quiet ∧ ¬flowInFlight,
|
|
1708
|
+
// where flowInFlight = a busy domain field truthy (流式中…, covers the streaming phase) OR a new
|
|
1709
|
+
// request still open (covers send→first-token). A short CONFIRM window bridges action→fetch-dispatch.
|
|
1710
|
+
// A synchronous action passes no onFrame: none of this runs, the old DOM-quiet terminus holds.
|
|
1711
|
+
// The unified post-action terminal: settle the DOM, sample the async trajectory, return the
|
|
1712
|
+
// compact `changed` delta + `flow` trace. All three mutating paths (DOM action / knob / semantic)
|
|
1713
|
+
// share it — none re-implements frame collection. The caller snapshots `before` + the four buffers
|
|
1714
|
+
// and runs the mutator itself (performAction / executeKnob / fn()), then hands the post-mutation
|
|
1715
|
+
// settle here. `progress(id, kind?)` is the throttled server-deadline ping (so a multi-second flow
|
|
1716
|
+
// keeps the handler alive); a synchronous write just yields [before, after] → traceFlow returns []
|
|
1717
|
+
// → no flow line, zero overhead. One terminal, no second copy to drift (the knob path was a
|
|
1718
|
+
// byte-for-byte duplicate of the action path; the semantic path had none — both collapse here).
|
|
1719
|
+
async function settleAndTrace(
|
|
1720
|
+
before: ScreenSnap,
|
|
1721
|
+
beforeConsole: LogEntry[],
|
|
1722
|
+
beforeNetwork: NetworkRequest[],
|
|
1723
|
+
beforeErrors: ErrorEntry[],
|
|
1724
|
+
progress: () => void,
|
|
1725
|
+
actionKind = '',
|
|
1726
|
+
): Promise<{ changed: string, flow?: string }> {
|
|
1727
|
+
const frames: { snap: ScreenSnap, t: number }[] = [{ snap: before, t: 0 }]
|
|
1728
|
+
let lastPing = 0
|
|
1729
|
+
const ping = () => {
|
|
1730
|
+
const now = performance.now()
|
|
1731
|
+
if (now - lastPing < 750)
|
|
1732
|
+
return
|
|
1733
|
+
lastPing = now
|
|
1734
|
+
progress()
|
|
1735
|
+
}
|
|
1736
|
+
// Baseline = requests already in flight BEFORE the mutator fired (from the pre-act buffer), so
|
|
1737
|
+
// the action's own synchronously-dispatched request counts as new-in-flight — the send→first-
|
|
1738
|
+
// token signal — not pre-existing.
|
|
1739
|
+
const preBaseline = new Set(beforeNetwork.filter(r => r.status === 0 && !r.failed))
|
|
1740
|
+
await waitForStable(50, 2000, (snap, t) => {
|
|
1741
|
+
frames.push({ snap, t })
|
|
1742
|
+
ping()
|
|
1743
|
+
}, ping, preBaseline, actionKind)
|
|
1744
|
+
const after = screenSnap()
|
|
1745
|
+
// after is the post-settle snap; the last onFrame fired ~quiet ms before this, so any final
|
|
1746
|
+
// domain move is usually already captured. Append after at the last frame's time (its diff vs
|
|
1747
|
+
// that frame is empty when they agree → traceFlow skips it, no dup line).
|
|
1748
|
+
frames.push({ snap: after, t: frames[frames.length - 1].t })
|
|
1749
|
+
const d = diffState(
|
|
1750
|
+
{ ui: '', console: beforeConsole, network: beforeNetwork, errors: beforeErrors, state: {}, url: '', timestamp: 0 },
|
|
1751
|
+
{ ui: '', console: [...consoleLogs], network: [...networkRequests], errors: [...errorEntries], state: {}, url: '', timestamp: 0 },
|
|
1752
|
+
)
|
|
1753
|
+
const changed = diffScreen(before, after, d.newErrors, d.newExceptions, d.newFailedRequests)
|
|
1754
|
+
const flow = traceFlow(frames)
|
|
1755
|
+
return { changed: changed.length ? changed.join('\n') : '(no state change)', flow: flow.length ? flow.join('\n') : undefined }
|
|
1756
|
+
}
|
|
1757
|
+
|
|
1758
|
+
function waitForStable(quiet = 50, timeout = 2000, onFrame?: (snap: ScreenSnap, t: number) => void, onProgress?: () => void, preBaseline?: Set<NetworkRequest>, actionKind = ''): Promise<string> {
|
|
1759
|
+
return new Promise((resolve) => {
|
|
1760
|
+
const start = performance.now()
|
|
1761
|
+
// Two caps: the normal short bound until a flow is actually confirmed, and a long bound once
|
|
1762
|
+
// one is (a real stream can run tens of seconds). Without this split, a busy-but-flowless DOM
|
|
1763
|
+
// (a settings dialog's spinner mutating forever) would hold every action open for the long cap
|
|
1764
|
+
// and blow the server deadline. flowSeen latches true the first time flowInFlight reads true.
|
|
1765
|
+
const SHORT_CAP = timeout
|
|
1766
|
+
const LONG_CAP = 30000
|
|
1767
|
+
let flowSeen = false
|
|
1768
|
+
const cap = () => (onFrame && flowSeen ? LONG_CAP : SHORT_CAP)
|
|
1769
|
+
// A busy domain field that has gone true→false = a flow that ran and FINISHED. The long
|
|
1770
|
+
// CONFIRM_PREP window exists only to wait out an async prep period whose fetch hasn't dispatched
|
|
1771
|
+
// yet (unobservable: no request, no busy field, idle on I/O). Once a busy field has opened and
|
|
1772
|
+
// closed, that premise is falsified — the flow is over, not pending — so the confirm window can
|
|
1773
|
+
// collapse back to BASE and return promptly instead of taxing the full PREP wait. This precisely
|
|
1774
|
+
// excludes the send-prep case: at input-clear time 流式中 is still false (never opened), so a
|
|
1775
|
+
// pre-dispatch transition there does NOT latch this and the long window holds. Latched in the
|
|
1776
|
+
// observer/settle below by sampling domainBusy() each tick.
|
|
1777
|
+
let busyOpened = false
|
|
1778
|
+
let flowClosed = false
|
|
1779
|
+
// Requests already open when the action begins — excluded from the in-flight flow signal so a
|
|
1780
|
+
// persistent stream / long-poll doesn't keep us waiting. Only requests dispatched *by* this
|
|
1781
|
+
// action count toward "still in flight". The caller passes a baseline captured BEFORE the
|
|
1782
|
+
// action fired (preBaseline): an action's own request often dispatches synchronously during
|
|
1783
|
+
// act(), so a baseline captured here (after act) would already contain it and wrongly exclude
|
|
1784
|
+
// it — making the send→first-token in-flight window invisible. Fall back to now if absent.
|
|
1785
|
+
const baseline = preBaseline ?? new Set(networkRequests.filter(r => r.status === 0 && !r.failed))
|
|
1786
|
+
// Settle-confirm window: an action that triggers a flow dispatches its fetch a few ms after
|
|
1787
|
+
// the DOM settles. Hold a short window so a not-yet-issued request can register before we
|
|
1788
|
+
// call it done. Far cheaper than blindly waiting out a whole first-token delay — the real
|
|
1789
|
+
// first-token gap is covered by the in-flight fetch signal (flowInFlight), not by waiting.
|
|
1790
|
+
//
|
|
1791
|
+
// But the dispatch latency is NOT a fixed "few ms": an app may do async prep before the
|
|
1792
|
+
// request leaves — read history from IndexedDB, compress, compose the body — so the fetch
|
|
1793
|
+
// lands a second or more after the DOM fell quiet, with NOTHING observable in between (no
|
|
1794
|
+
// request yet, no busy field yet, the main thread idle on async I/O). A flat 400ms confirm
|
|
1795
|
+
// misses those flows; a flat 2.5s confirm would tax EVERY sync action by 2.5s. The generic
|
|
1796
|
+
// tell that "a flow is being prepared" is a long task fired since the action began — the
|
|
1797
|
+
// prep's synchronous CPU burst (serialize/compress) shows up as one. It's browser-native and
|
|
1798
|
+
// app-agnostic (PerformanceObserver('longtask'), no app field). So the confirm window is
|
|
1799
|
+
// dynamic: CONFIRM_BASE for a sync action, CONFIRM_PREP once a post-action long task proves
|
|
1800
|
+
// prep is underway. A pure sync action (no long task, no follow-up) still returns at BASE.
|
|
1801
|
+
//
|
|
1802
|
+
// BASE is NOT a "wait for a possibly-slow flow" budget — that's PREP's job, gated on the
|
|
1803
|
+
// longtask precursor. BASE only has to outlast the gap between DOM-settle and a request that
|
|
1804
|
+
// dispatches a tick or two later (a .then()/microtask after the click handler), so the
|
|
1805
|
+
// in-flight signal can catch it. A synchronously-dispatched request is already visible to
|
|
1806
|
+
// newInFlight when settle begins (it fired inside act()'s call stack); only a deferred-by-a-
|
|
1807
|
+
// -microtask dispatch needs any wait at all, and that lands within ~100ms. 400ms was paying a
|
|
1808
|
+
// flow-sized tax on EVERY sync action (tab switch, panel toggle) that never dispatches anything
|
|
1809
|
+
// — the dominant case. 120ms covers the microtask-deferred dispatch and cuts the common sync
|
|
1810
|
+
// action from ~480ms to ~170ms. A real slow-prep flow is unaffected: it trips sawPrep → PREP.
|
|
1811
|
+
// hover and fill share a *timer-gated reveal* that lands after a sync action would return:
|
|
1812
|
+
// · hover — a dwell-gated tooltip/preview/menu fires from a setTimeout (~300-600ms) with
|
|
1813
|
+
// NO longtask and NO request until then (live: Wikipedia page-preview ~500ms dwell).
|
|
1814
|
+
// · fill — typing into a search/combobox box almost always kicks a *debounced* suggestion
|
|
1815
|
+
// fetch (~150ms debounce + XHR + render → dropdown lands ~300-500ms later). On exit-2 the
|
|
1816
|
+
// parachute probe never wraps window.fetch, so newInFlight is structurally dead on
|
|
1817
|
+
// external sites — the reveal is invisible to the network signal and arrives as a pure
|
|
1818
|
+
// DOM mutation after the debounce. With a 120ms base, settle returns "(no state change)"
|
|
1819
|
+
// before the dropdown even fetches (live: Wikipedia autocomplete missed entirely),
|
|
1820
|
+
// forcing the agent to guess-and-/screen — the exact extra-roundtrip class hover fixed.
|
|
1821
|
+
// Both fire from a setTimeout, no CPU burst (sawPrep stays false) and no observable request,
|
|
1822
|
+
// so neither BASE nor PREP would otherwise extend the window. A base long enough to outlast
|
|
1823
|
+
// the timer to its FIRST mutation closes both; once that fires the quiet-loop takes over.
|
|
1824
|
+
// Matches the wait to the action's known semantics — no tax on click/press.
|
|
1825
|
+
const CONFIRM_BASE = actionKind === 'hover' || actionKind === 'fill' ? 700 : 120
|
|
1826
|
+
const CONFIRM_PREP = 2500
|
|
1827
|
+
// A long task whose end is after this settle began = the action kicked off CPU work → likely
|
|
1828
|
+
// preparing an async request. lastEndAt is page-relative ms, same clock as `start`.
|
|
1829
|
+
const sawPrep = () => !!perfBuffer && perfBuffer.longtasks.lastEndAt > start
|
|
1830
|
+
// PREP only while a flow might still be PENDING. Once a busy field has opened and closed
|
|
1831
|
+
// (flowClosed), the flow already finished — collapse to BASE so a fast async op (open→fetch→
|
|
1832
|
+
// loading:false in 165ms) returns promptly instead of taxing the full 2.5s prep wait.
|
|
1833
|
+
const trackBusy = () => {
|
|
1834
|
+
if (!onFrame) return
|
|
1835
|
+
if (domainBusy()) busyOpened = true
|
|
1836
|
+
else if (busyOpened) flowClosed = true
|
|
1837
|
+
}
|
|
1838
|
+
const confirmWindow = () => (onFrame && sawPrep() && !flowClosed ? CONFIRM_PREP : CONFIRM_BASE)
|
|
1839
|
+
let timer = 0
|
|
1840
|
+
let observer: MutationObserver
|
|
1841
|
+
const elapsed = () => performance.now() - start
|
|
1842
|
+
const done = () => {
|
|
1843
|
+
observer.disconnect()
|
|
1844
|
+
clearTimeout(timer)
|
|
1845
|
+
resolve(collectUI())
|
|
1846
|
+
}
|
|
1847
|
+
// Called when the DOM has been quiet for `quiet`ms. In flow mode this is a *candidate*
|
|
1848
|
+
// terminus, vetoed while the state machine is busy, a request is in flight, or we're still
|
|
1849
|
+
// inside the brief confirm window that lets a just-dispatched request appear.
|
|
1850
|
+
const settle = () => {
|
|
1851
|
+
// sawPrep latches flowSeen: a post-action long task is evidence a flow is being prepared,
|
|
1852
|
+
// so the cap must already be the long one (else SHORT_CAP=2000 would kill a >2s prep before
|
|
1853
|
+
// its fetch ever dispatches — the outer gate fires done() first). Treat prep like in-flight
|
|
1854
|
+
// for cap/ping purposes; the confirmWindow below still bounds how long we wait for nothing.
|
|
1855
|
+
if (onFrame && sawPrep())
|
|
1856
|
+
flowSeen = true
|
|
1857
|
+
trackBusy()
|
|
1858
|
+
if (onFrame && elapsed() < cap()) {
|
|
1859
|
+
if (flowInFlight(baseline)) {
|
|
1860
|
+
flowSeen = true
|
|
1861
|
+
// Work in flight (busy domain field truthy, or a fetch/stream still open). Poll
|
|
1862
|
+
// until it clears — a stream updates React state without a body mutation we'd see.
|
|
1863
|
+
// Ping each poll: a flow can wait seconds with no DOM mutation (network pending),
|
|
1864
|
+
// so the server's action deadline must be extended from here, not only on frames.
|
|
1865
|
+
onProgress?.()
|
|
1866
|
+
timer = window.setTimeout(settle, quiet)
|
|
1867
|
+
return
|
|
1868
|
+
}
|
|
1869
|
+
if (elapsed() < confirmWindow()) {
|
|
1870
|
+
// Nothing in flight yet, but a flow's request may be moments from dispatch — the
|
|
1871
|
+
// window stretches to CONFIRM_PREP if a long task already proved prep is underway.
|
|
1872
|
+
// Ping too: the prep wait itself can outlast the server's base deadline.
|
|
1873
|
+
onProgress?.()
|
|
1874
|
+
timer = window.setTimeout(settle, quiet)
|
|
1875
|
+
return
|
|
1876
|
+
}
|
|
1877
|
+
}
|
|
1878
|
+
done()
|
|
1879
|
+
}
|
|
1880
|
+
observer = new MutationObserver(() => {
|
|
1881
|
+
clearTimeout(timer)
|
|
1882
|
+
if (flowInFlight(baseline))
|
|
1883
|
+
flowSeen = true
|
|
1884
|
+
trackBusy()
|
|
1885
|
+
try {
|
|
1886
|
+
onFrame?.(screenSnap(), elapsed())
|
|
1887
|
+
}
|
|
1888
|
+
catch {}
|
|
1889
|
+
if (elapsed() > cap())
|
|
1890
|
+
done()
|
|
1891
|
+
else
|
|
1892
|
+
timer = window.setTimeout(settle, quiet)
|
|
1893
|
+
})
|
|
1894
|
+
observer.observe(document.body, { childList: true, subtree: true, attributes: true, characterData: true })
|
|
1895
|
+
timer = window.setTimeout(settle, quiet)
|
|
1896
|
+
})
|
|
1897
|
+
}
|
|
1898
|
+
|
|
1899
|
+
// Multi-tab: the HMR channel broadcasts to every open tab. Only the tab the user
|
|
1900
|
+
// is actually looking at should answer — otherwise server races N responses and
|
|
1901
|
+
// keeps a random one, while click/fill fire in *all* tabs. visibilityState is
|
|
1902
|
+
// 'visible' for exactly the focused tab (or single tab), 'hidden' for the rest.
|
|
1903
|
+
//
|
|
1904
|
+
// But when the user is reading the terminal, EVERY dev tab is hidden — then no one
|
|
1905
|
+
// would answer. So the server sends requireVisible=true first; if it times out with
|
|
1906
|
+
// no answer it retries requireVisible=false, and every tab answers (the original
|
|
1907
|
+
// race). `skip` collapses that into one guard: hidden tab skips only round one.
|
|
1908
|
+
function skip(msg?: { requireVisible?: boolean, tab?: string }) {
|
|
1909
|
+
if (msg?.tab) // addressed by id: only that tab answers, regardless of visibility (incl. background)
|
|
1910
|
+
return msg.tab !== TAB_ID
|
|
1911
|
+
return msg?.requireVisible !== false && document.visibilityState !== 'visible'
|
|
1912
|
+
}
|
|
1913
|
+
|
|
1914
|
+
// Click-to-source overlay. Hold Alt or Ctrl and the element under the cursor gets a highlight
|
|
1915
|
+
// box + a `file:line` label; click opens it in the editor via GET /__aihand/open. Reads the
|
|
1916
|
+
// raw data-insp-path the source-loc babel plugin stamps on host elements — the same attribute
|
|
1917
|
+
// /dom and /screen consume. This is the human-facing half of aihand (the AI never holds a
|
|
1918
|
+
// modifier, so its synthetic clicks never trigger this).
|
|
1919
|
+
function installClickToSource() {
|
|
1920
|
+
// HMR re-runs this module; without a guard each reload adds another set of mousemove/click
|
|
1921
|
+
// listeners (and a fresh closure with its own card), so you'd see one overlay per reload —
|
|
1922
|
+
// the stale dark card + the new one. A window flag makes installation idempotent.
|
|
1923
|
+
const w = window as { __aihand_click_to_source__?: boolean }
|
|
1924
|
+
if (w.__aihand_click_to_source__)
|
|
1925
|
+
return
|
|
1926
|
+
w.__aihand_click_to_source__ = true
|
|
1927
|
+
|
|
1928
|
+
let root: HTMLDivElement | null = null
|
|
1929
|
+
let parts: Record<string, HTMLDivElement> = {}
|
|
1930
|
+
let card: HTMLDivElement | null = null
|
|
1931
|
+
let tip: Record<string, HTMLElement> = {}
|
|
1932
|
+
let armed = false
|
|
1933
|
+
let current: Element | null = null
|
|
1934
|
+
let pinned = false
|
|
1935
|
+
let ladder: LadderRung[] = []
|
|
1936
|
+
|
|
1937
|
+
// Chrome DevTools box-model palette: content / padding / margin in distinct tints.
|
|
1938
|
+
const MARGIN = 'rgba(246,178,107,0.66)'
|
|
1939
|
+
const PADDING = 'rgba(147,196,125,0.55)'
|
|
1940
|
+
const CONTENT = 'rgba(111,168,220,0.66)'
|
|
1941
|
+
|
|
1942
|
+
const nearestInsp = (el: Element | null): Element | null => {
|
|
1943
|
+
let n: Element | null = el
|
|
1944
|
+
while (n && n !== document.body) {
|
|
1945
|
+
if (n.getAttribute('data-insp-path'))
|
|
1946
|
+
return n
|
|
1947
|
+
n = n.parentElement
|
|
1948
|
+
}
|
|
1949
|
+
return null
|
|
1950
|
+
}
|
|
1951
|
+
|
|
1952
|
+
// Default jump target: the call site (first rung past the host with a distinct location) —
|
|
1953
|
+
// i.e. the component that wrote <ActionBtn/>, not ActionBtn's own internals. Fall back to 0.
|
|
1954
|
+
const defaultLevel = (l: LadderRung[]): number => {
|
|
1955
|
+
const cs = callSite(l)
|
|
1956
|
+
return cs ? l.indexOf(cs) : 0
|
|
1957
|
+
}
|
|
1958
|
+
|
|
1959
|
+
const openRung = async (rung: LadderRung | undefined) => {
|
|
1960
|
+
if (!rung?.raw)
|
|
1961
|
+
return
|
|
1962
|
+
// map transformed → source coords (cached) so the editor opens the real line, not the
|
|
1963
|
+
// inflated transformed one
|
|
1964
|
+
const [file, line, column] = (await mapToSource(rung.raw)).split(':')
|
|
1965
|
+
if (!file)
|
|
1966
|
+
return
|
|
1967
|
+
fetch(`/__aihand/open?file=${encodeURIComponent(file)}&line=${line || 1}&column=${column || 1}`)
|
|
1968
|
+
clear()
|
|
1969
|
+
}
|
|
1970
|
+
|
|
1971
|
+
// Freeze the popover: stop tracking hover, make the card clickable, and re-style the default
|
|
1972
|
+
// rung as the obvious primary target. Now you can release Alt and click any rung at leisure.
|
|
1973
|
+
const pin = () => {
|
|
1974
|
+
if (!card)
|
|
1975
|
+
return
|
|
1976
|
+
pinned = true
|
|
1977
|
+
card.style.pointerEvents = 'auto'
|
|
1978
|
+
if (tip.hint)
|
|
1979
|
+
tip.hint.textContent = 'click a row to open · esc to close'
|
|
1980
|
+
}
|
|
1981
|
+
|
|
1982
|
+
const row = (key: string, makeValue: () => HTMLElement[]) => {
|
|
1983
|
+
const r = document.createElement('div')
|
|
1984
|
+
r.style.cssText = 'display:flex;align-items:center;gap:8px;justify-content:space-between'
|
|
1985
|
+
const k = document.createElement('span')
|
|
1986
|
+
k.textContent = key
|
|
1987
|
+
k.style.cssText = 'color:#80868b'
|
|
1988
|
+
const v = document.createElement('span')
|
|
1989
|
+
v.style.cssText = 'display:flex;align-items:center;gap:5px;color:#202124;min-width:0'
|
|
1990
|
+
v.append(...makeValue())
|
|
1991
|
+
r.append(k, v)
|
|
1992
|
+
return r
|
|
1993
|
+
}
|
|
1994
|
+
|
|
1995
|
+
const ensure = () => {
|
|
1996
|
+
if (root)
|
|
1997
|
+
return
|
|
1998
|
+
root = document.createElement('div')
|
|
1999
|
+
root.style.cssText = 'position:fixed;inset:0;z-index:2147483646;pointer-events:none;overflow:hidden'
|
|
2000
|
+
// Order matters: margin (outermost) painted first, content (innermost) last on top.
|
|
2001
|
+
for (const k of ['mt', 'mr', 'mb', 'ml', 'pt', 'pr', 'pb', 'pl', 'content']) {
|
|
2002
|
+
const d = document.createElement('div')
|
|
2003
|
+
d.style.cssText = 'position:fixed;pointer-events:none'
|
|
2004
|
+
parts[k] = d
|
|
2005
|
+
root.append(d)
|
|
2006
|
+
}
|
|
2007
|
+
|
|
2008
|
+
card = document.createElement('div')
|
|
2009
|
+
// pointer-events:none while hovering (so it never blocks the cursor); pin() flips it to
|
|
2010
|
+
// auto so the frozen popover's rungs become clickable.
|
|
2011
|
+
card.style.cssText = 'position:fixed;z-index:2147483647;pointer-events:none;display:flex;flex-direction:column;gap:6px;min-width:360px;max-width:640px;background:#fff;color:#202124;font:11px/1.55 ui-monospace,SFMono-Regular,Menlo,monospace;padding:12px 14px;border:1px solid rgba(0,0,0,0.08);border-radius:8px;box-shadow:0 6px 24px rgba(0,0,0,.2)'
|
|
2012
|
+
|
|
2013
|
+
const header = document.createElement('div')
|
|
2014
|
+
header.style.cssText = 'display:flex;align-items:baseline;gap:10px;justify-content:space-between;margin-bottom:1px'
|
|
2015
|
+
tip.tag = document.createElement('span')
|
|
2016
|
+
tip.tag.style.cssText = 'font-weight:700;color:#9333ea;font-size:12px'
|
|
2017
|
+
tip.dim = document.createElement('span')
|
|
2018
|
+
tip.dim.style.cssText = 'color:#202124;white-space:nowrap'
|
|
2019
|
+
header.append(tip.tag, tip.dim)
|
|
2020
|
+
|
|
2021
|
+
tip.swatch = document.createElement('span')
|
|
2022
|
+
tip.swatch.style.cssText = 'width:12px;height:12px;border-radius:2px;border:1px solid rgba(0,0,0,0.25);flex:none'
|
|
2023
|
+
tip.color = document.createElement('span')
|
|
2024
|
+
const colorRow = row('Color', () => [tip.swatch, tip.color])
|
|
2025
|
+
|
|
2026
|
+
tip.font = document.createElement('span')
|
|
2027
|
+
tip.font.style.cssText = 'overflow:hidden;text-overflow:ellipsis;white-space:nowrap'
|
|
2028
|
+
const fontRow = row('Font', () => [tip.font])
|
|
2029
|
+
|
|
2030
|
+
const divider = document.createElement('div')
|
|
2031
|
+
divider.style.cssText = 'height:1px;background:rgba(0,0,0,0.1);margin:4px 0'
|
|
2032
|
+
|
|
2033
|
+
// The owner ladder: leaf host at the bottom, each owner component above. Each rung is a
|
|
2034
|
+
// jump target — the number key picks it, the default (nearest component) is highlighted.
|
|
2035
|
+
tip.ladder = document.createElement('div')
|
|
2036
|
+
tip.ladder.style.cssText = 'display:flex;flex-direction:column;gap:2px'
|
|
2037
|
+
|
|
2038
|
+
tip.hint = document.createElement('div')
|
|
2039
|
+
tip.hint.textContent = '⌥click to pin, then pick a row'
|
|
2040
|
+
tip.hint.style.cssText = 'color:#9aa0a6;white-space:nowrap;margin-top:4px;font-size:11px'
|
|
2041
|
+
|
|
2042
|
+
card.append(header, colorRow, fontRow, divider, tip.ladder, tip.hint)
|
|
2043
|
+
document.body.append(root, card)
|
|
2044
|
+
}
|
|
2045
|
+
const clear = () => {
|
|
2046
|
+
root?.remove()
|
|
2047
|
+
card?.remove()
|
|
2048
|
+
root = card = current = null
|
|
2049
|
+
pinned = false
|
|
2050
|
+
ladder = []
|
|
2051
|
+
parts = {}
|
|
2052
|
+
tip = {}
|
|
2053
|
+
}
|
|
2054
|
+
|
|
2055
|
+
const place = (k: string, x: number, y: number, w: number, h: number, bg: string) => {
|
|
2056
|
+
const d = parts[k]
|
|
2057
|
+
d.style.cssText = `position:fixed;pointer-events:none;left:${x}px;top:${y}px;width:${w}px;height:${h}px;background:${bg}`
|
|
2058
|
+
}
|
|
2059
|
+
|
|
2060
|
+
const paint = (el: Element) => {
|
|
2061
|
+
ensure()
|
|
2062
|
+
const r = el.getBoundingClientRect()
|
|
2063
|
+
const cs = getComputedStyle(el)
|
|
2064
|
+
const m = { t: parseInt(cs.marginTop) || 0, r: parseInt(cs.marginRight) || 0, b: parseInt(cs.marginBottom) || 0, l: parseInt(cs.marginLeft) || 0 }
|
|
2065
|
+
const p = { t: parseInt(cs.paddingTop) || 0, r: parseInt(cs.paddingRight) || 0, b: parseInt(cs.paddingBottom) || 0, l: parseInt(cs.paddingLeft) || 0 }
|
|
2066
|
+
// Boxes in viewport coords (position:fixed → no scroll math). border-box = r.
|
|
2067
|
+
const mx = r.left - m.l, my = r.top - m.t, mw = r.width + m.l + m.r
|
|
2068
|
+
place('mt', mx, my, mw, m.t, MARGIN)
|
|
2069
|
+
place('mb', mx, r.bottom, mw, m.b, MARGIN)
|
|
2070
|
+
place('ml', mx, r.top, m.l, r.height, MARGIN)
|
|
2071
|
+
place('mr', r.right, r.top, m.r, r.height, MARGIN)
|
|
2072
|
+
place('pt', r.left, r.top, r.width, p.t, PADDING)
|
|
2073
|
+
place('pb', r.left, r.bottom - p.b, r.width, p.b, PADDING)
|
|
2074
|
+
place('pl', r.left, r.top + p.t, p.l, r.height - p.t - p.b, PADDING)
|
|
2075
|
+
place('pr', r.right - p.r, r.top + p.t, p.r, r.height - p.t - p.b, PADDING)
|
|
2076
|
+
place('content', r.left + p.l, r.top + p.t, r.width - p.l - p.r, r.height - p.t - p.b, CONTENT)
|
|
2077
|
+
|
|
2078
|
+
tip.tag.textContent = el.tagName.toLowerCase()
|
|
2079
|
+
tip.dim.textContent = `${Math.round(r.width)} × ${Math.round(r.height)}`
|
|
2080
|
+
const color = cs.color
|
|
2081
|
+
tip.swatch.style.background = color
|
|
2082
|
+
tip.color.textContent = color
|
|
2083
|
+
tip.font.textContent = `${cs.fontSize} ${cs.fontFamily.split(',')[0].replaceAll('"', '')}`
|
|
2084
|
+
|
|
2085
|
+
// Build the climbable ladder and render one clickable row per rung. Default jump =
|
|
2086
|
+
// nearest component (level 1) when there is one, else the host itself (level 0).
|
|
2087
|
+
ladder = ownerLadder(el)
|
|
2088
|
+
const def = defaultLevel(ladder)
|
|
2089
|
+
tip.ladder.textContent = ''
|
|
2090
|
+
ladder.forEach((rung, i) => {
|
|
2091
|
+
const r = document.createElement('div')
|
|
2092
|
+
const isDef = i === def
|
|
2093
|
+
const jumpable = !!rung.raw
|
|
2094
|
+
r.style.cssText = `display:flex;align-items:center;gap:8px;padding:3px 6px;border-radius:4px;${jumpable ? 'cursor:pointer;' : 'opacity:0.45;'}${isDef ? 'background:rgba(26,115,232,0.1)' : ''}`
|
|
2095
|
+
const num = document.createElement('span')
|
|
2096
|
+
num.textContent = String(i + 1)
|
|
2097
|
+
num.style.cssText = 'color:#9aa0a6;flex:none;width:14px;text-align:right'
|
|
2098
|
+
const nm = document.createElement('span')
|
|
2099
|
+
nm.textContent = rung.name
|
|
2100
|
+
nm.style.cssText = `flex:none;${i === 0 ? 'color:#9333ea' : 'color:#188038;font-weight:600'}`
|
|
2101
|
+
const loc = document.createElement('span')
|
|
2102
|
+
loc.textContent = fullInsp(rung.raw)
|
|
2103
|
+
loc.style.cssText = 'color:#1a73e8;overflow:hidden;text-overflow:ellipsis;white-space:nowrap'
|
|
2104
|
+
// rung.raw is in transformed coords (_debugStack); map it back to source via the
|
|
2105
|
+
// module's sourcemap for display. rung.raw stays immutable (transformed) — openRung
|
|
2106
|
+
// maps it again on click off the same cache, so there's no double-mapping.
|
|
2107
|
+
if (rung.raw) {
|
|
2108
|
+
mapToSource(rung.raw).then((mapped) => { loc.textContent = fullInsp(mapped) })
|
|
2109
|
+
}
|
|
2110
|
+
r.append(num, nm, loc)
|
|
2111
|
+
if (jumpable) {
|
|
2112
|
+
r.addEventListener('mouseenter', () => { r.style.background = 'rgba(26,115,232,0.18)' })
|
|
2113
|
+
r.addEventListener('mouseleave', () => { r.style.background = isDef ? 'rgba(26,115,232,0.1)' : '' })
|
|
2114
|
+
// mousedown not click: the global Alt+click handler also fires on click and would
|
|
2115
|
+
// double-jump. mousedown + stopPropagation claims the gesture here, on this rung.
|
|
2116
|
+
r.addEventListener('mousedown', (ev) => {
|
|
2117
|
+
ev.preventDefault()
|
|
2118
|
+
ev.stopPropagation()
|
|
2119
|
+
openRung(rung)
|
|
2120
|
+
})
|
|
2121
|
+
}
|
|
2122
|
+
tip.ladder.append(r)
|
|
2123
|
+
})
|
|
2124
|
+
|
|
2125
|
+
// Position the card just above the element, flipping below if it would clip the top.
|
|
2126
|
+
card!.style.left = `${Math.min(r.left, innerWidth - 348)}px`
|
|
2127
|
+
const cardH = card!.offsetHeight
|
|
2128
|
+
const above = my - cardH - 6
|
|
2129
|
+
card!.style.top = `${above < 4 ? r.bottom + m.b + 6 : above}px`
|
|
2130
|
+
}
|
|
2131
|
+
|
|
2132
|
+
|
|
2133
|
+
|
|
2134
|
+
addEventListener('mousemove', (e) => {
|
|
2135
|
+
// Once pinned, the popover is frozen — hover no longer repaints, so you can move the
|
|
2136
|
+
// mouse to a rung and click it without the target shifting under you.
|
|
2137
|
+
if (pinned)
|
|
2138
|
+
return
|
|
2139
|
+
armed = e.altKey && e.shiftKey
|
|
2140
|
+
if (!armed) {
|
|
2141
|
+
if (current)
|
|
2142
|
+
clear()
|
|
2143
|
+
return
|
|
2144
|
+
}
|
|
2145
|
+
const hit = nearestInsp(e.target as Element)
|
|
2146
|
+
if (!hit) {
|
|
2147
|
+
clear()
|
|
2148
|
+
return
|
|
2149
|
+
}
|
|
2150
|
+
if (hit !== current) {
|
|
2151
|
+
current = hit
|
|
2152
|
+
paint(hit)
|
|
2153
|
+
}
|
|
2154
|
+
}, true)
|
|
2155
|
+
|
|
2156
|
+
addEventListener('click', (e) => {
|
|
2157
|
+
// Pinned: a click is a selection. On a rung → jump there; anywhere else → dismiss.
|
|
2158
|
+
if (pinned) {
|
|
2159
|
+
if (card && card.contains(e.target as Node))
|
|
2160
|
+
return // a rung's own mousedown handled the jump
|
|
2161
|
+
e.preventDefault()
|
|
2162
|
+
e.stopPropagation()
|
|
2163
|
+
clear()
|
|
2164
|
+
return
|
|
2165
|
+
}
|
|
2166
|
+
// Unpinned: Alt+Shift-click pins the popover open (release the modifiers, then pick a rung).
|
|
2167
|
+
if (!(e.altKey && e.shiftKey))
|
|
2168
|
+
return
|
|
2169
|
+
const hit = nearestInsp(e.target as Element)
|
|
2170
|
+
if (!hit)
|
|
2171
|
+
return
|
|
2172
|
+
e.preventDefault()
|
|
2173
|
+
e.stopPropagation()
|
|
2174
|
+
if (hit !== current) {
|
|
2175
|
+
current = hit
|
|
2176
|
+
paint(hit)
|
|
2177
|
+
}
|
|
2178
|
+
pin()
|
|
2179
|
+
}, true)
|
|
2180
|
+
|
|
2181
|
+
// While the popover is open, number keys jump to that rung. macOS emits the Option-composed
|
|
2182
|
+
// glyph in e.key (Alt+1 → "¡"), so match e.code ("Digit1"/"Numpad1") which is layout-stable.
|
|
2183
|
+
addEventListener('keydown', (e) => {
|
|
2184
|
+
if (!current || !ladder.length)
|
|
2185
|
+
return
|
|
2186
|
+
if (e.key === 'Escape') {
|
|
2187
|
+
clear()
|
|
2188
|
+
return
|
|
2189
|
+
}
|
|
2190
|
+
const digit = e.code.startsWith('Digit') || e.code.startsWith('Numpad') ? e.code.slice(-1) : ''
|
|
2191
|
+
const n = digit >= '1' && digit <= '9' ? Number(digit) : 0
|
|
2192
|
+
if (n >= 1 && n <= ladder.length && ladder[n - 1].raw) {
|
|
2193
|
+
e.preventDefault()
|
|
2194
|
+
openRung(ladder[n - 1])
|
|
2195
|
+
}
|
|
2196
|
+
}, true)
|
|
2197
|
+
|
|
2198
|
+
// Only an unpinned (hover) overlay tracks the modifier; a pinned popover stays put.
|
|
2199
|
+
addEventListener('keyup', () => { if (current && !pinned) clear() })
|
|
2200
|
+
addEventListener('blur', () => { if (current && !pinned) clear() })
|
|
2201
|
+
}
|
|
2202
|
+
|
|
2203
|
+
// 探针体——transport 无关的消息处理器全集。出口1(Vite HMR)、出口2(CDP)各自把
|
|
2204
|
+
// 自己的双向通道包成 Transport 注入进来,体内 27 处 send 走的是同一个收敛点。返回 dispose。
|
|
2205
|
+
// 体内全部辅助(skip/TAB_ID/screenSnap/collectUI/settleAndTrace…)是模块作用域闭包,原样可用;
|
|
2206
|
+
// knobSem 也是模块级——出口1 shim 填,出口2 留空,knob-action 经它解析态射。
|
|
2207
|
+
export function installProbe(transport: Transport) {
|
|
2208
|
+
const hot = { send: transport.send }
|
|
2209
|
+
const disposers: Array<() => void> = []
|
|
2210
|
+
const onMsg = (event: string, handler: (...a: any[]) => void) => {
|
|
2211
|
+
disposers.push(transport.onMsg(event, handler))
|
|
2212
|
+
}
|
|
2213
|
+
|
|
2214
|
+
onMsg('aihand:collect', (msg: { requireVisible?: boolean, tab?: string }) => {
|
|
2215
|
+
if (skip(msg))
|
|
2216
|
+
return
|
|
2217
|
+
hot.send('aihand:state', {
|
|
2218
|
+
tab: TAB_ID,
|
|
2219
|
+
url: location.href,
|
|
2220
|
+
title: document.title,
|
|
2221
|
+
visible: document.visibilityState === 'visible',
|
|
2222
|
+
ui: collectUI(),
|
|
2223
|
+
console: [...consoleLogs],
|
|
2224
|
+
network: [...networkRequests],
|
|
2225
|
+
errors: [...errorEntries],
|
|
2226
|
+
actions: [...actionEntries],
|
|
2227
|
+
actionsDropped: buffers?.actionsDropped ?? 0,
|
|
2228
|
+
state: collectState(),
|
|
2229
|
+
performance: collectPerformance(),
|
|
2230
|
+
timestamp: Date.now(),
|
|
2231
|
+
})
|
|
2232
|
+
})
|
|
2233
|
+
|
|
2234
|
+
onMsg('aihand:action', async (msg: { id: number, type: ActionType, args: ActionArgs, requireVisible?: boolean, tab?: string }) => {
|
|
2235
|
+
if (skip(msg))
|
|
2236
|
+
return
|
|
2237
|
+
// Idempotency: the server re-broadcasts an addressed action while the tab is mid self-heal,
|
|
2238
|
+
// so the same id can land twice. Execute once per id; on a repeat replay the cached result
|
|
2239
|
+
// (re-settles the server's pendingActions slot if the original reply was lost in teardown).
|
|
2240
|
+
const done = (window.__AIPEEK_DONE_ACTIONS__ ??= new Map<number, ActionResult>())
|
|
2241
|
+
const cached = done.get(msg.id)
|
|
2242
|
+
if (cached) {
|
|
2243
|
+
hot.send('aihand:result', { tab: TAB_ID, id: msg.id, ...cached })
|
|
2244
|
+
return
|
|
2245
|
+
}
|
|
2246
|
+
// Snapshot the state machine + error/network buffers *before* the action, so the
|
|
2247
|
+
// post-settle diff is exactly what this action caused (see result.screen below).
|
|
2248
|
+
const before = screenSnap()
|
|
2249
|
+
const beforeConsole = [...consoleLogs]
|
|
2250
|
+
const beforeNetwork = [...networkRequests]
|
|
2251
|
+
const beforeErrors = [...errorEntries]
|
|
2252
|
+
// Guard against native alert/confirm/prompt freezing the probe (see withDialogGuard).
|
|
2253
|
+
const { result, dialogs } = await withDialogGuard(() => performAction(msg.type, msg.args))
|
|
2254
|
+
if (dialogs.length)
|
|
2255
|
+
result.detail = `${result.detail ?? ''} [auto-dismissed ${dialogs.join('; ')}]`.trim()
|
|
2256
|
+
// realclick resolved to (x,y) but didn't click — synthetic events can't open a Radix
|
|
2257
|
+
// ContextMenu. Fire a trusted click through whatever channel can: in Electron the page
|
|
2258
|
+
// can reach the main process via electronAPI.invoke('aihand:input') → sendInputEvent;
|
|
2259
|
+
// in a plain Chrome tab it can't (no chrome.debugger from page JS), so leave ui
|
|
2260
|
+
// undefined and let the server drive its extension queue.
|
|
2261
|
+
// realclick resolved (x,y) but synthetic events can't open a Radix ContextMenu — fire
|
|
2262
|
+
// a trusted click via Electron's main process if reachable, else leave it for the
|
|
2263
|
+
// server's extension queue. Either path then settles like any other mutating action.
|
|
2264
|
+
const electronAPI = (window as { electronAPI?: { invoke: (channel: string, ...args: unknown[]) => Promise<unknown> } }).electronAPI
|
|
2265
|
+
if (msg.type === 'realclick' && result.ok && electronAPI) {
|
|
2266
|
+
await electronAPI.invoke('aihand:input', { type: 'click', button: msg.args.button ?? 'left', x: result.x, y: result.y })
|
|
2267
|
+
result.fired = true // server: trusted click already fired in-process, skip the extension queue
|
|
2268
|
+
}
|
|
2269
|
+
|
|
2270
|
+
// For mutating actions, settle the DOM then ship *only the change this action caused*
|
|
2271
|
+
// (state-machine transition + new errors/failed requests), not a fresh snapshot. AI
|
|
2272
|
+
// reads the delta and drills into /ui or /dom for detail if needed. The timeline still
|
|
2273
|
+
// surfaces concurrent user actions. hover counts: its whole point is revealing a
|
|
2274
|
+
// menu/tooltip — what it surfaces is invisible without the delta, forcing an extra
|
|
2275
|
+
// /screen otherwise (same forced-roundtrip class as click's nav back-fill).
|
|
2276
|
+
const mutated = msg.type === 'click' || msg.type === 'fill' || msg.type === 'press'
|
|
2277
|
+
|| msg.type === 'hover'
|
|
2278
|
+
|| (msg.type === 'realclick' && !!electronAPI)
|
|
2279
|
+
if (result.ok && mutated) {
|
|
2280
|
+
const { changed, flow } = await settleAndTrace(before, beforeConsole, beforeNetwork, beforeErrors,
|
|
2281
|
+
() => hot.send('aihand:action-progress', { tab: TAB_ID, id: msg.id }), msg.type)
|
|
2282
|
+
result.screen = changed
|
|
2283
|
+
if (flow)
|
|
2284
|
+
result.flow = flow
|
|
2285
|
+
result.actions = attachTimeline()
|
|
2286
|
+
}
|
|
2287
|
+
done.set(msg.id, result)
|
|
2288
|
+
if (done.size > 64)
|
|
2289
|
+
done.delete(done.keys().next().value!)
|
|
2290
|
+
hot.send('aihand:result', { tab: TAB_ID, id: msg.id, ...result })
|
|
2291
|
+
})
|
|
2292
|
+
|
|
2293
|
+
// eval: run server-supplied code in the page with auto-return (see runEval).
|
|
2294
|
+
onMsg('aihand:eval', async (msg: { id: number, code: string, requireVisible?: boolean, tab?: string }) => {
|
|
2295
|
+
if (skip(msg))
|
|
2296
|
+
return
|
|
2297
|
+
const { ok, value, error, hint } = await runEval(msg.code)
|
|
2298
|
+
hot.send('aihand:eval-result', { tab: TAB_ID, id: msg.id, ok, value, error, hint })
|
|
2299
|
+
})
|
|
2300
|
+
|
|
2301
|
+
// semantic-action: the green channel. The app mounts named semantic functions on
|
|
2302
|
+
// window.__AIHAND_ACTIONS__ (e.g. sendPrompt) — each is the very (store-write + real
|
|
2303
|
+
// semantic fn) the button's onClick runs, minus the DOM. Calling it returns the act()
|
|
2304
|
+
// delta (operation IS observation), so there's no separate /screen round-trip. Unlike
|
|
2305
|
+
// /click it skips selector-resolution and disabled/covered gating entirely. A missing
|
|
2306
|
+
// action name or a throw comes back as ok:false — the app declares which actions exist.
|
|
2307
|
+
onMsg('aihand:semantic-action', async (msg: { id: number, name: string, args?: unknown[], requireVisible?: boolean, tab?: string }) => {
|
|
2308
|
+
if (skip(msg))
|
|
2309
|
+
return
|
|
2310
|
+
const actions = window.__AIHAND_ACTIONS__
|
|
2311
|
+
const fn = actions?.[msg.name]
|
|
2312
|
+
if (!fn) {
|
|
2313
|
+
const available = actions ? Object.keys(actions).join(', ') : '(app mounted no __AIHAND_ACTIONS__)'
|
|
2314
|
+
hot.send('aihand:semantic-result', { tab: TAB_ID, id: msg.id, ok: false, error: `no semantic action "${msg.name}" — available: ${available || '(none)'}` })
|
|
2315
|
+
return
|
|
2316
|
+
}
|
|
2317
|
+
// Same terminal as click/knob. A semantic action IS the store-write + real semantic fn a
|
|
2318
|
+
// button's onClick runs (sendPrompt = set input + generateWithPrompt) — the prototypical
|
|
2319
|
+
// async flow (send → think → stream → done). Snapshot before, run the fn, then settleAndTrace
|
|
2320
|
+
// samples the full A→Δt→B→Δt→C trajectory. Previously this path bypassed settle entirely and
|
|
2321
|
+
// returned the fn's own return value, so an async flow collapsed to whatever delta the fn had
|
|
2322
|
+
// already computed at first await — the stream was invisible. Now the feedback axis covers
|
|
2323
|
+
// every mutating path identically; no action type gets a lesser feedback contract.
|
|
2324
|
+
// Arity guard: fn.length is the count of required params (defaulted/rest params don't
|
|
2325
|
+
// count), so args.length < fn.length is a strict under-call — calling anyway feeds
|
|
2326
|
+
// undefined into the app and crashes downstream (e.g. sendPrompt(undefined) → split of
|
|
2327
|
+
// undefined). Refine that dead-end back to the recoverable cause: name the missing args
|
|
2328
|
+
// before invoking. Never over-fires — optional params keep fn.length low. (Wrong param
|
|
2329
|
+
// NAME like ?arg= lands here too: it's dropped at the HTTP layer, so args stays [].)
|
|
2330
|
+
const argv = msg.args ?? []
|
|
2331
|
+
if (argv.length < fn.length) {
|
|
2332
|
+
hot.send('aihand:semantic-result', { tab: TAB_ID, id: msg.id, ok: false, error: `"${msg.name}" needs ${fn.length} arg(s), got ${argv.length}. pass args=<JSON array>, e.g. args=["…"]` })
|
|
2333
|
+
return
|
|
2334
|
+
}
|
|
2335
|
+
const before = screenSnap()
|
|
2336
|
+
const beforeConsole = [...consoleLogs]
|
|
2337
|
+
const beforeNetwork = [...networkRequests]
|
|
2338
|
+
const beforeErrors = [...errorEntries]
|
|
2339
|
+
try {
|
|
2340
|
+
await fn(...argv)
|
|
2341
|
+
}
|
|
2342
|
+
catch (e) {
|
|
2343
|
+
hot.send('aihand:semantic-result', { tab: TAB_ID, id: msg.id, ok: false, error: e instanceof Error ? e.message : String(e) })
|
|
2344
|
+
return
|
|
2345
|
+
}
|
|
2346
|
+
const { changed, flow } = await settleAndTrace(before, beforeConsole, beforeNetwork, beforeErrors,
|
|
2347
|
+
() => hot.send('aihand:action-progress', { tab: TAB_ID, id: msg.id, kind: 'semantic' }))
|
|
2348
|
+
const parts = [changed]
|
|
2349
|
+
if (flow)
|
|
2350
|
+
parts.push(`--- flow ---\n${flow}`)
|
|
2351
|
+
hot.send('aihand:semantic-result', { tab: TAB_ID, id: msg.id, ok: true, value: parts.join('\n') })
|
|
2352
|
+
})
|
|
2353
|
+
|
|
2354
|
+
// knob-action: the god-tier green channel. No hand-mounting — the app exposes only its store
|
|
2355
|
+
// instances (__AIPEEK_STORES__); we resolve the knob's morphism from the build-time schema
|
|
2356
|
+
// (knobSem) and write the store directly via executeKnob. Then we sample the resulting
|
|
2357
|
+
// trajectory exactly like aihand:action does (before-frame + onFrame bursts → diffScreen +
|
|
2358
|
+
// traceFlow), returning the compact `--- changed ---` delta, never a full state dump. A
|
|
2359
|
+
// context-dependent morphism (executable:false) or an ambiguous/unknown label throws → ok:false
|
|
2360
|
+
// with the reason; the channel never fake-replays.
|
|
2361
|
+
onMsg('aihand:knob-action', async (msg: { id: number, knob: string, file?: string, value?: string, requireVisible?: boolean, tab?: string }) => {
|
|
2362
|
+
if (skip(msg))
|
|
2363
|
+
return
|
|
2364
|
+
const fail = (error: string) => hot.send('aihand:knob-result', { tab: TAB_ID, id: msg.id, ok: false, error })
|
|
2365
|
+
|
|
2366
|
+
// @kN 可寻址 ref → 投影:translate(@kN→label,号取自最近一次 /screen 的 __aihandKnobRefs)+ 消歧
|
|
2367
|
+
// (leaf/callsite 两候选试一遍)都在 resolveKnobRef 一处。非 @k 形(直传 label)原样走老路。
|
|
2368
|
+
const refs = (window as any).__aihandKnobRefs as Record<string, { label: string, files: string[] }> | undefined
|
|
2369
|
+
const resolved = resolveKnobRef(knobSem, msg.knob, refs, msg.file)
|
|
2370
|
+
if (!resolved.ok) {
|
|
2371
|
+
fail(resolved.error)
|
|
2372
|
+
return
|
|
2373
|
+
}
|
|
2374
|
+
const stores = window.__AIPEEK_STORES__ as Record<string, any> | undefined
|
|
2375
|
+
if (!stores) {
|
|
2376
|
+
fail('app mounted no __AIPEEK_STORES__ — cannot replay a store morphism')
|
|
2377
|
+
return
|
|
2378
|
+
}
|
|
2379
|
+
|
|
2380
|
+
const before = screenSnap()
|
|
2381
|
+
const beforeConsole = [...consoleLogs]
|
|
2382
|
+
const beforeNetwork = [...networkRequests]
|
|
2383
|
+
const beforeErrors = [...errorEntries]
|
|
2384
|
+
try {
|
|
2385
|
+
executeKnob(resolved.proj, msg.value === undefined ? undefined : parseLiteral(msg.value), stores)
|
|
2386
|
+
}
|
|
2387
|
+
catch (e) {
|
|
2388
|
+
fail(e instanceof Error ? e.message : String(e))
|
|
2389
|
+
return
|
|
2390
|
+
}
|
|
2391
|
+
|
|
2392
|
+
// Same terminal as aihand:action — a knob may kick off an async flow (mode switch that
|
|
2393
|
+
// lazy-loads, a toggle that fetches), so settleAndTrace samples the trajectory and pings to
|
|
2394
|
+
// extend the server deadline; a synchronous write just yields [before, after] → no flow.
|
|
2395
|
+
const { changed, flow } = await settleAndTrace(before, beforeConsole, beforeNetwork, beforeErrors,
|
|
2396
|
+
() => hot.send('aihand:action-progress', { tab: TAB_ID, id: msg.id, kind: 'knob' }))
|
|
2397
|
+
const parts = [changed]
|
|
2398
|
+
if (flow)
|
|
2399
|
+
parts.push(`--- flow ---\n${flow}`)
|
|
2400
|
+
hot.send('aihand:knob-result', { tab: TAB_ID, id: msg.id, ok: true, value: parts.join('\n') })
|
|
2401
|
+
})
|
|
2402
|
+
|
|
2403
|
+
onMsg('aihand:collect-dom', (msg: { scope?: string, sel?: string, requireVisible?: boolean, tab?: string }) => {
|
|
2404
|
+
if (skip(msg))
|
|
2405
|
+
return
|
|
2406
|
+
hot.send('aihand:dom', { tab: TAB_ID, dom: collectDom(msg?.scope, msg?.sel) })
|
|
2407
|
+
})
|
|
2408
|
+
|
|
2409
|
+
onMsg('aihand:collect-state-path', (msg: { path: string, requireVisible?: boolean, tab?: string }) => {
|
|
2410
|
+
if (skip(msg))
|
|
2411
|
+
return
|
|
2412
|
+
hot.send('aihand:state-path', { tab: TAB_ID, ...collectStatePath(msg.path) })
|
|
2413
|
+
})
|
|
2414
|
+
|
|
2415
|
+
onMsg('aihand:collect-screen', (msg: { requireVisible?: boolean, tab?: string, form?: 'tree' | 'visual' | 'knobs' | 'read' }) => {
|
|
2416
|
+
if (skip(msg))
|
|
2417
|
+
return
|
|
2418
|
+
// Ship the rendered text *and* the structured snap + buffers, so the server can serve
|
|
2419
|
+
// /screen?since=<token> by diffing this snap against a stashed prior one (diffScreen).
|
|
2420
|
+
hot.send('aihand:screen', {
|
|
2421
|
+
tab: TAB_ID,
|
|
2422
|
+
screen: collectScreen(),
|
|
2423
|
+
canvas: collectCanvas(msg?.form),
|
|
2424
|
+
snap: screenSnap(),
|
|
2425
|
+
console: [...consoleLogs],
|
|
2426
|
+
network: [...networkRequests],
|
|
2427
|
+
errors: [...errorEntries],
|
|
2428
|
+
})
|
|
2429
|
+
})
|
|
2430
|
+
|
|
2431
|
+
// Perf profiler handlers
|
|
2432
|
+
onMsg('aihand:perf-reset', (msg: { requireVisible?: boolean, tab?: string }) => {
|
|
2433
|
+
if (skip(msg))
|
|
2434
|
+
return
|
|
2435
|
+
resetPerf()
|
|
2436
|
+
hot.send('aihand:perf-reset-ack', { tab: TAB_ID })
|
|
2437
|
+
})
|
|
2438
|
+
|
|
2439
|
+
return () => {
|
|
2440
|
+
for (const off of disposers) off()
|
|
2441
|
+
}
|
|
2442
|
+
}
|
|
2443
|
+
|
|
2444
|
+
// 出口1(Vite dev server)适配:把 HMR WebSocket 包成 Transport 注入 installProbe。
|
|
2445
|
+
// Vite 专属的一切——hello 报到、HMR socket 自愈、virtual:aihand-knobs 拉旋钮投影、
|
|
2446
|
+
// import.meta.hot.accept() 字面量——全留这里,绝不下沉进 installProbe(对偶出口2 没有 HMR)。
|
|
2447
|
+
if (import.meta.hot) {
|
|
2448
|
+
const hot = import.meta.hot
|
|
2449
|
+
installClickToSource()
|
|
2450
|
+
|
|
2451
|
+
const transport: Transport = {
|
|
2452
|
+
send: (event, payload) => hot.send(event, payload),
|
|
2453
|
+
onMsg: (event, handler) => {
|
|
2454
|
+
hot.on(event, handler)
|
|
2455
|
+
return () => hot.off(event, handler)
|
|
2456
|
+
},
|
|
2457
|
+
}
|
|
2458
|
+
|
|
2459
|
+
// virtual:aihand-knobs:knobSchema() 插件长出的 build 时旋钮投影,填进模块级 knobSem
|
|
2460
|
+
// (lookupSem/collectKnobs 与 installProbe 共读同一份)。插件缺席时(测试/未注册)留空表。
|
|
2461
|
+
// @ts-expect-error virtual module only resolves when knobSchema() plugin is registered
|
|
2462
|
+
import('virtual:aihand-knobs').then(m => { knobSem = m.knobs }).catch(() => {})
|
|
2463
|
+
|
|
2464
|
+
// The probe is a side-effect-only module: it registers channel handlers + window listeners,
|
|
2465
|
+
// it exports nothing. A naive HMR re-run would STACK them (a second aihand:collect handler, a
|
|
2466
|
+
// second mousemove listener) — so historically this module was NOT self-accepting and every
|
|
2467
|
+
// edit triggered a FULL PAGE RELOAD, which wipes the app under test (mobx stores, scroll,
|
|
2468
|
+
// open dialogs). That reload is pure collateral: editing the probe shouldn't reset the page
|
|
2469
|
+
// it observes. The fix decouples the two — track every registration this run, tear them all
|
|
2470
|
+
// down in hot.dispose, then hot.accept so vite swaps the module in place with no reload.
|
|
2471
|
+
const probeDispose = installProbe(transport)
|
|
2472
|
+
|
|
2473
|
+
const disposers: Array<() => void> = [probeDispose]
|
|
2474
|
+
const onMsg = (event: string, handler: (...a: any[]) => void) => {
|
|
2475
|
+
hot.on(event, handler)
|
|
2476
|
+
disposers.push(() => hot.off(event, handler))
|
|
2477
|
+
}
|
|
2478
|
+
|
|
2479
|
+
// 主动报到:补上 roster 缺失的「注册边」。在此之前 roster 只在收到轮询回复时(aihand:state/
|
|
2480
|
+
// result/dom/screen)才登记一个 tab——于是会话里第一个命令若是 /tabs(纯 roster 读,自身不轮询),
|
|
2481
|
+
// 即使前台开着健康 tab 也必然读到 (no live tabs)。连上即 hello、visibilitychange 重发,roster
|
|
2482
|
+
// 实时为真。TAB_ID 存 sessionStorage 跨自愈 reload 存活,reload 后 hello 自然重放。
|
|
2483
|
+
const hello = () => hot.send('aihand:hello', {
|
|
2484
|
+
tab: TAB_ID,
|
|
2485
|
+
url: location.href,
|
|
2486
|
+
title: document.title,
|
|
2487
|
+
visible: document.visibilityState === 'visible',
|
|
2488
|
+
})
|
|
2489
|
+
// hot.send is fire-and-forget: a hello sent before the HMR socket is OPEN is silently dropped,
|
|
2490
|
+
// not queued. On a fresh load (especially the self-heal reload) this module often evaluates
|
|
2491
|
+
// before the socket finishes connecting — and `vite:ws:connect` only fires once, so if it
|
|
2492
|
+
// already fired before this listener attached, the connect-retry never runs either. The tab
|
|
2493
|
+
// then never registers (eval still works, but /tabs is blind) until a stray visibilitychange
|
|
2494
|
+
// happens to resend. So don't bet hello on one fire: resend on a short interval until the
|
|
2495
|
+
// server acknowledges via the roster (it echoes back over `aihand:hello-ack`), capped so a
|
|
2496
|
+
// genuinely-down server doesn't spin. Idempotent on the server (seen() just upserts the tab).
|
|
2497
|
+
let helloAcked = false
|
|
2498
|
+
onMsg('aihand:hello-ack', (msg: { tab?: string }) => { if (msg.tab === TAB_ID) helloAcked = true })
|
|
2499
|
+
let helloTries = 0
|
|
2500
|
+
const helloUntilAcked = () => {
|
|
2501
|
+
if (helloAcked || helloTries++ > 20) return
|
|
2502
|
+
hello()
|
|
2503
|
+
setTimeout(helloUntilAcked, 250)
|
|
2504
|
+
}
|
|
2505
|
+
helloUntilAcked()
|
|
2506
|
+
addEventListener('visibilitychange', hello)
|
|
2507
|
+
disposers.push(() => removeEventListener('visibilitychange', hello))
|
|
2508
|
+
|
|
2509
|
+
// Self-heal a dropped HMR socket. The whole action chain rides this WS; when it drops,
|
|
2510
|
+
// Vite's own reconnect (client.mjs waitForSuccessfulPing) is GATED on visibilityState ===
|
|
2511
|
+
// 'visible', so a backgrounded tab/window (Electron we app.hide() on launch) never reconnects
|
|
2512
|
+
// and never reloads — stranding the chain on a dead socket while BOOT_ID is unchanged.
|
|
2513
|
+
//
|
|
2514
|
+
// The fix is EVENT-DRIVEN, not polled. An earlier version set a flag for the client-patch
|
|
2515
|
+
// HTTP heartbeat to notice — but a long-lived background setInterval is exactly what the
|
|
2516
|
+
// browser throttles/freezes (verified: a page-load-time interval stops firing in a long-
|
|
2517
|
+
// backgrounded tab, while the WS message channel stays instantly live). So we act the moment
|
|
2518
|
+
// vite:ws:disconnect fires: kick off a short self-bound setTimeout poll (armed by the event,
|
|
2519
|
+
// not a resident timer) that waits for /ping to answer, then reloads to re-handshake. We also
|
|
2520
|
+
// keep the global flag for the heartbeat as a belt-and-suspenders second trigger.
|
|
2521
|
+
const wsDead = (v: boolean) => { (window as { __aihand_ws_dead__?: boolean }).__aihand_ws_dead__ = v }
|
|
2522
|
+
let healing = false
|
|
2523
|
+
const healAfterDrop = async () => {
|
|
2524
|
+
if (healing) return
|
|
2525
|
+
healing = true
|
|
2526
|
+
// Poll the HTTP heartbeat (independent of the dead WS). Once the server answers, reload.
|
|
2527
|
+
// setTimeout (re-armed per tick) rather than setInterval: a fresh timer scheduled right
|
|
2528
|
+
// after live activity resists the background freeze that kills resident intervals.
|
|
2529
|
+
const tick = async () => {
|
|
2530
|
+
try {
|
|
2531
|
+
await fetch('/__aihand/ping', { cache: 'no-store' })
|
|
2532
|
+
location.reload() // server reachable again → re-handshake the WS via a fresh load
|
|
2533
|
+
}
|
|
2534
|
+
catch {
|
|
2535
|
+
setTimeout(tick, 2000) // server still down — keep waiting, don't reload on failure
|
|
2536
|
+
}
|
|
2537
|
+
}
|
|
2538
|
+
tick()
|
|
2539
|
+
}
|
|
2540
|
+
onMsg('vite:ws:disconnect', () => { wsDead(true); healAfterDrop() })
|
|
2541
|
+
onMsg('vite:ws:connect', () => {
|
|
2542
|
+
wsDead(false)
|
|
2543
|
+
healing = false
|
|
2544
|
+
hello() // socket came back without a page reload — re-register so roster doesn't go stale
|
|
2545
|
+
})
|
|
2546
|
+
|
|
2547
|
+
// Self-accept + unwind: dispose runs before the next module instance evaluates, so the new
|
|
2548
|
+
// run re-registers fresh handlers onto a clean slate (no stacking), and vite swaps in place
|
|
2549
|
+
// instead of reloading the page. This is what makes editing the probe NOT reset the app
|
|
2550
|
+
// under test. (vite:ws self-heal still reloads on a real server restart — a different path.)
|
|
2551
|
+
//
|
|
2552
|
+
// The accept MUST be spelled `import.meta.hot.accept()` LITERALLY, not via the `hot` alias.
|
|
2553
|
+
// Vite detects self-acceptance by lexically scanning source for the token run
|
|
2554
|
+
// `import.meta.hot.accept` (importAnalysis: `source.slice(endHot, endHot+7) === ".accept"`);
|
|
2555
|
+
// an aliased `hot.accept()` is invisible to that scan, so the module stays
|
|
2556
|
+
// isSelfAccepting:false and every edit force-reloads the page (`!node.importers.size` path),
|
|
2557
|
+
// wiping the app under test. Keep this call literal.
|
|
2558
|
+
hot.dispose(() => {
|
|
2559
|
+
for (const off of disposers) off()
|
|
2560
|
+
})
|
|
2561
|
+
import.meta.hot.accept()
|
|
2562
|
+
}
|