aipeek 0.2.3 → 0.2.5

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,493 @@
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
+ export const START_TAG = '<!-- AIPEEK:START -->'
111
+ export const END_TAG = '<!-- AIPEEK:END -->'
112
+
113
+ // Marker-based injection — the block lives between START_TAG and END_TAG, so
114
+ // re-injection is a deterministic splice (find markers, replace between) rather
115
+ // than fuzzy line matching. New file → write markers + snippet. Existing markers
116
+ // → replace their contents. No markers yet → append a fresh marked block.
117
+ export function injectClaudeMd(root: string, port: number) {
118
+ const path = resolve(root, 'CLAUDE.md')
119
+ const block = `${START_TAG}\n${aipeekSnippet(port).trim()}\n${END_TAG}\n`
120
+ try {
121
+ if (!existsSync(path)) {
122
+ writeFileSync(path, block)
123
+ return
124
+ }
125
+ const content = readFileSync(path, 'utf-8')
126
+ const si = content.indexOf(START_TAG)
127
+ const ei = content.indexOf(END_TAG)
128
+ if (si !== -1 && ei !== -1) {
129
+ writeFileSync(path, content.slice(0, si) + block.trimEnd() + content.slice(ei + END_TAG.length))
130
+ return
131
+ }
132
+ const sep = content.endsWith('\n') ? '' : '\n'
133
+ writeFileSync(path, `${content}${sep}\n${block}`)
134
+ }
135
+ catch {}
136
+ }
137
+
138
+ export function aipeekPlugin(): Plugin {
139
+ let pendingResolve: ((data: RawState) => void) | null = null
140
+ let server: ViteDevServer
141
+ let lastRaw: RawState | null = null
142
+ let pushTimer: ReturnType<typeof setTimeout> | undefined
143
+ const pendingActions = new Map<number, (r: ActionResult) => void>()
144
+ let actionId = 0
145
+
146
+ let pendingDom: ((dom: string) => void) | null = null
147
+ let pendingScreen: ((screen: string) => void) | null = null
148
+ const pendingEvals = new Map<number, (r: { ok: boolean, value?: string, error?: string }) => void>()
149
+ let evalId = 0
150
+
151
+ // Multi-tab: round one asks only the visible tab (requireVisible). If no tab is
152
+ // visible (user reading the terminal), nobody answers within VISIBLE_MS, so round
153
+ // two drops the guard and any tab replies. `arm` installs the pending slot and
154
+ // returns a clearer; the slot's resolve settles the promise on either round.
155
+ const VISIBLE_MS = 400
156
+ function twoPhase<T>(
157
+ event: string,
158
+ payload: Record<string, unknown>,
159
+ arm: (resolve: (v: T) => void) => () => void,
160
+ fullMs = 3000,
161
+ ): Promise<T> {
162
+ return new Promise<T>((resolve, reject) => {
163
+ let settled = false
164
+ const clear = arm((v) => {
165
+ settled = true
166
+ resolve(v)
167
+ })
168
+ server.hot.send(event, { ...payload, requireVisible: true })
169
+ setTimeout(() => {
170
+ if (settled)
171
+ return
172
+ // no visible tab answered — ask everyone
173
+ server.hot.send(event, { ...payload, requireVisible: false })
174
+ setTimeout(() => {
175
+ if (settled)
176
+ return
177
+ clear()
178
+ reject(new Error(`timeout: no client response within ${VISIBLE_MS + fullMs}ms`))
179
+ }, fullMs)
180
+ }, VISIBLE_MS)
181
+ })
182
+ }
183
+
184
+ function collectFromClient(): Promise<RawState> {
185
+ return twoPhase<RawState>('aipeek:collect', {}, (resolve) => {
186
+ pendingResolve = resolve
187
+ return () => {
188
+ pendingResolve = null
189
+ }
190
+ })
191
+ }
192
+
193
+ function collectDomFromClient(scope?: string, sel?: string): Promise<string> {
194
+ return twoPhase<string>('aipeek:collect-dom', { scope, sel }, (resolve) => {
195
+ pendingDom = resolve
196
+ return () => {
197
+ pendingDom = null
198
+ }
199
+ })
200
+ }
201
+
202
+ function collectScreenFromClient(): Promise<string> {
203
+ return twoPhase<string>('aipeek:collect-screen', {}, (resolve) => {
204
+ pendingScreen = resolve
205
+ return () => {
206
+ pendingScreen = null
207
+ }
208
+ })
209
+ }
210
+
211
+ function sendAction(type: string, args: ActionArgs): Promise<ActionResult> {
212
+ const id = ++actionId
213
+ // wait actions own their timeout; give the channel that long + slack
214
+ const fullMs = Math.max(args.timeout ?? 0, 3000) + 2000
215
+ return twoPhase<ActionResult>('aipeek:action', { id, type, args }, (resolve) => {
216
+ pendingActions.set(id, resolve)
217
+ return () => {
218
+ pendingActions.delete(id)
219
+ }
220
+ }, fullMs)
221
+ }
222
+
223
+ function evalInClient(code: string): Promise<{ ok: boolean, value?: string, error?: string }> {
224
+ const id = ++evalId
225
+ return twoPhase('aipeek:eval', { id, code }, (resolve) => {
226
+ pendingEvals.set(id, resolve)
227
+ return () => {
228
+ pendingEvals.delete(id)
229
+ }
230
+ }, 8000)
231
+ }
232
+
233
+ return {
234
+ name: 'aipeek',
235
+ apply: 'serve',
236
+
237
+ transformIndexHtml() {
238
+ const patchCode = compilePatch()
239
+ return [
240
+ // Synchronous inline script — patches console/fetch/XHR/errors
241
+ // BEFORE any ES modules execute
242
+ {
243
+ tag: 'script',
244
+ children: patchCode,
245
+ injectTo: 'head-prepend',
246
+ },
247
+ // Module script — collectors + HMR channel (can be deferred)
248
+ {
249
+ tag: 'script',
250
+ attrs: { type: 'module' },
251
+ children: `import '/@fs/${clientPath}'`,
252
+ injectTo: 'body',
253
+ },
254
+ ]
255
+ },
256
+
257
+ configureServer(_server) {
258
+ server = _server
259
+ injectClaudeMd(server.config.root, server.config.server.port || 5173)
260
+
261
+ server.hot.on('aipeek:state', (data: RawState) => {
262
+ if (pendingResolve) {
263
+ pendingResolve(data)
264
+ pendingResolve = null
265
+ }
266
+ })
267
+
268
+ server.hot.on('aipeek:result', (data: ActionResult & { id: number }) => {
269
+ const resolve = pendingActions.get(data.id)
270
+ if (resolve) {
271
+ pendingActions.delete(data.id)
272
+ resolve(data)
273
+ }
274
+ })
275
+
276
+ server.hot.on('aipeek:eval-result', (data: { id: number, ok: boolean, value?: string, error?: string }) => {
277
+ const resolve = pendingEvals.get(data.id)
278
+ if (resolve) {
279
+ pendingEvals.delete(data.id)
280
+ resolve(data)
281
+ }
282
+ })
283
+
284
+ server.hot.on('aipeek:dom', (data: { dom: string }) => {
285
+ if (pendingDom) {
286
+ pendingDom(data.dom)
287
+ pendingDom = null
288
+ }
289
+ })
290
+
291
+ server.hot.on('aipeek:screen', (data: { screen: string }) => {
292
+ if (pendingScreen) {
293
+ pendingScreen(data.screen)
294
+ pendingScreen = null
295
+ }
296
+ })
297
+
298
+ // push mode: auto-detect errors after HMR
299
+ server.hot.on('vite:afterUpdate', () => {
300
+ clearTimeout(pushTimer)
301
+ pushTimer = setTimeout(async () => {
302
+ try {
303
+ const raw = await collectFromClient()
304
+ const diff = diffState(lastRaw, raw)
305
+ lastRaw = raw
306
+ if (!diff.clean) {
307
+ const msg = emitDiff(diff)
308
+ if (msg)
309
+ server.config.logger.warn(msg)
310
+ }
311
+ }
312
+ catch {}
313
+ }, 500)
314
+ })
315
+
316
+ // /__aipeek — summary
317
+ // /__aipeek/{section}/{index} — detail
318
+ // /__aipeek/check — health check
319
+ server.middlewares.use('/__aipeek', async (req, res) => {
320
+ const url = new URL(req.url || '/', 'http://localhost')
321
+ const parts = url.pathname.split('/').filter(Boolean)
322
+ const full = url.searchParams.has('full')
323
+
324
+ try {
325
+ // /__aipeek/eval — run arbitrary JS in the page. POST body = code,
326
+ // or ?code=. The page evaluates it and returns the result (or thrown
327
+ // error). The escape hatch for anything the typed endpoints can't do:
328
+ // install event listeners, read closures, probe real event flow.
329
+ if (parts[0] === 'eval') {
330
+ let code = url.searchParams.get('code') || ''
331
+ if (!code && req.method === 'POST')
332
+ code = await readBody(req)
333
+ if (!code) {
334
+ res.writeHead(400, { 'Content-Type': 'text/plain; charset=utf-8' })
335
+ res.end('eval needs ?code= or a POST body')
336
+ return
337
+ }
338
+ const r = await evalInClient(code)
339
+ res.writeHead(r.ok ? 200 : 422, { 'Content-Type': 'text/plain; charset=utf-8' })
340
+ res.end(r.ok ? (r.value ?? 'undefined') : `error: ${r.error}`)
341
+ return
342
+ }
343
+
344
+ // /__aipeek/dom[?scope=Component|?sel=CSS] — semantic DOM, scoped, on-demand
345
+ if (parts[0] === 'dom') {
346
+ const dom = await collectDomFromClient(
347
+ url.searchParams.get('scope') || undefined,
348
+ url.searchParams.get('sel') || undefined,
349
+ )
350
+ res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' })
351
+ res.end(dom || '(empty)')
352
+ return
353
+ }
354
+
355
+ // /__aipeek/screen — state-machine projection {view, modal, focus, knobs}
356
+ if (parts[0] === 'screen') {
357
+ const screen = await collectScreenFromClient()
358
+ res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' })
359
+ res.end(screen || '(empty)')
360
+ return
361
+ }
362
+
363
+ // /__aipeek/chain — POST a JSON array of actions, run them in
364
+ // sequence (each settles the DOM before the next), stop on first
365
+ // failure. One round-trip for a whole interaction.
366
+ if (parts[0] === 'chain') {
367
+ const body = await readBody(req)
368
+ let steps: Array<{ type: string } & ActionArgs>
369
+ try {
370
+ steps = JSON.parse(body)
371
+ if (!Array.isArray(steps))
372
+ throw new Error('body must be a JSON array')
373
+ }
374
+ catch (e) {
375
+ res.writeHead(400, { 'Content-Type': 'text/plain; charset=utf-8' })
376
+ res.end(`invalid chain body: ${e instanceof Error ? e.message : String(e)}`)
377
+ return
378
+ }
379
+ lastRaw = null
380
+ const lines: string[] = []
381
+ let lastUi = ''
382
+ let allOk = true
383
+ for (let i = 0; i < steps.length; i++) {
384
+ const { type, ...args } = steps[i]
385
+ const check = resolveAction(type, args)
386
+ if (!check.valid) {
387
+ lines.push(`[${i}] ✗ ${type}: ${check.error}`)
388
+ allOk = false
389
+ break
390
+ }
391
+ const r = await sendAction(type, args)
392
+ lines.push(`[${i}] ${r.ok ? '✓' : '✗'} ${type}: ${r.ok ? (r.detail || 'ok') : r.error}`)
393
+ // Per-step screen projection — captures the transition each
394
+ // mutating step caused, so a view change mid-chain is visible
395
+ // at its source step rather than collapsed into the final tree.
396
+ if (r.screen)
397
+ lines.push(r.screen.split('\n').map(l => ` ${l}`).join('\n'))
398
+ if (r.ui)
399
+ lastUi = r.ui
400
+ if (!r.ok) {
401
+ allOk = false
402
+ break
403
+ }
404
+ }
405
+ res.writeHead(allOk ? 200 : 422, { 'Content-Type': 'text/plain; charset=utf-8' })
406
+ res.end(lastUi ? `${lines.join('\n')}\n\n--- ui after ---\n${lastUi}` : lines.join('\n'))
407
+ return
408
+ }
409
+
410
+ // action endpoints: /__aipeek/{click|fill|press|wait|screenshot}?...
411
+ if (['click', 'fill', 'press', 'wait', 'screenshot'].includes(parts[0])) {
412
+ const q = url.searchParams
413
+ const args: ActionArgs = {
414
+ sel: q.get('sel') || undefined,
415
+ text: q.get('text') || undefined,
416
+ value: q.has('value') ? q.get('value')! : undefined,
417
+ key: q.get('key') || undefined,
418
+ timeout: q.has('timeout') ? Number(q.get('timeout')) : undefined,
419
+ gone: q.has('gone') ? q.get('gone') !== 'false' : undefined,
420
+ }
421
+ const check = resolveAction(parts[0], args)
422
+ if (!check.valid) {
423
+ res.writeHead(400, { 'Content-Type': 'text/plain; charset=utf-8' })
424
+ res.end(check.error)
425
+ return
426
+ }
427
+ const result = await sendAction(parts[0], args)
428
+ lastRaw = null // page mutated; force fresh collect next read
429
+ if (parts[0] === 'screenshot' && result.dataUrl) {
430
+ const dir = resolve(server.config.root, '.aipeek')
431
+ mkdirSync(dir, { recursive: true })
432
+ const name = q.get('out') || `shot-${result.dataUrl.length}.png`
433
+ const file = resolve(dir, name)
434
+ writeFileSync(file, Buffer.from(result.dataUrl.split(',')[1], 'base64'))
435
+ res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' })
436
+ res.end(`saved: ${file}`)
437
+ return
438
+ }
439
+ res.writeHead(result.ok ? 200 : 422, { 'Content-Type': 'text/plain; charset=utf-8' })
440
+ const head = result.ok ? (result.detail || 'ok') : `${result.error}${result.detail ? `\n\nclickable: ${result.detail}` : ''}`
441
+ res.end(result.ui ? `${head}\n\n--- ui after ---\n${result.ui}` : head)
442
+ return
443
+ }
444
+
445
+ // check endpoint
446
+ if (parts[0] === 'check') {
447
+ const raw = await collectFromClient()
448
+ lastRaw = raw
449
+ const result = check(raw)
450
+ const output = emitCheck(result)
451
+ res.writeHead(result.pass ? 200 : 417, { 'Content-Type': 'text/plain; charset=utf-8' })
452
+ res.end(output)
453
+ return
454
+ }
455
+
456
+ // detail: /__aipeek/{section}[/{index}][?full]
457
+ if (parts.length >= 1) {
458
+ if (!lastRaw)
459
+ lastRaw = await collectFromClient()
460
+ const result = detail(lastRaw, parts[0], parts[1], full)
461
+ if (result !== null) {
462
+ res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' })
463
+ res.end(result)
464
+ return
465
+ }
466
+ res.writeHead(404, { 'Content-Type': 'text/plain' })
467
+ res.end(`not found: ${parts.join('/')}`)
468
+ return
469
+ }
470
+
471
+ // summary or full: /__aipeek[?full]
472
+ const raw = await collectFromClient()
473
+ lastRaw = raw
474
+ if (full) {
475
+ const compacted = compact(raw)
476
+ const output = emit(compacted)
477
+ res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' })
478
+ res.end(output)
479
+ }
480
+ else {
481
+ const output = emitSummary(raw)
482
+ res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' })
483
+ res.end(output)
484
+ }
485
+ }
486
+ catch (err) {
487
+ res.writeHead(504, { 'Content-Type': 'text/plain' })
488
+ res.end(err instanceof Error ? err.message : 'unknown error')
489
+ }
490
+ })
491
+ },
492
+ }
493
+ }