@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,266 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* mcp/tools/roll-film.js — roll_film MCP tool(2026-08-08;批量制同晚补上)
|
|
3
|
+
*
|
|
4
|
+
* 自部署 MiniMax-H3 视频产线。一次调用 = 一批镜头(1-16 条),全批共用一个
|
|
5
|
+
* seed(成片纪律);盒上串行渲,每出一镜当场落盘上墙。后端两档:
|
|
6
|
+
* box(默认) 站主 5090 盒子(h3box.py video over SSH),模型常驻零冷启动。
|
|
7
|
+
* modal Modal H100+sage 备用档,NODESIGN_FILM_BACKEND=modal 显式才走。
|
|
8
|
+
* 配方恒定:Turbo 8 步 / 1344×768 / 单镜 ≤12.25s。批准制:admin+获批账号。
|
|
9
|
+
* 只回文本路径,不回 image block;agent 可以自己抽帧看(2026-08-18 解禁),
|
|
10
|
+
* 但"这条好不好"归用户判。
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import os from 'node:os';
|
|
14
|
+
import path from 'node:path';
|
|
15
|
+
import fs from 'node:fs/promises';
|
|
16
|
+
import { spawn } from 'node:child_process';
|
|
17
|
+
import { tool } from '@anthropic-ai/claude-agent-sdk';
|
|
18
|
+
import { z } from 'zod';
|
|
19
|
+
import { Events } from '../../agent/events.js';
|
|
20
|
+
import { getProject } from '../../../projects/store.js';
|
|
21
|
+
import { getUserById } from '../../../auth/users-store.js';
|
|
22
|
+
import { can, localGenApproved, DENIAL } from '../../../auth/tier.js';
|
|
23
|
+
import { boxConfig, runBox, sshArgs, scpArgs, localBoxEnabled, BOX_OFF_MSG } from './h3box-ssh.js';
|
|
24
|
+
|
|
25
|
+
const H3_REPO = process.env.NODESIGN_H3_REPO || '/home/wangang-dev/projects/minimax-h3-modal';
|
|
26
|
+
const MODAL_BIN = process.env.NODESIGN_MODAL_BIN || path.join(os.homedir(), '.local/bin/modal');
|
|
27
|
+
const PER_SHOT_TIMEOUT_MS = Number(process.env.NODESIGN_FILM_TIMEOUT_MS) || 900_000;
|
|
28
|
+
|
|
29
|
+
function frameCount(durationS) {
|
|
30
|
+
const f = Math.max(5, Math.round(durationS * 24));
|
|
31
|
+
return f + ((5 - (f % 17)) % 17 + 17) % 17;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function estCostUsd(d, backend) {
|
|
35
|
+
const pts = backend === 'modal'
|
|
36
|
+
? [[5.17, 0.10], [8, 0.16], [10, 0.21], [12.25, 0.28]]
|
|
37
|
+
: [[5.17, 0.015], [8, 0.02], [12.25, 0.03]];
|
|
38
|
+
if (d <= pts[0][0]) return pts[0][1];
|
|
39
|
+
for (let i = 1; i < pts.length; i++) {
|
|
40
|
+
if (d <= pts[i][0]) {
|
|
41
|
+
const [x0, y0] = pts[i - 1]; const [x1, y1] = pts[i];
|
|
42
|
+
return Math.round((y0 + (d - x0) * (y1 - y0) / (x1 - x0)) * 1000) / 1000;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return pts[pts.length - 1][1];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function runModal(args, { cwd, signal, timeoutMs }) {
|
|
49
|
+
return new Promise((resolve) => {
|
|
50
|
+
const child = spawn(MODAL_BIN, args, { cwd, stdio: ['ignore', 'pipe', 'pipe'], env: process.env });
|
|
51
|
+
let out = ''; let err = '';
|
|
52
|
+
child.stdout.on('data', (d) => { out = (out + d).slice(-8000); });
|
|
53
|
+
child.stderr.on('data', (d) => { err = (err + d).slice(-4000); });
|
|
54
|
+
const timer = setTimeout(() => { try { child.kill('SIGKILL'); } catch { /* */ } }, timeoutMs);
|
|
55
|
+
const onAbort = () => { try { child.kill('SIGKILL'); } catch { /* */ } };
|
|
56
|
+
signal?.addEventListener?.('abort', onAbort, { once: true });
|
|
57
|
+
child.on('close', (code) => {
|
|
58
|
+
clearTimeout(timer);
|
|
59
|
+
signal?.removeEventListener?.('abort', onAbort);
|
|
60
|
+
resolve({ code, out, err });
|
|
61
|
+
});
|
|
62
|
+
child.on('error', (e) => { clearTimeout(timer); resolve({ code: -1, out, err: String(e.message) }); });
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** 单镜落盘 + sidecar + 上墙事件;返回 caption 行 */
|
|
67
|
+
async function landShot({ localMp4, shot, seed, backend, wallS, outDir, ctx, sessionId }) {
|
|
68
|
+
const fileName = `${shot.name}-${shot.jobId.slice(5)}.mp4`;
|
|
69
|
+
const absOut = path.join(outDir, fileName);
|
|
70
|
+
await fs.rename(localMp4, absOut).catch(async () => {
|
|
71
|
+
await fs.copyFile(localMp4, absOut); await fs.unlink(localMp4).catch(() => { /* */ });
|
|
72
|
+
});
|
|
73
|
+
const sizeBytes = (await fs.stat(absOut)).size;
|
|
74
|
+
const cost = estCostUsd(shot.duration, backend);
|
|
75
|
+
try {
|
|
76
|
+
const metaDir = path.join(outDir, '.meta');
|
|
77
|
+
await fs.mkdir(metaDir, { recursive: true });
|
|
78
|
+
await fs.writeFile(path.join(metaDir, `${path.parse(fileName).name}.json`), JSON.stringify({
|
|
79
|
+
prompt: shot.prompt, kind: 'film', durationS: shot.duration, frames: frameCount(shot.duration),
|
|
80
|
+
seed, model: backend === 'modal' ? 'minimax-h3-turbo8-h100-sage' : 'minimax-h3-turbo8-5090-sage2',
|
|
81
|
+
estCostUsd: cost, wallClockS: wallS,
|
|
82
|
+
firstFrame: shot.first_frame || null, lastFrame: shot.last_frame || null,
|
|
83
|
+
sessionId: ctx?.sessionId || sessionId || null, runId: ctx?.runId || null,
|
|
84
|
+
ts: new Date().toISOString(),
|
|
85
|
+
}, null, 2));
|
|
86
|
+
} catch (e) { console.warn(`[roll-film] meta sidecar failed: ${e.message}`); }
|
|
87
|
+
// MCP 工具写盘不走自动 file_changed —— 手动发,出一镜上墙一镜
|
|
88
|
+
// 工作区相对路径(fileChanged 正字法,2026-08-14 普查改;绝对路径=前端寻址哑弹)
|
|
89
|
+
try { ctx?.emit?.(Events.fileChanged(path.posix.join('assets', 'generated', fileName), 'add')); } catch { /* */ }
|
|
90
|
+
return `[${shot.name}] assets/generated/${fileName} — ${shot.duration}s ${(sizeBytes / 1e6).toFixed(1)}MB ${Math.floor(wallS / 60)}m${wallS % 60}s ~$${cost}`;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export async function rollFilm(
|
|
94
|
+
{ workspaceRoot, sharedRoot, projectId, sessionId, ctx },
|
|
95
|
+
{ shots, seed },
|
|
96
|
+
) {
|
|
97
|
+
const asText = (text, isError = false) =>
|
|
98
|
+
({ content: [{ type: 'text', text }], ...(isError ? { isError: true } : {}) });
|
|
99
|
+
try {
|
|
100
|
+
const project = getProject(projectId);
|
|
101
|
+
if (!project) return asText('错误:项目不存在', true);
|
|
102
|
+
const owner = project.ownerId ? getUserById(project.ownerId) : null;
|
|
103
|
+
if (!owner) return asText('错误:找不到项目归属用户', true);
|
|
104
|
+
// 档位闸 + 逐人批准(auth/tier.js):basic 档不开本地产线;pro 档还要被站主批过
|
|
105
|
+
if (!can(owner, 'localGen')) return asText(DENIAL.localGenTier, true);
|
|
106
|
+
if (!localGenApproved(owner)) return asText(DENIAL.localGenApproval, true);
|
|
107
|
+
|
|
108
|
+
// 全批先验完再花钱:帧域 + 关键帧存在性 + 名字唯一
|
|
109
|
+
const names = new Set();
|
|
110
|
+
for (const s of shots) {
|
|
111
|
+
const frames = frameCount(s.duration);
|
|
112
|
+
if (frames > 294) return asText(`[${s.name}] ${frames} 帧超产线安全域 294(12.25s),拆镜`, true);
|
|
113
|
+
if (names.has(s.name)) return asText(`镜名重复:${s.name}`, true);
|
|
114
|
+
names.add(s.name);
|
|
115
|
+
for (const slot of ['first_frame', 'last_frame']) {
|
|
116
|
+
if (!s[slot]) continue;
|
|
117
|
+
const abs = path.resolve(workspaceRoot || process.cwd(), s[slot]);
|
|
118
|
+
try {
|
|
119
|
+
if (!(await fs.stat(abs)).isFile()) throw new Error('x');
|
|
120
|
+
} catch { return asText(`[${s.name}] ${slot} 找不到:${s[slot]}`, true); }
|
|
121
|
+
s[`_abs_${slot}`] = abs;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const backend = (process.env.NODESIGN_FILM_BACKEND || 'box').toLowerCase();
|
|
126
|
+
// 只拦 box 后端:modal 是另一台机器,站主关本地盒子不影响它
|
|
127
|
+
if (backend === 'box' && !localBoxEnabled()) {
|
|
128
|
+
return asText(BOX_OFF_MSG, true);
|
|
129
|
+
}
|
|
130
|
+
const signal = ctx?.abortController?.signal;
|
|
131
|
+
const outDir = path.join(sharedRoot || workspaceRoot, 'assets', 'generated');
|
|
132
|
+
await fs.mkdir(outDir, { recursive: true });
|
|
133
|
+
const batch = Date.now().toString(36);
|
|
134
|
+
const lines = []; const failed = [];
|
|
135
|
+
|
|
136
|
+
let box = null;
|
|
137
|
+
if (backend !== 'modal') {
|
|
138
|
+
box = boxConfig();
|
|
139
|
+
if (!box) return asText('视频盒子未配置(站主没开机)。转告用户;备用 Modal 档要站主设 NODESIGN_FILM_BACKEND=modal。', true);
|
|
140
|
+
const mk = await runBox(box, 'ssh', [...sshArgs(box), 'mkdir -p nd_jobs'], { timeoutMs: 30_000, signal });
|
|
141
|
+
if (mk.code !== 0) return asText(`盒子连不上(没开机/地址过期):\n${(mk.err || '').slice(-400)}\n转告用户。`, true);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// 逐镜串行:一镜完整走完(渲→取回→上墙)再下一镜,agent 中途被打断也保住已出的
|
|
145
|
+
for (let i = 0; i < shots.length; i++) {
|
|
146
|
+
const shot = shots[i];
|
|
147
|
+
shot.jobId = `film-${batch}s${i}`;
|
|
148
|
+
const job = { name: shot.jobId, prompt: shot.prompt, seed, duration: shot.duration, width: 1344, height: 768 };
|
|
149
|
+
if (shot.steps) job.steps = shot.steps;
|
|
150
|
+
const t0 = Date.now();
|
|
151
|
+
let localMp4 = null; let failMsg = null;
|
|
152
|
+
|
|
153
|
+
if (backend === 'modal') {
|
|
154
|
+
if (shot._abs_first_frame) job.first_frame = shot._abs_first_frame;
|
|
155
|
+
if (shot._abs_last_frame) job.last_frame = shot._abs_last_frame;
|
|
156
|
+
const jp = path.join(os.tmpdir(), `nd-${shot.jobId}.json`);
|
|
157
|
+
await fs.writeFile(jp, JSON.stringify([job]));
|
|
158
|
+
const r = await runModal(['run', 'h3_comfy.py', '--jobs-file', jp], { cwd: H3_REPO, signal, timeoutMs: PER_SHOT_TIMEOUT_MS });
|
|
159
|
+
fs.unlink(jp).catch(() => { /* */ });
|
|
160
|
+
if (r.code !== 0) failMsg = (r.err || r.out).slice(-600);
|
|
161
|
+
else {
|
|
162
|
+
const hits = (await fs.readdir(path.join(H3_REPO, 'outputs'))).filter((f) => f.startsWith(`${shot.jobId}_`) && f.endsWith('.mp4'));
|
|
163
|
+
if (hits.length) localMp4 = path.join(H3_REPO, 'outputs', hits[0]);
|
|
164
|
+
else failMsg = `无成片。输出尾:${r.out.slice(-400)}`;
|
|
165
|
+
}
|
|
166
|
+
} else {
|
|
167
|
+
for (const slot of ['first_frame', 'last_frame']) {
|
|
168
|
+
const abs = shot[`_abs_${slot}`];
|
|
169
|
+
if (!abs) continue;
|
|
170
|
+
const remote = `nd_jobs/${shot.jobId}-${slot}${path.extname(abs) || '.png'}`;
|
|
171
|
+
const up = await runBox(box, 'scp', [...scpArgs(box), abs, `${box.target}:${remote}`], { timeoutMs: 60_000, signal });
|
|
172
|
+
if (up.code !== 0) { failMsg = `关键帧上传失败:${up.err.slice(-300)}`; break; }
|
|
173
|
+
job[slot] = `~/${remote}`;
|
|
174
|
+
}
|
|
175
|
+
if (!failMsg) {
|
|
176
|
+
const jl = path.join(os.tmpdir(), `nd-${shot.jobId}.json`);
|
|
177
|
+
await fs.writeFile(jl, JSON.stringify([job]));
|
|
178
|
+
const upJ = await runBox(box, 'scp', [...scpArgs(box), jl, `${box.target}:nd_jobs/${shot.jobId}.json`], { timeoutMs: 30_000, signal });
|
|
179
|
+
fs.unlink(jl).catch(() => { /* */ });
|
|
180
|
+
if (upJ.code !== 0) failMsg = `任务上传失败:${upJ.err.slice(-300)}`;
|
|
181
|
+
else {
|
|
182
|
+
const gen = await runBox(box, 'ssh', [...sshArgs(box), `python3 ~/h3box.py video ~/nd_jobs/${shot.jobId}.json`], { timeoutMs: PER_SHOT_TIMEOUT_MS, signal });
|
|
183
|
+
if (gen.code !== 0 || !gen.out.includes('success')) failMsg = `渲染失败(exit ${gen.code}):${(gen.err || gen.out).slice(-600)}`;
|
|
184
|
+
else {
|
|
185
|
+
const pd = await fs.mkdtemp(path.join(os.tmpdir(), 'nd-film-'));
|
|
186
|
+
const pull = await runBox(box, 'scp', [...scpArgs(box), `${box.target}:outputs/${shot.jobId}_*.mp4`, pd], { timeoutMs: 120_000, signal });
|
|
187
|
+
const got = pull.code === 0 ? (await fs.readdir(pd)).filter((f) => f.endsWith('.mp4')) : [];
|
|
188
|
+
if (got.length) localMp4 = path.join(pd, got[0]);
|
|
189
|
+
else failMsg = `成片取回失败:${pull.err.slice(-300)}`;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const wallS = Math.round((Date.now() - t0) / 1000);
|
|
196
|
+
if (localMp4) {
|
|
197
|
+
lines.push(await landShot({ localMp4, shot, seed, backend, wallS, outDir, ctx, sessionId }));
|
|
198
|
+
} else {
|
|
199
|
+
failed.push(`[${shot.name}] ${failMsg}`);
|
|
200
|
+
if (signal?.aborted) break;
|
|
201
|
+
break; // 串行批中途失败即停:后镜大概率同因失败,别空烧
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const head = `Batch done ${lines.length}/${shots.length} shots, seed=${seed}, via ${backend === 'modal' ? 'Modal H100+sage' : '5090 盒子 sage2'}`;
|
|
206
|
+
// 2026-08-18:解禁。mp4 塞不进视觉通道,要看就 ffmpeg 抽帧再看那几张图。
|
|
207
|
+
const tail = 'You may check these: pull two or three frames with ffmpeg and look at them '
|
|
208
|
+
+ 'for technical breakage (colour cast, duplicated figures, broken limbs, mush, first '
|
|
209
|
+
+ 'frame not matching the anchor) and re-roll those yourself; delete the temp frames after. '
|
|
210
|
+
+ 'Do NOT judge whether a shot is good — hand the paths to the user for that.';
|
|
211
|
+
if (failed.length) {
|
|
212
|
+
return asText([head, ...lines, 'FAILED(批在此中断,未渲镜不再空烧):', ...failed, tail].join('\n'), lines.length === 0);
|
|
213
|
+
}
|
|
214
|
+
return asText([head, ...lines, tail].join('\n'));
|
|
215
|
+
} catch (err) {
|
|
216
|
+
return asText(`roll_film 失败:${err.message}`, true);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* @param {object} deps
|
|
222
|
+
*/
|
|
223
|
+
export function makeRollFilmTool(deps) {
|
|
224
|
+
return tool(
|
|
225
|
+
'roll_film',
|
|
226
|
+
((localBoxEnabled() || (process.env.NODESIGN_FILM_BACKEND || 'box').toLowerCase() === 'modal')
|
|
227
|
+
? ''
|
|
228
|
+
: '⛔ CURRENTLY UNAVAILABLE — the owner powers this GPU box on and off by hand, '
|
|
229
|
+
+ 'and it is off right now. Do not call this tool; tell the user the local box is off.\n\n')
|
|
230
|
+
+ `Generate video shots (picture + native audio) on the owner's self-hosted
|
|
231
|
+
MiniMax-H3 lane (RTX 5090 box, SageAttention, Turbo 8-step, 24fps). One call =
|
|
232
|
+
one batch of 1-16 shots rendered back-to-back; each finished shot lands on the
|
|
233
|
+
canvas immediately. Max 12.25s per shot — longer stories are multiple shots.
|
|
234
|
+
|
|
235
|
+
Call ONLY when the user explicitly asks for video, and align the shot list with
|
|
236
|
+
the user before rolling a multi-shot batch. Roughly 3-5 minutes per shot,
|
|
237
|
+
serial; tell the user the camera is rolling. Never auto-retry shots you already
|
|
238
|
+
got back. Requires the GPU box online — if unreachable, relay that and stop.
|
|
239
|
+
|
|
240
|
+
Prompts must follow the H3 three-field English format (cookbook arrives as a
|
|
241
|
+
system reminder attached to your FIRST call — treat your first batch as the
|
|
242
|
+
learning pass and refine from it). Keep character/style blocks verbatim
|
|
243
|
+
identical across shots; the whole batch shares ONE seed automatically. Keyframe
|
|
244
|
+
anchors (first_frame/last_frame) accept paths like "assets/generated/kf1.png"
|
|
245
|
+
(make them with paint_still or generate_image, ideally 1344x768).
|
|
246
|
+
|
|
247
|
+
Clips land at assets/generated/<name>-*.mp4. To check one, pull a couple of
|
|
248
|
+
frames with ffmpeg and look at those — catch colour cast, duplicated figures,
|
|
249
|
+
broken limbs, mush, or a first frame that does not match its anchor, and re-roll
|
|
250
|
+
those yourself (delete the temp frames after). Whether a shot is GOOD is the
|
|
251
|
+
user's call, not yours: report paths
|
|
252
|
+
and move on.`,
|
|
253
|
+
{
|
|
254
|
+
shots: z.array(z.object({
|
|
255
|
+
prompt: z.string().describe('H3 three-field English prompt'),
|
|
256
|
+
duration: z.number().min(5.2).max(12.25).default(8),
|
|
257
|
+
name: z.string().regex(/^[\w-]{1,40}$/).describe('shot slug, unique in batch'),
|
|
258
|
+
first_frame: z.string().optional(),
|
|
259
|
+
last_frame: z.string().optional(),
|
|
260
|
+
steps: z.number().int().min(4).max(8).optional().describe('default 8; 4 = fast draft'),
|
|
261
|
+
})).min(1).max(16).describe('shots rendered serially in one batch'),
|
|
262
|
+
seed: z.number().int().default(1101).describe('ONE seed shared by the whole batch (film discipline)'),
|
|
263
|
+
},
|
|
264
|
+
(args) => rollFilm(deps, args),
|
|
265
|
+
);
|
|
266
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* screenshot-docx.js — screenshot 工具的 docx 分支。
|
|
3
|
+
*
|
|
4
|
+
* 为什么单独一个模块:docx 跟 deck / site 走的是**两条完全不同的管线**
|
|
5
|
+
* (LibreOffice → PDF → PNG vs playwright),共享的只有「返回一张图 + 一句
|
|
6
|
+
* caption」这个出口形状。塞进 screenshot.js 会变成一个大 if 横在中间。
|
|
7
|
+
*
|
|
8
|
+
* agent 那边**动词不变**:还是 screenshot。形态注册表的 `renderable` 能力位
|
|
9
|
+
* 负责分流,工具签名和使用习惯零变化 —— 「做完看一眼」这条纪律是跨形态的,
|
|
10
|
+
* 不该因为产物换了种类就让 agent 重新学一个工具名。
|
|
11
|
+
*
|
|
12
|
+
* ⚠️ 这只眼睛有两处**已知失真**,caption 里必须说出来,不能让 agent 拿它当
|
|
13
|
+
* 终审(写进 SKILL 的同一套话):
|
|
14
|
+
* 1. 中文字体是替身(服务器没有宋体/黑体/仿宋,用 Noto / LXGW 代显)——
|
|
15
|
+
* 断行一致但**行高不一致**(multiple 行距乘字体自带行高,替身≈1.24 vs
|
|
16
|
+
* 真雅黑≈1.32 / 苹方≈1.4),multiple 行距下页数不可照图判
|
|
17
|
+
* 2. **TOC 域**显示的是缓存占位文案,不是真目录(Word 打开更新域才生成)。
|
|
18
|
+
* ⚠️ 别把这条写成「域都不更新」—— 实测 PAGE 域 LO 是**正常求值**的,
|
|
19
|
+
* 页脚页码看到几就是几。假警报会训练 agent 忽略警报。
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import fs from 'node:fs/promises';
|
|
23
|
+
import { execFileSync } from 'node:child_process';
|
|
24
|
+
import { renderDocx, cleanupRender } from '../../../lib/docx/render.js';
|
|
25
|
+
import { normalizeShot } from './helpers/shot-pipeline.js';
|
|
26
|
+
|
|
27
|
+
/** 一次最多回几页 —— 40 页文档全渲回来是上下文炸弹 */
|
|
28
|
+
const MAX_PAGES = 6;
|
|
29
|
+
/** 不指定范围时默认看几页 */
|
|
30
|
+
const DEFAULT_PAGES = 2;
|
|
31
|
+
|
|
32
|
+
const DPI = { normal: 100, high: 150 };
|
|
33
|
+
|
|
34
|
+
/** PDF 总页数。拿不到就返回 null,不为了一个数字让整次截图失败 */
|
|
35
|
+
function pdfPageCount(pdfPath) {
|
|
36
|
+
try {
|
|
37
|
+
const out = execFileSync('pdfinfo', [pdfPath], { encoding: 'utf8', timeout: 15000 });
|
|
38
|
+
const m = out.match(/^Pages:\s+(\d+)/m);
|
|
39
|
+
return m ? Number(m[1]) : null;
|
|
40
|
+
} catch { return null; }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* 解析页码范围。`pages` 收 "3"、"2-5"、"all" 三种写法。
|
|
45
|
+
* @returns {{from:number, to:number, explicit:boolean}}
|
|
46
|
+
*/
|
|
47
|
+
export function parsePageRange(pages) {
|
|
48
|
+
if (!pages) return { from: 1, to: DEFAULT_PAGES, explicit: false };
|
|
49
|
+
const s = String(pages).trim();
|
|
50
|
+
if (/^all$/i.test(s)) return { from: 1, to: MAX_PAGES, explicit: true };
|
|
51
|
+
const range = s.match(/^(\d+)\s*-\s*(\d+)$/);
|
|
52
|
+
if (range) {
|
|
53
|
+
const from = Math.max(1, Number(range[1]));
|
|
54
|
+
return { from, to: Math.max(from, Math.min(Number(range[2]), from + MAX_PAGES - 1)), explicit: true };
|
|
55
|
+
}
|
|
56
|
+
const one = s.match(/^(\d+)$/);
|
|
57
|
+
if (one) { const n = Math.max(1, Number(one[1])); return { from: n, to: n, explicit: true }; }
|
|
58
|
+
return { from: 1, to: DEFAULT_PAGES, explicit: false };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* @param {{absPath:string, relPath:string}} target 已解析的产物目标
|
|
63
|
+
* @param {{pages?:string, detail?:'normal'|'high'}} opts
|
|
64
|
+
* @returns {Promise<{content:Array, isError?:boolean}>} MCP 工具返回体
|
|
65
|
+
*/
|
|
66
|
+
export async function screenshotDocx(target, opts = {}) {
|
|
67
|
+
try {
|
|
68
|
+
await fs.access(target.absPath);
|
|
69
|
+
} catch {
|
|
70
|
+
return {
|
|
71
|
+
content: [{ type: 'text', text: `${target.relPath} 还没构建出来。先写 token 源再 build,或者确认文件名。` }],
|
|
72
|
+
isError: true,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const { from, to, explicit } = parsePageRange(opts.pages);
|
|
77
|
+
const dpi = DPI[opts.detail === 'high' ? 'high' : 'normal'];
|
|
78
|
+
|
|
79
|
+
let res;
|
|
80
|
+
try {
|
|
81
|
+
// pdftoppm 的 -l 超过实际页数会自动截断,所以这里不用先问总页数
|
|
82
|
+
res = await renderDocx(target.absPath, { pngPages: [from, to], dpi });
|
|
83
|
+
} catch (err) {
|
|
84
|
+
return {
|
|
85
|
+
content: [{
|
|
86
|
+
type: 'text',
|
|
87
|
+
text: `渲染失败:${String(err.message || err).slice(0, 400)}\n`
|
|
88
|
+
+ '这是渲染链路的问题不是文档的问题,别靠猜改文档 —— 先看 soffice 在不在、文件是不是完整的 docx。',
|
|
89
|
+
}],
|
|
90
|
+
isError: true,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
try {
|
|
95
|
+
const total = pdfPageCount(res.pdf);
|
|
96
|
+
if (!res.pngs.length) {
|
|
97
|
+
return {
|
|
98
|
+
content: [{ type: 'text', text: `${target.relPath} 渲染出来是空的(共 ${total ?? '?'} 页,请求 ${from}-${to})。` }],
|
|
99
|
+
isError: true,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const shown = `${from}${res.pngs.length > 1 ? `-${from + res.pngs.length - 1}` : ''}`;
|
|
104
|
+
const caption = [
|
|
105
|
+
`${target.relPath} · 第 ${shown} 页${total ? ` / 共 ${total} 页` : ''} · ${dpi}dpi · ${res.ms}ms`,
|
|
106
|
+
];
|
|
107
|
+
// 没看完就说没看完 —— 静默只给前两页,agent 会以为自己看过全文
|
|
108
|
+
if (total && from + res.pngs.length - 1 < total) {
|
|
109
|
+
caption.push(`⚠️ 还有 ${total - (from + res.pngs.length - 1)} 页没看:pages:"3-6" 指定范围,一次最多 ${MAX_PAGES} 页。`);
|
|
110
|
+
}
|
|
111
|
+
if (!explicit && total && total > DEFAULT_PAGES) {
|
|
112
|
+
caption.push('(没传 pages 时默认只渲前两页)');
|
|
113
|
+
}
|
|
114
|
+
caption.push(
|
|
115
|
+
'已知失真:① 中文是**替身字体**(雅黑/等线→MiSans、仿宋→朱雀仿宋、宋体→Noto Serif、'
|
|
116
|
+
+ '楷体→LXGW),字形跟用户 Word 里仍有差;CJK 全角等宽所以**断行位置**一致,但行高只有'
|
|
117
|
+
+ '雅黑档对齐了(MiSans 1.326 ≈ 真雅黑 1.32)—— 宋体等其它档、以及 Mac 上替到苹方(≈1.4)'
|
|
118
|
+
+ '的行高仍不同(2026-08-19 真 Word 实证:2 页简历在 Word 里变过 4 页);'
|
|
119
|
+
+ '② **TOC 域**这里显示的是占位文案不是真目录(Word 打开更新域才生成)——'
|
|
120
|
+
+ '页码域是正常的,看到几就是几。'
|
|
121
|
+
+ '版式、间距、缩进、层级可以照这张图判;字形观感、目录内容不能,'
|
|
122
|
+
+ '**multiple 行距下的页数和分页位置也不能**(排满的页真 Word 会多出页;'
|
|
123
|
+
+ '页数敏感的文档行距用 exact/atLeast 磅值,两边就一样高了)。',
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
// 跟全站眼睛同口径(08-21):过 normalizeShot → 视觉档尺寸 + webp q82。视觉模型按像素
|
|
127
|
+
// 网格计 token,PNG 原图多出来的字节它看不见;公文页 PNG 260KB → webp 97KB,放大
|
|
128
|
+
// 对比字形无差。少的是传给上游的字节和 CLI 按字符估算的假仪表,真 token 账不变。
|
|
129
|
+
const images = await Promise.all(res.pngs.map(async (p) => {
|
|
130
|
+
const n = await normalizeShot(await fs.readFile(p));
|
|
131
|
+
return { type: 'image', data: n.data, mimeType: n.mimeType };
|
|
132
|
+
}));
|
|
133
|
+
|
|
134
|
+
return { content: [{ type: 'text', text: caption.join('\n') }, ...images] };
|
|
135
|
+
} finally {
|
|
136
|
+
await cleanupRender(res);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* mcp/tools/screenshot-url.js — screenshot_url MCP tool(2026-07-29)
|
|
3
|
+
*
|
|
4
|
+
* 对任意外部 URL 截图。诞生背景:explorer 找视觉参考只能 WebFetch 拿文本,
|
|
5
|
+
* 再用文字向主 agent 转述"这个站是深色的、图占主导"—— 找视觉参考却看不见
|
|
6
|
+
* 视觉。这个工具让 explorer / 主 agent 直接看到参考站长什么样。
|
|
7
|
+
*
|
|
8
|
+
* 安全:
|
|
9
|
+
* - 只放 http/https(file:// 会变成任意本地文件读取,硬拒)
|
|
10
|
+
* - 拒绝内网/环回/link-local 字面量(localhost / 127.* / 10.* / 172.16-31.* /
|
|
11
|
+
* 192.168.* / 169.254.* / *.local / [::1])—— explorer 会读不可信网页内容,
|
|
12
|
+
* prompt injection 不该能借它窥探内网服务。DNS rebinding 不在防御范围
|
|
13
|
+
* (截图只回图片,风险面已经很小)。
|
|
14
|
+
*
|
|
15
|
+
* 加载策略:外站经常永远到不了 networkidle(分析脚本长轮询),goto 超时不算
|
|
16
|
+
* 失败 —— 部分渲染的参考图也比纯文字转述强,超时记进 caption。
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { tool } from '@anthropic-ai/claude-agent-sdk';
|
|
20
|
+
import { z } from 'zod';
|
|
21
|
+
import { attachPageDiagnostics, runBeforeShot, normalizeShot, FIDELITY_LAUNCH_ARGS, detectPaintTransform } from './helpers/shot-pipeline.js';
|
|
22
|
+
import { checkUrl, attachSsrfGuard } from '../../../lib/ssrf-guard.js';
|
|
23
|
+
import { denyText } from './browse.js';
|
|
24
|
+
import { startBrowseProxy } from '../../../lib/browse-proxy.js';
|
|
25
|
+
|
|
26
|
+
const RASTER_SCALE = 0.6;
|
|
27
|
+
const DEVICE_VIEWPORTS = {
|
|
28
|
+
desktop: { width: 1440, height: 900 },
|
|
29
|
+
tablet: { width: 834, height: 1112 },
|
|
30
|
+
mobile: { width: 390, height: 844 },
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const PRIVATE_HOST_RE = new RegExp(
|
|
34
|
+
'^(localhost|0\\.0\\.0\\.0|127\\.|10\\.|192\\.168\\.|169\\.254\\.'
|
|
35
|
+
+ '|172\\.(1[6-9]|2[0-9]|3[01])\\.'
|
|
36
|
+
+ '|\\[::1\\]|\\[fc|\\[fd|\\[fe80)'
|
|
37
|
+
, 'i',
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
function validateUrl(raw) {
|
|
41
|
+
let u;
|
|
42
|
+
try {
|
|
43
|
+
u = new URL(raw);
|
|
44
|
+
} catch {
|
|
45
|
+
return { ok: false, message: `not a valid URL: ${raw}` };
|
|
46
|
+
}
|
|
47
|
+
if (u.protocol !== 'http:' && u.protocol !== 'https:') {
|
|
48
|
+
return { ok: false, message: `only http/https URLs are allowed (got ${u.protocol})` };
|
|
49
|
+
}
|
|
50
|
+
// ⚠️ 这里**只做词法预筛**。真判据是 checkUrl(解析 DNS 按 IP 判)+ 页面上挂的
|
|
51
|
+
// CDP 闸(拦跳转与子资源)。留着这道是因为它便宜、能在解析之前挡掉最常见的字面量。
|
|
52
|
+
const host = u.hostname.toLowerCase();
|
|
53
|
+
if (PRIVATE_HOST_RE.test(host) || host.endsWith('.local')) {
|
|
54
|
+
return { ok: false, message: `refusing to screenshot private/internal address: ${host}` };
|
|
55
|
+
}
|
|
56
|
+
return { ok: true, url: u };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* @param {object} deps
|
|
61
|
+
* @param {import('../../agent/context.js').AgentContext} [deps.ctx]
|
|
62
|
+
*/
|
|
63
|
+
export function makeScreenshotUrlTool({ projectId, ctx } = {}) {
|
|
64
|
+
return tool(
|
|
65
|
+
'screenshot_url',
|
|
66
|
+
`Take a screenshot of any external web page (http/https) and return it as an
|
|
67
|
+
image you can see via vision. THE tool for gathering visual design references —
|
|
68
|
+
when researching how other sites handle layout, typography, color, or imagery,
|
|
69
|
+
look at them instead of reading their HTML and imagining.
|
|
70
|
+
|
|
71
|
+
- device: render at a real device width (desktop=1440, tablet=834, mobile=390)
|
|
72
|
+
- fullPage=true captures the whole scrollable page (auto-scrolls first so
|
|
73
|
+
lazy-loaded images and scroll reveals are triggered); default captures the
|
|
74
|
+
first viewport only — usually enough to judge a site's character, and much
|
|
75
|
+
cheaper in context
|
|
76
|
+
- The caption reports console errors / failed resources of the target page —
|
|
77
|
+
ignore those unless they explain a broken-looking render.
|
|
78
|
+
|
|
79
|
+
External pages can be slow; if the network never settles the shot is taken
|
|
80
|
+
anyway after 12s and the caption says so. Only http/https and public hosts.`,
|
|
81
|
+
{
|
|
82
|
+
url: z.string().describe('The http/https URL to screenshot'),
|
|
83
|
+
device: z
|
|
84
|
+
.enum(['desktop', 'tablet', 'mobile'])
|
|
85
|
+
.optional()
|
|
86
|
+
.describe('Viewport width preset: desktop=1440 (default), tablet=834, mobile=390'),
|
|
87
|
+
fullPage: z
|
|
88
|
+
.boolean()
|
|
89
|
+
.optional()
|
|
90
|
+
.describe('Capture the full scrollable page (auto-scrolls through it first to trigger lazyload). Default false = first viewport only, much cheaper.'),
|
|
91
|
+
detail: z
|
|
92
|
+
.enum(['normal', 'high'])
|
|
93
|
+
.optional()
|
|
94
|
+
.describe("Raster detail. 'normal' (default) = 0.6x pixels, enough for layout/palette judgment. 'high' = full resolution, only when you must read small text."),
|
|
95
|
+
},
|
|
96
|
+
async ({ url: rawUrl, device, fullPage, detail }) => {
|
|
97
|
+
const check = validateUrl(rawUrl);
|
|
98
|
+
if (!check.ok) {
|
|
99
|
+
return { content: [{ type: 'text', text: check.message }], isError: true };
|
|
100
|
+
}
|
|
101
|
+
// ⛔ 2026-08-18 补:上面那道 `validateUrl` 是**纯词法**的 —— 挡得住
|
|
102
|
+
// `127.0.0.1` 这种字面量,挡不住一个 DNS 解析到内网的公网域名,也不管 302。
|
|
103
|
+
// 新闸(lib/ssrf-guard.js)本来就是来替换它的,但上线那天**忘了接这个工具**,
|
|
104
|
+
// 于是它自己一直是个活着的 SSRF 洞。现在:按解析出的 IP 判 + 页面上挂 CDP 闸
|
|
105
|
+
// 拦跳转与子资源。
|
|
106
|
+
const pre = await checkUrl(check.url.href);
|
|
107
|
+
if (!pre.ok) {
|
|
108
|
+
// 拒因分种类说(DNS 死域名 ≠ 策略拦截;文案与线上地址提示同 browse 工具)
|
|
109
|
+
const tail = denyText(pre, projectId, check.url.href).join('\n');
|
|
110
|
+
return { content: [{ type: 'text', text: `refusing to screenshot: ${pre.reason}\n${tail}` }], isError: true };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const vp = DEVICE_VIEWPORTS[device || 'desktop'];
|
|
114
|
+
let browser;
|
|
115
|
+
try {
|
|
116
|
+
const { chromium } = await import('playwright');
|
|
117
|
+
// ⭐ 走同一个出网代理(2026-08-18 二次修)。CDP 那道闸看不见四类东西:
|
|
118
|
+
// WebSocket 握手、`<link rel=prefetch>`、`sendBeacon`、还没装闸的弹窗
|
|
119
|
+
// —— 四条都是攻出来的,而这个工具吃的正是**任意用户给的 URL**,同一批
|
|
120
|
+
// 绕过一字不改就能用在它身上。代理是所有出网的必经之路,且连的是自己
|
|
121
|
+
// 解析并验过的那个 IP(顺带根除 DNS 重绑定)。
|
|
122
|
+
// `bypass: ''`:默认会放过 loopback,那正是最要拦的。
|
|
123
|
+
const { port: proxyPort } = await startBrowseProxy();
|
|
124
|
+
browser = await chromium.launch({
|
|
125
|
+
headless: true,
|
|
126
|
+
args: FIDELITY_LAUNCH_ARGS,
|
|
127
|
+
proxy: { server: `http://127.0.0.1:${proxyPort}`, bypass: '' },
|
|
128
|
+
});
|
|
129
|
+
const rasterScale = detail === 'high' ? 1 : RASTER_SCALE;
|
|
130
|
+
const ctx = await browser.newContext({ viewport: vp, deviceScaleFactor: rasterScale, colorScheme: 'light' });
|
|
131
|
+
const guard = await attachSsrfGuard(ctx, undefined, { proxied: true });
|
|
132
|
+
const page = await ctx.newPage();
|
|
133
|
+
await guard.armPage(page); // ⭐ 必须 await 完才导航(竞态是攻出来的)
|
|
134
|
+
const diag = attachPageDiagnostics(page);
|
|
135
|
+
|
|
136
|
+
let gotoNote = null;
|
|
137
|
+
try {
|
|
138
|
+
await page.goto(check.url.href, { waitUntil: 'networkidle', timeout: 12000 });
|
|
139
|
+
} catch (err) {
|
|
140
|
+
// 超时(页面已部分渲染)→ 照截;真导航失败(DNS/refused)→ 报错
|
|
141
|
+
if (!/Timeout/i.test(String(err?.message))) {
|
|
142
|
+
return {
|
|
143
|
+
content: [{ type: 'text', text: `Failed to load ${rawUrl}: ${err?.message || err}` }],
|
|
144
|
+
isError: true,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
gotoNote = 'network never settled (12s) — captured current render state';
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (fullPage) {
|
|
151
|
+
// 滚一遍触发 lazyload / scroll reveal,再回顶整页截
|
|
152
|
+
await runBeforeShot(page, 'scrollToBottom');
|
|
153
|
+
}
|
|
154
|
+
const buf = await page.screenshot({ fullPage: fullPage === true, type: 'png' });
|
|
155
|
+
|
|
156
|
+
try {
|
|
157
|
+
ctx?.emit?.({
|
|
158
|
+
type: 'run.screenshot_taken',
|
|
159
|
+
sizeBytes: buf.length,
|
|
160
|
+
viewport: vp,
|
|
161
|
+
mode: `url=${check.url.hostname}`,
|
|
162
|
+
});
|
|
163
|
+
} catch { /* emit fail-safe */ }
|
|
164
|
+
|
|
165
|
+
const paintNote = await detectPaintTransform(page);
|
|
166
|
+
const shot = await normalizeShot(buf);
|
|
167
|
+
const title = await page.title().catch(() => '');
|
|
168
|
+
const finalUrl = page.url();
|
|
169
|
+
const captionParts = [
|
|
170
|
+
`Screenshot of ${finalUrl}${title ? ` — "${title}"` : ''} (${device || 'desktop'} ${vp.width}x${vp.height} @${rasterScale}x, fullPage=${fullPage === true})`,
|
|
171
|
+
];
|
|
172
|
+
if (shot.note) captionParts.push(shot.note);
|
|
173
|
+
if (gotoNote) captionParts.push(gotoNote);
|
|
174
|
+
if (paintNote) captionParts.push(paintNote);
|
|
175
|
+
captionParts.push(diag.summary());
|
|
176
|
+
|
|
177
|
+
return {
|
|
178
|
+
content: [
|
|
179
|
+
{ type: 'text', text: captionParts.join('\n') },
|
|
180
|
+
{ type: 'image', data: shot.data, mimeType: shot.mimeType },
|
|
181
|
+
],
|
|
182
|
+
};
|
|
183
|
+
} catch (err) {
|
|
184
|
+
return {
|
|
185
|
+
content: [{ type: 'text', text: `screenshot_url failed: ${err?.message || String(err)}` }],
|
|
186
|
+
isError: true,
|
|
187
|
+
};
|
|
188
|
+
} finally {
|
|
189
|
+
if (browser) {
|
|
190
|
+
try { await browser.close(); } catch { /* ignore */ }
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
},
|
|
194
|
+
);
|
|
195
|
+
}
|