@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,444 +1,444 @@
1
- /**
2
- * server/api/canvas.js — Canvas + Spec read/write/history/revert
3
- *
4
- * 路径(2026-08-13 起**项目级**,会话级留作 alias —— 同 pending-changes.js):
5
- * GET /api/projects/:pid/assets/*subPath 单文件(iframe 内相对资源)
6
- * GET /api/projects/:pid/canvas → text/html
7
- * GET /api/projects/:pid/canvas/deck-meta deck 比例信息
8
- * PUT /api/projects/:pid/canvas { html, source?, path? }
9
- * GET /api/projects/:pid/canvas/history git log
10
- * POST /api/projects/:pid/canvas/revert { commit }
11
- * POST /api/projects/:pid/canvas/undo
12
- * GET /api/projects/:pid/spec spec.json(agent 私域档案)
13
- * GET /api/projects/:pid/config session-config.json
14
- * PATCH /api/projects/:pid/config
15
- * (/:pid/sessions/:sid/... 同 handler 双挂载 —— 老前端和 jsonl 里的历史引用
16
- * 还打得通;扁平化后 sessionRoot === 工作区根,这批文件从来就是每项目一份。
17
- * 真正 per-session 的数据(.nd/<sid>/、PUT /sessions/:sid/model)不在本文件,
18
- * 那些保持 sid 专属,见 sessions.js。)
19
- *
20
- * 文件实际位置(扁平化后全在工作区根):
21
- * <workspace>/canvas.html、spec.json、session-config.json、assets/、.git/
22
- */
23
-
24
- import express from 'express';
25
- import { promises as fs } from 'fs';
26
- import path from 'path';
27
- import { validateProjectId, getProject } from '../projects/store.js';
28
- import { guardProject } from './_guard.js';
29
- import {
30
- getSessionWorkspace, ensureSessionWorkspace, validateSessionId,
31
- getWorkspaceRoot, ensureProjectWorkspace,
32
- commitWorkspace, listHistory, revertWorkspace,
33
- } from '../projects/workspace.js';
34
- import { fitInjectionBlock } from './standalone-fit.js';
35
- import { resolveDeckSize, extractDeckAspect } from '../shared/deck.js';
36
- import { kindOfPath } from '../lib/artifact-target.js';
37
- import {
38
- sendImage, isThumbPath, findOriginalForThumbnail, imageCacheControl,
39
- THUMBNAIL_MAX_DIM, THUMBNAIL_QUALITY,
40
- } from '../lib/image-variant.js';
41
- import { sendVideo, isVideo } from '../lib/video-variant.js';
42
- import { readUiConfigFile, withUiDefaults, writeUiConfig } from '../projects/ui-config.js';
43
-
44
- const router = express.Router();
45
-
46
- const MAX_HTML_BYTES = 8 * 1024 * 1024; // 8MB
47
-
48
- function guard(req, res) {
49
- // sid 只在走老 alias 时存在 —— 有就校验形状,没有就是项目级路由
50
- if (req.params.sid !== undefined) {
51
- try {
52
- validateSessionId(req.params.sid);
53
- } catch (err) {
54
- res.status(400).json({ error: err.message || 'invalid pid/sid' });
55
- return null;
56
- }
57
- }
58
- // pid 校验 + 存在性 + 归属(2026-07-30 多用户)统一走 guardProject
59
- return guardProject(req, res);
60
- }
61
-
62
- /** 两条挂载共用(只读路径):alias 带 sid 走原路,项目级直接取工作区根 */
63
- function rootOf(req) {
64
- return req.params.sid !== undefined
65
- ? getSessionWorkspace(req.params.pid, req.params.sid)
66
- : getWorkspaceRoot(req.params.pid);
67
- }
68
-
69
- /** 两条挂载共用(写路径):同上,但先 ensure 工作区存在 */
70
- function ensureRootOf(req) {
71
- return req.params.sid !== undefined
72
- ? ensureSessionWorkspace(req.params.pid, req.params.sid)
73
- : ensureProjectWorkspace(req.params.pid);
74
- }
75
-
76
- // 单文件 GET(assets/* 子树)—— 让 iframe 里 <img src="assets/generated/x.jpg">
77
- // 自然解析 + chat 渲染 image content block 缩略图也走这个 endpoint。
78
- // 走 sessions/<sid>/assets softlink 透到 shared/assets,路径限 assets/* 子树
79
- // 防 traversal。MIME 按扩展名定。
80
- const ASSET_MIME = {
81
- '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
82
- '.webp': 'image/webp', '.gif': 'image/gif', '.svg': 'image/svg+xml',
83
- '.pdf': 'application/pdf', '.mp4': 'video/mp4', '.webm': 'video/webm',
84
- '.mp3': 'audio/mpeg', '.wav': 'audio/wav', '.ogg': 'audio/ogg',
85
- };
86
- // thumbnail 地址的解析与兜底(缺 thumb → 回原图 → 现编 512 webp)在
87
- // lib/image-variant.js,artifact-file 路由吃的是同一份,两条路由行为一致。
88
-
89
- router.get(['/:pid/assets/*subPath', '/:pid/sessions/:sid/assets/*subPath'], async (req, res, next) => {
90
- try {
91
- if (!guard(req, res)) return;
92
- const sessionRoot = rootOf(req);
93
- // Express 5 named wildcard:req.params.subPath 是 string[] 或 string
94
- const raw = req.params.subPath;
95
- const subPath = Array.isArray(raw) ? raw.join('/') : (raw || '');
96
- if (!subPath) return res.status(400).json({ error: 'asset path required' });
97
-
98
- let absPath = path.resolve(sessionRoot, 'assets', subPath);
99
- const assetsRoot = path.resolve(sessionRoot, 'assets');
100
- // 防 traversal:resolve 后必须在 sessions/<sid>/assets/ 下
101
- if (absPath !== assetsRoot && !absPath.startsWith(assetsRoot + path.sep)) {
102
- return res.status(403).json({ error: 'path escapes assets/' });
103
- }
104
-
105
- // 请求的是不是缩略图地址 —— 决定 fallback 到原图之后要不要顺手缩到 512
106
- const wantsThumb = isThumbPath(absPath);
107
-
108
- let stat;
109
- let servedOriginalForThumb = false;
110
- try {
111
- stat = await fs.stat(absPath);
112
- } catch (err) {
113
- if (err.code !== 'ENOENT') throw err;
114
- // ENOENT 兜底:thumbnail 路径不存在 → fallback 到原图(老图 / 生成失败 case)
115
- if (wantsThumb) {
116
- const original = await findOriginalForThumbnail(absPath);
117
- if (original) {
118
- absPath = original;
119
- stat = await fs.stat(original);
120
- servedOriginalForThumb = true;
121
- } else {
122
- return res.status(404).json({ error: 'thumbnail and original both missing' });
123
- }
124
- } else {
125
- return res.status(404).json({ error: 'asset not found' });
126
- }
127
- }
128
- if (!stat.isFile()) return res.status(400).json({ error: 'not a file' });
129
-
130
- const ext = path.extname(absPath).toLowerCase();
131
- // 带 ?v= 的是内容寻址 URL(源图一变 v 就变),可以永久缓存;不带的只能短缓存
132
- res.setHeader('Cache-Control', imageCacheControl(req));
133
- // 视频走 Range + 派生档(deck 里 <video src="assets/x.mp4"> 也打这条路由)
134
- if (isVideo(ext)) {
135
- return sendVideo(req, res, absPath, stat, {
136
- fallbackMime: ASSET_MIME[ext] || 'application/octet-stream',
137
- });
138
- }
139
-
140
- // 显示路径一律发派生图(原图只留给导出,见 lib/image-variant.js)。
141
- // 走 thumbnail 地址却拿到原图时补上 512 长边,等于把当年该生成的那张现补出来。
142
- return sendImage(req, res, absPath, stat, {
143
- fallbackMime: ASSET_MIME[ext] || 'application/octet-stream',
144
- maxDim: servedOriginalForThumb ? THUMBNAIL_MAX_DIM : null,
145
- quality: servedOriginalForThumb ? THUMBNAIL_QUALITY : undefined,
146
- });
147
- } catch (err) { next(err); }
148
- });
149
-
150
- /**
151
- * Preview-only:把 canvas.html 里 <img src="assets/generated/<name>.<ext>"> 透明
152
- * 重写成 src="assets/generated/.thumbnails/<name>.thumb.webp"。仅 GET /canvas serve
153
- * 时改输出,**不动** agent 写的源文件。
154
- *
155
- * 理由:单图原始 6-8MB(Gemini 默认 1080×1920+ PNG),iframe 缩放(zoom=transform
156
- * scale)+ 多图同时渲染让 GPU/RAM 暴涨,preview 体感卡。thumbnail 长边 512 / ~20KB
157
- * 足够 preview 看清布局。导出走 build-standalone 仍 inline 原图,最终交付不损质量。
158
- *
159
- * 也覆盖 CSS 内 url(...) 引用的图片(agent 用 background-image 时常见)。
160
- *
161
- * thumbnail 不存在时 asset endpoint 会自动 fallback 到原图(见下方 ENOENT 分支),
162
- * 老图(thumbnail 没生成的)/ 生成失败的都能正常显示。
163
- */
164
- /**
165
- * 注入唯一权威 fit injection block(<script> + <style>)到 </body> 前。
166
- *
167
- * preview iframe 路径跟离线 / 导出 HTML 走同一份 standalone-fit,确保渲染一致:
168
- * 每 section 包 100vw×100vh frame + scroll-snap + CSS min() 缩放。
169
- *
170
- * 已含 __nd-standard-fit script 的跳过(重启场景:HTML 已经 saved 了)。
171
- */
172
- function injectFitBlock(html) {
173
- if (/__nd-standard-fit\b/.test(html)) return html;
174
- const block = fitInjectionBlock();
175
- if (html.includes('</body>')) return html.replace('</body>', block + '\n</body>');
176
- if (html.includes('</html>')) return html.replace('</html>', block + '\n</html>');
177
- return html + block;
178
- }
179
-
180
- function rewriteImagesToThumbnails(html) {
181
- const imgRe = /(<img\b[^>]*\bsrc\s*=\s*["'])assets\/generated\/(?!\.thumbnails\/)([^"']+?)\.(png|jpg|jpeg|webp|gif)(["'])/gi;
182
- const cssUrlRe = /(url\(\s*["']?)assets\/generated\/(?!\.thumbnails\/)([^"')]+?)\.(png|jpg|jpeg|webp|gif)(["']?\s*\))/gi;
183
- return html
184
- .replace(imgRe, (_m, prefix, name, _ext, suffix) =>
185
- `${prefix}assets/generated/.thumbnails/${name}.thumb.webp${suffix}`)
186
- .replace(cssUrlRe, (_m, prefix, name, _ext, suffix) =>
187
- `${prefix}assets/generated/.thumbnails/${name}.thumb.webp${suffix}`);
188
- }
189
-
190
- router.get(['/:pid/canvas', '/:pid/sessions/:sid/canvas'], async (req, res, next) => {
191
- try {
192
- if (!guard(req, res)) return;
193
- const sessionRoot = rootOf(req);
194
- const file = path.join(sessionRoot, 'canvas.html');
195
- try {
196
- let content = await fs.readFile(file, 'utf8');
197
- // 注入 <base href>:让 iframe 内 <img src="assets/...">/url("assets/...") 等
198
- // 相对资源解析显式锚到本工作区,不依赖 iframe.src 的隐式 base URL
199
- // (src 带 ?v=xxx query / 部署 redirect 等都可能让浏览器解析跑偏)。
200
- // 已含 <base> 时跳过;正则只匹配开始 <head> tag。
201
- // base 跟着请求进来的那条路走(项目级 / sid alias)——assets 两边都挂了,
202
- // 项目级页面锚到 sid 路径(或反过来)虽然也能通,但没必要跨范式。
203
- if (!/<base\s+href=/i.test(content)) {
204
- const baseHref = req.params.sid !== undefined
205
- ? `/api/projects/${encodeURIComponent(req.params.pid)}/sessions/${encodeURIComponent(req.params.sid)}/`
206
- : `/api/projects/${encodeURIComponent(req.params.pid)}/`;
207
- content = content.replace(/<head([^>]*)>/i, `<head$1>\n <base href="${baseHref}">`);
208
- }
209
- // 透明替换 generated 图片为 thumbnail(preview 流畅;导出 / agent 看到的源
210
- // 文件不动)。env NODESIGN_DISABLE_THUMBNAIL_REWRITE=1 关掉这行为(应急)。
211
- if (process.env.NODESIGN_DISABLE_THUMBNAIL_REWRITE !== '1') {
212
- content = rewriteImagesToThumbnails(content);
213
- }
214
- // 注入唯一权威 fit script(每 section 自动包 100vw×100vh frame + scroll-snap)
215
- // preview iframe 跟离线打开 / 导出 HTML 共享同一 fit 行为,渲染一致
216
- content = injectFitBlock(content);
217
- res.setHeader('Content-Type', 'text/html; charset=utf-8');
218
- res.setHeader('Cache-Control', 'no-store');
219
- res.send(content);
220
- } catch (err) {
221
- if (err.code === 'ENOENT') {
222
- // 还没生成(session 刚建,agent 没跑过)—— 占位 HTML
223
- res.setHeader('Content-Type', 'text/html; charset=utf-8');
224
- res.setHeader('Cache-Control', 'no-store');
225
- res.status(200).send(EMPTY_CANVAS_HTML);
226
- } else {
227
- throw err;
228
- }
229
- }
230
- } catch (err) { next(err); }
231
- });
232
-
233
- /**
234
- * GET /:pid/canvas/deck-meta —— 返 deck 比例信息
235
- *
236
- * 读 canvas.html wrap data-deck-aspect 属性 → resolve 到 4 档预设
237
- * (16:9 / 16:10 / 9:16 / 4:3),返 { aspect, width, height }。
238
- *
239
- * 用途:前端 Home 缩略图 / Workspace ThumbnailBox 需要在挂载前知道 deck
240
- * 比例才能正确设容器 aspectRatio + iframe size。Canvas 主路径自己会读
241
- * data-deck-aspect 不需要这个 endpoint。
242
- *
243
- * canvas.html 还没生成 → 返默认 16:9(让前端能用 fallback 占位)
244
- */
245
- router.get(['/:pid/canvas/deck-meta', '/:pid/sessions/:sid/canvas/deck-meta'], async (req, res, next) => {
246
- try {
247
- if (!guard(req, res)) return;
248
- const sessionRoot = rootOf(req);
249
- // ?path= 让任务 deck 也能问自己的比例(缺省旧式 cwd/canvas.html)
250
- const rel = typeof req.query.path === 'string' && req.query.path ? req.query.path : 'canvas.html';
251
- const file = path.resolve(sessionRoot, rel);
252
- let html = '';
253
- if (file === sessionRoot || file.startsWith(sessionRoot + path.sep)) {
254
- try { html = await fs.readFile(file, 'utf8'); } catch { /* canvas 还没生成 → 默认 16:9 fallback */ }
255
- }
256
- // kind 一起返回:站点没有"比例"这回事(响应式、高度不定),前端拿到
257
- // kind='site' 就别用下面这组数去套固定画框。不返 kind 的话前端只能拿到
258
- // 静默 fallback 的 16:9,把一个网站塞进 1920×1080 的信箱框里。
259
- const kind = await kindOfPath(sessionRoot, rel);
260
- const aspect = extractDeckAspect(html);
261
- const { width, height } = resolveDeckSize(aspect);
262
- res.setHeader('Cache-Control', 'no-store');
263
- res.json({ kind, aspect, width, height });
264
- } catch (err) { next(err); }
265
- });
266
-
267
- /**
268
- * PUT /:pid/canvas —— 落库用户在画布上的直接编辑
269
- *
270
- * body.path(2026-07-28):任务模型下 deck 住 tasks/<任务>/canvas.html,
271
- * 不带这个字段就会把用户的改动写进 sessions/<sid>/canvas.html —— 前端显示
272
- * "已保存",用户看的那份却纹丝不动。缺省仍是旧式 cwd/canvas.html。
273
- */
274
- router.put(['/:pid/canvas', '/:pid/sessions/:sid/canvas'], async (req, res, next) => {
275
- try {
276
- if (!guard(req, res)) return;
277
- const { html, source = 'user', path: relPath } = req.body || {};
278
- if (typeof html !== 'string' || html.length === 0) {
279
- return res.status(400).json({ error: 'html string required' });
280
- }
281
- if (Buffer.byteLength(html, 'utf8') > MAX_HTML_BYTES) {
282
- return res.status(413).json({ error: 'html too large (>8MB)' });
283
- }
284
-
285
- const sessionRoot = await ensureRootOf(req);
286
- const file = path.resolve(sessionRoot, typeof relPath === 'string' && relPath ? relPath : 'canvas.html');
287
- if (file !== sessionRoot && !file.startsWith(sessionRoot + path.sep)) {
288
- return res.status(400).json({ error: 'path escapes workspace' });
289
- }
290
- if (!file.endsWith('.html')) {
291
- return res.status(400).json({ error: 'path must be an .html file' });
292
- }
293
- await fs.mkdir(path.dirname(file), { recursive: true });
294
- await fs.writeFile(file, html, 'utf8');
295
-
296
- const ts = new Date().toISOString();
297
- const commit = await commitWorkspace(
298
- req.params.pid, req.params.sid,
299
- `${source}-edit: ${ts}`,
300
- { author: source === 'agent' ? 'agent' : 'user' },
301
- );
302
- res.json({ ok: true, commit });
303
- } catch (err) { next(err); }
304
- });
305
-
306
- // history / revert / undo:git 仓是项目级一个,workspace.js 三个 git helper 的
307
- // sessionId 参数只是记出处 / 已不参与路径 —— 项目级挂载直接把 undefined 传下去
308
- router.get(['/:pid/canvas/history', '/:pid/sessions/:sid/canvas/history'], async (req, res, next) => {
309
- try {
310
- if (!guard(req, res)) return;
311
- const limit = Math.min(Number(req.query.limit) || 50, 200);
312
- const entries = await listHistory(req.params.pid, req.params.sid, { limit });
313
- res.json({ entries });
314
- } catch (err) { next(err); }
315
- });
316
-
317
- router.post(['/:pid/canvas/revert', '/:pid/sessions/:sid/canvas/revert'], async (req, res, next) => {
318
- try {
319
- if (!guard(req, res)) return;
320
- const { commit } = req.body || {};
321
- if (!commit || typeof commit !== 'string') {
322
- return res.status(400).json({ error: 'commit hash required' });
323
- }
324
- const newCommit = await revertWorkspace(req.params.pid, req.params.sid, commit);
325
- res.json({ ok: true, commit: newCommit });
326
- } catch (err) {
327
- if (err.code === 'INVALID_COMMIT') return res.status(400).json({ error: err.message });
328
- next(err);
329
- }
330
- });
331
-
332
- router.post(['/:pid/canvas/undo', '/:pid/sessions/:sid/canvas/undo'], async (req, res, next) => {
333
- try {
334
- if (!guard(req, res)) return;
335
- const entries = await listHistory(req.params.pid, req.params.sid, { limit: 5 });
336
- if (!entries || entries.length < 2) {
337
- return res.status(400).json({
338
- error: 'no previous version to undo to',
339
- code: 'NO_PREV_COMMIT',
340
- });
341
- }
342
- const prevCommit = entries[1].commit || entries[1].hash || entries[1].sha;
343
- if (!prevCommit) {
344
- return res.status(500).json({ error: 'history entry missing commit hash' });
345
- }
346
- const newCommit = await revertWorkspace(req.params.pid, req.params.sid, prevCommit);
347
- res.json({ ok: true, commit: newCommit, revertedTo: prevCommit });
348
- } catch (err) {
349
- if (err.code === 'INVALID_COMMIT') return res.status(400).json({ error: err.message });
350
- next(err);
351
- }
352
- });
353
-
354
- /**
355
- * GET /:pid/spec —— 读工作区根的 spec.json(agent 私域档案)
356
- *
357
- * 不存在或解析失败时返回 {} —— 让前端不会因 spec 缺失崩。
358
- * 这是只读 endpoint —— spec.json 完全由 agent 维护(mutateSpecJson 写的就是
359
- * 工作区根这份,hooks.js 读的也是它 —— 项目级挂载跟写入方同一落点)。
360
- * 注意:.nd/<sid>/ 里也有一份叫 spec.json 的会话私档(压缩摘要),那是另一回事,
361
- * 不走这条路由。
362
- */
363
- router.get(['/:pid/spec', '/:pid/sessions/:sid/spec'], async (req, res, next) => {
364
- try {
365
- if (!guard(req, res)) return;
366
- const sessionRoot = rootOf(req);
367
- const file = path.join(sessionRoot, 'spec.json');
368
- try {
369
- const raw = await fs.readFile(file, 'utf8');
370
- let spec = {};
371
- try { spec = JSON.parse(raw); } catch { spec = {}; }
372
- if (!spec || typeof spec !== 'object' || Array.isArray(spec)) spec = {};
373
- res.json({ spec });
374
- } catch (err) {
375
- if (err.code === 'ENOENT') return res.json({ spec: {} });
376
- throw err;
377
- }
378
- } catch (err) { next(err); }
379
- });
380
-
381
- // GET /plan endpoint 已删(Phase 4);plan mode 整条线 2026-08-21 整体移除。
382
-
383
- /**
384
- * GET /:pid/config —— 读项目级 UI 配置(ui-config.json,#25 从
385
- * session-config.json 改名 —— 扁平化后它就是项目级的了,旧名跟
386
- * .nd/<sid>/session-config.json(模型域,session-model.js 专管)同名不同域。
387
- * 读写细节和迁移策略在 projects/ui-config.js)
388
- *
389
- * 跟 spec.json 区分:
390
- * - spec.json = agent 私域档案(agent 通过 record_decision/expose_tweaks/PostCompact 写)
391
- * - ui-config.json = 用户/前端偏好(toggle 状态、UI 偏好)
392
- *
393
- * 当前字段:
394
- * - tweaks_mode_enabled: bool 是否启用 Tweaks 模式(agent 主动暴露微调参数)
395
- *
396
- * 文件不存在时返回默认 config。
397
- */
398
- router.get(['/:pid/config', '/:pid/sessions/:sid/config'], async (req, res, next) => {
399
- try {
400
- if (!guard(req, res)) return;
401
- const sessionRoot = rootOf(req);
402
- res.json({ config: withUiDefaults(await readUiConfigFile(sessionRoot)) });
403
- } catch (err) { next(err); }
404
- });
405
-
406
- /**
407
- * PATCH /:pid/config —— 部分更新 ui-config.json
408
- *
409
- * body: 任意 partial config(只覆盖传进来的字段)
410
- * 返回:merge 后的完整 config
411
- */
412
- router.patch(['/:pid/config', '/:pid/sessions/:sid/config'], async (req, res, next) => {
413
- try {
414
- if (!guard(req, res)) return;
415
- const sessionRoot = await ensureRootOf(req);
416
- const patch = req.body || {};
417
- if (typeof patch !== 'object' || Array.isArray(patch)) {
418
- return res.status(400).json({ error: 'body must be object' });
419
- }
420
- // model 不能从这条泛用通道改:改模型除了写字段还要让跑着的 query 认账
421
- // (空闲时关掉重启),只写文件会让配置和进程各说各话。走
422
- // PUT /sessions/:sid/model —— 那条把两步绑在一起。
423
- if ('model' in patch) {
424
- return res.status(400).json({
425
- error: 'model 不能通过 config PATCH 修改,请用 PUT /sessions/:sid/model',
426
- code: 'USE_MODEL_ENDPOINT',
427
- });
428
- }
429
- const current = withUiDefaults(await readUiConfigFile(sessionRoot));
430
- const merged = { ...current, ...patch, updatedAt: new Date().toISOString() };
431
- await writeUiConfig(sessionRoot, merged);
432
- res.json({ config: merged });
433
- } catch (err) { next(err); }
434
- });
435
-
436
- const EMPTY_CANVAS_HTML = `<!doctype html>
437
- <html lang="zh"><head><meta charset="utf-8"><title>NoDesign canvas</title>
438
- <style>html,body{margin:0;height:100%;font-family:system-ui;background:#F9F8F6}
439
- .placeholder{display:flex;align-items:center;justify-content:center;height:100%;
440
- color:#3a2a18aa;font-size:14px;letter-spacing:.02em}</style></head>
441
- <body><div class="placeholder">canvas.html 还没生成 · 等 agent 跑一次 turn</div></body></html>
442
- `;
443
-
444
- export default router;
1
+ /**
2
+ * server/api/canvas.js — Canvas + Spec read/write/history/revert
3
+ *
4
+ * 路径(2026-08-13 起**项目级**,会话级留作 alias —— 同 pending-changes.js):
5
+ * GET /api/projects/:pid/assets/*subPath 单文件(iframe 内相对资源)
6
+ * GET /api/projects/:pid/canvas → text/html
7
+ * GET /api/projects/:pid/canvas/deck-meta deck 比例信息
8
+ * PUT /api/projects/:pid/canvas { html, source?, path? }
9
+ * GET /api/projects/:pid/canvas/history git log
10
+ * POST /api/projects/:pid/canvas/revert { commit }
11
+ * POST /api/projects/:pid/canvas/undo
12
+ * GET /api/projects/:pid/spec spec.json(agent 私域档案)
13
+ * GET /api/projects/:pid/config session-config.json
14
+ * PATCH /api/projects/:pid/config
15
+ * (/:pid/sessions/:sid/... 同 handler 双挂载 —— 老前端和 jsonl 里的历史引用
16
+ * 还打得通;扁平化后 sessionRoot === 工作区根,这批文件从来就是每项目一份。
17
+ * 真正 per-session 的数据(.nd/<sid>/、PUT /sessions/:sid/model)不在本文件,
18
+ * 那些保持 sid 专属,见 sessions.js。)
19
+ *
20
+ * 文件实际位置(扁平化后全在工作区根):
21
+ * <workspace>/canvas.html、spec.json、session-config.json、assets/、.git/
22
+ */
23
+
24
+ import express from 'express';
25
+ import { promises as fs } from 'fs';
26
+ import path from 'path';
27
+ import { validateProjectId, getProject } from '../projects/store.js';
28
+ import { guardProject } from './_guard.js';
29
+ import {
30
+ getSessionWorkspace, ensureSessionWorkspace, validateSessionId,
31
+ getWorkspaceRoot, ensureProjectWorkspace,
32
+ commitWorkspace, listHistory, revertWorkspace,
33
+ } from '../projects/workspace.js';
34
+ import { fitInjectionBlock } from './standalone-fit.js';
35
+ import { resolveDeckSize, extractDeckAspect } from '../shared/deck.js';
36
+ import { kindOfPath } from '../lib/artifact-target.js';
37
+ import {
38
+ sendImage, isThumbPath, findOriginalForThumbnail, imageCacheControl,
39
+ THUMBNAIL_MAX_DIM, THUMBNAIL_QUALITY,
40
+ } from '../lib/image-variant.js';
41
+ import { sendVideo, isVideo } from '../lib/video-variant.js';
42
+ import { readUiConfigFile, withUiDefaults, writeUiConfig } from '../projects/ui-config.js';
43
+
44
+ const router = express.Router();
45
+
46
+ const MAX_HTML_BYTES = 8 * 1024 * 1024; // 8MB
47
+
48
+ function guard(req, res) {
49
+ // sid 只在走老 alias 时存在 —— 有就校验形状,没有就是项目级路由
50
+ if (req.params.sid !== undefined) {
51
+ try {
52
+ validateSessionId(req.params.sid);
53
+ } catch (err) {
54
+ res.status(400).json({ error: err.message || 'invalid pid/sid' });
55
+ return null;
56
+ }
57
+ }
58
+ // pid 校验 + 存在性 + 归属(2026-07-30 多用户)统一走 guardProject
59
+ return guardProject(req, res);
60
+ }
61
+
62
+ /** 两条挂载共用(只读路径):alias 带 sid 走原路,项目级直接取工作区根 */
63
+ function rootOf(req) {
64
+ return req.params.sid !== undefined
65
+ ? getSessionWorkspace(req.params.pid, req.params.sid)
66
+ : getWorkspaceRoot(req.params.pid);
67
+ }
68
+
69
+ /** 两条挂载共用(写路径):同上,但先 ensure 工作区存在 */
70
+ function ensureRootOf(req) {
71
+ return req.params.sid !== undefined
72
+ ? ensureSessionWorkspace(req.params.pid, req.params.sid)
73
+ : ensureProjectWorkspace(req.params.pid);
74
+ }
75
+
76
+ // 单文件 GET(assets/* 子树)—— 让 iframe 里 <img src="assets/generated/x.jpg">
77
+ // 自然解析 + chat 渲染 image content block 缩略图也走这个 endpoint。
78
+ // 走 sessions/<sid>/assets softlink 透到 shared/assets,路径限 assets/* 子树
79
+ // 防 traversal。MIME 按扩展名定。
80
+ const ASSET_MIME = {
81
+ '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
82
+ '.webp': 'image/webp', '.gif': 'image/gif', '.svg': 'image/svg+xml',
83
+ '.pdf': 'application/pdf', '.mp4': 'video/mp4', '.webm': 'video/webm',
84
+ '.mp3': 'audio/mpeg', '.wav': 'audio/wav', '.ogg': 'audio/ogg',
85
+ };
86
+ // thumbnail 地址的解析与兜底(缺 thumb → 回原图 → 现编 512 webp)在
87
+ // lib/image-variant.js,artifact-file 路由吃的是同一份,两条路由行为一致。
88
+
89
+ router.get(['/:pid/assets/*subPath', '/:pid/sessions/:sid/assets/*subPath'], async (req, res, next) => {
90
+ try {
91
+ if (!guard(req, res)) return;
92
+ const sessionRoot = rootOf(req);
93
+ // Express 5 named wildcard:req.params.subPath 是 string[] 或 string
94
+ const raw = req.params.subPath;
95
+ const subPath = Array.isArray(raw) ? raw.join('/') : (raw || '');
96
+ if (!subPath) return res.status(400).json({ error: 'asset path required' });
97
+
98
+ let absPath = path.resolve(sessionRoot, 'assets', subPath);
99
+ const assetsRoot = path.resolve(sessionRoot, 'assets');
100
+ // 防 traversal:resolve 后必须在 sessions/<sid>/assets/ 下
101
+ if (absPath !== assetsRoot && !absPath.startsWith(assetsRoot + path.sep)) {
102
+ return res.status(403).json({ error: 'path escapes assets/' });
103
+ }
104
+
105
+ // 请求的是不是缩略图地址 —— 决定 fallback 到原图之后要不要顺手缩到 512
106
+ const wantsThumb = isThumbPath(absPath);
107
+
108
+ let stat;
109
+ let servedOriginalForThumb = false;
110
+ try {
111
+ stat = await fs.stat(absPath);
112
+ } catch (err) {
113
+ if (err.code !== 'ENOENT') throw err;
114
+ // ENOENT 兜底:thumbnail 路径不存在 → fallback 到原图(老图 / 生成失败 case)
115
+ if (wantsThumb) {
116
+ const original = await findOriginalForThumbnail(absPath);
117
+ if (original) {
118
+ absPath = original;
119
+ stat = await fs.stat(original);
120
+ servedOriginalForThumb = true;
121
+ } else {
122
+ return res.status(404).json({ error: 'thumbnail and original both missing' });
123
+ }
124
+ } else {
125
+ return res.status(404).json({ error: 'asset not found' });
126
+ }
127
+ }
128
+ if (!stat.isFile()) return res.status(400).json({ error: 'not a file' });
129
+
130
+ const ext = path.extname(absPath).toLowerCase();
131
+ // 带 ?v= 的是内容寻址 URL(源图一变 v 就变),可以永久缓存;不带的只能短缓存
132
+ res.setHeader('Cache-Control', imageCacheControl(req));
133
+ // 视频走 Range + 派生档(deck 里 <video src="assets/x.mp4"> 也打这条路由)
134
+ if (isVideo(ext)) {
135
+ return sendVideo(req, res, absPath, stat, {
136
+ fallbackMime: ASSET_MIME[ext] || 'application/octet-stream',
137
+ });
138
+ }
139
+
140
+ // 显示路径一律发派生图(原图只留给导出,见 lib/image-variant.js)。
141
+ // 走 thumbnail 地址却拿到原图时补上 512 长边,等于把当年该生成的那张现补出来。
142
+ return sendImage(req, res, absPath, stat, {
143
+ fallbackMime: ASSET_MIME[ext] || 'application/octet-stream',
144
+ maxDim: servedOriginalForThumb ? THUMBNAIL_MAX_DIM : null,
145
+ quality: servedOriginalForThumb ? THUMBNAIL_QUALITY : undefined,
146
+ });
147
+ } catch (err) { next(err); }
148
+ });
149
+
150
+ /**
151
+ * Preview-only:把 canvas.html 里 <img src="assets/generated/<name>.<ext>"> 透明
152
+ * 重写成 src="assets/generated/.thumbnails/<name>.thumb.webp"。仅 GET /canvas serve
153
+ * 时改输出,**不动** agent 写的源文件。
154
+ *
155
+ * 理由:单图原始 6-8MB(Gemini 默认 1080×1920+ PNG),iframe 缩放(zoom=transform
156
+ * scale)+ 多图同时渲染让 GPU/RAM 暴涨,preview 体感卡。thumbnail 长边 512 / ~20KB
157
+ * 足够 preview 看清布局。导出走 build-standalone 仍 inline 原图,最终交付不损质量。
158
+ *
159
+ * 也覆盖 CSS 内 url(...) 引用的图片(agent 用 background-image 时常见)。
160
+ *
161
+ * thumbnail 不存在时 asset endpoint 会自动 fallback 到原图(见下方 ENOENT 分支),
162
+ * 老图(thumbnail 没生成的)/ 生成失败的都能正常显示。
163
+ */
164
+ /**
165
+ * 注入唯一权威 fit injection block(<script> + <style>)到 </body> 前。
166
+ *
167
+ * preview iframe 路径跟离线 / 导出 HTML 走同一份 standalone-fit,确保渲染一致:
168
+ * 每 section 包 100vw×100vh frame + scroll-snap + CSS min() 缩放。
169
+ *
170
+ * 已含 __nd-standard-fit script 的跳过(重启场景:HTML 已经 saved 了)。
171
+ */
172
+ function injectFitBlock(html) {
173
+ if (/__nd-standard-fit\b/.test(html)) return html;
174
+ const block = fitInjectionBlock();
175
+ if (html.includes('</body>')) return html.replace('</body>', block + '\n</body>');
176
+ if (html.includes('</html>')) return html.replace('</html>', block + '\n</html>');
177
+ return html + block;
178
+ }
179
+
180
+ function rewriteImagesToThumbnails(html) {
181
+ const imgRe = /(<img\b[^>]*\bsrc\s*=\s*["'])assets\/generated\/(?!\.thumbnails\/)([^"']+?)\.(png|jpg|jpeg|webp|gif)(["'])/gi;
182
+ const cssUrlRe = /(url\(\s*["']?)assets\/generated\/(?!\.thumbnails\/)([^"')]+?)\.(png|jpg|jpeg|webp|gif)(["']?\s*\))/gi;
183
+ return html
184
+ .replace(imgRe, (_m, prefix, name, _ext, suffix) =>
185
+ `${prefix}assets/generated/.thumbnails/${name}.thumb.webp${suffix}`)
186
+ .replace(cssUrlRe, (_m, prefix, name, _ext, suffix) =>
187
+ `${prefix}assets/generated/.thumbnails/${name}.thumb.webp${suffix}`);
188
+ }
189
+
190
+ router.get(['/:pid/canvas', '/:pid/sessions/:sid/canvas'], async (req, res, next) => {
191
+ try {
192
+ if (!guard(req, res)) return;
193
+ const sessionRoot = rootOf(req);
194
+ const file = path.join(sessionRoot, 'canvas.html');
195
+ try {
196
+ let content = await fs.readFile(file, 'utf8');
197
+ // 注入 <base href>:让 iframe 内 <img src="assets/...">/url("assets/...") 等
198
+ // 相对资源解析显式锚到本工作区,不依赖 iframe.src 的隐式 base URL
199
+ // (src 带 ?v=xxx query / 部署 redirect 等都可能让浏览器解析跑偏)。
200
+ // 已含 <base> 时跳过;正则只匹配开始 <head> tag。
201
+ // base 跟着请求进来的那条路走(项目级 / sid alias)——assets 两边都挂了,
202
+ // 项目级页面锚到 sid 路径(或反过来)虽然也能通,但没必要跨范式。
203
+ if (!/<base\s+href=/i.test(content)) {
204
+ const baseHref = req.params.sid !== undefined
205
+ ? `/api/projects/${encodeURIComponent(req.params.pid)}/sessions/${encodeURIComponent(req.params.sid)}/`
206
+ : `/api/projects/${encodeURIComponent(req.params.pid)}/`;
207
+ content = content.replace(/<head([^>]*)>/i, `<head$1>\n <base href="${baseHref}">`);
208
+ }
209
+ // 透明替换 generated 图片为 thumbnail(preview 流畅;导出 / agent 看到的源
210
+ // 文件不动)。env NODESIGN_DISABLE_THUMBNAIL_REWRITE=1 关掉这行为(应急)。
211
+ if (process.env.NODESIGN_DISABLE_THUMBNAIL_REWRITE !== '1') {
212
+ content = rewriteImagesToThumbnails(content);
213
+ }
214
+ // 注入唯一权威 fit script(每 section 自动包 100vw×100vh frame + scroll-snap)
215
+ // preview iframe 跟离线打开 / 导出 HTML 共享同一 fit 行为,渲染一致
216
+ content = injectFitBlock(content);
217
+ res.setHeader('Content-Type', 'text/html; charset=utf-8');
218
+ res.setHeader('Cache-Control', 'no-store');
219
+ res.send(content);
220
+ } catch (err) {
221
+ if (err.code === 'ENOENT') {
222
+ // 还没生成(session 刚建,agent 没跑过)—— 占位 HTML
223
+ res.setHeader('Content-Type', 'text/html; charset=utf-8');
224
+ res.setHeader('Cache-Control', 'no-store');
225
+ res.status(200).send(EMPTY_CANVAS_HTML);
226
+ } else {
227
+ throw err;
228
+ }
229
+ }
230
+ } catch (err) { next(err); }
231
+ });
232
+
233
+ /**
234
+ * GET /:pid/canvas/deck-meta —— 返 deck 比例信息
235
+ *
236
+ * 读 canvas.html wrap data-deck-aspect 属性 → resolve 到 4 档预设
237
+ * (16:9 / 16:10 / 9:16 / 4:3),返 { aspect, width, height }。
238
+ *
239
+ * 用途:前端 Home 缩略图 / Workspace ThumbnailBox 需要在挂载前知道 deck
240
+ * 比例才能正确设容器 aspectRatio + iframe size。Canvas 主路径自己会读
241
+ * data-deck-aspect 不需要这个 endpoint。
242
+ *
243
+ * canvas.html 还没生成 → 返默认 16:9(让前端能用 fallback 占位)
244
+ */
245
+ router.get(['/:pid/canvas/deck-meta', '/:pid/sessions/:sid/canvas/deck-meta'], async (req, res, next) => {
246
+ try {
247
+ if (!guard(req, res)) return;
248
+ const sessionRoot = rootOf(req);
249
+ // ?path= 让任务 deck 也能问自己的比例(缺省旧式 cwd/canvas.html)
250
+ const rel = typeof req.query.path === 'string' && req.query.path ? req.query.path : 'canvas.html';
251
+ const file = path.resolve(sessionRoot, rel);
252
+ let html = '';
253
+ if (file === sessionRoot || file.startsWith(sessionRoot + path.sep)) {
254
+ try { html = await fs.readFile(file, 'utf8'); } catch { /* canvas 还没生成 → 默认 16:9 fallback */ }
255
+ }
256
+ // kind 一起返回:站点没有"比例"这回事(响应式、高度不定),前端拿到
257
+ // kind='site' 就别用下面这组数去套固定画框。不返 kind 的话前端只能拿到
258
+ // 静默 fallback 的 16:9,把一个网站塞进 1920×1080 的信箱框里。
259
+ const kind = await kindOfPath(sessionRoot, rel);
260
+ const aspect = extractDeckAspect(html);
261
+ const { width, height } = resolveDeckSize(aspect);
262
+ res.setHeader('Cache-Control', 'no-store');
263
+ res.json({ kind, aspect, width, height });
264
+ } catch (err) { next(err); }
265
+ });
266
+
267
+ /**
268
+ * PUT /:pid/canvas —— 落库用户在画布上的直接编辑
269
+ *
270
+ * body.path(2026-07-28):任务模型下 deck 住 tasks/<任务>/canvas.html,
271
+ * 不带这个字段就会把用户的改动写进 sessions/<sid>/canvas.html —— 前端显示
272
+ * "已保存",用户看的那份却纹丝不动。缺省仍是旧式 cwd/canvas.html。
273
+ */
274
+ router.put(['/:pid/canvas', '/:pid/sessions/:sid/canvas'], async (req, res, next) => {
275
+ try {
276
+ if (!guard(req, res)) return;
277
+ const { html, source = 'user', path: relPath } = req.body || {};
278
+ if (typeof html !== 'string' || html.length === 0) {
279
+ return res.status(400).json({ error: 'html string required' });
280
+ }
281
+ if (Buffer.byteLength(html, 'utf8') > MAX_HTML_BYTES) {
282
+ return res.status(413).json({ error: 'html too large (>8MB)' });
283
+ }
284
+
285
+ const sessionRoot = await ensureRootOf(req);
286
+ const file = path.resolve(sessionRoot, typeof relPath === 'string' && relPath ? relPath : 'canvas.html');
287
+ if (file !== sessionRoot && !file.startsWith(sessionRoot + path.sep)) {
288
+ return res.status(400).json({ error: 'path escapes workspace' });
289
+ }
290
+ if (!file.endsWith('.html')) {
291
+ return res.status(400).json({ error: 'path must be an .html file' });
292
+ }
293
+ await fs.mkdir(path.dirname(file), { recursive: true });
294
+ await fs.writeFile(file, html, 'utf8');
295
+
296
+ const ts = new Date().toISOString();
297
+ const commit = await commitWorkspace(
298
+ req.params.pid, req.params.sid,
299
+ `${source}-edit: ${ts}`,
300
+ { author: source === 'agent' ? 'agent' : 'user' },
301
+ );
302
+ res.json({ ok: true, commit });
303
+ } catch (err) { next(err); }
304
+ });
305
+
306
+ // history / revert / undo:git 仓是项目级一个,workspace.js 三个 git helper 的
307
+ // sessionId 参数只是记出处 / 已不参与路径 —— 项目级挂载直接把 undefined 传下去
308
+ router.get(['/:pid/canvas/history', '/:pid/sessions/:sid/canvas/history'], async (req, res, next) => {
309
+ try {
310
+ if (!guard(req, res)) return;
311
+ const limit = Math.min(Number(req.query.limit) || 50, 200);
312
+ const entries = await listHistory(req.params.pid, req.params.sid, { limit });
313
+ res.json({ entries });
314
+ } catch (err) { next(err); }
315
+ });
316
+
317
+ router.post(['/:pid/canvas/revert', '/:pid/sessions/:sid/canvas/revert'], async (req, res, next) => {
318
+ try {
319
+ if (!guard(req, res)) return;
320
+ const { commit } = req.body || {};
321
+ if (!commit || typeof commit !== 'string') {
322
+ return res.status(400).json({ error: 'commit hash required' });
323
+ }
324
+ const newCommit = await revertWorkspace(req.params.pid, req.params.sid, commit);
325
+ res.json({ ok: true, commit: newCommit });
326
+ } catch (err) {
327
+ if (err.code === 'INVALID_COMMIT') return res.status(400).json({ error: err.message });
328
+ next(err);
329
+ }
330
+ });
331
+
332
+ router.post(['/:pid/canvas/undo', '/:pid/sessions/:sid/canvas/undo'], async (req, res, next) => {
333
+ try {
334
+ if (!guard(req, res)) return;
335
+ const entries = await listHistory(req.params.pid, req.params.sid, { limit: 5 });
336
+ if (!entries || entries.length < 2) {
337
+ return res.status(400).json({
338
+ error: 'no previous version to undo to',
339
+ code: 'NO_PREV_COMMIT',
340
+ });
341
+ }
342
+ const prevCommit = entries[1].commit || entries[1].hash || entries[1].sha;
343
+ if (!prevCommit) {
344
+ return res.status(500).json({ error: 'history entry missing commit hash' });
345
+ }
346
+ const newCommit = await revertWorkspace(req.params.pid, req.params.sid, prevCommit);
347
+ res.json({ ok: true, commit: newCommit, revertedTo: prevCommit });
348
+ } catch (err) {
349
+ if (err.code === 'INVALID_COMMIT') return res.status(400).json({ error: err.message });
350
+ next(err);
351
+ }
352
+ });
353
+
354
+ /**
355
+ * GET /:pid/spec —— 读工作区根的 spec.json(agent 私域档案)
356
+ *
357
+ * 不存在或解析失败时返回 {} —— 让前端不会因 spec 缺失崩。
358
+ * 这是只读 endpoint —— spec.json 完全由 agent 维护(mutateSpecJson 写的就是
359
+ * 工作区根这份,hooks.js 读的也是它 —— 项目级挂载跟写入方同一落点)。
360
+ * 注意:.nd/<sid>/ 里也有一份叫 spec.json 的会话私档(压缩摘要),那是另一回事,
361
+ * 不走这条路由。
362
+ */
363
+ router.get(['/:pid/spec', '/:pid/sessions/:sid/spec'], async (req, res, next) => {
364
+ try {
365
+ if (!guard(req, res)) return;
366
+ const sessionRoot = rootOf(req);
367
+ const file = path.join(sessionRoot, 'spec.json');
368
+ try {
369
+ const raw = await fs.readFile(file, 'utf8');
370
+ let spec = {};
371
+ try { spec = JSON.parse(raw); } catch { spec = {}; }
372
+ if (!spec || typeof spec !== 'object' || Array.isArray(spec)) spec = {};
373
+ res.json({ spec });
374
+ } catch (err) {
375
+ if (err.code === 'ENOENT') return res.json({ spec: {} });
376
+ throw err;
377
+ }
378
+ } catch (err) { next(err); }
379
+ });
380
+
381
+ // GET /plan endpoint 已删(Phase 4);plan mode 整条线 2026-08-21 整体移除。
382
+
383
+ /**
384
+ * GET /:pid/config —— 读项目级 UI 配置(ui-config.json,#25 从
385
+ * session-config.json 改名 —— 扁平化后它就是项目级的了,旧名跟
386
+ * .nd/<sid>/session-config.json(模型域,session-model.js 专管)同名不同域。
387
+ * 读写细节和迁移策略在 projects/ui-config.js)
388
+ *
389
+ * 跟 spec.json 区分:
390
+ * - spec.json = agent 私域档案(agent 通过 record_decision/expose_tweaks/PostCompact 写)
391
+ * - ui-config.json = 用户/前端偏好(toggle 状态、UI 偏好)
392
+ *
393
+ * 当前字段:
394
+ * - tweaks_mode_enabled: bool 是否启用 Tweaks 模式(agent 主动暴露微调参数)
395
+ *
396
+ * 文件不存在时返回默认 config。
397
+ */
398
+ router.get(['/:pid/config', '/:pid/sessions/:sid/config'], async (req, res, next) => {
399
+ try {
400
+ if (!guard(req, res)) return;
401
+ const sessionRoot = rootOf(req);
402
+ res.json({ config: withUiDefaults(await readUiConfigFile(sessionRoot)) });
403
+ } catch (err) { next(err); }
404
+ });
405
+
406
+ /**
407
+ * PATCH /:pid/config —— 部分更新 ui-config.json
408
+ *
409
+ * body: 任意 partial config(只覆盖传进来的字段)
410
+ * 返回:merge 后的完整 config
411
+ */
412
+ router.patch(['/:pid/config', '/:pid/sessions/:sid/config'], async (req, res, next) => {
413
+ try {
414
+ if (!guard(req, res)) return;
415
+ const sessionRoot = await ensureRootOf(req);
416
+ const patch = req.body || {};
417
+ if (typeof patch !== 'object' || Array.isArray(patch)) {
418
+ return res.status(400).json({ error: 'body must be object' });
419
+ }
420
+ // model 不能从这条泛用通道改:改模型除了写字段还要让跑着的 query 认账
421
+ // (空闲时关掉重启),只写文件会让配置和进程各说各话。走
422
+ // PUT /sessions/:sid/model —— 那条把两步绑在一起。
423
+ if ('model' in patch) {
424
+ return res.status(400).json({
425
+ error: 'model 不能通过 config PATCH 修改,请用 PUT /sessions/:sid/model',
426
+ code: 'USE_MODEL_ENDPOINT',
427
+ });
428
+ }
429
+ const current = withUiDefaults(await readUiConfigFile(sessionRoot));
430
+ const merged = { ...current, ...patch, updatedAt: new Date().toISOString() };
431
+ await writeUiConfig(sessionRoot, merged);
432
+ res.json({ config: merged });
433
+ } catch (err) { next(err); }
434
+ });
435
+
436
+ const EMPTY_CANVAS_HTML = `<!doctype html>
437
+ <html lang="zh"><head><meta charset="utf-8"><title>NoDesign canvas</title>
438
+ <style>html,body{margin:0;height:100%;font-family:system-ui;background:#F9F8F6}
439
+ .placeholder{display:flex;align-items:center;justify-content:center;height:100%;
440
+ color:#3a2a18aa;font-size:14px;letter-spacing:.02em}</style></head>
441
+ <body><div class="placeholder">canvas.html 还没生成 · 等 agent 跑一次 turn</div></body></html>
442
+ `;
443
+
444
+ export default router;