aihand 0.0.1 → 0.1.1

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.
Files changed (113) hide show
  1. package/README.md +152 -2
  2. package/dist/chunk-2NTK7H4W.js +10 -0
  3. package/dist/chunk-3X4FTHLC.cjs +369 -0
  4. package/dist/chunk-BXVNR4E2.js +399 -0
  5. package/dist/chunk-C7DGE6MY.cjs +1456 -0
  6. package/dist/chunk-DUUCVLC3.cjs +254 -0
  7. package/dist/chunk-FAHI53KO.cjs +125 -0
  8. package/dist/chunk-G7KVJ7NF.js +369 -0
  9. package/dist/chunk-GNEUSRGP.js +52 -0
  10. package/dist/chunk-IGNEAOLT.cjs +130 -0
  11. package/dist/chunk-IS5XFUDB.js +125 -0
  12. package/dist/chunk-JLYC76XL.js +2448 -0
  13. package/dist/chunk-KQOABC2O.cjs +52 -0
  14. package/dist/chunk-OVMK33AC.cjs +104 -0
  15. package/dist/chunk-OWYK2IGV.js +250 -0
  16. package/dist/chunk-PQSQN4CN.js +126 -0
  17. package/dist/chunk-QF6AG3M5.cjs +410 -0
  18. package/dist/chunk-QSAMLXML.js +1456 -0
  19. package/dist/chunk-VEKYRKPF.cjs +399 -0
  20. package/dist/chunk-Y6H7W7PI.cjs +2451 -0
  21. package/dist/chunk-YKSYW77R.js +410 -0
  22. package/dist/chunk-Z2Y65YOY.cjs +7 -0
  23. package/dist/chunk-ZJQRNIK7.js +104 -0
  24. package/dist/cli-3J7EYI6G.cjs +651 -0
  25. package/dist/cli-FIJLKAGI.js +649 -0
  26. package/dist/cli-JQEIE7RQ.js +120 -0
  27. package/dist/cli-K3OS2QQH.cjs +122 -0
  28. package/dist/cli-OSYG6LJD.cjs +89 -0
  29. package/dist/cli-TXRW5PG6.js +89 -0
  30. package/dist/cli.cjs +81 -0
  31. package/dist/cli.js +81 -0
  32. package/dist/config-5KEQLN6L.cjs +13 -0
  33. package/dist/config-PJPYKDLQ.js +13 -0
  34. package/dist/graph-IH56SCPK.js +8 -0
  35. package/dist/graph-ZUXXCJ5A.cjs +8 -0
  36. package/dist/index.cjs +481 -0
  37. package/dist/index.d.cts +461 -0
  38. package/dist/index.d.ts +461 -0
  39. package/dist/index.js +479 -0
  40. package/dist/locate-5XFSXJ5J.cjs +15 -0
  41. package/dist/locate-NKSUGL3A.js +15 -0
  42. package/dist/refactor-5FWSZIBN.cjs +19 -0
  43. package/dist/refactor-BOB3SZSA.js +19 -0
  44. package/dist/scan-4R7GQG2W.cjs +9 -0
  45. package/dist/scan-VF54GAAX.js +9 -0
  46. package/dist/ui/probe/server.cjs +505 -0
  47. package/dist/ui/probe/server.js +507 -0
  48. package/dist/vite.cjs +12 -0
  49. package/dist/vite.d.cts +12 -0
  50. package/dist/vite.d.ts +12 -0
  51. package/dist/vite.js +12 -0
  52. package/package.json +82 -9
  53. package/src/cli.ts +107 -0
  54. package/src/index.ts +54 -0
  55. package/src/read/cli.ts +650 -0
  56. package/src/read/compact.ts +286 -0
  57. package/src/read/config.ts +62 -0
  58. package/src/read/graph.ts +182 -0
  59. package/src/read/index.ts +12 -0
  60. package/src/read/inject.ts +121 -0
  61. package/src/read/locate.ts +104 -0
  62. package/src/read/panel.ts +335 -0
  63. package/src/read/pipeline.ts +78 -0
  64. package/src/read/refactor.ts +576 -0
  65. package/src/read/render.ts +1118 -0
  66. package/src/read/scan.ts +61 -0
  67. package/src/read/seam.ts +0 -0
  68. package/src/read/security.ts +171 -0
  69. package/src/read/signals.ts +333 -0
  70. package/src/read/state.ts +71 -0
  71. package/src/read/stategraph.ts +205 -0
  72. package/src/read/types.ts +162 -0
  73. package/src/read/vite.ts +77 -0
  74. package/src/ui/babel/line-profiler.ts +197 -0
  75. package/src/ui/babel/source-loc.ts +68 -0
  76. package/src/ui/bridge/cdp-bridge.ts +138 -0
  77. package/src/ui/bridge/compile-probe.ts +80 -0
  78. package/src/ui/bridge/transport.ts +26 -0
  79. package/src/ui/bridge/vite-bridge.ts +116 -0
  80. package/src/ui/client/client-patch.ts +899 -0
  81. package/src/ui/client/client.ts +2562 -0
  82. package/src/ui/core/action.ts +747 -0
  83. package/src/ui/core/candidates.ts +348 -0
  84. package/src/ui/core/canvas.ts +305 -0
  85. package/src/ui/core/check.ts +34 -0
  86. package/src/ui/core/compact.ts +314 -0
  87. package/src/ui/core/detail.ts +244 -0
  88. package/src/ui/core/diff.ts +253 -0
  89. package/src/ui/core/emit.ts +198 -0
  90. package/src/ui/core/knob-exec.ts +137 -0
  91. package/src/ui/core/perf.ts +254 -0
  92. package/src/ui/core/types.ts +164 -0
  93. package/src/ui/core/util.ts +221 -0
  94. package/src/ui/index.ts +5 -0
  95. package/src/ui/probe/cli.ts +139 -0
  96. package/src/ui/probe/server.ts +468 -0
  97. package/src/ui/self/act.ts +47 -0
  98. package/src/ui/self/discover.ts +101 -0
  99. package/src/ui/self/grow.ts +121 -0
  100. package/src/ui/self/install.ts +100 -0
  101. package/src/ui/self/probe.ts +105 -0
  102. package/src/ui/self/screen-hook.ts +44 -0
  103. package/src/ui/self/self.ts +48 -0
  104. package/src/ui/self/store-refs.ts +123 -0
  105. package/src/ui/self/store-schema.ts +65 -0
  106. package/src/ui/self/synth.ts +37 -0
  107. package/src/ui/server/cli.ts +102 -0
  108. package/src/ui/server/dispatch.ts +276 -0
  109. package/src/ui/server/help-text.ts +237 -0
  110. package/src/ui/server/knob-schema.ts +87 -0
  111. package/src/ui/server/plugin.ts +1151 -0
  112. package/src/vite.ts +39 -0
  113. package/index.js +0 -2
