aipeek 0.2.3 → 0.2.4

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.
@@ -0,0 +1,536 @@
1
+ import type { Plugin, ViteDevServer } from 'vite'
2
+ import type { ActionArgs, ActionResult } from '../core/action'
3
+ import type { RawState } from '../core/types'
4
+ import { Buffer } from 'node:buffer'
5
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
6
+ import { dirname, resolve } from 'node:path'
7
+ import { fileURLToPath } from 'node:url'
8
+ import { transformSync } from 'esbuild'
9
+ import { resolveAction } from '../core/action'
10
+ import { check } from '../core/check'
11
+ import { compact } from '../core/compact'
12
+ import { detail } from '../core/detail'
13
+ import { diffState } from '../core/diff'
14
+ import { emit, emitCheck, emitDiff, emitSummary } from '../core/emit'
15
+
16
+ function readBody(req: { on: (e: string, cb: (c: unknown) => void) => void }): Promise<string> {
17
+ return new Promise((resolve) => {
18
+ let s = ''
19
+ req.on('data', c => s += c)
20
+ req.on('end', () => resolve(s))
21
+ })
22
+ }
23
+
24
+ const __dirname = dirname(fileURLToPath(import.meta.url))
25
+ // client/core sources are read at runtime (client.ts served via Vite transform,
26
+ // client-patch.ts compiled by esbuild). Two layouts: source consumption
27
+ // (__dirname=src/server → ../client) and published package (__dirname=dist → ../src/client).
28
+ const clientDir = existsSync(resolve(__dirname, '../client'))
29
+ ? resolve(__dirname, '../client')
30
+ : resolve(__dirname, '../src/client')
31
+ const clientPath = resolve(clientDir, 'client.ts')
32
+ const patchPath = resolve(clientDir, 'client-patch.ts')
33
+
34
+ // Compile patch code to JS at plugin init (synchronous, runs once)
35
+ function compilePatch(): string {
36
+ const source = readFileSync(patchPath, 'utf-8')
37
+ const result = transformSync(source, {
38
+ loader: 'ts',
39
+ target: 'es2020',
40
+ format: 'iife',
41
+ minify: false,
42
+ })
43
+ return result.code
44
+ }
45
+
46
+ function aipeekSnippet(port: number) {
47
+ const base = `http://localhost:${port}/__aipeek`
48
+ return `
49
+ # aipeek — Runtime Browser Inspector
50
+
51
+ IMPORTANT: Before debugging any UI issue, visual bug, or runtime error, ALWAYS fetch the live app state first. Do NOT guess — look at the actual browser state.
52
+
53
+ ## Read state — cheapest first
54
+
55
+ \`\`\`bash
56
+ curl ${base}/screen # state-machine projection {view, modal, focus, knobs} — START HERE
57
+ curl ${base}/ui # React component tree — deep-dive when /screen isn't enough
58
+ curl '${base}/dom?scope=ChatInput' # semantic DOM scoped to a component — UI as text, src locations
59
+ curl ${base} # high-density summary (ok sections → 1 line, issues → expanded)
60
+ curl ${base}?full # full dump: UI tree + console + network + errors + state
61
+ curl ${base}/check # pass/fail health check — use after code changes
62
+ curl ${base}/console # console logs (errors, warnings, info)
63
+ curl ${base}/network # fetch/XHR requests with status and timing
64
+ curl ${base}/errors # uncaught errors and unhandled rejections
65
+ curl ${base}/state # registered store snapshots
66
+ \`\`\`
67
+
68
+ \`/screen\` projects the whole UI to a few state variables — start there, not \`/ui\`. Append
69
+ \`?full\` for untruncated output. Append \`/{index}\` for a specific item's detail.
70
+
71
+ To inspect or edit a component, work top-down — the full DOM is huge, a scoped view is
72
+ accurate: \`/screen\` or \`/ui\` to find the component, then \`/dom?scope=<Name>\` (matches the
73
+ source path) or \`/dom?sel=<css>\` for just that subtree. Each line carries its source
74
+ location (\`@File.tsx:line\`), so the DOM view tells you exactly where to edit.
75
+
76
+ ## Drive the page (acts on the currently-open tab — no separate browser)
77
+
78
+ \`\`\`bash
79
+ curl '${base}/click?text=New' # click by visible text (or sel= for CSS)
80
+ curl '${base}/fill?sel=textarea&value=hi' # set an input/textarea/select value
81
+ curl '${base}/press?key=Enter' # key on focused element (e.g. Control+a)
82
+ curl '${base}/wait?text=Done&timeout=8000' # poll until text/sel appears (add gone=1 to wait until it disappears)
83
+ curl '${base}/screenshot?out=shot.png' # DOM→PNG into .aipeek/ (html-to-image; lossy)
84
+ \`\`\`
85
+
86
+ \`click\`/\`fill\`/\`press\` settle the DOM and append the resulting UI tree (\`--- ui after ---\`)
87
+ to the response — no follow-up read needed. On a miss, the response lists the reachable
88
+ clickable elements so you can re-target. URL-encode any \`sel=\` with non-ASCII or quotes:
89
+ \`curl -G ${base}/click --data-urlencode 'sel=button[title="知识库"]'\`.
90
+
91
+ **Chain — a whole interaction in one round-trip.** POST a JSON array; runs in sequence,
92
+ each step settles before the next, stops on first failure:
93
+
94
+ \`\`\`bash
95
+ curl -X POST ${base}/chain -d '[
96
+ {"type":"click","sel":"button[title=\\"知识库\\"]"},
97
+ {"type":"wait","text":"Done"},
98
+ {"type":"fill","sel":"textarea","value":"hi"},
99
+ {"type":"press","key":"Enter"}
100
+ ]'
101
+ \`\`\`
102
+
103
+ **Escape hatch.** \`curl '${base}/eval?code=...'\` (or POST the code as the body) runs arbitrary
104
+ JS in the page and returns the result — for anything the typed endpoints can't do.
105
+
106
+ aipeek auto-detects errors after HMR and prints them to the terminal — watch for \`[aipeek]\` messages.
107
+ `
108
+ }
109
+
110
+ // normalize a line so cosmetic diffs (indent, port number) don't break matching
111
+ function norm(line: string): string {
112
+ const t = line.trim()
113
+ const i = t.indexOf('localhost:')
114
+ if (i === -1)
115
+ return t
116
+ let j = i + 'localhost:'.length
117
+ while (j < t.length && t[j] >= '0' && t[j] <= '9') j++
118
+ return `${t.slice(0, i)}localhost:PORT${t.slice(j)}`
119
+ }
120
+
121
+ // strip every existing aipeek block by simulating a human reading line-by-line:
122
+ // scan downward; once enough recent lines belong to the snippet we're INSIDE a
123
+ // block; once they stop belonging we're OUTSIDE again. A run whose lines are
124
+ // >50% snippet-content is dropped. Content-fuzzy — survives wording/port edits.
125
+ function stripBlocks(content: string, snippet: string): string {
126
+ const known = new Set(snippet.split('\n').map(norm).filter(l => l.length > 3))
127
+ const lines = content.split('\n')
128
+ const keep: string[] = []
129
+ let inside = false
130
+ let buf: string[] = []
131
+ let hits = 0
132
+
133
+ const flush = () => {
134
+ // settle the buffered run: drop it if it reads as snippet, else keep it
135
+ if (buf.length && hits / buf.length <= 0.5)
136
+ keep.push(...buf)
137
+ buf = []
138
+ hits = 0
139
+ inside = false
140
+ }
141
+
142
+ for (const line of lines) {
143
+ const isKnown = known.has(norm(line))
144
+ if (!inside) {
145
+ if (isKnown) {
146
+ inside = true
147
+ buf = [line]
148
+ hits = 1
149
+ }
150
+ else {
151
+ keep.push(line)
152
+ }
153
+ continue
154
+ }
155
+ // INSIDE: keep accumulating until we drift away from the snippet
156
+ buf.push(line)
157
+ if (isKnown)
158
+ hits++
159
+ // a run of unknown lines means the block ended — settle it
160
+ else if (buf.slice(-3).every(l => !known.has(norm(l))))
161
+ flush()
162
+ }
163
+ flush()
164
+ return keep.join('\n')
165
+ }
166
+
167
+ function injectClaudeMd(root: string, port: number) {
168
+ const path = resolve(root, 'CLAUDE.md')
169
+ const snippet = aipeekSnippet(port)
170
+ try {
171
+ if (!existsSync(path)) {
172
+ writeFileSync(path, snippet.trimStart())
173
+ return
174
+ }
175
+ const stripped = stripBlocks(readFileSync(path, 'utf-8'), snippet).trimEnd()
176
+ writeFileSync(path, `${stripped}\n${snippet}`)
177
+ }
178
+ catch {}
179
+ }
180
+
181
+ export function aipeekPlugin(): Plugin {
182
+ let pendingResolve: ((data: RawState) => void) | null = null
183
+ let server: ViteDevServer
184
+ let lastRaw: RawState | null = null
185
+ let pushTimer: ReturnType<typeof setTimeout> | undefined
186
+ const pendingActions = new Map<number, (r: ActionResult) => void>()
187
+ let actionId = 0
188
+
189
+ let pendingDom: ((dom: string) => void) | null = null
190
+ let pendingScreen: ((screen: string) => void) | null = null
191
+ const pendingEvals = new Map<number, (r: { ok: boolean, value?: string, error?: string }) => void>()
192
+ let evalId = 0
193
+
194
+ // Multi-tab: round one asks only the visible tab (requireVisible). If no tab is
195
+ // visible (user reading the terminal), nobody answers within VISIBLE_MS, so round
196
+ // two drops the guard and any tab replies. `arm` installs the pending slot and
197
+ // returns a clearer; the slot's resolve settles the promise on either round.
198
+ const VISIBLE_MS = 400
199
+ function twoPhase<T>(
200
+ event: string,
201
+ payload: Record<string, unknown>,
202
+ arm: (resolve: (v: T) => void) => () => void,
203
+ fullMs = 3000,
204
+ ): Promise<T> {
205
+ return new Promise<T>((resolve, reject) => {
206
+ let settled = false
207
+ const clear = arm((v) => {
208
+ settled = true
209
+ resolve(v)
210
+ })
211
+ server.hot.send(event, { ...payload, requireVisible: true })
212
+ setTimeout(() => {
213
+ if (settled)
214
+ return
215
+ // no visible tab answered — ask everyone
216
+ server.hot.send(event, { ...payload, requireVisible: false })
217
+ setTimeout(() => {
218
+ if (settled)
219
+ return
220
+ clear()
221
+ reject(new Error(`timeout: no client response within ${VISIBLE_MS + fullMs}ms`))
222
+ }, fullMs)
223
+ }, VISIBLE_MS)
224
+ })
225
+ }
226
+
227
+ function collectFromClient(): Promise<RawState> {
228
+ return twoPhase<RawState>('aipeek:collect', {}, (resolve) => {
229
+ pendingResolve = resolve
230
+ return () => {
231
+ pendingResolve = null
232
+ }
233
+ })
234
+ }
235
+
236
+ function collectDomFromClient(scope?: string, sel?: string): Promise<string> {
237
+ return twoPhase<string>('aipeek:collect-dom', { scope, sel }, (resolve) => {
238
+ pendingDom = resolve
239
+ return () => {
240
+ pendingDom = null
241
+ }
242
+ })
243
+ }
244
+
245
+ function collectScreenFromClient(): Promise<string> {
246
+ return twoPhase<string>('aipeek:collect-screen', {}, (resolve) => {
247
+ pendingScreen = resolve
248
+ return () => {
249
+ pendingScreen = null
250
+ }
251
+ })
252
+ }
253
+
254
+ function sendAction(type: string, args: ActionArgs): Promise<ActionResult> {
255
+ const id = ++actionId
256
+ // wait actions own their timeout; give the channel that long + slack
257
+ const fullMs = Math.max(args.timeout ?? 0, 3000) + 2000
258
+ return twoPhase<ActionResult>('aipeek:action', { id, type, args }, (resolve) => {
259
+ pendingActions.set(id, resolve)
260
+ return () => {
261
+ pendingActions.delete(id)
262
+ }
263
+ }, fullMs)
264
+ }
265
+
266
+ function evalInClient(code: string): Promise<{ ok: boolean, value?: string, error?: string }> {
267
+ const id = ++evalId
268
+ return twoPhase('aipeek:eval', { id, code }, (resolve) => {
269
+ pendingEvals.set(id, resolve)
270
+ return () => {
271
+ pendingEvals.delete(id)
272
+ }
273
+ }, 8000)
274
+ }
275
+
276
+ return {
277
+ name: 'aipeek',
278
+ apply: 'serve',
279
+
280
+ transformIndexHtml() {
281
+ const patchCode = compilePatch()
282
+ return [
283
+ // Synchronous inline script — patches console/fetch/XHR/errors
284
+ // BEFORE any ES modules execute
285
+ {
286
+ tag: 'script',
287
+ children: patchCode,
288
+ injectTo: 'head-prepend',
289
+ },
290
+ // Module script — collectors + HMR channel (can be deferred)
291
+ {
292
+ tag: 'script',
293
+ attrs: { type: 'module' },
294
+ children: `import '/@fs/${clientPath}'`,
295
+ injectTo: 'body',
296
+ },
297
+ ]
298
+ },
299
+
300
+ configureServer(_server) {
301
+ server = _server
302
+ injectClaudeMd(server.config.root, server.config.server.port || 5173)
303
+
304
+ server.hot.on('aipeek:state', (data: RawState) => {
305
+ if (pendingResolve) {
306
+ pendingResolve(data)
307
+ pendingResolve = null
308
+ }
309
+ })
310
+
311
+ server.hot.on('aipeek:result', (data: ActionResult & { id: number }) => {
312
+ const resolve = pendingActions.get(data.id)
313
+ if (resolve) {
314
+ pendingActions.delete(data.id)
315
+ resolve(data)
316
+ }
317
+ })
318
+
319
+ server.hot.on('aipeek:eval-result', (data: { id: number, ok: boolean, value?: string, error?: string }) => {
320
+ const resolve = pendingEvals.get(data.id)
321
+ if (resolve) {
322
+ pendingEvals.delete(data.id)
323
+ resolve(data)
324
+ }
325
+ })
326
+
327
+ server.hot.on('aipeek:dom', (data: { dom: string }) => {
328
+ if (pendingDom) {
329
+ pendingDom(data.dom)
330
+ pendingDom = null
331
+ }
332
+ })
333
+
334
+ server.hot.on('aipeek:screen', (data: { screen: string }) => {
335
+ if (pendingScreen) {
336
+ pendingScreen(data.screen)
337
+ pendingScreen = null
338
+ }
339
+ })
340
+
341
+ // push mode: auto-detect errors after HMR
342
+ server.hot.on('vite:afterUpdate', () => {
343
+ clearTimeout(pushTimer)
344
+ pushTimer = setTimeout(async () => {
345
+ try {
346
+ const raw = await collectFromClient()
347
+ const diff = diffState(lastRaw, raw)
348
+ lastRaw = raw
349
+ if (!diff.clean) {
350
+ const msg = emitDiff(diff)
351
+ if (msg)
352
+ server.config.logger.warn(msg)
353
+ }
354
+ }
355
+ catch {}
356
+ }, 500)
357
+ })
358
+
359
+ // /__aipeek — summary
360
+ // /__aipeek/{section}/{index} — detail
361
+ // /__aipeek/check — health check
362
+ server.middlewares.use('/__aipeek', async (req, res) => {
363
+ const url = new URL(req.url || '/', 'http://localhost')
364
+ const parts = url.pathname.split('/').filter(Boolean)
365
+ const full = url.searchParams.has('full')
366
+
367
+ try {
368
+ // /__aipeek/eval — run arbitrary JS in the page. POST body = code,
369
+ // or ?code=. The page evaluates it and returns the result (or thrown
370
+ // error). The escape hatch for anything the typed endpoints can't do:
371
+ // install event listeners, read closures, probe real event flow.
372
+ if (parts[0] === 'eval') {
373
+ let code = url.searchParams.get('code') || ''
374
+ if (!code && req.method === 'POST')
375
+ code = await readBody(req)
376
+ if (!code) {
377
+ res.writeHead(400, { 'Content-Type': 'text/plain; charset=utf-8' })
378
+ res.end('eval needs ?code= or a POST body')
379
+ return
380
+ }
381
+ const r = await evalInClient(code)
382
+ res.writeHead(r.ok ? 200 : 422, { 'Content-Type': 'text/plain; charset=utf-8' })
383
+ res.end(r.ok ? (r.value ?? 'undefined') : `error: ${r.error}`)
384
+ return
385
+ }
386
+
387
+ // /__aipeek/dom[?scope=Component|?sel=CSS] — semantic DOM, scoped, on-demand
388
+ if (parts[0] === 'dom') {
389
+ const dom = await collectDomFromClient(
390
+ url.searchParams.get('scope') || undefined,
391
+ url.searchParams.get('sel') || undefined,
392
+ )
393
+ res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' })
394
+ res.end(dom || '(empty)')
395
+ return
396
+ }
397
+
398
+ // /__aipeek/screen — state-machine projection {view, modal, focus, knobs}
399
+ if (parts[0] === 'screen') {
400
+ const screen = await collectScreenFromClient()
401
+ res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' })
402
+ res.end(screen || '(empty)')
403
+ return
404
+ }
405
+
406
+ // /__aipeek/chain — POST a JSON array of actions, run them in
407
+ // sequence (each settles the DOM before the next), stop on first
408
+ // failure. One round-trip for a whole interaction.
409
+ if (parts[0] === 'chain') {
410
+ const body = await readBody(req)
411
+ let steps: Array<{ type: string } & ActionArgs>
412
+ try {
413
+ steps = JSON.parse(body)
414
+ if (!Array.isArray(steps))
415
+ throw new Error('body must be a JSON array')
416
+ }
417
+ catch (e) {
418
+ res.writeHead(400, { 'Content-Type': 'text/plain; charset=utf-8' })
419
+ res.end(`invalid chain body: ${e instanceof Error ? e.message : String(e)}`)
420
+ return
421
+ }
422
+ lastRaw = null
423
+ const lines: string[] = []
424
+ let lastUi = ''
425
+ let allOk = true
426
+ for (let i = 0; i < steps.length; i++) {
427
+ const { type, ...args } = steps[i]
428
+ const check = resolveAction(type, args)
429
+ if (!check.valid) {
430
+ lines.push(`[${i}] ✗ ${type}: ${check.error}`)
431
+ allOk = false
432
+ break
433
+ }
434
+ const r = await sendAction(type, args)
435
+ lines.push(`[${i}] ${r.ok ? '✓' : '✗'} ${type}: ${r.ok ? (r.detail || 'ok') : r.error}`)
436
+ // Per-step screen projection — captures the transition each
437
+ // mutating step caused, so a view change mid-chain is visible
438
+ // at its source step rather than collapsed into the final tree.
439
+ if (r.screen)
440
+ lines.push(r.screen.split('\n').map(l => ` ${l}`).join('\n'))
441
+ if (r.ui)
442
+ lastUi = r.ui
443
+ if (!r.ok) {
444
+ allOk = false
445
+ break
446
+ }
447
+ }
448
+ res.writeHead(allOk ? 200 : 422, { 'Content-Type': 'text/plain; charset=utf-8' })
449
+ res.end(lastUi ? `${lines.join('\n')}\n\n--- ui after ---\n${lastUi}` : lines.join('\n'))
450
+ return
451
+ }
452
+
453
+ // action endpoints: /__aipeek/{click|fill|press|wait|screenshot}?...
454
+ if (['click', 'fill', 'press', 'wait', 'screenshot'].includes(parts[0])) {
455
+ const q = url.searchParams
456
+ const args: ActionArgs = {
457
+ sel: q.get('sel') || undefined,
458
+ text: q.get('text') || undefined,
459
+ value: q.has('value') ? q.get('value')! : undefined,
460
+ key: q.get('key') || undefined,
461
+ timeout: q.has('timeout') ? Number(q.get('timeout')) : undefined,
462
+ gone: q.has('gone') ? q.get('gone') !== 'false' : undefined,
463
+ }
464
+ const check = resolveAction(parts[0], args)
465
+ if (!check.valid) {
466
+ res.writeHead(400, { 'Content-Type': 'text/plain; charset=utf-8' })
467
+ res.end(check.error)
468
+ return
469
+ }
470
+ const result = await sendAction(parts[0], args)
471
+ lastRaw = null // page mutated; force fresh collect next read
472
+ if (parts[0] === 'screenshot' && result.dataUrl) {
473
+ const dir = resolve(server.config.root, '.aipeek')
474
+ mkdirSync(dir, { recursive: true })
475
+ const name = q.get('out') || `shot-${result.dataUrl.length}.png`
476
+ const file = resolve(dir, name)
477
+ writeFileSync(file, Buffer.from(result.dataUrl.split(',')[1], 'base64'))
478
+ res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' })
479
+ res.end(`saved: ${file}`)
480
+ return
481
+ }
482
+ res.writeHead(result.ok ? 200 : 422, { 'Content-Type': 'text/plain; charset=utf-8' })
483
+ const head = result.ok ? (result.detail || 'ok') : `${result.error}${result.detail ? `\n\nclickable: ${result.detail}` : ''}`
484
+ res.end(result.ui ? `${head}\n\n--- ui after ---\n${result.ui}` : head)
485
+ return
486
+ }
487
+
488
+ // check endpoint
489
+ if (parts[0] === 'check') {
490
+ const raw = await collectFromClient()
491
+ lastRaw = raw
492
+ const result = check(raw)
493
+ const output = emitCheck(result)
494
+ res.writeHead(result.pass ? 200 : 417, { 'Content-Type': 'text/plain; charset=utf-8' })
495
+ res.end(output)
496
+ return
497
+ }
498
+
499
+ // detail: /__aipeek/{section}[/{index}][?full]
500
+ if (parts.length >= 1) {
501
+ if (!lastRaw)
502
+ lastRaw = await collectFromClient()
503
+ const result = detail(lastRaw, parts[0], parts[1], full)
504
+ if (result !== null) {
505
+ res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' })
506
+ res.end(result)
507
+ return
508
+ }
509
+ res.writeHead(404, { 'Content-Type': 'text/plain' })
510
+ res.end(`not found: ${parts.join('/')}`)
511
+ return
512
+ }
513
+
514
+ // summary or full: /__aipeek[?full]
515
+ const raw = await collectFromClient()
516
+ lastRaw = raw
517
+ if (full) {
518
+ const compacted = compact(raw)
519
+ const output = emit(compacted)
520
+ res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' })
521
+ res.end(output)
522
+ }
523
+ else {
524
+ const output = emitSummary(raw)
525
+ res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' })
526
+ res.end(output)
527
+ }
528
+ }
529
+ catch (err) {
530
+ res.writeHead(504, { 'Content-Type': 'text/plain' })
531
+ res.end(err instanceof Error ? err.message : 'unknown error')
532
+ }
533
+ })
534
+ },
535
+ }
536
+ }