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,254 @@
|
|
|
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
|
+
// Which buckets a single hit (component/line/frame) writes to: the live view + the __all__
|
|
28
|
+
// aggregate. When the active view IS __all__, return it ONCE — else every hit double-counts
|
|
29
|
+
// into __all__ (n, total, frames all 2×). Mirrors client-patch.ts targetBuckets().
|
|
30
|
+
export function targetBuckets(active: string): string[] {
|
|
31
|
+
return active === '__all__' ? ['__all__'] : [active, '__all__']
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// One-line summary: 23fps · 18% dropped · 4 longtasks(max 120ms) · hot: ChatList 340ms, Markdown 120ms
|
|
35
|
+
export function compactPerformance(p: PerformanceData): string {
|
|
36
|
+
const all = p.buckets.find(b => b.name === '__all__')
|
|
37
|
+
if (!all) return 'perf: (no data)'
|
|
38
|
+
|
|
39
|
+
const { total, long, samples } = all.frames
|
|
40
|
+
const fps = total > 0 ? Math.round((1000 / avgOf(samples)) * 10) / 10 : 0
|
|
41
|
+
const droppedPct = total > 0 ? Math.round((long / total) * 100) : 0
|
|
42
|
+
|
|
43
|
+
const parts: string[] = [`${fps}fps`, `${droppedPct}% dropped`]
|
|
44
|
+
|
|
45
|
+
if (p.longtasks.count > 0) {
|
|
46
|
+
parts.push(`${p.longtasks.count} longtasks(max ${Math.round(p.longtasks.max)}ms)`)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Top 2 components by total
|
|
50
|
+
const topComps = Object.entries(all.components)
|
|
51
|
+
.map(([name, stat]) => ({ name, total: stat.total }))
|
|
52
|
+
.sort((a, b) => b.total - a.total)
|
|
53
|
+
.slice(0, 2)
|
|
54
|
+
|
|
55
|
+
if (topComps.length > 0 && p.mobxPatched) {
|
|
56
|
+
const hot = topComps.map(c => `${c.name} ${Math.round(c.total)}ms`).join(', ')
|
|
57
|
+
parts.push(`hot: ${hot}`)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return `perf: ${parts.join(' · ')}`
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Full report adapting litcode __recReport
|
|
64
|
+
export function detailPerformance(p: PerformanceData): string {
|
|
65
|
+
const lines: string[] = []
|
|
66
|
+
|
|
67
|
+
// Banner
|
|
68
|
+
const windowSec = (p.windowMs / 1000).toFixed(1)
|
|
69
|
+
lines.push(`=== Performance (${windowSec}s window) ===`)
|
|
70
|
+
if (p.hiddenFrames > 0) {
|
|
71
|
+
lines.push(`⚠ Tab was backgrounded or unfocused — ${p.hiddenFrames} throttled frames skipped (rAF throttling lies about timing)`)
|
|
72
|
+
lines.push('For accurate profiling: keep tab FOREGROUND + FOCUSED + run /profile/reset before reproducing')
|
|
73
|
+
}
|
|
74
|
+
lines.push(`MobX attribution: ${p.mobxPatched ? 'ON' : 'OFF'}`)
|
|
75
|
+
if (!p.mobxPatched) {
|
|
76
|
+
lines.push('(Component attribution disabled — set window.__AIPEEK_MOBX__={Reaction} in src/lib/mobx.ts to enable)')
|
|
77
|
+
}
|
|
78
|
+
lines.push('')
|
|
79
|
+
|
|
80
|
+
// longtasks
|
|
81
|
+
if (p.longtasks.count > 0) {
|
|
82
|
+
lines.push(`Longtasks (>50ms): ${p.longtasks.count}, max ${Math.round(p.longtasks.max)}ms`)
|
|
83
|
+
lines.push('')
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Buckets (always show __all__, then per-view only if >1 bucket)
|
|
87
|
+
const showPerView = p.buckets.length > 1
|
|
88
|
+
for (const bucket of p.buckets) {
|
|
89
|
+
if (bucket.name !== '__all__' && !showPerView) continue
|
|
90
|
+
|
|
91
|
+
lines.push(`--- ${bucket.name} ---`)
|
|
92
|
+
|
|
93
|
+
// Frame stats. total===0 means NO trustworthy frames were sampled (tab unfocused/hidden
|
|
94
|
+
// the whole window). Printing "0 dropped (0.0%)" then reads as "buttery smooth" — the exact
|
|
95
|
+
// inverse of the truth. An empty denominator is not 0%, it's no data: say so plainly.
|
|
96
|
+
const { total, long, max, samples } = bucket.frames
|
|
97
|
+
if (total === 0) {
|
|
98
|
+
lines.push('Frames: none sampled (tab unfocused/hidden — no frame data this window)')
|
|
99
|
+
} else {
|
|
100
|
+
const droppedPct = ((long / total) * 100).toFixed(1)
|
|
101
|
+
const avgFrame = avgOf(samples).toFixed(1)
|
|
102
|
+
lines.push(`Frames: ${total} total, ${long} dropped (${droppedPct}%), max ${Math.round(max)}ms, avg ${avgFrame}ms`)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Components (top 20 by total, desc)
|
|
106
|
+
const comps = Object.entries(bucket.components)
|
|
107
|
+
.map(([name, stat]) => ({ name, ...summarizeStat(stat) }))
|
|
108
|
+
.sort((a, b) => b.total - a.total)
|
|
109
|
+
.slice(0, 20)
|
|
110
|
+
|
|
111
|
+
if (comps.length > 0 && p.mobxPatched) {
|
|
112
|
+
lines.push('Components (by total self-time):')
|
|
113
|
+
for (const c of comps) {
|
|
114
|
+
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`)
|
|
115
|
+
}
|
|
116
|
+
} else if (!p.mobxPatched) {
|
|
117
|
+
lines.push('(no component data — attribution OFF)')
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Hot lines (top 15 by total time) — babel line-level instrumentation.
|
|
121
|
+
// Orthogonal to component attribution: points at the exact source line, not just the component.
|
|
122
|
+
// INCLUSIVE, not self-time: __line wraps each statement, so an outer statement's timing
|
|
123
|
+
// contains every nested __line below it (`const x = foo(bar())` counts foo+bar). Labeling
|
|
124
|
+
// this "self-time" would lie — you'd optimize a high-level line that just sums its callees.
|
|
125
|
+
const hotLines = Object.entries(bucket.lines ?? {})
|
|
126
|
+
.map(([label, stat]) => ({ label, ...summarizeStat(stat) }))
|
|
127
|
+
.sort((a, b) => b.total - a.total)
|
|
128
|
+
.slice(0, 15)
|
|
129
|
+
|
|
130
|
+
if (hotLines.length > 0) {
|
|
131
|
+
lines.push('Hot lines (by total inclusive time — contains nested calls):')
|
|
132
|
+
for (const l of hotLines) {
|
|
133
|
+
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`)
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
lines.push('')
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return lines.join('\n')
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Closed-loop verdict: did my fix work? Compares two snapshots (before/after) by component
|
|
144
|
+
// name + line label, lists per-key total-ms deltas, and a headline verdict driven by the
|
|
145
|
+
// __all__ frame dropped% change. This is what lets an automated fix→measure loop decide
|
|
146
|
+
// "keep it" vs "revert and try again" without eyeballing two reports.
|
|
147
|
+
export function diffPerformance(before: PerformanceData, after: PerformanceData): string {
|
|
148
|
+
const out: string[] = []
|
|
149
|
+
|
|
150
|
+
const beforeAll = before.buckets.find(b => b.name === '__all__')
|
|
151
|
+
const afterAll = after.buckets.find(b => b.name === '__all__')
|
|
152
|
+
|
|
153
|
+
const droppedPct = (b: PerfBucketData | undefined) =>
|
|
154
|
+
b && b.frames.total > 0 ? (b.frames.long / b.frames.total) * 100 : 0
|
|
155
|
+
const beforeDrop = droppedPct(beforeAll)
|
|
156
|
+
const afterDrop = droppedPct(afterAll)
|
|
157
|
+
const dropDelta = afterDrop - beforeDrop
|
|
158
|
+
|
|
159
|
+
// Total self-time across all components — the fallback ground truth when frames weren't
|
|
160
|
+
// exercised (e.g. a fix reproduced via direct calls, not real renders → 0%→0% frames).
|
|
161
|
+
const sumTotal = (b: PerfBucketData | undefined) =>
|
|
162
|
+
b ? Object.values(b.components).reduce((s, c) => s + c.total, 0) : 0
|
|
163
|
+
const beforeSelf = sumTotal(beforeAll)
|
|
164
|
+
const afterSelf = sumTotal(afterAll)
|
|
165
|
+
const selfDelta = afterSelf - beforeSelf
|
|
166
|
+
|
|
167
|
+
// Verdict: frame health is the user-visible truth. But frames are only comparable when BOTH
|
|
168
|
+
// snapshots actually sampled frames AND at least one saw drops. If the after-tab went hidden
|
|
169
|
+
// its frameLoop stops (frames.total→0), which would read as a false "0% dropped" win — so fall
|
|
170
|
+
// back to self-time (MobX self-time keeps recording in the background) whenever a side is blind.
|
|
171
|
+
const bothSampledFrames = (beforeAll?.frames.total ?? 0) > 0 && (afterAll?.frames.total ?? 0) > 0
|
|
172
|
+
const sawDrops = (beforeAll?.frames.long ?? 0) > 0 || (afterAll?.frames.long ?? 0) > 0
|
|
173
|
+
const framesExercised = bothSampledFrames && sawDrops
|
|
174
|
+
|
|
175
|
+
// Blind-window guard: the after-window must actually REPRODUCE the workload to be diffable.
|
|
176
|
+
// afterSelf===0 means zero components re-rendered. Two shapes both mean "workload not run":
|
|
177
|
+
// (a) hard-blind — frames.total===0 too (rAF frozen, tab fully suspended);
|
|
178
|
+
// (b) soft-blind — frames still ticked (rAF only throttled) but self-time is zero while the
|
|
179
|
+
// before-window had real self-time. Frames alone then describe an idle tab's compositor,
|
|
180
|
+
// not the render workload — a -5% frame delta off a backgrounded tab is NOT a code win.
|
|
181
|
+
// A genuine fix shows frames recovering AND self-time dropping in agreement; "frames still
|
|
182
|
+
// dropping but self-time vanished entirely" is the contradiction that betrays an idle after-tab.
|
|
183
|
+
// The pure-paint case (beforeSelf===0===afterSelf, frames>0) is NOT blind — it goes to frames.
|
|
184
|
+
const afterBlind = afterSelf === 0 && (beforeSelf > 0 || (afterAll?.frames.total ?? 0) === 0)
|
|
185
|
+
let verdict: string
|
|
186
|
+
if (afterBlind) {
|
|
187
|
+
verdict = 'NO DATA'
|
|
188
|
+
} else if (framesExercised) {
|
|
189
|
+
if (dropDelta < -FRAME_NOISE_PCT) verdict = 'IMPROVED'
|
|
190
|
+
else if (dropDelta > FRAME_NOISE_PCT) verdict = 'REGRESSED'
|
|
191
|
+
else verdict = 'UNCHANGED'
|
|
192
|
+
} else {
|
|
193
|
+
const floor = Math.max(SELF_NOISE_MS, beforeSelf * SELF_NOISE_REL)
|
|
194
|
+
if (selfDelta < -floor) verdict = 'IMPROVED'
|
|
195
|
+
else if (selfDelta > floor) verdict = 'REGRESSED'
|
|
196
|
+
else verdict = 'UNCHANGED'
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
out.push(`=== Perf diff: ${verdict} ===`)
|
|
200
|
+
if (afterBlind) {
|
|
201
|
+
const why = (afterAll?.frames.total ?? 0) === 0
|
|
202
|
+
? 'recorded no frames and no re-renders'
|
|
203
|
+
: `recorded frames but zero re-renders (was ${Math.round(beforeSelf)}ms self-time)`
|
|
204
|
+
out.push(`after-window ${why} — tab backgrounded or workload not reproduced. Re-run with the tab focused and the interaction repeated.`)
|
|
205
|
+
out.push('')
|
|
206
|
+
return out.join('\n').trimEnd()
|
|
207
|
+
}
|
|
208
|
+
out.push(`Dropped frames: ${beforeDrop.toFixed(1)}% → ${afterDrop.toFixed(1)}% (${dropDelta >= 0 ? '+' : ''}${dropDelta.toFixed(1)}%)`)
|
|
209
|
+
if (!framesExercised && Math.round(selfDelta) !== 0) {
|
|
210
|
+
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`)
|
|
211
|
+
}
|
|
212
|
+
if (before.longtasks.count !== after.longtasks.count) {
|
|
213
|
+
out.push(`Longtasks: ${before.longtasks.count} → ${after.longtasks.count}`)
|
|
214
|
+
}
|
|
215
|
+
out.push('')
|
|
216
|
+
|
|
217
|
+
// Per-component total-ms deltas (union of names), biggest absolute change first.
|
|
218
|
+
const compDelta = keyedTotalDelta(beforeAll?.components ?? {}, afterAll?.components ?? {})
|
|
219
|
+
if (compDelta.length > 0) {
|
|
220
|
+
out.push('Components (Δ total self-time):')
|
|
221
|
+
for (const d of compDelta) out.push(' ' + formatDelta(d))
|
|
222
|
+
out.push('')
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Per-line total-ms deltas (only when line instrumentation was on).
|
|
226
|
+
const lineDelta = keyedTotalDelta(beforeAll?.lines ?? {}, afterAll?.lines ?? {})
|
|
227
|
+
if (lineDelta.length > 0) {
|
|
228
|
+
out.push('Hot lines (Δ total inclusive time):')
|
|
229
|
+
for (const d of lineDelta) out.push(' ' + formatDelta(d))
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
return out.join('\n').trimEnd()
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
interface KeyDelta { key: string, before: number, after: number, delta: number, isNew: boolean, gone: boolean }
|
|
236
|
+
|
|
237
|
+
function keyedTotalDelta(a: Record<string, PerfStat>, b: Record<string, PerfStat>): KeyDelta[] {
|
|
238
|
+
const keys = new Set([...Object.keys(a), ...Object.keys(b)])
|
|
239
|
+
const rows: KeyDelta[] = []
|
|
240
|
+
for (const key of keys) {
|
|
241
|
+
const beforeT = a[key]?.total ?? 0
|
|
242
|
+
const afterT = b[key]?.total ?? 0
|
|
243
|
+
const delta = afterT - beforeT
|
|
244
|
+
if (Math.round(delta) === 0) continue // unchanged keys add noise
|
|
245
|
+
rows.push({ key, before: beforeT, after: afterT, delta, isNew: !a[key], gone: !b[key] })
|
|
246
|
+
}
|
|
247
|
+
return rows.sort((x, y) => Math.abs(y.delta) - Math.abs(x.delta)).slice(0, 20)
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function formatDelta(d: KeyDelta): string {
|
|
251
|
+
const sign = d.delta >= 0 ? '+' : ''
|
|
252
|
+
const tag = d.isNew ? ' (new)' : d.gone ? ' (gone)' : ''
|
|
253
|
+
return `${d.key.padEnd(36)} ${Math.round(d.before)}ms → ${Math.round(d.after)}ms (${sign}${Math.round(d.delta)}ms)${tag}`
|
|
254
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
export interface LogEntry {
|
|
2
|
+
level: 'error' | 'warn' | 'info' | 'debug' | 'log'
|
|
3
|
+
text: string
|
|
4
|
+
timestamp?: number
|
|
5
|
+
source?: string
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface NetworkRequest {
|
|
9
|
+
method: string
|
|
10
|
+
url: string
|
|
11
|
+
status: number
|
|
12
|
+
duration: number
|
|
13
|
+
resourceType: string
|
|
14
|
+
requestHeaders?: Record<string, string>
|
|
15
|
+
responseHeaders?: Record<string, string>
|
|
16
|
+
requestBody?: string
|
|
17
|
+
requestSample?: string
|
|
18
|
+
responseBody?: string
|
|
19
|
+
responseSample?: string
|
|
20
|
+
failed?: boolean
|
|
21
|
+
failureText?: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface ErrorEntry {
|
|
25
|
+
message: string
|
|
26
|
+
stack?: string
|
|
27
|
+
source?: string
|
|
28
|
+
line?: number
|
|
29
|
+
column?: number
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// One semantic page action — a state-machine transition. trusted=real human / real
|
|
33
|
+
// input; !trusted=aihand 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
|
+
|
|
47
|
+
export interface RawState {
|
|
48
|
+
tab?: string
|
|
49
|
+
url: string
|
|
50
|
+
title?: string
|
|
51
|
+
visible?: boolean
|
|
52
|
+
ui: string
|
|
53
|
+
console: LogEntry[]
|
|
54
|
+
network: NetworkRequest[]
|
|
55
|
+
errors: ErrorEntry[]
|
|
56
|
+
actions?: ActionEntry[]
|
|
57
|
+
actionsDropped?: number // head entries the per-tab ring evicted before this window
|
|
58
|
+
state: Record<string, unknown>
|
|
59
|
+
performance?: PerformanceData
|
|
60
|
+
timestamp: number
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// --- Performance profiler ---
|
|
64
|
+
//
|
|
65
|
+
// 语义分桶 profiler 的投影。三类正交信号(组件 self-time / 帧 dt / longtask)按 view 分桶,
|
|
66
|
+
// 答「哪个组件在烧帧」。client-patch 采集进 ring buffer,client 投影成此结构,core/perf.ts 纯渲染。
|
|
67
|
+
|
|
68
|
+
// 一段重复计时的累积统计。total/n/max 全是 O(1) 流式全程真值,avg=total/n 派生。
|
|
69
|
+
// 不存 samples:它过去只为 p95,而 p95 是近窗近似、和全程 total 不同源(放同一行即撒谎),已删。
|
|
70
|
+
export interface PerfStat {
|
|
71
|
+
total: number
|
|
72
|
+
n: number
|
|
73
|
+
max: number
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// 一个语义桶(一个 view,或全局 __all__):组件 self-time map + 帧统计 + 行级 self-time map。
|
|
77
|
+
export interface PerfBucketData {
|
|
78
|
+
name: string
|
|
79
|
+
components: Record<string, PerfStat>
|
|
80
|
+
frames: { total: number, long: number, max: number, samples: number[] }
|
|
81
|
+
lines: Record<string, PerfStat> // "File.tsx:42:varName" → stat,babel 行级插桩
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export interface PerformanceData {
|
|
85
|
+
windowMs: number // Date.now() - startedAt,采样窗口时长
|
|
86
|
+
hiddenFrames: number // 标签页隐藏期被跳过的帧(后台 rAF 节流会说谎,故不计入 frames)
|
|
87
|
+
mobxPatched: boolean // Reaction.track patch 是否生效(否则无组件归因)
|
|
88
|
+
buckets: PerfBucketData[] // __all__ 在前
|
|
89
|
+
longtasks: { count: number, max: number }
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// One live client (browser tab / Electron window) seen by the server. lastSeen is the
|
|
93
|
+
// server-side arrival time of its most recent reply — used to address one tab (?tab=)
|
|
94
|
+
// and to age out stale entries from /tabs.
|
|
95
|
+
export interface TabInfo {
|
|
96
|
+
id: string
|
|
97
|
+
url: string
|
|
98
|
+
title: string
|
|
99
|
+
visible: boolean
|
|
100
|
+
lastSeen: number
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export interface CompactState {
|
|
104
|
+
url: string
|
|
105
|
+
ui: string
|
|
106
|
+
console: string
|
|
107
|
+
network: string
|
|
108
|
+
errors: string
|
|
109
|
+
state: string
|
|
110
|
+
performance?: string
|
|
111
|
+
timestamp: number
|
|
112
|
+
counts: {
|
|
113
|
+
console: number
|
|
114
|
+
network: number
|
|
115
|
+
errors: number
|
|
116
|
+
state: number
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export interface Assertion {
|
|
121
|
+
name: string
|
|
122
|
+
pass: boolean
|
|
123
|
+
detail?: string
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export interface CheckResult {
|
|
127
|
+
pass: boolean
|
|
128
|
+
assertions: Assertion[]
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export interface DiffResult {
|
|
132
|
+
newErrors: LogEntry[]
|
|
133
|
+
newExceptions: ErrorEntry[]
|
|
134
|
+
newFailedRequests: NetworkRequest[]
|
|
135
|
+
uiGone: boolean
|
|
136
|
+
clean: boolean
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// The state-machine projection at one instant — what a click/fill is diffed against
|
|
140
|
+
// before vs after it settles. view/modal/focus are aihand's generic UI vars; `domain`
|
|
141
|
+
// is the app's own state machine, injected via window.__AIPEEK_SCREEN__() — the few
|
|
142
|
+
// variables that DON'T live in the DOM (是否流式 / 选了哪个模型 / 未读数), which a
|
|
143
|
+
// DOM-only projector can never synthesize. Empty when the app injects nothing.
|
|
144
|
+
// A big structural block of the page (>5% viewport), digested to a comparison-stable key.
|
|
145
|
+
// key = tag + quantized rect + childElementCount — small jitter doesn't change it, wholesale
|
|
146
|
+
// region replacement does. The DOM-only signal behind view-change inference (see diffScreen).
|
|
147
|
+
// label = role||tag + child count — a cheap human name (no text walk) so an *appeared* region
|
|
148
|
+
// (a dropdown/popover that isn't a modal and isn't a nav) can be reported self-sufficiently.
|
|
149
|
+
export interface RegionDigest {
|
|
150
|
+
tag: string
|
|
151
|
+
key: string
|
|
152
|
+
label: string
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export interface ScreenSnap {
|
|
156
|
+
view: string
|
|
157
|
+
modal: string
|
|
158
|
+
focus: string
|
|
159
|
+
domain: Record<string, unknown>
|
|
160
|
+
// >5% viewport structural blocks. Only consumed when view is double-(unknown) — i.e. the app
|
|
161
|
+
// didn't inject __AIPEEK_VIEW__, so view-change is inferred from region replacement instead.
|
|
162
|
+
// Absent in old fixtures → treated as empty (no DOM fallback signal), zero regression.
|
|
163
|
+
regions: RegionDigest[]
|
|
164
|
+
}
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import type { ActionEntry, TabInfo } from './types'
|
|
2
|
+
|
|
3
|
+
export function truncate(s: string, max: number): string {
|
|
4
|
+
return s.length > max ? `${s.slice(0, max)}…` : s
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
// unknown → 人类可读字符串。穷举 JS 类型,让 JSON.stringify 的「漏网类型 → {}」失败模式不可能出现。
|
|
8
|
+
// 返回未截断字符串——截断由调用方按各自 max 处理。
|
|
9
|
+
export function formatValue(v: unknown, seen: Set<object> = new Set()): string {
|
|
10
|
+
if (v === null || v === undefined)
|
|
11
|
+
return String(v)
|
|
12
|
+
const t = typeof v
|
|
13
|
+
if (t === 'string')
|
|
14
|
+
return v as string
|
|
15
|
+
if (t === 'number' || t === 'boolean' || t === 'bigint')
|
|
16
|
+
return String(v)
|
|
17
|
+
if (t === 'symbol')
|
|
18
|
+
return (v as symbol).toString()
|
|
19
|
+
if (t === 'function')
|
|
20
|
+
return `[Function: ${(v as { name?: string }).name || 'anonymous'}]`
|
|
21
|
+
// 此后 v 是 object
|
|
22
|
+
const obj = v as object
|
|
23
|
+
if (seen.has(obj))
|
|
24
|
+
return '[Circular]'
|
|
25
|
+
if (v instanceof Error)
|
|
26
|
+
return v.stack || `${v.name}: ${v.message}`
|
|
27
|
+
seen.add(obj)
|
|
28
|
+
if (v instanceof Map) {
|
|
29
|
+
const items = [...v.entries()].slice(0, 15).map(([k, val]) => `${formatValue(k, seen)} => ${formatValue(val, seen)}`)
|
|
30
|
+
return `Map(${v.size}) {${items.join(', ')}${v.size > 15 ? ', …' : ''}}`
|
|
31
|
+
}
|
|
32
|
+
if (v instanceof Set) {
|
|
33
|
+
const items = [...v.values()].slice(0, 15).map(val => formatValue(val, seen))
|
|
34
|
+
return `Set(${v.size}) {${items.join(', ')}${v.size > 15 ? ', …' : ''}}`
|
|
35
|
+
}
|
|
36
|
+
if (Array.isArray(v)) {
|
|
37
|
+
const items = v.slice(0, 30).map(val => formatValue(val, seen))
|
|
38
|
+
return `[${items.join(', ')}${v.length > 30 ? ', …' : ''}]`
|
|
39
|
+
}
|
|
40
|
+
try {
|
|
41
|
+
return JSON.stringify(v)
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
// 循环引用 / getter 抛错 → 手动遍历可枚举键,循环处标 [Circular]
|
|
45
|
+
const entries = Object.entries(v as Record<string, unknown>).slice(0, 15)
|
|
46
|
+
const parts = entries.map(([k, val]) => `${k}: ${formatValue(val, seen)}`)
|
|
47
|
+
return `{${parts.join(', ')}}`
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// stack → 应用栈帧(去 node_modules / <anonymous>),保留 `at ` 前缀。max 与溢出提示由调用方决定。
|
|
52
|
+
export function appStackFrames(stack: string, max: number): string[] {
|
|
53
|
+
return stack
|
|
54
|
+
.split('\n')
|
|
55
|
+
.map(l => l.trim())
|
|
56
|
+
.filter(l => l.startsWith('at ') && !l.includes('node_modules') && !l.includes('<anonymous>'))
|
|
57
|
+
.slice(0, max)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// pathname only; pass `search` to append a truncated query string
|
|
61
|
+
export function compactUrl(url: string, search?: number): string {
|
|
62
|
+
try {
|
|
63
|
+
const u = new URL(url)
|
|
64
|
+
if (search && u.search)
|
|
65
|
+
return `${u.pathname}?${truncate(u.search.slice(1), search)}`
|
|
66
|
+
return u.pathname
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
return truncate(url, 80)
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// SPA 路由切换判据(出口2 同文档导航回填用):CDP Page.navigatedWithinDocument 对一次真路由切换
|
|
74
|
+
// 之外还会为锚点滚动 / replaceState 状态更新喷一串事件,只有 pathname+search 真变的那一次才是换页。
|
|
75
|
+
// 纯 #hash 变化(同 path 同 search,只 hash 不同)是页内锚点跳转、不是路由切换,排除。URL 解析失败
|
|
76
|
+
// (相对/畸形)退回字符串严格不等——保守:宁可漏判一次少见的畸形 URL 换页,不误判一次锚点为换页。
|
|
77
|
+
export function isRouteChange(fromUrl: string, toUrl: string): boolean {
|
|
78
|
+
try {
|
|
79
|
+
const a = new URL(fromUrl)
|
|
80
|
+
const b = new URL(toUrl)
|
|
81
|
+
return a.pathname !== b.pathname || a.search !== b.search
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
return fromUrl !== toUrl && stripHash(fromUrl) !== stripHash(toUrl)
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
function stripHash(u: string): string {
|
|
88
|
+
const i = u.indexOf('#')
|
|
89
|
+
return i < 0 ? u : u.slice(0, i)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// A tab is "live" if it replied within STALE_MS (a closed tab stops replying, so its
|
|
93
|
+
// lastSeen freezes). One definition, shared by the server's roster/guard and formatTabs.
|
|
94
|
+
const STALE_MS = 30_000
|
|
95
|
+
export const isLive = (t: TabInfo, now: number) => now - t.lastSeen < STALE_MS
|
|
96
|
+
|
|
97
|
+
// Render the live-tab roster — shared by GET /tabs and the 409 "which tab?" guard.
|
|
98
|
+
// `now` is server time. The visible one is marked first so the caller can pick.
|
|
99
|
+
// When NO tab is visible (the user is reading the terminal, so every dev tab is
|
|
100
|
+
// backgrounded — the common case), append a note: background is fully operable, so
|
|
101
|
+
// the model never reads `○ background` as "can't act, ask the user to foreground it".
|
|
102
|
+
export function formatTabs(tabs: TabInfo[], now: number): string {
|
|
103
|
+
const live = tabs.filter(t => isLive(t, now))
|
|
104
|
+
if (!live.length)
|
|
105
|
+
return '(no live tabs)'
|
|
106
|
+
const rows = live
|
|
107
|
+
.sort((a, b) => Number(b.visible) - Number(a.visible) || a.id.localeCompare(b.id))
|
|
108
|
+
.map((t) => {
|
|
109
|
+
const age = Math.round((now - t.lastSeen) / 1000)
|
|
110
|
+
const mark = t.visible ? '● visible' : '○ background'
|
|
111
|
+
return `${t.id} ${mark} ${truncate(t.title || '(untitled)', 40)} ${compactUrl(t.url)} ${age}s ago`
|
|
112
|
+
})
|
|
113
|
+
.join('\n')
|
|
114
|
+
if (live.some(t => t.visible))
|
|
115
|
+
return rows
|
|
116
|
+
return `${rows}\n(all backgrounded because the user is looking at the terminal — this is normal. Reads and clicks/fills work on background tabs; do NOT ask the user to foreground a tab. Only /profile needs focus.)`
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// The single projection π for every "didn't get an answer" path: connection state → one
|
|
120
|
+
// actionable message. Defined once, pure (no closure, no clock), so no failure path can
|
|
121
|
+
// collapse two states that need different fixes (the law: ker π ⊆ ker f*). The five fibers
|
|
122
|
+
// each demand a different action, so each gets its own branch:
|
|
123
|
+
// addressed, live → handler threw / hung / socket dropped mid self-heal (retry, check console)
|
|
124
|
+
// addressed, not live → tab closed or mid self-heal (check /tabs, retry)
|
|
125
|
+
// broadcast, live tabs → handler threw or tab just closed (check /tabs + console)
|
|
126
|
+
// broadcast, sockets>0 → page open but client not injected/wrong server (check the port)
|
|
127
|
+
// broadcast, no socket → no browser pointed here at all (open the app)
|
|
128
|
+
// `socketCount` is the raw HMR-socket count (server.ws.clients.size) — the bit that separates
|
|
129
|
+
// "open but uninjected" from "no browser", which the aihand roster alone can't see.
|
|
130
|
+
export function diagnose(tab: string | undefined, tabs: TabInfo[], now: number, socketCount: number, port: number): string {
|
|
131
|
+
if (tab) {
|
|
132
|
+
const t = tabs.find(x => x.id === tab)
|
|
133
|
+
return t && isLive(t, now)
|
|
134
|
+
? `tab '${tab}' is connected but didn't answer in time — most likely the HMR socket dropped while the tab was backgrounded and self-heal hasn't reconnected yet (retry in ~2s, it reloads itself), or the handler threw/hung on a bad selector. Reads (/screen /dom) work backgrounded — do NOT ask the user to foreground the tab. Check /__aihand/console.`
|
|
135
|
+
: `tab '${tab}' is not connected — it closed, or the server restarted and it hasn't re-handshaked yet. Check /__aihand/tabs for live ids; if the page is mid self-heal, retry in ~2s.`
|
|
136
|
+
}
|
|
137
|
+
const live = tabs.filter(t => isLive(t, now))
|
|
138
|
+
if (live.length)
|
|
139
|
+
return `${live.length} tab(s) connected but none answered — a handler threw, or they just closed. Check /__aihand/tabs (still live?) and /__aihand/console (errors?).`
|
|
140
|
+
return socketCount > 0
|
|
141
|
+
? `no aihand tab registered, but ${socketCount} page(s) are connected to this dev server via HMR — the page is open yet the aihand 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.`
|
|
142
|
+
: `no browser tab connected to this dev server on :${port} — open the app, or check you're hitting the right port.`
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Render the action ring as a cause-effect timeline. Each entry is a transition; the UI
|
|
146
|
+
// change (→ dialog opened / closed / view switched) is the diff of this entry's post-settle
|
|
147
|
+
// snapshot against the prior one *on the same tab*. `T`=trusted (human), `S`=synthetic
|
|
148
|
+
// (aihand). With anchorTs set, the matching entry is bracketed by "你当前的行为" dividers so
|
|
149
|
+
// the model reads [earlier] → its own action → [what followed]. Pure: no DOM. Shared by the
|
|
150
|
+
// single-tab `recent actions` tail (client.ts) and the cross-tab GET /timeline (server).
|
|
151
|
+
//
|
|
152
|
+
// dropped = how many head entries the ring evicted before this window (it's a bounded ring,
|
|
153
|
+
// 30 per-tab / 200 server). A causal chain with a silently-cut head reads as "this is the
|
|
154
|
+
// start" → the model anchors on a non-start. Same diagnostic-fiber law diff.ts already obeys
|
|
155
|
+
// for flow frames (a clipped trace must announce the clip): announce the eviction at the head.
|
|
156
|
+
export function formatActions(entries: ActionEntry[], anchorTs?: number, dropped = 0): string {
|
|
157
|
+
if (!entries.length)
|
|
158
|
+
return dropped > 0 ? `┄ … 更早 ${dropped} 条动作已超出窗口(此处非会话起点)` : '(no actions)'
|
|
159
|
+
const sorted = [...entries].sort((a, b) => a.ts - b.ts)
|
|
160
|
+
// Tag lines with their tab only when the stream spans >1 tab (the cross-tab /timeline).
|
|
161
|
+
// Single-tab `recent actions` stays byte-identical — no tab prefix. uiChange diffs per
|
|
162
|
+
// tab, so an interleaved A/B stream doesn't read B's modal as A's transition.
|
|
163
|
+
const multiTab = new Set(sorted.map(e => e.tab).filter(Boolean)).size > 1
|
|
164
|
+
const lines: string[] = []
|
|
165
|
+
if (dropped > 0)
|
|
166
|
+
lines.push(`┄ … 更早 ${dropped} 条动作已超出窗口(下方非会话起点)`)
|
|
167
|
+
const prevByTab = new Map<string | undefined, ActionEntry>()
|
|
168
|
+
let anchored = false
|
|
169
|
+
for (const e of sorted) {
|
|
170
|
+
const isAnchor = anchorTs !== undefined && !anchored && e.ts === anchorTs
|
|
171
|
+
if (isAnchor) {
|
|
172
|
+
lines.push('—————— 你当前的行为 ——————')
|
|
173
|
+
anchored = true
|
|
174
|
+
}
|
|
175
|
+
const who = e.trusted ? 'T' : 'S'
|
|
176
|
+
const tag = multiTab ? `${e.tab ?? '?'} ` : ''
|
|
177
|
+
const val = e.value ? ` value="${e.value}"` : ''
|
|
178
|
+
const change = uiChange(prevByTab.get(e.tab), e)
|
|
179
|
+
lines.push(`${tag}[${who}] ${e.type} ${e.target}${val}${change ? ` → ${change}` : ''}`)
|
|
180
|
+
if (isAnchor)
|
|
181
|
+
lines.push('——————————————————————————')
|
|
182
|
+
prevByTab.set(e.tab, e)
|
|
183
|
+
}
|
|
184
|
+
return lines.join('\n')
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Append an action to the server's cross-tab ring, mutating it in place. Mirrors
|
|
188
|
+
// client-patch's input debounce at the aggregation point: a tab fires its hook on every
|
|
189
|
+
// keystroke (rewriting its in-page entry in place), so consecutive same-tab input on the
|
|
190
|
+
// same target rewrites the logged entry instead of appending — else "hello" becomes 5 rows
|
|
191
|
+
// (h/he/hel/…). Any other action ends the run. Ring-bounded by max. Pure logic, fixture-tested.
|
|
192
|
+
export function appendAction(log: ActionEntry[], tab: string, entry: ActionEntry, max: number): void {
|
|
193
|
+
const last = log[log.length - 1]
|
|
194
|
+
if (entry.type === 'input' && last?.type === 'input' && last.tab === tab && last.target === entry.target) {
|
|
195
|
+
log[log.length - 1] = { ...entry, tab }
|
|
196
|
+
return
|
|
197
|
+
}
|
|
198
|
+
log.push({ ...entry, tab })
|
|
199
|
+
if (log.length > max)
|
|
200
|
+
log.shift()
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function uiChange(prev: ActionEntry | undefined, cur: ActionEntry): string {
|
|
204
|
+
if (cur.modal === undefined)
|
|
205
|
+
return ''
|
|
206
|
+
const prevModal = prev?.modal ?? 'none'
|
|
207
|
+
if (cur.modal !== prevModal) {
|
|
208
|
+
if (cur.modal === 'none')
|
|
209
|
+
return '弹窗关闭'
|
|
210
|
+
if (prevModal === 'none')
|
|
211
|
+
return `弹窗打开「${cur.modal}」`
|
|
212
|
+
return `弹窗切换「${cur.modal}」`
|
|
213
|
+
}
|
|
214
|
+
// Both sides must be captured to call it a transition. A prior entry whose view wasn't
|
|
215
|
+
// snapshotted (screen hook not injected yet) is *unknown*, not "a different view" — rendering
|
|
216
|
+
// `视图 undefined→chat` projects a non-event as a transition (ker π must not collapse
|
|
217
|
+
// unknown-prior into changed). Unknown prior → no change.
|
|
218
|
+
if (prev && cur.view !== undefined && prev.view !== undefined && cur.view !== prev.view)
|
|
219
|
+
return `视图 ${prev.view}→${cur.view}`
|
|
220
|
+
return ''
|
|
221
|
+
}
|
package/src/ui/index.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { check } from './core/check'
|
|
2
|
+
export { diffState } from './core/diff'
|
|
3
|
+
export { emitCheck, emitDiff, emitSummary } from './core/emit'
|
|
4
|
+
export type { CheckResult, CompactState, DiffResult, RawState } from './core/types'
|
|
5
|
+
export { aihandPlugin } from './server/plugin'
|