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.
- package/README.md +92 -18
- package/dist/{chunk-STYCUT23.cjs → chunk-7ALIH3JX.cjs} +622 -46
- package/dist/{chunk-37VLLZIU.js → chunk-7NJSWR7E.js} +621 -45
- package/dist/index.cjs +2 -2
- package/dist/index.d.cts +43 -0
- package/dist/index.d.ts +43 -0
- package/dist/index.js +1 -1
- package/dist/plugin.cjs +2 -2
- package/dist/plugin.js +1 -1
- package/package.json +3 -1
- package/src/babel/line-profiler.ts +190 -0
- package/src/client/client-patch.ts +332 -2
- package/src/client/client.ts +259 -44
- package/src/core/action.ts +199 -22
- 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 +250 -0
- package/src/core/types.ts +73 -0
- package/src/core/util.ts +115 -0
- package/src/server/plugin.ts +463 -52
package/src/core/perf.ts
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
import type { PerformanceData, PerfStat, PerfBucketData } from './types.js'
|
|
2
|
+
|
|
3
|
+
// Verdict noise floors: a delta smaller than these is treated as measurement noise, not a real
|
|
4
|
+
// change. Frame %: ±3pp. Self-time fallback: 10% relative, but at least 5ms absolute.
|
|
5
|
+
const FRAME_NOISE_PCT = 3
|
|
6
|
+
const SELF_NOISE_REL = 0.1
|
|
7
|
+
const SELF_NOISE_MS = 5
|
|
8
|
+
|
|
9
|
+
const avgOf = (samples: number[]) =>
|
|
10
|
+
samples.length > 0 ? samples.reduce((s, v) => s + v, 0) / samples.length : 0
|
|
11
|
+
|
|
12
|
+
// Summarize accumulated stat into display shape. total/n/max are O(1) streaming all-time
|
|
13
|
+
// truths; avg derives from them. No p95 here: it would need a per-key sample buffer, which is
|
|
14
|
+
// near-window while total/n/max are all-time — putting two different sample sets in one row
|
|
15
|
+
// (a row implies one distribution). max already gives the worst case, avg the center; that's
|
|
16
|
+
// enough to decide "is this worth optimizing" without a same-row lie.
|
|
17
|
+
export function summarizeStat(stat: PerfStat) {
|
|
18
|
+
const avg = stat.n > 0 ? stat.total / stat.n : 0
|
|
19
|
+
return {
|
|
20
|
+
total: stat.total,
|
|
21
|
+
n: stat.n,
|
|
22
|
+
avg,
|
|
23
|
+
max: stat.max,
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Merge two stats (for __all__ aggregation or HMR accumulation)
|
|
28
|
+
export function mergeStat(a: PerfStat, b: PerfStat): PerfStat {
|
|
29
|
+
return {
|
|
30
|
+
total: a.total + b.total,
|
|
31
|
+
n: a.n + b.n,
|
|
32
|
+
max: Math.max(a.max, b.max),
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Which buckets a single hit (component/line/frame) writes to: the live view + the __all__
|
|
37
|
+
// aggregate. When the active view IS __all__, return it ONCE — else every hit double-counts
|
|
38
|
+
// into __all__ (n, total, frames all 2×). Mirrors client-patch.ts targetBuckets().
|
|
39
|
+
export function targetBuckets(active: string): string[] {
|
|
40
|
+
return active === '__all__' ? ['__all__'] : [active, '__all__']
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// One-line summary: 23fps · 18% dropped · 4 longtasks(max 120ms) · hot: ChatList 340ms, Markdown 120ms
|
|
44
|
+
export function compactPerformance(p: PerformanceData): string {
|
|
45
|
+
const all = p.buckets.find(b => b.name === '__all__')
|
|
46
|
+
if (!all) return 'perf: (no data)'
|
|
47
|
+
|
|
48
|
+
const { total, long, samples } = all.frames
|
|
49
|
+
const fps = total > 0 ? Math.round((1000 / avgOf(samples)) * 10) / 10 : 0
|
|
50
|
+
const droppedPct = total > 0 ? Math.round((long / total) * 100) : 0
|
|
51
|
+
|
|
52
|
+
const parts: string[] = [`${fps}fps`, `${droppedPct}% dropped`]
|
|
53
|
+
|
|
54
|
+
if (p.longtasks.count > 0) {
|
|
55
|
+
parts.push(`${p.longtasks.count} longtasks(max ${Math.round(p.longtasks.max)}ms)`)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Top 2 components by total
|
|
59
|
+
const topComps = Object.entries(all.components)
|
|
60
|
+
.map(([name, stat]) => ({ name, total: stat.total }))
|
|
61
|
+
.sort((a, b) => b.total - a.total)
|
|
62
|
+
.slice(0, 2)
|
|
63
|
+
|
|
64
|
+
if (topComps.length > 0 && p.mobxPatched) {
|
|
65
|
+
const hot = topComps.map(c => `${c.name} ${Math.round(c.total)}ms`).join(', ')
|
|
66
|
+
parts.push(`hot: ${hot}`)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return `perf: ${parts.join(' · ')}`
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Full report adapting litcode __recReport
|
|
73
|
+
export function detailPerformance(p: PerformanceData): string {
|
|
74
|
+
const lines: string[] = []
|
|
75
|
+
|
|
76
|
+
// Banner
|
|
77
|
+
const windowSec = (p.windowMs / 1000).toFixed(1)
|
|
78
|
+
lines.push(`=== Performance (${windowSec}s window) ===`)
|
|
79
|
+
if (p.hiddenFrames > 0) {
|
|
80
|
+
lines.push(`⚠ Tab was backgrounded — ${p.hiddenFrames} hidden frames skipped (rAF throttling lies about timing)`)
|
|
81
|
+
lines.push('For accurate profiling: keep tab FOREGROUND + run /profile/reset before reproducing')
|
|
82
|
+
}
|
|
83
|
+
lines.push(`MobX attribution: ${p.mobxPatched ? 'ON' : 'OFF'}`)
|
|
84
|
+
if (!p.mobxPatched) {
|
|
85
|
+
lines.push('(Component attribution disabled — set window.__AIPEEK_MOBX__={Reaction} in src/lib/mobx.ts to enable)')
|
|
86
|
+
}
|
|
87
|
+
lines.push('')
|
|
88
|
+
|
|
89
|
+
// longtasks
|
|
90
|
+
if (p.longtasks.count > 0) {
|
|
91
|
+
lines.push(`Longtasks (>50ms): ${p.longtasks.count}, max ${Math.round(p.longtasks.max)}ms`)
|
|
92
|
+
lines.push('')
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Buckets (always show __all__, then per-view only if >1 bucket)
|
|
96
|
+
const showPerView = p.buckets.length > 1
|
|
97
|
+
for (const bucket of p.buckets) {
|
|
98
|
+
if (bucket.name !== '__all__' && !showPerView) continue
|
|
99
|
+
|
|
100
|
+
lines.push(`--- ${bucket.name} ---`)
|
|
101
|
+
|
|
102
|
+
// Frame stats
|
|
103
|
+
const { total, long, max, samples } = bucket.frames
|
|
104
|
+
const droppedPct = total > 0 ? ((long / total) * 100).toFixed(1) : '0.0'
|
|
105
|
+
const avgFrame = avgOf(samples).toFixed(1)
|
|
106
|
+
lines.push(`Frames: ${total} total, ${long} dropped (${droppedPct}%), max ${Math.round(max)}ms, avg ${avgFrame}ms`)
|
|
107
|
+
|
|
108
|
+
// Components (top 20 by total, desc)
|
|
109
|
+
const comps = Object.entries(bucket.components)
|
|
110
|
+
.map(([name, stat]) => ({ name, ...summarizeStat(stat) }))
|
|
111
|
+
.sort((a, b) => b.total - a.total)
|
|
112
|
+
.slice(0, 20)
|
|
113
|
+
|
|
114
|
+
if (comps.length > 0 && p.mobxPatched) {
|
|
115
|
+
lines.push('Components (by total self-time):')
|
|
116
|
+
for (const c of comps) {
|
|
117
|
+
lines.push(` ${c.name.padEnd(30)} ${Math.round(c.total)}ms / ${c.n}× = ${c.avg.toFixed(1)}ms avg, max ${Math.round(c.max)}ms`)
|
|
118
|
+
}
|
|
119
|
+
} else if (!p.mobxPatched) {
|
|
120
|
+
lines.push('(no component data — attribution OFF)')
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Hot lines (top 15 by total time) — babel line-level instrumentation.
|
|
124
|
+
// Orthogonal to component attribution: points at the exact source line, not just the component.
|
|
125
|
+
// INCLUSIVE, not self-time: __line wraps each statement, so an outer statement's timing
|
|
126
|
+
// contains every nested __line below it (`const x = foo(bar())` counts foo+bar). Labeling
|
|
127
|
+
// this "self-time" would lie — you'd optimize a high-level line that just sums its callees.
|
|
128
|
+
const hotLines = Object.entries(bucket.lines ?? {})
|
|
129
|
+
.map(([label, stat]) => ({ label, ...summarizeStat(stat) }))
|
|
130
|
+
.sort((a, b) => b.total - a.total)
|
|
131
|
+
.slice(0, 15)
|
|
132
|
+
|
|
133
|
+
if (hotLines.length > 0) {
|
|
134
|
+
lines.push('Hot lines (by total inclusive time — contains nested calls):')
|
|
135
|
+
for (const l of hotLines) {
|
|
136
|
+
lines.push(` ${l.label.padEnd(36)} ${Math.round(l.total)}ms / ${l.n}× = ${l.avg.toFixed(1)}ms avg, max ${Math.round(l.max)}ms`)
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
lines.push('')
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return lines.join('\n')
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Closed-loop verdict: did my fix work? Compares two snapshots (before/after) by component
|
|
147
|
+
// name + line label, lists per-key total-ms deltas, and a headline verdict driven by the
|
|
148
|
+
// __all__ frame dropped% change. This is what lets an automated fix→measure loop decide
|
|
149
|
+
// "keep it" vs "revert and try again" without eyeballing two reports.
|
|
150
|
+
export function diffPerformance(before: PerformanceData, after: PerformanceData): string {
|
|
151
|
+
const out: string[] = []
|
|
152
|
+
|
|
153
|
+
const beforeAll = before.buckets.find(b => b.name === '__all__')
|
|
154
|
+
const afterAll = after.buckets.find(b => b.name === '__all__')
|
|
155
|
+
|
|
156
|
+
const droppedPct = (b: PerfBucketData | undefined) =>
|
|
157
|
+
b && b.frames.total > 0 ? (b.frames.long / b.frames.total) * 100 : 0
|
|
158
|
+
const beforeDrop = droppedPct(beforeAll)
|
|
159
|
+
const afterDrop = droppedPct(afterAll)
|
|
160
|
+
const dropDelta = afterDrop - beforeDrop
|
|
161
|
+
|
|
162
|
+
// Total self-time across all components — the fallback ground truth when frames weren't
|
|
163
|
+
// exercised (e.g. a fix reproduced via direct calls, not real renders → 0%→0% frames).
|
|
164
|
+
const sumTotal = (b: PerfBucketData | undefined) =>
|
|
165
|
+
b ? Object.values(b.components).reduce((s, c) => s + c.total, 0) : 0
|
|
166
|
+
const beforeSelf = sumTotal(beforeAll)
|
|
167
|
+
const afterSelf = sumTotal(afterAll)
|
|
168
|
+
const selfDelta = afterSelf - beforeSelf
|
|
169
|
+
|
|
170
|
+
// Verdict: frame health is the user-visible truth. But frames are only comparable when BOTH
|
|
171
|
+
// snapshots actually sampled frames AND at least one saw drops. If the after-tab went hidden
|
|
172
|
+
// its frameLoop stops (frames.total→0), which would read as a false "0% dropped" win — so fall
|
|
173
|
+
// back to self-time (MobX self-time keeps recording in the background) whenever a side is blind.
|
|
174
|
+
const bothSampledFrames = (beforeAll?.frames.total ?? 0) > 0 && (afterAll?.frames.total ?? 0) > 0
|
|
175
|
+
const sawDrops = (beforeAll?.frames.long ?? 0) > 0 || (afterAll?.frames.long ?? 0) > 0
|
|
176
|
+
const framesExercised = bothSampledFrames && sawDrops
|
|
177
|
+
|
|
178
|
+
// Blind-window guard: a backgrounded after-tab records NEITHER frames (rAF frozen) NOR
|
|
179
|
+
// self-time (nothing re-renders, so Reaction.track never fires). Both signals are absent, so
|
|
180
|
+
// selfDelta = 0 - beforeSelf is a large negative → a FALSE "IMPROVED" off an empty window.
|
|
181
|
+
// (The fallback comment above assumed self-time survives backgrounding; live data disproved it
|
|
182
|
+
// — a hidden tab renders nothing.) No data is not a win: refuse to render any direction.
|
|
183
|
+
const afterBlind = (afterAll?.frames.total ?? 0) === 0 && afterSelf === 0
|
|
184
|
+
let verdict: string
|
|
185
|
+
if (afterBlind) {
|
|
186
|
+
verdict = 'NO DATA'
|
|
187
|
+
} else if (framesExercised) {
|
|
188
|
+
if (dropDelta < -FRAME_NOISE_PCT) verdict = 'IMPROVED'
|
|
189
|
+
else if (dropDelta > FRAME_NOISE_PCT) verdict = 'REGRESSED'
|
|
190
|
+
else verdict = 'UNCHANGED'
|
|
191
|
+
} else {
|
|
192
|
+
const floor = Math.max(SELF_NOISE_MS, beforeSelf * SELF_NOISE_REL)
|
|
193
|
+
if (selfDelta < -floor) verdict = 'IMPROVED'
|
|
194
|
+
else if (selfDelta > floor) verdict = 'REGRESSED'
|
|
195
|
+
else verdict = 'UNCHANGED'
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
out.push(`=== Perf diff: ${verdict} ===`)
|
|
199
|
+
if (afterBlind) {
|
|
200
|
+
out.push('after-window recorded no frames and no self-time — tab backgrounded or idle. Re-run with the tab focused and the interaction reproduced.')
|
|
201
|
+
out.push('')
|
|
202
|
+
return out.join('\n').trimEnd()
|
|
203
|
+
}
|
|
204
|
+
out.push(`Dropped frames: ${beforeDrop.toFixed(1)}% → ${afterDrop.toFixed(1)}% (${dropDelta >= 0 ? '+' : ''}${dropDelta.toFixed(1)}%)`)
|
|
205
|
+
if (!framesExercised && Math.round(selfDelta) !== 0) {
|
|
206
|
+
out.push(`Total self-time: ${Math.round(beforeSelf)}ms → ${Math.round(afterSelf)}ms (${selfDelta >= 0 ? '+' : ''}${Math.round(selfDelta)}ms) — frames flat, verdict by self-time`)
|
|
207
|
+
}
|
|
208
|
+
if (before.longtasks.count !== after.longtasks.count) {
|
|
209
|
+
out.push(`Longtasks: ${before.longtasks.count} → ${after.longtasks.count}`)
|
|
210
|
+
}
|
|
211
|
+
out.push('')
|
|
212
|
+
|
|
213
|
+
// Per-component total-ms deltas (union of names), biggest absolute change first.
|
|
214
|
+
const compDelta = keyedTotalDelta(beforeAll?.components ?? {}, afterAll?.components ?? {})
|
|
215
|
+
if (compDelta.length > 0) {
|
|
216
|
+
out.push('Components (Δ total self-time):')
|
|
217
|
+
for (const d of compDelta) out.push(' ' + formatDelta(d))
|
|
218
|
+
out.push('')
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Per-line total-ms deltas (only when line instrumentation was on).
|
|
222
|
+
const lineDelta = keyedTotalDelta(beforeAll?.lines ?? {}, afterAll?.lines ?? {})
|
|
223
|
+
if (lineDelta.length > 0) {
|
|
224
|
+
out.push('Hot lines (Δ total inclusive time):')
|
|
225
|
+
for (const d of lineDelta) out.push(' ' + formatDelta(d))
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
return out.join('\n').trimEnd()
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
interface KeyDelta { key: string, before: number, after: number, delta: number, isNew: boolean, gone: boolean }
|
|
232
|
+
|
|
233
|
+
function keyedTotalDelta(a: Record<string, PerfStat>, b: Record<string, PerfStat>): KeyDelta[] {
|
|
234
|
+
const keys = new Set([...Object.keys(a), ...Object.keys(b)])
|
|
235
|
+
const rows: KeyDelta[] = []
|
|
236
|
+
for (const key of keys) {
|
|
237
|
+
const beforeT = a[key]?.total ?? 0
|
|
238
|
+
const afterT = b[key]?.total ?? 0
|
|
239
|
+
const delta = afterT - beforeT
|
|
240
|
+
if (Math.round(delta) === 0) continue // unchanged keys add noise
|
|
241
|
+
rows.push({ key, before: beforeT, after: afterT, delta, isNew: !a[key], gone: !b[key] })
|
|
242
|
+
}
|
|
243
|
+
return rows.sort((x, y) => Math.abs(y.delta) - Math.abs(x.delta)).slice(0, 20)
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function formatDelta(d: KeyDelta): string {
|
|
247
|
+
const sign = d.delta >= 0 ? '+' : ''
|
|
248
|
+
const tag = d.isNew ? ' (new)' : d.gone ? ' (gone)' : ''
|
|
249
|
+
return `${d.key.padEnd(36)} ${Math.round(d.before)}ms → ${Math.round(d.after)}ms (${sign}${Math.round(d.delta)}ms)${tag}`
|
|
250
|
+
}
|
package/src/core/types.ts
CHANGED
|
@@ -29,16 +29,76 @@ export interface ErrorEntry {
|
|
|
29
29
|
column?: number
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
// One semantic page action — a state-machine transition. trusted=real human / real
|
|
33
|
+
// input; !trusted=aipeek synthetic. view/modal/focus are the post-settle projection
|
|
34
|
+
// snapshot; the UI change ("dialog opened") is the diff against the prior entry.
|
|
35
|
+
export interface ActionEntry {
|
|
36
|
+
type: string
|
|
37
|
+
target: string
|
|
38
|
+
value?: string
|
|
39
|
+
trusted: boolean
|
|
40
|
+
view?: string
|
|
41
|
+
modal?: string
|
|
42
|
+
focus?: string
|
|
43
|
+
ts: number
|
|
44
|
+
tab?: string // set only in the server's cross-tab /timeline merge; undefined per-tab
|
|
45
|
+
}
|
|
46
|
+
|
|
32
47
|
export interface RawState {
|
|
48
|
+
tab?: string
|
|
33
49
|
url: string
|
|
50
|
+
title?: string
|
|
51
|
+
visible?: boolean
|
|
34
52
|
ui: string
|
|
35
53
|
console: LogEntry[]
|
|
36
54
|
network: NetworkRequest[]
|
|
37
55
|
errors: ErrorEntry[]
|
|
56
|
+
actions?: ActionEntry[]
|
|
38
57
|
state: Record<string, unknown>
|
|
58
|
+
performance?: PerformanceData
|
|
39
59
|
timestamp: number
|
|
40
60
|
}
|
|
41
61
|
|
|
62
|
+
// --- Performance profiler ---
|
|
63
|
+
//
|
|
64
|
+
// 语义分桶 profiler 的投影。三类正交信号(组件 self-time / 帧 dt / longtask)按 view 分桶,
|
|
65
|
+
// 答「哪个组件在烧帧」。client-patch 采集进 ring buffer,client 投影成此结构,core/perf.ts 纯渲染。
|
|
66
|
+
|
|
67
|
+
// 一段重复计时的累积统计。total/n/max 全是 O(1) 流式全程真值,avg=total/n 派生。
|
|
68
|
+
// 不存 samples:它过去只为 p95,而 p95 是近窗近似、和全程 total 不同源(放同一行即撒谎),已删。
|
|
69
|
+
export interface PerfStat {
|
|
70
|
+
total: number
|
|
71
|
+
n: number
|
|
72
|
+
max: number
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// 一个语义桶(一个 view,或全局 __all__):组件 self-time map + 帧统计 + 行级 self-time map。
|
|
76
|
+
export interface PerfBucketData {
|
|
77
|
+
name: string
|
|
78
|
+
components: Record<string, PerfStat>
|
|
79
|
+
frames: { total: number, long: number, max: number, samples: number[] }
|
|
80
|
+
lines: Record<string, PerfStat> // "File.tsx:42:varName" → stat,babel 行级插桩
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface PerformanceData {
|
|
84
|
+
windowMs: number // Date.now() - startedAt,采样窗口时长
|
|
85
|
+
hiddenFrames: number // 标签页隐藏期被跳过的帧(后台 rAF 节流会说谎,故不计入 frames)
|
|
86
|
+
mobxPatched: boolean // Reaction.track patch 是否生效(否则无组件归因)
|
|
87
|
+
buckets: PerfBucketData[] // __all__ 在前
|
|
88
|
+
longtasks: { count: number, max: number }
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// One live client (browser tab / Electron window) seen by the server. lastSeen is the
|
|
92
|
+
// server-side arrival time of its most recent reply — used to address one tab (?tab=)
|
|
93
|
+
// and to age out stale entries from /tabs.
|
|
94
|
+
export interface TabInfo {
|
|
95
|
+
id: string
|
|
96
|
+
url: string
|
|
97
|
+
title: string
|
|
98
|
+
visible: boolean
|
|
99
|
+
lastSeen: number
|
|
100
|
+
}
|
|
101
|
+
|
|
42
102
|
export interface CompactState {
|
|
43
103
|
url: string
|
|
44
104
|
ui: string
|
|
@@ -46,6 +106,7 @@ export interface CompactState {
|
|
|
46
106
|
network: string
|
|
47
107
|
errors: string
|
|
48
108
|
state: string
|
|
109
|
+
performance?: string
|
|
49
110
|
timestamp: number
|
|
50
111
|
counts: {
|
|
51
112
|
console: number
|
|
@@ -73,3 +134,15 @@ export interface DiffResult {
|
|
|
73
134
|
uiGone: boolean
|
|
74
135
|
clean: boolean
|
|
75
136
|
}
|
|
137
|
+
|
|
138
|
+
// The state-machine projection at one instant — what a click/fill is diffed against
|
|
139
|
+
// before vs after it settles. view/modal/focus are aipeek's generic UI vars; `domain`
|
|
140
|
+
// is the app's own state machine, injected via window.__AIPEEK_SCREEN__() — the few
|
|
141
|
+
// variables that DON'T live in the DOM (是否流式 / 选了哪个模型 / 未读数), which a
|
|
142
|
+
// DOM-only projector can never synthesize. Empty when the app injects nothing.
|
|
143
|
+
export interface ScreenSnap {
|
|
144
|
+
view: string
|
|
145
|
+
modal: string
|
|
146
|
+
focus: string
|
|
147
|
+
domain: Record<string, unknown>
|
|
148
|
+
}
|
package/src/core/util.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { ActionEntry, TabInfo } from './types'
|
|
2
|
+
|
|
1
3
|
export function truncate(s: string, max: number): string {
|
|
2
4
|
return s.length > max ? `${s.slice(0, max)}…` : s
|
|
3
5
|
}
|
|
@@ -67,3 +69,116 @@ export function compactUrl(url: string, search?: number): string {
|
|
|
67
69
|
return truncate(url, 80)
|
|
68
70
|
}
|
|
69
71
|
}
|
|
72
|
+
|
|
73
|
+
// A tab is "live" if it replied within STALE_MS (a closed tab stops replying, so its
|
|
74
|
+
// lastSeen freezes). One definition, shared by the server's roster/guard and formatTabs.
|
|
75
|
+
export const STALE_MS = 30_000
|
|
76
|
+
export const isLive = (t: TabInfo, now: number) => now - t.lastSeen < STALE_MS
|
|
77
|
+
|
|
78
|
+
// Render the live-tab roster — shared by GET /tabs and the 409 "which tab?" guard.
|
|
79
|
+
// `now` is server time. The visible one is marked first so the caller can pick.
|
|
80
|
+
export function formatTabs(tabs: TabInfo[], now: number): string {
|
|
81
|
+
const live = tabs.filter(t => isLive(t, now))
|
|
82
|
+
if (!live.length)
|
|
83
|
+
return '(no live tabs)'
|
|
84
|
+
return live
|
|
85
|
+
.sort((a, b) => Number(b.visible) - Number(a.visible) || a.id.localeCompare(b.id))
|
|
86
|
+
.map((t) => {
|
|
87
|
+
const age = Math.round((now - t.lastSeen) / 1000)
|
|
88
|
+
const mark = t.visible ? '● visible' : '○ background'
|
|
89
|
+
return `${t.id} ${mark} ${truncate(t.title || '(untitled)', 40)} ${compactUrl(t.url)} ${age}s ago`
|
|
90
|
+
})
|
|
91
|
+
.join('\n')}
|
|
92
|
+
|
|
93
|
+
// The single projection π for every "didn't get an answer" path: connection state → one
|
|
94
|
+
// actionable message. Defined once, pure (no closure, no clock), so no failure path can
|
|
95
|
+
// collapse two states that need different fixes (the law: ker π ⊆ ker f*). The five fibers
|
|
96
|
+
// each demand a different action, so each gets its own branch:
|
|
97
|
+
// addressed, live → handler threw / hung / read needs foreground (check console)
|
|
98
|
+
// addressed, not live → tab closed or mid self-heal (check /tabs, retry)
|
|
99
|
+
// broadcast, live tabs → handler threw or tab just closed (check /tabs + console)
|
|
100
|
+
// broadcast, sockets>0 → page open but client not injected/wrong server (check the port)
|
|
101
|
+
// broadcast, no socket → no browser pointed here at all (open the app)
|
|
102
|
+
// `socketCount` is the raw HMR-socket count (server.ws.clients.size) — the bit that separates
|
|
103
|
+
// "open but uninjected" from "no browser", which the aipeek roster alone can't see.
|
|
104
|
+
export function diagnose(tab: string | undefined, tabs: TabInfo[], now: number, socketCount: number, port: number): string {
|
|
105
|
+
if (tab) {
|
|
106
|
+
const t = tabs.find(x => x.id === tab)
|
|
107
|
+
return t && isLive(t, now)
|
|
108
|
+
? `tab '${tab}' is connected but didn't answer in time — the handler likely threw or hung (bad selector, infinite loop, or a read that needs the tab foreground). Check /__aipeek/console.`
|
|
109
|
+
: `tab '${tab}' is not connected — it closed, or the server restarted and it hasn't re-handshaked yet. Check /__aipeek/tabs for live ids; if the page is mid self-heal, retry in ~2s.`
|
|
110
|
+
}
|
|
111
|
+
const live = tabs.filter(t => isLive(t, now))
|
|
112
|
+
if (live.length)
|
|
113
|
+
return `${live.length} tab(s) connected but none answered — a handler threw, or they just closed. Check /__aipeek/tabs (still live?) and /__aipeek/console (errors?).`
|
|
114
|
+
return socketCount > 0
|
|
115
|
+
? `no aipeek tab registered, but ${socketCount} page(s) are connected to this dev server via HMR — the page is open yet the aipeek client didn't load. Is it served by THIS vite server on :${port}? A separate vite config / vite preview / production build won't inject it.`
|
|
116
|
+
: `no browser tab connected to this dev server on :${port} — open the app, or check you're hitting the right port.`
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Render the action ring as a cause-effect timeline. Each entry is a transition; the UI
|
|
120
|
+
// change (→ dialog opened / closed / view switched) is the diff of this entry's post-settle
|
|
121
|
+
// snapshot against the prior one *on the same tab*. `T`=trusted (human), `S`=synthetic
|
|
122
|
+
// (aipeek). With anchorTs set, the matching entry is bracketed by "你当前的行为" dividers so
|
|
123
|
+
// the model reads [earlier] → its own action → [what followed]. Pure: no DOM. Shared by the
|
|
124
|
+
// single-tab `recent actions` tail (client.ts) and the cross-tab GET /timeline (server).
|
|
125
|
+
export function formatActions(entries: ActionEntry[], anchorTs?: number): string {
|
|
126
|
+
if (!entries.length)
|
|
127
|
+
return '(no actions)'
|
|
128
|
+
const sorted = [...entries].sort((a, b) => a.ts - b.ts)
|
|
129
|
+
// Tag lines with their tab only when the stream spans >1 tab (the cross-tab /timeline).
|
|
130
|
+
// Single-tab `recent actions` stays byte-identical — no tab prefix. uiChange diffs per
|
|
131
|
+
// tab, so an interleaved A/B stream doesn't read B's modal as A's transition.
|
|
132
|
+
const multiTab = new Set(sorted.map(e => e.tab).filter(Boolean)).size > 1
|
|
133
|
+
const lines: string[] = []
|
|
134
|
+
const prevByTab = new Map<string | undefined, ActionEntry>()
|
|
135
|
+
let anchored = false
|
|
136
|
+
for (const e of sorted) {
|
|
137
|
+
const isAnchor = anchorTs !== undefined && !anchored && e.ts === anchorTs
|
|
138
|
+
if (isAnchor) {
|
|
139
|
+
lines.push('—————— 你当前的行为 ——————')
|
|
140
|
+
anchored = true
|
|
141
|
+
}
|
|
142
|
+
const who = e.trusted ? 'T' : 'S'
|
|
143
|
+
const tag = multiTab ? `${e.tab ?? '?'} ` : ''
|
|
144
|
+
const val = e.value ? ` value="${e.value}"` : ''
|
|
145
|
+
const change = uiChange(prevByTab.get(e.tab), e)
|
|
146
|
+
lines.push(`${tag}[${who}] ${e.type} ${e.target}${val}${change ? ` → ${change}` : ''}`)
|
|
147
|
+
if (isAnchor)
|
|
148
|
+
lines.push('——————————————————————————')
|
|
149
|
+
prevByTab.set(e.tab, e)
|
|
150
|
+
}
|
|
151
|
+
return lines.join('\n')
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Append an action to the server's cross-tab ring, mutating it in place. Mirrors
|
|
155
|
+
// client-patch's input debounce at the aggregation point: a tab fires its hook on every
|
|
156
|
+
// keystroke (rewriting its in-page entry in place), so consecutive same-tab input on the
|
|
157
|
+
// same target rewrites the logged entry instead of appending — else "hello" becomes 5 rows
|
|
158
|
+
// (h/he/hel/…). Any other action ends the run. Ring-bounded by max. Pure logic, fixture-tested.
|
|
159
|
+
export function appendAction(log: ActionEntry[], tab: string, entry: ActionEntry, max: number): void {
|
|
160
|
+
const last = log[log.length - 1]
|
|
161
|
+
if (entry.type === 'input' && last?.type === 'input' && last.tab === tab && last.target === entry.target) {
|
|
162
|
+
log[log.length - 1] = { ...entry, tab }
|
|
163
|
+
return
|
|
164
|
+
}
|
|
165
|
+
log.push({ ...entry, tab })
|
|
166
|
+
if (log.length > max)
|
|
167
|
+
log.shift()
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function uiChange(prev: ActionEntry | undefined, cur: ActionEntry): string {
|
|
171
|
+
if (cur.modal === undefined)
|
|
172
|
+
return ''
|
|
173
|
+
const prevModal = prev?.modal ?? 'none'
|
|
174
|
+
if (cur.modal !== prevModal) {
|
|
175
|
+
if (cur.modal === 'none')
|
|
176
|
+
return '弹窗关闭'
|
|
177
|
+
if (prevModal === 'none')
|
|
178
|
+
return `弹窗打开「${cur.modal}」`
|
|
179
|
+
return `弹窗切换「${cur.modal}」`
|
|
180
|
+
}
|
|
181
|
+
if (prev && cur.view !== undefined && cur.view !== prev.view)
|
|
182
|
+
return `视图 ${prev.view}→${cur.view}`
|
|
183
|
+
return ''
|
|
184
|
+
}
|