@xiaobuyu/nodesign 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +661 -0
- package/README.md +140 -0
- package/bin/nodesign.js +176 -0
- package/package.json +104 -0
- package/server/api/README.md +45 -0
- package/server/api/_guard.js +75 -0
- package/server/api/admin.js +187 -0
- package/server/api/assets/docx-page.js +103 -0
- package/server/api/assets/helpers.js +22 -0
- package/server/api/assets/notes.js +144 -0
- package/server/api/assets.js +896 -0
- package/server/api/board.js +57 -0
- package/server/api/browse.js +138 -0
- package/server/api/canvas.js +444 -0
- package/server/api/chatai.js +324 -0
- package/server/api/exports/build-standalone.js +980 -0
- package/server/api/exports/cards.js +118 -0
- package/server/api/exports/docx-pdf.js +46 -0
- package/server/api/exports/export-page.js +142 -0
- package/server/api/exports/handoff.js +147 -0
- package/server/api/exports.js +690 -0
- package/server/api/instruction.js +75 -0
- package/server/api/local.js +115 -0
- package/server/api/me.js +116 -0
- package/server/api/memory.js +185 -0
- package/server/api/pending-changes.js +366 -0
- package/server/api/plugins.js +164 -0
- package/server/api/projects.js +220 -0
- package/server/api/publish.js +64 -0
- package/server/api/recent.js +73 -0
- package/server/api/sessions.js +589 -0
- package/server/api/skills.js +77 -0
- package/server/api/standalone-fit.js +168 -0
- package/server/api/turn-compose.js +180 -0
- package/server/api/turn-inflight.js +44 -0
- package/server/api/turn.js +669 -0
- package/server/auth/README.md +17 -0
- package/server/auth/middleware.js +138 -0
- package/server/auth/origin-guard.js +71 -0
- package/server/auth/session.js +108 -0
- package/server/auth/tier.js +113 -0
- package/server/auth/users-store.js +348 -0
- package/server/edit/README.md +29 -0
- package/server/engine/README.md +113 -0
- package/server/engine/agent/agent-shared.js +550 -0
- package/server/engine/agent/auto-mode-default-hard-deny.txt +4 -0
- package/server/engine/agent/auto-mode-rules.js +78 -0
- package/server/engine/agent/context.js +418 -0
- package/server/engine/agent/events.js +464 -0
- package/server/engine/agent/hooks/canvas-validate.js +199 -0
- package/server/engine/agent/hooks/failure.js +120 -0
- package/server/engine/agent/hooks/file-events.js +75 -0
- package/server/engine/agent/hooks/lifecycle.js +233 -0
- package/server/engine/agent/hooks/post-canvas-focus.js +80 -0
- package/server/engine/agent/hooks/post-guidance.js +206 -0
- package/server/engine/agent/hooks/post-subagent-report.js +104 -0
- package/server/engine/agent/hooks/post-trim.js +58 -0
- package/server/engine/agent/hooks/pre-board-neighborhood.js +34 -0
- package/server/engine/agent/hooks/pre-defaults.js +84 -0
- package/server/engine/agent/hooks/pre-injectors.js +277 -0
- package/server/engine/agent/hooks/pre-performance-log-guard.js +78 -0
- package/server/engine/agent/hooks/pre-starter-files.js +61 -0
- package/server/engine/agent/hooks/pre-workspace-scope-guard.js +78 -0
- package/server/engine/agent/hooks/site-validate.js +94 -0
- package/server/engine/agent/hooks/tool-prompts.js +31 -0
- package/server/engine/agent/hooks/turn-state-memory.js +56 -0
- package/server/engine/agent/hooks/user-prompt-submit.js +215 -0
- package/server/engine/agent/hooks.js +354 -0
- package/server/engine/agent/init-contract.js +69 -0
- package/server/engine/agent/isolation.js +159 -0
- package/server/engine/agent/model-context.js +359 -0
- package/server/engine/agent/model-table.js +292 -0
- package/server/engine/agent/plugin-loader.js +266 -0
- package/server/engine/agent/prompts/nodesign-prelude.md +336 -0
- package/server/engine/agent/prompts/tools/ask-user-question-protocol.md +71 -0
- package/server/engine/agent/prompts/tools/direct-edit-protocol.md +98 -0
- package/server/engine/agent/prompts/tools/generate-image-cookbook.gemini-gateway.md +583 -0
- package/server/engine/agent/prompts/tools/generate-image-cookbook.md +483 -0
- package/server/engine/agent/prompts/tools/hybrid-reference.md +100 -0
- package/server/engine/agent/prompts/tools/paint-still-cookbook.md +270 -0
- package/server/engine/agent/prompts/tools/roll-film-cookbook.md +70 -0
- package/server/engine/agent/prompts/tools/site-reference.md +106 -0
- package/server/engine/agent/prompts/tools/tweaks-syntax.md +125 -0
- package/server/engine/agent/prompts/tools/vision-checker-dispatch.md +48 -0
- package/server/engine/agent/session-loop.js +1024 -0
- package/server/engine/agent/session-model.js +164 -0
- package/server/engine/agent/skill.js +204 -0
- package/server/engine/agent/system-prompts.js +86 -0
- package/server/engine/agent/task-events.js +78 -0
- package/server/engine/agents/ds-extractor.md +132 -0
- package/server/engine/agents/explorer.md +190 -0
- package/server/engine/agents/index.js +230 -0
- package/server/engine/agents/schemas/design-system.json +156 -0
- package/server/engine/agents/schemas/tweak-schema.json +113 -0
- package/server/engine/agents/tweak-proposer.md +127 -0
- package/server/engine/agents/vision-checker.md +254 -0
- package/server/engine/browse/capture.js +422 -0
- package/server/engine/browse/card.js +137 -0
- package/server/engine/browse/handover.js +59 -0
- package/server/engine/browse/page-digest.js +119 -0
- package/server/engine/browse/refs.js +196 -0
- package/server/engine/browse/registry.js +307 -0
- package/server/engine/browse/screencast.js +170 -0
- package/server/engine/browse/state.js +121 -0
- package/server/engine/chatai/chat-log.js +79 -0
- package/server/engine/chatai/index.js +193 -0
- package/server/engine/chatai/openai-compat.js +152 -0
- package/server/engine/chatai/orchestrate.js +309 -0
- package/server/engine/chatai/perform.js +40 -0
- package/server/engine/chatai/summarize.js +79 -0
- package/server/engine/mcp/capability-gate.js +52 -0
- package/server/engine/mcp/index.js +339 -0
- package/server/engine/mcp/param-sanitizer.js +142 -0
- package/server/engine/mcp/tools/arrange-on-board.js +124 -0
- package/server/engine/mcp/tools/artifact-session.js +278 -0
- package/server/engine/mcp/tools/browse-computer.js +421 -0
- package/server/engine/mcp/tools/browse-find-batch.js +183 -0
- package/server/engine/mcp/tools/browse-screenshot.js +157 -0
- package/server/engine/mcp/tools/browse.js +480 -0
- package/server/engine/mcp/tools/build-docx.js +101 -0
- package/server/engine/mcp/tools/clear-pending-changes.js +99 -0
- package/server/engine/mcp/tools/create-on-board.js +155 -0
- package/server/engine/mcp/tools/crystallize-skill.js +168 -0
- package/server/engine/mcp/tools/deliver-files.js +0 -0
- package/server/engine/mcp/tools/explain-style.js +237 -0
- package/server/engine/mcp/tools/export-handoff.js +133 -0
- package/server/engine/mcp/tools/expose-tweaks.js +149 -0
- package/server/engine/mcp/tools/generate-image.js +842 -0
- package/server/engine/mcp/tools/get-computed-styles.js +164 -0
- package/server/engine/mcp/tools/get-pending-changes.js +205 -0
- package/server/engine/mcp/tools/h3box-ssh.js +84 -0
- package/server/engine/mcp/tools/helpers/acquire-page.js +82 -0
- package/server/engine/mcp/tools/helpers/motion-lab.js +554 -0
- package/server/engine/mcp/tools/helpers/motion-scroll.js +112 -0
- package/server/engine/mcp/tools/helpers/perception-page.js +215 -0
- package/server/engine/mcp/tools/helpers/reference-download.js +103 -0
- package/server/engine/mcp/tools/helpers/rembg-bridge.py +80 -0
- package/server/engine/mcp/tools/helpers/rembg.js +273 -0
- package/server/engine/mcp/tools/helpers/shot-pipeline.js +280 -0
- package/server/engine/mcp/tools/highlight.js +68 -0
- package/server/engine/mcp/tools/list-pages.js +222 -0
- package/server/engine/mcp/tools/lookup-tags.js +59 -0
- package/server/engine/mcp/tools/navigate-to-page.js +60 -0
- package/server/engine/mcp/tools/organize-board.js +64 -0
- package/server/engine/mcp/tools/paint-still.js +434 -0
- package/server/engine/mcp/tools/pin-to-board.js +130 -0
- package/server/engine/mcp/tools/preview-deck.js +130 -0
- package/server/engine/mcp/tools/profile-scroll.js +305 -0
- package/server/engine/mcp/tools/publish-site.js +102 -0
- package/server/engine/mcp/tools/query-elements.js +195 -0
- package/server/engine/mcp/tools/read-board.js +103 -0
- package/server/engine/mcp/tools/read-document.js +93 -0
- package/server/engine/mcp/tools/read-page.js +264 -0
- package/server/engine/mcp/tools/read-tavern-json.js +143 -0
- package/server/engine/mcp/tools/record-decision.js +140 -0
- package/server/engine/mcp/tools/relate-on-board.js +110 -0
- package/server/engine/mcp/tools/remove-background.js +391 -0
- package/server/engine/mcp/tools/report-issue.js +115 -0
- package/server/engine/mcp/tools/roll-film.js +266 -0
- package/server/engine/mcp/tools/screenshot-docx.js +138 -0
- package/server/engine/mcp/tools/screenshot-url.js +195 -0
- package/server/engine/mcp/tools/screenshot.js +583 -0
- package/server/engine/mcp/tools/tier-gate.js +96 -0
- package/server/engine/mcp/tools/trace-motion.js +215 -0
- package/server/engine/mcp/tools/web-search.js +548 -0
- package/server/engine/motion/inventory.js +349 -0
- package/server/engine/perception/session.js +235 -0
- package/server/engine/plugins/nodesign/.claude-plugin/plugin.json +5 -0
- package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/SKILL.md +192 -0
- package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/canvas.template.html +667 -0
- package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/patterns/hybrid-grid.md +22 -0
- package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/patterns/image-led-cover.md +25 -0
- package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/patterns/portrait.md +23 -0
- package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/patterns/quote-backdrop.md +21 -0
- package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/patterns/section-divider.md +23 -0
- package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/patterns/text-led.md +21 -0
- package/server/engine/plugins/nodesign/skills/docx-craft/SKILL.md +162 -0
- package/server/engine/plugins/nodesign/skills/docx-craft/references/token-schema.md +407 -0
- package/server/engine/plugins/nodesign/skills/docx-craft//346/226/207/346/241/243.template.json +80 -0
- package/server/engine/plugins/nodesign/skills/rp-craft/SKILL.md +228 -0
- 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 -0
- package/server/engine/plugins/nodesign/skills/rp-craft//346/274/224/345/207/272.template.js +268 -0
- package/server/engine/plugins/nodesign/skills/site-craft/SKILL.md +323 -0
- package/server/engine/plugins/nodesign/skills/site-craft/patterns/build-lane.md +134 -0
- package/server/engine/plugins/nodesign/skills/site-craft/patterns/cutout-collage.md +61 -0
- package/server/engine/plugins/nodesign/skills/site-craft/patterns/mock-app.md +163 -0
- package/server/engine/plugins/nodesign/skills/site-craft/patterns/page-transitions.md +86 -0
- package/server/engine/runs/active-runs.js +698 -0
- package/server/engine/runs/live-turn.js +252 -0
- package/server/engine/runs/store.js +364 -0
- package/server/engine/runs/turn-relay.js +217 -0
- package/server/engine/runtime/workspace.js +164 -0
- package/server/index.js +235 -0
- package/server/lib/artifact-file-path.js +80 -0
- package/server/lib/artifact-target.js +284 -0
- package/server/lib/asset-refs.js +239 -0
- package/server/lib/async-queue.js +104 -0
- package/server/lib/auto-relations.js +130 -0
- package/server/lib/binding-types.js +60 -0
- package/server/lib/board-kind-sizes.js +62 -0
- package/server/lib/board-relations.js +133 -0
- package/server/lib/browse-proxy.js +180 -0
- package/server/lib/canvas-id.js +36 -0
- package/server/lib/cover.js +227 -0
- package/server/lib/danbooru-tags.js +291 -0
- package/server/lib/doc-extract.js +156 -0
- package/server/lib/docx/build-from-source.js +260 -0
- package/server/lib/docx/build.js +490 -0
- package/server/lib/docx/dump-styles.js +287 -0
- package/server/lib/docx/fonts/nodesign-cjk.conf +164 -0
- package/server/lib/docx/merge-runs.js +129 -0
- package/server/lib/docx/numbering.js +218 -0
- package/server/lib/docx/order.js +152 -0
- package/server/lib/docx/rawzip.js +172 -0
- package/server/lib/docx/render.js +92 -0
- package/server/lib/docx/text-lint.js +225 -0
- package/server/lib/docx/tokens.js +335 -0
- package/server/lib/docx/units.js +29 -0
- package/server/lib/docx/xml.js +291 -0
- package/server/lib/docx-pages.js +187 -0
- package/server/lib/export-collect.js +289 -0
- package/server/lib/export-package.js +210 -0
- package/server/lib/html-srcset.js +145 -0
- package/server/lib/image-variant.js +460 -0
- package/server/lib/ingress/forward-openai-chat.js +305 -0
- package/server/lib/ingress/openai-chat.js +491 -0
- package/server/lib/ingress/session-notice.js +66 -0
- package/server/lib/ingress/session-routes.js +68 -0
- package/server/lib/ingress/slot-probe.js +130 -0
- package/server/lib/ingress/upstream-billing.js +52 -0
- package/server/lib/ingress/upstream-fail-streak.js +72 -0
- package/server/lib/ingress/upstream-truncation.js +48 -0
- package/server/lib/issues-store.js +182 -0
- package/server/lib/kinds/deck.js +72 -0
- package/server/lib/kinds/docx.js +283 -0
- package/server/lib/kinds/file-kinds.js +113 -0
- package/server/lib/kinds/index.js +202 -0
- package/server/lib/kinds/site.js +161 -0
- package/server/lib/model-ingress.js +543 -0
- package/server/lib/moderation.js +255 -0
- package/server/lib/notice-store.js +103 -0
- package/server/lib/plugin-install.js +104 -0
- package/server/lib/plugin-validator.js +721 -0
- package/server/lib/publish-store.js +115 -0
- package/server/lib/quick-summary.js +52 -0
- package/server/lib/quota.js +270 -0
- package/server/lib/rate-window.js +29 -0
- package/server/lib/reference-assets.js +92 -0
- package/server/lib/region-shot.js +135 -0
- package/server/lib/safe-path.js +73 -0
- package/server/lib/sdk-session.js +33 -0
- package/server/lib/showcase-store.js +92 -0
- package/server/lib/site-publish.js +465 -0
- package/server/lib/ssrf-guard.js +432 -0
- package/server/lib/task-scan.js +158 -0
- package/server/lib/tavern-json.js +154 -0
- package/server/lib/video-variant.js +237 -0
- package/server/lib/workspace-path.js +33 -0
- package/server/ops/fix-sdk-musl.mjs +40 -0
- package/server/ops/install-macos-fonts.sh +114 -0
- package/server/ops/macos-fonts.conf +336 -0
- package/server/ops/sandbox-shim/bwrap +57 -0
- package/server/projects/assets-summary.js +119 -0
- package/server/projects/auto-name.js +53 -0
- package/server/projects/board-store.js +640 -0
- package/server/projects/move-entry.js +103 -0
- package/server/projects/store.js +270 -0
- package/server/projects/ui-config.js +54 -0
- package/server/projects/workspace-templates.js +53 -0
- package/server/projects/workspace.js +1025 -0
- package/server/runtime/capabilities.js +153 -0
- package/server/runtime/local-config.js +177 -0
- package/server/runtime/local-env.js +88 -0
- package/server/runtime/platform.js +342 -0
- package/server/runtime/profile.js +72 -0
- package/server/services/rembg-launcher.js +313 -0
- package/server/services/rembg-service.py +334 -0
- package/server/shared/README.md +25 -0
- package/server/shared/deck.js +54 -0
- package/server/shared/time.js +49 -0
- package/server/style-pipeline/README.md +53 -0
- package/server/ws/broker.js +36 -0
- package/server/ws/browse-channel.js +177 -0
- package/server/ws/index.js +386 -0
- package/web/README.md +89 -0
- package/web/dist/assets/BrowserWindow-Dc7J7CYc.js +11 -0
- package/web/dist/assets/DeckWindow-4G0xX7P3.js +16 -0
- package/web/dist/assets/DocxWindow-BXQsoPkh.js +16 -0
- package/web/dist/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
- package/web/dist/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
- package/web/dist/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
- package/web/dist/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
- package/web/dist/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
- package/web/dist/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
- package/web/dist/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
- package/web/dist/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
- package/web/dist/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
- package/web/dist/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
- package/web/dist/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
- package/web/dist/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
- package/web/dist/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
- package/web/dist/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
- package/web/dist/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
- package/web/dist/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
- package/web/dist/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
- package/web/dist/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
- package/web/dist/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
- package/web/dist/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
- package/web/dist/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
- package/web/dist/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
- package/web/dist/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
- package/web/dist/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
- package/web/dist/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
- package/web/dist/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
- package/web/dist/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
- package/web/dist/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
- package/web/dist/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
- package/web/dist/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
- package/web/dist/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
- package/web/dist/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
- package/web/dist/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
- package/web/dist/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
- package/web/dist/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
- package/web/dist/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
- package/web/dist/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
- package/web/dist/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
- package/web/dist/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
- package/web/dist/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
- package/web/dist/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
- package/web/dist/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
- package/web/dist/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
- package/web/dist/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
- package/web/dist/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
- package/web/dist/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
- package/web/dist/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
- package/web/dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
- package/web/dist/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
- package/web/dist/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
- package/web/dist/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
- package/web/dist/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
- package/web/dist/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
- package/web/dist/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
- package/web/dist/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
- package/web/dist/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
- package/web/dist/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
- package/web/dist/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
- package/web/dist/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
- package/web/dist/assets/SiteWindow-D6eS-2gM.js +18 -0
- package/web/dist/assets/again-D8tfgKKO.webp +0 -0
- package/web/dist/assets/caveat-nd-CP6HlsNg.woff2 +0 -0
- package/web/dist/assets/clock-xJhvfYpS.webp +0 -0
- package/web/dist/assets/film-cut-BBKMwH7q.webp +0 -0
- package/web/dist/assets/film-still-BZ_DL9Tt.webp +0 -0
- package/web/dist/assets/index-C0KUf6Wd.css +1 -0
- package/web/dist/assets/index-Cbl3ATvp.js +2125 -0
- package/web/dist/assets/ink-desk-BRbxeDsR.webp +0 -0
- package/web/dist/assets/ink-night-Bz4BT3XH.webp +0 -0
- package/web/dist/assets/ink-portrait-C0DM4V8Q.webp +0 -0
- package/web/dist/assets/longcang-regular-C0uvTuKa.woff2 +0 -0
- package/web/dist/assets/lxgw-nd-bold-DbRfiTgo.woff2 +0 -0
- package/web/dist/assets/lxgw-nd-regular-CpMw9Ehg.woff2 +0 -0
- package/web/dist/assets/pending-edit-apply-DTjo2geW.js +31 -0
- package/web/dist/assets/question-8H4HSqxh.webp +0 -0
- package/web/dist/assets/reject-BaPJqElj.webp +0 -0
- package/web/dist/assets/rotate-cw-LMT3x1Zj.js +11 -0
- package/web/dist/assets/rp-portrait-CbrGpew2.webp +0 -0
- package/web/dist/assets/rp-street-C4jVKjxx.webp +0 -0
- package/web/dist/assets/square-dashed-mouse-pointer-CBoAkfhi.js +11 -0
- package/web/dist/assets/tangle-DIxHVNuT.webp +0 -0
- package/web/dist/assets/thisone-BylZKglV.webp +0 -0
- package/web/dist/assets/thumb-DO1wfK1I.webp +0 -0
- package/web/dist/index.html +18 -0
|
@@ -0,0 +1,896 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* server/api/assets.js — 上传素材到 project shared workspace
|
|
3
|
+
*
|
|
4
|
+
* POST /api/projects/:pid/assets multipart file → 写到 shared/assets/
|
|
5
|
+
* GET /api/projects/:pid/assets 列 shared/assets/ 下的文件
|
|
6
|
+
* DELETE /api/projects/:pid/assets/:filename 删 shared/assets/<filename>
|
|
7
|
+
*
|
|
8
|
+
* H3 改造:assets 是 project 共享资源(跨 session),落在 shared/assets/。
|
|
9
|
+
* agent 通过 additionalDirectories 跨目录 Read。
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import express from 'express';
|
|
13
|
+
import multer from 'multer';
|
|
14
|
+
import { promises as fs } from 'fs';
|
|
15
|
+
import path from 'path';
|
|
16
|
+
import { validateProjectId, getProject } from '../projects/store.js';
|
|
17
|
+
import { guardProject } from './_guard.js';
|
|
18
|
+
import {
|
|
19
|
+
getSharedDir, ensureProjectWorkspace, removeSessionWorkspace, commitWorkspace,
|
|
20
|
+
} from '../projects/workspace.js';
|
|
21
|
+
import { patchBoard, readBoard, reconcileBoardRenames, forwardId, forwardPath, renameBoardPaths } from '../projects/board-store.js';
|
|
22
|
+
import { moveEntry, MoveError } from '../projects/move-entry.js';
|
|
23
|
+
import { reconcileAutoRefsThrottled } from '../lib/auto-relations.js';
|
|
24
|
+
import { taskManifest, ENTRY_FILE, KIND_SITE, docxClaimedFiles, isDirArtifact } from '../lib/artifact-target.js';
|
|
25
|
+
import { RESERVED_DIRS, HARD_IGNORE_DIRS, DRAFTS_DIR, isReservedFile } from '../lib/task-scan.js';
|
|
26
|
+
import { listReferences } from '../lib/reference-assets.js';
|
|
27
|
+
import { resolveArtifactFile, isServablePath } from '../lib/artifact-file-path.js';
|
|
28
|
+
import { getProjectCover } from '../lib/cover.js';
|
|
29
|
+
import { makeDocxPageHandler, makeDocxPdfHandler } from './assets/docx-page.js';
|
|
30
|
+
import { mountNotesRoutes } from './assets/notes.js';
|
|
31
|
+
import { safeSegment, parseNoteFrontmatter } from './assets/helpers.js';
|
|
32
|
+
import {
|
|
33
|
+
sendImage, isThumbPath, findOriginalForThumbnail, imageCacheControl,
|
|
34
|
+
THUMBNAIL_MAX_DIM, THUMBNAIL_QUALITY,
|
|
35
|
+
} from '../lib/image-variant.js';
|
|
36
|
+
import { injectSrcset } from '../lib/html-srcset.js';
|
|
37
|
+
import { sendVideo, isVideo } from '../lib/video-variant.js';
|
|
38
|
+
import { IMAGE_EXTS, VIDEO_EXTS, decorateCardKind } from '../lib/kinds/file-kinds.js';
|
|
39
|
+
|
|
40
|
+
const router = express.Router();
|
|
41
|
+
|
|
42
|
+
const upload = multer({
|
|
43
|
+
storage: multer.memoryStorage(), // 先收到内存再写磁盘(方便 sanitize 文件名)
|
|
44
|
+
limits: { fileSize: 16 * 1024 * 1024 },
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* 文件名净化 —— 保留原名的可读性,只挡掉真正危险的字符。
|
|
49
|
+
*
|
|
50
|
+
* 老版本是 ASCII 白名单(`[^A-Za-z0-9._-]` → '_'),中文名进来整个变成一串
|
|
51
|
+
* 下划线:「品牌规范-2026.pdf」落盘成「_____-2026.pdf」。文件名是 agent 判断
|
|
52
|
+
* 素材是什么的第一手信号(turn 的 assets 提示里就是列文件名给它看),抹掉等于
|
|
53
|
+
* 每次上传都丢一层语义。
|
|
54
|
+
*
|
|
55
|
+
* 现在按"排除法":路径分隔符、Windows 保留字符、控制字符、空白 → '_'(agent 会在
|
|
56
|
+
* Bash 里引用这些路径,带空格容易出事);开头的点去掉(防隐藏文件);长度按码点
|
|
57
|
+
* 截断(别把 UTF-8 从中间切断)。连字符和中文原样留着。
|
|
58
|
+
* 落盘后仍然只在 assets/ 一层里用,路径逃逸由调用处的 resolve 前缀校验兜底。
|
|
59
|
+
*/
|
|
60
|
+
const UNSAFE_NAME_CHARS = /[\u0000-\u001f\u007f/\\:*?"<>|]/g;
|
|
61
|
+
|
|
62
|
+
function sanitizeFilename(name) {
|
|
63
|
+
const cleaned = String(name || '')
|
|
64
|
+
.normalize('NFC')
|
|
65
|
+
.replace(UNSAFE_NAME_CHARS, '_')
|
|
66
|
+
.replace(/\s+/g, '_')
|
|
67
|
+
.replace(/^\.+/, '')
|
|
68
|
+
.trim();
|
|
69
|
+
if (!cleaned) return 'unnamed';
|
|
70
|
+
const chars = [...cleaned];
|
|
71
|
+
if (chars.length <= 80) return cleaned;
|
|
72
|
+
// 超长时保住扩展名 —— 后面的 mime 判断 / 是否当图上墙全看它
|
|
73
|
+
const ext = /\.[A-Za-z0-9]{1,10}$/.exec(cleaned)?.[0] || '';
|
|
74
|
+
return chars.slice(0, 80 - ext.length).join('') + ext;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* multer 按 RFC 7578 把 multipart 的 filename 当 latin1 读,中文名到手就是
|
|
79
|
+
* 「æµè¯.txt」这种乱码。浏览器实际发的是 UTF-8 字节,按 latin1 还原成 Buffer
|
|
80
|
+
* 再用 UTF-8 解一次就对了。解出来不是合法 UTF-8 时保留原值。
|
|
81
|
+
*/
|
|
82
|
+
function decodeUploadName(raw) {
|
|
83
|
+
const name = String(raw || '');
|
|
84
|
+
try {
|
|
85
|
+
const fixed = Buffer.from(name, 'latin1').toString('utf8');
|
|
86
|
+
return fixed.includes('\uFFFD') ? name : fixed;
|
|
87
|
+
} catch { return name; }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** 单层文件名:不许带路径、不许 '..'、不许以点开头(隐藏文件) */
|
|
91
|
+
router.post('/:pid/assets', upload.single('file'), async (req, res, next) => {
|
|
92
|
+
try {
|
|
93
|
+
if (!guardProject(req, res)) return;
|
|
94
|
+
if (!req.file) return res.status(400).json({ error: 'no file (field name: file)' });
|
|
95
|
+
|
|
96
|
+
await ensureProjectWorkspace(req.params.pid);
|
|
97
|
+
const assetsDir = path.join(getSharedDir(req.params.pid), 'assets');
|
|
98
|
+
|
|
99
|
+
const originalName = decodeUploadName(req.file.originalname);
|
|
100
|
+
let filename = sanitizeFilename(originalName);
|
|
101
|
+
const targetPath = path.join(assetsDir, filename);
|
|
102
|
+
if (await exists(targetPath)) {
|
|
103
|
+
const ts = Date.now().toString(36);
|
|
104
|
+
filename = `${ts}_${filename}`;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const finalPath = path.join(assetsDir, filename);
|
|
108
|
+
await fs.writeFile(finalPath, req.file.buffer);
|
|
109
|
+
|
|
110
|
+
res.status(201).json({
|
|
111
|
+
asset: {
|
|
112
|
+
// path 给 agent Read 用 — 相对 cwd(sessions/<sid>/)走 ../shared/assets/
|
|
113
|
+
// 或者用 SDK additionalDirectories 拿到的绝对路径前缀;前端展示用 name 即可。
|
|
114
|
+
path: `../../shared/assets/${filename}`,
|
|
115
|
+
name: filename,
|
|
116
|
+
originalName,
|
|
117
|
+
size: req.file.size,
|
|
118
|
+
mime: req.file.mimetype,
|
|
119
|
+
},
|
|
120
|
+
});
|
|
121
|
+
} catch (err) { next(err); }
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
router.get('/:pid/assets', async (req, res, next) => {
|
|
125
|
+
try {
|
|
126
|
+
if (!guardProject(req, res)) return;
|
|
127
|
+
|
|
128
|
+
const assetsDir = path.join(getSharedDir(req.params.pid), 'assets');
|
|
129
|
+
let entries;
|
|
130
|
+
try {
|
|
131
|
+
entries = await fs.readdir(assetsDir, { withFileTypes: true });
|
|
132
|
+
} catch (err) {
|
|
133
|
+
if (err.code === 'ENOENT') return res.json({ assets: [] });
|
|
134
|
+
throw err;
|
|
135
|
+
}
|
|
136
|
+
const assets = [];
|
|
137
|
+
for (const e of entries) {
|
|
138
|
+
if (!e.isFile()) continue;
|
|
139
|
+
let stat;
|
|
140
|
+
try { stat = await fs.stat(path.join(assetsDir, e.name)); } catch { continue; }
|
|
141
|
+
assets.push({
|
|
142
|
+
path: `../../shared/assets/${e.name}`, name: e.name,
|
|
143
|
+
size: stat.size, mtime: stat.mtime.toISOString(),
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
assets.sort((a, b) => (a.mtime < b.mtime ? 1 : -1));
|
|
147
|
+
|
|
148
|
+
// 参考素材单独一组,走抽屉不上画布。为什么它们此前完全看不见(以及为什么
|
|
149
|
+
// 修法是加扫描口不是改形态注册表)见 lib/reference-assets.js 文件头。
|
|
150
|
+
const references = await listReferences(assetsDir);
|
|
151
|
+
|
|
152
|
+
res.json({ assets, references });
|
|
153
|
+
} catch (err) { next(err); }
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
// H4b:删 asset 文件
|
|
157
|
+
router.delete('/:pid/assets/:filename', async (req, res, next) => {
|
|
158
|
+
try {
|
|
159
|
+
if (!guardProject(req, res)) return;
|
|
160
|
+
|
|
161
|
+
const filename = req.params.filename;
|
|
162
|
+
// 防 traversal:单层文件名 + resolve 后必须还在 assets/ 里
|
|
163
|
+
// (不能用 ASCII 白名单——中文名的素材会删不掉)
|
|
164
|
+
if (!safeSegment(filename)) {
|
|
165
|
+
return res.status(400).json({ error: 'invalid filename' });
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const assetsDir = path.join(getSharedDir(req.params.pid), 'assets');
|
|
169
|
+
const filePath = path.resolve(assetsDir, filename);
|
|
170
|
+
if (!filePath.startsWith(assetsDir + path.sep)) {
|
|
171
|
+
return res.status(400).json({ error: 'invalid filename' });
|
|
172
|
+
}
|
|
173
|
+
try {
|
|
174
|
+
await fs.unlink(filePath);
|
|
175
|
+
} catch (err) {
|
|
176
|
+
if (err.code === 'ENOENT') return res.status(404).json({ error: 'asset not found' });
|
|
177
|
+
throw err;
|
|
178
|
+
}
|
|
179
|
+
res.status(204).end();
|
|
180
|
+
} catch (err) { next(err); }
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
async function exists(p) {
|
|
184
|
+
try { await fs.access(p); return true; } catch { return false; }
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// ── 工作台产物墙(2026-07-27 v1)──
|
|
188
|
+
// 产物 = 目前是文件(上传素材 + generated 生成图);未来扩展便签 / 关键帧 /
|
|
189
|
+
// 文案 / 时序 / 视频时在这里加 kind。前端 ArtifactBoard 消费。
|
|
190
|
+
|
|
191
|
+
const ARTIFACT_MIME = {
|
|
192
|
+
'.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
|
|
193
|
+
'.webp': 'image/webp', '.gif': 'image/gif', '.svg': 'image/svg+xml',
|
|
194
|
+
'.pdf': 'application/pdf', '.mp4': 'video/mp4', '.webm': 'video/webm',
|
|
195
|
+
'.mp3': 'audio/mpeg', '.zip': 'application/zip',
|
|
196
|
+
'.html': 'text/html; charset=utf-8', '.md': 'text/markdown; charset=utf-8',
|
|
197
|
+
'.css': 'text/css', '.js': 'text/javascript', '.json': 'application/json',
|
|
198
|
+
// ⚠️ `.mjs` 缺了会当 application/octet-stream 发,浏览器**拒绝当模块加载**
|
|
199
|
+
// (而 screenshot 那边承诺"动态 import 跟用户看到的一致",原来只对 .js 成立)。
|
|
200
|
+
// 字体缺 mime 不致命但会走错缓存/解码路径;.wasm 缺了 instantiateStreaming 直接失败。
|
|
201
|
+
'.mjs': 'text/javascript', '.wasm': 'application/wasm',
|
|
202
|
+
'.woff2': 'font/woff2', '.woff': 'font/woff', '.ttf': 'font/ttf', '.otf': 'font/otf',
|
|
203
|
+
'.txt': 'text/plain; charset=utf-8', '.ico': 'image/x-icon',
|
|
204
|
+
'.xml': 'application/xml', '.csv': 'text/csv; charset=utf-8',
|
|
205
|
+
};
|
|
206
|
+
// 「什么算图片 / 视频」的真相源在 lib/kinds/file-kinds.js(2026-08-17 从这里挪过去)——
|
|
207
|
+
// 导出按卡类型收产物时也要问同一个问题,抄两份会分叉成「画布认、导出不认」。
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* 文件夹递归深度上限。
|
|
211
|
+
*
|
|
212
|
+
* 3 层是给用户的(prelude 里也是这么跟 agent 说的:"层级别超过两三层")——
|
|
213
|
+
* 再深就得点进去好几下才看得见东西,桌面这个隐喻本身就失效了。这不是防御性
|
|
214
|
+
* 的深度限制,构建目录 / node_modules 那类由 RESERVED_DIRS + HARD_IGNORE_DIRS
|
|
215
|
+
* 挡在外面,跟深度无关。
|
|
216
|
+
*/
|
|
217
|
+
const FOLDER_MAX_DEPTH = 3;
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* 「这个文件夹是真没了,还是只是这一瞬看不见」的两次判定。
|
|
221
|
+
*
|
|
222
|
+
* 记的是**上一次扫描时哪些是可疑的**。只有连着两次都不在才算死。一次 mv、
|
|
223
|
+
* 一次改名、agent 写到一半的目录都活不过第二次判定 —— 而它们只要活到 turn
|
|
224
|
+
* 结束,git 的改名检测就能把画布上那条改成新名字(reconcileBoardRenames)。
|
|
225
|
+
*
|
|
226
|
+
* 同时跳过正在改名窗口里的 id:转发表说它刚被改成别的名字,那它当然不在
|
|
227
|
+
* 磁盘上,删了就等于把刚改好的名字又废掉。
|
|
228
|
+
*/
|
|
229
|
+
const zoneSuspects = new Map(); // pid → Set(上一轮可疑的 zone id)
|
|
230
|
+
|
|
231
|
+
function confirmDeadZones(pid, suspects, live) {
|
|
232
|
+
const prev = zoneSuspects.get(pid) || new Set();
|
|
233
|
+
const dead = suspects.filter(z => prev.has(z) && forwardId(pid, z) === z);
|
|
234
|
+
// 这一轮的可疑名单留给下一轮;已经确认死掉的不用再留
|
|
235
|
+
zoneSuspects.set(pid, new Set(suspects.filter(z => !dead.includes(z))));
|
|
236
|
+
// 活过来的(改完名又出现、或者 agent 重新建了)自然从名单里消失
|
|
237
|
+
for (const z of live) zoneSuspects.get(pid).delete(z);
|
|
238
|
+
return dead;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* GET /:pid/artifacts — 产物清单(project 级,跨 session)。
|
|
243
|
+
* 返回 { artifacts: [{ kind, name, path, size, mtime, ext, hasThumb }] }
|
|
244
|
+
* kind: 'generated'(agent 生成图,assets/generated/)| 'upload'(用户上传,assets/ 顶层)
|
|
245
|
+
* path: agent 视角相对路径('assets/...',session cwd 软链下直接可 Read / 引用)
|
|
246
|
+
*/
|
|
247
|
+
router.get('/:pid/artifacts', async (req, res, next) => {
|
|
248
|
+
try {
|
|
249
|
+
const project = guardProject(req, res);
|
|
250
|
+
if (!project) return;
|
|
251
|
+
|
|
252
|
+
// 结构迁移挂在这里而不是只挂在「发消息 / 上传」上:这是**打开项目必调**的
|
|
253
|
+
// 那个接口,而迁移一旦晚于第一次渲染,用户会先看到一个叫 `tasks` 的文件夹
|
|
254
|
+
// 套着他的文件夹。跑过之后是三次 stat 的事(幂等早退),不值得省。
|
|
255
|
+
await ensureProjectWorkspace(req.params.pid);
|
|
256
|
+
// 跟上 agent 在画布背后做的改名(`mv` 之后卡片 id 就变了)。同上:挂在这里
|
|
257
|
+
// 是为了让用户看到的第一帧就是对齐过的。没有新 commit 时是一次 rev-parse。
|
|
258
|
+
await reconcileBoardRenames(req.params.pid).catch(
|
|
259
|
+
(err) => console.warn('[board] 改名对账失败:', err.message));
|
|
260
|
+
|
|
261
|
+
const assetsDir = path.join(getSharedDir(req.params.pid), 'assets');
|
|
262
|
+
const artifacts = [];
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* 任务目录里**不该被当成收纳文件夹**的那些子目录。
|
|
266
|
+
*
|
|
267
|
+
* 任务目录是有槽位约定的:构建产物在 dist/out/build/_site/public,站点试作在
|
|
268
|
+
* _drafts/,便利贴在 notes/(它单独扫、有自己的形态)。这些都由各自的 kind
|
|
269
|
+
* 解析器管,递归进去只会把构建中间物倒到画布上。
|
|
270
|
+
*/
|
|
271
|
+
const NOT_A_FOLDER = new Set([
|
|
272
|
+
'dist', 'out', 'build', '_site', 'public', // 构建产物(site.js 的 OUTPUT_DIRS)
|
|
273
|
+
'_drafts', // 站点试作(各自是独立产物)
|
|
274
|
+
'notes', // 便利贴(单独扫成 note 形态)
|
|
275
|
+
'node_modules',
|
|
276
|
+
// 扁平化之后扫描根变成整个工作区,这两个是基础设施不是收纳文件夹:
|
|
277
|
+
// assets/ 上面已经按 upload / generated / note 三种语义单独扫过一遍,
|
|
278
|
+
// 不挡的话每张图会以两个不同的 path 上墙两次。
|
|
279
|
+
'assets', 'exports',
|
|
280
|
+
]);
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* @param {number} depth 还能往下几层。收纳只做**一层** —— 再深就不是"收纳"
|
|
284
|
+
* 而是目录树了,画布上表达不了,也不是用户要的东西。
|
|
285
|
+
*/
|
|
286
|
+
// 工作区根的 relPrefix 是空串,`${prefix}/${name}` 会拼出 `/canvas.html`
|
|
287
|
+
// 这种带头斜杠的路径 —— 它会一路当成物件 id 传到画布和 artifact-file 路由。
|
|
288
|
+
const joinRel = (prefix, name) => (prefix ? `${prefix}/${name}` : name);
|
|
289
|
+
|
|
290
|
+
const scanDir = async (dir, kind, relPrefix, depth = 0) => {
|
|
291
|
+
let entries;
|
|
292
|
+
try {
|
|
293
|
+
entries = await fs.readdir(dir, { withFileTypes: true });
|
|
294
|
+
} catch (err) {
|
|
295
|
+
if (err.code === 'ENOENT') return;
|
|
296
|
+
throw err;
|
|
297
|
+
}
|
|
298
|
+
for (const e of entries) {
|
|
299
|
+
if (e.isDirectory()) {
|
|
300
|
+
// 收纳文件夹(2026-08-07):agent 用 mkdir + mv 把同主题的东西归到
|
|
301
|
+
// 一起,画布把它显示成一组。它是**真目录**,不是画布上的虚拟分组 ——
|
|
302
|
+
// 「文件系统即真相」这条不能因为加了个分组就破。
|
|
303
|
+
if (depth <= 0) continue;
|
|
304
|
+
if (e.name.startsWith('.') || e.name.startsWith('_')) continue;
|
|
305
|
+
if (NOT_A_FOLDER.has(e.name)) continue;
|
|
306
|
+
await scanDir(path.join(dir, e.name), kind, joinRel(relPrefix, e.name), depth - 1);
|
|
307
|
+
continue;
|
|
308
|
+
}
|
|
309
|
+
if (!e.isFile()) continue;
|
|
310
|
+
if (e.name.startsWith('.')) continue;
|
|
311
|
+
// 兄弟 webp 不单独上墙(否则每张生成图都是重影,两张卡还指同一份 sidecar)。
|
|
312
|
+
// 留 PNG:母版是产物,webp 是显示副本。见 lib/image-variant.js
|
|
313
|
+
if (/\.webp$/i.test(e.name)
|
|
314
|
+
&& entries.some(x => x.isFile() && x.name === e.name.replace(/\.webp$/i, '.png'))) continue;
|
|
315
|
+
// 基础设施不上墙(board.json 是画布自己的布局档、*.template.* 是起手模板)
|
|
316
|
+
if (isReservedFile(e.name)) continue;
|
|
317
|
+
const ext = path.extname(e.name).toLowerCase();
|
|
318
|
+
// agent 正在写的时候文件可能在 readdir 和 stat 之间消失(重写 / 改名)。
|
|
319
|
+
// 原来这里 stat 抛出会一路冒到路由 → 500 → 前端把画布清空。
|
|
320
|
+
// 单个文件读不到就跳过它,不能因此让整份清单失败。
|
|
321
|
+
let stat;
|
|
322
|
+
try {
|
|
323
|
+
stat = await fs.stat(path.join(dir, e.name));
|
|
324
|
+
} catch { continue; }
|
|
325
|
+
const item = {
|
|
326
|
+
kind,
|
|
327
|
+
name: e.name,
|
|
328
|
+
path: joinRel(relPrefix, e.name),
|
|
329
|
+
size: stat.size,
|
|
330
|
+
mtime: stat.mtime.toISOString(),
|
|
331
|
+
ext,
|
|
332
|
+
isImage: IMAGE_EXTS.has(ext),
|
|
333
|
+
isVideo: VIDEO_EXTS.has(ext),
|
|
334
|
+
// 不再探盘:缩略图地址对任何 generated 图都一定能出图(artifact-file
|
|
335
|
+
// 缺文件时回原图现编一张,见 lib/image-variant.js)。原来那次 exists()
|
|
336
|
+
// 探的是 .thumb.jpg,改名成 .thumb.webp 之后它会对老图一律返 false,
|
|
337
|
+
// 让产物墙退回去加载 3MB 原图 —— 正好是这轮要消灭的东西。
|
|
338
|
+
hasThumb: kind === 'generated' && IMAGE_EXTS.has(ext),
|
|
339
|
+
};
|
|
340
|
+
decorateCardKind(item);
|
|
341
|
+
// 语义元数据(generate-image.js 落 .meta/<base>.json sidecar:prompt /
|
|
342
|
+
// assetRole / provider / aspectRatio / sessionId / runId)—— 物件不只是
|
|
343
|
+
// 文件,带着它的来历上墙
|
|
344
|
+
if (kind === 'generated') {
|
|
345
|
+
try {
|
|
346
|
+
const metaRaw = await fs.readFile(
|
|
347
|
+
path.join(dir, '.meta', `${e.name.slice(0, -ext.length)}.json`), 'utf8');
|
|
348
|
+
item.meta = JSON.parse(metaRaw);
|
|
349
|
+
} catch { /* 无 sidecar(旧图)→ 无 meta */ }
|
|
350
|
+
}
|
|
351
|
+
// 便签直接把正文带给前端渲卡片(≤4KB 截断,完整内容走 artifact-file)。
|
|
352
|
+
// frontmatter 里的 session 字段是分区归属(agent 写便签时按约定带上)。
|
|
353
|
+
if (kind === 'note' && ext === '.md') {
|
|
354
|
+
try {
|
|
355
|
+
const raw = await fs.readFile(path.join(dir, e.name), 'utf8');
|
|
356
|
+
const { body, sessionId } = parseNoteFrontmatter(raw);
|
|
357
|
+
item.text = body.length > 4096 ? body.slice(0, 4096) + '…' : body;
|
|
358
|
+
if (sessionId) item.sessionId = sessionId;
|
|
359
|
+
} catch { /* */ }
|
|
360
|
+
}
|
|
361
|
+
artifacts.push(item);
|
|
362
|
+
}
|
|
363
|
+
};
|
|
364
|
+
|
|
365
|
+
await scanDir(assetsDir, 'upload', 'assets');
|
|
366
|
+
await scanDir(path.join(assetsDir, 'generated'), 'generated', 'assets/generated');
|
|
367
|
+
await scanDir(path.join(assetsDir, 'notes'), 'note', 'assets/notes');
|
|
368
|
+
|
|
369
|
+
// ── 项目产物(2026-08-07 扁平化)────────────────────────────────────
|
|
370
|
+
//
|
|
371
|
+
// 产物直接住工作区根,一个项目可以并排放**多个平等产物**:顶层每个 .html
|
|
372
|
+
// 各是一份 deck、根 index.html = 一个站(子页和 style.css 不各自上墙)、
|
|
373
|
+
// 无根站时带 index.html 的子目录各是一个站、_drafts/*.html 各是一个单页。
|
|
374
|
+
// 没有主 / 试作等级。
|
|
375
|
+
//
|
|
376
|
+
// ── 文件夹枚举(2026-08-08)──────────────────────────────────────────
|
|
377
|
+
//
|
|
378
|
+
// 工作区就是一张桌面:产物可以摊在根上,也可以收进文件夹,文件夹还能套
|
|
379
|
+
// 文件夹。所以解析器要**按文件夹跑**,一个文件夹一份 manifest。
|
|
380
|
+
//
|
|
381
|
+
// 字段名仍叫 `tasks`(前端的取数路径不用动),但语义已经是「文件夹」:
|
|
382
|
+
// `id` 是**工作区相对路径**,根用 `''`。所有 id 都是路径,画布上的身份和
|
|
383
|
+
// 磁盘上的位置是同一个字符串 —— 这样 agent `mv` 一个文件之后,git 的改名
|
|
384
|
+
// 检测能直接翻译成画布 id 的改名(见 board-store 的 reconcileBoardRenames)。
|
|
385
|
+
const workspaceRoot = getSharedDir(req.params.pid);
|
|
386
|
+
const tasks = [];
|
|
387
|
+
let hasRootSite = false;
|
|
388
|
+
// 根站认领的一级子目录(pages 顶层段)—— 散文件过滤要用同一口径
|
|
389
|
+
const rootSiteClaims = new Set();
|
|
390
|
+
|
|
391
|
+
/** manifest 里的路径是相对**它那个目录**的,挂到工作区坐标系上要加前缀 */
|
|
392
|
+
const under = (base, p) => (!p ? p : (base ? `${base}/${p}` : p));
|
|
393
|
+
// 文件夹清单跟产物清单分开:**空文件夹也要出现在桌面上**(你刚建的那个
|
|
394
|
+
// 还没往里放东西的文件夹,不该等有了产物才显形)
|
|
395
|
+
const folders = [];
|
|
396
|
+
|
|
397
|
+
const collect = async (dir, rel, depth) => {
|
|
398
|
+
let stat;
|
|
399
|
+
try { stat = await fs.stat(dir); } catch { return; }
|
|
400
|
+
|
|
401
|
+
const manifest = await taskManifest(dir);
|
|
402
|
+
const list = manifest?.artifacts || [];
|
|
403
|
+
if (!rel) {
|
|
404
|
+
const rs = list.find(a => a.kind === KIND_SITE && !a.single && !a.srcRoot);
|
|
405
|
+
hasRootSite = !!rs;
|
|
406
|
+
for (const pg of (rs?.pages || [])) {
|
|
407
|
+
const seg = String(pg).split('/')[0];
|
|
408
|
+
if (seg && seg !== pg) rootSiteClaims.add(seg);
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
if (list.length) {
|
|
413
|
+
tasks.push({
|
|
414
|
+
id: rel,
|
|
415
|
+
title: rel ? rel.split('/').pop() : (project.name || '产物'),
|
|
416
|
+
kind: manifest.kind,
|
|
417
|
+
sessionId: null, // 产物与会话脱钩(2026-08-07)
|
|
418
|
+
mtime: stat.mtime.toISOString(),
|
|
419
|
+
exports: manifest.exportFormats || [],
|
|
420
|
+
artifacts: list.map((a) => ({
|
|
421
|
+
kind: a.kind,
|
|
422
|
+
view: a.view,
|
|
423
|
+
single: !!a.single,
|
|
424
|
+
file: under(rel, a.file), // deck / 单页:html 文件(相对工作区根)
|
|
425
|
+
root: under(rel, a.root) || rel,
|
|
426
|
+
srcRoot: under(rel, a.srcRoot), // 站点源目录;root≠srcRoot = 构建型
|
|
427
|
+
// entry 是**相对 base 的**,base + '/' + entry 必须永远拼得出真实路径。
|
|
428
|
+
// 单页站点以前 base 给空、entry 给全路径,前端拿不到 base 就回退成
|
|
429
|
+
// 文件夹名,拼出 `rin/rin/_drafts/…` 这种双前缀(实测 404)。
|
|
430
|
+
// 现在单页也给 base(= 入口文件所在目录),两种站点一个拼法。
|
|
431
|
+
entry: a.single ? path.basename(a.entryRel || '') : a.entry,
|
|
432
|
+
entryRel: under(rel, a.entryRel),
|
|
433
|
+
// word 的 token 源与成员表。⚠️ 都过 under():manifest 给任务相对路径,前端要工作区相对,透传 404
|
|
434
|
+
...(a.sourceFile ? { sourceFile: under(rel, a.sourceFile) } : {}),
|
|
435
|
+
...(a.members ? { members: a.members.map(m => ({ ...m, file: under(rel, m.file), sourceFile: m.sourceFile ? under(rel, m.sourceFile) : null })) } : {}),
|
|
436
|
+
base: a.single
|
|
437
|
+
? under(rel, path.dirname(a.entryRel || '.')).replace(/^\.$/, rel)
|
|
438
|
+
: (under(rel, a.root) || rel),
|
|
439
|
+
pages: a.pages, // 站点内部路径,相对站根,不加前缀
|
|
440
|
+
title: a.title,
|
|
441
|
+
exports: a.exportFormats,
|
|
442
|
+
// 其余形态没有这个字段,前端按 kind 分支取用。
|
|
443
|
+
nodes: a.nodes,
|
|
444
|
+
truncated: a.truncated, // 撞深度上限被截断的目录,要让人看见
|
|
445
|
+
})),
|
|
446
|
+
});
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
if (depth >= FOLDER_MAX_DEPTH) return;
|
|
450
|
+
|
|
451
|
+
// 这个目录里,哪些子目录**已经被上面那份 manifest 认领**了。
|
|
452
|
+
//
|
|
453
|
+
// 一个站点目录既能被父目录扫成一件产物(`site:伊蕾娜手账研究站`),又能被
|
|
454
|
+
// 当成一个文件夹递归进去 —— 不去重的话它在桌面上出现两次:一张站点卡 +
|
|
455
|
+
// 一张同名文件夹卡,点哪个都对一半。认领了就跳过:**它是产物,不是容器**,
|
|
456
|
+
// 里面的 `assets/` `pages/` 是这个站的内部结构,不是并列的文件夹。
|
|
457
|
+
const claimed = new Set();
|
|
458
|
+
for (const a of list) {
|
|
459
|
+
for (const p of [a.root, a.srcRoot, a.file, a.entryRel]) {
|
|
460
|
+
const seg = String(p || '').split('/')[0];
|
|
461
|
+
if (seg && seg !== p) claimed.add(seg); // 只有带下级路径的才算认领
|
|
462
|
+
else if (seg && isDirArtifact(a)) claimed.add(seg); // 顶层段整段认领:只有目录型产物有资格(判据问注册表)
|
|
463
|
+
}
|
|
464
|
+
// 根站(root='' srcRoot=''):上面四个字段全切不出认领段,可它的 pages
|
|
465
|
+
// 跨着子目录('posts/chapter-1.html')。那些子目录是站点内部结构,不是
|
|
466
|
+
// 并列容器 —— 不认领的话它们会被递归成独立任务、页面被 deck 解析器
|
|
467
|
+
// 再收编一遍,同一份文件在桌面上出现两个身份(站点页 + deck 卡),
|
|
468
|
+
// 而且卡还打不开(实测 proj_mss59y9l_8ems,2026-08-14)。
|
|
469
|
+
// 只在根站场景做:非根站的 root 目录本身已被认领,内部结构扫不到。
|
|
470
|
+
if (a.kind === KIND_SITE && !a.single && !a.root && !a.srcRoot) {
|
|
471
|
+
for (const pg of (a.pages || [])) {
|
|
472
|
+
const seg = String(pg).split('/')[0];
|
|
473
|
+
if (seg && seg !== pg) claimed.add(seg);
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
let entries = [];
|
|
479
|
+
try { entries = await fs.readdir(dir, { withFileTypes: true }); } catch { return; }
|
|
480
|
+
for (const e of entries) {
|
|
481
|
+
if (!e.isDirectory() || e.name.startsWith('.')) continue;
|
|
482
|
+
if (RESERVED_DIRS.has(e.name) || HARD_IGNORE_DIRS.has(e.name)) continue;
|
|
483
|
+
if (e.name === DRAFTS_DIR) continue; // 站点试作,由 site 解析器管
|
|
484
|
+
if (claimed.has(e.name)) continue; // 已经是一件产物了
|
|
485
|
+
folders.push(under(rel, e.name));
|
|
486
|
+
await collect(path.join(dir, e.name), under(rel, e.name), depth + 1);
|
|
487
|
+
}
|
|
488
|
+
};
|
|
489
|
+
|
|
490
|
+
try {
|
|
491
|
+
await collect(workspaceRoot, '', 0);
|
|
492
|
+
// 散文件(agent 写的 .md、数据文件、脚本…):**每个目录只平铺自己那一层**。
|
|
493
|
+
//
|
|
494
|
+
// `folders` 已经是递归全量(含嵌套),所以根扫一层 + 每个文件夹各扫一层
|
|
495
|
+
// 正好覆盖一遍。之前根这里传的是 depth=1(自己递归下去一层),跟后面
|
|
496
|
+
// 那个 folders 循环重叠 —— 文件夹里的每个文件都被吐两遍,实测一个项目
|
|
497
|
+
// 24 条产物里 6 条重复,画布上就是一张卡叠着另一张同名卡。
|
|
498
|
+
await scanDir(workspaceRoot, 'task-file', '', 0);
|
|
499
|
+
await scanDir(path.join(workspaceRoot, 'notes'), 'note', 'notes');
|
|
500
|
+
for (const rel of folders) {
|
|
501
|
+
await scanDir(path.join(workspaceRoot, rel), 'task-file', rel, 0);
|
|
502
|
+
}
|
|
503
|
+
} catch (err) {
|
|
504
|
+
if (err.code !== 'ENOENT') throw err;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
// .html 不当普通文件卡上墙(deck / 站点物件已经代表它们了)。
|
|
508
|
+
//
|
|
509
|
+
// ⚠️ 这里曾写 `if (hasRootSite) return false` —— 根站一存在,**全工作区**
|
|
510
|
+
// 任何文件夹里的散文件一律隐形。用户把便签搬进文件夹,卡当场凭空消失
|
|
511
|
+
// (文件在磁盘上完好,2026-08-14 实案)。根站只吞**自己的地盘**:根层
|
|
512
|
+
// 散文件(.md 除外 —— 那是阅读卡)+ 认领子目录(pages 顶层段)里的文件;
|
|
513
|
+
// 别的文件夹照常上墙。口径与前端 resolveObjectId / exports 的根站规则一致。
|
|
514
|
+
// word 认领的文件(.docx + token 源)不当散文件卡,同 .html 被产物卡代表一条规则(判据在 kinds/index.js)
|
|
515
|
+
const docxClaims = docxClaimedFiles(tasks);
|
|
516
|
+
const filtered = artifacts.filter((a) => {
|
|
517
|
+
if (a.kind !== 'task-file') return true;
|
|
518
|
+
if (docxClaims.has(String(a.path))) return false;
|
|
519
|
+
if (hasRootSite) {
|
|
520
|
+
const p = String(a.path);
|
|
521
|
+
const slash = p.indexOf('/');
|
|
522
|
+
const swallowed = slash < 0
|
|
523
|
+
? !p.toLowerCase().endsWith('.md')
|
|
524
|
+
: rootSiteClaims.has(p.slice(0, slash));
|
|
525
|
+
if (swallowed) return false;
|
|
526
|
+
}
|
|
527
|
+
return !a.name.toLowerCase().endsWith('.html');
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
// 剪掉画布上没有对应目录的文件夹 —— **但要等它连着两次都不在**。
|
|
531
|
+
//
|
|
532
|
+
// 跟物件不一样,文件夹有权威清单(`folders` 就是刚扫出来的磁盘真相),所以
|
|
533
|
+
// 剪是可以剪的。物件那边不行:board.objects 是稀疏的,"不在 board 里"是常态。
|
|
534
|
+
//
|
|
535
|
+
// 但立刻剪是错的,而且是**破坏性**的:这个接口在一轮对话中途会被反复调用
|
|
536
|
+
// (每次 listVersion 跳动),而 agent 的 `mv 稿件 定稿` 要到 turn 结束才进
|
|
537
|
+
// git。中间那次扫描一看「稿件」没了就删,而 patchBoard 的端点级联会把连着
|
|
538
|
+
// 这个文件夹的关系线一起删掉 —— 等 turn 结束、对账把里面的物件改好名时,
|
|
539
|
+
// 文件夹的位置、标题、连线已经没了,找不回来。
|
|
540
|
+
//
|
|
541
|
+
// 两道闸:① 正在改名窗口里的(转发表里有)一律不碰 ② 连续两次扫描都不在
|
|
542
|
+
// 才算真没了。改名、临时 mv、agent 写到一半,都活不过第二次判定。
|
|
543
|
+
const live = new Set(folders);
|
|
544
|
+
const board = await readBoard(req.params.pid);
|
|
545
|
+
const suspects = Object.keys(board.zones || {}).filter(z => !live.has(z));
|
|
546
|
+
const deadZones = confirmDeadZones(req.params.pid, suspects, live);
|
|
547
|
+
if (deadZones.length) {
|
|
548
|
+
await patchBoard(req.params.pid, {
|
|
549
|
+
zones: Object.fromEntries(deadZones.map(z => [z, null])),
|
|
550
|
+
});
|
|
551
|
+
console.log(`[board] ${req.params.pid} 清掉 ${deadZones.length} 个没有目录撑着的文件夹: ${deadZones.slice(0, 3).join(', ')}`);
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
filtered.sort((a, b) => (a.mtime < b.mtime ? 1 : -1));
|
|
555
|
+
// 自动落线对账(2026-08-14):manifests 正好在手,顺手把「html 真实引用
|
|
556
|
+
// 了哪些素材」落成 by:'auto' 的 ref 边。fire-and-forget + 30s 节流,
|
|
557
|
+
// 绝不拖累清单接口。详见 lib/auto-relations.js 头注。
|
|
558
|
+
reconcileAutoRefsThrottled(req.params.pid, workspaceRoot, tasks);
|
|
559
|
+
res.json({ artifacts: filtered, tasks, folders });
|
|
560
|
+
} catch (err) { next(err); }
|
|
561
|
+
});
|
|
562
|
+
|
|
563
|
+
/**
|
|
564
|
+
* GET /:pid/cover — 项目封面 webp(首页卡片缩略图)
|
|
565
|
+
*
|
|
566
|
+
* 服务端截最新产物的图(见 lib/cover.js 里为什么不是 iframe)。缓存按源 mtime,
|
|
567
|
+
* 命中就是读盘;没命中要起一次 chromium,串行排队,冷启动 1-3s。
|
|
568
|
+
* 没产物 / 截图环境不可用 → 204,前端画占位框(不是错误,别报 500)。
|
|
569
|
+
*/
|
|
570
|
+
// .docx 页图(画布缩略图 + 产物窗翻页共用一份缓存)。实现在 assets/docx-page.js ——
|
|
571
|
+
router.get('/:pid/docx-page', makeDocxPageHandler({ getSharedDir, guardProject }));
|
|
572
|
+
router.get('/:pid/docx-pdf', makeDocxPdfHandler({ getSharedDir, guardProject })); // 整份 PDF,同一份缓存
|
|
573
|
+
|
|
574
|
+
router.get('/:pid/cover', async (req, res, next) => {
|
|
575
|
+
try {
|
|
576
|
+
if (!guardProject(req, res)) return;
|
|
577
|
+
let result;
|
|
578
|
+
try {
|
|
579
|
+
result = await getProjectCover(req.params.pid, getSharedDir(req.params.pid));
|
|
580
|
+
} catch (err) {
|
|
581
|
+
console.warn('[cover] render failed:', err.message);
|
|
582
|
+
return res.status(204).end();
|
|
583
|
+
}
|
|
584
|
+
if (!result) return res.status(204).end();
|
|
585
|
+
if (req.headers['if-none-match'] === `"${result.etag}"`) return res.status(304).end();
|
|
586
|
+
res.set('ETag', `"${result.etag}"`);
|
|
587
|
+
res.set('Cache-Control', 'private, max-age=60');
|
|
588
|
+
res.type('image/webp').send(result.buffer);
|
|
589
|
+
} catch (err) { next(err); }
|
|
590
|
+
});
|
|
591
|
+
|
|
592
|
+
|
|
593
|
+
/**
|
|
594
|
+
* GET /:pid/artifact-file/*subPath — project 级文件服务(shared/assets 子树)。
|
|
595
|
+
* 工作台缩略图 / 大图用,不依赖 session(canvas.js 的同款路由是 session 级的)。
|
|
596
|
+
* 防 traversal 同 canvas.js:resolve 后必须留在 shared/assets 下。
|
|
597
|
+
*/
|
|
598
|
+
/**
|
|
599
|
+
* DELETE /:pid/folders/*subPath —— 删一个文件夹(连同里面的一切)。
|
|
600
|
+
*
|
|
601
|
+
* 取代旧的 `DELETE /:pid/tasks/:name`。那条做三件事:删任务目录、**连带删掉
|
|
602
|
+
* 绑定的那次对话**、清 board.json 里的 zone 行。中间那件随「任务=会话」一起
|
|
603
|
+
* 废了 —— 会话现在归项目,跟任何文件夹都没有绑定关系,删文件夹不该动对话。
|
|
604
|
+
*
|
|
605
|
+
* 路径按**工作区相对路径**收(`稿件/初稿`),因为文件夹可以嵌套。
|
|
606
|
+
*/
|
|
607
|
+
/**
|
|
608
|
+
* POST /:pid/folders —— 新建文件夹(`{ parent?: '稿件', name?: '初稿' }`)。
|
|
609
|
+
*
|
|
610
|
+
* 在这之前**用户建不了文件夹**:全仓没有任何接口或按钮,只有 agent `mkdir`
|
|
611
|
+
* 会让画布上多出一个。也就是说这套收纳体系对用户是只读的 —— 能收起、能搬走、
|
|
612
|
+
* 能删掉,就是不能建。桌面隐喻里这说不通。
|
|
613
|
+
*
|
|
614
|
+
* 重名自动加序号(「新建文件夹 2」),跟操作系统一样 —— 报错让人再想个名字
|
|
615
|
+
* 是没必要的摩擦,这里没有任何东西会被覆盖。
|
|
616
|
+
*/
|
|
617
|
+
router.post('/:pid/folders', express.json(), async (req, res, next) => {
|
|
618
|
+
try {
|
|
619
|
+
if (!guardProject(req, res)) return;
|
|
620
|
+
const root = getSharedDir(req.params.pid);
|
|
621
|
+
const parent = String(req.body?.parent ?? '').replace(/\\/g, '/').replace(/^\/+|\/+$/g, '');
|
|
622
|
+
const wanted = sanitizeFilename(req.body?.name || '新建文件夹').replace(/\.+$/, '') || '新建文件夹';
|
|
623
|
+
|
|
624
|
+
const parentAbs = parent ? path.resolve(root, parent) : root;
|
|
625
|
+
if (parentAbs !== root && !parentAbs.startsWith(root + path.sep)) {
|
|
626
|
+
return res.status(400).json({ error: 'path escapes workspace' });
|
|
627
|
+
}
|
|
628
|
+
if (parent && (RESERVED_DIRS.has(parent.split('/')[0]) || parent.startsWith('.'))) {
|
|
629
|
+
return res.status(400).json({ error: '不能在这个目录里新建' });
|
|
630
|
+
}
|
|
631
|
+
if (!(await fs.stat(parentAbs).catch(() => null))?.isDirectory()) {
|
|
632
|
+
return res.status(404).json({ error: 'parent folder not found' });
|
|
633
|
+
}
|
|
634
|
+
if (parent.split('/').filter(Boolean).length >= FOLDER_MAX_DEPTH) {
|
|
635
|
+
return res.status(400).json({ error: `文件夹最多套 ${FOLDER_MAX_DEPTH} 层` });
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
let name = wanted;
|
|
639
|
+
for (let n = 2; await exists(path.join(parentAbs, name)); n += 1) name = `${wanted} ${n}`;
|
|
640
|
+
await fs.mkdir(path.join(parentAbs, name));
|
|
641
|
+
res.status(201).json({ ok: true, folder: parent ? `${parent}/${name}` : name });
|
|
642
|
+
} catch (err) { next(err); }
|
|
643
|
+
});
|
|
644
|
+
|
|
645
|
+
/**
|
|
646
|
+
* POST /:pid/move —— 把一个东西搬到另一个文件夹里(**真的动磁盘**)。
|
|
647
|
+
*
|
|
648
|
+
* body: `{ from: '稿件/主稿.html', to: '定稿' }`(to='' = 搬到工作区根)
|
|
649
|
+
*
|
|
650
|
+
* 这是「拖进文件夹 = 真 mv」那条交互的落点。语义按操作系统桌面走:画布上
|
|
651
|
+
* 在哪,磁盘上就在哪,永远一致。代价是**移动 = 换身份**(id 就是路径),
|
|
652
|
+
* 所以这一步的顺序不能错:
|
|
653
|
+
*
|
|
654
|
+
* ① fs.rename 先动磁盘。失败就整个失败,画布一个字节不改
|
|
655
|
+
* ② renameBoardPaths 同一个请求内改画布身份(物件 / 文件夹 / 归属字段 /
|
|
656
|
+
* 关系线端点),顺带在转发表里记一笔
|
|
657
|
+
* ③ 带着新 board 响应 前端拿到就重写 layoutRef —— 不给的话它手上还是旧 id,
|
|
658
|
+
* 800ms 后那一发防抖 flush 会把旧条目写回来
|
|
659
|
+
* ④ 同步 commit 对账器稍后会看到这次改名并重放一遍;因为 ② 已经做完,
|
|
660
|
+
* 重放是个空操作(幂等)。不 commit 的话它下次算 diff
|
|
661
|
+
* 时才看到,中间任何一次扫描都可能把旧文件夹当死的剪掉
|
|
662
|
+
*/
|
|
663
|
+
router.post('/:pid/move', express.json(), async (req, res, next) => {
|
|
664
|
+
try {
|
|
665
|
+
if (!guardProject(req, res)) return;
|
|
666
|
+
// 语义(NO_MOVE_OUT 白名单史 / 产物目录不透明规则 / ①②③④ 顺序)全部住在
|
|
667
|
+
// moveEntry —— 2026-08-14 抽成唯一实现,agent 的 organize_board 共用同一份
|
|
668
|
+
const out = await moveEntry(req.params.pid, req.body?.from, req.body?.to);
|
|
669
|
+
res.json(out); // ③
|
|
670
|
+
if (out.moved) {
|
|
671
|
+
// ④ 响应之后再 commit:它只服务于稍后的对账,不该让用户多等一次 git add
|
|
672
|
+
commitWorkspace(req.params.pid, null, `move: ${out.from} → ${out.to}`, { author: 'user' })
|
|
673
|
+
.catch(err => console.warn('[git] move commit failed:', err.message));
|
|
674
|
+
}
|
|
675
|
+
} catch (err) {
|
|
676
|
+
if (err instanceof MoveError) return res.status(err.status).json({ error: err.message });
|
|
677
|
+
next(err);
|
|
678
|
+
}
|
|
679
|
+
});
|
|
680
|
+
|
|
681
|
+
/**
|
|
682
|
+
* POST /:pid/rename —— 给一个东西改名(**真的动磁盘**,位置不变只换最后一段)。
|
|
683
|
+
*
|
|
684
|
+
* body: `{ from: '稿件/主稿.html', name: '定稿' }`
|
|
685
|
+
*
|
|
686
|
+
* 跟 `/move` 是一对:move 换爹,rename 换名字。在这之前**画布上没有任何改名
|
|
687
|
+
* 入口** —— 三层传播的机器(renameBoardPaths / git 对账 / 转发表)08-08 就造好
|
|
688
|
+
* 了,缺的只是这扇门,于是文件夹只能叫「新建文件夹」,或者让 agent 去 mv。
|
|
689
|
+
*
|
|
690
|
+
* 扩展名归系统管:改 `主稿.html` 时用户输入的是「定稿」,我们补回 `.html`。
|
|
691
|
+
* 让用户自己带扩展名的话,删掉它就等于把一份 deck 变成一个普通文件。
|
|
692
|
+
*/
|
|
693
|
+
router.post('/:pid/rename', express.json(), async (req, res, next) => {
|
|
694
|
+
try {
|
|
695
|
+
if (!guardProject(req, res)) return;
|
|
696
|
+
const root = getSharedDir(req.params.pid);
|
|
697
|
+
const from = String(req.body?.from ?? '').replace(/\\/g, '/').replace(/^\/+|\/+$/g, '');
|
|
698
|
+
if (!from) return res.status(400).json({ error: 'from required' });
|
|
699
|
+
|
|
700
|
+
const absFrom = path.resolve(root, from);
|
|
701
|
+
if (!absFrom.startsWith(root + path.sep)) {
|
|
702
|
+
return res.status(400).json({ error: 'path escapes workspace' });
|
|
703
|
+
}
|
|
704
|
+
const seg0 = from.split('/')[0];
|
|
705
|
+
if (RESERVED_DIRS.has(seg0) || seg0.startsWith('.')) {
|
|
706
|
+
return res.status(400).json({ error: '这个位置的东西不参与改名' });
|
|
707
|
+
}
|
|
708
|
+
const st = await fs.stat(absFrom).catch(() => null);
|
|
709
|
+
if (!st) return res.status(404).json({ error: 'source not found' });
|
|
710
|
+
|
|
711
|
+
const oldBase = path.basename(from);
|
|
712
|
+
const ext = st.isDirectory() ? '' : (path.extname(oldBase) || '');
|
|
713
|
+
// 用户输入里带的扩展名剥掉,最后统一补回原来那个
|
|
714
|
+
const wanted = sanitizeFilename(String(req.body?.name ?? ''))
|
|
715
|
+
.replace(new RegExp(`${ext.replace('.', '\\.')}$`, 'i'), '')
|
|
716
|
+
.replace(/\.+$/, '')
|
|
717
|
+
.trim();
|
|
718
|
+
if (!wanted) return res.status(400).json({ error: '名字不能为空' });
|
|
719
|
+
|
|
720
|
+
const parent = from.includes('/') ? from.slice(0, from.lastIndexOf('/')) : '';
|
|
721
|
+
const nextRel = (parent ? `${parent}/` : '') + wanted + ext;
|
|
722
|
+
if (nextRel === from) return res.json({ ok: true, from, to: from, renamed: false });
|
|
723
|
+
if (await exists(path.resolve(root, nextRel))) {
|
|
724
|
+
return res.status(409).json({ error: `「${wanted}${ext}」已经有一个了` });
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
// 顺序同 /move:先动磁盘,再改画布身份(物件 / 文件夹 / 归属 / 关系线端点)
|
|
728
|
+
await fs.rename(absFrom, path.resolve(root, nextRel));
|
|
729
|
+
const { board } = await renameBoardPaths(req.params.pid, [[from, nextRel]]);
|
|
730
|
+
res.json({ ok: true, from, to: nextRel, renamed: true, board });
|
|
731
|
+
commitWorkspace(req.params.pid, null, `rename: ${from} → ${nextRel}`, { author: 'user' })
|
|
732
|
+
.catch(err => console.warn('[git] rename commit failed:', err.message));
|
|
733
|
+
} catch (err) { next(err); }
|
|
734
|
+
});
|
|
735
|
+
|
|
736
|
+
router.delete('/:pid/folders/*subPath', async (req, res, next) => {
|
|
737
|
+
try {
|
|
738
|
+
if (!guardProject(req, res)) return;
|
|
739
|
+
const raw = req.params.subPath;
|
|
740
|
+
const rel = (Array.isArray(raw) ? raw.join('/') : (raw || '')).replace(/\/+$/, '');
|
|
741
|
+
if (!rel) return res.status(400).json({ error: 'folder path required' });
|
|
742
|
+
|
|
743
|
+
const root = getSharedDir(req.params.pid);
|
|
744
|
+
const dir = path.resolve(root, rel);
|
|
745
|
+
// 防越界 + 防把工作区自己删了;保留目录一概不许删(.claude 里是项目指引和
|
|
746
|
+
// 记忆,.nd 是各次对话的暗档案,.git 是历史 —— 都不是"用户的文件夹")
|
|
747
|
+
if (dir !== path.join(root, rel) || !dir.startsWith(root + path.sep)) {
|
|
748
|
+
return res.status(400).json({ error: 'invalid path' });
|
|
749
|
+
}
|
|
750
|
+
if (RESERVED_DIRS.has(rel.split('/')[0])) {
|
|
751
|
+
return res.status(400).json({ error: 'reserved directory' });
|
|
752
|
+
}
|
|
753
|
+
const st = await fs.stat(dir).catch(() => null);
|
|
754
|
+
if (!st?.isDirectory()) return res.status(404).json({ error: 'folder not found' });
|
|
755
|
+
|
|
756
|
+
await fs.rm(dir, { recursive: true, force: true });
|
|
757
|
+
|
|
758
|
+
// board.json 跟着剪:这个文件夹自己的那行,以及住在它里面的全部物件。
|
|
759
|
+
// 不剪的话磁盘上没了、画布上还在,就是 2026-07-30 那批「删不掉的僵尸
|
|
760
|
+
// 文件夹」的来源 —— 删除必须是一个动作,不能指望前端补第二刀。
|
|
761
|
+
const board = await readBoard(req.params.pid);
|
|
762
|
+
const patch = { zones: { [rel]: null }, objects: {} };
|
|
763
|
+
const under = `${rel}/`;
|
|
764
|
+
for (const id of Object.keys(board?.objects || {})) {
|
|
765
|
+
const p = id.includes(':') ? id.slice(id.indexOf(':') + 1) : id;
|
|
766
|
+
if (p === rel || p.startsWith(under)) patch.objects[id] = null;
|
|
767
|
+
}
|
|
768
|
+
for (const zid of Object.keys(board?.zones || {})) {
|
|
769
|
+
if (zid.startsWith(under)) patch.zones[zid] = null; // 嵌套在里面的子文件夹
|
|
770
|
+
}
|
|
771
|
+
await patchBoard(req.params.pid, patch);
|
|
772
|
+
|
|
773
|
+
res.json({ ok: true, removed: rel, objects: Object.keys(patch.objects).length });
|
|
774
|
+
} catch (err) { next(err); }
|
|
775
|
+
});
|
|
776
|
+
|
|
777
|
+
router.get('/:pid/artifact-file/*subPath', async (req, res, next) => {
|
|
778
|
+
try {
|
|
779
|
+
// ⚠️ Cache-Control 必须先设、且错误路径也要带(2026-07-29 SPiCa 裸奔事故):
|
|
780
|
+
// Cloudflare 对 .css/.js/.png 等扩展名按后缀边缘缓存,源站 `no-cache` 会被
|
|
781
|
+
// 改写成浏览器 max-age=14400(4 小时),**404 响应同样被缓存 4 小时** ——
|
|
782
|
+
// agent 先写 index.html 后写 style.css 的间隙里用户加载一次,浏览器就把
|
|
783
|
+
// "css 404" 缓存 4 小时,之后怎么刷新都裸奔。实测 `no-store` / `private`
|
|
784
|
+
// 会让 CF 判为 DYNAMIC 原样透传(同路由的 .html 就是这么幸免的)。
|
|
785
|
+
// 所以:默认 no-store 兜底一切错误路径,成功路径按类型再覆盖。
|
|
786
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
787
|
+
if (!guardProject(req, res)) return;
|
|
788
|
+
|
|
789
|
+
// 路径判据(`tasks/` 兼容、越界、点目录白名单)抽在 lib/artifact-file-path.js ——
|
|
790
|
+
// 那是安全判据,住在路由里没法单测,而且它有一条错在那儿修了(见文件头)
|
|
791
|
+
const resolved = await resolveArtifactFile(getSharedDir(req.params.pid), req.params.subPath);
|
|
792
|
+
if (!resolved.ok) return res.status(resolved.status).json({ error: resolved.error });
|
|
793
|
+
const { sharedRoot, absPath, subPath } = resolved;
|
|
794
|
+
|
|
795
|
+
let stat;
|
|
796
|
+
let servePath = absPath;
|
|
797
|
+
// 缩略图地址缺文件时回原图现编一张(老图 / 生成失败 / 07-31 前的 .thumb.jpg)。
|
|
798
|
+
// 产物墙的缩略图走的就是这条路由,跟 canvas 那条 session assets 路由同一份兜底。
|
|
799
|
+
let servedOriginalForThumb = false;
|
|
800
|
+
try {
|
|
801
|
+
stat = await fs.stat(servePath);
|
|
802
|
+
} catch (err) {
|
|
803
|
+
if (err.code !== 'ENOENT') throw err;
|
|
804
|
+
// ① 改名转发(#4):开着的窗拿的还是旧路径 —— 重命名、拖进文件夹、
|
|
805
|
+
// agent mv 之后,产物窗/图片卡的 URL 都指着旧地址。转发表(board-store)
|
|
806
|
+
// 记着最近几分钟的改名,按最长前缀换掉重试一次。安全检查跟主路径同款:
|
|
807
|
+
// 转发目标同样不许越界、不许摸基础设施目录。
|
|
808
|
+
const fwd = forwardPath(req.params.pid, subPath);
|
|
809
|
+
if (fwd !== subPath) {
|
|
810
|
+
const absFwd = path.resolve(sharedRoot, fwd);
|
|
811
|
+
// 判据跟主路径**同一份**(lib/artifact-file-path.js)—— 这儿原来自己抄了一遍
|
|
812
|
+
// 越界检查和 DOT_OK 白名单,抄第二遍就是等着哪天改一处漏一处
|
|
813
|
+
if (isServablePath(sharedRoot, absFwd)) {
|
|
814
|
+
try { stat = await fs.stat(absFwd); servePath = absFwd; } catch { /* ② 继续走缩略图兜底 */ }
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
// ② 缩略图兜底
|
|
818
|
+
if (!stat) {
|
|
819
|
+
const original = isThumbPath(servePath) ? await findOriginalForThumbnail(servePath) : null;
|
|
820
|
+
if (!original) return res.status(404).json({ error: 'file not found' });
|
|
821
|
+
servePath = original;
|
|
822
|
+
stat = await fs.stat(original);
|
|
823
|
+
servedOriginalForThumb = true;
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
// 目录 → 找 index.html(站点常见的 `href="about/"` 写法;deck 场景用不到但无害)
|
|
827
|
+
if (stat.isDirectory()) {
|
|
828
|
+
const indexPath = path.join(servePath, ENTRY_FILE[KIND_SITE]);
|
|
829
|
+
try {
|
|
830
|
+
const s = await fs.stat(indexPath);
|
|
831
|
+
if (s.isFile()) { servePath = indexPath; stat = s; }
|
|
832
|
+
} catch { /* 没有 index.html 就按下面的 not a file 处理 */ }
|
|
833
|
+
}
|
|
834
|
+
if (!stat.isFile()) return res.status(400).json({ error: 'not a file' });
|
|
835
|
+
|
|
836
|
+
const ext = path.extname(servePath).toLowerCase();
|
|
837
|
+
// 站点在编辑中要看到最新的那一份:html/css/js 一律 no-store —— no-cache 不够,
|
|
838
|
+
// CF 会对这些扩展名边缘缓存 + 把浏览器 TTL 改写成 4 小时(见路由入口注释)。
|
|
839
|
+
// 图片按 URL 有没有版本标记决定能缓多久(见 imageCacheControl)。
|
|
840
|
+
// `?nd=raw` = 原样发,不走显示改写层(srcset/sizes/lazy 注入 + 派生图)。
|
|
841
|
+
// 感知通道带它 —— 为什么必须带见 mcp/tools/helpers/perception-page.js 的
|
|
842
|
+
// artifactFileUrl 注释(注入的 sizes 会把横向溢出藏起来,而发布出去的站点没有
|
|
843
|
+
// 这层注入,agent 的眼睛该看交付物)。用户预览照旧走改写层。
|
|
844
|
+
// `?nd=raw` 给主文档;`X-ND-Raw` 头给它的**子资源**(img/css/js 的请求上没有
|
|
845
|
+
// query 可挂,感知 context 用 extraHTTPHeaders 一次性盖住整页)。
|
|
846
|
+
const rawRequested = req.query?.nd === 'raw' || req.headers['x-nd-raw'] === '1';
|
|
847
|
+
|
|
848
|
+
const editable = ext === '.html' || ext === '.htm' || ext === '.css' || ext === '.js';
|
|
849
|
+
res.setHeader('Cache-Control', editable ? 'no-store' : imageCacheControl(req));
|
|
850
|
+
|
|
851
|
+
// 这条路由是站点窗的图片入口:站点页面里的 <img src="assets/x.png"> 全打这儿。
|
|
852
|
+
// deck 那条 thumbnail 重写只作用于 GET /canvas,站点从来没享受过,于是一页
|
|
853
|
+
// 三张生图就是 5MB 起。显示一律发派生图(原图只留给导出)后降 ~90%。
|
|
854
|
+
// 尺寸由 ?w= 决定(srcset 注入产生),不传就是原尺寸:站点按真实设备宽取景,
|
|
855
|
+
// 服务端自作主张缩会让桌面糊。
|
|
856
|
+
if (IMAGE_EXTS.has(ext) && !rawRequested) {
|
|
857
|
+
return sendImage(req, res, servePath, stat, {
|
|
858
|
+
fallbackMime: ARTIFACT_MIME[ext] || 'application/octet-stream',
|
|
859
|
+
maxDim: servedOriginalForThumb ? THUMBNAIL_MAX_DIM : null,
|
|
860
|
+
quality: servedOriginalForThumb ? THUMBNAIL_QUALITY : undefined,
|
|
861
|
+
});
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
// 视频:Range + 派生档。以前这条路由对视频是整个文件一次性 res.end,
|
|
865
|
+
// 没有 206 浏览器拖不动进度条(见 lib/video-variant.js)。
|
|
866
|
+
if (isVideo(ext)) {
|
|
867
|
+
return sendVideo(req, res, servePath, stat, {
|
|
868
|
+
fallbackMime: ARTIFACT_MIME[ext] || 'application/octet-stream',
|
|
869
|
+
});
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
// 站点页面:注入 srcset 让浏览器按视口挑尺寸。只加属性不动 DOM 结构,
|
|
873
|
+
// 理由见 lib/html-srcset.js(<picture> 会改盒模型,站点布局是 agent 写的)。
|
|
874
|
+
if ((ext === '.html' || ext === '.htm') && !rawRequested) {
|
|
875
|
+
let html = await fs.readFile(servePath, 'utf8');
|
|
876
|
+
try {
|
|
877
|
+
html = await injectSrcset(html, path.dirname(servePath), sharedRoot);
|
|
878
|
+
} catch (err) {
|
|
879
|
+
console.warn('[artifact-file] srcset inject failed:', err.message);
|
|
880
|
+
}
|
|
881
|
+
const body = Buffer.from(html, 'utf8');
|
|
882
|
+
res.setHeader('Content-Type', ARTIFACT_MIME[ext]);
|
|
883
|
+
res.setHeader('Content-Length', body.length);
|
|
884
|
+
return res.end(body);
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
res.setHeader('Content-Type', ARTIFACT_MIME[ext] || 'application/octet-stream');
|
|
888
|
+
res.setHeader('Content-Length', stat.size);
|
|
889
|
+
res.end(await fs.readFile(servePath));
|
|
890
|
+
} catch (err) { next(err); }
|
|
891
|
+
});
|
|
892
|
+
|
|
893
|
+
// 便签增删改(assets/notes.js)——自成一体,不跟这里其余路由共享状态
|
|
894
|
+
mountNotesRoutes({ router, guardProject, getSharedDir, ensureProjectWorkspace, sanitizeFilename });
|
|
895
|
+
|
|
896
|
+
export default router;
|