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,899 @@
|
|
|
1
|
+
// aihand client patch — synchronous, non-module
|
|
2
|
+
// Injected as inline <script> at head-prepend to patch console/fetch/XHR/errors
|
|
3
|
+
// BEFORE any application modules execute.
|
|
4
|
+
|
|
5
|
+
import type { ActionEntry, ErrorEntry, LogEntry, NetworkRequest } from '../core/types'
|
|
6
|
+
|
|
7
|
+
type NetworkEntry = NetworkRequest
|
|
8
|
+
|
|
9
|
+
// --- Perf buffer (runtime accumulation; client.ts projects it into PerformanceData) ---
|
|
10
|
+
// Internal shape differs from core/types PerformanceData: buckets is a name→bucket map (not
|
|
11
|
+
// array), carries startedAt + active (the live view key). client.ts does the array/windowMs
|
|
12
|
+
// projection on collect.
|
|
13
|
+
interface PerfStatBuf { total: number, n: number, max: number }
|
|
14
|
+
interface PerfBucketBuf {
|
|
15
|
+
components: Record<string, PerfStatBuf>
|
|
16
|
+
frames: { total: number, long: number, max: number, samples: number[] }
|
|
17
|
+
lines: Record<string, PerfStatBuf> // line-level: "File.tsx:42:varName" → stat
|
|
18
|
+
}
|
|
19
|
+
interface PerfBuffer {
|
|
20
|
+
startedAt: number
|
|
21
|
+
hiddenFrames: number
|
|
22
|
+
mobxPatched: boolean
|
|
23
|
+
buckets: Record<string, PerfBucketBuf>
|
|
24
|
+
active: string
|
|
25
|
+
// lastEndAt: when the most recent long task FINISHED (startTime + duration, page-relative ms).
|
|
26
|
+
// A longtask entry only dispatches AFTER the task ends, so this is "the main thread was blocking
|
|
27
|
+
// until at least here". The flow terminus reads it as a third, app-agnostic busy signal: a
|
|
28
|
+
// no-network, no-DOM-mutation compute flow (IndexedDB, a Worker's sync hop, a heavy render pass)
|
|
29
|
+
// still shows up as long tasks while it grinds, so freshness here means "still working".
|
|
30
|
+
longtasks: { count: number, max: number, lastEndAt: number }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface AipeekBuffers {
|
|
34
|
+
consoleLogs: LogEntry[]
|
|
35
|
+
networkRequests: NetworkEntry[]
|
|
36
|
+
errorEntries: ErrorEntry[]
|
|
37
|
+
actionEntries: ActionEntry[]
|
|
38
|
+
perf: PerfBuffer
|
|
39
|
+
actionsDropped: number
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// --- Ring buffers (persist across HMR) ---
|
|
43
|
+
|
|
44
|
+
const MAX_CONSOLE = 100
|
|
45
|
+
const MAX_NETWORK = 50
|
|
46
|
+
const MAX_ERRORS = 20
|
|
47
|
+
const MAX_ACTIONS = 30
|
|
48
|
+
|
|
49
|
+
const BUFFERS_KEY = '__AIPEEK_BUFFERS__'
|
|
50
|
+
const existing = (window as any)[BUFFERS_KEY] as AipeekBuffers | undefined
|
|
51
|
+
const consoleLogs: LogEntry[] = existing?.consoleLogs ?? []
|
|
52
|
+
const networkRequests: NetworkEntry[] = existing?.networkRequests ?? []
|
|
53
|
+
const errorEntries: ErrorEntry[] = existing?.errorEntries ?? []
|
|
54
|
+
const actionEntries: ActionEntry[] = existing?.actionEntries ?? []
|
|
55
|
+
const perf: PerfBuffer = existing?.perf ?? {
|
|
56
|
+
startedAt: Date.now(),
|
|
57
|
+
hiddenFrames: 0,
|
|
58
|
+
mobxPatched: false,
|
|
59
|
+
buckets: {},
|
|
60
|
+
active: '__all__',
|
|
61
|
+
longtasks: { count: 0, max: 0, lastEndAt: 0 },
|
|
62
|
+
}
|
|
63
|
+
// Monotonic count of action entries the ring evicted from its head — the timeline is a causal
|
|
64
|
+
// chain, so a silently-cut head reads as "this is the start". Survives HMR via the existing buffer.
|
|
65
|
+
let actionsDropped = existing?.actionsDropped ?? 0
|
|
66
|
+
;(window as any)[BUFFERS_KEY] = { consoleLogs, networkRequests, errorEntries, actionEntries, perf, get actionsDropped() { return actionsDropped } }
|
|
67
|
+
|
|
68
|
+
const PERF_SAMPLE_CAP = 5000
|
|
69
|
+
|
|
70
|
+
function perfBucket(name: string): PerfBucketBuf {
|
|
71
|
+
let b = perf.buckets[name]
|
|
72
|
+
if (!b) {
|
|
73
|
+
b = { components: {}, frames: { total: 0, long: 0, max: 0, samples: [] }, lines: {} }
|
|
74
|
+
perf.buckets[name] = b
|
|
75
|
+
}
|
|
76
|
+
return b
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// The view bucket + the __all__ aggregate. When active IS __all__, return it once —
|
|
80
|
+
// else every hit double-counts into __all__ (n, total, frames all 2×).
|
|
81
|
+
function targetBuckets(): string[] {
|
|
82
|
+
return perf.active === '__all__' ? ['__all__'] : [perf.active, '__all__']
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// One timed hit (component render / line exec) → a stat map, accumulating total/n/max.
|
|
86
|
+
// No samples buffer: total/n/max are O(1) streaming all-time truths; p95 was the only sample
|
|
87
|
+
// consumer and is gone (near-window p95 vs all-time total = same-row lie). Dropping the per-hit
|
|
88
|
+
// push/shift also lightens the line-instrumentation hot path (less self-pollution).
|
|
89
|
+
function recordStat(map: Record<string, PerfStatBuf>, key: string, ms: number) {
|
|
90
|
+
let s = map[key]
|
|
91
|
+
if (!s) {
|
|
92
|
+
s = { total: 0, n: 0, max: 0 }
|
|
93
|
+
map[key] = s
|
|
94
|
+
}
|
|
95
|
+
s.total += ms
|
|
96
|
+
s.n += 1
|
|
97
|
+
if (ms > s.max)
|
|
98
|
+
s.max = ms
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// One component render's self-time → both its live view bucket and the __all__ aggregate.
|
|
102
|
+
function hitComponent(name: string, ms: number) {
|
|
103
|
+
for (const bucketName of targetBuckets())
|
|
104
|
+
recordStat(perfBucket(bucketName).components, name, ms)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// One line's execution time → both its live view bucket and the __all__ aggregate.
|
|
108
|
+
// Called by babel-inserted __line(label, fn) wrapper.
|
|
109
|
+
function hitLine(label: string, ms: number) {
|
|
110
|
+
for (const bucketName of targetBuckets())
|
|
111
|
+
recordStat(perfBucket(bucketName).lines, label, ms)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Runtime for babel plugin: wraps one statement, times it, records to perf.lines.
|
|
115
|
+
// Exposed globally so babel-transformed code can call it (client-patch can't be imported).
|
|
116
|
+
;(window as any).__line = function __line<T>(label: string, fn: () => T): T {
|
|
117
|
+
const start = performance.now()
|
|
118
|
+
try {
|
|
119
|
+
return fn()
|
|
120
|
+
}
|
|
121
|
+
finally {
|
|
122
|
+
hitLine(label, performance.now() - start)
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// One frame's dt → both live view bucket and __all__. long = dropped (>16.7ms ≈ missed 60fps).
|
|
127
|
+
function hitFrame(dt: number) {
|
|
128
|
+
for (const bucketName of targetBuckets()) {
|
|
129
|
+
const f = perfBucket(bucketName).frames
|
|
130
|
+
f.total += 1
|
|
131
|
+
if (dt > 16.7)
|
|
132
|
+
f.long += 1
|
|
133
|
+
if (dt > f.max)
|
|
134
|
+
f.max = dt
|
|
135
|
+
f.samples.push(dt)
|
|
136
|
+
if (f.samples.length > PERF_SAMPLE_CAP)
|
|
137
|
+
f.samples.shift()
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// (b) Frame loop — zero-coupling, holds for any app. Throttle guard: a backgrounded OR unfocused
|
|
142
|
+
// window throttles rAF to ~1fps, so each such frame looks like a ~1000ms "dropped" frame and lies.
|
|
143
|
+
// document.hidden only fires when the TAB is switched away; a window that's merely unfocused
|
|
144
|
+
// (another app has focus, minimized, fully occluded) keeps document.hidden=false yet the OS still
|
|
145
|
+
// throttles its rAF — same lie, different probe. document.hasFocus() catches that second case.
|
|
146
|
+
// Don't count dt while throttled; tally hiddenFrames so the report can flag it.
|
|
147
|
+
const WARN_THRESHOLD = 20 // dropped% — push warning when ≥20% frames jank
|
|
148
|
+
const WARN_COOLDOWN = 5000 // ms — don't spam, at most one warn per 5s
|
|
149
|
+
let lastFrame = performance.now()
|
|
150
|
+
let lastWarn = 0
|
|
151
|
+
function frameLoop() {
|
|
152
|
+
const now = performance.now()
|
|
153
|
+
if (document.hidden || !document.hasFocus()) {
|
|
154
|
+
perf.hiddenFrames += 1
|
|
155
|
+
lastFrame = now
|
|
156
|
+
requestAnimationFrame(frameLoop)
|
|
157
|
+
return
|
|
158
|
+
}
|
|
159
|
+
const dt = now - lastFrame
|
|
160
|
+
hitFrame(dt)
|
|
161
|
+
lastFrame = now
|
|
162
|
+
|
|
163
|
+
// Push mode: auto-warn on jank (≥20% dropped), once per 5s
|
|
164
|
+
const bucket = perf.buckets[perf.active]
|
|
165
|
+
if (bucket && bucket.frames.total > 20) { // need ≥20 frames for stable %
|
|
166
|
+
const droppedPct = (bucket.frames.long / bucket.frames.total) * 100
|
|
167
|
+
if (droppedPct >= WARN_THRESHOLD && now - lastWarn > WARN_COOLDOWN) {
|
|
168
|
+
const top3 = Object.entries(bucket.components)
|
|
169
|
+
.sort(([, a], [, b]) => b.total - a.total)
|
|
170
|
+
.slice(0, 3)
|
|
171
|
+
.map(([name, s]) => `${name} ${s.total.toFixed(0)}ms`)
|
|
172
|
+
.join(', ')
|
|
173
|
+
const hot = top3 ? ` · hot: ${top3}` : ''
|
|
174
|
+
console.warn(`[aihand] 🐌 ${droppedPct.toFixed(0)}% frames dropped${hot}`)
|
|
175
|
+
lastWarn = now
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
requestAnimationFrame(frameLoop)
|
|
180
|
+
}
|
|
181
|
+
requestAnimationFrame(frameLoop)
|
|
182
|
+
|
|
183
|
+
// (b) Longtask observer — main-thread blocks >50ms, app-agnostic.
|
|
184
|
+
try {
|
|
185
|
+
new PerformanceObserver((list) => {
|
|
186
|
+
for (const entry of list.getEntries()) {
|
|
187
|
+
perf.longtasks.count += 1
|
|
188
|
+
if (entry.duration > perf.longtasks.max)
|
|
189
|
+
perf.longtasks.max = entry.duration
|
|
190
|
+
const endAt = entry.startTime + entry.duration
|
|
191
|
+
if (endAt > perf.longtasks.lastEndAt)
|
|
192
|
+
perf.longtasks.lastEndAt = endAt
|
|
193
|
+
}
|
|
194
|
+
}).observe({ entryTypes: ['longtask'] })
|
|
195
|
+
}
|
|
196
|
+
catch { /* longtask not supported — frames + components still work */ }
|
|
197
|
+
|
|
198
|
+
// (a) Component self-time via MobX Reaction.track. client-patch can't import mobx, so the app
|
|
199
|
+
// exposes the live singleton at window.__AIPEEK_MOBX__={Reaction} (dev-only seam in src/lib/mobx.ts).
|
|
200
|
+
// mobx-react-lite names every component's Reaction "observer"+name, so name_.slice(8) is the
|
|
201
|
+
// component name. Patch is idempotent (track.__aihand flag) and survives HMR via the shared buffer.
|
|
202
|
+
function patchMobx() {
|
|
203
|
+
const mobx = (window as any).__AIPEEK_MOBX__
|
|
204
|
+
const Reaction = mobx?.Reaction
|
|
205
|
+
const track = Reaction?.prototype?.track
|
|
206
|
+
if (!track || (track as any).__aihand)
|
|
207
|
+
return
|
|
208
|
+
const patched = function (this: any, fn: () => void) {
|
|
209
|
+
const name: string = this.name_ ?? ''
|
|
210
|
+
if (name.indexOf('observer') !== 0)
|
|
211
|
+
return track.call(this, fn)
|
|
212
|
+
const start = performance.now()
|
|
213
|
+
try {
|
|
214
|
+
return track.call(this, fn)
|
|
215
|
+
}
|
|
216
|
+
finally {
|
|
217
|
+
hitComponent(name.slice(8) || '(anon)', performance.now() - start)
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
;(patched as any).__aihand = true
|
|
221
|
+
Reaction.prototype.track = patched
|
|
222
|
+
perf.mobxPatched = true
|
|
223
|
+
}
|
|
224
|
+
patchMobx()
|
|
225
|
+
// The seam may run after this patch (module order). Retry briefly so attribution turns on
|
|
226
|
+
// once src/lib/mobx.ts executes; stops as soon as it succeeds.
|
|
227
|
+
if (!perf.mobxPatched) {
|
|
228
|
+
let tries = 0
|
|
229
|
+
const retry = setInterval(() => {
|
|
230
|
+
patchMobx()
|
|
231
|
+
if (perf.mobxPatched || ++tries > 20)
|
|
232
|
+
clearInterval(retry)
|
|
233
|
+
}, 100)
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function pushBounded<T>(arr: T[], item: T, max: number) {
|
|
237
|
+
arr.push(item)
|
|
238
|
+
if (arr.length > max)
|
|
239
|
+
arr.shift()
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// --- Patch console ---
|
|
243
|
+
|
|
244
|
+
/* eslint-disable no-console -- patching console is the point of this block */
|
|
245
|
+
for (const level of ['log', 'info', 'warn', 'error', 'debug'] as const) {
|
|
246
|
+
const orig = console[level]
|
|
247
|
+
console[level] = (...args: unknown[]) => {
|
|
248
|
+
pushBounded(consoleLogs, {
|
|
249
|
+
level,
|
|
250
|
+
text: args.map(a => typeof a === 'string' ? a : formatValue(a)).join(' '),
|
|
251
|
+
timestamp: Date.now(),
|
|
252
|
+
}, MAX_CONSOLE)
|
|
253
|
+
orig.apply(console, args)
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
/* eslint-enable no-console */
|
|
257
|
+
|
|
258
|
+
// --- Patch fetch — 透明摄像头:tee body,主流原样返回,副流后台 capture ---
|
|
259
|
+
|
|
260
|
+
const originalFetch = window.fetch
|
|
261
|
+
|
|
262
|
+
// --- Self-heal handshake: reconnect after a dev-server restart without a human reload ---
|
|
263
|
+
// The whole aihand action chain rides the Vite HMR WebSocket. A full server restart kills
|
|
264
|
+
// that socket and Vite's reconnect often gives up — stranding the page on "connection lost".
|
|
265
|
+
// So we poll an HTTP heartbeat (independent of the WS) carrying the server's BOOT_ID; when it
|
|
266
|
+
// changes (server is a new process), the page reloads itself and re-handshakes the new server.
|
|
267
|
+
//
|
|
268
|
+
// The heartbeat MUST run in a Worker, not a main-thread setInterval. Browsers throttle
|
|
269
|
+
// main-thread timers in BACKGROUND tabs to once per minute (Chrome 88+) — and the aihand tab is
|
|
270
|
+
// almost always backgrounded (the operator is watching a terminal, not the page). A main-thread
|
|
271
|
+
// poll therefore took ~60s to notice a restart, forcing a manual reload. Worker timers are NOT
|
|
272
|
+
// throttled (throttling is main-thread only), so the Worker keeps a true ~2s heartbeat while
|
|
273
|
+
// backgrounded. The Worker only times + fetches + posts the BOOT_ID back; ALL decision logic
|
|
274
|
+
// (sessionStorage debounce, wsDead check, location.reload) stays on the main thread, where a
|
|
275
|
+
// Worker can't reach storage/location anyway.
|
|
276
|
+
;(function keepAlive() {
|
|
277
|
+
const SEEN_KEY = '__aihand_boot__'
|
|
278
|
+
const RELOADING_KEY = '__aihand_reloading__'
|
|
279
|
+
|
|
280
|
+
// Main-thread decision: given a freshly-fetched BOOT_ID, decide whether to reload+re-handshake.
|
|
281
|
+
// Two reload triggers, same fix (reload → re-handshake), both debounced by RELOADING_KEY:
|
|
282
|
+
// 1. BOOT_ID changed → server restarted (the original case).
|
|
283
|
+
// 2. BOOT_ID same but the HMR WebSocket is dead → the socket dropped (Electron window
|
|
284
|
+
// backgrounded/suspended) and Vite's reconnect is gated on visibility, so it never
|
|
285
|
+
// comes back on its own. The action chain rides that socket, so a dead WS = "can't
|
|
286
|
+
// see" even though the server is fine. client.ts sets this flag on vite:ws:disconnect.
|
|
287
|
+
const handleBoot = (id: string) => {
|
|
288
|
+
sessionStorage.removeItem(RELOADING_KEY) // ping succeeded — clear the reload debounce
|
|
289
|
+
const seen = sessionStorage.getItem(SEEN_KEY)
|
|
290
|
+
if (!seen) {
|
|
291
|
+
sessionStorage.setItem(SEEN_KEY, id) // first sighting — remember this server
|
|
292
|
+
return
|
|
293
|
+
}
|
|
294
|
+
const wsDead = (window as { __aihand_ws_dead__?: boolean }).__aihand_ws_dead__ === true
|
|
295
|
+
if (seen !== id || wsDead) {
|
|
296
|
+
sessionStorage.setItem(SEEN_KEY, id)
|
|
297
|
+
if (!sessionStorage.getItem(RELOADING_KEY)) {
|
|
298
|
+
sessionStorage.setItem(RELOADING_KEY, '1')
|
|
299
|
+
location.reload()
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// Heartbeat source: a Worker timer that survives background throttling. Its whole body is the
|
|
305
|
+
// poll — fetch the BOOT_ID, post it back; on failure post nothing (server not up yet → keep
|
|
306
|
+
// polling, never reload on a mere failure). Inlined as a Blob (client-patch can't import).
|
|
307
|
+
try {
|
|
308
|
+
if (typeof Worker === 'undefined')
|
|
309
|
+
throw new Error('no Worker')
|
|
310
|
+
// A Blob-URL Worker's baseURI is `blob:…`, not the page origin — a relative '/__aihand/ping'
|
|
311
|
+
// fails to parse there. So bake the absolute URL in from the main thread's location.origin.
|
|
312
|
+
const pingUrl = `${location.origin}/__aihand/ping`
|
|
313
|
+
const src = `setInterval(async () => {
|
|
314
|
+
try {
|
|
315
|
+
const r = await fetch(${JSON.stringify(pingUrl)}, { cache: 'no-store' })
|
|
316
|
+
postMessage(await r.text())
|
|
317
|
+
} catch (e) {}
|
|
318
|
+
}, 2000)`
|
|
319
|
+
const worker = new Worker(URL.createObjectURL(new Blob([src], { type: 'text/javascript' })))
|
|
320
|
+
worker.onmessage = e => handleBoot(e.data as string)
|
|
321
|
+
}
|
|
322
|
+
catch {
|
|
323
|
+
// No Worker (or Blob construction blocked) — fall back to a main-thread poll. Throttled in
|
|
324
|
+
// a background tab, but still self-heals eventually; never worse than before this change.
|
|
325
|
+
setInterval(async () => {
|
|
326
|
+
try {
|
|
327
|
+
handleBoot(await (await originalFetch('/__aihand/ping', { cache: 'no-store' })).text())
|
|
328
|
+
}
|
|
329
|
+
catch {}
|
|
330
|
+
}, 2000)
|
|
331
|
+
}
|
|
332
|
+
})()
|
|
333
|
+
|
|
334
|
+
// --- Restore-then-verify: a reload is only a heal if the page comes back rendered ---
|
|
335
|
+
// handleBoot reloads on a server restart, then unconditionally assumes the page is healthy.
|
|
336
|
+
// But a reload can land on a WHITE SCREEN — the new bundle threw on boot, the app mounted
|
|
337
|
+
// nothing into #root. The old heartbeat never notices (BOOT_ID is stable, WS may even connect),
|
|
338
|
+
// so the operator is stranded on a blank page that *looks* healed. Borrowing agent-browser's
|
|
339
|
+
// `--restore-check`: the restore action carries its own verification fiber — if the restored
|
|
340
|
+
// state isn't actually good, don't bank it as success. Here: after a heal-reload, confirm #root
|
|
341
|
+
// rendered; if still empty past a grace window, reload once more (bounded), never claim success.
|
|
342
|
+
// 元真理: one binary "did it render" check, no configurable assertion table — that's all it takes
|
|
343
|
+
// to stop a white screen from masquerading as healed.
|
|
344
|
+
;(function verifyRestore() {
|
|
345
|
+
const RELOADING_KEY = '__aihand_reloading__' // set by handleBoot right before its location.reload()
|
|
346
|
+
const RETRY_KEY = '__aihand_heal_retry__'
|
|
347
|
+
const MAX_RETRY = 2 // bounded: a genuinely-broken build must NOT reload-loop forever
|
|
348
|
+
if (!sessionStorage.getItem(RELOADING_KEY))
|
|
349
|
+
return // this load wasn't triggered by a self-heal reload — nothing to verify
|
|
350
|
+
const rendered = () => {
|
|
351
|
+
const root = document.getElementById('root') || document.querySelector('#app, [data-reactroot]')
|
|
352
|
+
return !!root && root.childElementCount > 0
|
|
353
|
+
}
|
|
354
|
+
// Grace window: the app needs a beat to mount. Check after the bundle has had time to run.
|
|
355
|
+
setTimeout(() => {
|
|
356
|
+
if (rendered()) {
|
|
357
|
+
sessionStorage.removeItem(RETRY_KEY) // healed for real — reset the retry budget
|
|
358
|
+
return
|
|
359
|
+
}
|
|
360
|
+
const tries = Number(sessionStorage.getItem(RETRY_KEY) || '0')
|
|
361
|
+
if (tries >= MAX_RETRY) {
|
|
362
|
+
// eslint-disable-next-line no-console
|
|
363
|
+
console.warn(`[aihand] self-heal reload left a white screen after ${tries} retries — build likely broken, stopping (not claiming healed)`)
|
|
364
|
+
return // don't reload-loop a broken bundle; leave it visibly broken, not falsely "healed"
|
|
365
|
+
}
|
|
366
|
+
sessionStorage.setItem(RETRY_KEY, String(tries + 1))
|
|
367
|
+
sessionStorage.setItem(RELOADING_KEY, '1') // keep the verify armed for the next attempt
|
|
368
|
+
location.reload()
|
|
369
|
+
}, 3000)
|
|
370
|
+
})()
|
|
371
|
+
|
|
372
|
+
window.fetch = async (input, init) => {
|
|
373
|
+
const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url
|
|
374
|
+
const method = init?.method || 'GET'
|
|
375
|
+
const start = performance.now()
|
|
376
|
+
|
|
377
|
+
// 立即推 entry,主线程持有同一引用,capture 完成后写回
|
|
378
|
+
const entry: NetworkEntry = {
|
|
379
|
+
method,
|
|
380
|
+
url,
|
|
381
|
+
status: 0,
|
|
382
|
+
duration: 0,
|
|
383
|
+
resourceType: 'fetch',
|
|
384
|
+
}
|
|
385
|
+
if (init?.headers) {
|
|
386
|
+
const rh: Record<string, string> = {}
|
|
387
|
+
new Headers(init.headers as HeadersInit).forEach((v, k) => {
|
|
388
|
+
rh[k] = v
|
|
389
|
+
})
|
|
390
|
+
entry.requestHeaders = rh
|
|
391
|
+
}
|
|
392
|
+
if (init?.body && typeof init.body === 'string') {
|
|
393
|
+
entry.requestBody = init.body.slice(0, 4000)
|
|
394
|
+
entry.requestSample = jsonSample(init.body)
|
|
395
|
+
}
|
|
396
|
+
pushBounded(networkRequests, entry, MAX_NETWORK)
|
|
397
|
+
|
|
398
|
+
let response: Response
|
|
399
|
+
try {
|
|
400
|
+
response = await originalFetch(input, init)
|
|
401
|
+
}
|
|
402
|
+
catch (err) {
|
|
403
|
+
entry.status = 0
|
|
404
|
+
entry.duration = Math.round(performance.now() - start)
|
|
405
|
+
entry.failed = true
|
|
406
|
+
entry.failureText = err instanceof Error ? err.message : String(err)
|
|
407
|
+
throw err
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
entry.status = response.status
|
|
411
|
+
entry.duration = Math.round(performance.now() - start)
|
|
412
|
+
const respHeaders: Record<string, string> = {}
|
|
413
|
+
response.headers.forEach((v, k) => {
|
|
414
|
+
respHeaders[k] = v
|
|
415
|
+
})
|
|
416
|
+
entry.responseHeaders = respHeaders
|
|
417
|
+
|
|
418
|
+
if (!response.body)
|
|
419
|
+
return response
|
|
420
|
+
|
|
421
|
+
// tee:主流原样给业务,副流后台读 4KB 做 capture
|
|
422
|
+
const [pass, peek] = response.body.tee()
|
|
423
|
+
captureBody(peek, entry).catch(() => {})
|
|
424
|
+
|
|
425
|
+
const proxied = new Response(pass, {
|
|
426
|
+
status: response.status,
|
|
427
|
+
statusText: response.statusText,
|
|
428
|
+
headers: response.headers,
|
|
429
|
+
})
|
|
430
|
+
Object.defineProperty(proxied, 'url', { value: response.url })
|
|
431
|
+
Object.defineProperty(proxied, 'type', { value: response.type })
|
|
432
|
+
Object.defineProperty(proxied, 'redirected', { value: response.redirected })
|
|
433
|
+
return proxied
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
async function captureBody(stream: ReadableStream<Uint8Array>, entry: NetworkEntry, MAX = 4000) {
|
|
437
|
+
const reader = stream.getReader()
|
|
438
|
+
const decoder = new TextDecoder()
|
|
439
|
+
let body = ''
|
|
440
|
+
try {
|
|
441
|
+
while (body.length < MAX) {
|
|
442
|
+
const { done, value } = await reader.read()
|
|
443
|
+
if (done)
|
|
444
|
+
break
|
|
445
|
+
body += decoder.decode(value, { stream: true })
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
catch { /* ignore */ }
|
|
449
|
+
finally {
|
|
450
|
+
try {
|
|
451
|
+
await reader.cancel()
|
|
452
|
+
}
|
|
453
|
+
catch {}
|
|
454
|
+
}
|
|
455
|
+
entry.responseBody = body.slice(0, MAX)
|
|
456
|
+
entry.responseSample = jsonSample(body)
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// --- Patch XHR ---
|
|
460
|
+
|
|
461
|
+
const XHRProto = XMLHttpRequest.prototype
|
|
462
|
+
const origOpen = XHRProto.open
|
|
463
|
+
const origSend = XHRProto.send
|
|
464
|
+
|
|
465
|
+
XHRProto.open = function (method: string, url: string | URL, ...rest: unknown[]) {
|
|
466
|
+
(this as any).__aihand = { method, url: String(url), start: 0 }
|
|
467
|
+
return (origOpen as any).apply(this, [method, url, ...rest])
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
XHRProto.send = function (...args: unknown[]) {
|
|
471
|
+
const meta = (this as any).__aihand
|
|
472
|
+
if (meta) {
|
|
473
|
+
meta.start = performance.now()
|
|
474
|
+
this.addEventListener('loadend', () => {
|
|
475
|
+
const responseHeaders: Record<string, string> = {}
|
|
476
|
+
const rawHeaders = this.getAllResponseHeaders()
|
|
477
|
+
for (const line of rawHeaders.trim().split('\r\n')) {
|
|
478
|
+
const idx = line.indexOf(': ')
|
|
479
|
+
if (idx > 0)
|
|
480
|
+
responseHeaders[line.slice(0, idx)] = line.slice(idx + 2)
|
|
481
|
+
}
|
|
482
|
+
pushBounded(networkRequests, {
|
|
483
|
+
method: meta.method,
|
|
484
|
+
url: meta.url,
|
|
485
|
+
status: this.status,
|
|
486
|
+
duration: Math.round(performance.now() - meta.start),
|
|
487
|
+
resourceType: 'xhr',
|
|
488
|
+
responseHeaders,
|
|
489
|
+
}, MAX_NETWORK)
|
|
490
|
+
})
|
|
491
|
+
}
|
|
492
|
+
return (origSend as any).apply(this, args)
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// --- Patch errors ---
|
|
496
|
+
|
|
497
|
+
// Extract a useful message+stack from any thrown value. The single chokepoint shared by the
|
|
498
|
+
// 'error' and 'unhandledrejection' listeners so they can never diverge: an Error gives both;
|
|
499
|
+
// a plain object with a .message (a common throw shape) gives that message; anything else
|
|
500
|
+
// stringifies. `{}` → "[object Object]" is the honest floor — there is genuinely no message.
|
|
501
|
+
function throwMeta(val: unknown): { message: string, stack: string | undefined } {
|
|
502
|
+
if (val instanceof Error)
|
|
503
|
+
return { message: val.message, stack: val.stack }
|
|
504
|
+
if (val == null)
|
|
505
|
+
return { message: '', stack: undefined }
|
|
506
|
+
const obj = val as { message?: unknown, stack?: unknown }
|
|
507
|
+
const message = typeof obj.message === 'string' && obj.message ? obj.message : String(val)
|
|
508
|
+
return { message, stack: typeof obj.stack === 'string' ? obj.stack : undefined }
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
window.addEventListener('error', (e) => {
|
|
512
|
+
if (e.target && e.target !== window) {
|
|
513
|
+
const el = e.target as HTMLElement
|
|
514
|
+
const src = (el as HTMLImageElement).src || (el as HTMLScriptElement).src || (el as HTMLLinkElement).href || ''
|
|
515
|
+
pushBounded(errorEntries, {
|
|
516
|
+
message: `Resource load failed: ${el.tagName.toLowerCase()} ${src}`,
|
|
517
|
+
source: src,
|
|
518
|
+
}, MAX_ERRORS)
|
|
519
|
+
return
|
|
520
|
+
}
|
|
521
|
+
// e.message can be empty — cross-origin "Script error.", or a non-Error throw
|
|
522
|
+
// (`throw undefined` / `throw {}` / `throw {code,detail}`). The real thrown value rides on
|
|
523
|
+
// e.error, so fall back to it instead of recording a blank "Uncaught" (a dead-end the consumer
|
|
524
|
+
// can't act on — 诊断纤维必须细分到根因,不可悲观坍缩成空). throwMeta is the shared extractor
|
|
525
|
+
// (Error → .message, {message} → .message, else String) so error & rejection can't drift.
|
|
526
|
+
const meta = throwMeta(e.error)
|
|
527
|
+
pushBounded(errorEntries, {
|
|
528
|
+
message: e.message || meta.message || 'Uncaught (no message — likely cross-origin script error)',
|
|
529
|
+
source: e.filename,
|
|
530
|
+
line: e.lineno,
|
|
531
|
+
column: e.colno,
|
|
532
|
+
stack: meta.stack,
|
|
533
|
+
}, MAX_ERRORS)
|
|
534
|
+
}, true)
|
|
535
|
+
|
|
536
|
+
window.addEventListener('unhandledrejection', (e) => {
|
|
537
|
+
const reason = e.reason
|
|
538
|
+
let message: string
|
|
539
|
+
let stack: string | undefined
|
|
540
|
+
if (reason instanceof Event) {
|
|
541
|
+
// A rejected resource-load event carries no message of its own — describe it by tag+src.
|
|
542
|
+
const target = reason.target as any
|
|
543
|
+
const tag = target?.tagName?.toLowerCase() || ''
|
|
544
|
+
const src = target?.src || target?.href || target?.currentSrc || ''
|
|
545
|
+
if (tag && src)
|
|
546
|
+
message = `Unhandled rejection: ${tag} load failed ${src}`
|
|
547
|
+
else if (tag)
|
|
548
|
+
message = `Unhandled rejection: ${tag} ${reason.type} event`
|
|
549
|
+
else
|
|
550
|
+
message = `Unhandled rejection: ${reason.type} event`
|
|
551
|
+
}
|
|
552
|
+
else {
|
|
553
|
+
const meta = throwMeta(reason)
|
|
554
|
+
message = meta.message
|
|
555
|
+
stack = meta.stack
|
|
556
|
+
}
|
|
557
|
+
pushBounded(errorEntries, { message, stack }, MAX_ERRORS)
|
|
558
|
+
})
|
|
559
|
+
|
|
560
|
+
// --- Patch EventSource (SSE) ---
|
|
561
|
+
|
|
562
|
+
const OrigEventSource = window.EventSource
|
|
563
|
+
if (OrigEventSource) {
|
|
564
|
+
window.EventSource = new Proxy(OrigEventSource, {
|
|
565
|
+
construct(Target, args) {
|
|
566
|
+
const es = new Target(...args as ConstructorParameters<typeof EventSource>)
|
|
567
|
+
const url = typeof args[0] === 'string' ? args[0] : String(args[0])
|
|
568
|
+
const start = performance.now()
|
|
569
|
+
|
|
570
|
+
pushBounded(networkRequests, {
|
|
571
|
+
method: 'GET',
|
|
572
|
+
url,
|
|
573
|
+
status: 0,
|
|
574
|
+
duration: 0,
|
|
575
|
+
resourceType: 'eventsource',
|
|
576
|
+
}, MAX_NETWORK)
|
|
577
|
+
|
|
578
|
+
es.addEventListener('open', () => {
|
|
579
|
+
const entry = networkRequests.find(r => r.url === url && r.resourceType === 'eventsource' && r.status === 0)
|
|
580
|
+
if (entry) {
|
|
581
|
+
entry.status = 200
|
|
582
|
+
entry.duration = Math.round(performance.now() - start)
|
|
583
|
+
}
|
|
584
|
+
})
|
|
585
|
+
|
|
586
|
+
es.addEventListener('error', () => {
|
|
587
|
+
pushBounded(networkRequests, {
|
|
588
|
+
method: 'GET',
|
|
589
|
+
url,
|
|
590
|
+
status: es.readyState === EventSource.CLOSED ? 0 : es.readyState,
|
|
591
|
+
duration: Math.round(performance.now() - start),
|
|
592
|
+
resourceType: 'eventsource',
|
|
593
|
+
failed: true,
|
|
594
|
+
failureText: es.readyState === EventSource.CLOSED ? 'connection closed' : 'connection error',
|
|
595
|
+
}, MAX_NETWORK)
|
|
596
|
+
})
|
|
597
|
+
|
|
598
|
+
return es
|
|
599
|
+
},
|
|
600
|
+
})
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
// --- User action timeline ---
|
|
604
|
+
//
|
|
605
|
+
// One document capture listener catches every semantic action — both the user's real
|
|
606
|
+
// events (isTrusted=true) and aihand's synthetic performAction events (isTrusted=false),
|
|
607
|
+
// since both bubble through document. Each entry stamps its post-settle UI projection
|
|
608
|
+
// (view/modal/focus) so a later diff against the prior entry reveals "dialog opened".
|
|
609
|
+
|
|
610
|
+
const KEYS = new Set(['Enter', 'Escape', 'Tab', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'])
|
|
611
|
+
let lastInputTarget: EventTarget | null = null
|
|
612
|
+
|
|
613
|
+
// inline mirror of client.ts visibleText/inspPath — client-patch can't import (see formatValue note)
|
|
614
|
+
function describe(el: Element): string {
|
|
615
|
+
// aria-label → own direct text → title → descendant text. Mirrors core elementLabel's chain
|
|
616
|
+
// order (candidates.ts): title sits BEFORE textContent — icon-only buttons (搜索/设置) carry
|
|
617
|
+
// their name in title=, with empty text, so without it they log as nameless <button>.
|
|
618
|
+
// client-patch can't import elementLabel (no-import constraint), so the chain is inlined.
|
|
619
|
+
// Flatten newlines → space then collapse runs: a multi-line tooltip/text
|
|
620
|
+
// (深度思考\n当前状态:关闭\n…) would otherwise spill the entry across 3 lines, breaking the
|
|
621
|
+
// one-line-per-action contract every reader (and /timeline) relies on. Literal split, not regex
|
|
622
|
+
// (codebase idiom — see line 427 / client.ts:483).
|
|
623
|
+
const flat = (el.getAttribute('aria-label') || directText(el) || el.getAttribute('title') || el.textContent || '')
|
|
624
|
+
.split('\n').join(' ').split('\t').join(' ')
|
|
625
|
+
const label = flat.split(' ').filter(Boolean).join(' ').slice(0, 40)
|
|
626
|
+
const tag = el.tagName.toLowerCase()
|
|
627
|
+
const name = label || `<${tag}>`
|
|
628
|
+
const raw = el.getAttribute('data-insp-path')
|
|
629
|
+
let loc = ''
|
|
630
|
+
if (raw) {
|
|
631
|
+
const [file, line] = raw.split(':')
|
|
632
|
+
const tail = file.split('/').slice(-2).join('/')
|
|
633
|
+
loc = line ? ` @${tail}:${line}` : ` @${tail}`
|
|
634
|
+
}
|
|
635
|
+
return `${name} [${tag}]${loc}`
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
function directText(el: Element): string {
|
|
639
|
+
let t = ''
|
|
640
|
+
for (const node of el.childNodes) {
|
|
641
|
+
if (node.nodeType === 3)
|
|
642
|
+
t += node.textContent
|
|
643
|
+
}
|
|
644
|
+
return t.trim()
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
// Light projection — just the variables that move when UI changes. NOT the full
|
|
648
|
+
// collectScreen (knobs etc.) which lives in client.ts; this only needs the deltas.
|
|
649
|
+
function stampScreen(entry: ActionEntry) {
|
|
650
|
+
let view = '(unknown)'
|
|
651
|
+
try { view = (window as any).__AIPEEK_VIEW__?.() ?? '(unknown)' } catch { /* app hook threw */ }
|
|
652
|
+
entry.view = view
|
|
653
|
+
perf.active = view === '(unknown)' ? '__all__' : view
|
|
654
|
+
const modalEl = document.querySelector('[role="dialog"][data-state="open"]')
|
|
655
|
+
entry.modal = modalEl
|
|
656
|
+
? (modalEl.querySelector('h1, h2, [id^="radix"]')?.textContent || 'untitled').trim().slice(0, 40)
|
|
657
|
+
: 'none'
|
|
658
|
+
const active = document.activeElement
|
|
659
|
+
entry.focus = active && active !== document.body ? describe(active) : 'none'
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
// Enqueue + stamp in one place — every listener funnels here so the trusted/pushBounded/
|
|
663
|
+
// settle-then-stamp triple isn't repeated five times.
|
|
664
|
+
function record(e: Event, type: string, target: Element, value?: string) {
|
|
665
|
+
const entry: ActionEntry = {
|
|
666
|
+
type,
|
|
667
|
+
target: describe(target),
|
|
668
|
+
value: value === undefined ? undefined : value.slice(0, 60),
|
|
669
|
+
trusted: e.isTrusted,
|
|
670
|
+
ts: Date.now(),
|
|
671
|
+
}
|
|
672
|
+
actionEntries.push(entry)
|
|
673
|
+
if (actionEntries.length > MAX_ACTIONS) {
|
|
674
|
+
actionEntries.shift()
|
|
675
|
+
actionsDropped++ // head evicted — timeline must announce the clip (diagnostic-fiber law)
|
|
676
|
+
}
|
|
677
|
+
// Stamp view/modal/focus after the first React commit settles (<20ms). Must land before the
|
|
678
|
+
// action response ships, or attachTimeline reads the entry without its `→ ui change` suffix —
|
|
679
|
+
// so this stays under waitForStable's quiet (now 50ms), with ~10ms headroom.
|
|
680
|
+
setTimeout(() => stampScreen(entry), 40)
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
// e.target is the innermost leaf actually clicked — the <svg> icon *inside* the button, not
|
|
684
|
+
// the button. A human perceives clicking "AI 对话", so record what they aimed at: climb to the
|
|
685
|
+
// nearest interactive ancestor (same INTERACTIVE set as command dispatch; inlined — client-patch
|
|
686
|
+
// can't import). The leaf often has no name (svg/span) → describe() falls back to <svg>, breaking
|
|
687
|
+
// the action↔feedback morphism. Falls back to the leaf if no interactive ancestor (rare).
|
|
688
|
+
const INTERACTIVE = 'a, button, input, textarea, select, [role], [onclick], [tabindex], [contenteditable], [aria-label]'
|
|
689
|
+
const aimed = (el: Element) => el.closest(INTERACTIVE) ?? el
|
|
690
|
+
document.addEventListener('click', e => e.target && record(e, 'click', aimed(e.target as Element)), true)
|
|
691
|
+
document.addEventListener('submit', e => e.target && record(e, 'submit', e.target as Element), true)
|
|
692
|
+
document.addEventListener('change', (e) => {
|
|
693
|
+
if (!e.target)
|
|
694
|
+
return
|
|
695
|
+
lastInputTarget = null
|
|
696
|
+
record(e, 'change', e.target as Element, (e.target as HTMLInputElement).value)
|
|
697
|
+
}, true)
|
|
698
|
+
|
|
699
|
+
document.addEventListener('keydown', (e) => {
|
|
700
|
+
if (KEYS.has(e.key))
|
|
701
|
+
record(e, `key:${e.key}`, (e.target as Element | null) ?? document.body)
|
|
702
|
+
}, true)
|
|
703
|
+
|
|
704
|
+
document.addEventListener('input', (e) => {
|
|
705
|
+
const el = e.target as HTMLInputElement | null
|
|
706
|
+
if (!el)
|
|
707
|
+
return
|
|
708
|
+
const value = el.value ?? (el.isContentEditable ? (el.textContent ?? '') : '')
|
|
709
|
+
// Debounce same-target keystrokes: rewrite the last entry instead of appending one
|
|
710
|
+
// per character (the timeline wants "input value=hello", not h/he/hel/…).
|
|
711
|
+
const last = actionEntries[actionEntries.length - 1]
|
|
712
|
+
if (lastInputTarget === el && last?.type === 'input') {
|
|
713
|
+
last.value = value.slice(0, 60)
|
|
714
|
+
last.trusted = e.isTrusted
|
|
715
|
+
last.ts = Date.now()
|
|
716
|
+
setTimeout(() => stampScreen(last), 180)
|
|
717
|
+
return
|
|
718
|
+
}
|
|
719
|
+
lastInputTarget = el
|
|
720
|
+
record(e, 'input', el, value)
|
|
721
|
+
}, true)
|
|
722
|
+
|
|
723
|
+
// --- 一体化反向态射最后一跳:Alt+Click 页面元素 → 浮层直接显示代码原因 ---
|
|
724
|
+
// FUSION §7。Alt+Click 任一元素 → 沿 DOM 找最近 data-insp-path → fetch /source → 浮层渲染
|
|
725
|
+
// 符号 + callers/callees(每条可点继续向上跳调用链)。镜像 read/locate.ts 的 sourcePanelHtml
|
|
726
|
+
// (client-patch 不能 import),core 单测钉契约,这里靠 aihand 真页面验证字节一致。
|
|
727
|
+
|
|
728
|
+
function srcEsc(s: string): string {
|
|
729
|
+
return s.split('&').join('&').split('<').join('<').split('>').join('>').split('"').join('"')
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
// 与 read/locate.ts sourcePanelHtml 同一渲染(LocateResult → HTML),逐字镜像。
|
|
733
|
+
function srcPanelHtml(r: any): string {
|
|
734
|
+
const at = (s: any) => `${s.filePath}:${s.startLine + 1}`
|
|
735
|
+
const row = (c: any) => `<div class="aihand-src-edge" data-src-path="${srcEsc(at(c))}">${srcEsc(c.name)} <span class="aihand-src-at">${srcEsc(at(c))}</span></div>`
|
|
736
|
+
const out = [`<div class="aihand-src-head">${srcEsc(r.symbol.name)} <span class="aihand-src-kind">${srcEsc(r.symbol.kind)}</span> <span class="aihand-src-at">${srcEsc(at(r.symbol))}</span></div>`]
|
|
737
|
+
if (r.callers.length)
|
|
738
|
+
out.push(`<div class="aihand-src-sec">Callers (${r.callers.length})</div>`, ...r.callers.map(row))
|
|
739
|
+
if (r.callees.length)
|
|
740
|
+
out.push(`<div class="aihand-src-sec">Callees (${r.callees.length})</div>`, ...r.callees.map(row))
|
|
741
|
+
if (!r.callers.length && !r.callees.length)
|
|
742
|
+
out.push(`<div class="aihand-src-empty">(no call edges found)</div>`)
|
|
743
|
+
if (r.impact)
|
|
744
|
+
out.push(`<div class="aihand-src-impact">Impact: ${srcEsc(r.impact.risk)} (${r.impact.upstreamCount} upstream)</div>`)
|
|
745
|
+
return out.join('')
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
let srcPanelEl: HTMLDivElement | null = null
|
|
749
|
+
function closeSrcPanel() {
|
|
750
|
+
srcPanelEl?.remove()
|
|
751
|
+
srcPanelEl = null
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
async function showSrcPanel(path: string, x: number, y: number) {
|
|
755
|
+
let r: any
|
|
756
|
+
try {
|
|
757
|
+
const resp = await originalFetch(`/__aihand/source?path=${encodeURIComponent(path)}&impact&json`, { cache: 'no-store' })
|
|
758
|
+
if (!resp.ok) {
|
|
759
|
+
r = null
|
|
760
|
+
}
|
|
761
|
+
else {
|
|
762
|
+
r = await resp.json()
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
catch {
|
|
766
|
+
r = null
|
|
767
|
+
}
|
|
768
|
+
closeSrcPanel()
|
|
769
|
+
const panel = document.createElement('div')
|
|
770
|
+
panel.className = 'aihand-src-panel'
|
|
771
|
+
panel.style.cssText = `position:fixed;left:${Math.min(x, window.innerWidth - 340)}px;top:${Math.min(y, window.innerHeight - 200)}px;z-index:2147483647;max-width:320px;max-height:60vh;overflow:auto;background:#1e1e1e;color:#ddd;font:12px/1.5 ui-monospace,monospace;border:1px solid #444;border-radius:6px;padding:8px 10px;box-shadow:0 8px 30px rgba(0,0,0,.5)`
|
|
772
|
+
panel.innerHTML = r
|
|
773
|
+
? srcPanelHtml(r)
|
|
774
|
+
: `<div class="aihand-src-empty">no symbol owns ${srcEsc(path)}</div>`
|
|
775
|
+
panel.dataset.x = String(x)
|
|
776
|
+
panel.dataset.y = String(y)
|
|
777
|
+
document.body.appendChild(panel)
|
|
778
|
+
srcPanelEl = panel
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
// 监听挂 window capture,不是 document capture:本 app 在 window↔document 之间有一层 capture
|
|
782
|
+
// 监听对「带修饰键的 click」(alt/meta/ctrl/shift 全部)stopPropagation,导致 document 级永远收不到
|
|
783
|
+
// Alt+Click(实测:plain 到 document,任何修饰键都被吞)。window capture 是事件最外层,先于那层吞噬者。
|
|
784
|
+
window.addEventListener('click', (e) => {
|
|
785
|
+
if (!(e.altKey && e.shiftKey))
|
|
786
|
+
return
|
|
787
|
+
const el = (e.target as Element | null)?.closest('[data-insp-path]') as HTMLElement | null
|
|
788
|
+
e.preventDefault()
|
|
789
|
+
e.stopPropagation()
|
|
790
|
+
if (!el) {
|
|
791
|
+
closeSrcPanel()
|
|
792
|
+
return
|
|
793
|
+
}
|
|
794
|
+
const raw = el.getAttribute('data-insp-path')!
|
|
795
|
+
// insp-path 形态 `rel:line` 或 `rel:line:col:Name` — 取 file:line 两段喂 /source(端点自会再 parse)。
|
|
796
|
+
const seg = raw.split(':')
|
|
797
|
+
const path = seg.length <= 2 ? raw : `${seg[0]}:${seg[1]}`
|
|
798
|
+
showSrcPanel(path, e.clientX + 4, e.clientY + 4)
|
|
799
|
+
}, true)
|
|
800
|
+
|
|
801
|
+
// 浮层内点 caller/callee 边 → 继续向上跳调用链。同样挂 window capture(本 app 吞掉冒泡到
|
|
802
|
+
// 浮层的 click,panel.addEventListener 收不到),委托判定 target 落在边上。
|
|
803
|
+
window.addEventListener('click', (e) => {
|
|
804
|
+
if ((e.altKey && e.shiftKey) || !srcPanelEl)
|
|
805
|
+
return
|
|
806
|
+
const edge = (e.target as Element | null)?.closest('[data-src-path]') as HTMLElement | null
|
|
807
|
+
if (edge && srcPanelEl.contains(edge)) {
|
|
808
|
+
e.preventDefault()
|
|
809
|
+
e.stopPropagation()
|
|
810
|
+
showSrcPanel(edge.getAttribute('data-src-path')!, Number(srcPanelEl.dataset.x), Number(srcPanelEl.dataset.y))
|
|
811
|
+
}
|
|
812
|
+
}, true)
|
|
813
|
+
|
|
814
|
+
window.addEventListener('keydown', (e) => {
|
|
815
|
+
if (e.key === 'Escape')
|
|
816
|
+
closeSrcPanel()
|
|
817
|
+
}, true)
|
|
818
|
+
// 点浮层外关闭(普通点击,非 Alt+Shift 触发手势)。
|
|
819
|
+
window.addEventListener('click', (e) => {
|
|
820
|
+
if (!(e.altKey && e.shiftKey) && srcPanelEl && !srcPanelEl.contains(e.target as Node))
|
|
821
|
+
closeSrcPanel()
|
|
822
|
+
}, true)
|
|
823
|
+
|
|
824
|
+
// --- Utils ---
|
|
825
|
+
|
|
826
|
+
function jsonSample(text: string): string | undefined {
|
|
827
|
+
if (!text || (text[0] !== '{' && text[0] !== '['))
|
|
828
|
+
return undefined
|
|
829
|
+
try {
|
|
830
|
+
return JSON.stringify(sampleOf(JSON.parse(text), 0))
|
|
831
|
+
}
|
|
832
|
+
catch {
|
|
833
|
+
return undefined
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
function sampleOf(v: unknown, d: number): unknown {
|
|
838
|
+
if (d > 4)
|
|
839
|
+
return null
|
|
840
|
+
if (v === null || v === undefined)
|
|
841
|
+
return v
|
|
842
|
+
if (typeof v === 'string')
|
|
843
|
+
return v.length > 50 ? v.slice(0, 50) : v
|
|
844
|
+
if (typeof v === 'number' || typeof v === 'boolean')
|
|
845
|
+
return v
|
|
846
|
+
if (Array.isArray(v))
|
|
847
|
+
return v.length ? [sampleOf(v[0], d + 1)] : []
|
|
848
|
+
if (typeof v === 'object') {
|
|
849
|
+
const result: Record<string, unknown> = {}
|
|
850
|
+
for (const [k, val] of Object.entries(v as Record<string, unknown>).slice(0, 15)) {
|
|
851
|
+
if (typeof val === 'function')
|
|
852
|
+
continue
|
|
853
|
+
result[k] = sampleOf(val, d + 1)
|
|
854
|
+
}
|
|
855
|
+
return result
|
|
856
|
+
}
|
|
857
|
+
return null
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
// client-patch 是自包含内联脚本(esbuild transformSync,无模块解析)——不能 import core/util。
|
|
861
|
+
// 这是 core/util.ts formatValue 的镜像;两处契约由各自单测守,client 侧靠运行时验证。
|
|
862
|
+
function formatValue(v: unknown, seen: Set<object> = new Set()): string {
|
|
863
|
+
if (v === null || v === undefined)
|
|
864
|
+
return String(v)
|
|
865
|
+
const t = typeof v
|
|
866
|
+
if (t === 'string')
|
|
867
|
+
return v as string
|
|
868
|
+
if (t === 'number' || t === 'boolean' || t === 'bigint')
|
|
869
|
+
return String(v)
|
|
870
|
+
if (t === 'symbol')
|
|
871
|
+
return (v as symbol).toString()
|
|
872
|
+
if (t === 'function')
|
|
873
|
+
return `[Function: ${(v as { name?: string }).name || 'anonymous'}]`
|
|
874
|
+
const obj = v as object
|
|
875
|
+
if (seen.has(obj))
|
|
876
|
+
return '[Circular]'
|
|
877
|
+
if (v instanceof Error)
|
|
878
|
+
return v.stack || `${v.name}: ${v.message}`
|
|
879
|
+
seen.add(obj)
|
|
880
|
+
if (v instanceof Map) {
|
|
881
|
+
const items = [...v.entries()].slice(0, 15).map(([k, val]) => `${formatValue(k, seen)} => ${formatValue(val, seen)}`)
|
|
882
|
+
return `Map(${v.size}) {${items.join(', ')}${v.size > 15 ? ', …' : ''}}`
|
|
883
|
+
}
|
|
884
|
+
if (v instanceof Set) {
|
|
885
|
+
const items = [...v.values()].slice(0, 15).map(val => formatValue(val, seen))
|
|
886
|
+
return `Set(${v.size}) {${items.join(', ')}${v.size > 15 ? ', …' : ''}}`
|
|
887
|
+
}
|
|
888
|
+
if (Array.isArray(v)) {
|
|
889
|
+
const items = v.slice(0, 30).map(val => formatValue(val, seen))
|
|
890
|
+
return `[${items.join(', ')}${v.length > 30 ? ', …' : ''}]`
|
|
891
|
+
}
|
|
892
|
+
try {
|
|
893
|
+
return JSON.stringify(v)
|
|
894
|
+
}
|
|
895
|
+
catch {
|
|
896
|
+
const entries = Object.entries(v as Record<string, unknown>).slice(0, 15)
|
|
897
|
+
return `{${entries.map(([k, val]) => `${k}: ${formatValue(val, seen)}`).join(', ')}}`
|
|
898
|
+
}
|
|
899
|
+
}
|