@@ -0,0 +1,237 @@
1
+ // The /__aihand/help body — the full curl playbook for the runtime probe (read state →
2
+ // reverse morphism → drive → chain → escape hatch). Pure: port → string, no closure deps.
3
+ // Lifted out of plugin.ts verbatim so the two real plugin functions lead the file.
4
+ export function helpText(port: number) {
5
+ const base = `http://localhost:${port}/__aihand`
6
+ return `
7
+ # aihand — Runtime Browser Inspector
8
+
9
+ 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.
10
+
11
+ ## Read state — cheapest first
12
+
13
+ \`\`\`bash
14
+ curl ${base}/screen # semantic layout tree (indent=nesting, 左/中/右) + {view, modal, focus, knobs} — START HERE (returns token: tN)
15
+ curl '${base}/screen?form=visual' # same screen as an ASCII art sketch instead of the layout tree
16
+ curl '${base}/screen?form=knobs' # only the control surface: clickable knobs + their store morphisms, grouped by column
17
+ curl '${base}/screen?since=tN' # only what moved since that token (view/modal/focus + new errors), not a full snapshot
18
+ curl ${base}/ui # React component tree — deep-dive when /screen isn't enough
19
+ curl '${base}/dom?scope=ChatInput' # semantic DOM scoped to a component — UI as text, src locations
20
+ curl ${base} # high-density summary (ok sections → 1 line, issues → expanded)
21
+ curl ${base}?full # full dump: UI tree + console + network + errors + state
22
+ curl ${base}/check # pass/fail health check — use after code changes
23
+ curl ${base}/console # console logs (errors, warnings, info)
24
+ curl ${base}/network # fetch/XHR requests with status and timing
25
+ curl ${base}/errors # uncaught errors and unhandled rejections
26
+ curl ${base}/state # registered store snapshots (overview)
27
+ curl ${base}/state/imStore # drill into one store (domain)
28
+ curl '${base}/state?path=imStore.conversations.0' # expand a nested value the overview collapsed to Array(N) (point)
29
+ curl ${base}/tabs # list live tabs (id, visible/background, title) for ?tab= addressing
30
+ curl ${base}/timeline # interleaved action stream across all tabs (who clicked what, in order)
31
+ curl '${base}/query?sel=[role=menuitem]' # this selector right now: count + each element's text/visible/attrs
32
+ curl ${base}/profile # performance profiler: which component/function is burning frames (+ source lines with AIPEEK_LINES=1)
33
+ curl ${base}/profile/reset # clear the profiler window, then reproduce the interaction
34
+ curl ${base}/profile/diff # closed loop: 1st call marks baseline, fix+reproduce, 2nd call → IMPROVED/REGRESSED verdict
35
+ \`\`\`
36
+
37
+ \`/profile\` is a verdict, not a flame graph — read it, fix, re-read, don't eyeball frames.
38
+ Three tiers, escalate only as needed:
39
+
40
+ \`\`\`bash
41
+ # 1. Diagnose — one read names the component/function burning frames (self-time × render count)
42
+ curl ${base}/profile
43
+
44
+ # 2. Closed-loop A/B — proves a fix worked instead of guessing
45
+ curl ${base}/profile/reset # clear the window
46
+ # …reproduce the slow interaction (scroll, stream, type)…
47
+ curl ${base}/profile # 1st call = baseline snapshot
48
+ # …edit code + reproduce the SAME interaction…
49
+ curl ${base}/profile/diff # 2nd call → IMPROVED / REGRESSED / NO DATA verdict
50
+
51
+ # 3. Line-level — break a hot component down to the exact source line
52
+ AIPEEK_LINES=1 <your dev command> # opt-in: line instrumentation taxes every render, off by default
53
+ curl ${base}/profile
54
+ \`\`\`
55
+
56
+ Profiling is the ONE read that needs the tab FOREGROUND + FOCUSED — the OS throttles rAF
57
+ for backgrounded/unfocused windows, so timing there is a lie. The profiler refuses to launder
58
+ that lie: a throttled window reads \`Frames: none sampled\` and a window that didn't reproduce
59
+ the workload reads \`NO DATA\`, never a fabricated \`0.0% dropped\` / \`IMPROVED\`. A real verdict
60
+ requires: tab in front, \`/profile/reset\`, then repeat the exact interaction. (\`/screen\` and
61
+ \`/dom\` work fine backgrounded — only profiling needs focus.)
62
+
63
+ \`/query\` is the read-side twin of click/fill's \`sel=\` — assert on a specific element
64
+ (how many match, its text, \`data-state\`, \`aria-*\`/\`data-*\`, value, disabled) without \`/eval\`.
65
+ Secret fields (password inputs, API-key/token fields) show \`‹redacted N chars›\` instead of
66
+ their value across \`/dom\`, \`/query\` and \`/screen\` — length stays visible, the secret doesn't.
67
+
68
+ \`/screen\` projects the whole UI to a few state variables — start there, not \`/ui\`. Append
69
+ \`?full\` for untruncated output. Each read prints a \`token: tN\` line; pass it back as
70
+ \`/screen?since=tN\` to get only the transition since that read (view/modal/focus + new
71
+ errors/failed requests), \`(no state change)\` if nothing moved — the cheap "what changed
72
+ after I acted" read, without re-paying for the unchanged 99%.
73
+
74
+ To inspect or edit a component, work top-down — the full DOM is huge, a scoped view is
75
+ accurate: \`/screen\` or \`/ui\` to find the component, then \`/dom?scope=<Name>\` (matches the
76
+ source path) or \`/dom?sel=<css>\` for just that subtree. Each line carries its source
77
+ location (\`@File.tsx:line\`), so the DOM view tells you exactly where to edit.
78
+
79
+ ## UI → code reason (reverse morphism)
80
+
81
+ \`\`\`bash
82
+ curl '${base}/source?path=src/features/Sidebar/index.tsx:78' # which symbol owns this line + its callers/callees
83
+ curl -G ${base}/source --data-urlencode 'path=<data-insp-path>' # feed /dom's data-insp-path raw (rel:line:col:Name)
84
+ \`\`\`
85
+
86
+ \`/dom\` tells you *where* a UI element lives (\`@File.tsx:line\`); \`/source\` tells you *what code
87
+ backs it* — the enclosing symbol, who calls it, what it calls. Paste a \`data-insp-path\`
88
+ (\`rel:line:col:Name\`) straight from \`/dom\`. This is the static twin of \`/open\` (which sends the
89
+ same coordinate to your editor): point at a rendered element → get its code reason, without grepping.
90
+
91
+ ## Drive the page (acts on the currently-open tab — no separate browser)
92
+
93
+ Pass \`text=\`/\`sel=\`/\`value=\` via \`-G --data-urlencode\` — **always**, not just for CJK. A raw
94
+ \`?text=群聊\` is an illegal HTTP request-target, rejected with an empty 400 *before aihand runs*
95
+ (a wasted round-trip); \`--data-urlencode\` escapes it for you, so the same form works for \`New\`
96
+ and \`群聊\` alike — write it right the first time.
97
+
98
+ \`\`\`bash
99
+ curl -G ${base}/click --data-urlencode 'text=New' # click by visible text (or 'sel=<css>')
100
+ curl -G ${base}/dblclick --data-urlencode 'text=todo' # double-click (inline edit / rename / select); fires dblclick handlers click can't
101
+ curl -G ${base}/hover --data-urlencode 'text=More' # hover only (reveal a menu/tooltip; no click)
102
+ curl -G ${base}/fill --data-urlencode 'sel=textarea' --data-urlencode 'value=hi' # set input/textarea; <select> matches by option text
103
+ curl -G ${base}/press --data-urlencode 'key=Enter' # key on focused element (e.g. Control+a)
104
+ curl -G ${base}/wait --data-urlencode 'text=Done' -d 'timeout=8000' # poll until text/sel appears (gone=1 until it disappears; enabled=1 until not [disabled])
105
+ curl '${base}/screenshot?out=shot.png' # DOM→PNG into .aidev/ (html-to-image; lossy)
106
+ \`\`\`
107
+
108
+ ASCII-only values may use the bare \`?text=New\` form, but \`-G --data-urlencode\` is never wrong —
109
+ prefer it so a value that turns out to contain CJK/space/\`&\` doesn't silently 400.
110
+
111
+ \`click\`/\`fill\`/\`press\` settle the DOM and append \`--- changed ---\`: only the state-machine
112
+ transition this action caused (\`view: a → b\`, \`modal: opened X\`, \`focus: …\`) plus any new
113
+ errors/failed requests — not a fresh snapshot. \`(no state change)\` means nothing moved. Read
114
+ the delta, then drill into /ui or /dom for detail if you need it. On a miss, the response lists
115
+ the reachable clickable elements so you can re-target.
116
+
117
+ Each \`click\`/\`fill\`/\`press\` response also carries a \`--- recent actions ---\` timeline:
118
+ the semantic page actions (yours and the user's) in order, \`T\`=trusted human / \`S\`=synthetic
119
+ aihand, each with its resulting UI change (\`→ 弹窗打开「…」\`/\`→ 弹窗关闭\`). Your own action is
120
+ bracketed by \`你当前的行为\` dividers. So if the user closed a dialog you just opened, you see
121
+ their \`T key:Escape → 弹窗关闭\` right after your \`S\` action — no need to query for it.
122
+
123
+ **Beyond click/fill/press** — four more interactions for what those can't reach:
124
+
125
+ \`\`\`bash
126
+ curl '${base}/scrollIntoView?text=Row 99' # scroll a target into view (off-screen list rows)
127
+ curl '${base}/drag?sel=.item&to=.slot' # synthetic pointer drag, source → destination
128
+ curl '${base}/drop?sel=.dropzone&files=a.png,b.pdf' # fire a file-drop (DataTransfer) on a target
129
+ curl '${base}/clipboard?mode=write&value=hi' # seed the clipboard (mode=read reports it back)
130
+ \`\`\`
131
+
132
+ \`drag\` fires a real pointer sequence (down → stepped moves past dnd-kit's activation
133
+ distance → up); if a dnd-kit reorder doesn't take, retry the same gesture via \`realclick\`
134
+ (trusted events). \`drop\` delivers the drop event with the named files (synthetic Files have
135
+ no byte content — fine for triggering handlers, not for real uploads). \`clipboard\` needs the
136
+ tab focused (browser security) and says so plainly when it isn't, rather than hanging.
137
+
138
+ A control tagged \`{needs-trusted?}\` in \`/screen\` or \`/dom\` opens a popup (\`aria-haspopup\`)
139
+ that a synthetic click may not trigger — reach for \`realclick\` on it from the start instead
140
+ of discovering it via a dead click. (Right-click-only menus carry no DOM marker, so they
141
+ still surface only on a miss — use \`realclick\` with \`button=right\` there.)
142
+
143
+ **Green channel — replay a knob's morphism, no DOM.** \`/screen?form=knobs\` lists every
144
+ control with its store morphism (\`「群聊」→ appUIStore.{ mode=im }\`). \`/action?knob=<label>\`
145
+ replays that morphism by writing the store directly — bypassing selector resolution, disabled
146
+ gating, and the silent-false-success a covered \`/click\` can return. It's smoother than
147
+ \`/click\`+\`/fill\` for any control whose effect is a store write:
148
+
149
+ \`\`\`bash
150
+ curl -G ${base}/action --data-urlencode 'knob=群聊' # literal: appUIStore.mode='im'
151
+ curl -G ${base}/action --data-urlencode 'knob=深度研究' # toggle: flips the bool
152
+ curl -G ${base}/action --data-urlencode 'knob=最大Token' --data-urlencode 'value=8' # param: needs ?value=
153
+ curl ${base}/action?knob=@k3 # @kN: addressable ref from /screen, no CJK escaping
154
+ \`\`\`
155
+
156
+ Every \`/screen\` projection tags each knob with a stable \`@kN\` ref (the number in \`?form=knobs\`,
157
+ shared across the ASCII art / tree / legend). \`/action?knob=@k3\` resolves it back to the label —
158
+ saving the CJK \`--data-urlencode\` round-trip and any \`&file=\` disambiguation. A stale/unknown
159
+ \`@kN\` is refused (422 listing the current numbers) — re-read \`/screen\` for fresh ones; it never
160
+ silently hits the wrong knob.
161
+
162
+ Returns the same \`--- changed ---\` trajectory a \`/click\` does (waitForStable + diffScreen),
163
+ not a full state dump. A knob whose value depends on runtime context (\`e.target.value\`, a
164
+ mapped array, a method with args) is **refused** (422 + reason) — fall back to \`/click\`/\`/fill\`
165
+ there; it never fake-replays. A label two components share returns 422 + candidates; add
166
+ \`&file=<path-fragment>\` to disambiguate.
167
+
168
+ **Multiple tabs.** Every read/drive command takes \`?tab=<id>\` to address one specific tab —
169
+ including a **background** one (you can drive the Chat tab while the user is looking at a
170
+ different tab). Run \`${base}/tabs\` to see the live ids. With one tab open, omit \`?tab=\` and
171
+ it just works. With several tabs open and no \`?tab=\`, the command returns \`409\` + the tab
172
+ list (rather than randomly hitting one) — pick an id from it and retry with \`?tab=\`.
173
+
174
+ **Multiple servers (federation).** When several dev servers run at once — a micro-frontend,
175
+ separate front/back servers, or a teammate's machine — every command also takes
176
+ \`?host=<host:port>\` to reach a *sibling* aihand. The plugin you curl reverse-proxies the
177
+ request to that peer (server-side, no browser): \`${base}/screen?host=localhost:5174\` reads
178
+ the app on :5174; combine with \`?tab=\` to point at one tab over there
179
+ (\`?host=192.168.1.9:5173&tab=t3\`). Omit \`?host=\` and it's the local server as always. There's
180
+ no registry — you name the peer, so list its tabs with \`/tabs?host=<host:port>\` first.
181
+
182
+ **Cross-tab timeline.** \`${base}/timeline\` interleaves the semantic actions of *every* tab
183
+ in time order — each line \`<tab> [T|S] <action> → <ui change>\` (\`T\`=trusted human,
184
+ \`S\`=synthetic aihand). The per-action \`--- recent actions ---\` tail only shows the acting
185
+ tab; \`/timeline\` is the group view, so an A/B comparison across two tabs (drive A, watch B
186
+ react) is one read. \`?tab=<id>\` filters to one tab's history.
187
+
188
+ **Chain — a whole interaction in one round-trip.** POST a JSON array; runs in sequence,
189
+ each step settles before the next, stops on first failure. A failed step carries the same
190
+ \`clickable:\` fallback list a single-shot \`/click\` gives, and a trailing
191
+ \`[n..m] skipped (… not run)\` line names every step the fail-fast stop left unrun — the page
192
+ is parked in a half-done state, not rolled back (page actions aren't transactional):
193
+
194
+ \`\`\`bash
195
+ curl -X POST ${base}/chain -d '[
196
+ {"type":"knob","knob":"群聊"},
197
+ {"type":"assert","screen":"appUIStore.mode","equals":"im"},
198
+ {"type":"click","sel":"button[title=\\"知识库\\"]"},
199
+ {"type":"wait","text":"Done"},
200
+ {"type":"fill","sel":"textarea","value":"hi"},
201
+ {"type":"assert","screen":"<a domain key from /screen>","equals":"false"},
202
+ {"type":"press","key":"Enter"}
203
+ ]'
204
+ \`\`\`
205
+
206
+ The \`screen\` key in an \`assert\` must be a real field from \`/screen\`'s \`domain:\` block (e.g.
207
+ \`streaming\`, \`appUIStore.mode\`) — copy it verbatim, the names are app-specific. A wrong key
208
+ fails with \`no domain key "X" — available: …\` listing every real one.
209
+
210
+ A \`knob\` step (\`{type:"knob",knob:"<panel label>"[,value,file]}\`) replays a panel morphism
211
+ via the green channel — the store-direct optimal path \`/action?knob=\` takes, now inside a
212
+ batch. Prefer it over \`click\` when a knob exists: no selector resolution, no overlay gating.
213
+
214
+ \`assert\` is the chain's mid-step judge: \`{type,screen,equals}\` checks a domain variable
215
+ (from the app's \`window.__AIPEEK_SCREEN__\`), or \`{type,sel,equals}\` an element's text. On
216
+ mismatch the chain stops and reports \`asserted X=="Y", actual "Z"\` — a test, not a guess.
217
+ Domain variables also show up in \`/screen\`'s \`domain:\` block and in every \`--- changed ---\`
218
+ diff (e.g. \`流式中: false → true\`) — the app's own state machine, which a DOM-only inspector
219
+ can't see. The app opts in by setting \`window.__AIPEEK_SCREEN__ = () => ({...})\`.
220
+
221
+ **Escape hatch.** \`curl '${base}/eval?code=...'\` (or POST the code as the body) runs arbitrary
222
+ JS in the page and returns the result — for what the typed endpoints can't do (install listeners,
223
+ read closures, probe event flow). For count/text/state/attr assertions reach for \`/query\` first.
224
+ When the code is a hand-rolled \`/state\` (or the selector is illegal), the response tail carries a \`hint:\` line pointing at the typed twin.
225
+
226
+ aihand auto-detects errors after HMR and prints them to the terminal — watch for \`[aihand]\` messages.
227
+
228
+ **Iterating on aihand itself.** Editing this server's own code (\`plugin.ts\` and anything in the Vite
229
+ config dependency graph) makes Vite AUTO-restart the dev server in-process — terminal prints
230
+ \`…changed, restarting server…\` then \`server restarted.\`, PID unchanged. You do NOT restart it by
231
+ hand and you do NOT ask the user to; just edit and wait ~2-4s, then poll \`${base}/tabs\` for the page
232
+ to re-register (it self-heals via an unthrottled heartbeat). The client probe (\`client.ts\`) instead
233
+ hot-swaps in place keeping app state. Server stdout goes to the user's terminal (you can't read it) —
234
+ to make server-side state curl-observable, add a temporary debug route, let Vite auto-restart, then
235
+ remove it.
236
+ `
237
+ }
@@ -0,0 +1,87 @@
1
+ // aihand/vite — 旋钮语义的 build 时投影,活的 /screen 的输入端零件。
2
+ //
3
+ // 对偶于 aiself storeSchema(状态/输出端):那个把 store 的值域从类型注解长出,这个把
4
+ // 旋钮的态射(拨它 → store.{field=to})从组件 onClick 长出。两者都是「build 时 AST、
5
+ // 运行时纯 JSON」—— tree-sitter 只在这里(Node 侧)跑,浏览器只收一张 join 表。
6
+ //
7
+ // 为什么 build 时:extractKnobs 走 web-tree-sitter,绝不进浏览器(同 aiself §0.3.0 铁律)。
8
+ // /screen 运行时只有 DOM 候选(label + @File:line);语义这一半在这里备好,运行时按
9
+ // join 键 label+file 拼回去。join 键不是 file:line —— host 行 ≠ handler 行,组件边界处错位。
10
+
11
+ import type { KnobArity, KnobOp } from '../../read/panel.js'
12
+ import { globSync } from 'glob'
13
+ import { buildPanel, classifyKnob, fmtTransitions } from '../../read/panel.js'
14
+
15
+ const VIRTUAL = 'virtual:aihand-knobs'
16
+ const RESOLVED = '\0' + VIRTUAL
17
+
18
+ interface Options {
19
+ // 扫哪些组件文件(glob,相对 vite root)。默认全 src 下的 tsx/jsx。
20
+ include?: string[]
21
+ }
22
+
23
+ // 一个旋钮在浏览器侧需要的最小投影:渲染好的态射 + store + 坐标 + 可执行算子。
24
+ // key 由 label+file 拼成(join 键),client 用 DOM 候选的 label+file 查它。
25
+ // transitions(人读串)供 /screen morphism 渲染;ops/arity/executable 供绿色通道 executeKnob 直接执行。
26
+ export interface KnobProjection {
27
+ transitions: string // 'mode=im' / 'showSideBar=!showSideBar'
28
+ store: string
29
+ line: number
30
+ ops: KnobOp[] // 结构化算子(field+kind+arity+to/reason),executeKnob 按 kind 分派
31
+ arity: KnobArity // 整钮:nullary(直接 replay) / param(需 ?value=)
32
+ executable: boolean // false = 任一 op 依赖运行时上下文,诚实拒绝
33
+ }
34
+
35
+ // 旋钮 join 键:label + file。label 为 null(纯图标旋钮)时退回 tag,仍带 file 区分。
36
+ // 与 client 侧从 @File:line 取 file 段、从 DOM 取 label 的拼法必须一致。
37
+ function keyOf(label: string | null, tag: string, file: string): string {
38
+ return `${label ?? `<${tag}>`} ${file}`
39
+ }
40
+
41
+ export function knobSchema(options: Options = {}) {
42
+ const include = options.include ?? ['src/**/*.tsx', 'src/**/*.jsx']
43
+ let cache: Record<string, KnobProjection> | null = null
44
+
45
+ const build = async (): Promise<Record<string, KnobProjection>> => {
46
+ if (cache)
47
+ return cache
48
+ const files = globSync(include, { nodir: true, ignore: ['**/node_modules/**'] })
49
+ const knobs = await buildPanel(files)
50
+ const byKey: Record<string, KnobProjection> = {}
51
+ for (const k of knobs) {
52
+ // filePath 是 glob 给的相对/绝对路径;client 侧的 file 来自 babel data-insp-path,
53
+ // 通常是末两段(如 Sidebar/index.tsx)。统一成末两段做 join 键。
54
+ const file = k.filePath.split('/').slice(-2).join('/')
55
+ const { ops, arity, executable } = classifyKnob(k.transitions)
56
+ byKey[keyOf(k.label, k.tag, file)] = {
57
+ transitions: fmtTransitions(k.transitions),
58
+ store: k.store,
59
+ line: k.line,
60
+ ops,
61
+ arity,
62
+ executable,
63
+ }
64
+ }
65
+ cache = byKey
66
+ return byKey
67
+ }
68
+
69
+ return {
70
+ name: 'aihand:knob-schema',
71
+ resolveId(id: string) {
72
+ if (id === VIRTUAL)
73
+ return RESOLVED
74
+ },
75
+ async load(id: string) {
76
+ if (id !== RESOLVED)
77
+ return
78
+ const knobs = await build()
79
+ return `export const knobs = ${JSON.stringify(knobs)}\n`
80
+ },
81
+ // 组件改动 → 失效缓存,HMR 重投影(onClick 改了 → 态射跟着变)。
82
+ handleHotUpdate(ctx: { file: string }) {
83
+ if (ctx.file.endsWith('.tsx') || ctx.file.endsWith('.jsx'))
84
+ cache = null
85
+ },
86
+ }
87
+ }