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.
- package/README.md +94 -19
- package/dist/{chunk-XA2LT6I4.js → chunk-4BPXH2SW.js} +715 -59
- package/dist/{chunk-5ZZYOETF.cjs → chunk-SDUTK75Y.cjs} +717 -61
- package/dist/index.cjs +2 -2
- package/dist/index.d.cts +44 -0
- package/dist/index.d.ts +44 -0
- package/dist/index.js +1 -1
- package/dist/plugin.cjs +4 -2
- package/dist/plugin.js +5 -3
- package/package.json +3 -1
- package/src/babel/line-profiler.ts +190 -0
- package/src/client/client-patch.ts +326 -2
- package/src/client/client.ts +255 -42
- package/src/core/action.ts +274 -18
- package/src/core/compact.ts +2 -0
- package/src/core/detail.ts +3 -1
- package/src/core/diff.ts +55 -1
- package/src/core/emit.ts +14 -2
- package/src/core/perf.ts +239 -0
- package/src/core/types.ts +73 -0
- package/src/core/util.ts +115 -0
- package/src/server/plugin.ts +577 -65
package/src/client/client.ts
CHANGED
|
@@ -3,18 +3,48 @@
|
|
|
3
3
|
// The patch code (console/fetch/XHR/errors) is in client-patch.ts,
|
|
4
4
|
// injected as a synchronous inline script before any modules execute.
|
|
5
5
|
|
|
6
|
-
import type { ActionArgs, ActionType } from '../core/action'
|
|
7
|
-
import type { ErrorEntry, LogEntry, NetworkRequest } from '../core/types'
|
|
8
|
-
import { INTERACTIVE, performAction, runEval, withDialogGuard } from '../core/action'
|
|
6
|
+
import type { ActionArgs, ActionResult, ActionType } from '../core/action'
|
|
7
|
+
import type { ActionEntry, ErrorEntry, LogEntry, NetworkRequest, PerformanceData, PerfBucketData, ScreenSnap } from '../core/types'
|
|
8
|
+
import { elementLabel, getDirectText, INTERACTIVE, isSensitive, performAction, reachable, runEval, safeValue, withDialogGuard } from '../core/action'
|
|
9
|
+
import { diffScreen, diffState, stringifyDomain } from '../core/diff'
|
|
10
|
+
import { formatActions } from '../core/util'
|
|
9
11
|
|
|
10
12
|
declare global {
|
|
11
|
-
interface Window {
|
|
13
|
+
interface Window {
|
|
14
|
+
__AIPEEK_STORES__?: Record<string, unknown>
|
|
15
|
+
// The app's own domain projection — collapses the UI to a few domain variables
|
|
16
|
+
// that don't live in the DOM (是否流式 / 选了哪个模型 / 未读数). Optional; when
|
|
17
|
+
// absent /screen falls back to the generic view/modal/focus projection unchanged.
|
|
18
|
+
__AIPEEK_SCREEN__?: () => Record<string, unknown>
|
|
19
|
+
// Idempotency for shortest-path delivery: the server re-broadcasts an addressed action
|
|
20
|
+
// while a tab is mid self-heal, so the same action id can arrive more than once. Map id →
|
|
21
|
+
// its final result; on a repeat we replay the cached result instead of re-executing.
|
|
22
|
+
__AIPEEK_DONE_ACTIONS__?: Map<number, ActionResult>
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Internal perf buffer shape (from client-patch.ts) — differs from PerformanceData
|
|
27
|
+
interface PerfStatBuf { total: number, n: number, max: number, samples: number[] }
|
|
28
|
+
interface PerfBucketBuf {
|
|
29
|
+
components: Record<string, PerfStatBuf>
|
|
30
|
+
frames: { total: number, long: number, max: number, samples: number[] }
|
|
31
|
+
lines: Record<string, PerfStatBuf>
|
|
32
|
+
}
|
|
33
|
+
interface PerfBuffer {
|
|
34
|
+
startedAt: number
|
|
35
|
+
hiddenFrames: number
|
|
36
|
+
mobxPatched: boolean
|
|
37
|
+
buckets: Record<string, PerfBucketBuf>
|
|
38
|
+
active: string
|
|
39
|
+
longtasks: { count: number, max: number }
|
|
12
40
|
}
|
|
13
41
|
|
|
14
42
|
interface AipeekBuffers {
|
|
15
43
|
consoleLogs: LogEntry[]
|
|
16
44
|
networkRequests: NetworkRequest[]
|
|
17
45
|
errorEntries: ErrorEntry[]
|
|
46
|
+
actionEntries: ActionEntry[]
|
|
47
|
+
perf: PerfBuffer
|
|
18
48
|
}
|
|
19
49
|
|
|
20
50
|
// Read buffers from the synchronous patch script
|
|
@@ -22,6 +52,71 @@ const buffers = (window as any).__AIPEEK_BUFFERS__ as AipeekBuffers | undefined
|
|
|
22
52
|
const consoleLogs: LogEntry[] = buffers?.consoleLogs ?? []
|
|
23
53
|
const networkRequests: NetworkRequest[] = buffers?.networkRequests ?? []
|
|
24
54
|
const errorEntries: ErrorEntry[] = buffers?.errorEntries ?? []
|
|
55
|
+
const actionEntries: ActionEntry[] = buffers?.actionEntries ?? []
|
|
56
|
+
const perfBuffer: PerfBuffer | undefined = buffers?.perf
|
|
57
|
+
|
|
58
|
+
// Project the runtime perf buffer into PerformanceData for collection.
|
|
59
|
+
// Top-20 components by total, capped samples already in buffer.
|
|
60
|
+
function collectPerformance(): PerformanceData | undefined {
|
|
61
|
+
if (!perfBuffer) return undefined
|
|
62
|
+
const buckets: PerfBucketData[] = []
|
|
63
|
+
// __all__ first, then named buckets
|
|
64
|
+
const names = Object.keys(perfBuffer.buckets).sort((a, b) => (a === '__all__' ? -1 : b === '__all__' ? 1 : a.localeCompare(b)))
|
|
65
|
+
for (const name of names) {
|
|
66
|
+
const b = perfBuffer.buckets[name]
|
|
67
|
+
const comps: Record<string, { total: number, n: number, max: number, samples: number[] }> = {}
|
|
68
|
+
const sorted = Object.entries(b.components).sort((a, b) => b[1].total - a[1].total).slice(0, 20)
|
|
69
|
+
for (const [cname, stat] of sorted) {
|
|
70
|
+
comps[cname] = { total: stat.total, n: stat.n, max: stat.max, samples: [...stat.samples] }
|
|
71
|
+
}
|
|
72
|
+
const lines: Record<string, { total: number, n: number, max: number, samples: number[] }> = {}
|
|
73
|
+
const sortedLines = Object.entries(b.lines ?? {}).sort((a, b) => b[1].total - a[1].total).slice(0, 30)
|
|
74
|
+
for (const [label, stat] of sortedLines) {
|
|
75
|
+
lines[label] = { total: stat.total, n: stat.n, max: stat.max, samples: [...stat.samples] }
|
|
76
|
+
}
|
|
77
|
+
buckets.push({
|
|
78
|
+
name,
|
|
79
|
+
components: comps,
|
|
80
|
+
frames: { total: b.frames.total, long: b.frames.long, max: b.frames.max, samples: [...b.frames.samples] },
|
|
81
|
+
lines,
|
|
82
|
+
})
|
|
83
|
+
}
|
|
84
|
+
return {
|
|
85
|
+
windowMs: Date.now() - perfBuffer.startedAt,
|
|
86
|
+
hiddenFrames: perfBuffer.hiddenFrames,
|
|
87
|
+
mobxPatched: perfBuffer.mobxPatched,
|
|
88
|
+
buckets,
|
|
89
|
+
longtasks: { ...perfBuffer.longtasks },
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Reset the perf buffer (clear window, start fresh)
|
|
94
|
+
function resetPerf() {
|
|
95
|
+
if (!perfBuffer) return
|
|
96
|
+
perfBuffer.startedAt = Date.now()
|
|
97
|
+
perfBuffer.hiddenFrames = 0
|
|
98
|
+
perfBuffer.buckets = {}
|
|
99
|
+
perfBuffer.longtasks = { count: 0, max: 0 }
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Stable per-tab id so commands can address one tab among many (?tab=). Lives in
|
|
103
|
+
// sessionStorage: one browser tab = one id, survives HMR/refresh, cleared on close.
|
|
104
|
+
// Falls back to an in-memory id if sessionStorage is unavailable (degrades to old
|
|
105
|
+
// broadcast behavior, never throws).
|
|
106
|
+
const TAB_ID = (() => {
|
|
107
|
+
try {
|
|
108
|
+
const k = '__aipeek_tab_id__'
|
|
109
|
+
let id = sessionStorage.getItem(k)
|
|
110
|
+
if (!id) {
|
|
111
|
+
id = `t${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`
|
|
112
|
+
sessionStorage.setItem(k, id)
|
|
113
|
+
}
|
|
114
|
+
return id
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
return `t${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`
|
|
118
|
+
}
|
|
119
|
+
})()
|
|
25
120
|
|
|
26
121
|
// --- On-demand collectors ---
|
|
27
122
|
|
|
@@ -228,18 +323,6 @@ function collectDomFallback(): string {
|
|
|
228
323
|
return lines.join('\n')
|
|
229
324
|
}
|
|
230
325
|
|
|
231
|
-
function getDirectText(el: Element): string {
|
|
232
|
-
let text = ''
|
|
233
|
-
for (const node of el.childNodes) {
|
|
234
|
-
if (node.nodeType === Node.TEXT_NODE) {
|
|
235
|
-
const t = node.textContent?.trim()
|
|
236
|
-
if (t)
|
|
237
|
-
text += (text ? ' ' : '') + t
|
|
238
|
-
}
|
|
239
|
-
}
|
|
240
|
-
return text
|
|
241
|
-
}
|
|
242
|
-
|
|
243
326
|
// --- Semantic DOM collector ---
|
|
244
327
|
// UI-as-text: strip Tailwind atomic classes, keep only what carries meaning —
|
|
245
328
|
// tag, role, semantic classes, data-*/aria, text, interactive state, a usable selector.
|
|
@@ -396,14 +479,28 @@ function baseState(el: Element): string[] {
|
|
|
396
479
|
return parts
|
|
397
480
|
}
|
|
398
481
|
|
|
482
|
+
// Heuristic: does opening this control need a *trusted* event (realclick), not a synthetic
|
|
483
|
+
// one? Synthetic dispatchEvent can't open a Radix portal popup reliably (and never a native
|
|
484
|
+
// right-click menu). aria-haspopup is the W3C marker Radix puts on menu/dropdown/popover/
|
|
485
|
+
// select triggers — the honest, low-false-positive signal. Tag it so the AI picks realclick
|
|
486
|
+
// up front instead of discovering it by a failed synthetic click. The `?` keeps it honest:
|
|
487
|
+
// a hint, not a guarantee (right-click-only handlers carry no DOM attribute, so they can't be
|
|
488
|
+
// detected here — those still surface only on a miss).
|
|
489
|
+
function needsTrusted(el: Element): boolean {
|
|
490
|
+
const hp = el.getAttribute('aria-haspopup')
|
|
491
|
+
return hp === 'menu' || hp === 'dialog' || hp === 'listbox' || hp === 'true'
|
|
492
|
+
}
|
|
493
|
+
|
|
399
494
|
function domState(el: Element): string {
|
|
400
495
|
const parts = baseState(el)
|
|
401
496
|
if (el.getAttribute('aria-selected') === 'true' || el.getAttribute('aria-checked') === 'true')
|
|
402
497
|
parts.push('selected')
|
|
403
498
|
if ((el as HTMLInputElement).value)
|
|
404
|
-
parts.push(`value="${(el
|
|
499
|
+
parts.push(`value="${safeValue(el).slice(0, 40)}"`)
|
|
405
500
|
if (el.hasAttribute('contenteditable'))
|
|
406
501
|
parts.push('editable')
|
|
502
|
+
if (needsTrusted(el))
|
|
503
|
+
parts.push('needs-trusted?')
|
|
407
504
|
return parts.length ? ` {${parts.join(' ')}}` : ''
|
|
408
505
|
}
|
|
409
506
|
|
|
@@ -447,9 +544,12 @@ export function collectDom(scope?: string, sel?: string): string {
|
|
|
447
544
|
// data-* minus framework noise. data-insp-path (code-inspector) is the source
|
|
448
545
|
// location — keep it, but compacted to its file:line tail, shown only when the
|
|
449
546
|
// node is itself meaningful (carries role/text/state), not on bare wrappers.
|
|
547
|
+
// On a secret field, redact data-* values too — an app could mirror the value into
|
|
548
|
+
// a data attr, and we never want that echoed (same stance as the value redaction).
|
|
549
|
+
const secret = isSensitive(el)
|
|
450
550
|
const dataAttrs = Array.from(el.attributes)
|
|
451
551
|
.filter(a => a.name.startsWith('data-') && a.name !== 'data-testid' && a.name !== 'data-insp-path')
|
|
452
|
-
.map(a => a.value ? `${a.name}="${a.value.slice(0, 30)}"` : a.name)
|
|
552
|
+
.map(a => a.value ? `${a.name}="${secret ? `‹redacted ${a.value.length}›` : a.value.slice(0, 30)}"` : a.name)
|
|
453
553
|
.slice(0, 3)
|
|
454
554
|
const aria = el.getAttribute('aria-label') || ''
|
|
455
555
|
const text = aria || getDirectText(el)
|
|
@@ -508,7 +608,9 @@ function collectState(): Record<string, unknown> {
|
|
|
508
608
|
// — the few variables a human reads off a washing-machine panel to act in O(1).
|
|
509
609
|
// knobs are clipped to what's reachable *now*: when a modal is open, only its
|
|
510
610
|
// subtree counts (the layer beneath is unclickable).
|
|
511
|
-
|
|
611
|
+
// The state-machine projection — just the three vars that move when the UI transitions.
|
|
612
|
+
// Shared by collectScreen (the /screen header) and the action handler's before/after diff.
|
|
613
|
+
export function screenSnap(): ScreenSnap {
|
|
512
614
|
const stores = window.__AIPEEK_STORES__ as Record<string, any> | undefined
|
|
513
615
|
const view = stores?.appUIStore?.mode ?? '(unknown)'
|
|
514
616
|
|
|
@@ -518,22 +620,37 @@ export function collectScreen(): string {
|
|
|
518
620
|
: ''
|
|
519
621
|
const modal = modalEl ? `${modalTitle || 'untitled'} @${inspPath(modalEl) || 'dialog'}` : 'none'
|
|
520
622
|
|
|
521
|
-
// Icon buttons carry no direct text — fall back to title, then any nested text.
|
|
522
|
-
const labelOf = (el: Element) => (el.getAttribute('aria-label') || getDirectText(el) || el.getAttribute('title') || (el.textContent || '').trim()).trim()
|
|
523
|
-
const visible = (el: Element) => el.getBoundingClientRect().width > 0
|
|
524
|
-
|
|
525
623
|
const active = document.activeElement
|
|
526
624
|
const focus = active && active !== document.body
|
|
527
|
-
? `${
|
|
625
|
+
? `${elementLabel(active).slice(0, 30) || active.tagName.toLowerCase()} [${active.tagName.toLowerCase()}]${domState(active)}`
|
|
528
626
|
: 'none'
|
|
529
627
|
|
|
628
|
+
let domain: Record<string, unknown> = {}
|
|
629
|
+
try {
|
|
630
|
+
domain = window.__AIPEEK_SCREEN__?.() ?? {}
|
|
631
|
+
}
|
|
632
|
+
catch {
|
|
633
|
+
domain = { '[error]': '__AIPEEK_SCREEN__ threw' }
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
return { view, modal, focus, domain }
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
export function collectScreen(): string {
|
|
640
|
+
const { view, modal, focus, domain } = screenSnap()
|
|
641
|
+
const domainLines = Object.entries(domain).map(([k, v]) => ` ${k}: ${stringifyDomain(v)}`)
|
|
642
|
+
|
|
643
|
+
const modalEl = document.querySelector('[role="dialog"][data-state="open"]')
|
|
644
|
+
|
|
530
645
|
// Group by source location. A source rendered many times is a *list* (content),
|
|
531
646
|
// not a control — collapse it to one "source ×N" line. The washing-machine
|
|
532
647
|
// panel shows "the laundry", not every garment.
|
|
533
648
|
const root = modalEl ?? document.body
|
|
534
649
|
const bySource = new Map<string, Element[]>()
|
|
535
650
|
for (const el of root.querySelectorAll(INTERACTIVE)) {
|
|
536
|
-
|
|
651
|
+
// reachable, not just isVisible: only list knobs a click can actually land on now —
|
|
652
|
+
// same predicate /click uses, so read and write agree (no "listed but unclickable").
|
|
653
|
+
if (!reachable(el))
|
|
537
654
|
continue
|
|
538
655
|
const loc = inspPath(el) || '?'
|
|
539
656
|
if (!bySource.has(loc))
|
|
@@ -547,7 +664,7 @@ export function collectScreen(): string {
|
|
|
547
664
|
continue
|
|
548
665
|
}
|
|
549
666
|
for (const el of els) {
|
|
550
|
-
const label =
|
|
667
|
+
const label = elementLabel(el).slice(0, 40)
|
|
551
668
|
knobs.push(` ${label || `<${el.tagName.toLowerCase()}>`} [${el.tagName.toLowerCase()}]${loc !== '?' ? ` ${loc}` : ''}${domState(el)}`)
|
|
552
669
|
}
|
|
553
670
|
if (knobs.length >= 40)
|
|
@@ -556,6 +673,7 @@ export function collectScreen(): string {
|
|
|
556
673
|
|
|
557
674
|
return [
|
|
558
675
|
`view: ${view}`,
|
|
676
|
+
...(domainLines.length ? [`domain:`, ...domainLines] : []),
|
|
559
677
|
`modal: ${modal}`,
|
|
560
678
|
`focus: ${focus}`,
|
|
561
679
|
`knobs (${knobs.length}):`,
|
|
@@ -563,6 +681,22 @@ export function collectScreen(): string {
|
|
|
563
681
|
].join('\n')
|
|
564
682
|
}
|
|
565
683
|
|
|
684
|
+
// Called from the action handler after the DOM has settled. The synthetic event aipeek
|
|
685
|
+
// just fired was captured into actionEntries by client-patch; find that entry (latest
|
|
686
|
+
// synthetic) and back-fill its snapshot synchronously off the now-settled DOM — not via
|
|
687
|
+
// client-patch's async 180ms stamp, which may not have run yet (Risk 1). Then render the
|
|
688
|
+
// timeline anchored on it, so the model sees its own action between before/after context.
|
|
689
|
+
function attachTimeline(): string {
|
|
690
|
+
const cur = [...actionEntries].reverse().find(e => !e.trusted)
|
|
691
|
+
if (cur) {
|
|
692
|
+
const stores = window.__AIPEEK_STORES__ as Record<string, any> | undefined
|
|
693
|
+
cur.view = stores?.appUIStore?.mode ?? '(unknown)'
|
|
694
|
+
const modalEl = document.querySelector('[role="dialog"][data-state="open"]')
|
|
695
|
+
cur.modal = modalEl ? (modalEl.querySelector('h1, h2, [id^="radix"]')?.textContent || 'untitled').trim().slice(0, 40) : 'none'
|
|
696
|
+
}
|
|
697
|
+
return formatActions([...actionEntries], cur?.ts)
|
|
698
|
+
}
|
|
699
|
+
|
|
566
700
|
function boundedSnapshot(obj: unknown, depth = 0): unknown {
|
|
567
701
|
if (depth > 3)
|
|
568
702
|
return '[…]'
|
|
@@ -621,59 +755,138 @@ function waitForStable(quiet = 150, timeout = 2000): Promise<string> {
|
|
|
621
755
|
// would answer. So the server sends requireVisible=true first; if it times out with
|
|
622
756
|
// no answer it retries requireVisible=false, and every tab answers (the original
|
|
623
757
|
// race). `skip` collapses that into one guard: hidden tab skips only round one.
|
|
624
|
-
function skip(msg?: { requireVisible?: boolean }) {
|
|
758
|
+
function skip(msg?: { requireVisible?: boolean, tab?: string }) {
|
|
759
|
+
if (msg?.tab) // addressed by id: only that tab answers, regardless of visibility (incl. background)
|
|
760
|
+
return msg.tab !== TAB_ID
|
|
625
761
|
return msg?.requireVisible !== false && document.visibilityState !== 'visible'
|
|
626
762
|
}
|
|
627
763
|
|
|
628
764
|
if (import.meta.hot) {
|
|
629
|
-
|
|
765
|
+
// 主动报到:补上 roster 缺失的「注册边」。在此之前 roster 只在收到轮询回复时(aipeek:state/
|
|
766
|
+
// result/dom/screen)才登记一个 tab——于是会话里第一个命令若是 /tabs(纯 roster 读,自身不轮询),
|
|
767
|
+
// 即使前台开着健康 tab 也必然读到 (no live tabs)。连上即 hello、visibilitychange 重发,roster
|
|
768
|
+
// 实时为真。TAB_ID 存 sessionStorage 跨自愈 reload 存活,reload 后 hello 自然重放。
|
|
769
|
+
const hello = () => import.meta.hot!.send('aipeek:hello', {
|
|
770
|
+
tab: TAB_ID,
|
|
771
|
+
url: location.href,
|
|
772
|
+
title: document.title,
|
|
773
|
+
visible: document.visibilityState === 'visible',
|
|
774
|
+
})
|
|
775
|
+
hello()
|
|
776
|
+
addEventListener('visibilitychange', hello)
|
|
777
|
+
|
|
778
|
+
import.meta.hot.on('aipeek:collect', (msg: { requireVisible?: boolean, tab?: string }) => {
|
|
630
779
|
if (skip(msg))
|
|
631
780
|
return
|
|
632
781
|
import.meta.hot!.send('aipeek:state', {
|
|
782
|
+
tab: TAB_ID,
|
|
633
783
|
url: location.href,
|
|
784
|
+
title: document.title,
|
|
785
|
+
visible: document.visibilityState === 'visible',
|
|
634
786
|
ui: collectUI(),
|
|
635
787
|
console: [...consoleLogs],
|
|
636
788
|
network: [...networkRequests],
|
|
637
789
|
errors: [...errorEntries],
|
|
790
|
+
actions: [...actionEntries],
|
|
638
791
|
state: collectState(),
|
|
792
|
+
performance: collectPerformance(),
|
|
639
793
|
timestamp: Date.now(),
|
|
640
794
|
})
|
|
641
795
|
})
|
|
642
796
|
|
|
643
|
-
import.meta.hot.on('aipeek:action', async (msg: { id: number, type: ActionType, args: ActionArgs, requireVisible?: boolean }) => {
|
|
797
|
+
import.meta.hot.on('aipeek:action', async (msg: { id: number, type: ActionType, args: ActionArgs, requireVisible?: boolean, tab?: string }) => {
|
|
644
798
|
if (skip(msg))
|
|
645
799
|
return
|
|
800
|
+
// Idempotency: the server re-broadcasts an addressed action while the tab is mid self-heal,
|
|
801
|
+
// so the same id can land twice. Execute once per id; on a repeat replay the cached result
|
|
802
|
+
// (re-settles the server's pendingActions slot if the original reply was lost in teardown).
|
|
803
|
+
const done = (window.__AIPEEK_DONE_ACTIONS__ ??= new Map<number, ActionResult>())
|
|
804
|
+
const cached = done.get(msg.id)
|
|
805
|
+
if (cached) {
|
|
806
|
+
import.meta.hot!.send('aipeek:result', { tab: TAB_ID, id: msg.id, ...cached })
|
|
807
|
+
return
|
|
808
|
+
}
|
|
809
|
+
// Snapshot the state machine + error/network buffers *before* the action, so the
|
|
810
|
+
// post-settle diff is exactly what this action caused (see result.screen below).
|
|
811
|
+
const before = screenSnap()
|
|
812
|
+
const beforeConsole = [...consoleLogs]
|
|
813
|
+
const beforeNetwork = [...networkRequests]
|
|
814
|
+
const beforeErrors = [...errorEntries]
|
|
646
815
|
// Guard against native alert/confirm/prompt freezing the probe (see withDialogGuard).
|
|
647
816
|
const { result, dialogs } = await withDialogGuard(() => performAction(msg.type, msg.args))
|
|
648
817
|
if (dialogs.length)
|
|
649
818
|
result.detail = `${result.detail ?? ''} [auto-dismissed ${dialogs.join('; ')}]`.trim()
|
|
650
|
-
//
|
|
651
|
-
//
|
|
652
|
-
//
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
819
|
+
// realclick resolved to (x,y) but didn't click — synthetic events can't open a Radix
|
|
820
|
+
// ContextMenu. Fire a trusted click through whatever channel can: in Electron the page
|
|
821
|
+
// can reach the main process via electronAPI.invoke('aipeek:input') → sendInputEvent;
|
|
822
|
+
// in a plain Chrome tab it can't (no chrome.debugger from page JS), so leave ui
|
|
823
|
+
// undefined and let the server drive its extension queue.
|
|
824
|
+
// realclick resolved (x,y) but synthetic events can't open a Radix ContextMenu — fire
|
|
825
|
+
// a trusted click via Electron's main process if reachable, else leave it for the
|
|
826
|
+
// server's extension queue. Either path then settles like any other mutating action.
|
|
827
|
+
const electronAPI = (window as { electronAPI?: { invoke: (channel: string, ...args: unknown[]) => Promise<unknown> } }).electronAPI
|
|
828
|
+
if (msg.type === 'realclick' && result.ok && electronAPI) {
|
|
829
|
+
await electronAPI.invoke('aipeek:input', { type: 'click', button: msg.args.button ?? 'left', x: result.x, y: result.y })
|
|
830
|
+
result.fired = true // server: trusted click already fired in-process, skip the extension queue
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
// For mutating actions, settle the DOM then ship *only the change this action caused*
|
|
834
|
+
// (state-machine transition + new errors/failed requests), not a fresh snapshot. AI
|
|
835
|
+
// reads the delta and drills into /ui or /dom for detail if needed. The timeline still
|
|
836
|
+
// surfaces concurrent user actions.
|
|
837
|
+
const mutated = msg.type === 'click' || msg.type === 'fill' || msg.type === 'press'
|
|
838
|
+
|| (msg.type === 'realclick' && !!electronAPI)
|
|
839
|
+
if (result.ok && mutated) {
|
|
840
|
+
await waitForStable()
|
|
841
|
+
const after = screenSnap()
|
|
842
|
+
const d = diffState(
|
|
843
|
+
{ ui: '', console: beforeConsole, network: beforeNetwork, errors: beforeErrors, state: {}, url: '', timestamp: 0 },
|
|
844
|
+
{ ui: '', console: [...consoleLogs], network: [...networkRequests], errors: [...errorEntries], state: {}, url: '', timestamp: 0 },
|
|
845
|
+
)
|
|
846
|
+
const changed = diffScreen(before, after, d.newErrors, d.newExceptions, d.newFailedRequests)
|
|
847
|
+
result.screen = changed.length ? changed.join('\n') : '(no state change)'
|
|
848
|
+
result.actions = attachTimeline()
|
|
656
849
|
}
|
|
657
|
-
|
|
850
|
+
done.set(msg.id, result)
|
|
851
|
+
if (done.size > 64)
|
|
852
|
+
done.delete(done.keys().next().value!)
|
|
853
|
+
import.meta.hot!.send('aipeek:result', { tab: TAB_ID, id: msg.id, ...result })
|
|
658
854
|
})
|
|
659
855
|
|
|
660
856
|
// eval: run server-supplied code in the page with auto-return (see runEval).
|
|
661
|
-
import.meta.hot.on('aipeek:eval', async (msg: { id: number, code: string, requireVisible?: boolean }) => {
|
|
857
|
+
import.meta.hot.on('aipeek:eval', async (msg: { id: number, code: string, requireVisible?: boolean, tab?: string }) => {
|
|
662
858
|
if (skip(msg))
|
|
663
859
|
return
|
|
664
860
|
const { ok, value, error } = await runEval(msg.code)
|
|
665
|
-
import.meta.hot!.send('aipeek:eval-result', { id: msg.id, ok, value, error })
|
|
861
|
+
import.meta.hot!.send('aipeek:eval-result', { tab: TAB_ID, id: msg.id, ok, value, error })
|
|
862
|
+
})
|
|
863
|
+
|
|
864
|
+
import.meta.hot.on('aipeek:collect-dom', (msg: { scope?: string, sel?: string, requireVisible?: boolean, tab?: string }) => {
|
|
865
|
+
if (skip(msg))
|
|
866
|
+
return
|
|
867
|
+
import.meta.hot!.send('aipeek:dom', { tab: TAB_ID, dom: collectDom(msg?.scope, msg?.sel) })
|
|
666
868
|
})
|
|
667
869
|
|
|
668
|
-
import.meta.hot.on('aipeek:collect-
|
|
870
|
+
import.meta.hot.on('aipeek:collect-screen', (msg: { requireVisible?: boolean, tab?: string }) => {
|
|
669
871
|
if (skip(msg))
|
|
670
872
|
return
|
|
671
|
-
|
|
873
|
+
// Ship the rendered text *and* the structured snap + buffers, so the server can serve
|
|
874
|
+
// /screen?since=<token> by diffing this snap against a stashed prior one (diffScreen).
|
|
875
|
+
import.meta.hot!.send('aipeek:screen', {
|
|
876
|
+
tab: TAB_ID,
|
|
877
|
+
screen: collectScreen(),
|
|
878
|
+
snap: screenSnap(),
|
|
879
|
+
console: [...consoleLogs],
|
|
880
|
+
network: [...networkRequests],
|
|
881
|
+
errors: [...errorEntries],
|
|
882
|
+
})
|
|
672
883
|
})
|
|
673
884
|
|
|
674
|
-
|
|
885
|
+
// Perf profiler handlers
|
|
886
|
+
import.meta.hot.on('aipeek:perf-reset', (msg: { requireVisible?: boolean, tab?: string }) => {
|
|
675
887
|
if (skip(msg))
|
|
676
888
|
return
|
|
677
|
-
|
|
889
|
+
resetPerf()
|
|
890
|
+
import.meta.hot!.send('aipeek:perf-reset-ack', { tab: TAB_ID })
|
|
678
891
|
})
|
|
679
892
|
}
|