aipeek 0.2.6 → 0.2.8

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