@xiaobuyu/nodesign 0.0.1 → 0.0.2

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 (282) hide show
  1. package/README.md +156 -106
  2. package/bin/nodesign.js +0 -0
  3. package/package.json +1 -1
  4. package/server/api/README.md +45 -45
  5. package/server/api/_guard.js +75 -75
  6. package/server/api/admin.js +187 -187
  7. package/server/api/assets/docx-page.js +103 -103
  8. package/server/api/assets/helpers.js +22 -22
  9. package/server/api/assets/notes.js +144 -144
  10. package/server/api/assets.js +896 -896
  11. package/server/api/board.js +57 -57
  12. package/server/api/browse.js +138 -138
  13. package/server/api/canvas.js +444 -444
  14. package/server/api/chatai.js +324 -324
  15. package/server/api/exports/build-standalone.js +980 -980
  16. package/server/api/exports/cards.js +118 -118
  17. package/server/api/exports/docx-pdf.js +46 -46
  18. package/server/api/exports/export-page.js +142 -142
  19. package/server/api/exports/handoff.js +147 -147
  20. package/server/api/exports.js +690 -690
  21. package/server/api/instruction.js +75 -75
  22. package/server/api/me.js +116 -116
  23. package/server/api/memory.js +185 -185
  24. package/server/api/pending-changes.js +366 -366
  25. package/server/api/plugins.js +164 -164
  26. package/server/api/projects.js +220 -220
  27. package/server/api/publish.js +64 -64
  28. package/server/api/recent.js +73 -73
  29. package/server/api/sessions.js +589 -589
  30. package/server/api/skills.js +77 -77
  31. package/server/api/standalone-fit.js +168 -168
  32. package/server/api/turn-compose.js +180 -180
  33. package/server/api/turn-inflight.js +44 -44
  34. package/server/auth/README.md +17 -17
  35. package/server/auth/middleware.js +138 -138
  36. package/server/auth/origin-guard.js +71 -71
  37. package/server/auth/session.js +108 -108
  38. package/server/auth/tier.js +113 -113
  39. package/server/auth/users-store.js +348 -348
  40. package/server/edit/README.md +29 -29
  41. package/server/engine/README.md +113 -113
  42. package/server/engine/agent/agent-shared.js +550 -550
  43. package/server/engine/agent/auto-mode-default-hard-deny.txt +4 -4
  44. package/server/engine/agent/auto-mode-rules.js +78 -78
  45. package/server/engine/agent/context.js +418 -418
  46. package/server/engine/agent/events.js +464 -464
  47. package/server/engine/agent/hooks/canvas-validate.js +199 -199
  48. package/server/engine/agent/hooks/failure.js +120 -120
  49. package/server/engine/agent/hooks/file-events.js +75 -75
  50. package/server/engine/agent/hooks/lifecycle.js +233 -233
  51. package/server/engine/agent/hooks/post-canvas-focus.js +80 -80
  52. package/server/engine/agent/hooks/post-guidance.js +206 -206
  53. package/server/engine/agent/hooks/post-subagent-report.js +104 -104
  54. package/server/engine/agent/hooks/post-trim.js +58 -58
  55. package/server/engine/agent/hooks/pre-board-neighborhood.js +34 -34
  56. package/server/engine/agent/hooks/pre-defaults.js +84 -84
  57. package/server/engine/agent/hooks/pre-injectors.js +277 -277
  58. package/server/engine/agent/hooks/pre-performance-log-guard.js +78 -78
  59. package/server/engine/agent/hooks/pre-starter-files.js +61 -61
  60. package/server/engine/agent/hooks/pre-workspace-scope-guard.js +78 -78
  61. package/server/engine/agent/hooks/site-validate.js +94 -94
  62. package/server/engine/agent/hooks/tool-prompts.js +31 -31
  63. package/server/engine/agent/hooks/turn-state-memory.js +56 -56
  64. package/server/engine/agent/hooks/user-prompt-submit.js +215 -215
  65. package/server/engine/agent/hooks.js +354 -354
  66. package/server/engine/agent/init-contract.js +69 -69
  67. package/server/engine/agent/isolation.js +159 -159
  68. package/server/engine/agent/model-table.js +292 -292
  69. package/server/engine/agent/plugin-loader.js +266 -266
  70. package/server/engine/agent/prompts/nodesign-prelude.md +336 -336
  71. package/server/engine/agent/prompts/tools/ask-user-question-protocol.md +71 -71
  72. package/server/engine/agent/prompts/tools/direct-edit-protocol.md +98 -98
  73. package/server/engine/agent/prompts/tools/generate-image-cookbook.gemini-gateway.md +583 -583
  74. package/server/engine/agent/prompts/tools/generate-image-cookbook.md +483 -483
  75. package/server/engine/agent/prompts/tools/hybrid-reference.md +100 -100
  76. package/server/engine/agent/prompts/tools/paint-still-cookbook.md +270 -270
  77. package/server/engine/agent/prompts/tools/roll-film-cookbook.md +70 -70
  78. package/server/engine/agent/prompts/tools/site-reference.md +106 -106
  79. package/server/engine/agent/prompts/tools/tweaks-syntax.md +125 -125
  80. package/server/engine/agent/prompts/tools/vision-checker-dispatch.md +48 -48
  81. package/server/engine/agent/session-loop.js +1024 -1024
  82. package/server/engine/agent/session-model.js +164 -164
  83. package/server/engine/agent/skill.js +204 -204
  84. package/server/engine/agent/task-events.js +78 -78
  85. package/server/engine/agents/ds-extractor.md +132 -132
  86. package/server/engine/agents/explorer.md +190 -190
  87. package/server/engine/agents/index.js +230 -230
  88. package/server/engine/agents/schemas/design-system.json +156 -156
  89. package/server/engine/agents/schemas/tweak-schema.json +113 -113
  90. package/server/engine/agents/tweak-proposer.md +127 -127
  91. package/server/engine/agents/vision-checker.md +254 -254
  92. package/server/engine/browse/capture.js +422 -422
  93. package/server/engine/browse/card.js +137 -137
  94. package/server/engine/browse/handover.js +59 -59
  95. package/server/engine/browse/page-digest.js +119 -119
  96. package/server/engine/browse/refs.js +196 -196
  97. package/server/engine/browse/registry.js +307 -307
  98. package/server/engine/browse/screencast.js +170 -170
  99. package/server/engine/browse/state.js +121 -121
  100. package/server/engine/chatai/chat-log.js +79 -79
  101. package/server/engine/chatai/index.js +193 -193
  102. package/server/engine/chatai/openai-compat.js +152 -152
  103. package/server/engine/chatai/orchestrate.js +309 -309
  104. package/server/engine/chatai/perform.js +40 -40
  105. package/server/engine/chatai/summarize.js +79 -79
  106. package/server/engine/mcp/capability-gate.js +52 -52
  107. package/server/engine/mcp/index.js +339 -339
  108. package/server/engine/mcp/param-sanitizer.js +142 -142
  109. package/server/engine/mcp/tools/arrange-on-board.js +124 -124
  110. package/server/engine/mcp/tools/artifact-session.js +278 -278
  111. package/server/engine/mcp/tools/browse-computer.js +421 -421
  112. package/server/engine/mcp/tools/browse-find-batch.js +183 -183
  113. package/server/engine/mcp/tools/browse-screenshot.js +157 -157
  114. package/server/engine/mcp/tools/browse.js +480 -480
  115. package/server/engine/mcp/tools/build-docx.js +101 -101
  116. package/server/engine/mcp/tools/clear-pending-changes.js +99 -99
  117. package/server/engine/mcp/tools/create-on-board.js +155 -155
  118. package/server/engine/mcp/tools/crystallize-skill.js +168 -168
  119. package/server/engine/mcp/tools/explain-style.js +237 -237
  120. package/server/engine/mcp/tools/export-handoff.js +133 -133
  121. package/server/engine/mcp/tools/expose-tweaks.js +149 -149
  122. package/server/engine/mcp/tools/generate-image.js +842 -842
  123. package/server/engine/mcp/tools/get-computed-styles.js +164 -164
  124. package/server/engine/mcp/tools/get-pending-changes.js +205 -205
  125. package/server/engine/mcp/tools/h3box-ssh.js +84 -84
  126. package/server/engine/mcp/tools/helpers/acquire-page.js +82 -82
  127. package/server/engine/mcp/tools/helpers/motion-lab.js +554 -554
  128. package/server/engine/mcp/tools/helpers/motion-scroll.js +112 -112
  129. package/server/engine/mcp/tools/helpers/perception-page.js +215 -215
  130. package/server/engine/mcp/tools/helpers/reference-download.js +103 -103
  131. package/server/engine/mcp/tools/helpers/rembg-bridge.py +80 -80
  132. package/server/engine/mcp/tools/helpers/shot-pipeline.js +280 -280
  133. package/server/engine/mcp/tools/highlight.js +68 -68
  134. package/server/engine/mcp/tools/list-pages.js +222 -222
  135. package/server/engine/mcp/tools/lookup-tags.js +59 -59
  136. package/server/engine/mcp/tools/navigate-to-page.js +60 -60
  137. package/server/engine/mcp/tools/organize-board.js +64 -64
  138. package/server/engine/mcp/tools/paint-still.js +434 -434
  139. package/server/engine/mcp/tools/pin-to-board.js +130 -130
  140. package/server/engine/mcp/tools/preview-deck.js +130 -130
  141. package/server/engine/mcp/tools/profile-scroll.js +305 -305
  142. package/server/engine/mcp/tools/publish-site.js +102 -102
  143. package/server/engine/mcp/tools/query-elements.js +195 -195
  144. package/server/engine/mcp/tools/read-board.js +103 -103
  145. package/server/engine/mcp/tools/read-document.js +93 -93
  146. package/server/engine/mcp/tools/read-page.js +264 -264
  147. package/server/engine/mcp/tools/read-tavern-json.js +143 -143
  148. package/server/engine/mcp/tools/record-decision.js +140 -140
  149. package/server/engine/mcp/tools/relate-on-board.js +110 -110
  150. package/server/engine/mcp/tools/report-issue.js +115 -115
  151. package/server/engine/mcp/tools/roll-film.js +266 -266
  152. package/server/engine/mcp/tools/screenshot-docx.js +138 -138
  153. package/server/engine/mcp/tools/screenshot-url.js +195 -195
  154. package/server/engine/mcp/tools/screenshot.js +583 -583
  155. package/server/engine/mcp/tools/tier-gate.js +96 -96
  156. package/server/engine/mcp/tools/trace-motion.js +215 -215
  157. package/server/engine/mcp/tools/web-search.js +548 -548
  158. package/server/engine/motion/inventory.js +349 -349
  159. package/server/engine/perception/session.js +235 -235
  160. package/server/engine/plugins/nodesign/.claude-plugin/plugin.json +5 -5
  161. package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/SKILL.md +192 -192
  162. package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/canvas.template.html +667 -667
  163. package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/patterns/hybrid-grid.md +22 -22
  164. package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/patterns/image-led-cover.md +25 -25
  165. package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/patterns/portrait.md +23 -23
  166. package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/patterns/quote-backdrop.md +21 -21
  167. package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/patterns/section-divider.md +23 -23
  168. package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/patterns/text-led.md +21 -21
  169. package/server/engine/plugins/nodesign/skills/docx-craft/SKILL.md +162 -162
  170. package/server/engine/plugins/nodesign/skills/docx-craft/references/token-schema.md +407 -407
  171. package/server/engine/plugins/nodesign/skills/docx-craft//346/226/207/346/241/243.template.json +80 -80
  172. package/server/engine/plugins/nodesign/skills/rp-craft/SKILL.md +228 -228
  173. package/server/engine/plugins/nodesign/skills/rp-craft/patterns//346/274/224/345/207/272/351/241/265-/346/234/200/345/260/217/345/256/236/347/216/260.html +78 -78
  174. package/server/engine/plugins/nodesign/skills/rp-craft//346/274/224/345/207/272.template.js +268 -268
  175. package/server/engine/plugins/nodesign/skills/site-craft/SKILL.md +323 -323
  176. package/server/engine/plugins/nodesign/skills/site-craft/patterns/build-lane.md +134 -134
  177. package/server/engine/plugins/nodesign/skills/site-craft/patterns/cutout-collage.md +61 -61
  178. package/server/engine/plugins/nodesign/skills/site-craft/patterns/mock-app.md +163 -163
  179. package/server/engine/plugins/nodesign/skills/site-craft/patterns/page-transitions.md +86 -86
  180. package/server/engine/runs/active-runs.js +698 -698
  181. package/server/engine/runs/live-turn.js +252 -252
  182. package/server/engine/runs/store.js +364 -364
  183. package/server/engine/runs/turn-relay.js +217 -217
  184. package/server/engine/runtime/workspace.js +164 -164
  185. package/server/lib/artifact-file-path.js +80 -80
  186. package/server/lib/artifact-target.js +284 -284
  187. package/server/lib/asset-refs.js +239 -239
  188. package/server/lib/async-queue.js +104 -104
  189. package/server/lib/auto-relations.js +130 -130
  190. package/server/lib/binding-types.js +60 -60
  191. package/server/lib/board-kind-sizes.js +62 -62
  192. package/server/lib/board-relations.js +133 -133
  193. package/server/lib/browse-proxy.js +180 -180
  194. package/server/lib/canvas-id.js +36 -36
  195. package/server/lib/cover.js +227 -227
  196. package/server/lib/danbooru-tags.js +291 -291
  197. package/server/lib/doc-extract.js +156 -156
  198. package/server/lib/docx/build-from-source.js +260 -260
  199. package/server/lib/docx/build.js +490 -490
  200. package/server/lib/docx/dump-styles.js +287 -287
  201. package/server/lib/docx/fonts/nodesign-cjk.conf +164 -164
  202. package/server/lib/docx/merge-runs.js +129 -129
  203. package/server/lib/docx/numbering.js +218 -218
  204. package/server/lib/docx/order.js +152 -152
  205. package/server/lib/docx/rawzip.js +172 -172
  206. package/server/lib/docx/render.js +92 -92
  207. package/server/lib/docx/text-lint.js +225 -225
  208. package/server/lib/docx/tokens.js +335 -335
  209. package/server/lib/docx/units.js +29 -29
  210. package/server/lib/docx/xml.js +291 -291
  211. package/server/lib/docx-pages.js +187 -187
  212. package/server/lib/export-collect.js +289 -289
  213. package/server/lib/export-package.js +210 -210
  214. package/server/lib/html-srcset.js +145 -145
  215. package/server/lib/image-variant.js +460 -460
  216. package/server/lib/ingress/forward-openai-chat.js +305 -305
  217. package/server/lib/ingress/openai-chat.js +491 -491
  218. package/server/lib/ingress/session-notice.js +66 -66
  219. package/server/lib/ingress/session-routes.js +68 -68
  220. package/server/lib/ingress/slot-probe.js +130 -130
  221. package/server/lib/ingress/upstream-billing.js +52 -52
  222. package/server/lib/ingress/upstream-fail-streak.js +72 -72
  223. package/server/lib/ingress/upstream-truncation.js +48 -48
  224. package/server/lib/issues-store.js +182 -182
  225. package/server/lib/kinds/deck.js +72 -72
  226. package/server/lib/kinds/docx.js +283 -283
  227. package/server/lib/kinds/file-kinds.js +113 -113
  228. package/server/lib/kinds/index.js +202 -202
  229. package/server/lib/kinds/site.js +161 -161
  230. package/server/lib/model-ingress.js +543 -543
  231. package/server/lib/moderation.js +255 -255
  232. package/server/lib/notice-store.js +103 -103
  233. package/server/lib/plugin-install.js +104 -104
  234. package/server/lib/plugin-validator.js +721 -721
  235. package/server/lib/publish-store.js +115 -115
  236. package/server/lib/quick-summary.js +52 -52
  237. package/server/lib/quota.js +270 -270
  238. package/server/lib/rate-window.js +29 -29
  239. package/server/lib/reference-assets.js +92 -92
  240. package/server/lib/region-shot.js +135 -135
  241. package/server/lib/safe-path.js +73 -73
  242. package/server/lib/sdk-session.js +33 -33
  243. package/server/lib/showcase-store.js +92 -92
  244. package/server/lib/site-publish.js +465 -465
  245. package/server/lib/ssrf-guard.js +432 -432
  246. package/server/lib/task-scan.js +158 -158
  247. package/server/lib/tavern-json.js +154 -154
  248. package/server/lib/video-variant.js +237 -237
  249. package/server/lib/workspace-path.js +33 -33
  250. package/server/ops/fix-sdk-musl.mjs +40 -40
  251. package/server/ops/install-macos-fonts.sh +114 -114
  252. package/server/ops/macos-fonts.conf +336 -336
  253. package/server/ops/sandbox-shim/bwrap +57 -57
  254. package/server/projects/assets-summary.js +119 -119
  255. package/server/projects/auto-name.js +53 -53
  256. package/server/projects/board-store.js +640 -640
  257. package/server/projects/move-entry.js +103 -103
  258. package/server/projects/store.js +270 -270
  259. package/server/projects/ui-config.js +54 -54
  260. package/server/projects/workspace-templates.js +53 -53
  261. package/server/projects/workspace.js +1025 -1025
  262. package/server/runtime/local-config.js +177 -177
  263. package/server/runtime/profile.js +72 -72
  264. package/server/services/rembg-service.py +334 -334
  265. package/server/shared/README.md +25 -25
  266. package/server/shared/deck.js +54 -54
  267. package/server/shared/time.js +49 -49
  268. package/server/style-pipeline/README.md +53 -53
  269. package/server/ws/broker.js +36 -36
  270. package/server/ws/browse-channel.js +177 -177
  271. package/server/ws/index.js +386 -386
  272. package/web/README.md +89 -89
  273. package/web/dist/assets/BrowserWindow-BYCgizHw.js +11 -0
  274. package/web/dist/assets/{DeckWindow-4G0xX7P3.js → DeckWindow-pNydEM4L.js} +1 -1
  275. package/web/dist/assets/{DocxWindow-BXQsoPkh.js → DocxWindow-PPgzh3Gq.js} +1 -1
  276. package/web/dist/assets/{SiteWindow-D6eS-2gM.js → SiteWindow-DM0LQPCO.js} +1 -1
  277. package/web/dist/assets/{index-Cbl3ATvp.js → index-BHTBJlpY.js} +32 -32
  278. package/web/dist/assets/{pending-edit-apply-DTjo2geW.js → pending-edit-apply-C0I13CbD.js} +1 -1
  279. package/web/dist/assets/{rotate-cw-LMT3x1Zj.js → rotate-cw-B1UQ8Hld.js} +1 -1
  280. package/web/dist/assets/{square-dashed-mouse-pointer-CBoAkfhi.js → square-dashed-mouse-pointer-8lrgqmfv.js} +1 -1
  281. package/web/dist/index.html +17 -17
  282. package/web/dist/assets/BrowserWindow-Dc7J7CYc.js +0 -11
