aipeek 0.2.7 → 0.2.9

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.
@@ -2,14 +2,35 @@
2
2
  // Injected as inline <script> at head-prepend to patch console/fetch/XHR/errors
3
3
  // BEFORE any application modules execute.
4
4
 
5
- import type { ErrorEntry, LogEntry, NetworkRequest } from '../core/types'
5
+ import type { ActionEntry, ErrorEntry, LogEntry, NetworkRequest } from '../core/types'
6
6
 
7
7
  type NetworkEntry = NetworkRequest
8
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
+ longtasks: { count: number, max: number }
26
+ }
27
+
9
28
  interface AipeekBuffers {
10
29
  consoleLogs: LogEntry[]
11
30
  networkRequests: NetworkEntry[]
12
31
  errorEntries: ErrorEntry[]
32
+ actionEntries: ActionEntry[]
33
+ perf: PerfBuffer
13
34
  }
14
35
 
15
36
  // --- Ring buffers (persist across HMR) ---
@@ -17,13 +38,185 @@ interface AipeekBuffers {
17
38
  const MAX_CONSOLE = 100
18
39
  const MAX_NETWORK = 50
19
40
  const MAX_ERRORS = 20
41
+ const MAX_ACTIONS = 30
20
42
 
21
43
  const BUFFERS_KEY = '__AIPEEK_BUFFERS__'
22
44
  const existing = (window as any)[BUFFERS_KEY] as AipeekBuffers | undefined
23
45
  const consoleLogs: LogEntry[] = existing?.consoleLogs ?? []
24
46
  const networkRequests: NetworkEntry[] = existing?.networkRequests ?? []
25
47
  const errorEntries: ErrorEntry[] = existing?.errorEntries ?? []
26
- ;(window as any)[BUFFERS_KEY] = { consoleLogs, networkRequests, errorEntries }
48
+ const actionEntries: ActionEntry[] = existing?.actionEntries ?? []
49
+ const perf: PerfBuffer = existing?.perf ?? {
50
+ startedAt: Date.now(),
51
+ hiddenFrames: 0,
52
+ mobxPatched: false,
53
+ buckets: {},
54
+ active: '__all__',
55
+ longtasks: { count: 0, max: 0 },
56
+ }
57
+ ;(window as any)[BUFFERS_KEY] = { consoleLogs, networkRequests, errorEntries, actionEntries, perf }
58
+
59
+ const PERF_SAMPLE_CAP = 5000
60
+
61
+ function perfBucket(name: string): PerfBucketBuf {
62
+ let b = perf.buckets[name]
63
+ if (!b) {
64
+ b = { components: {}, frames: { total: 0, long: 0, max: 0, samples: [] }, lines: {} }
65
+ perf.buckets[name] = b
66
+ }
67
+ return b
68
+ }
69
+
70
+ // The view bucket + the __all__ aggregate. When active IS __all__, return it once —
71
+ // else every hit double-counts into __all__ (n, total, frames all 2×).
72
+ function targetBuckets(): string[] {
73
+ return perf.active === '__all__' ? ['__all__'] : [perf.active, '__all__']
74
+ }
75
+
76
+ // One timed hit (component render / line exec) → a stat map, accumulating total/n/max.
77
+ // No samples buffer: total/n/max are O(1) streaming all-time truths; p95 was the only sample
78
+ // consumer and is gone (near-window p95 vs all-time total = same-row lie). Dropping the per-hit
79
+ // push/shift also lightens the line-instrumentation hot path (less self-pollution).
80
+ function recordStat(map: Record<string, PerfStatBuf>, key: string, ms: number) {
81
+ let s = map[key]
82
+ if (!s) {
83
+ s = { total: 0, n: 0, max: 0 }
84
+ map[key] = s
85
+ }
86
+ s.total += ms
87
+ s.n += 1
88
+ if (ms > s.max)
89
+ s.max = ms
90
+ }
91
+
92
+ // One component render's self-time → both its live view bucket and the __all__ aggregate.
93
+ function hitComponent(name: string, ms: number) {
94
+ for (const bucketName of targetBuckets())
95
+ recordStat(perfBucket(bucketName).components, name, ms)
96
+ }
97
+
98
+ // One line's execution time → both its live view bucket and the __all__ aggregate.
99
+ // Called by babel-inserted __line(label, fn) wrapper.
100
+ function hitLine(label: string, ms: number) {
101
+ for (const bucketName of targetBuckets())
102
+ recordStat(perfBucket(bucketName).lines, label, ms)
103
+ }
104
+
105
+ // Runtime for babel plugin: wraps one statement, times it, records to perf.lines.
106
+ // Exposed globally so babel-transformed code can call it (client-patch can't be imported).
107
+ ;(window as any).__line = function __line<T>(label: string, fn: () => T): T {
108
+ const start = performance.now()
109
+ try {
110
+ return fn()
111
+ }
112
+ finally {
113
+ hitLine(label, performance.now() - start)
114
+ }
115
+ }
116
+
117
+ // One frame's dt → both live view bucket and __all__. long = dropped (>16.7ms ≈ missed 60fps).
118
+ function hitFrame(dt: number) {
119
+ for (const bucketName of targetBuckets()) {
120
+ const f = perfBucket(bucketName).frames
121
+ f.total += 1
122
+ if (dt > 16.7)
123
+ f.long += 1
124
+ if (dt > f.max)
125
+ f.max = dt
126
+ f.samples.push(dt)
127
+ if (f.samples.length > PERF_SAMPLE_CAP)
128
+ f.samples.shift()
129
+ }
130
+ }
131
+
132
+ // (b) Frame loop — zero-coupling, holds for any app. document.hidden guard: backgrounded rAF
133
+ // throttles to ~1fps, so each hidden frame looks like a 1000ms "dropped" frame and lies.
134
+ // Don't count dt while hidden; tally hiddenFrames so the report can flag it.
135
+ const WARN_THRESHOLD = 20 // dropped% — push warning when ≥20% frames jank
136
+ const WARN_COOLDOWN = 5000 // ms — don't spam, at most one warn per 5s
137
+ let lastFrame = performance.now()
138
+ let lastWarn = 0
139
+ function frameLoop() {
140
+ const now = performance.now()
141
+ if (document.hidden) {
142
+ perf.hiddenFrames += 1
143
+ lastFrame = now
144
+ requestAnimationFrame(frameLoop)
145
+ return
146
+ }
147
+ const dt = now - lastFrame
148
+ hitFrame(dt)
149
+ lastFrame = now
150
+
151
+ // Push mode: auto-warn on jank (≥20% dropped), once per 5s
152
+ const bucket = perf.buckets[perf.active]
153
+ if (bucket && bucket.frames.total > 20) { // need ≥20 frames for stable %
154
+ const droppedPct = (bucket.frames.long / bucket.frames.total) * 100
155
+ if (droppedPct >= WARN_THRESHOLD && now - lastWarn > WARN_COOLDOWN) {
156
+ const top3 = Object.entries(bucket.components)
157
+ .sort(([, a], [, b]) => b.total - a.total)
158
+ .slice(0, 3)
159
+ .map(([name, s]) => `${name} ${s.total.toFixed(0)}ms`)
160
+ .join(', ')
161
+ const hot = top3 ? ` · hot: ${top3}` : ''
162
+ console.warn(`[aipeek] 🐌 ${droppedPct.toFixed(0)}% frames dropped${hot}`)
163
+ lastWarn = now
164
+ }
165
+ }
166
+
167
+ requestAnimationFrame(frameLoop)
168
+ }
169
+ requestAnimationFrame(frameLoop)
170
+
171
+ // (b) Longtask observer — main-thread blocks >50ms, app-agnostic.
172
+ try {
173
+ new PerformanceObserver((list) => {
174
+ for (const entry of list.getEntries()) {
175
+ perf.longtasks.count += 1
176
+ if (entry.duration > perf.longtasks.max)
177
+ perf.longtasks.max = entry.duration
178
+ }
179
+ }).observe({ entryTypes: ['longtask'] })
180
+ }
181
+ catch { /* longtask not supported — frames + components still work */ }
182
+
183
+ // (a) Component self-time via MobX Reaction.track. client-patch can't import mobx, so the app
184
+ // exposes the live singleton at window.__AIPEEK_MOBX__={Reaction} (dev-only seam in src/lib/mobx.ts).
185
+ // mobx-react-lite names every component's Reaction "observer"+name, so name_.slice(8) is the
186
+ // component name. Patch is idempotent (track.__aipeek flag) and survives HMR via the shared buffer.
187
+ function patchMobx() {
188
+ const mobx = (window as any).__AIPEEK_MOBX__
189
+ const Reaction = mobx?.Reaction
190
+ const track = Reaction?.prototype?.track
191
+ if (!track || (track as any).__aipeek)
192
+ return
193
+ const patched = function (this: any, fn: () => void) {
194
+ const name: string = this.name_ ?? ''
195
+ if (name.indexOf('observer') !== 0)
196
+ return track.call(this, fn)
197
+ const start = performance.now()
198
+ try {
199
+ return track.call(this, fn)
200
+ }
201
+ finally {
202
+ hitComponent(name.slice(8) || '(anon)', performance.now() - start)
203
+ }
204
+ }
205
+ ;(patched as any).__aipeek = true
206
+ Reaction.prototype.track = patched
207
+ perf.mobxPatched = true
208
+ }
209
+ patchMobx()
210
+ // The seam may run after this patch (module order). Retry briefly so attribution turns on
211
+ // once src/lib/mobx.ts executes; stops as soon as it succeeds.
212
+ if (!perf.mobxPatched) {
213
+ let tries = 0
214
+ const retry = setInterval(() => {
215
+ patchMobx()
216
+ if (perf.mobxPatched || ++tries > 20)
217
+ clearInterval(retry)
218
+ }, 100)
219
+ }
27
220
 
28
221
  function pushBounded<T>(arr: T[], item: T, max: number) {
29
222
  arr.push(item)
@@ -50,6 +243,47 @@ for (const level of ['log', 'info', 'warn', 'error', 'debug'] as const) {
50
243
  // --- Patch fetch — 透明摄像头:tee body,主流原样返回,副流后台 capture ---
51
244
 
52
245
  const originalFetch = window.fetch
246
+
247
+ // --- Self-heal handshake: reconnect after a dev-server restart without a human reload ---
248
+ // The whole aipeek action chain rides the Vite HMR WebSocket. A full server restart kills
249
+ // that socket and Vite's reconnect often gives up — stranding the page on "connection lost".
250
+ // So we poll an HTTP heartbeat (independent of the WS) carrying the server's BOOT_ID; when it
251
+ // changes (server is a new process), the page reloads itself and re-handshakes the new server.
252
+ // Uses originalFetch so the poll never lands in the network ring.
253
+ ;(function keepAlive() {
254
+ const SEEN_KEY = '__aipeek_boot__'
255
+ const RELOADING_KEY = '__aipeek_reloading__'
256
+ setInterval(async () => {
257
+ let id: string
258
+ try {
259
+ id = await (await originalFetch('/__aipeek/ping', { cache: 'no-store' })).text()
260
+ }
261
+ catch {
262
+ return // server not back yet — keep polling, don't reload on mere failure
263
+ }
264
+ sessionStorage.removeItem(RELOADING_KEY) // ping succeeded — clear the reload debounce
265
+ const seen = sessionStorage.getItem(SEEN_KEY)
266
+ if (!seen) {
267
+ sessionStorage.setItem(SEEN_KEY, id) // first sighting — remember this server
268
+ return
269
+ }
270
+ // Two reload triggers, same fix (reload → re-handshake), both debounced by RELOADING_KEY:
271
+ // 1. BOOT_ID changed → server restarted (the original case).
272
+ // 2. BOOT_ID same but the HMR WebSocket is dead → the socket dropped (Electron window
273
+ // backgrounded/suspended) and Vite's reconnect is gated on visibility, so it never
274
+ // comes back on its own. The action chain rides that socket, so a dead WS = "can't
275
+ // see" even though the server is fine. client.ts sets this flag on vite:ws:disconnect.
276
+ const wsDead = (window as { __aipeek_ws_dead__?: boolean }).__aipeek_ws_dead__ === true
277
+ if (seen !== id || wsDead) {
278
+ sessionStorage.setItem(SEEN_KEY, id)
279
+ if (!sessionStorage.getItem(RELOADING_KEY)) {
280
+ sessionStorage.setItem(RELOADING_KEY, '1')
281
+ location.reload()
282
+ }
283
+ }
284
+ }, 2000)
285
+ })()
286
+
53
287
  window.fetch = async (input, init) => {
54
288
  const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url
55
289
  const method = init?.method || 'GET'
@@ -266,6 +500,102 @@ if (OrigEventSource) {
266
500
  })
267
501
  }
268
502
 
503
+ // --- User action timeline ---
504
+ //
505
+ // One document capture listener catches every semantic action — both the user's real
506
+ // events (isTrusted=true) and aipeek's synthetic performAction events (isTrusted=false),
507
+ // since both bubble through document. Each entry stamps its post-settle UI projection
508
+ // (view/modal/focus) so a later diff against the prior entry reveals "dialog opened".
509
+
510
+ const KEYS = new Set(['Enter', 'Escape', 'Tab', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'])
511
+ let lastInputTarget: EventTarget | null = null
512
+
513
+ // inline mirror of client.ts visibleText/inspPath — client-patch can't import (see formatValue note)
514
+ function describe(el: Element): string {
515
+ const label = (el.getAttribute('aria-label') || directText(el) || '').trim().slice(0, 40)
516
+ const tag = el.tagName.toLowerCase()
517
+ const name = label || `<${tag}>`
518
+ const raw = el.getAttribute('data-insp-path')
519
+ let loc = ''
520
+ if (raw) {
521
+ const [file, line] = raw.split(':')
522
+ const tail = file.split('/').slice(-2).join('/')
523
+ loc = line ? ` @${tail}:${line}` : ` @${tail}`
524
+ }
525
+ return `${name} [${tag}]${loc}`
526
+ }
527
+
528
+ function directText(el: Element): string {
529
+ let t = ''
530
+ for (const node of el.childNodes) {
531
+ if (node.nodeType === 3)
532
+ t += node.textContent
533
+ }
534
+ return t.trim()
535
+ }
536
+
537
+ // Light projection — just the variables that move when UI changes. NOT the full
538
+ // collectScreen (knobs etc.) which lives in client.ts; this only needs the deltas.
539
+ function stampScreen(entry: ActionEntry) {
540
+ const stores = (window as any).__AIPEEK_STORES__
541
+ const view = stores?.appUIStore?.mode ?? '(unknown)'
542
+ entry.view = view
543
+ perf.active = view === '(unknown)' ? '__all__' : view
544
+ const modalEl = document.querySelector('[role="dialog"][data-state="open"]')
545
+ entry.modal = modalEl
546
+ ? (modalEl.querySelector('h1, h2, [id^="radix"]')?.textContent || 'untitled').trim().slice(0, 40)
547
+ : 'none'
548
+ const active = document.activeElement
549
+ entry.focus = active && active !== document.body ? describe(active) : 'none'
550
+ }
551
+
552
+ // Enqueue + stamp in one place — every listener funnels here so the trusted/pushBounded/
553
+ // settle-then-stamp triple isn't repeated five times.
554
+ function record(e: Event, type: string, target: Element, value?: string) {
555
+ const entry: ActionEntry = {
556
+ type,
557
+ target: describe(target),
558
+ value: value === undefined ? undefined : value.slice(0, 60),
559
+ trusted: e.isTrusted,
560
+ ts: Date.now(),
561
+ }
562
+ pushBounded(actionEntries, entry, MAX_ACTIONS)
563
+ setTimeout(() => stampScreen(entry), 180)
564
+ }
565
+
566
+ document.addEventListener('click', e => e.target && record(e, 'click', e.target as Element), true)
567
+ document.addEventListener('submit', e => e.target && record(e, 'submit', e.target as Element), true)
568
+ document.addEventListener('change', (e) => {
569
+ if (!e.target)
570
+ return
571
+ lastInputTarget = null
572
+ record(e, 'change', e.target as Element, (e.target as HTMLInputElement).value)
573
+ }, true)
574
+
575
+ document.addEventListener('keydown', (e) => {
576
+ if (KEYS.has(e.key))
577
+ record(e, `key:${e.key}`, (e.target as Element | null) ?? document.body)
578
+ }, true)
579
+
580
+ document.addEventListener('input', (e) => {
581
+ const el = e.target as HTMLInputElement | null
582
+ if (!el)
583
+ return
584
+ const value = el.value ?? (el.isContentEditable ? (el.textContent ?? '') : '')
585
+ // Debounce same-target keystrokes: rewrite the last entry instead of appending one
586
+ // per character (the timeline wants "input value=hello", not h/he/hel/…).
587
+ const last = actionEntries[actionEntries.length - 1]
588
+ if (lastInputTarget === el && last?.type === 'input') {
589
+ last.value = value.slice(0, 60)
590
+ last.trusted = e.isTrusted
591
+ last.ts = Date.now()
592
+ setTimeout(() => stampScreen(last), 180)
593
+ return
594
+ }
595
+ lastInputTarget = el
596
+ record(e, 'input', el, value)
597
+ }, true)
598
+
269
599
  // --- Utils ---
270
600
 
271
601
  function jsonSample(text: string): string | undefined {