@@ -1,215 +1,215 @@
1
- /**
2
- * server/engine/mcp/tools/helpers/perception-page.js
3
- * 感知工具的**唯一**页面加载口(2026-08-18)
4
- *
5
- * ## 为什么要有这一层
6
- *
7
- * 在这之前四个感知工具各自 `page.goto('file://' + abs)`,而用户预览走的是
8
- * `/api/projects/:pid/artifact-file/*`(http)。**两条通道不同源**,于是:
9
- *
10
- * - `location.protocol` 守卫(导出 zip 用 file:// 打开时关掉客户端路由那种)
11
- * 在自检时触发,agent 看到的是退化行为
12
- * - `fetch`/XHR 被 CORS 拒 —— 任何加载 JSON 的站点在自检里是死的
13
- * - **localStorage 的 origin 是字符串 "null"**,全部任务共用一个桶;而用户
14
- * 预览下它们各自独立。mock-app 产线「写了刷新还在」那条硬定义正好踩在
15
- * 这上面 —— agent 验的东西和用户看的东西不是一回事
16
- * - Service Worker / 动态 import 同理
17
- *
18
- * 一个 agent 为此花了 4 次截图 + 一次把 `location.protocol` 写进 DOM 再截图,
19
- * 才反推出来自己被 file:// 打开了(问题库 iss_msxk2oci_0v0v)。
20
- *
21
- * ## 凭什么能走 http
22
- *
23
- * MCP 工具的 handler 跟 express server 跑在**同一个进程**里,而 token 是无状态
24
- * HMAC:`mintToken(ownerId)` 当场铸一个、塞进 chromium 的 cookie jar 即可。
25
- * 拿到的权限跟项目所有者在浏览器里一模一样——不是新开一道后门,是走用户那道门。
26
- * (web/scripts/shot-live.mjs 早就是这么干的,这里只是把它收进产品代码。)
27
- *
28
- * ⚠️ 铸出来的 token 只进本机 chromium 的 cookie jar,不写日志、不进返回文本。
29
- * ⚠️ 前提是**调用方跟 HTTP server 同进程**(共用 NODESIGN_AUTH_SECRET)。独立脚本
30
- * 里直接调这个 helper 会铸出对不上号的 token,服务端回 401 —— 那是环境问题不是
31
- * 产品 bug,跑验证脚本记得 `node --env-file=.env`。
32
- */
33
-
34
- import path from 'node:path';
35
- import { getProject } from '../../../../projects/store.js';
36
- import { COOKIE_NAME, mintToken, authEnabled } from '../../../../auth/session.js';
37
- import { getUserById } from '../../../../auth/users-store.js';
38
-
39
- // ── 渲染层保真(2026-08-07 立,2026-08-18 从 screenshot.js 挪来收成一份)──
40
- // Chromium 的强制暗色(Auto Dark)会在 paint 层反转颜色,而页面自己的 JS
41
- // 一点都测不到 —— 2026-08-05 那次「位图深色 / computed style 浅色」事故就是它。
42
- // ⚠️ 这组参数以前只有 screenshot / screenshot-url 两条路带,query_elements /
43
- // get_computed_styles / list_pages 是裸奔的 —— 同一个 DOM 两条通道两种渲染,
44
- // 正是「判据本身要先验一遍」那类坑。现在收成一份,谁开 chromium 都带上。
45
- export const FIDELITY_LAUNCH_ARGS = [
46
- '--disable-features=WebContentsForceDark', // 自动暗色:paint 层反转,页面自己测不到
47
- '--force-color-profile=srgb', // 色彩配置固定 srgb,排除 ICC 差异
48
- ];
49
-
50
- /** 服务端自己的监听口。跟 server/index.js 同一个默认值,改端口两处一起改。 */
51
- const PORT = Number(process.env.PORT || 4001);
52
-
53
- /** 127.0.0.1 而不是 localhost:避免 ::1/127.0.0.1 双栈解析下 cookie 域对不上 */
54
- export const PERCEPTION_ORIGIN = `http://127.0.0.1:${PORT}`;
55
- /**
56
- * 站点检查的真实设备宽(08-21 收成一份:以前 screenshot / explain_style / profile_scroll
57
- * 各抄一份 {desktop:1440,…})。站点没有"比例",版面是宽度算出来的,所以档位给
58
- * 真实设备宽、直接当 viewport、不缩放。
59
- */
60
- export const SITE_DEVICE_W = { desktop: 1440, tablet: 834, mobile: 390 };
61
-
62
- /**
63
- * 开一个「保真」chromium。凡是要把页面变成给人或给模型看的像素的地方都走这里 ——
64
- * 参数散在九个调用点上时,有三个是漏的(2026-08-18 逐处对账才发现,当天收敛完)。
65
- *
66
- * ⚠️ **有两处故意不走这里**,别顺手也"收敛"掉:
67
- * `screenshot-url.js` 和 `engine/browse/registry.js`。它们打的是**外部 URL**,
68
- * 必须挂 `--proxy-server` 走出网闸(lib/browse-proxy.js);而这个函数开的浏览器
69
- * 专门打 `127.0.0.1:4001` 上我们自己的产物 —— 那正是出网闸要拦的地址。
70
- * 两条通道的**安全前提相反**,共用一个 launch 只会让其中一条失守或不通。
71
- */
72
- export async function launchPerceptionBrowser() {
73
- const { chromium } = await import('playwright');
74
- return chromium.launch({ headless: true, args: FIDELITY_LAUNCH_ARGS });
75
- }
76
-
77
- /**
78
- * 工作区相对路径 → 用户预览用的同一个 URL。
79
- * 逐段 encodeURIComponent —— 任务名和文件名大量是中文,整体编码会把 `/` 也吃掉。
80
- */
81
- export function artifactFileUrl(projectId, relPath, { raw = true } = {}) {
82
- const sub = String(relPath || '').split('/').filter(Boolean).map(encodeURIComponent).join('/');
83
- // ⭐ `?nd=raw` —— 不走显示改写层,拿原始 HTML + 原图。
84
- //
85
- // artifact-file 平时是**显示通道**:给 html 注 srcset/sizes/loading=lazy、给图发
86
- // 降尺寸的 webp/avif 派生图。对用户预览这些都对(省 90% 流量)。但 agent 的眼睛
87
- // 走同一条路由之后,它看到的就不是**交付物**了 —— 发布出去的站点是原始 HTML
88
- // (site-publish 拷原文,没有这层注入)。
89
- //
90
- // ⛔ 实测差别(2026-08-18 审查攻出来的):一张 1800px 宽、没有 CSS 宽约束的图,
91
- // 注入 `sizes="100vw"` 之后在 agent 眼里渲染成 1440px、`overflow:false`;
92
- // 而访客那边是 1800px、`overflow:true` —— **显示改写层把横向溢出藏起来了**,
93
- // 而「忘写 max-width:100%」正是最常见的那类真 bug。顺带图也不是原图
94
- // (naturalWidth 1440 vs 1800),逐像素/锐度类判断跟着失真。
95
- //
96
- // ⚠️ 代价:感知页加载原图更慢更吃 CPU(1 vCPU 上是真代价)。取舍是**眼睛的
97
- // 正确性优先于自检速度** —— 一个看错的自检比一个慢的自检坏得多。
98
- // (http 同源那半边不受影响:fetch/localStorage/SW 照旧跟用户预览一致,实测过。)
99
- return `${PERCEPTION_ORIGIN}/api/projects/${projectId}/artifact-file/${sub}${raw ? '?nd=raw' : ''}`;
100
- }
101
-
102
- /**
103
- * 退化提示 → 一行可以直接拼进返回文本的话。
104
- *
105
- * `openArtifactPage` 的契约是「note 非空 = 没能走成 http,调用方必须把它写进
106
- * 返回文本」。⛔ 实测只有 screenshot 一个调用方照做了,另外五个工具连 `.note`
107
- * 两个字都没出现过(grep 计数 0)—— 也就是 07-29 那个「file:// 静默回退」的
108
- * bug 被重新种了一遍:`profile_scroll` 的描述铁口直断"走 http 同源",一旦回退,
109
- * 它按 Resource Timing 报「images 0 files 0KB」,那正好是它主诊断的反面,
110
- * 而唯一能解释这件事的 note 被丢掉了。
111
- *
112
- * 所以把措辞收成一份,调用点无脑拼。配套有个 lint 钉住"import 了就必须用"。
113
- */
114
- export function degradedNote(opened) {
115
- if (!opened?.note) return null;
116
- return `⚠️ 这一页没能按用户预览的方式打开(${opened.note})。`
117
- + 'fetch/XHR/localStorage 的行为跟用户那边不一样,凡是依赖它们的结论都不作数。';
118
- }
119
-
120
- /**
121
- * 打开一个产物页面,返回 { page, context, url, note }。
122
- *
123
- * - `note` 非空表示**没能走成 http**(退回了 file://),调用方应把它写进 caption:
124
- * 悄悄退回去就是把这个 bug 重新种一遍。
125
- * - HTTP 状态 >= 400 直接抛错,不截一张 JSON 错误页给 agent 当产物看。
126
- *
127
- * @param {import('playwright').Browser} browser
128
- * @param {object} opts
129
- * @param {string} opts.projectId
130
- * @param {string} opts.workspaceRoot 项目工作区根(= artifact-file 的可服务根)
131
- * @param {string} opts.absPath 要打开的文件绝对路径
132
- * @param {object} [opts.viewport]
133
- * @param {number} [opts.deviceScaleFactor]
134
- * @param {number} [opts.timeout]
135
- * @param {string} [opts.waitUntil]
136
- */
137
- export async function openArtifactPage(browser, {
138
- projectId, workspaceRoot, absPath,
139
- viewport, deviceScaleFactor = 1, timeout = 15000, waitUntil = 'networkidle',
140
- }) {
141
- const contextOpts = { colorScheme: 'light', deviceScaleFactor };
142
- if (viewport) contextOpts.viewport = viewport;
143
- // ⭐ `?nd=raw` 只挂在**主文档**的 URL 上,页面里 `<img src="assets/x.png">` 发出去的
144
- // 请求不带任何 query —— 于是主文档拿到了原样 HTML,图片却照旧走派生图。实测:
145
- // 一张 1.36MB 的 PNG 发出去是 **35.7KB 的 webp(2.6%)**。两个后果都真实:
146
- // ① 没有 CSS 宽约束的图按**固有尺寸**排版,派生图尺寸不同 → 版面跟访客不一样;
147
- // ② `profile_scroll` 的"图片总字节"少报了几十倍,而它的头号建议就是按体积开药。
148
- // 每个请求都带一个头最省:query 要改写 HTML(那就不叫 raw 了)。
149
- //
150
- // ⚠️ 但**不能用 context 级 extraHTTPHeaders**(第一版就是,agent 上报逮到):
151
- // 那会把这个自定义头带给页面请求的**所有主机**,而任何自定义头都会把跨域字体
152
- // 请求从「简单请求」升级成要 CORS 预检 —— fonts.gstatic.com 不认这个头,预检
153
- // 挂掉,Google Fonts 整站全灭,感知截图里字体全部回退。头只对感知源有意义
154
- //(assets.js 读它决定给不给原图),所以用 route 只加在同源请求上。
155
- const context = await browser.newContext(contextOpts);
156
- await context.route(`${PERCEPTION_ORIGIN}/**`, (route) => route.continue({
157
- headers: { ...route.request().headers(), 'X-ND-Raw': '1' },
158
- }));
159
-
160
- let url = null;
161
- let note = null;
162
-
163
- const rel = projectId && workspaceRoot
164
- ? path.relative(workspaceRoot, absPath)
165
- : null;
166
- const insideWorkspace = rel != null && rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel);
167
-
168
- if (insideWorkspace) {
169
- url = artifactFileUrl(projectId, rel.split(path.sep).join('/'));
170
- if (authEnabled()) {
171
- // 项目所有者的身份 —— guardProject 只认 owner 或 admin
172
- const ownerId = getProject(projectId)?.ownerId;
173
- // ⚠️ 光有 ownerId 不够:`mintToken` 只把 id 烤进签名,而 `requestUser` 会
174
- // **重新查库**并拒掉 disabled / 已删除的用户。于是账号一被停用,六个感知工具
175
- // 全部报「artifact-file returned HTTP 401」—— 一句完全指不到根因的话
176
- // (真因是账号状态,跟文件、路径、权限配置都无关)。这里提前认出来,
177
- // 退到 file:// 并把真原因写进 note(note 会被所有调用方拼进返回文本,
178
- // 有 lint 钉着)。
179
- const owner = ownerId ? getUserById(ownerId) : null;
180
- if (ownerId && owner && !owner.disabled) {
181
- await context.addCookies([{
182
- name: COOKIE_NAME, value: mintToken(ownerId), url: PERCEPTION_ORIGIN,
183
- }]);
184
- } else {
185
- const why = !ownerId ? 'project owner unknown'
186
- : (!owner ? `owner account ${ownerId} no longer exists`
187
- : `owner account ${owner.username} is disabled — an admin has to re-enable it; `
188
- + 'this is an account-state problem, not a file or path problem');
189
- note = `${why} — loaded via file:// (fetch/XHR/localStorage behave differently from the user preview)`;
190
- url = null;
191
- }
192
- }
193
- } else {
194
- // 产物落在工作区外(不该发生,但寻址层出岔子时别整个瞎掉)
195
- note = 'file is outside the project workspace — loaded via file:// (fetch/XHR/localStorage behave differently from the user preview)';
196
- }
197
-
198
- const page = await context.newPage();
199
- if (!url) {
200
- return { page, context, url: `file://${absPath}`, note, viaHttp: false, goto: () => page.goto(`file://${absPath}`, { waitUntil, timeout }) };
201
- }
202
-
203
- return {
204
- page, context, url, note, viaHttp: true,
205
- goto: async () => {
206
- const resp = await page.goto(url, { waitUntil, timeout });
207
- // 401/403/404 会渲成一张 JSON 错误页 —— 截它等于给 agent 一张假产物
208
- const status = resp?.status();
209
- if (status && status >= 400) {
210
- throw new Error(`artifact-file returned HTTP ${status} for ${decodeURIComponent(url.replace(PERCEPTION_ORIGIN, ''))}`);
211
- }
212
- return resp;
213
- },
214
- };
215
- }
1
+ /**
2
+ * server/engine/mcp/tools/helpers/perception-page.js
3
+ * 感知工具的**唯一**页面加载口(2026-08-18)
4
+ *
5
+ * ## 为什么要有这一层
6
+ *
7
+ * 在这之前四个感知工具各自 `page.goto('file://' + abs)`,而用户预览走的是
8
+ * `/api/projects/:pid/artifact-file/*`(http)。**两条通道不同源**,于是:
9
+ *
10
+ * - `location.protocol` 守卫(导出 zip 用 file:// 打开时关掉客户端路由那种)
11
+ * 在自检时触发,agent 看到的是退化行为
12
+ * - `fetch`/XHR 被 CORS 拒 —— 任何加载 JSON 的站点在自检里是死的
13
+ * - **localStorage 的 origin 是字符串 "null"**,全部任务共用一个桶;而用户
14
+ * 预览下它们各自独立。mock-app 产线「写了刷新还在」那条硬定义正好踩在
15
+ * 这上面 —— agent 验的东西和用户看的东西不是一回事
16
+ * - Service Worker / 动态 import 同理
17
+ *
18
+ * 一个 agent 为此花了 4 次截图 + 一次把 `location.protocol` 写进 DOM 再截图,
19
+ * 才反推出来自己被 file:// 打开了(问题库 iss_msxk2oci_0v0v)。
20
+ *
21
+ * ## 凭什么能走 http
22
+ *
23
+ * MCP 工具的 handler 跟 express server 跑在**同一个进程**里,而 token 是无状态
24
+ * HMAC:`mintToken(ownerId)` 当场铸一个、塞进 chromium 的 cookie jar 即可。
25
+ * 拿到的权限跟项目所有者在浏览器里一模一样——不是新开一道后门,是走用户那道门。
26
+ * (web/scripts/shot-live.mjs 早就是这么干的,这里只是把它收进产品代码。)
27
+ *
28
+ * ⚠️ 铸出来的 token 只进本机 chromium 的 cookie jar,不写日志、不进返回文本。
29
+ * ⚠️ 前提是**调用方跟 HTTP server 同进程**(共用 NODESIGN_AUTH_SECRET)。独立脚本
30
+ * 里直接调这个 helper 会铸出对不上号的 token,服务端回 401 —— 那是环境问题不是
31
+ * 产品 bug,跑验证脚本记得 `node --env-file=.env`。
32
+ */
33
+
34
+ import path from 'node:path';
35
+ import { getProject } from '../../../../projects/store.js';
36
+ import { COOKIE_NAME, mintToken, authEnabled } from '../../../../auth/session.js';
37
+ import { getUserById } from '../../../../auth/users-store.js';
38
+
39
+ // ── 渲染层保真(2026-08-07 立,2026-08-18 从 screenshot.js 挪来收成一份)──
40
+ // Chromium 的强制暗色(Auto Dark)会在 paint 层反转颜色,而页面自己的 JS
41
+ // 一点都测不到 —— 2026-08-05 那次「位图深色 / computed style 浅色」事故就是它。
42
+ // ⚠️ 这组参数以前只有 screenshot / screenshot-url 两条路带,query_elements /
43
+ // get_computed_styles / list_pages 是裸奔的 —— 同一个 DOM 两条通道两种渲染,
44
+ // 正是「判据本身要先验一遍」那类坑。现在收成一份,谁开 chromium 都带上。
45
+ export const FIDELITY_LAUNCH_ARGS = [
46
+ '--disable-features=WebContentsForceDark', // 自动暗色:paint 层反转,页面自己测不到
47
+ '--force-color-profile=srgb', // 色彩配置固定 srgb,排除 ICC 差异
48
+ ];
49
+
50
+ /** 服务端自己的监听口。跟 server/index.js 同一个默认值,改端口两处一起改。 */
51
+ const PORT = Number(process.env.PORT || 4001);
52
+
53
+ /** 127.0.0.1 而不是 localhost:避免 ::1/127.0.0.1 双栈解析下 cookie 域对不上 */
54
+ export const PERCEPTION_ORIGIN = `http://127.0.0.1:${PORT}`;
55
+ /**
56
+ * 站点检查的真实设备宽(08-21 收成一份:以前 screenshot / explain_style / profile_scroll
57
+ * 各抄一份 {desktop:1440,…})。站点没有"比例",版面是宽度算出来的,所以档位给
58
+ * 真实设备宽、直接当 viewport、不缩放。
59
+ */
60
+ export const SITE_DEVICE_W = { desktop: 1440, tablet: 834, mobile: 390 };
61
+
62
+ /**
63
+ * 开一个「保真」chromium。凡是要把页面变成给人或给模型看的像素的地方都走这里 ——
64
+ * 参数散在九个调用点上时,有三个是漏的(2026-08-18 逐处对账才发现,当天收敛完)。
65
+ *
66
+ * ⚠️ **有两处故意不走这里**,别顺手也"收敛"掉:
67
+ * `screenshot-url.js` 和 `engine/browse/registry.js`。它们打的是**外部 URL**,
68
+ * 必须挂 `--proxy-server` 走出网闸(lib/browse-proxy.js);而这个函数开的浏览器
69
+ * 专门打 `127.0.0.1:4001` 上我们自己的产物 —— 那正是出网闸要拦的地址。
70
+ * 两条通道的**安全前提相反**,共用一个 launch 只会让其中一条失守或不通。
71
+ */
72
+ export async function launchPerceptionBrowser() {
73
+ const { chromium } = await import('playwright');
74
+ return chromium.launch({ headless: true, args: FIDELITY_LAUNCH_ARGS });
75
+ }
76
+
77
+ /**
78
+ * 工作区相对路径 → 用户预览用的同一个 URL。
79
+ * 逐段 encodeURIComponent —— 任务名和文件名大量是中文,整体编码会把 `/` 也吃掉。
80
+ */
81
+ export function artifactFileUrl(projectId, relPath, { raw = true } = {}) {
82
+ const sub = String(relPath || '').split('/').filter(Boolean).map(encodeURIComponent).join('/');
83
+ // ⭐ `?nd=raw` —— 不走显示改写层,拿原始 HTML + 原图。
84
+ //
85
+ // artifact-file 平时是**显示通道**:给 html 注 srcset/sizes/loading=lazy、给图发
86
+ // 降尺寸的 webp/avif 派生图。对用户预览这些都对(省 90% 流量)。但 agent 的眼睛
87
+ // 走同一条路由之后,它看到的就不是**交付物**了 —— 发布出去的站点是原始 HTML
88
+ // (site-publish 拷原文,没有这层注入)。
89
+ //
90
+ // ⛔ 实测差别(2026-08-18 审查攻出来的):一张 1800px 宽、没有 CSS 宽约束的图,
91
+ // 注入 `sizes="100vw"` 之后在 agent 眼里渲染成 1440px、`overflow:false`;
92
+ // 而访客那边是 1800px、`overflow:true` —— **显示改写层把横向溢出藏起来了**,
93
+ // 而「忘写 max-width:100%」正是最常见的那类真 bug。顺带图也不是原图
94
+ // (naturalWidth 1440 vs 1800),逐像素/锐度类判断跟着失真。
95
+ //
96
+ // ⚠️ 代价:感知页加载原图更慢更吃 CPU(1 vCPU 上是真代价)。取舍是**眼睛的
97
+ // 正确性优先于自检速度** —— 一个看错的自检比一个慢的自检坏得多。
98
+ // (http 同源那半边不受影响:fetch/localStorage/SW 照旧跟用户预览一致,实测过。)
99
+ return `${PERCEPTION_ORIGIN}/api/projects/${projectId}/artifact-file/${sub}${raw ? '?nd=raw' : ''}`;
100
+ }
101
+
102
+ /**
103
+ * 退化提示 → 一行可以直接拼进返回文本的话。
104
+ *
105
+ * `openArtifactPage` 的契约是「note 非空 = 没能走成 http,调用方必须把它写进
106
+ * 返回文本」。⛔ 实测只有 screenshot 一个调用方照做了,另外五个工具连 `.note`
107
+ * 两个字都没出现过(grep 计数 0)—— 也就是 07-29 那个「file:// 静默回退」的
108
+ * bug 被重新种了一遍:`profile_scroll` 的描述铁口直断"走 http 同源",一旦回退,
109
+ * 它按 Resource Timing 报「images 0 files 0KB」,那正好是它主诊断的反面,
110
+ * 而唯一能解释这件事的 note 被丢掉了。
111
+ *
112
+ * 所以把措辞收成一份,调用点无脑拼。配套有个 lint 钉住"import 了就必须用"。
113
+ */
114
+ export function degradedNote(opened) {
115
+ if (!opened?.note) return null;
116
+ return `⚠️ 这一页没能按用户预览的方式打开(${opened.note})。`
117
+ + 'fetch/XHR/localStorage 的行为跟用户那边不一样,凡是依赖它们的结论都不作数。';
118
+ }
119
+
120
+ /**
121
+ * 打开一个产物页面,返回 { page, context, url, note }。
122
+ *
123
+ * - `note` 非空表示**没能走成 http**(退回了 file://),调用方应把它写进 caption:
124
+ * 悄悄退回去就是把这个 bug 重新种一遍。
125
+ * - HTTP 状态 >= 400 直接抛错,不截一张 JSON 错误页给 agent 当产物看。
126
+ *
127
+ * @param {import('playwright').Browser} browser
128
+ * @param {object} opts
129
+ * @param {string} opts.projectId
130
+ * @param {string} opts.workspaceRoot 项目工作区根(= artifact-file 的可服务根)
131
+ * @param {string} opts.absPath 要打开的文件绝对路径
132
+ * @param {object} [opts.viewport]
133
+ * @param {number} [opts.deviceScaleFactor]
134
+ * @param {number} [opts.timeout]
135
+ * @param {string} [opts.waitUntil]
136
+ */
137
+ export async function openArtifactPage(browser, {
138
+ projectId, workspaceRoot, absPath,
139
+ viewport, deviceScaleFactor = 1, timeout = 15000, waitUntil = 'networkidle',
140
+ }) {
141
+ const contextOpts = { colorScheme: 'light', deviceScaleFactor };
142
+ if (viewport) contextOpts.viewport = viewport;
143
+ // ⭐ `?nd=raw` 只挂在**主文档**的 URL 上,页面里 `<img src="assets/x.png">` 发出去的
144
+ // 请求不带任何 query —— 于是主文档拿到了原样 HTML,图片却照旧走派生图。实测:
145
+ // 一张 1.36MB 的 PNG 发出去是 **35.7KB 的 webp(2.6%)**。两个后果都真实:
146
+ // ① 没有 CSS 宽约束的图按**固有尺寸**排版,派生图尺寸不同 → 版面跟访客不一样;
147
+ // ② `profile_scroll` 的"图片总字节"少报了几十倍,而它的头号建议就是按体积开药。
148
+ // 每个请求都带一个头最省:query 要改写 HTML(那就不叫 raw 了)。
149
+ //
150
+ // ⚠️ 但**不能用 context 级 extraHTTPHeaders**(第一版就是,agent 上报逮到):
151
+ // 那会把这个自定义头带给页面请求的**所有主机**,而任何自定义头都会把跨域字体
152
+ // 请求从「简单请求」升级成要 CORS 预检 —— fonts.gstatic.com 不认这个头,预检
153
+ // 挂掉,Google Fonts 整站全灭,感知截图里字体全部回退。头只对感知源有意义
154
+ //(assets.js 读它决定给不给原图),所以用 route 只加在同源请求上。
155
+ const context = await browser.newContext(contextOpts);
156
+ await context.route(`${PERCEPTION_ORIGIN}/**`, (route) => route.continue({
157
+ headers: { ...route.request().headers(), 'X-ND-Raw': '1' },
158
+ }));
159
+
160
+ let url = null;
161
+ let note = null;
162
+
163
+ const rel = projectId && workspaceRoot
164
+ ? path.relative(workspaceRoot, absPath)
165
+ : null;
166
+ const insideWorkspace = rel != null && rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel);
167
+
168
+ if (insideWorkspace) {
169
+ url = artifactFileUrl(projectId, rel.split(path.sep).join('/'));
170
+ if (authEnabled()) {
171
+ // 项目所有者的身份 —— guardProject 只认 owner 或 admin
172
+ const ownerId = getProject(projectId)?.ownerId;
173
+ // ⚠️ 光有 ownerId 不够:`mintToken` 只把 id 烤进签名,而 `requestUser` 会
174
+ // **重新查库**并拒掉 disabled / 已删除的用户。于是账号一被停用,六个感知工具
175
+ // 全部报「artifact-file returned HTTP 401」—— 一句完全指不到根因的话
176
+ // (真因是账号状态,跟文件、路径、权限配置都无关)。这里提前认出来,
177
+ // 退到 file:// 并把真原因写进 note(note 会被所有调用方拼进返回文本,
178
+ // 有 lint 钉着)。
179
+ const owner = ownerId ? getUserById(ownerId) : null;
180
+ if (ownerId && owner && !owner.disabled) {
181
+ await context.addCookies([{
182
+ name: COOKIE_NAME, value: mintToken(ownerId), url: PERCEPTION_ORIGIN,
183
+ }]);
184
+ } else {
185
+ const why = !ownerId ? 'project owner unknown'
186
+ : (!owner ? `owner account ${ownerId} no longer exists`
187
+ : `owner account ${owner.username} is disabled — an admin has to re-enable it; `
188
+ + 'this is an account-state problem, not a file or path problem');
189
+ note = `${why} — loaded via file:// (fetch/XHR/localStorage behave differently from the user preview)`;
190
+ url = null;
191
+ }
192
+ }
193
+ } else {
194
+ // 产物落在工作区外(不该发生,但寻址层出岔子时别整个瞎掉)
195
+ note = 'file is outside the project workspace — loaded via file:// (fetch/XHR/localStorage behave differently from the user preview)';
196
+ }
197
+
198
+ const page = await context.newPage();
199
+ if (!url) {
200
+ return { page, context, url: `file://${absPath}`, note, viaHttp: false, goto: () => page.goto(`file://${absPath}`, { waitUntil, timeout }) };
201
+ }
202
+
203
+ return {
204
+ page, context, url, note, viaHttp: true,
205
+ goto: async () => {
206
+ const resp = await page.goto(url, { waitUntil, timeout });
207
+ // 401/403/404 会渲成一张 JSON 错误页 —— 截它等于给 agent 一张假产物
208
+ const status = resp?.status();
209
+ if (status && status >= 400) {
210
+ throw new Error(`artifact-file returned HTTP ${status} for ${decodeURIComponent(url.replace(PERCEPTION_ORIGIN, ''))}`);
211
+ }
212
+ return resp;
213
+ },
214
+ };
215
+ }
@@ -1,103 +1,103 @@
1
- /**
2
- * server/engine/mcp/tools/helpers/reference-download.js
3
- * 把搜到的参考图下载进 `assets/references/`(2026-08-18 从 web-search.js 拆出)
4
- *
5
- * 拆的直接原因是行数棘轮,但拆对了:这跟"搜索"是两件事 —— 一件是问 provider 要结果,
6
- * 一件是把图落进工作区。`browser_capture` 也往同一个目录写,两边的落点约定
7
- * (`assets/references/`、`.meta/<主干>.json` 出处 sidecar)应该看得见彼此。
8
- */
9
-
10
- import path from 'node:path';
11
- import fs from 'node:fs/promises';
12
- import crypto from 'node:crypto';
13
-
14
- // ── reference image download ──
15
-
16
- const DL_TIMEOUT_MS = 10_000;
17
- const DL_MAX_BYTES = 5 * 1024 * 1024; // 5MB(Anthropic image content block 单图上限)
18
- const DL_MIN_BYTES = 5 * 1024; // 5KB(< 这个多半是 logo / 损坏)
19
-
20
- const MIME_TO_EXT = {
21
- 'image/jpeg': '.jpg',
22
- 'image/jpg': '.jpg',
23
- 'image/png': '.png',
24
- 'image/webp': '.webp',
25
- 'image/gif': '.gif',
26
- };
27
-
28
- function shortHash(s) {
29
- return crypto.createHash('sha256').update(s).digest('hex').slice(0, 10);
30
- }
31
-
32
- /**
33
- * 下载一张图到 <root>/assets/references/ref-<hash>-<role>.<ext>。
34
- * 已存在 → 直接返回(按 hash 去重)。
35
- *
36
- * @returns {Promise<{ relPath: string, absPath: string, sizeBytes: number, mimeType: string } | null>}
37
- * 失败/校验不通过 → null(caller 跳过这一张)
38
- */
39
- async function downloadOneImage(url, refsDir) {
40
- const controller = new AbortController();
41
- const timer = setTimeout(() => controller.abort(), DL_TIMEOUT_MS);
42
- try {
43
- const hash = shortHash(url);
44
- const res = await fetch(url, { signal: controller.signal, redirect: 'follow' });
45
- if (!res.ok) return null;
46
- const mime = (res.headers.get('content-type') || '').toLowerCase().split(';')[0].trim();
47
- const ext = MIME_TO_EXT[mime];
48
- if (!ext) return null;
49
-
50
- const buf = Buffer.from(await res.arrayBuffer());
51
- if (buf.length < DL_MIN_BYTES || buf.length > DL_MAX_BYTES) return null;
52
-
53
- const fileName = `ref-${hash}${ext}`;
54
- const absPath = path.join(refsDir, fileName);
55
- // 去重:同 url → 同 hash → 同文件名 → 已存在就跳写
56
- let exists = false;
57
- try { await fs.access(absPath); exists = true; } catch { /* not exists */ }
58
- if (!exists) await fs.writeFile(absPath, buf);
59
-
60
- return {
61
- relPath: path.posix.join('assets', 'references', fileName),
62
- absPath,
63
- sizeBytes: buf.length,
64
- mimeType: mime,
65
- base64: buf.toString('base64'), // 直接传给 CallToolResult image block
66
- };
67
- } catch {
68
- return null;
69
- } finally {
70
- clearTimeout(timer);
71
- }
72
- }
73
-
74
- /**
75
- * 并发下载 top-N 图,filter 掉失败/超大/过小。
76
- *
77
- * @returns {Promise<Array<{url, description, title, relPath, absPath, sizeBytes, mimeType}>>}
78
- */
79
- export async function downloadReferenceImages(images, { workspaceRoot, sharedRoot, stopAfter = Infinity }) {
80
- const baseRoot = sharedRoot || workspaceRoot;
81
- if (!baseRoot) return [];
82
- const refsDir = path.join(baseRoot, 'assets', 'references');
83
- await fs.mkdir(refsDir, { recursive: true });
84
-
85
- // ⚠️ **够了就停**(2026-08-18)。原来是把候选全并发下完再 slice,于是多下的那些
86
- // 留在磁盘上、agent 不知道它们存在、却照样占空间和进导出包。候选多给是为了容错
87
- // (有些 URL 会 404),不是为了多存。
88
- // 代价:从全并发变成小批并发,慢一点;换来的是"盘上有什么 = 报了什么"。
89
- const out = [];
90
- const BATCH = 3;
91
- for (let i = 0; i < images.length && out.length < stopAfter; i += BATCH) {
92
- const settled = await Promise.allSettled(
93
- images.slice(i, i + BATCH).map(async (img) => {
94
- const dl = await downloadOneImage(img.url, refsDir);
95
- return dl ? { ...img, ...dl } : null;
96
- }),
97
- );
98
- for (const r of settled) {
99
- if (r.status === 'fulfilled' && r.value && out.length < stopAfter) out.push(r.value);
100
- }
101
- }
102
- return out;
103
- }
1
+ /**
2
+ * server/engine/mcp/tools/helpers/reference-download.js
3
+ * 把搜到的参考图下载进 `assets/references/`(2026-08-18 从 web-search.js 拆出)
4
+ *
5
+ * 拆的直接原因是行数棘轮,但拆对了:这跟"搜索"是两件事 —— 一件是问 provider 要结果,
6
+ * 一件是把图落进工作区。`browser_capture` 也往同一个目录写,两边的落点约定
7
+ * (`assets/references/`、`.meta/<主干>.json` 出处 sidecar)应该看得见彼此。
8
+ */
9
+
10
+ import path from 'node:path';
11
+ import fs from 'node:fs/promises';
12
+ import crypto from 'node:crypto';
13
+
14
+ // ── reference image download ──
15
+
16
+ const DL_TIMEOUT_MS = 10_000;
17
+ const DL_MAX_BYTES = 5 * 1024 * 1024; // 5MB(Anthropic image content block 单图上限)
18
+ const DL_MIN_BYTES = 5 * 1024; // 5KB(< 这个多半是 logo / 损坏)
19
+
20
+ const MIME_TO_EXT = {
21
+ 'image/jpeg': '.jpg',
22
+ 'image/jpg': '.jpg',
23
+ 'image/png': '.png',
24
+ 'image/webp': '.webp',
25
+ 'image/gif': '.gif',
26
+ };
27
+
28
+ function shortHash(s) {
29
+ return crypto.createHash('sha256').update(s).digest('hex').slice(0, 10);
30
+ }
31
+
32
+ /**
33
+ * 下载一张图到 <root>/assets/references/ref-<hash>-<role>.<ext>。
34
+ * 已存在 → 直接返回(按 hash 去重)。
35
+ *
36
+ * @returns {Promise<{ relPath: string, absPath: string, sizeBytes: number, mimeType: string } | null>}
37
+ * 失败/校验不通过 → null(caller 跳过这一张)
38
+ */
39
+ async function downloadOneImage(url, refsDir) {
40
+ const controller = new AbortController();
41
+ const timer = setTimeout(() => controller.abort(), DL_TIMEOUT_MS);
42
+ try {
43
+ const hash = shortHash(url);
44
+ const res = await fetch(url, { signal: controller.signal, redirect: 'follow' });
45
+ if (!res.ok) return null;
46
+ const mime = (res.headers.get('content-type') || '').toLowerCase().split(';')[0].trim();
47
+ const ext = MIME_TO_EXT[mime];
48
+ if (!ext) return null;
49
+
50
+ const buf = Buffer.from(await res.arrayBuffer());
51
+ if (buf.length < DL_MIN_BYTES || buf.length > DL_MAX_BYTES) return null;
52
+
53
+ const fileName = `ref-${hash}${ext}`;
54
+ const absPath = path.join(refsDir, fileName);
55
+ // 去重:同 url → 同 hash → 同文件名 → 已存在就跳写
56
+ let exists = false;
57
+ try { await fs.access(absPath); exists = true; } catch { /* not exists */ }
58
+ if (!exists) await fs.writeFile(absPath, buf);
59
+
60
+ return {
61
+ relPath: path.posix.join('assets', 'references', fileName),
62
+ absPath,
63
+ sizeBytes: buf.length,
64
+ mimeType: mime,
65
+ base64: buf.toString('base64'), // 直接传给 CallToolResult image block
66
+ };
67
+ } catch {
68
+ return null;
69
+ } finally {
70
+ clearTimeout(timer);
71
+ }
72
+ }
73
+
74
+ /**
75
+ * 并发下载 top-N 图,filter 掉失败/超大/过小。
76
+ *
77
+ * @returns {Promise<Array<{url, description, title, relPath, absPath, sizeBytes, mimeType}>>}
78
+ */
79
+ export async function downloadReferenceImages(images, { workspaceRoot, sharedRoot, stopAfter = Infinity }) {
80
+ const baseRoot = sharedRoot || workspaceRoot;
81
+ if (!baseRoot) return [];
82
+ const refsDir = path.join(baseRoot, 'assets', 'references');
83
+ await fs.mkdir(refsDir, { recursive: true });
84
+
85
+ // ⚠️ **够了就停**(2026-08-18)。原来是把候选全并发下完再 slice,于是多下的那些
86
+ // 留在磁盘上、agent 不知道它们存在、却照样占空间和进导出包。候选多给是为了容错
87
+ // (有些 URL 会 404),不是为了多存。
88
+ // 代价:从全并发变成小批并发,慢一点;换来的是"盘上有什么 = 报了什么"。
89
+ const out = [];
90
+ const BATCH = 3;
91
+ for (let i = 0; i < images.length && out.length < stopAfter; i += BATCH) {
92
+ const settled = await Promise.allSettled(
93
+ images.slice(i, i + BATCH).map(async (img) => {
94
+ const dl = await downloadOneImage(img.url, refsDir);
95
+ return dl ? { ...img, ...dl } : null;
96
+ }),
97
+ );
98
+ for (const r of settled) {
99
+ if (r.status === 'fulfilled' && r.value && out.length < stopAfter) out.push(r.value);
100
+ }
101
+ }
102
+ return out;
103
+ }