Compare commits

..

175 Commits

Author SHA1 Message Date
codex-bot 38a71e04ca fix(ci): accept hydrated markers hidden by responsive layouts
creative workspace review checks / verify-deployed-canvas (pull_request) Successful in 3m24s
creative workspace review checks / verify (pull_request) Successful in 25m23s
2026-11-02 16:32:37 +08:00
codex-bot 7f397fd3a2 fix(ci): verify production readiness without network idle
creative workspace review checks / verify-deployed-canvas (pull_request) Failing after 2m45s
creative workspace review checks / verify (pull_request) Has been cancelled
2026-11-02 16:20:08 +08:00
Zayden-Jung 5a317008df Merge pull request 'feat(home): 手绘场景与编辑式内容丰富首页' (#44) from codex/landing-editorial into main
creative workspace CI and deployment / verify-and-deploy (push) Failing after 15m54s
Reviewed-on: #44
2026-11-02 07:56:43 +00:00
codex-bot 602263084f feat(home): add illustrated editorial landing sections
creative workspace review checks / verify-deployed-canvas (pull_request) Successful in 55s
creative workspace review checks / verify (pull_request) Successful in 27m17s
2026-11-02 14:56:44 +08:00
Zayden-Jung 5b4644749d Merge pull request 'feat(auth): 啡跃品牌视觉与居中表单的分屏登录页' (#42) from codex/auth-layout-polish into main
creative workspace CI and deployment / verify-and-deploy (push) Successful in 15m2s
Reviewed-on: #42
2026-11-01 17:13:07 +00:00
Zayden-Jung 7656f0438c Merge pull request 'feat(auth): 邮件重设密码与旧会话撤销' (#43) from codex/account-recovery into main
creative workspace CI and deployment / verify-and-deploy (push) Successful in 16m6s
Reviewed-on: #43
2026-11-01 16:45:16 +00:00
codex-bot ca08f44137 merge: integrate password recovery dependency for combined auth validation
creative workspace review checks / verify-deployed-canvas (pull_request) Successful in 1m5s
creative workspace review checks / verify (pull_request) Successful in 28m38s
2026-11-02 00:13:07 +08:00
codex-bot b31a4835ee test(auth): verify recovery endpoints over HTTP with real storage
creative workspace review checks / verify-deployed-canvas (pull_request) Successful in 53s
creative workspace review checks / verify (pull_request) Successful in 25m37s
2026-11-02 00:02:32 +08:00
codex-bot fd96b6d9e9 fix(auth): preserve form state after email-only actions
creative workspace review checks / verify-deployed-canvas (pull_request) Successful in 59s
creative workspace review checks / verify (pull_request) Has been cancelled
2026-11-01 23:54:21 +08:00
codex-bot ee72835ea3 feat(auth): add recovery UI and stable viewport login interactions
creative workspace review checks / verify-deployed-canvas (pull_request) Successful in 52s
creative workspace review checks / verify (pull_request) Has been cancelled
2026-11-01 23:50:32 +08:00
codex-bot 6800a1b1c4 feat(auth): add single-use email password recovery and session revocation
creative workspace review checks / verify-deployed-canvas (pull_request) Successful in 58s
creative workspace review checks / verify (pull_request) Has been cancelled
2026-11-01 23:43:26 +08:00
codex-bot 1da1114d52 fix(auth): clarify verification resend eligibility
creative workspace review checks / verify-deployed-canvas (pull_request) Successful in 56s
creative workspace review checks / verify (pull_request) Has been cancelled
2026-11-01 23:33:25 +08:00
codex-bot 8c48812a11 test(auth): capture deployed split login layouts in release acceptance
creative workspace review checks / verify-deployed-canvas (pull_request) Successful in 47s
creative workspace review checks / verify (pull_request) Has been cancelled
2026-11-01 23:24:31 +08:00
codex-bot 88d68f3d3c feat(auth): introduce immersive split-screen login design
creative workspace review checks / verify-deployed-canvas (pull_request) Successful in 54s
creative workspace review checks / verify (pull_request) Has been cancelled
2026-11-01 23:19:23 +08:00
codex-bot 6819db40a8 fix(auth): rebalance account introduction typography and spacing
creative workspace review checks / verify-deployed-canvas (pull_request) Successful in 59s
creative workspace review checks / verify (pull_request) Has been cancelled
2026-11-01 23:10:36 +08:00
Zayden-Jung b2381a1cdf Merge pull request 'fix(ci): 以客户端就绪判定脚本取消是否阻断启动' (#41) from codex/ci-bootstrap-readiness into main
creative workspace CI and deployment / verify-and-deploy (push) Successful in 15m57s
Reviewed-on: #41
2026-11-01 14:45:13 +00:00
codex-bot 150e4b3af8 fix(ci): accept hydrated documents with cancelled preload requests
creative workspace review checks / verify-deployed-canvas (pull_request) Successful in 1m7s
creative workspace review checks / verify (pull_request) Successful in 26m21s
2026-11-01 22:14:37 +08:00
Zayden-Jung e36b8aea6c Merge pull request 'fix(ci): 等待浏览器脚本与账户状态就绪后再操作' (#40) from codex/ci-browser-readiness into main
creative workspace CI and deployment / verify-and-deploy (push) Failing after 10m12s
Reviewed-on: #40
2026-11-01 13:58:14 +00:00
codex-bot 517c31ca04 fix(ci): wait for document bootstrap before browser interactions
creative workspace review checks / verify-deployed-canvas (pull_request) Successful in 1m23s
creative workspace review checks / verify (pull_request) Successful in 24m43s
2026-11-01 21:36:24 +08:00
Zayden-Jung fafdae66bb Merge pull request 'fix(auth): 独立登录注册页面并弱化主题切换' (#39) from codex/auth-page-fix into main
creative workspace CI and deployment / verify-and-deploy (push) Failing after 9m31s
Reviewed-on: #39
2026-11-01 13:16:22 +00:00
codex-bot a45d43f5f7 fix(auth): separate account page from workspace navigation
creative workspace review checks / verify-deployed-canvas (pull_request) Successful in 1m13s
creative workspace review checks / verify (pull_request) Failing after 20m8s
2026-11-01 20:40:01 +08:00
Zayden-Jung 5b68c5b84e Merge pull request 'fix(ci): 统一画布验收、导航恢复与 review 资源清理' (#38) from codex/ci-acceptance-stability into main
creative workspace CI and deployment / verify-and-deploy (push) Successful in 15m58s
Reviewed-on: #38
2026-11-01 09:34:53 +00:00
codex-bot 5c068ace81 fix(ci): complete deferred script loading before brand assertions
creative workspace review checks / verify-deployed-canvas (pull_request) Successful in 55s
creative workspace review checks / verify (pull_request) Successful in 26m46s
2026-11-01 17:02:38 +08:00
codex-bot f4397db869 fix(ci): preserve dependency parent layers during review cleanup
creative workspace review checks / verify-deployed-canvas (pull_request) Successful in 1m5s
creative workspace review checks / verify (pull_request) Failing after 26m4s
2026-11-01 16:31:56 +08:00
codex-bot 9e5c5abbdf fix(ci): wait for restored document filters before asserting values
creative workspace review checks / verify-deployed-canvas (pull_request) Successful in 54s
creative workspace review checks / verify (pull_request) Has been cancelled
2026-11-01 16:10:57 +08:00
codex-bot 8b949484c1 fix(ci): wait for canvas hydration instead of the window load event
creative workspace review checks / verify-deployed-canvas (pull_request) Successful in 58s
creative workspace review checks / verify (pull_request) Failing after 25m52s
2026-11-01 15:40:33 +08:00
codex-bot 9887a5958a fix(ci): bound navigation recovery and retire review images safely
creative workspace review checks / verify-deployed-canvas (pull_request) Failing after 59s
creative workspace review checks / verify (pull_request) Has been cancelled
2026-11-01 15:35:23 +08:00
codex-bot cc6b376f56 fix(ci): verify persistent canvas themes on the root element 2026-11-01 15:32:13 +08:00
Zayden-Jung cfa3d426a0 Merge pull request 'fix(ci): 按当前 Studio 入口调整发布验收范围' (#36) from codex/fix-release-acceptance into main
creative workspace CI and deployment / verify-and-deploy (push) Failing after 11m37s
Reviewed-on: #36
2026-10-31 20:50:02 +00:00
codex-bot f7c6681c47 fix(ci): align release acceptance with active Studio entry
creative workspace review checks / verify (pull_request) Has been cancelled
2026-11-01 04:46:20 +08:00
Zayden-Jung b76642e06d Merge pull request 'fix(ci): 修复生产浏览器验收的依赖路径' (#35) from codex/fix-acceptance-deps into main
creative workspace CI and deployment / verify-and-deploy (push) Failing after 11m5s
Reviewed-on: #35
2026-10-31 20:20:48 +00:00
codex-bot 2570ea13bd fix(ci): resolve browser acceptance dependencies in workspace
creative workspace review checks / verify (pull_request) Successful in 11m9s
2026-11-01 04:18:44 +08:00
Zayden-Jung d316b0de03 Merge pull request 'fix(ui): 完成啡跃主题与浏览器图标的生产接入' (#34) from codex/brand-ui into main
Endoleap brand checks / verify (push) Successful in 6s
creative workspace CI and deployment / verify-and-deploy (push) Failing after 10m53s
Reviewed-on: #34
2026-10-31 19:38:12 +00:00
codex-bot 2a324c4d19 fix(ui): wait for rendered browser state during CI navigation
Endoleap brand checks / verify (pull_request) Successful in 6s
creative workspace review checks / verify (pull_request) Successful in 26m0s
2026-11-01 03:09:33 +08:00
codex-bot 628449735b fix(ui): fit the mark into compact library navigation
Endoleap brand checks / verify (pull_request) Successful in 6s
creative workspace review checks / verify (pull_request) Failing after 21m24s
2026-11-01 02:43:55 +08:00
codex-bot 7dca4495bb fix(ui): keep the Endoleap wordmark visible on mobile
Endoleap brand checks / verify (pull_request) Successful in 6s
creative workspace review checks / verify (pull_request) Has been cancelled
2026-11-01 02:42:42 +08:00
codex-bot 30ac83cd85 fix(ui): serve approved brand icons on public login pages
Endoleap brand checks / verify (pull_request) Successful in 32s
creative workspace review checks / verify (pull_request) Has been cancelled
2026-11-01 02:14:39 +08:00
codex-bot aa3c0cf11d fix(ui): align standalone tracing and workspace runtime paths
Endoleap brand checks / verify (pull_request) Has been cancelled
creative workspace review checks / verify (pull_request) Has been cancelled
2026-11-01 02:12:34 +08:00
codex-bot ada5338af5 fix(ui): resolve linked brand styles from the monorepo root
creative workspace review checks / verify (pull_request) Failing after 5m8s
2026-11-01 02:04:51 +08:00
codex-bot a3935c5adc fix(ui): preserve authored sheets and keyboard link contrast
creative workspace review checks / verify (pull_request) Has been cancelled
2026-11-01 02:00:00 +08:00
codex-bot abc6eafd0c fix(ui): install locked workspace dependencies in Docker
creative workspace review checks / verify (pull_request) Has been cancelled
2026-11-01 01:52:10 +08:00
Zayden-Jung 861f1b13f6 Merge pull request 'feat(ui): 全站接入啡跃配色、Logo 与浏览器图标' (#33) from codex/brand-ui into main
Endoleap brand checks / verify (push) Successful in 6s
creative workspace CI and deployment / verify-and-deploy (push) Failing after 1m12s
Reviewed-on: #33
2026-10-31 17:48:29 +00:00
codex-bot 8062dbc878 feat(ui): apply Endoleap themes, brand marks and favicons
Endoleap brand checks / verify (pull_request) Successful in 2m25s
creative workspace review checks / verify (pull_request) Failing after 2m20s
2026-11-01 01:44:51 +08:00
Zayden-Jung ff91a281f1 Merge pull request 'feat(brand): 引入啡跃品牌资源与 Tailwind / shadcn 主题包' (#32) from codex/brand-system into main
Endoleap brand checks / verify (push) Successful in 6s
creative workspace CI and deployment / verify-and-deploy (push) Failing after 6m1s
Reviewed-on: #32
2026-10-31 16:30:12 +00:00
codex-bot 74e058bb44 fix(ci): support ces-control legacy Docker builder
Endoleap brand checks / verify (pull_request) Successful in 3m55s
creative workspace review checks / verify (pull_request) Failing after 5m40s
2026-11-01 00:25:01 +08:00
codex-bot c405f47916 feat(brand): add Endoleap assets and production theme package
Endoleap brand checks / verify (pull_request) Failing after 5s
creative workspace review checks / verify (pull_request) Has been cancelled
2026-11-01 00:22:43 +08:00
Zayden-Jung 0849268232 Merge pull request 'Fix canvas placement and CI browser verification' (#30) from codex/canvas-node-placement into main
creative workspace CI and deployment / verify-and-deploy (push) Failing after 6m49s
Reviewed-on: #30
2026-10-30 19:06:45 +00:00
codex-bot ab5ea7876b Synchronize guidance navigation with fulfilled session and rendered state
creative workspace review checks / verify (pull_request) Successful in 23m45s
2026-10-31 02:24:22 +08:00
codex-bot a589f51698 Clean disposable CI stacks on interruption and job completion
creative workspace review checks / verify (pull_request) Failing after 7m18s
2026-10-31 02:14:35 +08:00
codex-bot 40c1023a86 Verify canvas text formatting and expansion on the deployed server
creative workspace review checks / verify (pull_request) Failing after 13m57s
2026-10-31 01:52:45 +08:00
codex-bot 2cc6f1f543 Place derived canvas nodes without covering existing content
creative workspace review checks / verify (pull_request) Has been cancelled
2026-10-31 01:23:41 +08:00
Zayden-Jung a3435c463f Merge pull request 'Add canvas references, selection editing and external agent tasks' (#29) from codex/canvas-node-workflow into main
creative workspace CI and deployment / verify-and-deploy (push) Failing after 16m25s
Reviewed-on: #29
2026-10-30 16:07:23 +00:00
codex-bot fcc0aba810 Account for hidden mobile account link in canvas reload check
creative workspace review checks / verify (pull_request) Successful in 26m2s
2026-10-30 23:30:03 +08:00
codex-bot cdcd4c4b94 Record navigation timing to diagnose canvas integration failures
creative workspace review checks / verify (pull_request) Failing after 19m40s
2026-10-30 22:25:37 +08:00
codex-bot 041620e2a5 Merge main release sequencing into canvas workflow
creative workspace review checks / verify (pull_request) Has been cancelled
2026-10-30 22:14:08 +08:00
Zayden-Jung dd367e40ac Merge pull request 'Fix creative workspace release sequencing and complete UI verification' (#28) from codex/creative-release-fix into main
creative workspace CI and deployment / verify-and-deploy (push) Failing after 7m7s
Reviewed-on: #28
2026-10-30 14:08:28 +00:00
codex-bot 3d48f5042d Assert workspace readiness after navigation commit instead of window load
creative workspace review checks / verify (pull_request) Has been cancelled
2026-10-30 21:58:38 +08:00
codex-bot 71da117335 Wait for the real account session before testing the login form
creative workspace review checks / verify (pull_request) Failing after 33m55s
2026-10-30 21:21:18 +08:00
codex-bot d2eaabe0da Run canvas interaction acceptance against the deployed public server
creative workspace review checks / verify (pull_request) Failing after 25m58s
2026-10-30 20:39:07 +08:00
codex-bot 95673d5c54 Wait for canvas account readiness and capture dispatch failures
creative workspace review checks / verify (pull_request) Has been cancelled
2026-10-30 20:35:25 +08:00
codex-bot a0c20972ac Verify successful image generation and upscale task delivery 2026-10-30 20:30:25 +08:00
codex-bot 0c8cf7c4cd Share the integration browser while isolating complete workspace UI checks
creative workspace review checks / verify (pull_request) Failing after 20m31s
2026-10-30 19:39:33 +08:00
codex-bot 429e78c722 Run complete workspace UI assertions in an isolated context of the integration browser
creative workspace review checks / verify (pull_request) Successful in 23m26s
2026-10-30 19:37:59 +08:00
codex-bot f4f0d67306 Use account and content readiness throughout canvas integration navigation
creative workspace review checks / verify (pull_request) Has been cancelled
2026-10-30 19:07:58 +08:00
codex-bot 4faef8b766 Wait for visible application readiness in workspace UI regression checks
creative workspace review checks / verify (pull_request) Failing after 19m53s
2026-10-30 19:07:20 +08:00
codex-bot fb6f29db76 Verify landing login and workspace entry before canvas agent delivery
creative workspace review checks / verify (pull_request) Has been cancelled
2026-10-30 18:48:54 +08:00
codex-bot 2bcd5992eb Retain document directory failure diagnostics in canvas integration
creative workspace review checks / verify (pull_request) Has been cancelled
2026-10-30 18:37:17 +08:00
codex-bot d8c06d6cc6 Capture document directory network and hydration failures in CI
creative workspace review checks / verify (pull_request) Failing after 19m53s
2026-10-30 18:37:02 +08:00
codex-bot 15542e977a Verify unsaved canvas edits survive arriving agent results
creative workspace review checks / verify (pull_request) Has been cancelled
2026-10-30 18:30:20 +08:00
codex-bot 5ab0ca78b2 Support copying agent credentials on the public HTTP workspace
creative workspace review checks / verify (pull_request) Has been cancelled
2026-10-30 18:25:03 +08:00
codex-bot 8ab67def5f Verify external canvas agent delivery through both running applications in CI
creative workspace review checks / verify (pull_request) Has been cancelled
2026-10-30 18:23:03 +08:00
codex-bot 8944e59ead Align canvas integration with corrected workspace UI and auth checks
creative workspace review checks / verify (pull_request) Has been cancelled
2026-10-30 18:14:26 +08:00
codex-bot cad84f8a67 Run nested UI checks and align authentication assertions with public workspace routes
creative workspace review checks / verify (pull_request) Failing after 19m41s
2026-10-30 18:13:58 +08:00
codex-bot 33405b7d8b Exercise document directory at its canonical entry in UI regression tests 2026-10-30 18:07:21 +08:00
codex-bot 67dea99cd1 Cover canvas concurrency and invalid agent result isolation
creative workspace review checks / verify (pull_request) Has been cancelled
2026-10-30 18:02:12 +08:00
codex-bot de851085dd Validate imported reference graphs and report malformed canvas input
creative workspace review checks / verify (pull_request) Has been cancelled
2026-10-30 17:56:56 +08:00
codex-bot af4e9b13ed Keep agent panel within narrow canvas viewports
creative workspace review checks / verify (pull_request) Has been cancelled
2026-10-30 17:53:14 +08:00
codex-bot b1e6083996 Connect canvas editing to scoped external agent tasks
creative workspace review checks / verify (pull_request) Has been cancelled
2026-10-30 17:50:26 +08:00
codex-bot cfc6686904 Capture document client navigation failures in CI
creative workspace review checks / verify (pull_request) Failing after 20m32s
2026-10-30 17:44:05 +08:00
codex-bot 7f7fc28e57 Add scoped canvas agent API with versioned S3 state and task results
creative workspace review checks / verify (pull_request) Has been cancelled
2026-10-30 17:38:05 +08:00
codex-bot bd8ccbb4a1 Add selected-node editing, reference ports and fixed canvas composer
creative workspace review checks / verify (pull_request) Has been cancelled
2026-10-30 17:27:34 +08:00
codex-bot 40a6076961 Wait for document navigation before asserting its rendered heading
creative workspace review checks / verify (pull_request) Failing after 17m59s
2026-10-30 17:18:42 +08:00
codex-bot d072aeeaa0 Model canvas references, script nodes and agent task snapshots 2026-10-30 17:12:47 +08:00
codex-bot 53a4103308 Sequence creative workspace releases and verify document discovery at its new entry
creative workspace review checks / verify (pull_request) Failing after 17m42s
2026-10-30 16:57:48 +08:00
Zayden-Jung ea7bbebb3e Merge pull request 'Redesign creative landing page, workspace and editor entry points' (#27) from codex/landing-workspace into main
studio CI and deployment / verify-and-deploy (push) Failing after 11m29s
waterfall-wiki CI and deployment / verify-and-deploy (push) Failing after 19m46s
Reviewed-on: #27
2026-10-30 07:17:43 +00:00
codex-bot 94193d485b Cover public creative entries while retaining protected Studio API checks
creative workspace review checks / verify (pull_request) Successful in 13m24s
2026-10-30 14:43:06 +08:00
codex-bot 265cd7444b Describe external agent workflow in creator-facing editor copy
creative workspace review checks / verify (pull_request) Has been cancelled
2026-10-30 14:38:28 +08:00
codex-bot f1c794654e Edit canvas text and replace images directly on double click
creative workspace review checks / verify (pull_request) Has been cancelled
2026-10-30 14:32:32 +08:00
codex-bot fd9b8640b7 Keep graphic toolbar and header within narrow viewports
creative workspace review checks / verify (pull_request) Has been cancelled
2026-10-30 14:29:36 +08:00
codex-bot 4791729b65 Make graphic panels collapsible and fix hand panning over layers
creative workspace review checks / verify (pull_request) Has been cancelled
2026-10-30 14:28:28 +08:00
codex-bot d362e1a975 Verify landing redirects and wait for cross-app route deployment
creative workspace review checks / verify (push) Has been cancelled
creative workspace review checks / verify (pull_request) Has been cancelled
2026-10-30 14:21:12 +08:00
codex-bot 924724c5e4 Align server visual acceptance with canonical editor routes and canvas onboarding
creative workspace review checks / verify (pull_request) Has been cancelled
creative workspace review checks / verify (push) Has been cancelled
2026-10-30 14:16:14 +08:00
codex-bot eed5d43e21 Fetch review history before checking the patch
creative workspace review checks / verify (push) Failing after 27m59s
creative workspace review checks / verify (pull_request) Failing after 2m20s
2026-10-30 14:13:03 +08:00
codex-bot 5a0554b3b1 Introduce creative landing page and workspace with focused editor layouts
creative workspace review checks / verify (push) Failing after 4s
creative workspace review checks / verify (pull_request) Failing after 2s
2026-10-30 14:10:40 +08:00
Zayden-Jung 82bef38536 Merge pull request 'Allow whole-card dragging outside explicit edit mode' (#26) from codex/canvas-card-drag into main
studio CI and deployment / verify-and-deploy (push) Successful in 8m42s
Reviewed-on: #26
2026-10-29 18:14:12 +00:00
codex-bot d050e06870 Drag canvas cards from their whole surface outside explicit edit mode 2026-10-30 02:03:34 +08:00
Zayden-Jung 045c3d8188 Merge pull request 'Fix Studio release smoke after S3 template migration' (#25) from codex/studio-release-fix into main
studio CI and deployment / verify-and-deploy (push) Successful in 8m53s
Reviewed-on: #25
2026-10-29 17:08:33 +00:00
codex-bot f3e9f5a3fb Use isolated Wiki media fixtures in Studio release smoke checks 2026-10-30 00:59:58 +08:00
codex-bot 922c7bd0d0 Merge pull request 'Move repository images and embedded template bitmaps to S3' (#23) from codex/s3-media into main
studio CI and deployment / verify-and-deploy (push) Failing after 4m49s
waterfall-wiki CI and deployment / verify-and-deploy (push) Failing after 18m8s
2026-10-29 16:43:28 +00:00
codex-bot 3f8bd11a17 Merge pull request 'Rebuild Studio infinite canvas as full-screen movable tools' (#24) from codex/canvas-rebuild into main
studio CI and deployment / verify-and-deploy (push) Failing after 8m37s
2026-10-29 16:43:24 +00:00
codex-bot e2bc4ae6bd Add light and dark canvas themes with floating dock and contextual actions 2026-10-30 00:42:33 +08:00
codex-bot ef66f687bd Expand dotted canvas to full screen and verify real tool gestures 2026-10-30 00:34:49 +08:00
codex-bot f5422b1541 Make canvas nodes editable resizable tools with anchored navigation 2026-10-30 00:26:01 +08:00
codex-bot 19bba57561 Move repository content images to S3-backed media references 2026-10-29 23:03:11 +08:00
Zayden-Jung 906c6985cb Merge pull request 'Fix Studio production acceptance after graphic workspace replaces catalog' (#22) from codex/studio-acceptance into main
studio CI and deployment / verify-and-deploy (push) Successful in 4m16s
Reviewed-on: #22
2026-10-29 14:40:04 +00:00
codex-bot 8649af1b37 Update production visual acceptance for new Studio workspaces 2026-10-29 22:18:17 +08:00
Zayden-Jung bcdcd0906b Merge pull request 'Unify guidance wiki SaaS and Studio Graphic workspaces' (#21) from codex/studio-graphic into main
studio CI and deployment / verify-and-deploy (push) Failing after 8m18s
waterfall-wiki CI and deployment / verify-and-deploy (push) Failing after 18m24s
Reviewed-on: #21
2026-10-29 14:04:25 +00:00
codex-bot cbf70936ab Combine guidance wiki and Studio workspaces for unified review 2026-10-29 21:45:12 +08:00
codex-bot 1a3a335aae Merge remote-tracking branch 'origin/main' into codex/studio-graphic 2026-10-29 21:34:23 +08:00
codex-bot 900fd89af9 Add Studio Graphic workspace and reuse email account service 2026-10-29 21:34:23 +08:00
Zayden-Jung 9b81cad9f9 Merge pull request 'Add Studio infinite canvas with local creative workflow' (#19) from codex/studio-infinite-canvas into main
studio CI and deployment / verify-and-deploy (push) Successful in 16m0s
Reviewed-on: #19
2026-10-29 13:30:19 +00:00
codex-bot 0d8aacb9a2 Rebuild guidance wiki as a unified SaaS workspace 2026-10-29 20:26:24 +08:00
codex-bot 8c5d838944 Add infinite canvas workspace with local drafts and connected nodes 2026-10-29 19:50:59 +08:00
Zayden-Jung f490543853 Merge pull request 'Keep image retention policy checks on main only' (#18) from codex/retention-main-only into main
Wiki image retention checks / retention-policy (push) Has been cancelled
Reviewed-on: #18
2026-10-29 11:22:47 +00:00
codex-bot b94c1e43f1 Keep image retention checks on main updates only 2026-10-29 19:02:45 +08:00
Zayden-Jung 9a603cf87e Merge pull request 'Retain wiki rollback images and expire unused CI images' (#17) from codex/wiki-image-retention into main
Wiki image retention checks / retention-policy (push) Has been cancelled
waterfall-wiki CI and deployment / verify-and-deploy (push) Successful in 40m26s
Reviewed-on: #17
2026-10-29 10:48:15 +00:00
codex-bot dbc3ad8c69 Retain verified wiki releases and expire unused CI images
Wiki image retention checks / retention-policy (pull_request) Successful in 52s
2026-10-29 18:43:57 +08:00
Zayden-Jung 1a7b8f5235 Merge pull request 'Record guidance design research and full acceptance evidence' (#10) from codex/guidance-research into main
Reviewed-on: #10
2026-10-29 10:34:07 +00:00
codex-bot d1020850a7 Record confirmed SMTP delivery and current main CI evidence 2026-10-29 18:27:21 +08:00
Zayden-Jung 254ac628be Merge pull request 'Fix public Agent discovery for independent guidance documents' (#12) from codex/guidance-agent-entry into main
waterfall-wiki CI and deployment / verify-and-deploy (push) Has been cancelled
Reviewed-on: #12
2026-10-29 10:19:43 +00:00
Zayden-Jung be23fe7030 Merge pull request 'Isolate smoke authentication configuration from production' (#16) from codex/smoke-auth-fixture into main
waterfall-wiki CI and deployment / verify-and-deploy (push) Successful in 19m48s
Reviewed-on: #16
2026-10-29 10:19:27 +00:00
codex-bot 6c498255e9 Isolate smoke authentication settings in the loopback child 2026-10-29 18:18:47 +08:00
codex-bot 75b7a025a0 Sync main and preserve public data route expectations 2026-10-29 18:08:19 +08:00
Zayden-Jung 1925d15272 Merge pull request 'Fix guidance integration fixture type inference' (#15) from codex/guidance-test-types into main
waterfall-wiki CI and deployment / verify-and-deploy (push) Failing after 6m53s
Reviewed-on: #15
2026-10-29 10:05:54 +00:00
codex-bot 24beb66484 Type guidance integration fixtures against the protocol 2026-10-29 18:03:17 +08:00
Zayden-Jung 03b8e46731 Merge pull request 'Only trigger CI and deployment on main updates' (#14) from codex/main-only-ci into main
python CI and deployment / verify-and-deploy (push) Successful in 1m2s
studio CI and deployment / verify-and-deploy (push) Successful in 4m13s
waterfall-wiki CI and deployment / verify-and-deploy (push) Failing after 2m4s
Reviewed-on: #14
2026-10-29 10:02:49 +00:00
codex-bot b1f1a454e7 Run CI and deployment only after main updates 2026-10-29 18:00:43 +08:00
codex-bot 1100b79062 Record remaining public SkillHub page templates 2026-10-29 17:57:56 +08:00
Zayden-Jung d779245e09 Merge pull request 'Fix guidance search counts, empty states and restorable filters' (#13) from codex/guidance-directory into main
waterfall-wiki CI and deployment / verify-and-deploy (push) Failing after 3m3s
Reviewed-on: #13
2026-10-29 09:54:47 +00:00
Zayden-Jung 968e7ff353 Merge pull request 'Align public homepage data checks with Next.js normalization' (#11) from codex/wiki-public-data-tests into main
waterfall-wiki CI and deployment / verify-and-deploy (push) Has been cancelled
Reviewed-on: #11
2026-10-29 09:54:36 +00:00
codex-bot 89f72512ae Record observed directory filtering and empty-result behavior 2026-10-29 17:49:52 +08:00
codex-bot 68155fc0f7 Fix guidance directory counts and preserve submitted filters
waterfall-wiki CI and deployment / verify-and-deploy (pull_request) Has been cancelled
2026-10-29 17:46:22 +08:00
codex-bot 554c0d120f Document complete homepage reference observations and delivery boundaries 2026-10-29 17:37:53 +08:00
codex-bot 28b9fb1464 Align homepage data route checks with public knowledge access
waterfall-wiki CI and deployment / verify-and-deploy (pull_request) Has been cancelled
2026-10-29 17:33:22 +08:00
codex-bot db4b00bad0 Connect public agent discovery to standalone versioned guidance
waterfall-wiki CI and deployment / verify-and-deploy (pull_request) Has been cancelled
2026-10-29 17:31:42 +08:00
codex-bot 21983619f3 Analyze reference discussions, evaluation UI and identity-gated publishing 2026-10-29 17:22:25 +08:00
Zayden-Jung 4de92fd059 Merge pull request 'Add persistent guidance publishing with immutable author-owned versions' (#9) from codex/guidance-publishing into main
waterfall-wiki CI and deployment / verify-and-deploy (push) Has been cancelled
Reviewed-on: #9
2026-10-29 09:21:41 +00:00
codex-bot 29e4ac849b Replace legacy homepage expectations with real guidance discovery checks
waterfall-wiki CI and deployment / verify-and-deploy (pull_request) Has been cancelled
2026-10-29 17:17:59 +08:00
codex-bot 5410da8b6d Specify page flows, component responsibilities and reference design choices 2026-10-29 17:15:27 +08:00
codex-bot af1b2c34e8 Track product requirements and runtime acceptance evidence 2026-10-29 17:08:04 +08:00
codex-bot 6e594f3fbf Exercise publishing permissions and XML over real HTTP integration
waterfall-wiki CI and deployment / verify-and-deploy (pull_request) Has been cancelled
2026-10-29 17:07:17 +08:00
codex-bot 5c89d27528 Harden XML round trips, author namespaces and publishing feedback
waterfall-wiki CI and deployment / verify-and-deploy (pull_request) Has been cancelled
2026-10-29 17:03:55 +08:00
codex-bot 0e47bae606 Merge remote-tracking branch 'origin/main' into codex/guidance-publishing
waterfall-wiki CI and deployment / verify-and-deploy (pull_request) Failing after 3m28s
# Conflicts:
#	apps/waterfall-wiki/ACCOUNT.md
#	apps/waterfall-wiki/tests/auth.test.ts
2026-10-29 16:55:12 +08:00
codex-bot 2bf572d353 Clarify homepage integration handoff
waterfall-wiki CI and deployment / verify-and-deploy (pull_request) Failing after 2m5s
2026-10-29 16:53:49 +08:00
codex-bot 9256dd27e1 Expose fixed version navigation and author-only withdrawal controls
waterfall-wiki CI and deployment / verify-and-deploy (pull_request) Failing after 3m26s
2026-10-29 16:51:43 +08:00
Zayden-Jung e920f90c7c Merge pull request 'Prototype independent linked guidance documents and discovery UI' (#7) from codex/linked-guidance into main
waterfall-wiki CI and deployment / verify-and-deploy (push) Failing after 3m31s
Reviewed-on: #7
2026-10-29 08:51:13 +00:00
Zayden-Jung 02267a73b9 Merge pull request 'Fail closed when administrator authentication is unconfigured' (#8) from codex/wiki-auth-hardening into main
waterfall-wiki CI and deployment / verify-and-deploy (push) Failing after 2m5s
Reviewed-on: #8
2026-10-29 08:51:03 +00:00
codex-bot fac685a81c Align authentication regression checks with public guidance routes
waterfall-wiki CI and deployment / verify-and-deploy (pull_request) Failing after 3m22s
2026-10-29 16:50:48 +08:00
codex-bot 7372783062 Record reference screenshots and anonymous publishing limits
waterfall-wiki CI and deployment / verify-and-deploy (pull_request) Failing after 2m3s
2026-10-29 16:50:35 +08:00
codex-bot 39fad306c3 Connect discovery and navigation to real published documents
waterfall-wiki CI and deployment / verify-and-deploy (pull_request) Failing after 6m10s
2026-10-29 16:49:31 +08:00
codex-bot a1984e615a Verify public knowledge reads and protected administrator writes
waterfall-wiki CI and deployment / verify-and-deploy (pull_request) Failing after 3m22s
2026-10-29 16:49:27 +08:00
codex-bot 0445b370fa Merge remote-tracking branch 'origin/codex/linked-guidance' into codex/guidance-publishing
# Conflicts:
#	apps/waterfall-wiki/package.json
2026-10-29 16:48:57 +08:00
codex-bot da45505485 Fix XML entity typing and remove local dependency link 2026-10-29 16:48:42 +08:00
codex-bot d5807aa298 Add persistent author-owned guidance publishing and immutable revisions 2026-10-29 16:47:21 +08:00
codex-bot b157504ca6 Keep account session revocation available when rate limited
waterfall-wiki CI and deployment / verify-and-deploy (pull_request) Failing after 3m15s
2026-10-29 16:46:50 +08:00
codex-bot 36d15dc7bf Merge remote-tracking branch 'origin/main' into codex/linked-guidance
waterfall-wiki CI and deployment / verify-and-deploy (pull_request) Failing after 6m2s
# Conflicts:
#	apps/waterfall-wiki/package.json
2026-10-29 16:45:33 +08:00
codex-bot e141de05fe Merge remote-tracking branch 'origin/codex/wiki-email-auth' into codex/guidance-publishing
# Conflicts:
#	apps/waterfall-wiki/package.json
2026-10-29 16:43:16 +08:00
codex-bot b131e1b11d Fail closed without admin configuration and document SMTP operations
waterfall-wiki CI and deployment / verify-and-deploy (pull_request) Failing after 3m22s
2026-10-29 16:41:50 +08:00
codex-bot c27fa4fe22 Fail closed without admin configuration and document SMTP operations 2026-10-29 16:39:09 +08:00
Zayden-Jung 2df69ec1c6 Merge pull request 'Add verified email accounts independent of administrator access' (#6) from codex/wiki-email-auth into main
waterfall-wiki CI and deployment / verify-and-deploy (push) Failing after 2m1s
Reviewed-on: #6
2026-10-29 08:37:35 +00:00
codex-bot ee69749405 Prototype standalone linked guidance documents and discovery UI
waterfall-wiki CI and deployment / verify-and-deploy (pull_request) Failing after 25m1s
2026-10-29 16:36:25 +08:00
codex-bot 134bc2c9cc Add independent verified email accounts and revocable sessions
waterfall-wiki CI and deployment / verify-and-deploy (pull_request) Failing after 11m56s
2026-10-29 16:34:53 +08:00
Zayden-Jung 8d487fa457 Merge pull request 'Add multi-page Studio artwork documents and native PNG delivery' (#5) from codex/studio-series into main
studio CI and deployment / verify-and-deploy (push) Successful in 3m52s
waterfall-wiki CI and deployment / verify-and-deploy (push) Successful in 5m5s
Reviewed-on: #5
2026-10-28 16:37:53 +00:00
codex-bot 4b24745eb9 Document and verify the high-quality preview HTTP contract
studio CI and deployment / verify-and-deploy (pull_request) Successful in 1m8s
waterfall-wiki CI and deployment / verify-and-deploy (pull_request) Successful in 18m56s
2026-10-29 00:09:22 +08:00
codex-bot 0360a5165b Measure export image resolution using the selected object fit
studio CI and deployment / verify-and-deploy (pull_request) Has been cancelled
waterfall-wiki CI and deployment / verify-and-deploy (pull_request) Has been cancelled
2026-10-29 00:03:40 +08:00
codex-bot dbc575ced7 Exercise multi-page server rendering and transactional asset validation
studio CI and deployment / verify-and-deploy (pull_request) Successful in 6m12s
waterfall-wiki CI and deployment / verify-and-deploy (pull_request) Has been cancelled
2026-10-28 23:53:16 +08:00
codex-bot 6a7d924436 Preserve exported revisions and provide a remote delivery command
studio CI and deployment / verify-and-deploy (pull_request) Has been cancelled
waterfall-wiki CI and deployment / verify-and-deploy (pull_request) Has been cancelled
2026-10-28 23:51:01 +08:00
codex-bot 04df551f6a Harden series validation and synchronize agent delivery contracts
studio CI and deployment / verify-and-deploy (pull_request) Successful in 6m43s
waterfall-wiki CI and deployment / verify-and-deploy (pull_request) Has been cancelled
2026-10-28 23:42:18 +08:00
codex-bot 174a3ef266 Add versioned multi-page Studio artwork documents and native exports
studio CI and deployment / verify-and-deploy (pull_request) Failing after 3m51s
waterfall-wiki CI and deployment / verify-and-deploy (pull_request) Failing after 2m58s
2026-10-28 23:36:54 +08:00
Zayden-Jung d12f9a8dc3 Merge pull request 'Render Studio saved results directly by ID' (#4) from codex/studio-result-page into main
waterfall-wiki CI and deployment / verify-and-deploy (push) Successful in 5m38s
studio CI and deployment / verify-and-deploy (push) Successful in 4m30s
Reviewed-on: #4
2026-10-28 13:43:24 +00:00
codex-bot b550c151bd Preserve the Studio agent documentation response handler
studio CI and deployment / verify-and-deploy (pull_request) Successful in 1m5s
waterfall-wiki CI and deployment / verify-and-deploy (pull_request) Successful in 17m44s
2026-10-28 21:17:04 +08:00
codex-bot eac2dd4cde Expect ID-only result links for both layout families
studio CI and deployment / verify-and-deploy (pull_request) Successful in 5m43s
waterfall-wiki CI and deployment / verify-and-deploy (pull_request) Failing after 1m39s
2026-10-28 21:07:53 +08:00
codex-bot df831a15ba Distinguish historical toolbox examples from current result flow
waterfall-wiki CI and deployment / verify-and-deploy (pull_request) Has been cancelled
studio CI and deployment / verify-and-deploy (pull_request) Has been cancelled
2026-10-28 21:05:04 +08:00
codex-bot de30ee4ec8 Render saved Studio results directly by ID without an editor
studio CI and deployment / verify-and-deploy (pull_request) Failing after 3m5s
waterfall-wiki CI and deployment / verify-and-deploy (pull_request) Has been cancelled
2026-10-28 21:02:21 +08:00
Zayden-Jung efffcdb1fe Merge pull request 'Fit serif headlines inside vertical slots' (#3) from codex/studio-render-api into main
studio CI and deployment / verify-and-deploy (push) Successful in 3m39s
Reviewed-on: #3
2026-10-28 11:16:27 +00:00
920 changed files with 17887 additions and 708 deletions
+27
View File
@@ -0,0 +1,27 @@
name: Endoleap brand checks
on:
pull_request:
branches: [main]
paths:
- 'packages/brand/**'
- 'pnpm-workspace.yaml'
- 'package.json'
- 'AGENTS.md'
- '.gitea/workflows/brand.yml'
push:
branches: [main]
paths:
- 'packages/brand/**'
- 'pnpm-workspace.yaml'
- '.gitea/workflows/brand.yml'
concurrency:
group: brand-${{ gitea.event.pull_request.number || gitea.ref }}
cancel-in-progress: true
jobs:
verify:
runs-on: ces-control
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
- name: Verify brand assets, Tailwind compilation and typed exports in Docker
run: docker build -f packages/brand/Dockerfile -t "endoleap-brand-check:${{ gitea.sha }}" .
+80
View File
@@ -0,0 +1,80 @@
name: creative workspace review checks
on:
pull_request:
branches: [main]
paths:
- 'apps/studio/**'
- 'apps/waterfall-wiki/**'
- '.gitea/workflows/**'
- 'scripts/*studio*'
- 'compose.creative-review.yml'
- 'scripts/integration.sh'
- 'scripts/cleanup-integration.sh'
- 'scripts/test-integration-cleanup.py'
- 'scripts/cleanup-review-images.sh'
- 'scripts/test-review-image-cleanup.py'
concurrency:
group: creative-workspace-${{ gitea.event.pull_request.number }}
cancel-in-progress: true
jobs:
verify-deployed-canvas:
runs-on: ces-control
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- name: Verify production acceptance against the designated deployed server
run: |
node --check scripts/verify-studio-visual.cjs
node --check apps/studio/scripts/infinite-canvas-smoke.cjs
bash -n scripts/verify-studio-production.sh
bash scripts/verify-studio-production.sh
- name: Save server acceptance evidence
id: acceptance_evidence
if: always()
uses: actions/upload-artifact@v3
with:
name: studio-production-acceptance
path: artifacts/studio-acceptance/
if-no-files-found: ignore
retention-days: 7
- name: Remove temporary acceptance output after upload
if: always() && steps.acceptance_evidence.outcome == 'success'
run: rm -rf artifacts/studio-acceptance
verify:
runs-on: ces-control
timeout-minutes: 60
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Check patch formatting
run: |
git diff --check HEAD^ HEAD
bash -n scripts/integration.sh scripts/cleanup-integration.sh scripts/verify-studio-production.sh scripts/cleanup-review-images.sh
node --check scripts/verify-studio-visual.cjs
python3 scripts/test-integration-cleanup.py
python3 scripts/test-review-image-cleanup.py
node --test apps/waterfall-wiki/tests/brand-navigation.test.mjs
- name: Verify Studio types, tests, build and smoke
run: docker build -f apps/studio/Dockerfile -t "creative-studio-review:${{ gitea.sha }}" .
- name: Build Wiki integration and runtime images
run: |
docker build --target integration -t "creative-wiki-tests:${{ gitea.sha }}" .
docker build --target runtime -t "creative-wiki-review:${{ gitea.sha }}" .
- name: Verify isolated PostgreSQL, S3 and browser integration
env:
STUDIO_REVIEW_IMAGE: creative-studio-review:${{ gitea.sha }}
WATERFALL_WIKI_IMAGE: creative-wiki-review:${{ gitea.sha }}
WATERFALL_WIKI_TEST_IMAGE: creative-wiki-tests:${{ gitea.sha }}
GITEA_RUN_ID: ${{ gitea.run_id }}
run: bash scripts/integration.sh
- name: Clean this run disposable integration resources even after cancellation
if: always()
env:
GITEA_RUN_ID: ${{ gitea.run_id }}
run: bash scripts/cleanup-integration.sh
- name: Remove this run disposable review image tags
if: always()
env:
REVIEW_SHA: ${{ gitea.sha }}
run: bash scripts/cleanup-review-images.sh
+2 -7
View File
@@ -6,15 +6,10 @@ on:
- 'apps/python/**'
- 'scripts/deploy-python.sh'
- '.gitea/workflows/python.yml'
pull_request:
paths:
- 'apps/python/**'
- 'scripts/deploy-python.sh'
- '.gitea/workflows/python.yml'
workflow_dispatch:
jobs:
verify-and-deploy:
if: gitea.ref == 'refs/heads/main' && gitea.event_name == 'push'
runs-on: ces-control
timeout-minutes: 45
steps:
@@ -24,5 +19,5 @@ jobs:
- name: Build and verify python
run: docker build -f apps/python/Dockerfile -t "prompt-python:${{ gitea.sha }}" .
- name: Release python
if: gitea.ref == 'refs/heads/main' && (gitea.event_name == 'push' || gitea.event_name == 'workflow_dispatch')
if: gitea.ref == 'refs/heads/main' && gitea.event_name == 'push'
run: bash scripts/deploy-python.sh "${{ gitea.sha }}"
-54
View File
@@ -1,54 +0,0 @@
name: studio CI and deployment
on:
push:
branches: [main]
paths:
- 'apps/studio/**'
- 'scripts/deploy-studio.sh'
- 'scripts/verify-studio-production.sh'
- 'scripts/verify-studio-library.mjs'
- 'scripts/verify-studio-visual.cjs'
- '.gitea/workflows/studio.yml'
- 'pnpm-workspace.yaml'
pull_request:
paths:
- 'apps/studio/**'
- 'scripts/deploy-studio.sh'
- 'scripts/verify-studio-production.sh'
- 'scripts/verify-studio-library.mjs'
- 'scripts/verify-studio-visual.cjs'
- '.gitea/workflows/studio.yml'
- 'pnpm-workspace.yaml'
workflow_dispatch:
jobs:
verify-and-deploy:
runs-on: ces-control
timeout-minutes: 45
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Build and verify studio
run: docker build -f apps/studio/Dockerfile -t "prompt-studio:${{ gitea.sha }}" .
- name: Release studio
if: gitea.ref == 'refs/heads/main' && (gitea.event_name == 'push' || gitea.event_name == 'workflow_dispatch')
run: bash scripts/deploy-studio.sh "${{ gitea.sha }}"
- name: Capture deployed baseline diagnostics (does not validate PR code)
if: gitea.event_name == 'pull_request'
run: bash scripts/verify-studio-production.sh --diagnose
- name: Verify the deployed Studio API and native pages
if: gitea.ref == 'refs/heads/main' && (gitea.event_name == 'push' || gitea.event_name == 'workflow_dispatch')
run: bash scripts/verify-studio-production.sh
- name: Save server acceptance evidence
id: acceptance_evidence
if: always()
uses: actions/upload-artifact@v3
with:
name: studio-production-acceptance
path: artifacts/studio-acceptance/
if-no-files-found: ignore
retention-days: 7
- name: Remove temporary acceptance output after upload
if: always() && steps.acceptance_evidence.outcome == 'success'
run: rm -rf artifacts/studio-acceptance
+49 -29
View File
@@ -1,51 +1,71 @@
name: waterfall-wiki CI and deployment
name: creative workspace CI and deployment
on:
push:
branches: [main]
paths:
- 'apps/waterfall-wiki/**'
- 'apps/studio/**'
- 'scripts/*studio*'
- 'content/studio-examples/**'
- 'content/studio-tools/layouts-api.md'
- '.gitea/workflows/waterfall-wiki.yml'
- 'Dockerfile'
- 'compose.yml'
- 'scripts/deploy.sh'
- 'scripts/wiki-image-lifecycle.py'
- 'scripts/infra.sh'
- 'scripts/auth-admin.sh'
- 'scripts/integration.sh'
- 'scripts/cleanup-integration.sh'
- 'scripts/test-integration-cleanup.py'
- 'pnpm-workspace.yaml'
pull_request:
paths:
- 'apps/waterfall-wiki/**'
- '.gitea/workflows/waterfall-wiki.yml'
- 'Dockerfile'
- 'compose.yml'
- 'scripts/deploy.sh'
- 'scripts/infra.sh'
- 'scripts/auth-admin.sh'
- 'scripts/integration.sh'
- 'pnpm-workspace.yaml'
workflow_dispatch:
jobs:
verify-and-deploy:
if: gitea.ref == 'refs/heads/main' && gitea.event_name == 'push'
runs-on: ces-control
timeout-minutes: 45
timeout-minutes: 60
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Validate configuration
run: docker compose --env-file apps/waterfall-wiki/.env.example config --quiet
- name: Build and verify waterfall-wiki
run: docker build --target integration -t "waterfall-wiki-tests:${{ gitea.sha }}" .
- name: Verify retained character originals
run: docker run --rm --entrypoint node "waterfall-wiki-tests:${{ gitea.sha }}" --test tests/character-pack.test.mjs
- name: Build runtime
run: docker build --target runtime -t "waterfall-wiki:${{ gitea.sha }}" .
- name: Integration stack
- name: Check shared content and documentation contracts
run: |
python3 scripts/test-integration-cleanup.py
bash -n scripts/integration.sh scripts/cleanup-integration.sh
node scripts/check-studio-contracts.mjs
node --check scripts/export-studio-series.cjs
node --check scripts/save-studio-series.mjs
bash -n scripts/publish-studio-guide.sh scripts/deliver-studio-series.sh
- name: Build and verify Studio
run: docker build -f apps/studio/Dockerfile -t "prompt-studio:${{ gitea.sha }}" .
- name: Build, verify, deploy and retain wiki images
env:
WATERFALL_WIKI_IMAGE: waterfall-wiki:${{ gitea.sha }}
WATERFALL_WIKI_TEST_IMAGE: waterfall-wiki-tests:${{ gitea.sha }}
run: bash scripts/integration.sh
- name: Deploy waterfall-wiki
if: gitea.ref == 'refs/heads/main' && (gitea.event_name == 'push' || gitea.event_name == 'workflow_dispatch')
run: bash scripts/deploy.sh "${{ gitea.sha }}"
RELEASE_SHA: ${{ gitea.sha }}
run: |
if [ "$(id -u)" -eq 0 ]; then
python3 scripts/wiki-image-lifecycle.py ci --sha "$RELEASE_SHA"
else
sudo -n python3 scripts/wiki-image-lifecycle.py ci --sha "$RELEASE_SHA"
fi
- name: Release Studio after Wiki routes are deployed
run: bash scripts/deploy-studio.sh "${{ gitea.sha }}"
- name: Verify the deployed creative workspace
run: bash scripts/verify-studio-production.sh
- name: Save server acceptance evidence
id: acceptance_evidence
if: always()
uses: actions/upload-artifact@v3
with:
name: studio-production-acceptance
path: artifacts/studio-acceptance/
if-no-files-found: ignore
retention-days: 7
- name: Remove temporary acceptance output after upload
if: always() && steps.acceptance_evidence.outcome == 'success'
run: rm -rf artifacts/studio-acceptance
- name: Clean this run disposable integration resources even after cancellation
if: always()
env:
GITEA_RUN_ID: ${{ gitea.run_id }}
run: bash scripts/cleanup-integration.sh
@@ -0,0 +1,19 @@
name: Wiki image retention checks
on:
push:
branches: [main]
paths:
- 'scripts/wiki-image-lifecycle.py'
- 'tests/test_wiki_image_lifecycle.py'
- '.gitea/workflows/wiki-image-retention-checks.yml'
jobs:
retention-policy:
if: gitea.ref == 'refs/heads/main' && gitea.event_name == 'push'
runs-on: ces-control
timeout-minutes: 5
steps:
- uses: actions/checkout@v4
- name: Check retention without building or accessing the Docker daemon
run: |
bash -n scripts/deploy.sh
PYTHONDONTWRITEBYTECODE=1 python3 -m unittest discover -s tests -p 'test_wiki_image_lifecycle.py' -v
+18
View File
@@ -10,3 +10,21 @@ dist/
__pycache__/
*.egg-info/
.venv/
# Content images belong in S3; metadata lives in managed-media.json.
*.png
*.jpg
*.jpeg
*.webp
*.gif
*.avif
# Approved, versioned brand resources are package source assets.
!packages/brand/assets/**/*.png
!packages/brand/docs/logo-preview.png
/apps/studio/public/brand/
/apps/studio/public/favicon.ico
/apps/studio/public/apple-touch-icon.png
/apps/waterfall-wiki/public/brand/
/apps/waterfall-wiki/public/favicon.ico
/apps/waterfall-wiki/public/apple-touch-icon.png
+7
View File
@@ -14,3 +14,10 @@ This block is written and re-added by `next dev` — verify at `node_modules/nex
- 页面改动必须先 push,等待 ces-control 的 CI/CD 成功部署,再直接访问服务器检查效果。
- 不启动本地网页预览,不使用 localhost / 127.0.0.1 页面进行视觉验收,也不把本地预览链接交付给用户。
- 类型检查、单元测试等不提供网页预览的检查可以本地执行;完整运行检查由 Docker CI 完成。
## 啡跃 Endoleap 品牌与主题
- 共享品牌包位于 [`packages/brand`](packages/brand/README.md)。修改品牌色、主题或标志前,阅读包内的 [品牌边界](packages/brand/docs/overview.md)、[配色与交互规范](packages/brand/docs/colors.md)、[Tailwind / shadcn 接入](packages/brand/docs/integration.md) 和 [Logo 规则](packages/brand/docs/logo.md)。不在仓库根 docs 重复维护一套品牌规范。
- 四套正式主题为 home-light、home-dark、canvas-light、canvas-dark;不创建 drafts,不恢复 proposed 命名。画布 Light / Dark 背景保持中性近白 / 近黑,用户素材与作品不整体染色。
- 基础色只在 tokens/palette.json 定义,主题引用色板键;Logo 轮廓只在 tokens/geometry.json 定义。保持 CSS、类型声明、资产与清单一致,不通过图片模型重画已固定的图形。
- 本地仅运行不生成产物的轻量读取检查。品牌导出、Tailwind/TypeScript 编译和 SVG 栅格化检查必须通过已确认的 ces-control Docker CI 执行,不在本机启动预览或构建。
+11 -7
View File
@@ -1,4 +1,5 @@
FROM node:24-bookworm-slim AS base
LABEL io.entroia.project="waterfall-wiki"
ENV NEXT_TELEMETRY_DISABLED=1
WORKDIR /app
RUN apt-get update \
@@ -6,13 +7,14 @@ RUN apt-get update \
&& rm -rf /var/lib/apt/lists/*
FROM base AS dependencies
WORKDIR /repo/apps/waterfall-wiki
LABEL io.entroia.wiki.build="true"
RUN corepack enable && corepack prepare pnpm@11.25.0 --activate
COPY apps/waterfall-wiki/pnpm-lock.yaml ./
COPY pnpm-workspace.yaml /tmp/monorepo-policy.yaml
RUN node -e "const fs=require('fs');const text=fs.readFileSync('/tmp/monorepo-policy.yaml','utf8');fs.writeFileSync('pnpm-workspace.yaml', text.slice(text.indexOf('allowBuilds:')));"
RUN pnpm fetch --frozen-lockfile --fetch-timeout=300000 --network-concurrency=8
COPY pnpm-workspace.yaml /repo/pnpm-workspace.yaml
COPY packages/brand/ /repo/packages/brand/
COPY apps/waterfall-wiki/package.json ./
RUN pnpm install --offline --frozen-lockfile
RUN pnpm --filter @prompt/waterfall-wiki install --frozen-lockfile --fetch-timeout=300000 --network-concurrency=8
FROM dependencies AS build
# .dockerignore excludes docs and operational notes without extra legacy COPY snapshots.
@@ -24,14 +26,16 @@ USER node
CMD ["npm", "run", "test:integration"]
FROM base AS runtime
WORKDIR /app/apps/waterfall-wiki
ENV NODE_ENV=production HOSTNAME=0.0.0.0 PORT=3000 \
FFMPEG_PATH=/usr/bin/ffmpeg FFPROBE_PATH=/usr/bin/ffprobe
COPY --from=build --chown=node:node /app/.next/standalone/ ./
COPY --from=build --chown=node:node /app/dist/worker.cjs /app/dist/bootstrap.cjs /app/dist/publish-learning-series.cjs /app/dist/auth-cli.cjs ./
COPY --from=build --chown=node:node /app/db/ ./db/
COPY --from=build --chown=node:node /repo/apps/waterfall-wiki/.next/standalone/ /app/
COPY --from=build --chown=node:node /repo/apps/waterfall-wiki/dist/worker.cjs /repo/apps/waterfall-wiki/dist/bootstrap.cjs /repo/apps/waterfall-wiki/dist/publish-learning-series.cjs /repo/apps/waterfall-wiki/dist/auth-cli.cjs ./
COPY --from=build --chown=node:node /repo/apps/waterfall-wiki/db/ ./db/
COPY --chown=node:node apps/waterfall-wiki/scripts/container-health.mjs ./
COPY --chown=node:node apps/waterfall-wiki/scripts/import-character-packs.mjs apps/waterfall-wiki/scripts/verify-character-pack.mjs ./scripts/
COPY --chown=node:node apps/waterfall-wiki/seed/characters/ ./seed/characters/
COPY --chown=node:node apps/waterfall-wiki/src/content/managed-media.json ./src/content/managed-media.json
USER node
EXPOSE 3000
CMD ["node", "server.js"]
+2 -2
View File
@@ -81,14 +81,14 @@ bash -n scripts/deploy.sh scripts/integration.sh scripts/deploy-studio.sh
## 部署与持久化
Gitea 工作流按应用路径触发:`waterfall-wiki.yml`、`studio.yml` 和 `python.yml`。waterfall-wiki 完成类型检查、单元测试、生产构建、冒烟和真实 PostgreSQL/S3 集成测试后部署;Studio 独立构建和部署。文档与作者数据本身不触发应用部署。
Web 应用由 `waterfall-wiki.yml` 统一发布,两个应用的路径都能触发它:先检查并构建 Studio,再完成 Wiki 的类型检查、单元测试、生产构建、冒烟及隔离 PostgreSQL/S3 集成测试并部署 Wiki,随后部署 Studio,最后在指定服务器验收。顺序在同一个 ces-control 作业内保证,避免独立作业互相等待。PR 检查也运行隔离集成测试,但不部署。Python 检查仍由 `python.yml` 承担;文档与作者数据本身不触发应用部署。
| 服务 | 入口/端口 |
| --- | --- |
| `waterfall-wiki` | 公网 `18131`,也是 Studio 的统一入口 |
| PostgreSQL | `127.0.0.1:18132` |
| 对象存储 | `127.0.0.1:18133` |
| Studio | `127.0.0.1:18134`;公网通过 `/studio` 代理 |
| Studio | `127.0.0.1:18134`;编辑器公网入口为 `/graphic`、`/infinite_canvas`,资源和 API 保留 `/studio` 代理 |
| Python | 无网络端口,按需运行 |
生产 Compose 项目仍为 `prompt`,部署根目录仍为 `/opt/prompt`。既有 `prompt_postgres_data`、`prompt_objects`、`prompt_auth_data` 卷与数据库身份保留;应用更名不会重新创建一套空存储。Compose 应用服务改为 `waterfall-wiki` 和 `waterfall-wiki-worker`,镜像参数为 `WATERFALL_WIKI_IMAGE` / `WATERFALL_WIKI_TEST_IMAGE`。
+11 -9
View File
@@ -1,21 +1,23 @@
FROM node:24-bookworm-slim AS build
WORKDIR /app
WORKDIR /repo/apps/studio
RUN corepack enable && corepack prepare pnpm@11.25.0 --activate
COPY apps/studio/pnpm-lock.yaml ./
COPY pnpm-workspace.yaml /tmp/monorepo-policy.yaml
RUN node -e "const fs=require('fs');const text=fs.readFileSync('/tmp/monorepo-policy.yaml','utf8');fs.writeFileSync('pnpm-workspace.yaml', text.slice(text.indexOf('allowBuilds:')));"
RUN pnpm fetch --frozen-lockfile --fetch-timeout=300000 --network-concurrency=8
COPY pnpm-workspace.yaml /repo/pnpm-workspace.yaml
COPY packages/brand/ /repo/packages/brand/
COPY apps/studio/package.json ./
RUN pnpm install --offline --frozen-lockfile
RUN pnpm --filter @prompt/studio install --frozen-lockfile --fetch-timeout=300000 --network-concurrency=8
COPY apps/studio/ .
# Compare the two public parameter validators inside the isolated CI build.
COPY apps/waterfall-wiki/src/lib/layout-parameters.ts /waterfall-wiki/src/lib/layout-parameters.ts
COPY apps/waterfall-wiki/src/lib/layout-parameters.ts /repo/apps/waterfall-wiki/src/lib/layout-parameters.ts
COPY apps/waterfall-wiki/src/lib/series.ts /repo/apps/waterfall-wiki/src/lib/series.ts
COPY apps/waterfall-wiki/src/lib/canvas-document.ts /repo/apps/waterfall-wiki/src/lib/canvas-document.ts
RUN pnpm typecheck && pnpm test && pnpm build && pnpm test:smoke
FROM node:24-bookworm-slim
WORKDIR /app
WORKDIR /app/apps/studio
ENV NODE_ENV=production HOSTNAME=0.0.0.0 PORT=3000
COPY --from=build --chown=node:node /app/.next/standalone/ ./
COPY --from=build --chown=node:node /app/.next/static/ ./.next/static/
COPY --from=build --chown=node:node /repo/apps/studio/.next/standalone/ /app/
COPY --from=build --chown=node:node /repo/apps/studio/.next/static/ ./.next/static/
COPY --from=build --chown=node:node /repo/apps/studio/public/ ./public/
USER node
EXPOSE 3000
CMD ["node", "server.js"]
+163
View File
@@ -0,0 +1,163 @@
{
"schemaVersion": 2,
"name": "把秋天装进一页 · 银杏观察手记",
"document": {
"version": 1,
"brief": {
"topic": "银杏与秋日观察手记",
"audience": "喜欢日常摄影与视觉手帐的小红书读者",
"intent": "以封面、观察方法和行动结尾组成三页可发布图文",
"designIntent": "奶油纸底、森林墨色、银杏金点缀;宋体大标题与克制的无衬线正文;所有页面保留相同页边距和节奏",
"imageStrategy": "生成一幅原创银杏静物插画作为系列母图;每页复用素材,保留裁切配置,不把正文烘焙进图片"
},
"style": {
"version": "autumn-journal/1",
"palette": {
"paper": "#F3EFE5",
"ink": "#253C32",
"muted": "#718074",
"accent": "#C3A050"
},
"typography": {
"title": {
"fontFamily": "serif",
"fontSize": 8.4,
"fontWeight": 600,
"lineHeight": 1.15,
"letterSpacing": 0
},
"body": {
"fontFamily": "sans",
"fontSize": 3.1,
"fontWeight": 400,
"lineHeight": 1.65,
"letterSpacing": 0
},
"caption": {
"fontFamily": "sans",
"fontSize": 1.7,
"fontWeight": 500,
"lineHeight": 1.2,
"letterSpacing": 0
}
},
"spacing": {
"margin": 10,
"gap": 4
}
},
"sources": [],
"pages": [
{
"key": "cover",
"role": "cover",
"layoutId": "standard-01",
"intent": "用银杏静物与两行大标题建立主题",
"bindings": {
"T01": "title",
"T02": "body",
"T03": "caption"
},
"parameters": {
"texts": {
"T01": {
"value": "把秋天\n装进一页"
},
"T02": {
"value": "不必走很远。\n一片银杏,就是今天的风景。"
},
"T03": {
"value": "SEASONAL NOTES / 01"
}
},
"images": {
"I01": {
"fit": "cover",
"cropX": 50,
"cropY": 50,
"zoom": 1
}
},
"decorations": {}
}
},
{
"key": "observe",
"role": "body",
"layoutId": "standard-01",
"intent": "用三个观察动作提供可实践的方法",
"bindings": {
"T01": "title",
"T02": "body",
"T03": "caption"
},
"parameters": {
"texts": {
"T01": {
"value": "先看见\n再记录"
},
"T02": {
"value": "看叶脉,看光落下的方向。\n留住一片金黄,也留出一半空白。\n把感受写成短句,画面就有了呼吸。"
},
"T03": {
"value": "FIELD NOTES / 02"
}
},
"images": {
"I01": {
"fit": "cover",
"cropX": 65,
"cropY": 40,
"zoom": 1.25
}
},
"decorations": {}
}
},
{
"key": "ending",
"role": "ending",
"layoutId": "standard-01",
"intent": "以轻量行动和提问收尾",
"bindings": {
"T01": "title",
"T02": "body",
"T03": "caption"
},
"parameters": {
"texts": {
"T01": {
"value": "留一点白\n给下一次",
"align": "middle"
},
"T02": {
"value": "今天,捡起一片叶子。\n用三个词记录它的颜色、纹理与心情。\n你的秋天,会从哪一页开始?",
"align": "middle"
},
"T03": {
"value": "YOUR TURN / 03"
}
},
"images": {
"I01": {
"fit": "cover",
"cropX": 65,
"cropY": 50,
"zoom": 1
}
},
"decorations": {},
"layers": {
"I01": {
"x": 40,
"y": 55,
"width": 40,
"height": 40,
"radius": 20
}
}
}
}
]
}
}
+2 -1
View File
@@ -1,3 +1,4 @@
import { resolve } from "node:path";
import type { NextConfig } from "next";
const config: NextConfig = { basePath: "/studio", output: "standalone", outputFileTracingRoot: process.cwd(), poweredByHeader: false };
const config: NextConfig = { turbopack: { root: resolve(process.cwd(), "../..") }, basePath: "/studio", output: "standalone", outputFileTracingRoot: resolve(process.cwd(), "../.."), poweredByHeader: false };
export default config;
+6 -4
View File
@@ -2,18 +2,20 @@
"name": "@prompt/studio",
"private": true,
"scripts": {
"dev": "next dev -p 3001",
"build": "next build",
"dev": "pnpm brand:prepare && next dev -p 3001",
"build": "pnpm brand:prepare && next build",
"start": "next start",
"typecheck": "next typegen && tsc --noEmit",
"test": "node --test tests/*.test.mjs",
"test:smoke": "node scripts/smoke.mjs"
"test:smoke": "node scripts/smoke.mjs",
"brand:prepare": "node ../../packages/brand/scripts/prepare-app.mjs"
},
"dependencies": {
"next": "16.4.2",
"react": "19.3.1",
"react-dom": "19.3.1",
"lucide-react": "1.61.0"
"lucide-react": "1.61.0",
"@endoleap/brand": "workspace:*"
},
"devDependencies": {
"@tailwindcss/postcss": "4.3.3",
+3
View File
@@ -8,6 +8,9 @@ importers:
.:
dependencies:
'@endoleap/brand':
specifier: workspace:*
version: link:../../packages/brand
lucide-react:
specifier: 1.61.0
version: 1.61.0(@types/react@19.3.0)(react@19.3.1)
+24
View File
@@ -0,0 +1,24 @@
const {chromium}=require('playwright-core');
const assert=require('node:assert/strict');
(async()=>{const browser=await chromium.launch({channel:'chrome',headless:true});try{const page=await browser.newPage({viewport:{width:1440,height:1000}});const errors=[];page.on('pageerror',e=>errors.push(e.message));
await page.route('**/api/account/session',r=>r.fulfill({json:{user:null}}));
await page.goto((process.env.STUDIO_TEST_ORIGIN||'http://8.211.154.134:18131')+'/graphic');
await page.getByRole('button',{name:'切换图层面板',exact:true}).click();
assert.equal(await page.getByRole('button',{name:'图层',exact:true}).isVisible(),false);
await page.getByRole('button',{name:'切换图层面板',exact:true}).click();
await page.getByRole('button',{name:'切换属性面板',exact:true}).click();
assert.equal(await page.getByRole('button',{name:'备份设计 JSON',exact:true}).isVisible(),false);
await page.getByRole('button',{name:'切换属性面板',exact:true}).click();
await page.getByRole('button',{name:'添加文字',exact:true}).click();
await page.getByRole('textbox',{name:'文字内容',exact:true}).fill('设计从这里开始');
await page.getByRole('button',{name:'添加矩形',exact:true}).click();
await page.getByRole('button',{name:'撤销',exact:true}).click();
await page.getByRole('button',{name:'重做',exact:true}).click();
await page.reload();await page.getByRole('button',{name:'文字',exact:true}).first().waitFor();
assert.ok(await page.evaluate(()=>localStorage.getItem('studio.graphic.v1').includes('设计从这里开始')));
const file=page.waitForEvent('download');await page.getByRole('button',{name:'导出 SVG',exact:true}).click();assert.ok((await file).suggestedFilename().endsWith('.svg'));
await page.getByRole('button',{name:'邮箱登录',exact:true}).click();await page.getByRole('dialog').waitFor();await page.keyboard.press('Escape');await page.getByRole('dialog').waitFor({state:'hidden'});
await page.screenshot({path:process.env.STUDIO_QA_SCREENSHOT||'graphic-qa.png',fullPage:true});
await page.setViewportSize({width:390,height:844});assert.equal(await page.evaluate(()=>document.documentElement.scrollWidth>innerWidth),false);
assert.deepEqual(errors,[]);console.log('Graphic editing, history, restore, SVG download, account dialog and mobile overflow checks passed.');
}finally{await browser.close()}})().catch(e=>{console.error(e);process.exitCode=1});
@@ -0,0 +1,128 @@
// Isolated browser context against the designated deployed server only.
const {chromium}=require('playwright-core');
const assert=require('node:assert/strict');
const fs=require('node:fs/promises');
(async()=>{
const base=process.env.BASE_URL||'http://8.211.154.134:18131';
assert.equal(base,'http://8.211.154.134:18131');
const browser=await chromium.launch(process.env.CHROMIUM_PATH?{executablePath:process.env.CHROMIUM_PATH,headless:true,args:['--no-sandbox','--disable-dev-shm-usage']}:{channel:'chrome',headless:true});
let page;
try{
const context=await browser.newContext({viewport:{width:1600,height:1100},storageState:process.env.STORAGE_STATE||undefined});
page=await context.newPage();const errors=[];let choosers=0;
page.on('pageerror',e=>errors.push(e.message));page.on('filechooser',()=>choosers++);
const snapshot=()=>page.evaluate(()=>JSON.parse(localStorage.getItem('studio.infinite-canvas.v1')));
const node=id=>page.locator(`[data-node-id="${id}"]`);
const content=id=>node(id).locator('[data-node-content]');
const waitField=(id,key,value)=>page.waitForFunction(({id,key,value})=>JSON.parse(localStorage.getItem('studio.infinite-canvas.v1')).cards.find(n=>n.id===id)?.[key]===value,{id,key,value});
const composer=page.getByRole('region',{name:'节点编辑器'});
const capture=async name=>{if(process.env.SCREENSHOT_DIR){await fs.mkdir(process.env.SCREENSHOT_DIR,{recursive:true});await page.screenshot({path:process.env.SCREENSHOT_DIR+'/'+name+'.png'})}};
async function unobstructed(id){const box=await node(id).boundingBox();assert.ok(box);for(const card of (await snapshot()).cards.filter(n=>n.id!==id)){const other=await node(card.id).boundingBox();assert.ok(other);assert.equal(box.x<other.x+other.width&&box.x+box.width>other.x&&box.y<other.y+other.height&&box.y+box.height>other.y,false,`${id} overlaps ${card.id}`)}}
const close=(a,b)=>assert.ok(Math.abs(a-b)<2,`${a} ≈ ${b}`);
async function drag(handle,dx,dy){const b=await handle.boundingBox();assert.ok(b);await page.mouse.move(b.x+b.width/2,b.y+b.height/2);await page.mouse.down();await page.mouse.move(b.x+b.width/2+dx,b.y+b.height/2+dy,{steps:12});await page.mouse.up()}
const canvasResponse=await page.goto(base+'/infinite_canvas',{waitUntil:'commit',timeout:60000});
assert.ok(canvasResponse?.ok(),'Canvas document returns HTTP success');
await page.locator('button[data-theme-ready="true"]').waitFor();
await page.getByRole('button',{name:'从示例开始 ↗',exact:true}).click();
await page.getByRole('button',{name:'适应画布',exact:true}).click();
await content('brief').click({position:{x:20,y:20}});
assert.equal(await node('brief').getAttribute('data-selected'),'true');assert.equal(await node('brief').getAttribute('data-editing'),'false');
await page.waitForTimeout(700);
await content('brief').click({position:{x:20,y:20}});
await node('brief').getByRole('textbox',{name:'01 / 创意简报 内容'}).fill('选择后再次点击进入编辑');
await waitField('brief','text','选择后再次点击进入编辑');
await node('brief').getByRole('button',{name:'加粗',exact:true}).click();
assert.equal((await snapshot()).cards.find(n=>n.id==='brief').style.bold,true);
await node('brief').getByRole('button',{name:'斜体',exact:true}).click();
await node('brief').getByLabel('文字颜色',{exact:true}).fill('#78905e');
await node('brief').getByRole('combobox',{name:'文字样式',exact:true}).selectOption('24');
await page.waitForFunction(()=>{const n=JSON.parse(localStorage.getItem('studio.infinite-canvas.v1')).cards.find(n=>n.id==='brief');return n.style.bold&&n.style.italic&&n.style.color==='#78905e'&&n.style.size===24});
assert.deepEqual(await node('brief').getByRole('textbox',{name:'01 / 创意简报 内容'}).evaluate(el=>{const s=getComputedStyle(el);return {size:s.fontSize,weight:s.fontWeight,style:s.fontStyle,color:s.color}}),{size:'24px',weight:'700',style:'italic',color:'rgb(120, 144, 94)'});
const compact=(await snapshot()).cards.find(n=>n.id==='brief');
await node('brief').getByRole('button',{name:'展开文本节点',exact:true}).click();
const expanded=(await snapshot()).cards.find(n=>n.id==='brief');assert.ok(expanded.width>(compact.width||320)&&expanded.height>(compact.height||350));
await capture('canvas-text-format-desktop');
await page.getByRole('button',{name:'撤销',exact:true}).click();
assert.equal((await snapshot()).cards.find(n=>n.id==='brief').width,compact.width);assert.equal((await snapshot()).cards.find(n=>n.id==='brief').height,compact.height);
await page.keyboard.press('Escape');
const before=(await snapshot()).cards.find(n=>n.id==='brief');
const z=await page.locator('[class*=world]').evaluate(el=>new DOMMatrix(getComputedStyle(el).transform).a);
await drag(content('brief'),45,25);
const after=(await snapshot()).cards.find(n=>n.id==='brief');close(after.x-before.x,45/z);close(after.y-before.y,25/z);
assert.equal(await node('brief').getAttribute('data-editing'),'false');
await content('reference').click();assert.equal(await composer.count(),0);
await page.waitForTimeout(700);await content('reference').click();await composer.waitFor();assert.equal(choosers,0,'Entering image editing must not force a file picker');
const panelBefore=await composer.boundingBox(),imageBefore=await node('reference').boundingBox();
await capture('canvas-composer-desktop');
await page.getByRole('button',{name:'缩小',exact:true}).click();
const panelAfter=await composer.boundingBox(),imageAfter=await node('reference').boundingBox();
close(panelBefore.width,panelAfter.width);close(panelBefore.height,panelAfter.height);assert.ok(imageAfter.width<imageBefore.width);
const chooser=page.waitForEvent('filechooser');await composer.getByRole('button',{name:'上传图片',exact:true}).click();
await (await chooser).setFiles({name:'reference.png',mimeType:'image/png',buffer:Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+ip1sAAAAASUVORK5CYII=','base64')});
await page.waitForFunction(()=>!!JSON.parse(localStorage.getItem('studio.infinite-canvas.v1')).cards.find(n=>n.id==='reference').image);
await node('reference').getByRole('button',{name:'图片高清 ↗',exact:true}).click();
const upscale=(await snapshot()).cards.find(n=>n.operation==='upscale');assert.ok(upscale);
await unobstructed(upscale.id);
const download=page.waitForEvent('download');await composer.getByRole('button',{name:'导出任务',exact:true}).click();
const task=JSON.parse(await fs.readFile(await (await download).path(),'utf8'));
assert.equal(task.target.id,upscale.id);assert.equal(task.references[0].id,'reference');assert.ok(task.references[0].image);
await page.keyboard.press('Escape');await page.getByRole('button',{name:'适应画布',exact:true}).click();
await content('brief').click({position:{x:20,y:20}});
await node('brief').getByRole('button',{name:'引用 01 / 创意简报 生成',exact:true}).click();
await page.getByRole('dialog',{name:'画布操作'}).getByRole('button',{name:'脚本',exact:true}).click();
const script=(await snapshot()).cards.find(n=>n.kind==='script');assert.ok(script);
await unobstructed(script.id);
await composer.getByRole('textbox',{name:'创作要求',exact:true}).fill('基于简报写六个分镜');
await waitField(script.id,'instruction','基于简报写六个分镜');assert.equal((await snapshot()).cards.find(n=>n.id===script.id).text,'');
await composer.getByRole('button',{name:'画布参考',exact:true}).click();
await composer.getByRole('group',{name:'选择引用节点'}).getByRole('button',{name:'02 / 视觉参考',exact:true}).click();
assert.ok((await snapshot()).links.some(([a,b])=>a==='reference'&&b===script.id));
await composer.getByRole('button',{name:'移除引用 01 / 创意简报',exact:true}).click();
assert.ok(!(await snapshot()).links.some(([a,b])=>a==='brief'&&b===script.id));
await page.keyboard.press('Escape');
await page.getByRole('button',{name:'适应画布',exact:true}).click();
await content('reference').click();await node('reference').getByRole('button',{name:'02 / 视觉参考 操作',exact:true}).click();
await page.getByRole('button',{name:'删除节点',exact:true}).click();
assert.ok((await snapshot()).cards.some(n=>n.id===script.id));assert.ok((await snapshot()).links.every(l=>!l.includes('reference')));
await page.getByRole('button',{name:'撤销',exact:true}).click();assert.ok((await snapshot()).links.some(([a,b])=>a==='reference'&&b===script.id));
await page.keyboard.press('Escape');
await content('brief').click({position:{x:20,y:20}});
const priorWidth=(await snapshot()).cards.find(n=>n.id==='brief').width;
await drag(node('brief').getByRole('button',{name:'调整 01 / 创意简报 尺寸',exact:true}),50,30);
assert.ok((await snapshot()).cards.find(n=>n.id==='brief').width>(priorWidth||320));
await page.getByRole('button',{name:'撤销',exact:true}).click();assert.equal((await snapshot()).cards.find(n=>n.id==='brief').width,priorWidth);
await page.getByRole('button',{name:'重做',exact:true}).click();
await page.getByRole('button',{name:'移动画布',exact:true}).click();
await page.mouse.move(15,300);await page.mouse.down();await page.mouse.move(75,340,{steps:8});await page.mouse.up();
assert.equal(await page.evaluate(()=>{const path=[...document.querySelectorAll('svg path')].find(p=>p.getAttribute('d')?.startsWith('M '));const d=path.getAttribute('d').match(/-?\d+(?:\.\d+)?/g).map(Number),point=new DOMPoint(d[0],d[1]).matrixTransform(path.getScreenCTM()),card=document.querySelector('[data-node-id="brief"]').getBoundingClientRect();return Math.abs(point.x-card.right)<3&&Math.abs(point.y-(card.top+card.height/2))<3}),true);
const worldAt=()=>page.locator('[class*=world]').evaluate(el=>{const m=new DOMMatrix(getComputedStyle(el).transform);return {x:(1450-m.e)/m.a,y:(150-m.f)/m.d,z:m.a}});
const anchorBefore=await worldAt();await page.mouse.move(1450,150);await page.keyboard.down('Control');await page.mouse.wheel(0,-80);await page.keyboard.up('Control');
await page.waitForFunction(z=>new DOMMatrix(getComputedStyle(document.querySelector('[class*=world]')).transform).a!==z,anchorBefore.z);
const anchorAfter=await worldAt();close(anchorBefore.x,anchorAfter.x);close(anchorBefore.y,anchorAfter.y);
const saved=await snapshot();await page.reload({waitUntil:'commit',timeout:60000});await page.locator('button[data-theme-ready="true"]').waitFor();await page.waitForFunction(()=>!!localStorage.getItem('studio.infinite-canvas.v1'));assert.deepEqual(await snapshot(),saved);
const themeToggle=page.getByRole('button',{name:'切换明暗主题',exact:true});
await page.waitForFunction(()=>document.querySelector('[data-theme-ready]')?.dataset.themeReady==='true');
const initialMode=await page.evaluate(()=>document.documentElement.dataset.mode);
assert.ok(['light','dark'].includes(initialMode));
for(const mode of [initialMode==='light'?'dark':'light',initialMode]){
await themeToggle.click();
const matchesTheme=mode=>{
const root=document.documentElement,canvas=document.querySelector('main');
return root.dataset.mode===mode&&root.dataset.theme===`canvas-${mode}`&&localStorage.getItem('endoleap.theme')===mode&&getComputedStyle(canvas).backgroundColor===(mode==='dark'?'rgb(20, 20, 20)':'rgb(250, 250, 250)');
};
await page.waitForFunction(matchesTheme,mode);
await page.reload({waitUntil:'commit'});
await page.waitForFunction(()=>document.querySelector('[data-theme-ready]')?.dataset.themeReady==='true');
await page.waitForFunction(matchesTheme,mode);
assert.deepEqual(await snapshot(),saved,'Theme changes must preserve canvas content');
await capture('canvas-theme-'+mode);
}
if(process.env.SCREENSHOT_DIR){await fs.mkdir(process.env.SCREENSHOT_DIR,{recursive:true});await page.screenshot({path:process.env.SCREENSHOT_DIR+'/canvas-nodes-desktop.png'});}
await page.setViewportSize({width:390,height:844});assert.ok(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth));
const viewport=await page.locator('[class*=viewport]').boundingBox();assert.equal(viewport.width,390);assert.equal(viewport.height,844);
if(process.env.SCREENSHOT_DIR)await page.screenshot({path:process.env.SCREENSHOT_DIR+'/canvas-nodes-mobile.png'});
assert.deepEqual(errors,[]);console.log('Passed: selected-node re-click editing, drag, formatting, fixed composer, upload, references, task export, deletion/undo, reload, both persistent root themes and mobile bounds');
}catch(error){if(process.env.SCREENSHOT_DIR&&page){await fs.mkdir(process.env.SCREENSHOT_DIR,{recursive:true});await page.screenshot({path:process.env.SCREENSHOT_DIR+'/canvas-failure.png'}).catch(()=>{})}throw error}finally{await browser.close()}
})().catch(e=>{console.error(e);process.exitCode=1});
+20 -8
View File
@@ -1,17 +1,29 @@
/** CI-only isolated runtime checks, never a local preview workflow. */
import {readFileSync} from 'node:fs';
import {spawn} from 'node:child_process';import {once} from 'node:events';import {createServer} from 'node:net';import {createServer as createHttpServer} from 'node:http';import assert from 'node:assert/strict';import {setTimeout} from 'node:timers/promises';
const designId='00000000-0000-4000-8000-000000000001',missingId='00000000-0000-4000-8000-000000000002';
let reads=0;
const wiki=createHttpServer((req,res)=>{reads++;assert.equal(req.headers.cookie,'session=smoke-test');res.setHeader('Content-Type','application/json');if(!req.url.endsWith(designId)){res.writeHead(404);return res.end('{}')}res.end(JSON.stringify({design:{id:designId,name:'已保存的实际页面',layoutId:'ref-01',revision:3,url:'/studio/designs/'+designId,parameters:{added:[{id:'user-smoke',kind:'text',role:'Added text',box:[10,10,20,10],direction:'horizontal',text:'新增元素已恢复',reading:'custom'}],layers:{T01:{x:12,rotation:25,opacity:.7},'user-smoke':{z:12}},texts:{T01:{value:'确定的页面文字',color:'#123456'}},images:{I01:{color:'#abcdef'}},decorations:{D01:{hidden:true}},paper:'#eeeeee'}}}))});
const standardId='00000000-0000-4000-8000-000000000003';
const seriesId='00000000-0000-4000-8000-000000000004';
const doc=JSON.parse(readFileSync(new URL('../fixtures/autumn-journal.json',import.meta.url))).document;
const assetId='00000000-0000-4000-8000-000000000005';doc.sources=[{assetId,author:'CI fixture',license:'test only',url:'generated',prompt:'test image',tool:'fixture'}];for(const p of doc.pages)p.parameters.images.I01.assetId=assetId;
let reads=0,mediaReads=0;
const wiki=createHttpServer((req,res)=>{assert.equal(req.headers.cookie,'session=smoke-test');if(/^\/api\/media\/[0-9a-f-]{36}$/.test(req.url)){mediaReads++;res.setHeader('Content-Type','image/png');return res.end(Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+j3ioAAAAASUVORK5CYII=','base64'))}reads++;res.setHeader('Content-Type','application/json');if(req.url.endsWith(seriesId))return res.end(JSON.stringify({design:{id:seriesId,name:'完整三页作品',layoutId:'standard-01',revision:1,url:'/studio/template-layout?id='+seriesId,parameters:doc.pages[0].parameters,document:doc}}));const standard=req.url.endsWith(standardId);if(!standard&&!req.url.endsWith(designId)){res.writeHead(404);return res.end('{}')}res.end(JSON.stringify({design:{id:standard?standardId:designId,name:'已保存的实际页面',layoutId:standard?'standard-01':'ref-01',revision:3,url:'/studio/designs/'+designId,parameters:{added:[{id:'user-smoke',kind:'text',role:'Added text',box:[10,10,20,10],direction:'horizontal',text:'新增元素已恢复',reading:'custom'}],layers:{T01:{x:12,rotation:25,opacity:.7},'user-smoke':{z:12}},texts:{T01:{value:'确定的页面文字',color:'#123456'}},images:{I01:{color:'#abcdef'}},decorations:{D01:{hidden:true}},paper:'#eeeeee'}}}))});
wiki.listen(0,'127.0.0.1');await once(wiki,'listening');
const socket=createServer();socket.listen(0,'127.0.0.1');await once(socket,'listening');const port=socket.address().port;await new Promise(r=>socket.close(r));const child=spawn(process.execPath,['.next/standalone/server.js'],{env:{...process.env,PORT:String(port),HOSTNAME:'127.0.0.1',STUDIO_WIKI_ORIGIN:'http://127.0.0.1:'+wiki.address().port},stdio:'inherit'}),base='http://127.0.0.1:'+port;
try{let ready=false;for(let i=0;i<60;i++){try{if((await fetch(base+'/studio/api/health')).ok){ready=true;break}}catch{}await setTimeout(250)}assert.ok(ready);const library=await fetch(base+'/studio').then(r=>r.text());assert.ok(library.includes('standard-01'));assert.ok(library.includes('ref-27'));const catalog=await fetch(base+'/studio/api/layouts').then(r=>r.json());assert.equal(catalog.layouts.length,28);
for(const l of catalog.layouts){const page=await fetch(base+l.url);assert.equal(page.status,200,l.id);const html=await page.text();assert.ok(html.includes(l.name));assert.ok(html.includes('data-layout-id="'+l.id+'"'));assert.ok(!/<svg|<img|data:image|trial-controls/.test(html),'Default page must use native elements, not source imagery');const schema=await fetch(base+l.api).then(r=>r.json());assert.equal(schema.id,l.id);for(const s of schema.elements)assert.ok(html.includes('data-element="'+s.id+'"'));if(l.family==='standard'){assert.equal((await fetch(base+l.api+'?format=svg')).status,400);continue;}const original=await fetch(base+l.api+'?format=svg').then(r=>r.text());assert.ok(original.includes('data-layout-id="'+l.id+'"'));const text=schema.elements.find(e=>e.kind==='text');const result=await fetch(base+l.api,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({texts:{[text.id]:{value:'API render <test>'}}})});assert.equal(result.status,200);assert.ok((await result.text()).includes('&lt;'));}
assert.equal(reads,0,'Default pages never load a saved design');
const socket=createServer();socket.listen(0,'127.0.0.1');await once(socket,'listening');const port=socket.address().port;await new Promise(r=>socket.close(r));const child=spawn(process.execPath,[process.env.STUDIO_STANDALONE_SERVER || '.next/standalone/apps/studio/server.js'],{env:{...process.env,PORT:String(port),HOSTNAME:'127.0.0.1',STUDIO_WIKI_ORIGIN:'http://127.0.0.1:'+wiki.address().port},stdio:'inherit'}),base='http://127.0.0.1:'+port;
try{let ready=false;for(let i=0;i<60;i++){try{if((await fetch(base+'/studio/api/health')).ok){ready=true;break}}catch{}await setTimeout(250)}assert.ok(ready);const entry=await fetch(base+'/studio',{redirect:'manual'});assert.equal(entry.status,307);assert.equal(entry.headers.get('location'),'/studio/graphic');const library=await fetch(base+'/studio/graphic').then(r=>r.text());assert.ok(library.includes('GRAPHIC'));assert.ok(!library.includes('ref-27')); const catalog=await fetch(base+'/studio/api/layouts').then(r=>r.json());assert.equal(catalog.layouts.length,28);
for(const l of catalog.layouts){const page=await fetch(base+l.url);assert.equal(page.status,200,l.id);const html=await page.text();assert.ok(html.includes(l.name));assert.ok(html.includes('data-layout-id="'+l.id+'"'));assert.ok(!/<svg|<img|data:image|trial-controls|page-tools|workbench-top|contenteditable|编辑页面/.test(html),'Default page must use native elements, not source imagery');const schema=await fetch(base+l.api).then(r=>r.json());assert.equal(schema.id,l.id);for(const s of schema.elements)assert.ok(html.includes('data-element="'+s.id+'"'));if(l.family==='standard'){assert.equal((await fetch(base+l.api+'?format=svg')).status,400);continue;}const original=await fetch(base+l.api+'?format=svg',{headers:{cookie:'session=smoke-test'}}).then(r=>r.text());assert.ok(original.includes('data-layout-id="'+l.id+'"'));const text=schema.elements.find(e=>e.kind==='text');const result=await fetch(base+l.api,{method:'POST',headers:{'Content-Type':'application/json',cookie:'session=smoke-test'},body:JSON.stringify({texts:{[text.id]:{value:'API render <test>'}}})});assert.equal(result.status,200);assert.ok((await result.text()).includes('&lt;'));}
assert.equal(reads,0,'Default pages never load a saved design');assert.ok(mediaReads>0,'SVG exports load isolated media fixtures');
const headers={cookie:'session=smoke-test'};
const saved=await fetch(base+'/studio/layouts/ref-01?id='+designId,{headers});assert.equal(saved.status,200);const savedHtml=await saved.text();assert.ok(savedHtml.includes('确定的页面文字'));assert.ok(savedHtml.includes('新增元素已恢复'));assert.ok(savedHtml.includes('data-element="user-smoke"'));assert.ok(savedHtml.includes('rotate(25deg)'));assert.ok(savedHtml.includes('opacity:0.7'));assert.ok(savedHtml.includes('data-design-id="'+designId+'"'));assert.ok(savedHtml.includes('#123456'));assert.ok(!savedHtml.includes('data-element="D01"'));assert.ok(!savedHtml.includes('data:image'));
for(const id of [designId,standardId]){
const saved=await fetch(base+'/studio/template-layout?id='+id,{headers});assert.equal(saved.status,200);const savedHtml=await saved.text();assert.ok(savedHtml.includes('确定的页面文字'));assert.ok(savedHtml.includes('新增元素已恢复'));assert.ok(savedHtml.includes('data-element="user-smoke"'));assert.ok(savedHtml.includes('rotate(25deg)'));assert.ok(savedHtml.includes('opacity:0.7'));assert.ok(savedHtml.includes('data-design-id="'+id+'"'));assert.ok(savedHtml.includes('#123456'));assert.ok(!savedHtml.includes('data-element="D01"'));assert.ok(!savedHtml.includes('data:image'));assert.ok(!/page-tools|workbench-top|contenteditable|编辑页面/.test(savedHtml));
}
const complete=await fetch(base+'/studio/template-layout?id='+seriesId,{headers});assert.equal(complete.status,200);const completeHtml=await complete.text(),artwork=completeHtml.match(/<main[\s\S]*?<\/main>/)[0];assert.equal((artwork.match(/data-page-key=/g)||[]).length,3);assert.ok(artwork.includes('data-style-version="autumn-journal/1"'));assert.ok(artwork.includes('把秋天'));assert.ok(artwork.includes('先看见'));assert.ok(artwork.includes('留一点白'));assert.ok(!/button|contenteditable|workbench-top/.test(artwork));
const second=await fetch(base+'/studio/template-layout?id='+seriesId+'&page=2&export=1',{headers});assert.equal(second.status,200);const secondHtml=(await second.text()).match(/<main[\s\S]*?<\/main>/)[0];assert.equal((secondHtml.match(/data-page-key=/g)||[]).length,1);assert.ok(secondHtml.includes('data-page="2"'));assert.ok(secondHtml.includes('height:1440px'));assert.ok(secondHtml.includes('width=2160'));assert.ok(secondHtml.includes('profile=export'));assert.ok(secondHtml.includes('Noto Serif CJK SC'));
for(const suffix of ['0','4','bad'])assert.equal((await fetch(base+'/studio/template-layout?id='+seriesId+'&page='+suffix,{headers})).status,404);
for(const path of ['/studio/template-layout','/studio/template-layout?id=invalid','/studio/template-layout?id='+missingId,'/studio/template-layout?id='+designId+'&id='+standardId])assert.equal((await fetch(base+path,{headers})).status,404);
assert.equal((await fetch(base+'/studio/layouts/ref-02?id='+designId,{headers})).status,404);
assert.equal((await fetch(base+'/studio/layouts/ref-01?id='+missingId,{headers})).status,404);
const beforeInvalid=reads;assert.equal((await fetch(base+'/studio/layouts/ref-01?id=invalid',{headers})).status,404);assert.equal(reads,beforeInvalid);
const legacy=await fetch(base+'/studio/designs/'+designId,{headers,redirect:'manual'});assert.equal(legacy.status,307);assert.equal(legacy.headers.get('location'),'/studio/layouts/ref-01?id='+designId);
const legacy=await fetch(base+'/studio/designs/'+designId,{headers,redirect:'manual'});assert.equal(legacy.status,307);assert.equal(legacy.headers.get('location'),'/studio/template-layout?id='+designId);
const old=await fetch(base+'/studio/layouts/ref-01?id='+designId,{headers,redirect:'manual'});assert.equal(old.status,307);assert.equal(old.headers.get('location'),'/studio/template-layout?id='+designId);
assert.equal((await fetch(base+'/studio/layouts/ref-28')).status,404);assert.equal((await fetch(base+'/studio/api/layouts/ref-01',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({texts:{I01:{value:'bad'}}})})).status,400);console.log('All 27 native pages, server-loaded saved IDs, legacy redirects and render APIs passed');}finally{child.kill('SIGTERM');await once(child,'exit');await new Promise(r=>wiki.close(r))}
+4
View File
@@ -0,0 +1,4 @@
/** The approved mark is copied from @endoleap/brand during the remote build. */
export function Brand({ compact = false }: { compact?: boolean }) {
return <span className="endoleap-brand"><img src="/studio/brand/logo-light.svg" className="endoleap-mark endoleap-mark-light" alt="" width={32} height={32}/><img src="/studio/brand/logo-dark.svg" className="endoleap-mark endoleap-mark-dark" alt="" width={32} height={32}/><span className="endoleap-wordmark">啡跃{!compact && <small>Endoleap</small>}</span></span>;
}
@@ -10,5 +10,5 @@ export function LayoutSelection(){
async function download(format:'svg'|'json'){
try{setStatus('正在生成…');const r=await fetch('/studio/api/export/layout-selection?format='+format,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({layoutId:layout.id,...current})});if(!r.ok)throw Error();const url=URL.createObjectURL(await r.blob()),a=document.createElement('a');a.href=url;a.download=layout.id+'.'+format;a.click();setTimeout(()=>URL.revokeObjectURL(url),500);setStatus('已导出当前模板。')}catch{setStatus('导出失败,请重试。')}
}
return <main className="template-trial"><Head><title>三种排版 · Studio 工具</title></Head><header className="trial-nav"><a href={tutorialPost}>← PROMPT 使用教程</a><span>STUDIO / LAYOUT SELECTION</span></header><section className="trial-intro"><div><small>REF-01 / REF-07 / REF-26</small><h1>三个构图,各有位置。</h1><p>切换真实原图对应的模板,分别试文字与配色。图位与装饰按教程构件重建为干净矢量;当前只放占位内容。</p></div></section><div className="trial-workspace"><aside className="trial-controls"><h2>选择与编辑</h2><label>排版<select value={active} onChange={e=>{setActive(Number(e.target.value));setStatus('')}}>{selections.map((l,i)=><option key={l.id} value={i}>{l.id} · {l.name}</option>)}</select></label>{layout.slots.filter(s=>s.kind==='text').map(s=><label key={s.id}>{s.id} · {s.role}<textarea rows={current.fields[s.id]?.includes('\n')?3:1} maxLength={120} value={current.fields[s.id]||''} onChange={e=>update({fields:{...current.fields,[s.id]:e.target.value}})}/></label>)}<div className="trial-colors"><label>主图色<input type="color" value={current.color} onChange={e=>update({color:e.target.value})}/></label><label>副图色<input type="color" value={current.accent} onChange={e=>update({accent:e.target.value})}/></label></div><label className="trial-switch"><input type="checkbox" checked={guides} onChange={e=>setGuides(e.target.checked)}/>显示构件边界</label><div className="trial-actions"><button className="trial-primary" onClick={()=>void download('svg')}>下载当前 SVG</button><button onClick={()=>void download('json')}>下载参数 JSON</button></div><p className="trial-help">每张模板单独保留本页修改,刷新恢复初始值。下载 SVG 为无边界线的正式图;原始低分辨率图层在 JSON 中保留作对照。</p><p role="status">{status}</p></aside><div><div className="selection-preview"><SourceCover layout={layout} values={current} guides={guides}/></div><div className="selection-thumbs">{selections.map((l,i)=><button key={l.id} aria-pressed={active===i} onClick={()=>setActive(i)}><SourceCover layout={l} values={values[i]}/><span>{l.id}</span></button>)}</div><details className="trial-reference"><summary>原图与构件坐标</summary><div className="trial-reference-body"><img src={referencePreview(layout.referenceUrl)} alt={layout.name+'原图'}/><div><a href={layout.tutorialUrl}>阅读这张原图的教程 ↗</a><table><thead><tr><th>ID</th><th>构件</th><th>x,y,w,h</th></tr></thead><tbody>{layout.slots.map(s=><tr key={s.id}><td>{s.id}</td><td>{s.role}</td><td>{s.box.join(', ')}</td></tr>)}</tbody></table></div></div></details></div></div></main>;
return <main className="template-trial"><Head><title>三种排版 · 啡跃 Endoleap 工具</title></Head><header className="trial-nav"><a href={tutorialPost}>← 啡跃使用教程</a><span>STUDIO / LAYOUT SELECTION</span></header><section className="trial-intro"><div><small>REF-01 / REF-07 / REF-26</small><h1>三个构图,各有位置。</h1><p>切换真实原图对应的模板,分别试文字与配色。图位与装饰按教程构件重建为干净矢量;当前只放占位内容。</p></div></section><div className="trial-workspace"><aside className="trial-controls"><h2>选择与编辑</h2><label>排版<select value={active} onChange={e=>{setActive(Number(e.target.value));setStatus('')}}>{selections.map((l,i)=><option key={l.id} value={i}>{l.id} · {l.name}</option>)}</select></label>{layout.slots.filter(s=>s.kind==='text').map(s=><label key={s.id}>{s.id} · {s.role}<textarea rows={current.fields[s.id]?.includes('\n')?3:1} maxLength={120} value={current.fields[s.id]||''} onChange={e=>update({fields:{...current.fields,[s.id]:e.target.value}})}/></label>)}<div className="trial-colors"><label>主图色<input type="color" value={current.color} onChange={e=>update({color:e.target.value})}/></label><label>副图色<input type="color" value={current.accent} onChange={e=>update({accent:e.target.value})}/></label></div><label className="trial-switch"><input type="checkbox" checked={guides} onChange={e=>setGuides(e.target.checked)}/>显示构件边界</label><div className="trial-actions"><button className="trial-primary" onClick={()=>void download('svg')}>下载当前 SVG</button><button onClick={()=>void download('json')}>下载参数 JSON</button></div><p className="trial-help">每张模板单独保留本页修改,刷新恢复初始值。下载 SVG 为无边界线的正式图;原始低分辨率图层在 JSON 中保留作对照。</p><p role="status">{status}</p></aside><div><div className="selection-preview"><SourceCover layout={layout} values={current} guides={guides}/></div><div className="selection-thumbs">{selections.map((l,i)=><button key={l.id} aria-pressed={active===i} onClick={()=>setActive(i)}><SourceCover layout={l} values={values[i]}/><span>{l.id}</span></button>)}</div><details className="trial-reference"><summary>原图与构件坐标</summary><div className="trial-reference-body"><img src={referencePreview(layout.referenceUrl)} alt={layout.name+'原图'}/><div><a href={layout.tutorialUrl}>阅读这张原图的教程 ↗</a><table><thead><tr><th>ID</th><th>构件</th><th>x,y,w,h</th></tr></thead><tbody>{layout.slots.map(s=><tr key={s.id}><td>{s.id}</td><td>{s.role}</td><td>{s.box.join(', ')}</td></tr>)}</tbody></table></div></div></details></div></div></main>;
}
@@ -1,3 +1,5 @@
import { ThemeToggle } from "./ThemeToggle";
import { Brand } from "./Brand";
import {CurvedCopy} from './NativeCurvedCopy';
import {useEffect,useRef,useState,type CSSProperties,type KeyboardEvent,type PointerEvent} from 'react';
import {ArrowLeft,MousePointer2,Type,Square,Circle,ImagePlus,Layers,SlidersHorizontal,Undo2,Redo2,Copy,Trash2,Eye,EyeOff,Lock,Unlock,ChevronUp,ChevronDown,Minus,Plus,Scan,Save} from 'lucide-react';
@@ -55,7 +57,7 @@ export function LayoutWorkbench({layout,design:initialDesign}:LayoutPageProps){
const selectedColor=slot?(slot.kind==='text'?params.texts[slot.id]?.color||textColor(layout.id,slot):slot.kind==='image'?params.images[slot.id]?.color||String(imageShape(layout,slot).backgroundColor):params.decorations[slot.id]?.color||'#83908a'):'';
return <main className={'native-layout-page'+(editing?' is-editing':'')}>
<Head><title>{name} · Prompt</title><meta name="description" content={layout.name+' · 可直接编辑的排版页面'}/></Head>
{editing&&<header className="workbench-top"><a href="/explore/8eb8c76500580f7a4b56ba3c" aria-label="返回目录"><ArrowLeft size={18}/></a><span className="studio-mark">P<span>STUDIO</span></span><input aria-label="页面名称" value={name} maxLength={120} onChange={e=>{setName(e.target.value);setDirty(true)}}/><span className="save-indicator">{busy?'正在处理…':dirty?'有未保存的修改':design?'已保存':'尚未保存'}</span><button title="撤销 ⌘Z" disabled={!history.length||busy} onClick={undo}><Undo2 size={17}/></button><button title="重做 ⇧⌘Z" disabled={!future.length||busy} onClick={redo}><Redo2 size={17}/></button><button onClick={()=>{setEditing(false);setTextEditing(null);setSelected(null)}}>查看页面</button><button className="primary" disabled={busy||!name.trim()} onClick={()=>void save()}><Save size={15}/>保存</button></header>}
{editing&&<header className="workbench-top"><a href="/explore/8eb8c76500580f7a4b56ba3c" aria-label="返回目录"><ArrowLeft size={18}/></a><a href="/workspace" className="studio-mark"><Brand compact/></a><ThemeToggle/><input aria-label="页面名称" value={name} maxLength={120} onChange={e=>{setName(e.target.value);setDirty(true)}}/><span className="save-indicator">{busy?'正在处理…':dirty?'有未保存的修改':design?'已保存':'尚未保存'}</span><button title="撤销 ⌘Z" disabled={!history.length||busy} onClick={undo}><Undo2 size={17}/></button><button title="重做 ⇧⌘Z" disabled={!future.length||busy} onClick={redo}><Redo2 size={17}/></button><button onClick={()=>{setEditing(false);setTextEditing(null);setSelected(null)}}>查看页面</button><button className="primary" disabled={busy||!name.trim()} onClick={()=>void save()}><Save size={15}/>保存</button></header>}
{editing&&<aside className={'workbench-layers '+(panel==='layers'?'mobile-open':'')} inert={busy}><div className="panel-heading"><Layers size={16}/><strong>图层与素材</strong><span>{elements.length}</span></div><div className="insert-tools"><button onClick={()=>add('text')}><Type/>文字</button><button onClick={()=>add('decoration','rectangle')}><Square/>矩形</button><button onClick={()=>add('decoration','ellipse')}><Circle/>椭圆</button><button onClick={()=>add('image')}><ImagePlus/>图片</button></div><div className="layers-caption">{layout.id.toUpperCase()} <span>从上到下排列</span></div><div className="layer-list">{[...elements].sort((a,b)=>(layer(b).z??(b.kind==='text'?3:b.kind==='image'?1:2))-(layer(a).z??(a.kind==='text'?3:a.kind==='image'?1:2))).map(s=><div key={s.id} className={'layer-row '+(selected===s.id?'active':'')+(layer(s).hidden?' muted':'')}><button className="layer-select" onClick={()=>{setSelected(s.id);setTextEditing(null)}}>{s.kind==='text'?<Type size={14}/>:s.kind==='image'?<ImagePlus size={14}/>:<Square size={14}/>}<span>{s.role}</span></button><button title="显示或隐藏图层" aria-label={s.role+'显示或隐藏'} onClick={()=>updateLayer(s,{hidden:!layer(s).hidden})}>{layer(s).hidden?<EyeOff size={13}/>:<Eye size={13}/>}</button><button title="锁定或解锁图层" aria-label={s.role+'锁定或解锁'} onClick={()=>updateLayer(s,{locked:!layer(s).locked})}>{layer(s).locked?<Lock size={13}/>:<Unlock size={13}/>}</button></div>)}</div><p className="workbench-tip">拖动移动 · 双击文字改写<br/>⌘/Ctrl D 复制 · 方向键微调<br/>Shift + 方向键移动 10 单位</p></aside>}
<div ref={viewport} className={editing?'workbench-viewport':'page-viewport'} onClick={e=>{if(e.target===e.currentTarget){setSelected(null);setTextEditing(null)}}}>
{editing&&<div className="canvas-caption">{layout.name}<span>{layout.width} × {layout.height}</span></div>}
@@ -0,0 +1,28 @@
import Head from 'next/head';
import type {CSSProperties} from 'react';
import type {AddedSlot,Layout,Parameters} from '../lib/layout-parameters';
import {CurvedCopy} from './NativeCurvedCopy';
import {decorationKind,defaultText,emptyParameters,imageShape,paperColor,slotPosition,textColor,textSize,type Design} from '../lib/native-layout';
/** A saved result contains artwork only: no editing state, controls or event handlers. */
export function NativeResult({layout,design}:{layout:Layout;design:Design|null}){
return <main className="native-layout-page"><Head><title>{design?.name||layout.name} · Prompt</title></Head><NativeArtwork layout={layout} design={design}/></main>;
}
export function NativeArtwork({layout,design,quality=false}:{layout:Layout;design:Design|null;quality?:boolean}){
const params:Parameters=design?.parameters||emptyParameters();
const elements=[...layout.elements,...(params.added||[])];
return <article className={'native-layout '+layout.id} aria-label={design?.name||layout.name} data-layout-id={layout.id} data-design-id={design?.id} style={{aspectRatio:`${layout.width}/${layout.height}`,height:quality?Math.round(1080*layout.height/layout.width):undefined,backgroundColor:params.paper||paperColor(layout.id)}}>
{['image','decoration','text'].flatMap(kind=>elements.filter(s=>s.kind===kind).map(s=>{
const t=params.texts[s.id],im=params.images[s.id],d=params.decorations[s.id],ls=params.layers?.[s.id]||{};
if(ls.hidden||(kind==='decoration'&&d?.hidden))return null;
const text=t?.value??defaultText(layout,s);
const position:CSSProperties={...slotPosition(layout,{...s,box:[ls.x??s.box[0],ls.y??s.box[1],ls.width??s.box[2],ls.height??s.box[3]]}),transform:`rotate(${ls.rotation||0}deg)`,opacity:ls.opacity??1,borderRadius:ls.radius===undefined?undefined:`${ls.radius/layout.width*100}cqw`,zIndex:ls.z??(kind==='text'?3:kind==='image'?1:2)};
const textStyle:CSSProperties={fontSize:`${(t?.fontSize??textSize(layout,s,text)*(t?.scale||1))/layout.width*100}cqw`,color:t?.color||textColor(layout.id,s),fontFamily:t?.fontFamily==='serif'?'Georgia,"Noto Serif CJK SC","Songti SC",serif':t?.fontFamily==='mono'?'"Noto Sans Mono CJK SC",monospace':t?.fontFamily==='sans'?'Arial,"Noto Sans CJK SC","PingFang SC","Microsoft YaHei",sans-serif':undefined,fontWeight:t?.fontWeight,lineHeight:t?.lineHeight,letterSpacing:t?.letterSpacing===undefined?undefined:`${t.letterSpacing/layout.width*100}cqw`,textAlign:t?.align==='middle'?'center':t?.align==='end'?'right':'left'};
return <div key={s.id} className={'native-element native-'+kind} data-element={s.id} aria-label={s.role} style={position}>
{kind==='text'?(s.direction==='curved'?<CurvedCopy layout={layout} slot={s} value={text} spacing={t?.letterSpacing} style={textStyle}/>:<div className={'native-copy direction-'+s.direction} style={textStyle}>{text}</div>):kind==='image'?<div className="native-image-fill" style={{...imageShape(layout,s),...(im?.color?{backgroundColor:im.color}:{}),...(ls.radius!==undefined?{borderRadius:`${ls.radius/layout.width*100}cqw`}:{})}}>
{im?.assetId&&<img src={'/api/preview?'+new URLSearchParams({source:'/api/media/'+im.assetId,width:quality?'2160':'960',...(quality?{profile:'export'}:{})})} srcSet={quality?undefined:[240,480,960,1280].map(width=>'/api/preview?'+new URLSearchParams({source:'/api/media/'+im.assetId!,width:String(width)})+' '+width+'w').join(',')} sizes={`(max-width: 860px) ${Math.min(100,(ls.width??s.box[2])/layout.width*100)}vw, ${Math.min(860,(ls.width??s.box[2])/layout.width*860)}px`} alt={s.role} style={{objectFit:im.fit||'cover',objectPosition:`${im.cropX??50}% ${im.cropY??50}%`,transform:`scale(${im.zoom??1})`}}/>}
</div>:<div className={'native-decoration decoration-'+((s as AddedSlot).shape?'solid':decorationKind(s))} style={{borderRadius:ls.radius!==undefined?`${ls.radius/layout.width*100}cqw`:(s as AddedSlot).shape==='ellipse'?'50%':undefined,color:d?.color||'#83908a',backgroundColor:(s as AddedSlot).shape?d?.color||'#83908a':decorationKind(s)==='band'?d?.color||'#dce1d8':undefined}}>{decorationKind(s)==='lines'&&Array.from({length:s.box[3]>4?3:1},(_,i)=><i key={i}/>)}{decorationKind(s)==='vertical-lines'&&<><i/><i/><i/></>}</div>}
</div>;
}))}
</article>;
}
@@ -0,0 +1,13 @@
import Head from 'next/head';
import fixed from '../content/templates/layouts.json';
import standard from '../content/standard-layout.json';
import {pageParameters} from '../lib/series';
import type {Design} from '../lib/native-layout';
import {NativeArtwork} from './NativeResult';
export function NativeSeries({design,page,quality}:{design:Design;page:number|null;quality:boolean}){
const doc=design.document!;
return <main className={'native-layout-page native-series'+(quality?' native-export':'')} data-style-version={doc.style.version} data-revision={design.revision}>
<Head><title>{design.name} · {doc.pages.length}页</title><meta name="description" content={doc.brief.intent}/></Head>
{doc.pages.map((p,index)=>{if(page!==null&&page!==index+1)return null;const layout=[standard,...fixed].find(l=>l.id===p.layoutId)!;return <div className="series-page" key={p.key} data-page={index+1} data-page-key={p.key} data-page-role={p.role}><NativeArtwork layout={layout} quality={quality} design={{...design,name:design.name+' · '+(index+1),layoutId:p.layoutId,parameters:pageParameters(doc,p,layout)}}/>{!quality&&<p className="series-page-number">{String(index+1).padStart(2,'0')} / {doc.pages.length}</p>}</div>})}
</main>;
}
+2 -2
View File
@@ -12,8 +12,8 @@ export function TemplateTrial({idea}:{idea:Idea}){
const download=(text:string,type:string,name:string)=>{const url=URL.createObjectURL(new Blob([text],{type})),a=document.createElement('a');a.href=url;a.download=name;a.click();setTimeout(()=>URL.revokeObjectURL(url),500)};
async function exportSvg(){await document.fonts.ready;const svg=document.querySelector(`[data-proof="${selected.key}"] svg`);if(!svg)return;download(new XMLSerializer().serializeToString(svg),'image/svg+xml',`ref-17-${selected.key}.svg`);setNotice('已下载当前封面 SVG,可继续编辑文字与图形。')}
const reset=()=>{setSlides(idea.slides);setMasthead(template.masthead);setGuides(false);setNotice('已恢复初始试版。')};
return <main className="template-trial"><Head><title>{idea.title} · Studio</title><link rel="alternate" type="text/markdown" href={'/studio/api/export/'+idea.slug+'?format=markdown'}/></Head>
<header className="trial-nav"><a href={tutorialPost}>← PROMPT 使用教程</a><span>STUDIO / TEMPLATE PROOF</span><a href={template.tutorialUrl} target="_blank" rel="noreferrer">排版教程 ↗</a></header>
return <main className="template-trial"><Head><title>{idea.title} · 啡跃 Endoleap</title><link rel="alternate" type="text/markdown" href={'/studio/api/export/'+idea.slug+'?format=markdown'}/></Head>
<header className="trial-nav"><a href={tutorialPost}>← 啡跃使用教程</a><span>STUDIO / TEMPLATE PROOF</span><a href={template.tutorialUrl} target="_blank" rel="noreferrer">排版教程 ↗</a></header>
<section className="trial-intro"><div><small>REF-17 · 同一母版 / 四张试版</small><h1>先试封面,再填内容。</h1><p>英文刊头、圆形叠标、左下主图与右侧窄栏保持同一套坐标。三组合集先用主题占位,暂不放入具体 Skills。</p></div><div className="trial-source"><b>01 母版</b><span>02 笔记复习</span><span>03 教学资料</span><span>04 英语学习</span></div></section>
<div className="trial-workspace"><aside className="trial-controls"><h2>试一试</h2><label>当前封面<select value={active} onChange={e=>{setActive(Number(e.target.value));setNotice('')}}>{slides.map((s,i)=><option value={i} key={s.key}>{s.label}</option>)}</select></label>
<fieldset><legend>共享英文刊头</legend><label>第一行<input value={masthead[0]} maxLength={8} onChange={e=>setMasthead([e.target.value.toUpperCase().replace(/[^A-Z ]/g,''),masthead[1]])}/></label><label>第二行<input value={masthead[1]} maxLength={8} onChange={e=>setMasthead([masthead[0],e.target.value.replace(/[^A-Za-z ]/g,'')])}/></label></fieldset>
@@ -0,0 +1,7 @@
import { useEffect, useState } from 'react';
export function ThemeToggle() {
const [dark, setDark] = useState(false);
const [ready, setReady] = useState(false);
useEffect(() => { const sync = () => setDark(document.documentElement.dataset.mode === 'dark'); sync(); setReady(true); window.addEventListener('endoleap-theme', sync); return () => window.removeEventListener('endoleap-theme', sync); }, []);
return <button type="button" data-theme-ready={ready} className="endoleap-theme-toggle" aria-label="切换明暗主题" title={dark ? '切换浅色模式' : '切换深色模式'} onClick={() => { const mode = document.documentElement.dataset.mode === 'dark' ? 'light' : 'dark'; const scope = document.documentElement.dataset.theme?.startsWith('canvas-') ? 'canvas' : 'home'; document.documentElement.dataset.mode = mode; document.documentElement.dataset.theme = `${scope}-${mode}`; try { localStorage.setItem('endoleap.theme', mode); } catch {} window.dispatchEvent(new Event('endoleap-theme')); }}>◐<span>{dark ? '浅色' : '深色'}</span></button>;
}
@@ -0,0 +1,38 @@
import {forwardRef,useEffect,useImperativeHandle,useRef,useState} from 'react';
import {parseCanvasDocument,type CanvasDocument} from '../../lib/canvas-document';
import s from '../../styles/canvas-agent.module.css';
type Task={id:string;targetId:string;status:'queued'|'running'|'succeeded'|'failed';error?:string};
type State={id:string;name:string;revision:number;document:CanvasDocument;tasks:Task[]};
type LinkState={expiresAt:string;lastSeenAt:string|null};
export type CanvasAgentHandle={queue:(target:string)=>Promise<void>};
type Props={document:CanvasDocument;email:string;onLoad:(document:CanvasDocument)=>void;onFeedback:(text:string)=>void};
async function api(path:string,method='GET',body?:unknown){const r=await fetch('/api/canvases'+path,{method,headers:body?{'Content-Type':'application/json'}:undefined,...(body?{body:JSON.stringify(body)}:{})});const data=await r.json();if(!r.ok)throw Error(data.error||'在线画布暂时不可用');return data;}
const names={queued:'等待 Agent',running:'Agent 正在处理',succeeded:'已完成',failed:'已结束'};
export default forwardRef<CanvasAgentHandle,Props>(function CanvasAgentPanel(p,ref){
const [open,setOpen]=useState(false),[busy,setBusy]=useState(false),[message,setMessage]=useState(''),[remote,setRemote]=useState<State|null>(null),[pending,setPending]=useState<State|null>(null),[connection,setConnection]=useState<LinkState|null>(null),[credential,setCredential]=useState(''),[items,setItems]=useState<{id:string;name:string}[]>([]);
const live=useRef(p.document),synced=useRef(''),current=useRef<State|null>(null),locked=useRef(false),account=useRef(p.email),generation=useRef(0),credentialField=useRef<HTMLTextAreaElement>(null);
live.current=p.document;account.current=p.email;
function update(state:State){current.current=state;setRemote(state);}
function apply(state:State){state={...state,document:parseCanvasDocument(state.document)};synced.current=JSON.stringify(state.document);update(state);setPending(null);p.onLoad(state.document);}
async function run(action:()=>Promise<void>){if(locked.current)return;locked.current=true;setBusy(true);try{await action()}catch(e){const text=e instanceof Error?e.message:'操作失败';setMessage(text);p.onFeedback(text)}finally{locked.current=false;setBusy(false)}}
useEffect(()=>{generation.current++;current.current=null;synced.current='';setRemote(null);setPending(null);setConnection(null);setCredential('');setItems([]);if(!p.email)return;const epoch=generation.current;void api('').then(data=>{if(epoch===generation.current)setItems(data.canvases)}).catch(()=>{});},[p.email]);
useEffect(()=>{if(!remote||!p.email)return;const id=remote.id,epoch=generation.current;let cancelled=false;const poll=async()=>{try{const state:State=await api('/'+id);if(cancelled||epoch!==generation.current||current.current?.id!==id||locked.current)return;if(state.revision!==current.current.revision){if(JSON.stringify(live.current)===synced.current){apply(state);setMessage('已同步 Agent 的画布更新')}else{setPending(state);setMessage('在线画布有更新,请选择要保留的版本')}}else update(state);const link=await api('/'+id+'/link');if(!cancelled&&epoch===generation.current)setConnection(link.link)}catch(e){if(!cancelled)setMessage(e instanceof Error?e.message:'同步失败')}};void poll();const timer=setInterval(()=>void poll(),5000);return()=>{cancelled=true;clearInterval(timer)};},[remote?.id,p.email]);
async function save():Promise<State>{if(!account.current)throw Error('请先登录邮箱账户');if(pending)throw Error('请先处理在线版本冲突');const doc=live.current,serialized=JSON.stringify(doc),old=current.current;const state:State=old?serialized===synced.current?old:await api('/'+old.id,'PUT',{revision:old.revision,document:doc}):await api('','POST',{document:doc,name:'灵感画布 '+new Date().toLocaleDateString()});if(account.current!==p.email)throw Error('账户已切换,请重新连接');synced.current=serialized;update(state);setMessage('已保存在线画布');return state;}
useImperativeHandle(ref,()=>({queue:async target=>{setOpen(true);await run(async()=>{const state=await save();if(JSON.stringify(live.current)!==synced.current)throw Error('画布刚有修改,请重新派发任务');const task:Task=await api('/'+state.id+'/tasks','POST',{revision:state.revision,targetId:target});if(account.current!==p.email)return;update({...state,tasks:[task,...state.tasks]});setMessage('任务已创建,等待你的 coding agent 领取');p.onFeedback('任务已创建,等待 Agent')})}}));
async function connect(){const state=await save(),link=await api('/'+state.id+'/link','POST',{});if(account.current!==p.email)return;setConnection({expiresAt:link.expiresAt,lastSeenAt:null});setCredential(JSON.stringify({canvasUrl:location.origin+'/api/canvases/'+state.id,authorization:'Bearer '+link.token,documentation:location.origin+link.documentation,expiresAt:link.expiresAt},null,2));setMessage('连接凭据已生成,有效期 1 小时');}
async function copyCredential(){
if(navigator.clipboard?.writeText){try{await navigator.clipboard.writeText(credential);setMessage('凭据已复制');return}catch{}}
const field=credentialField.current;if(!field)return;field.focus();field.select();
let copied=false;try{copied=document.execCommand('copy')}catch{}
setMessage(copied?'凭据已复制':'已选中凭据,请按 Ctrl+C 或 ⌘C 复制');
}
const dirty=!!remote&&JSON.stringify(p.document)!==synced.current;
return <div className={s.root}><button onClick={()=>setOpen(v=>!v)} aria-expanded={open}>Agent{remote?' · 在线画布':''}</button>{open&&<section className={s.panel} aria-label="Agent 连接"><header><strong>你的 coding agent</strong><button aria-label="关闭 Agent 连接" onClick={()=>setOpen(false)}>×</button></header>{!p.email?<a href="/account">登录后连接在线画布 ↗</a>:<>
<div className={s.actions}><button disabled={busy||!!pending} onClick={()=>void run(async()=>{await save()})}>{remote?(dirty?'保存修改':'已同步'):'保存为在线画布'}</button><button disabled={busy||!!pending} onClick={()=>void run(connect)}>生成连接凭据</button>{connection&&<button disabled={busy} onClick={()=>void run(async()=>{await api('/'+current.current!.id+'/link','DELETE',{});setConnection(null);setCredential('');setMessage('Agent 访问已撤销')})}>撤销连接</button>}</div>
{!remote&&<label>打开在线画布<select aria-label="打开在线画布" defaultValue="" disabled={busy} onChange={e=>{const id=e.target.value;if(id)void run(async()=>{const state:State=await api('/'+id);parseCanvasDocument(state.document);setPending(state);setMessage('加载在线画布将替换当前草稿,请先导出需要保留的内容')})}}><option value="">选择画布</option>{items.map(item=><option value={item.id} key={item.id}>{item.name} · {item.id.slice(0,8)}</option>)}</select></label>}
{connection&&<small>{connection.lastSeenAt?'Agent 最近访问:'+new Date(connection.lastSeenAt).toLocaleTimeString():'等待 Agent 首次访问'} · 到期 {new Date(connection.expiresAt).toLocaleTimeString()}</small>}
{credential&&<><textarea ref={credentialField} aria-label="Agent 连接凭据" readOnly value={credential}/><button onClick={()=>void copyCredential()}>复制给 coding agent</button></>}
{pending&&<div className={s.conflict}><button disabled={busy} onClick={()=>{apply(pending);setMessage('已载入在线版本')}}>载入在线版本</button><button disabled={busy} onClick={()=>{setPending(null);current.current=null;setRemote(null);synced.current='';setConnection(null);setCredential('');setMessage('已保留本机草稿,可另存为新画布')}}>保留草稿并断开</button></div>}
{!!remote?.tasks.length&&<ul aria-label="画布任务">{remote.tasks.slice(0,8).map(task=><li key={task.id}><span>{p.document.cards.find(n=>n.id===task.targetId)?.title||'已移除节点'} · {names[task.status]}</span>{task.error&&<small>{task.error}</small>}{['queued','running'].includes(task.status)&&<button disabled={busy} onClick={()=>void run(async()=>{await api('/'+remote.id+'/tasks/'+task.id,'PATCH',{status:'failed',error:'用户结束任务'});setMessage('任务已结束,可修改后重新派发')})}>结束任务</button>}</li>)}</ul>}
</>}<p role="status">{message}</p><a href="/api/canvases?agent_mode=True" target="_blank" rel="noreferrer">接入文档 ↗</a></section>}</div>
});
@@ -0,0 +1,9 @@
import {useEffect,useState} from 'react';
import {ImagePlus,FileText,Plus,X,Maximize,Download,ArrowUp} from 'lucide-react';
import type {CanvasNode} from '../../lib/canvas-document';
import s from '../../styles/canvas-composer.module.css';
type Props={pickerRequest:number;feedback:string;node:CanvasNode;nodes:CanvasNode[];references:CanvasNode[];onChange:(text:string)=>void;onRename:(title:string)=>void;onBeginEdit:()=>void;onUpload:()=>void;onAddReference:(id:string)=>void;onRemoveReference:(id:string)=>void;onQueue:()=>void;onExport:()=>void;onClose:()=>void};
export default function CanvasComposer(p:Props){const [expanded,expand]=useState(false),[choosing,choose]=useState(false);useEffect(()=>{if(p.pickerRequest)choose(true)},[p.pickerRequest]);return <section className={s.composer} data-expanded={expanded} aria-label="节点编辑器"><header><span>{p.node.kind==='image'?<ImagePlus size={16}/>:<FileText size={16}/>} <input aria-label="节点名称" maxLength={200} value={p.node.title} onFocus={p.onBeginEdit} onChange={e=>p.onRename(e.target.value)}/></span><div>{p.node.kind==='image'&&<button onClick={p.onUpload}><Plus size={15}/>上传图片</button>}<button aria-expanded={choosing} onClick={()=>choose(v=>!v)}><Plus size={15}/>画布参考</button><button aria-label="展开编辑器" aria-pressed={expanded} onClick={()=>expand(v=>!v)}><Maximize size={16}/></button><button aria-label="关闭节点编辑器" onClick={p.onClose}><X size={17}/></button></div></header>
{choosing&&<div className={s.picker} role="group" aria-label="选择引用节点">{p.nodes.filter(n=>n.id!==p.node.id&&!p.references.some(r=>r.id===n.id)).map(n=><button key={n.id} onClick={()=>{p.onAddReference(n.id);choose(false)}}>{n.kind==='image'?<ImagePlus size={15}/>:<FileText size={15}/>} {n.title}</button>)}{p.nodes.length<2&&<span>先在画布上添加一个参考节点</span>}</div>}
{!!p.references.length&&<div className={s.references}>{p.references.map((n,i)=><div className={s.reference} key={n.id}>{n.image?<img src={n.image} alt={n.title}/>:<FileText size={24}/>}<span>{i+1}</span><small>{n.title}</small><button aria-label={`移除引用 ${n.title}`} onClick={()=>p.onRemoveReference(n.id)}><X size={12}/></button></div>)}</div>}
<textarea aria-label="创作要求" maxLength={10000} value={p.node.instruction??p.node.text} onFocus={p.onBeginEdit} onChange={e=>p.onChange(e.target.value)} placeholder={p.node.kind==='script'?'描述剧情片段、故事或分镜要求…':'描述画面,或写下对参考图的修改要求…'}/><footer><span role="status">{p.feedback||<>{p.node.operation==='upscale'?'图片高清':p.node.kind==='script'?'分镜脚本':'创作任务'} · {p.references.length} 个引用</>}</span><div><button onClick={p.onExport}><Download size={15}/>导出任务</button><button onClick={p.onQueue}><ArrowUp size={15}/>交给 Agent</button></div></footer></section>}
@@ -0,0 +1,16 @@
import {useRef,type PointerEvent} from 'react';
import {Type,ImagePlus,FileText,Plus,Bold,Italic,Maximize,MoreHorizontal} from 'lucide-react';
import type {CanvasNode,TextStyle} from '../../lib/canvas-document';
import s from '../../styles/canvas-node.module.css';
type Props={node:CanvasNode;selected:boolean;editing:boolean;hand:boolean;onSelect:()=>void;onChooseReference:()=>void;onEdit:()=>void;onDrag:(e:PointerEvent<HTMLElement>)=>void;onResize:(e:PointerEvent<HTMLElement>)=>void;onChange:(text:string)=>void;onRename:(title:string)=>void;onStyle:(style:TextStyle)=>void;onReference:(e:PointerEvent<HTMLButtonElement>|React.MouseEvent<HTMLButtonElement>)=>void;onMenu:(e:React.MouseEvent<HTMLButtonElement>)=>void;onUpload:()=>void;onUpscale:()=>void;onExpand:()=>void;onBeginEdit:()=>void};
export default function CanvasNodeView(p:Props){
const {node:n}=p;const gesture=useRef<{selected:boolean;x:number;y:number;moved:boolean}|null>(null);
const icon=n.kind==='image'?<ImagePlus size={15}/>:n.kind==='script'?<FileText size={15}/>:<Type size={15}/>;
const style={fontWeight:n.style?.bold?700:400,fontStyle:n.style?.italic?'italic':'normal',fontSize:n.style?.size||15,color:n.style?.color};
return <article data-node-id={n.id} data-editing={p.editing} data-selected={p.selected} className={s.node} style={{left:n.x,top:n.y,width:n.width||320,height:n.height||350}} onPointerDown={e=>{gesture.current=null;if(p.hand||(p.editing&&n.kind==='text')||e.button!==0||(e.target as HTMLElement).closest('button,input,select'))return;gesture.current={selected:p.selected,x:e.clientX,y:e.clientY,moved:false};e.stopPropagation();p.onSelect();p.onDrag(e)}} onPointerMove={e=>{const g=gesture.current;if(g&&Math.hypot(e.clientX-g.x,e.clientY-g.y)>=4)g.moved=true}} onClick={e=>{if((e.target as HTMLElement).closest('button,input,select'))return;const g=gesture.current;if(!p.hand&&!p.editing&&g?.selected&&!g.moved)p.onEdit();gesture.current=null}} onKeyDown={e=>{if(e.target!==e.currentTarget)return;if(e.key==='Enter'){e.preventDefault();p.onEdit()}}} tabIndex={0} aria-label={n.title}>
<header className={s.title}>{icon}<input aria-label="节点名称" value={n.title} maxLength={200} onFocus={p.onBeginEdit} onChange={e=>p.onRename(e.target.value)} onPointerDown={e=>e.stopPropagation()}/>{p.selected&&<button aria-label={`${n.title} 操作`} onClick={e=>{e.stopPropagation();p.onMenu(e)}}><MoreHorizontal size={17}/></button>}</header>
{p.editing&&n.kind==='text'&&<div className={s.format} onPointerDown={e=>e.stopPropagation()}><input aria-label="文字颜色" type="color" value={n.style?.color||'#dddddd'} onChange={e=>p.onStyle({...n.style,color:e.target.value})}/><select aria-label="文字样式" value={n.style?.size||15} onChange={e=>p.onStyle({...n.style,size:Number(e.target.value)})}><option value={15}>正文</option><option value={24}>小标题</option><option value={32}>标题</option></select><button aria-label="加粗" aria-pressed={!!n.style?.bold} onClick={()=>p.onStyle({...n.style,bold:!n.style?.bold})}><Bold size={17}/></button><button aria-label="斜体" aria-pressed={!!n.style?.italic} onClick={()=>p.onStyle({...n.style,italic:!n.style?.italic})}><Italic size={17}/></button><button aria-label="展开文本节点" onClick={p.onExpand}><Maximize size={17}/></button></div>}
{n.kind==='image'?<div data-node-content className={s.image}>{n.image?<img src={n.image} alt={n.title} draggable={false}/>:<div className={s.empty}><ImagePlus size={48} strokeWidth={1}/><button onClick={e=>{e.stopPropagation();p.onUpload()}}>上传图片</button></div>}{p.selected&&n.image&&<button className={s.upscale} onClick={e=>{e.stopPropagation();p.onUpscale()}}>图片高清 ↗</button>}</div>:p.editing&&n.kind==='text'?<textarea data-node-content autoFocus aria-label={`${n.title} 内容`} style={style} value={n.text} maxLength={10000} placeholder="输入内容…" onFocus={p.onBeginEdit} onChange={e=>p.onChange(e.target.value)}/>:<div data-node-content className={s.content} style={style}>{n.text?<p>{n.text}</p>:<div className={s.empty}>{icon}<button onClick={e=>{e.stopPropagation();p.onEdit()}}>{n.kind==='text'?'自己编写内容':n.kind==='script'?'编写脚本':'填写创作要求'}</button></div>}</div>}
{p.selected&&<><button className={`${s.reference} ${s.incoming}`} aria-label={`为 ${n.title} 添加参考`} onClick={e=>{e.stopPropagation();p.onChooseReference()}}><Plus size={18}/></button><button className={s.reference} aria-label={`引用 ${n.title} 生成`} onClick={e=>{e.stopPropagation();p.onReference(e)}}><Plus size={18}/></button><button className={s.resize} aria-label={`调整 ${n.title} 尺寸`} onPointerDown={e=>{e.stopPropagation();gesture.current=null;p.onResize(e)}} onClick={e=>e.stopPropagation()}>↘</button></>}
</article>;
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+78
View File
@@ -0,0 +1,78 @@
export type NodeKind = 'text' | 'image' | 'prompt' | 'script';
export type TextStyle = {bold?:boolean;italic?:boolean;color?:string;size?:number};
export type CanvasNode = {
id:string;kind:NodeKind;title:string;text:string;x:number;y:number;
image?:string;width?:number;height?:number;style?:TextStyle;
instruction?:string;
operation?:'generate'|'upscale'|'script';
};
export type CanvasDocument = {version:1;cards:CanvasNode[];links:[string,string][]};
const kinds:NodeKind[]=['text','image','prompt','script'];
const object=(v:unknown):v is Record<string,unknown>=>!!v&&typeof v==='object'&&!Array.isArray(v);
const bounded=(v:unknown,min:number,max:number)=>typeof v==='number'&&Number.isFinite(v)&&v>=min&&v<=max;
const text=(v:unknown,max:number)=>typeof v==='string'&&v.length<=max;
const image=(v:unknown)=>typeof v==='string'&&v.length<3000000&&/^data:image\/(png|jpeg|webp);base64,[A-Za-z0-9+/=]+$/.test(v);
export function parseCanvasDocument(value:unknown):CanvasDocument {
const d=typeof value==='string'?JSON.parse(value):value;
if(!object(d)||d.version!==1||!Array.isArray(d.cards)||d.cards.length>80||!Array.isArray(d.links)||d.links.length>200)throw Error('画布格式不正确');
const ids=new Set<string>();
const cards:CanvasNode[]=d.cards.map((n:unknown)=>{
if(!object(n)||!text(n.id,100)||!n.id||ids.has(n.id as string)||!kinds.includes(n.kind as NodeKind)||!text(n.title,200)||!text(n.text,10000)||!bounded(n.x,-100000,100000)||!bounded(n.y,-100000,100000))throw Error('节点格式不正确');
if(n.width!==undefined&&!bounded(n.width,180,1200)||n.height!==undefined&&!bounded(n.height,160,1200)||n.image!==undefined&&!image(n.image))throw Error('节点尺寸或图片不正确');
if(n.instruction!==undefined&&!text(n.instruction,10000))throw Error('创作要求格式不正确');
if(n.operation!==undefined&&!['generate','upscale','script'].includes(n.operation as string))throw Error('生成任务类型不正确');
let style:TextStyle|undefined;
if(n.style!==undefined){
if(!object(n.style)||n.style.bold!==undefined&&typeof n.style.bold!=='boolean'||n.style.italic!==undefined&&typeof n.style.italic!=='boolean'||n.style.color!==undefined&&(typeof n.style.color!=='string'||!/^#[a-f\d]{6}$/i.test(n.style.color))||n.style.size!==undefined&&!bounded(n.style.size,10,72))throw Error('文字样式不正确');
style={...(n.style.bold===undefined?{}:{bold:n.style.bold as boolean}),...(n.style.italic===undefined?{}:{italic:n.style.italic as boolean}),...(n.style.color===undefined?{}:{color:n.style.color as string}),...(n.style.size===undefined?{}:{size:n.style.size as number})};
}
ids.add(n.id as string);
return {id:n.id as string,kind:n.kind as NodeKind,title:n.title as string,text:n.text as string,x:n.x as number,y:n.y as number,...(n.width===undefined?{}:{width:n.width as number}),...(n.height===undefined?{}:{height:n.height as number}),...(n.image===undefined?{}:{image:n.image as string}),...(style?{style}:{}),...(n.instruction===undefined?{}:{instruction:n.instruction as string}),...(n.operation===undefined?{}:{operation:n.operation as CanvasNode['operation']})};
});
const pairs=new Set<string>();
const links:[string,string][]=d.links.map((l:unknown)=>{
if(!Array.isArray(l)||l.length!==2||l[0]===l[1]||!l.every(id=>typeof id==='string'&&ids.has(id)))throw Error('引用节点不存在');
const key=JSON.stringify(l);if(pairs.has(key))throw Error('引用重复');pairs.add(key);return [l[0],l[1]];
});
const outgoing=new Map<string,string[]>(),indegree=new Map(cards.map(n=>[n.id,0]));
for(const [from,to] of links){outgoing.set(from,[...(outgoing.get(from)||[]),to]);indegree.set(to,indegree.get(to)!+1)}
const roots=cards.filter(n=>indegree.get(n.id)===0).map(n=>n.id);let visited=0;
while(roots.length){const id=roots.pop()!;visited++;for(const to of outgoing.get(id)||[]){const count=indegree.get(to)!-1;indegree.set(to,count);if(!count)roots.push(to)}}
if(visited!==cards.length)throw Error('引用不能形成循环');
return {version:1,cards,links};
}
export function referencedNodes(doc:CanvasDocument,target:string):CanvasNode[]{
return doc.links.filter(([,to])=>to===target).map(([from])=>doc.cards.find(n=>n.id===from)!).filter(Boolean);
}
export function connectNodes(doc:CanvasDocument,from:string,to:string):CanvasDocument {
if(from===to||![from,to].every(id=>doc.cards.some(n=>n.id===id)))throw Error('请选择不同的有效节点');
if(doc.links.some(([a,b])=>a===from&&b===to))return doc;
if(doc.links.length>=200)throw Error('最多 200 条引用');
const seen=new Set<string>();const pending=[to];
while(pending.length){const id=pending.pop()!;if(id===from)throw Error('引用不能形成循环');if(seen.has(id))continue;seen.add(id);pending.push(...doc.links.filter(([a])=>a===id).map(([,b])=>b));}
return {...doc,links:[...doc.links,[from,to]]};
}
export function removeCanvasNode(doc:CanvasDocument,id:string):CanvasDocument {
return {...doc,cards:doc.cards.filter(n=>n.id!==id),links:doc.links.filter(l=>!l.includes(id))};
}
export function deriveNode(doc:CanvasDocument,source:string,node:CanvasNode):CanvasDocument {
if(doc.cards.length>=80||doc.cards.some(n=>n.id===node.id))throw Error('节点数量或标识不正确');
const target=parseCanvasDocument({version:1,cards:[node],links:[]}).cards[0];
for(let attempt=0;attempt<=doc.cards.length+1;attempt++){
const blockers=doc.cards.filter(n=>target.x<n.x+(n.width||320)+64&&target.x+(target.width||320)+64>n.x&&target.y<n.y+(n.height||350)+64&&target.y+(target.height||350)+64>n.y);
if(!blockers.length)return parseCanvasDocument(connectNodes({...doc,cards:[...doc.cards,target]},source,target.id));
const nextY=Math.max(...blockers.map(n=>n.y+(n.height||350)+64));
target.y=nextY>100000?-100000:nextY;
}
throw Error('请移动现有节点后再创建引用节点');
}
export function agentTask(doc:CanvasDocument,id:string){
const node=doc.cards.find(n=>n.id===id);if(!node)throw Error('目标节点不存在');
const instruction=node.instruction??node.text;
if(!instruction.trim()&&node.operation!=='upscale')throw Error('请先填写创作要求');
const references=referencedNodes(doc,id);
if(node.operation==='upscale'&&!references.some(n=>n.kind==='image'&&n.image))throw Error('请先引用一张图片');
return {schema:'studio.canvas-task/1',target:{id:node.id,kind:node.kind,title:node.title},operation:node.operation||'generate',instruction,...(node.image?{sourceImage:node.image}:{}),references:references.map(n=>({id:n.id,kind:n.kind,title:n.title,text:n.text,...(n.image?{image:n.image}:{}),...(n.style?{style:n.style}:{})})),result:{targetId:node.id,allowedFields:node.kind==='image'?['image','text']:['text']}};
}
+2 -1
View File
@@ -1,4 +1,5 @@
import type {IncomingMessage} from 'node:http';
import {series} from './series';
import {parameters,uuid} from './layout-parameters';
import fixedLayouts from '../content/templates/layouts.json';
import standardLayout from '../content/standard-layout.json';
@@ -19,5 +20,5 @@ export async function readDesign(id:unknown,req:IncomingMessage):Promise<Design|
const layout=layouts.find(l=>l.id===design.layoutId);
if(!layout||design.id!==id)throw new Error('Invalid saved layout');
if((design as Design&{archived?:boolean}).archived)return null;
return {...design,parameters:parameters(layout,design.parameters)};
return {...design,...(design.document?{document:series(design.document,layouts)}:{}),parameters:parameters(layout,design.parameters)};
}
+19
View File
@@ -0,0 +1,19 @@
export type GraphicItem = {
id:string;kind:'text'|'rect'|'ellipse'|'image';name:string;text:string;
x:number;y:number;w:number;h:number;fill:string;size:number;hidden:boolean;locked:boolean;src?:string;
};
export type GraphicDocument = {name:string;background:string;items:GraphicItem[]};
const color=(value:unknown)=>typeof value==='string'&&/^#[a-f0-9]{6}$/i.test(value);
/** Accept only bounded editable documents; never execute or load external assets. */
export function parseGraphicDocument(raw:string):GraphicDocument {
if(raw.length>5*1024*1024)throw Error('设计文件不得超过 5MB');
const d=JSON.parse(raw);
if(!d||typeof d.name!=='string'||d.name.length>100||!color(d.background)||!Array.isArray(d.items)||d.items.length>500)throw Error('设计文件格式不正确');
const ids=new Set<string>();
for(const i of d.items){
if(!i||typeof i.id!=='string'||i.id.length>100||ids.has(i.id)||!['text','rect','ellipse','image'].includes(i.kind)||typeof i.name!=='string'||i.name.length>200||typeof i.text!=='string'||i.text.length>4000||!color(i.fill)||typeof i.hidden!=='boolean'||typeof i.locked!=='boolean'||![i.x,i.y,i.w,i.h,i.size].every(Number.isFinite)||Math.abs(i.x)>4000||Math.abs(i.y)>4000||i.w<1||i.h<1||i.w>4000||i.h>4000||i.size<8||i.size>240)throw Error('设计图层格式不正确');
if(i.kind==='image'&&(typeof i.src!=='string'||!/^data:image\/(png|jpeg|webp);base64,[A-Za-z0-9+/=]+$/.test(i.src)))throw Error('图片必须为嵌入式 PNG、JPEG 或 WebP');
ids.add(i.id);
}
return {name:d.name,background:d.background,items:d.items.map((i:GraphicItem)=>({id:i.id,kind:i.kind,name:i.name,text:i.text,x:i.x,y:i.y,w:i.w,h:i.h,fill:i.fill,size:i.size,hidden:i.hidden,locked:i.locked,...(i.kind==='image'?{src:i.src}:{})}))};
}
+17 -1
View File
@@ -4,6 +4,22 @@ import type {Parameters} from './layout-parameters';
export async function embedLayoutImages(req:NextApiRequest,params:Parameters){
const ids=[...new Set(Object.values(params.images).flatMap(v=>v.assetId?[v.assetId]:[]))],sources:Record<string,string>={};let total=0;
const headers:Record<string,string>={};if(req.headers.authorization)headers.authorization=req.headers.authorization;if(req.headers.cookie)headers.cookie=req.headers.cookie;
for(const id of ids){const url='http://waterfall-wiki:3000/api/preview?'+new URLSearchParams({source:'/api/media/'+id,width:'960'}),r=await fetch(url,{headers,signal:AbortSignal.timeout(15000),redirect:'error'});if(!r.ok||r.headers.get('content-type')?.split(';')[0]!=='image/webp')throw Error('图片预览不可访问,请检查图片 ID 与登录状态');const chunks:Buffer[]=[];const reader=r.body!.getReader();try{while(true){const {done,value}=await reader.read();if(done)break;total+=value.length;if(total>16*1024*1024)throw Error('图片预览总计超限');chunks.push(Buffer.from(value))}}finally{await reader.cancel()}sources[id]='data:image/webp;base64,'+Buffer.concat(chunks).toString('base64');}
for(const id of ids){const url=(process.env.STUDIO_WIKI_ORIGIN||'http://waterfall-wiki:3000')+'/api/preview?'+new URLSearchParams({source:'/api/media/'+id,width:'960'}),r=await fetch(url,{headers,signal:AbortSignal.timeout(15000),redirect:'error'});if(!r.ok||r.headers.get('content-type')?.split(';')[0]!=='image/webp')throw Error('图片预览不可访问,请检查图片 ID 与登录状态');const chunks:Buffer[]=[];const reader=r.body!.getReader();try{while(true){const {done,value}=await reader.read();if(done)break;total+=value.length;if(total>16*1024*1024)throw Error('图片预览总计超限');chunks.push(Buffer.from(value))}}finally{await reader.cancel()}sources[id]='data:image/webp;base64,'+Buffer.concat(chunks).toString('base64');}
return sources;
}
/** Export a self-contained SVG without storing image bytes in template JSON. */
export async function embedSvgMedia(req:NextApiRequest,svg:string){
const paths=[...new Set(svg.match(/\/api\/media\/[0-9a-f-]{36}/g)||[])];
const headers:Record<string,string>={};if(req.headers.authorization)headers.authorization=req.headers.authorization;if(req.headers.cookie)headers.cookie=req.headers.cookie;
let total=0;
for(const path of paths){
const response=await fetch((process.env.STUDIO_WIKI_ORIGIN||'http://waterfall-wiki:3000')+path,{headers,redirect:'error',signal:AbortSignal.timeout(15000)});
const mime=response.headers.get('content-type')?.split(';')[0];
if(!response.ok||!mime||!['image/png','image/jpeg','image/webp'].includes(mime))throw Error('模板图片不可访问');
const chunks:Uint8Array[]=[];const reader=response.body!.getReader();
try{while(true){const {done,value}=await reader.read();if(done)break;total+=value.length;if(total>16*1024*1024)throw Error('模板图片总计超限');chunks.push(value);}}finally{await reader.cancel();}
svg=svg.replaceAll(path,'data:'+mime+';base64,'+Buffer.concat(chunks).toString('base64'));
}
return svg;
}
+2 -2
View File
@@ -1,11 +1,11 @@
import type {CSSProperties} from 'react';
import type {Layout,Parameters,Slot} from './layout-parameters';
export type Design={id:string;name:string;layoutId:string;parameters:Parameters;revision:number;url:string};
export type Design={document?:import('./series').Series;id:string;name:string;layoutId:string;parameters:Parameters;revision:number;url:string};
// getRandomValues is available on the deployment's HTTP origin; randomUUID is not.
export const newElementId=()=> 'user-'+Array.from(globalThis.crypto.getRandomValues(new Uint32Array(4)),n=>n.toString(16).padStart(8,'0')).join('');
export const emptyParameters=():Parameters=>({texts:{},images:{},decorations:{}});
export const designUrl=(layoutId:string,id:string)=>`/studio/layouts/${layoutId}?id=${id}`;
export const designUrl=(_layoutId:string,id:string)=>`/studio/template-layout?id=${id}`;
export function nativeLayout(layout:Layout):Layout {
return {id:layout.id,name:layout.name,width:layout.width,height:layout.height,elements:layout.elements.map(({id,kind,role,box,direction,text,reading})=>({id,kind,role,box,direction,text,reading}))};
}
+42
View File
@@ -0,0 +1,42 @@
import {parameters,LayoutParameterError,type Layout,type Parameters,type TextValue,uuid} from './layout-parameters';
export type Typography=Required<Pick<TextValue,'fontFamily'|'fontSize'|'fontWeight'|'lineHeight'|'letterSpacing'>>;
export type Series={version:1;brief:{topic:string;audience:string;intent:string;designIntent:string;imageStrategy:string};style:{version:string;palette:{paper:string;ink:string;muted:string;accent:string};typography:Record<'title'|'body'|'caption',Typography>;spacing:{margin:number;gap:number}};sources:{assetId:string;author:string;license:string;url:string;prompt:string;tool:string}[];pages:{key:string;role:'cover'|'body'|'ending';layoutId:string;intent:string;bindings:Record<string,'title'|'body'|'caption'>;parameters:Parameters}[]};
function fail(message:string):never{throw new LayoutParameterError(message)}
const obj=(v:unknown)=>{if(!v||typeof v!=='object'||Array.isArray(v))return fail('作品字段必须是对象');return v as Record<string,unknown>};
function exact(v:unknown,allowed:string[]){const o=obj(v);if(Object.keys(o).some(k=>!allowed.includes(k)))fail('作品包含未知字段');return o}
function str(v:unknown,max=1000){if(typeof v!=='string'||!v.trim()||v.length>max)fail('作品文字缺失或超长');return v as string}
function num(v:unknown,min:number,max:number){if(typeof v!=='number'||!Number.isFinite(v)||v<min||v>max)fail('作品数值超出范围');return v as number}
/** Closed, bounded content document. The same validator runs in persistence and SSR. */
export function series(input:unknown,layouts:Layout[]):Series{
const raw=exact(input,['version','brief','style','sources','pages']);if(raw.version!==1)fail('不支持的作品版本');
const brief=exact(raw.brief,['topic','audience','intent','designIntent','imageStrategy']);for(const k of ['topic','audience','intent','designIntent','imageStrategy'])str(brief[k],2000);
const style=exact(raw.style,['version','palette','typography','spacing']);str(style.version,80);
const palette=exact(style.palette,['paper','ink','muted','accent']);for(const k of ['paper','ink','muted','accent'])if(!/^#[a-f\d]{6}$/i.test(String(palette[k])))fail('作品颜色使用 #RRGGBB');
const typography=exact(style.typography,['title','body','caption']);
for(const role of ['title','body','caption']){const t=exact(typography[role],['fontFamily','fontSize','fontWeight','lineHeight','letterSpacing']);if(!['sans','serif','mono'].includes(String(t.fontFamily)))fail('无效作品字体');num(t.fontSize,.1,40);num(t.fontWeight,100,900);num(t.lineHeight,.5,4);num(t.letterSpacing,-5,10)}
const spacing=exact(style.spacing,['margin','gap']);num(spacing.margin,4,16);num(spacing.gap,1,10);if(Number(spacing.margin)*2+Number(spacing.gap)*4>48)fail('页边距与间距组合过大');
if(!Array.isArray(raw.sources)||raw.sources.length>100)fail('素材来源最多100条');
const sources=raw.sources.map(v=>{const s=exact(v,['assetId','author','license','url','prompt','tool']);if(!uuid(str(s.assetId,36)))fail('来源需素材 UUID');for(const k of ['author','license','url','prompt','tool'])str(s[k],4000);if(s.url!=='generated'&&!/^https?:\/\//.test(String(s.url)))fail('来源使用 http(s) 地址或 generated');return s as Series['sources'][number]});
if(!Array.isArray(raw.pages)||raw.pages.length<3||raw.pages.length>18)fail('作品需要3–18页');
const seen=new Set<string>();
const pages=raw.pages.map((entry,index)=>{const p=exact(entry,['key','role','layoutId','intent','bindings','parameters']);const key=str(p.key,60);if(!/^[a-z0-9-]+$/.test(key)||seen.has(key))fail('页面 key 必须唯一');seen.add(key);
const role=index===0?'cover':index===(raw.pages as unknown[]).length-1?'ending':'body';if(p.role!==role)fail('页序须为封面、正文、结尾');str(p.intent,1000);const layout=layouts.find(l=>l.id===p.layoutId);if(!layout)fail('作品模板不存在');const resolvedLayout=layout!;const params=parameters(resolvedLayout,p.parameters);const bindings=obj(p.bindings);const slots=[...resolvedLayout.elements,...(params.added||[])];for(const [id,token] of Object.entries(bindings)){if(!slots.some(s=>s.id===id&&s.kind==='text')||!['title','body','caption'].includes(String(token)))fail('字体绑定无效');if(!params.texts[id])fail('绑定文字必须提供文案')}
// Every visible text is authored; template placeholder copy must never leak into a finished series.
for(const s of slots)if(s.kind==='text'&&!params.layers?.[s.id]?.hidden&&!params.texts[s.id])fail('请填写或隐藏所有文字元素');
for(const image of Object.values(params.images))if(image.assetId&&!sources.some(s=>s.assetId===image.assetId))fail('每张图片需记录作者、许可和生成过程');
return {...p,parameters:params,bindings} as Series['pages'][number]});
return {version:1,brief:brief as Series['brief'],style:style as Series['style'],sources,pages};
}
/** Style units use a 120-wide reference canvas; explicit element values always win. */
export function pageParameters(doc:Series,page:Series['pages'][number],layout:Layout):Parameters{
const p=page.parameters,s=doc.style,k=layout.width/120;
const result:Parameters={...p,paper:p.paper||s.palette.paper,texts:{...p.texts},images:{...p.images},decorations:{...p.decorations},layers:{...p.layers}};
for(const [id,token] of Object.entries(page.bindings)){const t=s.typography[token];result.texts[id]={...t,fontSize:t.fontSize*k,letterSpacing:t.letterSpacing*k,color:token==='caption'?s.palette.muted:s.palette.ink,...p.texts[id]}}
for(const slot of [...layout.elements,...(p.added||[])]){if(slot.kind==='image')result.images[slot.id]={color:s.palette.accent,...p.images[slot.id]};if(slot.kind==='decoration')result.decorations[slot.id]={color:s.palette.accent,...p.decorations[slot.id]}}
if(layout.id==='standard-01'){
const m=s.spacing.margin,g=s.spacing.gap,w=120-2*m;
const boxes:Record<string,number[]>={T03:[m,m,w,4],T01:[m,m+4+g,w,28],I01:[m,m+32+2*g,w,50],T02:[m,m+82+3*g,w,27],D01:[m,160-m,w,.4]};
for(const [id,[x,y,width,height]] of Object.entries(boxes))result.layers![id]={x,y,width,height,...p.layers?.[id]};
}
return result;
}
+6 -1
View File
@@ -1,3 +1,8 @@
import { useEffect } from 'react';
import { useRouter } from 'next/router';
import type { AppProps } from "next/app";
import "../styles/globals.css";
export default function App({Component,pageProps}:AppProps){return <Component {...pageProps}/>;}
import "../styles/brand.css";
export default function App({Component,pageProps}:AppProps){const router = useRouter();
useEffect(() => { const scope = /^\/(?:graphic|infinite_canvas|layouts|template-layout)(?:\/|$)/.test(router.pathname) ? 'canvas' : 'home'; document.documentElement.dataset.theme = `${scope}-${document.documentElement.dataset.mode === 'dark' ? 'dark' : 'light'}`; }, [router.pathname]);
return <Component {...pageProps}/>;}
+3
View File
@@ -0,0 +1,3 @@
import { Html, Head, Main, NextScript } from 'next/document';
const initializeTheme = String.raw`(function(){try{var mode=localStorage.getItem('endoleap.theme')==='dark'?'dark':'light';var canvas=/^\/(?:studio\/)?(?:graphic|infinite_canvas|layouts|template-layout)(?:\/|$)/.test(location.pathname);document.documentElement.dataset.mode=mode;document.documentElement.dataset.theme=(canvas?'canvas':'home')+'-'+mode;}catch(e){}})();`;
export default function Document() { return <Html lang="zh-CN" data-theme="home-light" data-mode="light" suppressHydrationWarning><Head><link rel="icon" href="/studio/favicon.ico?v=endoleap-1.2" sizes="any"/><link rel="icon" type="image/svg+xml" href="/studio/brand/icon-dark.svg?v=endoleap-1.2"/><link rel="apple-touch-icon" href="/studio/apple-touch-icon.png"/><script dangerouslySetInnerHTML={{__html:initializeTheme}}/></Head><body><Main/><NextScript/></body></Html>; }
+3 -2
View File
@@ -1,3 +1,4 @@
import {embedSvgMedia} from '../../../lib/layout-images';
import {renderToStaticMarkup} from 'react-dom/server';
import {serveApiDocumentation} from '../../../lib/api-documentation';
import type {NextApiRequest,NextApiResponse} from 'next';
@@ -5,7 +6,7 @@ import {ideas,ideaMarkdown} from '../../../content/ideas';
import {selections} from '../../../components/SourceCover';
import {renderCover} from '../../../lib/render-cover';
import {tutorialPost} from '../../../content/tool-links';
export default function handler(req:NextApiRequest,res:NextApiResponse){
export default async function handler(req:NextApiRequest,res:NextApiResponse){
if(serveApiDocumentation(req,res,'/studio/api/export/{slug}'))return;
if(!['GET','POST'].includes(req.method||'')){res.setHeader('Allow','GET, POST');return res.status(405).end();}
const slug=String(req.query.slug||''),idea=ideas.find(i=>i.slug===slug);if(!idea)return res.status(404).end();
@@ -17,7 +18,7 @@ export default function handler(req:NextApiRequest,res:NextApiResponse){
const result=renderCover(slug,input);res.setHeader('Cache-Control','no-store');
res.setHeader('Content-Disposition',`attachment; filename="${slug}.${req.query.format==='json'?'json':'svg'}"`);
if(req.query.format==='json')return res.json(result.data);
res.setHeader('Content-Type','image/svg+xml; charset=utf-8');return res.send(renderToStaticMarkup(result.node));
res.setHeader('Content-Type','image/svg+xml; charset=utf-8');return res.send(await embedSvgMedia(req,renderToStaticMarkup(result.node)));
}catch(error){return res.status(400).json({error:error instanceof Error?error.message:'无效参数'});}
}
if(req.query.format==='markdown'){res.setHeader('Content-Type','text/markdown; charset=utf-8');return res.send(ideaMarkdown(idea)+'\n\n使用教程:'+tutorialPost+(idea.tool?'\n\n```json\n'+JSON.stringify(selections,null,2)+'\n```':''));}
+1 -1
View File
@@ -1,3 +1,3 @@
import type {NextApiRequest,NextApiResponse} from 'next';
import {ideas} from '../../content/ideas';
export default function handler(_:NextApiRequest,res:NextApiResponse){res.setHeader('Content-Type','text/markdown; charset=utf-8');res.send('# 创作站\n\n- [27 套排版目录与 API](/studio/api/layouts?agent_mode=True)\n- [按 ID 保存效果](/api/studio/designs?agent_mode=True)\n\n'+ideas.map(i=>`- [${i.title}](/studio/ideas/${i.slug}) · [正文](/studio/api/export/${i.slug}?format=markdown)`).join('\n'));}
export default function handler(_:NextApiRequest,res:NextApiResponse){res.setHeader('Content-Type','text/markdown; charset=utf-8');res.send('# 创作站\n\n- [在线画布、引用与 Agent 任务](/api/canvases?agent_mode=True)\n- [27 套排版目录与 API](/studio/api/layouts?agent_mode=True)\n- [多页作品与统一视觉](/api/studio/series?agent_mode=True)\n- [按 ID 保存效果](/api/studio/designs?agent_mode=True)\n\n'+ideas.map(i=>`- [${i.title}](/studio/ideas/${i.slug}) · [正文](/studio/api/export/${i.slug}?format=markdown)`).join('\n'));}
+3 -2
View File
@@ -1,3 +1,4 @@
import {embedSvgMedia} from '../../../lib/layout-images';
import type {NextApiRequest,NextApiResponse} from 'next';
import {embedLayoutImages} from '../../../lib/layout-images';
import {createElement} from 'react';
@@ -15,7 +16,7 @@ const workspaceSchema={
};
export default async function handler(req:NextApiRequest,res:NextApiResponse){
res.setHeader('Cache-Control','no-store');
if(String(req.query.agent_mode).toLowerCase()==='true'&&['GET','HEAD'].includes(req.method||'')){res.setHeader('Content-Type','text/plain; charset=utf-8');if(req.method==='HEAD')return res.status(200).end();return res.send(`# 扩展编辑参数\n${JSON.stringify(workspaceSchema,null,2)}\n\n# 排版参数与渲染 API\n\nGET /studio/api/layouts/{id} 返回模板尺寸、T/I/D 构件 ID、坐标、原始文字可辨识状态与默认参数。id 为 standard-01(标准图文)或 ref-01 .. ref-27(固定构图)。standard-01 仅返回原生模板参数,不提供兼容 SVG。\n页面 /studio/layouts/{id} 直接提供原生 HTML/CSS 默认排版,不加载原图。带 ?id={designId} 在服务端加载已保存页面;ID 与排版不匹配或不存在返回404。右键或编辑页面可调整元素。\nGET 同地址 ?format=svg 是兼容的原图图层导出,与原生页面使用的 HTML/CSS 不同。POST JSON 参数返回 SVG,不自动保存。?format=json 返回规范化参数。\n参数整体形状:{texts:{T01:{value:"标题",color:"#26362f",scale:1,align:"start"}},images:{I01:{color:"#788880",assetId:"已上传图片UUID",fit:"cover"}},decorations:{D01:{color:"#53645b",hidden:false}},paper:"#f4f2eb"}。全部可省略;构件 ID 必须属于当前模板。文字 value 可为空(移除),最多500字/8行;scale 0.25–2,align start/middle/end;fit cover/contain。图片先通过 /api/upload assetOnly=true 上传。\n未替换构件保留原始低分辨率图层;替换文字为 SVG 文字,未辨识小字不会编造。图位使用原始形状蒙版。paper 修改原底色层,不覆盖原图已有设计色块。\n文字、图像、装饰分组一一对应源图,不接受任意 URL、SVG 或未知构件。最大请求64KB。\n保存:POST /api/studio/designs {layoutId,name,parameters},返回 design.id/url/revision。再次打开 /studio/layouts/{layoutId}?id={id}(原生页面);/studio/designs/{id} 自动跳转;PATCH /api/studio/designs/{id} {name,parameters,revision} 更新。\n错误400参数不合法,404模板不存在,405方法不支持。\n目录:/studio/api/layouts?agent_mode=True\n`)}
if(String(req.query.agent_mode).toLowerCase()==='true'&&['GET','HEAD'].includes(req.method||'')){res.setHeader('Content-Type','text/plain; charset=utf-8');if(req.method==='HEAD')return res.status(200).end();return res.send(`# 扩展编辑参数\n${JSON.stringify(workspaceSchema,null,2)}\n\n# 排版参数与渲染 API\n\nGET /studio/api/layouts/{id} 返回模板尺寸、T/I/D 构件 ID、坐标、原始文字可辨识状态与默认参数。id 为 standard-01(标准图文)或 ref-01 .. ref-27(固定构图)。standard-01 仅返回原生模板参数,不提供兼容 SVG。\n页面 /studio/layouts/{id} 直接提供原生 HTML/CSS 默认排版,不加载原图。保存结果直接打开 /studio/template-layout?id={designId},服务端根据 ID 读取模板、图片和文字,无需选版或编辑器;缺失或不存在的 ID 返回404。\nGET 同地址 ?format=svg 是兼容的原图图层导出,与原生页面使用的 HTML/CSS 不同。POST JSON 参数返回 SVG,不自动保存。?format=json 返回规范化参数。\n参数整体形状:{texts:{T01:{value:"标题",color:"#26362f",scale:1,align:"start"}},images:{I01:{color:"#788880",assetId:"已上传图片UUID",fit:"cover"}},decorations:{D01:{color:"#53645b",hidden:false}},paper:"#f4f2eb"}。全部可省略;构件 ID 必须属于当前模板。文字 value 可为空(移除),最多500字/8行;scale 0.25–2,align start/middle/end;fit cover/contain。图片先通过 /api/upload assetOnly=true 上传。\n未替换构件保留原始低分辨率图层;替换文字为 SVG 文字,未辨识小字不会编造。图位使用原始形状蒙版。paper 修改原底色层,不覆盖原图已有设计色块。\n文字、图像、装饰分组一一对应源图,不接受任意 URL、SVG 或未知构件。最大请求64KB。\n保存:POST /api/studio/designs {layoutId,name,parameters},返回 design.id/url/revision。再次打开返回的 /studio/template-layout?id={id}(纯结果页面);/studio/designs/{id} 自动跳转;PATCH /api/studio/designs/{id} {name,parameters,revision} 更新。\n错误400参数不合法,404模板不存在,405方法不支持。\n目录:/studio/api/layouts?agent_mode=True\n`)}
if(req.query.id==='standard-01'){
if(!['GET','HEAD','POST'].includes(req.method||'')){res.setHeader('Allow','GET, HEAD, POST');return res.status(405).end()}
try{const p=parameters(standardLayout,req.method==='POST'?req.body:{});
@@ -25,6 +26,6 @@ export default async function handler(req:NextApiRequest,res:NextApiResponse){
}catch(e){return res.status(400).json({error:e instanceof Error?e.message:'参数不合法'})}
}
if(!['GET','HEAD','POST'].includes(req.method||'')){res.setHeader('Allow','GET, HEAD, POST');return res.status(405).end()}const layout=layouts.find(l=>l.id===req.query.id);if(!layout)return res.status(404).json({error:'排版不存在'});
try{if(req.query.format!==undefined&&!['svg','json'].includes(String(req.query.format)))throw new LayoutParameterError('format 为 svg 或 json');const params=parameters(layout,req.method==='POST'?req.body:{});if(req.method==='HEAD')return res.status(200).end();if(req.query.format==='json')return res.json({layoutId:layout.id,parameters:params});if(req.method==='POST'||req.query.format==='svg'){res.setHeader('Content-Type','image/svg+xml; charset=utf-8');return res.send(renderToStaticMarkup(createElement(LayoutCanvas,{layout,params,imageSources:await embedLayoutImages(req,params)})))}return res.json({schemaVersion:1,family:'fixed',id:layout.id,name:layout.name,width:layout.width,height:layout.height,sourceSha256:layout.sourceSha256,tutorialUrl:layout.tutorialUrl,elements:layout.elements.map(({id,kind,role,box,direction,text,reading})=>({id,kind,role,box,direction,text,reading})),parameters:params,workspaceSchema,url:'/studio/layouts/'+layout.id})}catch(e){return res.status(400).json({error:e instanceof Error?e.message:'参数不合法'})}
try{if(req.query.format!==undefined&&!['svg','json'].includes(String(req.query.format)))throw new LayoutParameterError('format 为 svg 或 json');const params=parameters(layout,req.method==='POST'?req.body:{});if(req.method==='HEAD')return res.status(200).end();if(req.query.format==='json')return res.json({layoutId:layout.id,parameters:params});if(req.method==='POST'||req.query.format==='svg'){res.setHeader('Content-Type','image/svg+xml; charset=utf-8');return res.send(await embedSvgMedia(req,renderToStaticMarkup(createElement(LayoutCanvas,{layout,params,imageSources:await embedLayoutImages(req,params)}))))}return res.json({schemaVersion:1,family:'fixed',id:layout.id,name:layout.name,width:layout.width,height:layout.height,sourceSha256:layout.sourceSha256,tutorialUrl:layout.tutorialUrl,elements:layout.elements.map(({id,kind,role,box,direction,text,reading})=>({id,kind,role,box,direction,text,reading})),parameters:params,workspaceSchema,url:'/studio/layouts/'+layout.id})}catch(e){return res.status(400).json({error:e instanceof Error?e.message:'参数不合法'})}
}
export const config={api:{bodyParser:{sizeLimit:'64kb'}}};
+2 -2
View File
@@ -5,6 +5,6 @@ export default function handler(req:NextApiRequest,res:NextApiResponse){
if(!['GET','HEAD'].includes(req.method||'')){res.setHeader('Allow','GET, HEAD');return res.status(405).end()}
const entries=[standard,...fixed].map(l=>({id:l.id,name:l.name,family:l.id==='standard-01'?'standard':'fixed',url:'/studio/layouts/'+l.id,api:'/studio/api/layouts/'+l.id}));
res.setHeader('Cache-Control','no-store');if(req.method==='HEAD')return res.status(200).end();
if(String(req.query.agent_mode).toLowerCase()==='true'){res.setHeader('Content-Type','text/plain; charset=utf-8');return res.send('# Studio 可视化排版 API 库\n\n'+entries.map(e=>`${e.family} / ${e.id} · ${e.name}\n页面:${e.url}\n参数:${e.api}?agent_mode=True`).join('\n\n')+'\n\nCRUD:/api/studio/designs?agent_mode=True\n统一文档:/explore/8eb8c76500580f7a4b56ba3c')}
return res.json({version:1,layouts:entries,saveApi:'/api/studio/designs',catalogUrl:'/studio',documentationUrl:'/explore/8eb8c76500580f7a4b56ba3c'});
if(String(req.query.agent_mode).toLowerCase()==='true'){res.setHeader('Content-Type','text/plain; charset=utf-8');return res.send('# Studio 可视化排版 API 库\n\n'+entries.map(e=>`${e.family} / ${e.id} · ${e.name}\n页面:${e.url}\n参数:${e.api}?agent_mode=True`).join('\n\n')+'\n\n多页作品:/api/studio/series?agent_mode=True\n单页 CRUD:/api/studio/designs?agent_mode=True\n统一文档:/explore/8eb8c76500580f7a4b56ba3c')}
return res.json({version:1,seriesDocumentVersion:1,seriesApi:'/api/studio/series',layouts:entries,saveApi:'/api/studio/designs',catalogUrl:'/studio',documentationUrl:'/explore/8eb8c76500580f7a4b56ba3c'});
}
+48
View File
@@ -0,0 +1,48 @@
import { ThemeToggle } from "../../components/ThemeToggle";
import { Brand } from "../../components/Brand";
import Head from 'next/head';
import {useEffect,useRef,useState,type PointerEvent} from 'react';
import {MousePointer2,Type,Square,Circle,Plus,Minus,Undo2,Redo2,Download,Layers,ImagePlus,Trash2,Copy,Eye,EyeOff,Lock,Unlock,Scan,ChevronRight,ArrowUpRight,PenTool,X,Hand,Link2,BookOpen,PanelLeft,PanelRight} from 'lucide-react';
import {newElementId} from '../../lib/native-layout';
import styles from '../../styles/graphic.module.css';
import {parseGraphicDocument,type GraphicItem as Item,type GraphicDocument as Doc} from '../../lib/graphic-document';
const blank:Doc={name:'未命名设计',background:'#ffffff',items:[]};
const storage='studio.graphic.v1';
export default function Graphic(){
const [doc,setDoc]=useState<Doc>(blank),[selected,setSelected]=useState(''),[past,setPast]=useState<Doc[]>([]),[future,setFuture]=useState<Doc[]>([]),[zoom,setZoom]=useState(.65),[status,setStatus]=useState('本机草稿'),[tab,setTab]=useState('图层'),[user,setUser]=useState<{email:string}|null>(null),[auth,setAuth]=useState(false),[mode,setMode]=useState('login'),[message,setMessage]=useState(''),[busy,setBusy]=useState(false);
const [tool,setTool]=useState('select'),[offset,setOffset]=useState({x:0,y:0}),[showLayers,setShowLayers]=useState(true),[showProps,setShowProps]=useState(true);
const dialog=useRef<HTMLDivElement>(null);
useEffect(()=>{if(!auth)return;const old=document.activeElement as HTMLElement;const box=dialog.current;box?.querySelector<HTMLElement>('button,input')?.focus();function key(e:KeyboardEvent){if(e.key==='Escape'){setAuth(false);return}if(e.key!=='Tab'||!box)return;const nodes=Array.from(box.querySelectorAll<HTMLElement>('button:not(:disabled),input,a[href]'));const first=nodes[0],last=nodes[nodes.length-1];if(e.shiftKey&&document.activeElement===first){e.preventDefault();last?.focus()}else if(!e.shiftKey&&document.activeElement===last){e.preventDefault();first?.focus()}}document.addEventListener('keydown',key);return()=>{document.removeEventListener('keydown',key);old?.focus()}},[auth]);
const restore=useRef<HTMLInputElement>(null);
const board=useRef<HTMLDivElement>(null),upload=useRef<HTMLInputElement>(null),viewport=useRef<HTMLDivElement>(null),ready=useRef(false),dirty=useRef(false);
const item=doc.items.find(i=>i.id===selected);
useEffect(()=>{try{const raw=localStorage.getItem(storage);if(raw){setDoc(parseGraphicDocument(raw));}}catch{setStatus('无法读取草稿,请导出备份')}ready.current=true;void refresh();},[]);
useEffect(()=>{if(!ready.current)return;try{localStorage.setItem(storage,JSON.stringify(doc));setStatus('已保存到此浏览器');dirty.current=false;}catch{setStatus('本机空间不足,请导出备份');dirty.current=true;}},[doc]);
useEffect(()=>{const warn=(e:BeforeUnloadEvent)=>{if(dirty.current){e.preventDefault();e.returnValue=''}};window.addEventListener('beforeunload',warn);return()=>window.removeEventListener('beforeunload',warn)},[]);
async function refresh(){try{const r=await fetch('/api/account/session');if(r.ok)setUser((await r.json()).user);}catch{/* Guest editing remains available. */}}
function change(next:Doc){setPast(p=>[...p.slice(-49),doc]);setFuture([]);setDoc(next);dirty.current=true;}
function patch(p:Partial<Item>){if(item&&!item.locked)change({...doc,items:doc.items.map(i=>i.id===selected?{...i,...p}:i)})}
function add(kind:Item['kind'],src?:string){if(doc.items.length>=500){setStatus('单页最多 500 个图层');return;}const id=newElementId();const next:Item={id,kind,name:kind==='text'?'文字':kind==='image'?'图片':kind==='rect'?'矩形':'椭圆',text:'让灵感,有形。',x:90+doc.items.length*12%260,y:100+doc.items.length*12%400,w:kind==='text'?480:260,h:kind==='text'?90:220,fill:kind==='text'?'#282832':'#b8b3ff',size:52,hidden:false,locked:false,src};change({...doc,items:[...doc.items,next]});setSelected(id);setTab('图层');}
function undo(){if(!past.length)return;setFuture(f=>[doc,...f]);setDoc(past[past.length-1]);setPast(p=>p.slice(0,-1));}
function redo(){if(!future.length)return;setPast(p=>[...p,doc]);setDoc(future[0]);setFuture(f=>f.slice(1));}
function remove(){if(item&&!item.locked){change({...doc,items:doc.items.filter(i=>i.id!==selected)});setSelected('')}}
function duplicate(){if(item&&doc.items.length<500){const id=newElementId();change({...doc,items:[...doc.items,{...item,id,x:item.x+24,y:item.y+24,name:item.name+' 副本',locked:false}]});setSelected(id)}}
function fit(){setOffset({x:0,y:0});if(viewport.current)setZoom(Math.max(.15,Math.min(1,(viewport.current.clientWidth-100)/800,(viewport.current.clientHeight-100)/1000)))}
useEffect(()=>{fit();},[]);
useEffect(()=>{function key(e:KeyboardEvent){if((e.target as HTMLElement).closest('input,textarea,select,button'))return;if((e.metaKey||e.ctrlKey)&&e.key.toLowerCase()==='z'){e.preventDefault();e.shiftKey?redo():undo()}else if((e.metaKey||e.ctrlKey)&&e.key.toLowerCase()==='d'){e.preventDefault();duplicate()}else if(e.key==='Delete'||e.key==='Backspace'){e.preventDefault();remove()}else if(e.key==='Escape')setSelected('');else if(!e.ctrlKey&&!e.metaKey&&e.key.toLowerCase()==='v')setTool('select');else if(!e.ctrlKey&&!e.metaKey&&e.key.toLowerCase()==='h')setTool('hand');else if(item&&!item.locked&&e.key.startsWith('Arrow')){e.preventDefault();const step=e.shiftKey?10:1;patch({x:item.x+(e.key==='ArrowRight'?step:e.key==='ArrowLeft'?-step:0),y:item.y+(e.key==='ArrowDown'?step:e.key==='ArrowUp'?-step:0)})}}window.addEventListener('keydown',key);return()=>window.removeEventListener('keydown',key)});
function drag(e:PointerEvent<HTMLDivElement>,i:Item){if(tool==='hand'){panBoard(e);return}e.stopPropagation();setSelected(i.id);if(i.locked||e.button!==0)return;e.preventDefault();e.currentTarget.setPointerCapture(e.pointerId);const x=e.clientX,y=e.clientY,start=doc;let next=start;const target=e.currentTarget;const move=(p:globalThis.PointerEvent)=>{next={...start,items:start.items.map(n=>n.id===i.id?{...n,x:Math.max(-4000,Math.min(4000,i.x+(p.clientX-x)/zoom)),y:Math.max(-4000,Math.min(4000,i.y+(p.clientY-y)/zoom))}:n)};setDoc(next)};const end=()=>{target.removeEventListener('pointermove',move);target.removeEventListener('pointerup',end);target.removeEventListener('pointercancel',end);if(next!==start){setPast(p=>[...p.slice(-49),start]);setFuture([])}};target.addEventListener('pointermove',move);target.addEventListener('pointerup',end);target.addEventListener('pointercancel',end);}
function resize(e:PointerEvent<HTMLElement>){e.stopPropagation();if(!item||item.locked)return;e.preventDefault();const start=doc,x=e.clientX,y=e.clientY,target=e.currentTarget;target.setPointerCapture(e.pointerId);let next=start;const move=(p:globalThis.PointerEvent)=>{next={...start,items:start.items.map(i=>i.id===item.id?{...i,w:Math.max(1,Math.min(4000,item.w+(p.clientX-x)/zoom)),h:Math.max(1,Math.min(4000,item.h+(p.clientY-y)/zoom))}:i)};setDoc(next)};const end=()=>{target.removeEventListener('pointermove',move);target.removeEventListener('pointerup',end);target.removeEventListener('pointercancel',end);if(next!==start){setPast(p=>[...p.slice(-49),start]);setFuture([])}};target.addEventListener('pointermove',move);target.addEventListener('pointerup',end);target.addEventListener('pointercancel',end)}
function panBoard(e:PointerEvent<HTMLElement>){if(tool!=='hand')return;e.preventDefault();const x=e.clientX,y=e.clientY,start=offset,target=e.currentTarget;target.setPointerCapture(e.pointerId);const move=(p:globalThis.PointerEvent)=>setOffset({x:start.x+p.clientX-x,y:start.y+p.clientY-y});const end=()=>{target.removeEventListener('pointermove',move);target.removeEventListener('pointerup',end);target.removeEventListener('pointercancel',end)};target.addEventListener('pointermove',move);target.addEventListener('pointerup',end);target.addEventListener('pointercancel',end)}
function download(content:string,type:string,extension:string){const url=URL.createObjectURL(new Blob([content],{type}));const a=document.createElement('a');a.href=url;a.download=doc.name+'.'+extension;a.click();setTimeout(()=>URL.revokeObjectURL(url),1000)}
function exportSVG(){const esc=(s:string)=>s.replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&apos;'}[c]!));const svg=doc.items.filter(i=>!i.hidden).map(i=>i.kind==='text'?`<text x="${i.x}" y="${i.y+i.size}" font-family="sans-serif" font-size="${i.size}" fill="${esc(i.fill)}">${i.text.split('\n').map((line,n)=>`<tspan x="${i.x}" dy="${n?i.size*1.3:0}">${esc(line)}</tspan>`).join('')}</text>`:i.kind==='image'?`<image x="${i.x}" y="${i.y}" width="${i.w}" height="${i.h}" href="${esc(i.src||'')}" preserveAspectRatio="xMidYMid slice"/>`:i.kind==='ellipse'?`<ellipse cx="${i.x+i.w/2}" cy="${i.y+i.h/2}" rx="${i.w/2}" ry="${i.h/2}" fill="${esc(i.fill)}"/>`:`<rect x="${i.x}" y="${i.y}" width="${i.w}" height="${i.h}" fill="${esc(i.fill)}"/>`).join('');download(`<svg xmlns="http://www.w3.org/2000/svg" width="800" height="1000" viewBox="0 0 800 1000"><rect width="800" height="1000" fill="${esc(doc.background)}"/>${svg}</svg>`,'image/svg+xml','svg');}
async function account(e:React.FormEvent<HTMLFormElement>){e.preventDefault();setBusy(true);setMessage('');const data=new FormData(e.currentTarget);try{const r=await fetch('/api/account/'+mode,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({email:data.get('email'),password:data.get('password')})});const d=await r.json();setMessage(d.error||d.message||'登录成功');if(r.ok){await refresh();if(mode==='login')setAuth(false);}}catch{setMessage('账户服务暂时无法连接')}finally{setBusy(false)}}
return <div className={styles.app} data-layers={showLayers} data-properties={showProps}><Head><title>{doc.name + " · 啡跃 Endoleap Graphic"}</title><meta name="description" content="在 Studio 中自由编排文字、图形与图片。"/></Head>
<header className={styles.top}><a href="/workspace" className={styles.brand}><Brand compact/><span className={styles.badge}>GRAPHIC</span></a><button aria-label="切换图层面板" aria-pressed={showLayers} onClick={()=>setShowLayers(v=>!v)}><PanelLeft size={17}/></button><button aria-label="切换属性面板" aria-pressed={showProps} onClick={()=>setShowProps(v=>!v)}><PanelRight size={17}/></button><ChevronRight size={14}/><input aria-label="设计名称" value={doc.name} maxLength={100} onChange={e=>change({...doc,name:e.target.value})}/><span className={styles.saved} role="status">{status}</span><button title="撤销" aria-label="撤销" disabled={!past.length} onClick={undo}><Undo2 size={17}/></button><button title="重做" aria-label="重做" disabled={!future.length} onClick={redo}><Redo2 size={17}/></button><ThemeToggle/><button onClick={()=>setAuth(true)} className={styles.account}>{user?user.email:'邮箱登录'}</button><button className={styles.primary} onClick={exportSVG}><Download size={15}/>导出 SVG</button></header>
<div className={styles.body}><aside className={styles.left}><div className={styles.tabs}>{['图层','素材','Agent'].map(t=><button key={t} aria-pressed={tab===t} onClick={()=>setTab(t)}>{t}</button>)}</div><div className={styles.pageTitle}><span>页面 01</span><span>800 × 1000</span></div>{tab==='Agent'?<div className={styles.agentPanel}><Link2 size={24}/><h3>与你的 Agent 一起设计</h3><p>将接入文档交给 coding agent,使用已有的模板与创作 API。</p><a href="/studio/api" target="_blank" rel="noreferrer">查看接入文档 ↗</a><a href="/documents" target="_blank" rel="noreferrer"><BookOpen size={15}/>知识与教程 ↗</a><small>在 Agent 中打开文档,查看可用的创作接口与使用方式。</small></div>:tab==='图层'?<><div className={styles.layers}>{[...doc.items].reverse().map(i=><div className={styles.layer} data-selected={i.id===selected} key={i.id}><button onClick={()=>setSelected(i.id)}>{i.kind==='text'?<Type size={14}/>:<Square size={14}/>}<span>{i.name}</span></button><button aria-label={i.hidden?'显示图层':'隐藏图层'} onClick={()=>change({...doc,items:doc.items.map(n=>n.id===i.id?{...n,hidden:!n.hidden}:n)})}>{i.hidden?<EyeOff size={13}/>:<Eye size={13}/>}</button><button aria-label={i.locked?'解锁图层':'锁定图层'} onClick={()=>change({...doc,items:doc.items.map(n=>n.id===i.id?{...n,locked:!n.locked}:n)})}>{i.locked?<Lock size={13}/>:<Unlock size={13}/>}</button></div>)}{!doc.items.length&&<div className={styles.emptyLayers}><Layers size={23}/><p>好设计,从第一笔开始</p><small>从下方工具栏添加文字与图形</small></div>}</div><button className={styles.addLayer} onClick={()=>add('text')}><Plus size={15}/>添加图层</button></>:<div className={styles.assets}><h3>基础元素</h3><button onClick={()=>add('rect')}><Square/>矩形</button><button onClick={()=>add('ellipse')}><Circle/>椭圆</button><button onClick={()=>add('text')}><Type/>文字</button><button onClick={()=>upload.current?.click()}><ImagePlus/>上传图片</button><p>你的画布,由你定义。支持 PNG、JPEG、WebP。</p></div>}<div className={styles.leftFooter}><a href="/infinite_canvas">Infinite Canvas <ArrowUpRight size={14}/></a><small>⌘ Z 撤销 · 方向键微调 · Delete 删除</small></div></aside>
<main ref={viewport} className={styles.viewport} onPointerDown={e=>{setSelected('');panBoard(e)}}><div className={styles.canvasLabel}>画板 01 <span>800 × 1000</span></div><div className={styles.boardWrap} style={{width:800*zoom,height:1000*zoom,transform:`translate(${offset.x}px,${offset.y}px)`}}><div ref={board} className={styles.board} style={{background:doc.background,transform:`scale(${zoom})`}}>{doc.items.filter(i=>!i.hidden).map(i=><div key={i.id} tabIndex={0} role="button" aria-label={i.name} aria-pressed={selected===i.id} onFocus={()=>setSelected(i.id)} onPointerDown={e=>drag(e,i)} className={styles.element} data-selected={selected===i.id} style={{left:i.x,top:i.y,width:i.w,height:i.h,borderRadius:i.kind==='ellipse'?'50%':0,background:i.kind==='rect'||i.kind==='ellipse'?i.fill:undefined,color:i.fill,fontSize:i.size,whiteSpace:'pre-wrap'}}>{i.kind==='text'?i.text:i.kind==='image'?<img src={i.src} alt={i.name} draggable={false}/>:null}{selected===i.id&&<><i className={styles.corner} onPointerDown={resize}/><span className={styles.measure}>{Math.round(i.w)} × {Math.round(i.h)}</span></>}</div>)}{!doc.items.length&&<div className={styles.blankHint}><PenTool size={34} strokeWidth={1}/><h1>留白,是创作的起点。</h1><p>添加文字、图形,或放入你的第一张图片。</p><button onPointerDown={e=>e.stopPropagation()} onClick={()=>add('text')}>开始设计 <ArrowUpRight size={16}/></button></div>}</div></div><div className={styles.tools} onPointerDown={e=>e.stopPropagation()}><button title="选择" aria-label="选择工具" aria-pressed={tool==='select'} onClick={()=>{setTool('select');setSelected('')}}><MousePointer2 size={20}/></button><button title="抓手工具" aria-label="抓手工具" aria-pressed={tool==='hand'} onClick={()=>setTool('hand')}><Hand size={19}/></button><button title="文字" aria-label="添加文字" onClick={()=>add('text')}><Type size={20}/></button><button title="矩形" aria-label="添加矩形" onClick={()=>add('rect')}><Square size={19}/></button><button title="椭圆" aria-label="添加椭圆" onClick={()=>add('ellipse')}><Circle size={19}/></button><button title="图片" aria-label="上传图片" onClick={()=>upload.current?.click()}><ImagePlus size={19}/></button><span/><button aria-label="缩小" onClick={()=>setZoom(z=>Math.max(.15,z-.1))}><Minus size={16}/></button><output>{Math.round(zoom*100)}%</output><button aria-label="放大" onClick={()=>setZoom(z=>Math.min(2,z+.1))}><Plus size={16}/></button><button aria-label="适应画布" onClick={fit}><Scan size={18}/></button></div></main>
<aside className={styles.right}><div className={styles.inspectorTitle}>设计属性<span>Design</span></div>{item?<div className={styles.props}><label>图层名称<input maxLength={200} value={item.name} disabled={item.locked} onChange={e=>patch({name:e.target.value})}/></label><h3>布局</h3><div className={styles.fields}>{(['x','y','w','h'] as const).map(k=><label key={k}>{({x:'X',y:'Y',w:'宽',h:'高'})[k]}<input type="number" value={Math.round(item[k])} disabled={item.locked} onChange={e=>{const value=Number(e.target.value);if(Number.isFinite(value))patch({[k]:k==='w'||k==='h'?Math.max(1,Math.min(4000,value)):Math.max(-4000,Math.min(4000,value))})}}/></label>)}</div>{item.kind==='text'&&<><h3>文字</h3><textarea aria-label="文字内容" value={item.text} maxLength={4000} disabled={item.locked} onChange={e=>patch({text:e.target.value})}/><label>字号<input type="number" min={8} max={240} value={item.size} disabled={item.locked} onChange={e=>patch({size:Math.max(8,Math.min(240,Number(e.target.value)||8))})}/></label></>}<h3>填色</h3><label className={styles.color}><input type="color" value={item.fill} disabled={item.locked} onChange={e=>patch({fill:e.target.value})}/>{item.fill.toUpperCase()}</label><div className={styles.actions}><button onClick={duplicate}><Copy size={15}/>复制</button><button disabled={item.locked} onClick={remove}><Trash2 size={15}/>删除</button></div><button disabled={item.locked} onClick={()=>{const n=doc.items.findIndex(i=>i.id===item.id);if(n>0){const items=[...doc.items];[items[n-1],items[n]]=[items[n],items[n-1]];change({...doc,items})}}}>下移一层</button></div>:<div className={styles.props}><h3>画板</h3><p>自由画布 · 竖版</p><div className={styles.dimensions}>W <b>800</b> H <b>1000</b></div><h3>背景颜色</h3><label className={styles.color}><input type="color" value={doc.background} onChange={e=>change({...doc,background:e.target.value})}/>{doc.background.toUpperCase()}</label><div className={styles.note}><MousePointer2 size={20}/><p>选中一个图层,<br/>调整属于它的每个细节。</p></div></div>}<div className={styles.rightFooter}><button onClick={()=>download(JSON.stringify(doc,null,2),'application/json','json')}><Download size={15}/>备份设计 JSON</button><button onClick={()=>restore.current?.click()}>导入设计 JSON</button><p>草稿仅保存在此浏览器。<br/>邮箱登录共用全站账户。</p></div></aside></div>
<input ref={restore} hidden type="file" accept="application/json,.json" onChange={async e=>{const f=e.target.files?.[0];e.target.value='';if(!f)return;try{if(f.size>5*1024*1024)throw Error('文件不得超过 5MB');const restored=parseGraphicDocument(await f.text());change(restored);setSelected('');}catch(error){setStatus(error instanceof Error?error.message:'导入失败')}}}/>
<input ref={upload} hidden type="file" accept="image/png,image/jpeg,image/webp" onChange={e=>{const f=e.target.files?.[0];if(!f)return;if(f.size>3*1024*1024||!['image/png','image/jpeg','image/webp'].includes(f.type)){setStatus('请使用 3MB 以内的 PNG / JPEG / WebP');return}const reader=new FileReader();reader.onload=()=>add('image',String(reader.result));reader.readAsDataURL(f);e.target.value=''}}/>
{auth&&<div className={styles.overlay}><div ref={dialog} className={styles.modal} role="dialog" aria-modal="true" aria-label="邮箱账户"><button className={styles.close} aria-label="关闭" onClick={()=>setAuth(false)}><X size={20}/></button><span className={styles.modalIcon}><Brand compact/></span><h2>{user?'你的 Studio 账户':'把灵感带进 Studio'}</h2>{user?<><p>{user.email}</p><button onClick={async()=>{try{const r=await fetch('/api/account/logout',{method:'POST',headers:{'Content-Type':'application/json'},body:'{}'});if(!r.ok)throw Error();setUser(null);setAuth(false)}catch{setMessage('退出失败,请重试')}}}>退出登录</button></>:<><p>使用知识站的同一个邮箱账户</p><div className={styles.tabs}>{[['login','登录'],['register','注册'],['resend','重发邮件']].map(([v,t])=><button key={v} aria-pressed={mode===v} onClick={()=>setMode(v)}>{t}</button>)}</div><form onSubmit={account}><label>邮箱<input autoFocus required type="email" name="email" autoComplete="email" maxLength={254}/></label><label>密码<input required type="password" name="password" minLength={12} maxLength={256} autoComplete={mode==='register'?'new-password':'current-password'}/></label><small>密码至少 12 字符;新账户需通过邮件验证。</small><button className={styles.primary} disabled={busy}>{busy?'处理中…':mode==='login'?'登录 Studio':mode==='register'?'创建账户':'重发验证邮件'}</button></form></>}<p role="status">{message}</p></div></div>}
</div>
}
+1 -1
View File
@@ -5,6 +5,6 @@ import {LayoutSelection} from '../../components/LayoutSelection';
import {tutorialPost} from '../../content/tool-links';
import {TemplateTrial} from '../../components/TemplateTrial';
import {ideas,type Idea} from '../../content/ideas';
export default function IdeaPage({idea}:{idea:Idea}){if(idea.tool==='layout-selection')return <LayoutSelection/>;if(idea.template)return <TemplateTrial idea={idea}/>;return <main className="editor"><Head><title>{idea.title} · 创作站</title><link rel="alternate" type="text/markdown" href={'/studio/api/export/'+idea.slug+'?format=markdown'}/></Head><header><a href={tutorialPost}>← PROMPT 使用教程</a><a href={'/studio/api/export/'+idea.slug}>导出内容包 ↓</a><a href={'/studio/api/export/'+idea.slug+'?format=markdown'}>Markdown ↓</a><button onClick={()=>window.print()}>打印 / PDF</button></header><h1>{idea.title}</h1><p>{idea.summary}</p><div className="slides">{idea.slides.map((s,i)=><article className="slide" key={i} style={{background:s.color}}><small>PROMPT STUDIO / {String(i+1).padStart(2,'0')}</small><h2>{s.heading}</h2><p>{s.body}</p><footer>记录灵感 · 自由表达</footer></article>)}</div></main>}
export default function IdeaPage({idea}:{idea:Idea}){if(idea.tool==='layout-selection')return <LayoutSelection/>;if(idea.template)return <TemplateTrial idea={idea}/>;return <main className="editor"><Head><title>{idea.title} · 创作站</title><link rel="alternate" type="text/markdown" href={'/studio/api/export/'+idea.slug+'?format=markdown'}/></Head><header><a href={tutorialPost}>← 啡跃使用教程</a><a href={'/studio/api/export/'+idea.slug}>导出内容包 ↓</a><a href={'/studio/api/export/'+idea.slug+'?format=markdown'}>Markdown ↓</a><button onClick={()=>window.print()}>打印 / PDF</button></header><h1>{idea.title}</h1><p>{idea.summary}</p><div className="slides">{idea.slides.map((s,i)=><article className="slide" key={i} style={{background:s.color}}><small>ENDOLEAP / {String(i+1).padStart(2,'0')}</small><h2>{s.heading}</h2><p>{s.body}</p><footer>记录灵感 · 自由表达</footer></article>)}</div></main>}
export const getStaticPaths:GetStaticPaths=async()=>({paths:ideas.map(i=>({params:{slug:i.slug}})),fallback:false});
export const getStaticProps:GetStaticProps=async({params})=>{const idea=ideas.find(i=>i.slug===params?.slug);return idea?{props:{idea}}:{notFound:true}};
+3 -6
View File
@@ -1,6 +1,3 @@
import Head from 'next/head';
import fixed from '../content/templates/layouts.json';
import standard from '../content/standard-layout.json';
import {nativeLayout} from '../lib/native-layout';
import {NativePreview as Preview} from '../components/NativePreview';
export default function StudioEntry(){return <main className="studio-library"><Head><title>Studio · 可视化排版 API 库</title></Head><nav className="library-nav"><a href="/">PROMPT / STUDIO</a><a href="/explore/8eb8c76500580f7a4b56ba3c">使用文档与工具箱</a><a href="/api/studio/designs?agent_mode=True">API 契约</a></nav><h1>用参数,编排画面。</h1><p className="library-intro">给 coding agent 文字、图片与创作意图。选择标准图文或固定构图,通过 HTTP 保存渲染描述,得到一个可持续更新的原生页面。</p><div className="library-heading"><h2>标准排版</h2><a href="/studio/api/layouts/standard-01">参数</a></div><div className="library-standard"><a href="/studio/layouts/standard-01"><Preview layout={standard}/></a><div><h2>一页,一件事。</h2><p>沿用 Studio 标题、正文与页脚的图文结构,增加可配置图片位。文字、颜色、图片和几何坐标共用一套参数,也可添加原生元素。</p><code>layoutId: standard-01</code><p><a href="/studio/layouts/standard-01">打开原生页面</a></p></div></div><div className="library-heading"><h2>固定排版 · 27 套</h2><a href="/explore/8eb8c76500580f7a4b56ba3c">使用文档</a></div><div className="library-grid">{fixed.map(l=><article className="library-item" key={l.id}><a href={'/studio/layouts/'+l.id}><Preview layout={nativeLayout(l)}/><h3>{l.name}</h3><p>{l.id.toUpperCase()}</p></a></article>)}</div></main>}
import type {GetServerSideProps} from 'next';
export default function StudioEntry(){return null}
export const getServerSideProps:GetServerSideProps=async()=>({redirect:{destination:'/graphic',permanent:false}});
@@ -0,0 +1,60 @@
import { ThemeToggle } from "../../components/ThemeToggle";
import { Brand } from "../../components/Brand";
import Head from 'next/head';
import {useEffect,useRef,useState} from 'react';
import {ArrowLeft,Plus,MousePointer2,Hand,ImagePlus,Type,Workflow,Undo2,Redo2,Minus,Maximize,Download,Upload,Trash2,Link2,Sparkles,ChevronRight,Sun,Moon} from 'lucide-react';
import s from '../../styles/infinite-canvas.module.css';
import CanvasNodeView from '../../components/canvas/CanvasNodeView';
import CanvasComposer from '../../components/canvas/CanvasComposer';
import CanvasAgentPanel,{type CanvasAgentHandle} from '../../components/canvas/CanvasAgentPanel';
import {parseCanvasDocument,deriveNode,connectNodes,removeCanvasNode,referencedNodes,agentTask,type CanvasNode as Card,type CanvasDocument as Doc} from '../../lib/canvas-document';
const example:Doc={version:1,cards:[{id:'brief',kind:'text',title:'01 / 创意简报',text:'把日常,拍成一场电影。\n\n一段关于城市清晨的视觉散文。低饱和色彩、自然光与细腻颗粒,让每一帧都有呼吸感。',x:70,y:140},{id:'reference',kind:'image',title:'02 / 视觉参考',text:'晨光 · 建筑 · 胶片质感\n上传你的参考图片',x:460,y:80},{id:'shot',kind:'prompt',title:'03 / 镜头构思',text:'清晨,阳光穿过窗边的植物,落在安静的房间里。缓慢推进镜头,35mm 胶片,温暖柔和的光线。',x:850,y:160}],links:[['brief','reference'],['reference','shot']]};
const initial:Doc={version:1,cards:[],links:[]};
const key='studio.infinite-canvas.v1';
export default function InfiniteCanvas(){
const [doc,setDoc]=useState<Doc>(initial),[selected,select]=useState('shot'),[zoom,setZoom]=useState(.8),[pan,setPan]=useState({x:0,y:0}),[tool,setTool]=useState('select'),[past,setPast]=useState<Doc[]>([]),[future,setFuture]=useState<Doc[]>([]),[status,setStatus]=useState('示例项目 · 尚未保存'),[ready,setReady]=useState(false),[email,setEmail]=useState(''),[link,setLink]=useState(''),[menu,setMenu]=useState<{x:number;y:number}|null>(null),[editing,setEditing]=useState(''),[refMenu,setRefMenu]=useState(''),[taskFeedback,setTaskFeedback]=useState(''),[composerTarget,setComposerTarget]=useState(''),[pickerRequest,setPickerRequest]=useState(0);
const file=useRef<HTMLInputElement>(null),json=useRef<HTMLInputElement>(null),viewport=useRef<HTMLDivElement>(null),drag=useRef<{id:string;x:number;y:number;ox:number;oy:number;before:Doc;resize?:boolean}|null>(null);
const pickerSequence=useRef(0),agent=useRef<CanvasAgentHandle>(null);
const active=doc.cards.find(c=>c.id===selected);
useEffect(()=>{try{const raw=localStorage.getItem(key);if(raw){const d=parseCanvasDocument(raw);setDoc(d);setStatus('已恢复本机草稿')}}catch{setStatus('草稿读取失败,可导入 JSON 恢复')}try{const v=JSON.parse(localStorage.getItem(key+'.view')||'null');if(v&&Number.isFinite(v.zoom)&&v.zoom>=.2&&v.zoom<=2&&Number.isFinite(v.x)&&Number.isFinite(v.y)){setZoom(v.zoom);setPan({x:v.x,y:v.y})}}catch{}setReady(true);fetch('/api/account/session').then(r=>r.ok?r.json():null).then(d=>setEmail(d?.user?.email||'')).catch(()=>{});},[]);
useEffect(()=>{if(!ready)return;try{localStorage.setItem(key,JSON.stringify(doc));setStatus('已保存到此浏览器')}catch{setStatus('本机空间不足,请导出项目备份')}},[doc,ready]);
useEffect(()=>{if(ready)try{localStorage.setItem(key+'.view',JSON.stringify({...pan,zoom}))}catch{}},[pan,zoom,ready]);
useEffect(()=>{const el=viewport.current;if(!el)return;const block=(e:WheelEvent)=>{if(!(e.target as HTMLElement).closest('textarea,input,select'))e.preventDefault()};el.addEventListener('wheel',block,{passive:false});return()=>el.removeEventListener('wheel',block)},[]);
useEffect(()=>{const keys=(e:KeyboardEvent)=>{if(e.key==='Escape'){setMenu(null);setRefMenu('');setEditing('');return}if((e.target as HTMLElement).closest('input,textarea,select,[contenteditable]'))return;if((e.ctrlKey||e.metaKey)&&e.key.toLowerCase()==='z'){e.preventDefault();if(e.shiftKey)redo();else undo()}if((e.ctrlKey||e.metaKey)&&e.key.toLowerCase()==='d'){e.preventDefault();duplicate()}};window.addEventListener('keydown',keys);return()=>window.removeEventListener('keydown',keys)});
function scaleAt(next:number,cx?:number,cy?:number){const el=viewport.current;if(!el)return;const r=el.getBoundingClientRect(),x=cx===undefined?r.width/2:cx-r.left,y=cy===undefined?r.height/2:cy-r.top;const z=Math.max(.2,Math.min(2,next));setPan({x:x-(x-pan.x)*z/zoom,y:y-(y-pan.y)*z/zoom});setZoom(z)}
function duplicate(){if(!active||doc.cards.length>=80)return;const id=Array.from(crypto.getRandomValues(new Uint8Array(16)),b=>b.toString(16).padStart(2,'0')).join('');commit({...doc,cards:[...doc.cards,{...active,id,title:active.title.slice(0,197)+' 副本',x:active.x+40,y:active.y+40}]});select(id)}
function commit(next:Doc){setPast(p=>[...p.slice(-29),doc]);setFuture([]);setDoc(next)}
function add(kind:Card['kind'],image?:string){if(doc.cards.length>=80){setStatus('最多 80 个节点');return}const id=Array.from(crypto.getRandomValues(new Uint8Array(16)),b=>b.toString(16).padStart(2,'0')).join('');const rect=viewport.current?.getBoundingClientRect();commit({...doc,cards:[...doc.cards,{id,kind,title:kind==='text'?'灵感笔记':kind==='image'?'图片节点':kind==='script'?'脚本生成器':'生成提示词',text:'',x:((menu?.x??(rect?.width||700)/2)-pan.x)/zoom-160,y:((menu?.y??(rect?.height||600)/2)-pan.y)/zoom-100,image}]});select(id)}
function change(field:'title'|'text'|'instruction',value:string,id=selected){setDoc(d=>({...d,cards:d.cards.map(c=>c.id===id?{...c,[field]:value}:c)}))}
function beginEdit(){setPast(p=>[...p.slice(-29),doc]);setFuture([])}
function undo(){if(!past.length)return;setFuture(f=>[doc,...f]);setDoc(past[past.length-1]);setPast(p=>p.slice(0,-1))}
function redo(){if(!future.length)return;setPast(p=>[...p,doc]);setDoc(future[0]);setFuture(f=>f.slice(1))}
function fit(){const el=viewport.current;if(!el||!doc.cards.length)return;const minX=Math.min(...doc.cards.map(c=>c.x)),minY=Math.min(...doc.cards.map(c=>c.y));const w=Math.max(...doc.cards.map(c=>c.x+(c.width||320)))-minX,h=Math.max(...doc.cards.map(c=>c.y+(c.height||350)))-minY;const z=Math.max(.2,Math.min(1,(el.clientWidth-80)/w,(el.clientHeight-100)/h));setZoom(z);setPan({x:(el.clientWidth-w*z)/2-minX*z,y:(el.clientHeight-h*z)/2-minY*z})}
function selectNode(id:string){if(id!==selected)setEditing('');select(id)}
function editNode(id:string){setPickerRequest(0);setComposerTarget('');select(id);setEditing(id);setMenu(null);setRefMenu('');setTaskFeedback('');setTool('select')}
function patchNode(id:string,patch:Partial<Card>){commit({...doc,cards:doc.cards.map(n=>n.id===id?{...n,...patch}:n)})}
function derive(source:string,kind:Card['kind'],operation:Card['operation']='generate'){
const parent=doc.cards.find(n=>n.id===source);if(!parent)return;
const id=Array.from(crypto.getRandomValues(new Uint8Array(16)),b=>b.toString(16).padStart(2,'0')).join('');
const title=operation==='upscale'?'图片高清':kind==='script'?'脚本生成器':kind==='image'?'图片节点':'文本节点';
try{commit(deriveNode(doc,source,{id,kind,operation,title,text:'',x:parent.x+(parent.width||320)+150,y:parent.y,width:360,height:300}));editNode(id);setComposerTarget(id)}catch(e){setStatus(e instanceof Error?e.message:'无法创建节点')}
}
function connect(from:string,to:string){try{const next=connectNodes(doc,from,to);if(next!==doc)commit(next);setTaskFeedback('')}catch(e){setTaskFeedback(e instanceof Error?e.message:'无法引用节点')}}
function exportTask(id:string){try{const task=agentTask(doc,id),url=URL.createObjectURL(new Blob([JSON.stringify(task,null,2)],{type:'application/json'}));const a=document.createElement('a');a.href=url;a.download=`canvas-task-${id}.json`;a.click();setTimeout(()=>URL.revokeObjectURL(url),1000);setTaskFeedback('Agent 任务已导出')}catch(e){setTaskFeedback(e instanceof Error?e.message:'无法导出任务')}}
function exportDoc(){const url=URL.createObjectURL(new Blob([JSON.stringify(doc,null,2)],{type:'application/json'}));const a=document.createElement('a');a.href=url;a.download='studio-canvas.json';a.click();setTimeout(()=>URL.revokeObjectURL(url),1000)}
return <main className={s.app}><Head><title>无限画布 · 啡跃 Endoleap</title><meta name="description" content="在无限画布上组织灵感、参考素材与镜头提示词"/></Head>
<header className={s.header}><a href="/workspace" className={s.back} aria-label="返回 Studio"><ArrowLeft size={18}/></a><a href="/workspace" className={s.brand}><Brand compact/></a><i/><span className={s.crumb}>工作空间 <ChevronRight size={13}/></span><strong>未命名的灵感宇宙</strong><span className={s.badge}>本机草稿</span><div className={s.spacer}/><CanvasAgentPanel ref={agent} document={doc} email={email} onLoad={next=>{setDoc(next);setPast([]);setFuture([]);setEditing('');select('')}} onFeedback={setTaskFeedback}/><ThemeToggle/><a href="/account">{email||'登录账户'}</a><button className={s.export} onClick={exportDoc}><Download size={15}/>导出项目</button></header>
<div className={s.workspace}><nav className={s.rail} aria-label="画布工具"><button title="选择工具" aria-label="选择工具" aria-pressed={tool==='select'} onClick={()=>setTool('select')}><MousePointer2/></button><button title="移动画布" aria-label="移动画布" aria-pressed={tool==='hand'} onClick={()=>setTool('hand')}><Hand/></button><hr/><button title="添加笔记" aria-label="添加笔记" onClick={()=>add('text')}><Type/></button><button title="上传图片" aria-label="上传图片" onClick={()=>file.current?.click()}><ImagePlus/></button><button title="添加提示词" aria-label="添加提示词" onClick={()=>add('prompt')}><Sparkles/></button><button title="导入项目" aria-label="导入项目" onClick={()=>json.current?.click()}><Upload/></button><div className={s.spacer}/><span>∞</span></nav>
<section className={s.canvasArea}>{!doc.cards.length&&<div className={s.startPanel}><span>YOUR NEXT IDEA STARTS HERE</span><h2>给想法,一个无限的空间。</h2><p>双击画布添加节点,或选择一个起点。</p><div><button onClick={()=>add('text')}><Type size={19}/>灵感笔记</button><button onClick={()=>file.current?.click()}><ImagePlus size={19}/>参考图片</button><button onClick={()=>add('prompt')}><Sparkles size={19}/>创作提示词</button></div><button onClick={()=>{commit(example);select('');setPan({x:0,y:0});setZoom(.7)}}>从示例开始 ↗</button><a href="/studio/api" target="_blank" rel="noreferrer">连接你的 coding agent · 接入文档 ↗</a></div>}<div className={s.canvasTitle}><span className={s.eyebrow}>YOUR IDEAS, CONNECTED</span><h1>无限画布<span> / Infinite canvas</span></h1><p>从一个灵感,连接所有可能。</p></div>
<div className={s.viewport} style={{backgroundPosition:`${pan.x}px ${pan.y}px`,backgroundSize:`${24*zoom*Math.max(1,2**Math.ceil(Math.log2(1/zoom)))}px ${24*zoom*Math.max(1,2**Math.ceil(Math.log2(1/zoom)))}px`}} ref={viewport} onDoubleClick={e=>{if(e.target===e.currentTarget){setMenu(null);add('text')}}} onContextMenu={e=>{e.preventDefault();const id=(e.target as HTMLElement).closest('[data-node-id]')?.getAttribute('data-node-id');select(id||'');setMenu({x:Math.min(e.clientX,window.innerWidth-250),y:Math.min(e.clientY,window.innerHeight-330)})}} onWheel={e=>{const target=e.target as HTMLElement;if(drag.current)return;if(target.closest('textarea,input,select'))return;if(e.ctrlKey||e.metaKey){scaleAt(zoom*Math.exp(-e.deltaY*.002),e.clientX,e.clientY)}else setPan(p=>({x:p.x-e.deltaX,y:p.y-e.deltaY}))}} onPointerDown={e=>{setMenu(null);setRefMenu('');if(e.target===e.currentTarget){setEditing('');select('')};if(e.target===e.currentTarget||(tool==='hand'&&!(e.target as HTMLElement).closest('input,textarea,button,select'))||e.button===1){e.currentTarget.setPointerCapture(e.pointerId);drag.current={id:'pan',x:e.clientX,y:e.clientY,ox:pan.x,oy:pan.y,before:doc}}}} onPointerMove={e=>{const d=drag.current;if(!d)return;if(d.id==='pan')setPan({x:d.ox+e.clientX-d.x,y:d.oy+e.clientY-d.y});else {if(!d.resize&&Math.hypot(e.clientX-d.x,e.clientY-d.y)<4)return;e.currentTarget.setPointerCapture(e.pointerId);setDoc({...d.before,cards:d.before.cards.map(c=>c.id===d.id?(d.resize?{...c,width:Math.max(260,Math.min(1200,d.ox+(e.clientX-d.x)/zoom)),height:Math.max(300,Math.min(1200,d.oy+(e.clientY-d.y)/zoom))}:{...c,x:d.ox+(e.clientX-d.x)/zoom,y:d.oy+(e.clientY-d.y)/zoom}):c)})}}} onPointerUp={e=>{const d=drag.current;if(d?.id==='pan'&&tool==='select'&&Math.hypot(e.clientX-d.x,e.clientY-d.y)<4){select('');setMenu({x:Math.min(e.clientX,window.innerWidth-250),y:Math.min(e.clientY,window.innerHeight-230)})}if(d&&d.id!=='pan'&&doc!==d.before){setPast(p=>[...p.slice(-29),d.before]);setFuture([])}drag.current=null}} onPointerCancel={()=>{if(drag.current?.id!=='pan'&&drag.current)setDoc(drag.current.before);drag.current=null}}>
<div className={s.world} style={{transform:`translate(${pan.x}px,${pan.y}px) scale(${zoom})`}}><svg className={s.wires} aria-hidden="true">{doc.links.map(([a,b])=>{const from=doc.cards.find(c=>c.id===a),to=doc.cards.find(c=>c.id===b);if(!from||!to)return null;return <path key={a+b} d={`M ${from.x+(from.width||320)} ${from.y+(from.height||350)/2} C ${from.x+(from.width||320)+70} ${from.y+(from.height||350)/2},${to.x-45} ${to.y+(to.height||350)/2},${to.x} ${to.y+(to.height||350)/2}`}/>})}</svg>
{doc.cards.map(c=><CanvasNodeView key={c.id} node={c} selected={selected===c.id} editing={editing===c.id&&composerTarget!==c.id} hand={tool==='hand'} onSelect={()=>selectNode(c.id)} onChooseReference={()=>{editNode(c.id);setComposerTarget(c.id);setPickerRequest(++pickerSequence.current)}} onEdit={()=>editNode(c.id)} onDrag={e=>{setMenu(null);if(c.id!==editing)setEditing('');drag.current={id:c.id,x:e.clientX,y:e.clientY,ox:c.x,oy:c.y,before:doc}}} onResize={e=>{viewport.current?.setPointerCapture(e.pointerId);select(c.id);drag.current={id:c.id,x:e.clientX,y:e.clientY,ox:c.width||320,oy:c.height||350,before:doc,resize:true}}} onChange={value=>change('text',value,c.id)} onRename={value=>change('title',value,c.id)} onStyle={style=>patchNode(c.id,{style})} onReference={e=>{select(c.id);setRefMenu(c.id);setMenu({x:Math.min(e.clientX,window.innerWidth-250),y:Math.min(e.clientY,window.innerHeight-230)})}} onMenu={e=>{select(c.id);setRefMenu('');setMenu({x:Math.min(e.clientX,window.innerWidth-250),y:Math.min(e.clientY,window.innerHeight-330)})}} onUpload={()=>{select(c.id);file.current?.click()}} onUpscale={()=>derive(c.id,'image','upscale')} onExpand={()=>patchNode(c.id,{width:Math.min(1200,(c.width||320)*1.5),height:Math.min(1200,(c.height||350)*1.3)})} onBeginEdit={beginEdit}/>)}
</div></div><div className={s.canvasBottom}><span role="status">● {status}</span><div className={s.controls}><button aria-label="撤销" disabled={!past.length} onClick={undo}><Undo2 size={16}/></button><button aria-label="重做" disabled={!future.length} onClick={redo}><Redo2 size={16}/></button><i/><button aria-label="缩小" onClick={()=>scaleAt(zoom-.1)}><Minus size={16}/></button><span>{Math.round(zoom*100)}%</span><button aria-label="放大" onClick={()=>scaleAt(zoom+.1)}><Plus size={16}/></button><button aria-label="适应画布" onClick={fit}><Maximize size={16}/></button></div></div></section>
{menu&&<div className={s.contextMenu} role="dialog" aria-label="画布操作" style={{left:Math.max(8,menu.x),top:Math.max(8,menu.y)}}><div className={s.menuTitle}>{refMenu?'引用该节点生成':active?active.title:'添加到画布'}<button aria-label="关闭画布操作" onClick={()=>setMenu(null)}>×</button></div>{refMenu?<>{(['text','image','script'] as const).map(kind=><button key={kind} onClick={()=>derive(refMenu,kind,kind==='script'?'script':'generate')}>{kind==='text'?'文本':kind==='image'?'图片':'脚本'}</button>)}</>:active?<><button onClick={()=>{editNode(active.id);setComposerTarget(active.id)}}>创作要求</button><button onClick={duplicate}>复制节点</button><button onClick={()=>{commit(removeCanvasNode(doc,selected));select('');setEditing('');setMenu(null)}}>删除节点</button><label>连接到<select value={link} onChange={e=>setLink(e.target.value)}><option value="">选择目标节点</option>{doc.cards.filter(c=>c.id!==selected).map(c=><option key={c.id} value={c.id}>{c.title}</option>)}</select></label><button disabled={!link} onClick={()=>{connect(selected,link);setLink('')}}>连接节点</button>{doc.links.filter(l=>l.includes(selected)).map(([a,b])=><button key={a+b} onClick={()=>commit({...doc,links:doc.links.filter(l=>l[0]!==a||l[1]!==b)})}>断开:{doc.cards.find(c=>c.id===(a===selected?b:a))?.title}</button>)}</>:<><button onClick={()=>{add('text');setMenu(null)}}>添加笔记</button><button onClick={()=>{add('image');setMenu(null)}}>添加图片</button><button onClick={()=>{add('script');setMenu(null)}}>添加脚本</button></>}</div>}</div>
{active&&editing===active.id&&(active.kind!=='text'||composerTarget===active.id)&&<CanvasComposer pickerRequest={pickerRequest} key={active.id} node={active} nodes={doc.cards} references={referencedNodes(doc,active.id)} onChange={value=>change('instruction',value,active.id)} onRename={value=>change('title',value,active.id)} onBeginEdit={beginEdit} onUpload={()=>file.current?.click()} onAddReference={id=>connect(id,active.id)} onRemoveReference={id=>commit({...doc,links:doc.links.filter(([from,to])=>from!==id||to!==active.id)})} onQueue={()=>void agent.current?.queue(active.id)} onExport={()=>exportTask(active.id)} onClose={()=>setEditing('')} feedback={taskFeedback}/>}
<input hidden ref={file} type="file" accept="image/png,image/jpeg,image/webp" onChange={async e=>{const f=e.target.files?.[0];e.target.value='';if(!f)return;if(!['image/png','image/jpeg','image/webp'].includes(f.type)||f.size>2000000){setStatus('请选择 2MB 以内的 PNG、JPEG 或 WebP');return}const reader=new FileReader();reader.onload=()=>{const image=String(reader.result);if(active?.kind==='image')commit({...doc,cards:doc.cards.map(c=>c.id===selected?{...c,image}:c)});else add('image',image)};reader.readAsDataURL(f)}}/>
<input hidden ref={json} type="file" accept="application/json,.json" onChange={async e=>{const f=e.target.files?.[0];e.target.value='';if(!f)return;try{if(f.size>15000000)throw Error();const d=parseCanvasDocument(await f.text());commit(d);select(d.cards[0]?.id||'');setPan({x:0,y:0});setStatus('项目已导入')}catch{setStatus('无法导入:项目格式错误或文件过大')}}}/>
</main>;
}
+7 -6
View File
@@ -1,19 +1,20 @@
import type {GetServerSideProps} from 'next';
import {LayoutWorkbench,type LayoutPageProps} from '../../components/LayoutWorkbench';
import {NativeResult} from '../../components/NativeResult';
import type {Layout} from '../../lib/layout-parameters';
import fixedLayouts from '../../content/templates/layouts.json';
import standardLayout from '../../content/standard-layout.json';
const layouts=[...fixedLayouts,standardLayout];
import {nativeLayout} from '../../lib/native-layout';
import {nativeLayout,designUrl} from '../../lib/native-layout';
import {readDesign} from '../../lib/design-server';
export default function LayoutPage(props:LayoutPageProps){return <LayoutWorkbench key={props.design?.id||props.layout.id} {...props}/>}
export const getServerSideProps:GetServerSideProps<LayoutPageProps>=async({params,query,req,res})=>{
export default function LayoutPage({layout}:{layout:Layout}){return <NativeResult layout={layout} design={null}/>}
export const getServerSideProps:GetServerSideProps<{layout:Layout}>=async({params,query,req,res})=>{
res.setHeader('Cache-Control','private, no-store');
const layout=layouts.find(l=>l.id===params?.layoutId);
if(!layout)return {notFound:true};
if(query.id!==undefined){
const design=await readDesign(query.id,req);
if(!design||design.layoutId!==layout.id)return {notFound:true};
return {props:{layout:nativeLayout(layout),design}};
return {redirect:{destination:designUrl(design.layoutId,design.id).slice('/studio'.length),permanent:false}};
}
return {props:{layout:nativeLayout(layout),design:null}};
return {props:{layout:nativeLayout(layout)}};
};
+21
View File
@@ -0,0 +1,21 @@
import type {GetServerSideProps} from 'next';
import {NativeSeries} from '../components/NativeSeries';
import {NativeResult} from '../components/NativeResult';
import fixedLayouts from '../content/templates/layouts.json';
import standardLayout from '../content/standard-layout.json';
import {readDesign} from '../lib/design-server';
import {nativeLayout,type Design} from '../lib/native-layout';
import type {Layout} from '../lib/layout-parameters';
type Props={layout:Layout;design:Design;page:number|null;quality:boolean};
export default function TemplateLayout(props:Props){return props.design.document?<NativeSeries design={props.design} page={props.page} quality={props.quality}/>:<NativeResult {...props}/>}
export const getServerSideProps:GetServerSideProps<Props>=async({query,req,res})=>{
res.setHeader('Cache-Control','private, no-store');
const design=await readDesign(query.id,req);
if(!design)return {notFound:true};
const layout=[standardLayout,...fixedLayouts].find(l=>l.id===design.layoutId);
if(!layout)return {notFound:true};
const page=query.page===undefined?null:Number(query.page);
if(page!==null&&(!Number.isInteger(page)||page<1||page>(design.document?.pages.length||1)))return {notFound:true};
return {props:{layout:nativeLayout(layout),design,page,quality:query.export==='1'}};
};
+24
View File
@@ -0,0 +1,24 @@
/* Shared chrome uses the canonical package tokens; artwork keeps its own palette. */
html,body{background:var(--background);color:var(--foreground);color-scheme:inherit}
body{margin:0}
.endoleap-brand{display:inline-flex;align-items:center;gap:9px;white-space:nowrap;line-height:1.15;vertical-align:middle;letter-spacing:normal;font-weight:650}
.endoleap-brand .endoleap-mark{width:32px;height:32px;object-fit:contain;flex-shrink:0}
.endoleap-mark-dark{display:none!important}
[data-theme$="-dark"] .endoleap-mark-light{display:none!important}
[data-theme$="-dark"] .endoleap-mark-dark{display:block!important}
.endoleap-wordmark{display:flex!important;flex-direction:column;font-size:17px!important;color:var(--foreground-strong)!important;letter-spacing:.02em!important;margin:0!important;gap:3px}
.endoleap-wordmark small{display:block;font-size:9px;font-weight:500;letter-spacing:.08em;color:var(--muted-foreground)}
button.endoleap-theme-toggle{display:inline-flex;align-items:center;justify-content:center;gap:5px;border:1px solid var(--border);background:var(--surface);color:var(--foreground);border-radius:8px;padding:7px 9px;font-size:12px;line-height:1.2;flex-shrink:0;cursor:pointer}
button.endoleap-theme-toggle:hover{background:var(--row-hover)}
:where(a,button,input,textarea,select):focus-visible{outline:2px solid var(--ring);outline-offset:3px}
::selection{background:var(--selection);color:var(--foreground)}
@media(max-width:600px){.endoleap-theme-toggle span{display:none}.endoleap-brand{gap:5px}.endoleap-brand .endoleap-mark{width:26px;height:26px}.endoleap-wordmark{font-size:14px!important}}
button:disabled{cursor:default}
input,textarea,select{accent-color:var(--primary)}
.is-editing.native-layout-page{background:var(--background);color:var(--foreground)}
.studio-mark,.page-tools,.workbench-top,.workbench-layers,.workbench-properties,.element-popover,.page-edit-info{color:var(--foreground)}
.workbench-top button.primary,.page-tools .page-save,.trial-actions .trial-primary{background:var(--primary);color:var(--primary-foreground);border-color:var(--primary)}
.workbench-top button.primary:hover,.page-tools .page-save:hover{background:var(--primary-hover)}
.native-element.is-selected,.proof-active .proof-select{outline-color:var(--selection-border)}
@@ -0,0 +1,3 @@
.root{position:static}.panel{position:absolute;left:0;top:calc(100% + 12px);width:min(390px,calc(100vw - 32px));max-height:75dvh;overflow:auto;padding:20px;border:1px solid var(--border);border-radius:16px;background:var(--surface);color:var(--foreground);box-shadow:0 15px 70px var(--shadow);z-index:12;display:grid;gap:14px;font-size:12px}.panel header,.actions,.conflict{display:flex;align-items:center;justify-content:space-between;gap:8px;flex-wrap:wrap}.panel button{font-size:11px!important;padding:7px 10px!important;background:var(--surface)!important;color:inherit!important;border:1px solid var(--border)!important}.panel button:disabled{opacity:.45}.panel textarea{width:100%;height:150px;resize:vertical;background:var(--surface);color:var(--foreground);border:1px solid var(--input);border-radius:8px;padding:10px;font:10px/1.7 monospace}.panel p{margin:0;color:var(--muted-foreground);line-height:1.7}.panel small{color:var(--muted-foreground);font-size:10px}.panel label{display:grid;gap:8px}.panel select{width:100%;background:var(--surface);color:inherit;padding:8px;border-radius:6px}.panel ul{padding:0;margin:0;list-style:none;display:grid;gap:12px}.panel li{display:grid;gap:5px;border-top:1px solid var(--border);padding-top:10px}.panel li button{justify-self:start}.panel a{color:inherit!important;text-decoration:underline!important}
:global(header):has(> .root .panel){z-index:12}
@@ -0,0 +1,4 @@
.composer{position:fixed;z-index:6;left:50%;bottom:92px;transform:translateX(-50%);width:min(900px,calc(100vw - 40px));height:280px;border:1px solid var(--border);border-radius:22px;padding:20px 24px;background:var(--surface);color:var(--foreground);display:flex;flex-direction:column;box-shadow:0 15px 70px var(--shadow)}.composer[data-expanded=true]{height:min(65dvh,650px)}.composer header{display:flex;align-items:center;justify-content:space-between;gap:12px;font-size:12px}.composer header>span,.composer header>div{display:flex;align-items:center;gap:8px}.composer header button,.composer footer button{background:var(--surface);border-color:var(--border);font-size:11px;padding:7px 10px}.composer textarea{display:block;flex:1;min-height:50px;width:100%;resize:none;padding:17px 0;border:0;background:transparent;color:inherit;font:inherit;line-height:1.8;outline:none}.composer footer{display:flex;align-items:center;justify-content:space-between;gap:12px;font-size:10px;color:var(--muted-foreground)}.references{display:flex;gap:10px;padding-top:15px;flex-shrink:0;overflow:auto}.reference{width:70px;height:65px;border:1px solid var(--border);border-radius:8px;position:relative;display:grid;place-items:center;flex-shrink:0;overflow:hidden;background:var(--surface)}.reference img{width:100%;height:100%;object-fit:cover}.reference>span{position:absolute;top:3px;left:4px;background:var(--surface);color:var(--foreground);border-radius:50%;padding:1px 4px;font-size:9px}.reference small{position:absolute;bottom:0;width:100%;text-align:center;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;background:var(--surface);font-size:8px;padding:3px}.reference button{position:absolute;top:1px;right:1px;padding:2px!important;background:var(--surface)!important;border:0!important}.picker{position:absolute;bottom:calc(100% + 10px);left:0;background:var(--surface);border:1px solid var(--border);border-radius:14px;padding:10px;max-height:200px;overflow:auto;display:grid;gap:5px;min-width:220px}.picker button{justify-content:flex-start;background:transparent;border:0}.picker>span{font-size:11px;padding:12px}@media(max-width:600px){.composer{width:calc(100vw - 20px);padding:15px;bottom:80px;height:260px;border-radius:16px}.composer header>span{max-width:95px;overflow:hidden;white-space:nowrap;font-size:10px}.composer header button{padding:5px;font-size:10px}.composer header{gap:4px}.composer header>div{gap:4px}}
.composer header input{width:200px;max-width:25vw;min-width:0;border:0;background:transparent;color:inherit;font:inherit;outline:none}
.composer footer>div{display:flex;gap:8px;flex-shrink:0}@media(max-width:600px){.composer footer>div{gap:4px}.composer footer button{font-size:10px;padding:6px}.composer footer>span{min-width:0}}
@@ -0,0 +1,3 @@
.node{position:absolute;background:var(--surface);border:1px solid var(--border);border-radius:13px;color:var(--foreground);pointer-events:auto;touch-action:none;cursor:grab;outline:none}.node[data-selected=true]{border-color:var(--selection-border)}.node[data-editing=true]{border:2px solid var(--border);cursor:default}.title{position:absolute;top:-31px;left:0;right:0;display:flex;gap:7px;align-items:center;height:26px;color:var(--muted-foreground);font-size:12px;white-space:nowrap}.title>span{overflow:hidden;text-overflow:ellipsis}.title>button{margin-left:auto;padding:3px!important;background:transparent!important;border:0!important}.content,.image{height:100%;overflow:hidden;border-radius:12px}.content{padding:25px;white-space:pre-wrap;overflow:auto;scrollbar-width:thin}.content p{margin:0;line-height:1.7;pointer-events:none}.image{position:relative;display:grid;place-items:center}.image img{width:100%;height:100%;object-fit:contain;pointer-events:none;user-select:none;-webkit-user-drag:none}.empty{height:100%;width:100%;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:22px;color:var(--muted-foreground)}.empty button{background:transparent!important;border:0!important;color:var(--muted-foreground)!important;font-size:12px!important}.node>textarea{width:100%;height:100%;border:0;border-radius:12px;background:transparent;color:inherit;outline:none;resize:none;padding:25px;line-height:1.7;touch-action:auto}.format{position:absolute;bottom:calc(100% + 48px);left:50%;transform:translateX(-50%);display:flex;align-items:center;gap:4px;background:var(--surface);border:1px solid var(--border);border-radius:12px;padding:6px;white-space:nowrap;z-index:3}.format input{width:24px;height:24px;padding:0;border:0;background:transparent}.format select{max-width:100px;background:transparent;color:var(--foreground);border:0;padding:6px}.format button{padding:5px!important;border:0!important;background:transparent!important}.format button[aria-pressed=true]{background:var(--selection)!important}.reference{position:absolute!important;right:-38px;top:calc(50% - 14px);width:28px;height:28px;border-radius:50%!important;border:1px solid var(--border)!important;padding:2px!important;background:var(--surface)!important;color:var(--foreground)!important}.resize{position:absolute!important;right:3px;bottom:3px;padding:3px!important;border:0!important;background:transparent!important;color:var(--muted-foreground)!important;cursor:nwse-resize!important}.upscale{position:absolute;bottom:10px;left:10px;font-size:10px!important;background:var(--surface)!important}.node:focus-visible{outline:2px solid var(--ring);outline-offset:3px}
.title>input{min-width:0;width:100%;border:0!important;background:transparent!important;padding:0!important;color:inherit;font:inherit;outline:none}.title>button{flex-shrink:0}
.incoming{right:auto!important;left:-38px}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,65 @@
import test from 'node:test';
import {readFileSync} from 'node:fs';
import assert from 'node:assert/strict';
import {parseCanvasDocument,deriveNode,connectNodes,removeCanvasNode,referencedNodes,agentTask} from '../src/lib/canvas-document.ts';
const note={id:'brief',kind:'text',title:'创意简报',text:'清晨的城市',x:20,y:50};
const doc={version:1,cards:[note],links:[]};
test('existing v1 browser drafts remain readable and untrusted fields are discarded',()=>{
assert.deepEqual(parseCanvasDocument(JSON.stringify({...doc,token:'ignored',cards:[{...note,html:'ignored'}]})),doc);
for(const bad of [{...doc,cards:[note,note]},{...doc,links:[['brief','missing']]},{...doc,cards:[{...note,image:'https://example.com/x.png'}]},{...doc,cards:[{...note,style:{color:'url(javascript:bad)'}}]}])assert.throws(()=>parseCanvasDocument(bad));
});
test('derived image and script nodes preserve directed references across save and restore',()=>{
const picture={...note,id:'picture',kind:'image',title:'参考图',image:'data:image/png;base64,AAAA',operation:'generate'};
const next=deriveNode(doc,'brief',picture);
const restored=parseCanvasDocument(JSON.stringify(deriveNode(next,'picture',{...note,id:'script',kind:'script',operation:'script'})));
assert.equal(referencedNodes(restored,'picture')[0].id,'brief');assert.equal(referencedNodes(restored,'script')[0].id,'picture');
assert.throws(()=>connectNodes(restored,'script','brief'),/循环/);
assert.throws(()=>parseCanvasDocument({...restored,links:[...restored.links,['script','brief']]}),/循环/);
const removed=removeCanvasNode(restored,'picture');assert.deepEqual(removed.links,[]);assert.equal(removed.cards.length,2);
});
test('agent task captures current referenced content and validates upscale inputs',()=>{
const next=deriveNode(doc,'brief',{...note,id:'out',kind:'script',operation:'script'});
assert.equal(agentTask(next,'out').references[0].text,'清晨的城市');
assert.equal(agentTask(next,'out').result.targetId,'out');
assert.throws(()=>agentTask({...next,cards:next.cards.map(n=>n.id==='out'?{...n,operation:'upscale'}:n)},'out'),/引用一张图片/);
});
test('instructions stay distinct from generated content in agent requests',()=>{
const result={...note,id:'script',kind:'script',text:'已有脚本正文',instruction:'改写为六个分镜',operation:'script'};
const restored=parseCanvasDocument(JSON.stringify(deriveNode(doc,'brief',result)));
assert.equal(agentTask(restored,'script').instruction,'改写为六个分镜');
assert.equal(restored.cards.find(n=>n.id==='script').text,'已有脚本正文');
});
test('editing an image passes the current image to the external agent',()=>{
const image={...note,id:'image',kind:'image',instruction:'把背景改为雪夜',image:'data:image/png;base64,AAAA'};
assert.equal(agentTask({version:1,cards:[image],links:[]},'image').sourceImage,image.image);
});
const overlaps=(a,b)=>a.x<b.x+(b.width||320)&&a.x+(a.width||320)>b.x&&a.y<b.y+(b.height||350)&&a.y+(a.height||350)>b.y;
test('branching the example keeps the source image accessible after image and script derivation',()=>{
const scene={version:1,cards:[
{...note,x:70,y:140},
{...note,id:'reference',kind:'image',x:460,y:80},
{...note,id:'shot',kind:'prompt',x:850,y:160},
],links:[['brief','reference'],['reference','shot']]};
const image=deriveNode(scene,'reference',{...note,id:'hd',kind:'image',operation:'upscale',x:930,y:80,width:360,height:300});
const result=deriveNode(image,'brief',{...note,id:'script',kind:'script',operation:'script',x:540,y:140,width:360,height:300});
assert.deepEqual(result.cards.slice(0,3),scene.cards,'Existing content and positions stay unchanged');
for(const added of result.cards.slice(3))for(const other of result.cards.filter(n=>n.id!==added.id))assert.equal(overlaps(added,other),false,`${added.id} blocks ${other.id}`);
assert.equal(referencedNodes(result,'hd')[0].id,'reference');
assert.equal(referencedNodes(result,'script')[0].id,'brief');
assert.deepEqual(parseCanvasDocument(JSON.stringify(result)),result);
});
test('a free requested position is retained and large nodes near the coordinate limit can still branch',()=>{
const free=deriveNode(doc,'brief',{...note,id:'free',x:2000,y:50});
assert.deepEqual({x:free.cards[1].x,y:free.cards[1].y},{x:2000,y:50});
const edge={version:1,cards:[{...note,x:90000,y:99600,width:1200,height:1200}],links:[]};
const result=deriveNode(edge,'brief',{...note,id:'edge',x:90500,y:99700,width:360,height:300});
assert.equal(overlaps(result.cards[0],result.cards[1]),false);
assert.deepEqual(parseCanvasDocument(JSON.stringify(result)),result);
});
test('agent and browser parse the same canvas document contract',()=>{
assert.equal(readFileSync(new URL('../src/lib/canvas-document.ts',import.meta.url),'utf8'),readFileSync(new URL('../../waterfall-wiki/src/lib/canvas-document.ts',import.meta.url),'utf8'));
});
@@ -0,0 +1,8 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {parseGraphicDocument} from '../src/lib/graphic-document.ts';
const item={id:'one',kind:'text',name:'标题',text:'<script>文本</script>',x:20,y:20,w:300,h:100,fill:'#123456',size:32,hidden:false,locked:false};
const doc={name:'作品',background:'#ffffff',items:[item]};
test('graphic backup restores editable text and ignores unrelated properties',()=>{assert.deepEqual(parseGraphicDocument(JSON.stringify({...doc,extra:'ignored'})),doc)});
test('graphic backup rejects corrupted layers, duplicate IDs and unbounded geometry',()=>{for(const items of [[{...item,text:null}],[item,item],[{...item,x:Infinity}],[{...item,w:-1}],[{...item,fill:'url(https://example.com)'}]])assert.throws(()=>parseGraphicDocument(JSON.stringify({...doc,items})));});
test('graphic backup disallows active SVG and remote image loads',()=>{for(const src of ['https://example.com/image.png','javascript:alert(1)','data:image/svg+xml;base64,PHN2Zz4='])assert.throws(()=>parseGraphicDocument(JSON.stringify({...doc,items:[{...item,kind:'image',src}]})));});
+27
View File
@@ -0,0 +1,27 @@
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {embedSvgMedia} from '../src/lib/layout-images.ts';
const id='a05dc841-240a-5a3a-b01b-d589fc21cd36';
test('SVG export fetches each internal media once with the caller authentication',async()=>{
const original=globalThis.fetch;let calls=0;
try{
globalThis.fetch=async(url,options)=>{calls++;assert.equal(url,'http://waterfall-wiki:3000/api/media/'+id);assert.equal(options.headers.cookie,'session=fixture');assert.equal(options.redirect,'error');return new Response(Buffer.from('fixture'),{headers:{'content-type':'image/png'}});};
const svg=await embedSvgMedia({headers:{cookie:'session=fixture'}},`<svg><image href="/api/media/${id}"/><image href="/api/media/${id}"/></svg>`);
assert.equal(calls,1);assert.equal((svg.match(/data:image\/png;base64,Zml4dHVyZQ==/g)||[]).length,2);assert.ok(!svg.includes('/api/media/'));
}finally{globalThis.fetch=original;}
});
test('SVG export rejects unavailable or non-image objects',async()=>{
const original=globalThis.fetch;
try{for(const response of [new Response('missing',{status:404}),new Response('<svg/>',{headers:{'content-type':'image/svg+xml'}})]){
globalThis.fetch=async()=>response;await assert.rejects(()=>embedSvgMedia({headers:{}},`<image href="/api/media/${id}"/>`),/模板图片不可访问/);
}}finally{globalThis.fetch=original;}
});
test('SVG exports without managed images do not access storage',async()=>{
assert.equal(await embedSvgMedia({headers:{}},'<svg><text>Title</text></svg>'),'<svg><text>Title</text></svg>');
});
test('SVG export uses the configured isolated Wiki origin',async()=>{
const original=globalThis.fetch,previous=process.env.STUDIO_WIKI_ORIGIN;
try{process.env.STUDIO_WIKI_ORIGIN='http://127.0.0.1:19000';globalThis.fetch=async url=>{assert.equal(url,'http://127.0.0.1:19000/api/media/'+id);return new Response('fixture',{headers:{'content-type':'image/png'}})};assert.ok((await embedSvgMedia({headers:{}},`<image href="/api/media/${id}"/>`)).includes('data:image/png;base64,'));}
finally{globalThis.fetch=original;if(previous===undefined)delete process.env.STUDIO_WIKI_ORIGIN;else process.env.STUDIO_WIKI_ORIGIN=previous;}
});
+1 -1
View File
@@ -1,7 +1,7 @@
import {test} from 'node:test';import assert from 'node:assert/strict';import {readFileSync} from 'node:fs';
import {parameters} from '../src/lib/layout-parameters.ts';
const layouts=JSON.parse(readFileSync(new URL('../src/content/templates/layouts.json',import.meta.url)));
test('27 layouts preserve unique typed source slots and complete shapes',()=>{assert.equal(layouts.length,27);for(const [i,l] of layouts.entries()){assert.equal(l.id,'ref-'+String(i+1).padStart(2,'0'));assert.equal(new Set(l.elements.map(s=>s.id)).size,l.elements.length);for(const s of l.elements){assert.match(s.id,/^[TID]\d{2}$/);assert.equal(s.kind,({T:'text',I:'image',D:'decoration'})[s.id[0]]);assert.ok(s.box[0]>=0&&s.box[1]>=0&&s.box[0]+s.box[2]<=l.width&&s.box[1]+s.box[3]<=l.height);assert.match(s.raster,/^data:image\/png;base64,/);if(s.kind==='image')assert.match(s.shape,/^data:image\/png;base64,/)}assert.deepEqual(parameters(l,{}),{texts:{},images:{},decorations:{}})}});
test('27 layouts preserve unique typed source slots and complete shapes',()=>{assert.equal(layouts.length,27);for(const [i,l] of layouts.entries()){assert.equal(l.id,'ref-'+String(i+1).padStart(2,'0'));assert.equal(new Set(l.elements.map(s=>s.id)).size,l.elements.length);for(const s of l.elements){assert.match(s.id,/^[TID]\d{2}$/);assert.equal(s.kind,({T:'text',I:'image',D:'decoration'})[s.id[0]]);assert.ok(s.box[0]>=0&&s.box[1]>=0&&s.box[0]+s.box[2]<=l.width&&s.box[1]+s.box[3]<=l.height);assert.match(s.raster,/^\/api\/media\/[a-f0-9-]{36}$/);if(s.kind==='image')assert.match(s.shape,/^\/api\/media\/[a-f0-9-]{36}$/)}assert.deepEqual(parameters(l,{}),{texts:{},images:{},decorations:{}})}});
test('parameters reject mismatched slots, unsafe URLs and excessive text',()=>{const l=layouts[0];for(const input of [{texts:{I01:{value:'bad'}}},{images:{I01:{url:'https://evil.test'}}},{texts:{T01:{value:'x',scale:3}}},{texts:{T01:{value:'x'.repeat(501)}}},{paper:'url(x)'},{decorations:{D01:{hidden:'true'}}},null,[]])assert.throws(()=>parameters(l,input));const p=parameters(l,{texts:{T01:{value:'<script>not markup</script>',scale:1.2,align:'middle'}},images:{I01:{color:'#abcdef',fit:'contain'}},decorations:{D01:{hidden:true}}});assert.equal(p.texts.T01.value,'<script>not markup</script>');assert.equal(p.decorations.D01.hidden,true)});
test('workspace parameters round-trip identically through Studio and Wiki validation',async()=>{const {parameters:wiki}=await import('../../waterfall-wiki/src/lib/layout-parameters.ts');for(const l of layouts){const added={id:'user-example',kind:'text',role:'Added text',box:[3,4,20,12],direction:'horizontal',text:'Hello',reading:'custom'};const input={added:[added],layers:{T01:{x:2,y:4,width:15,height:10,rotation:25,opacity:.7,radius:2,z:10,hidden:false,locked:true},'user-example':{x:8,z:20}},texts:{T01:{value:'Updated',fontFamily:'serif',fontSize:4,fontWeight:700,lineHeight:1.6,letterSpacing:.2},'user-example':{value:'Hello'}},images:{I01:{fit:'cover',cropX:15,cropY:85,zoom:2}}};assert.deepEqual(parameters(l,input),wiki(l,input));assert.deepEqual(parameters(l,parameters(l,input)),parameters(l,input))}});
test('workspace validation rejects unbounded, mismatched and executable properties',()=>{const l=layouts[0];for(const input of [{layers:{unknown:{x:2}}},{layers:{T01:{rotation:Infinity}}},{layers:{T01:{width:0}}},{layers:{T01:{opacity:2}}},{layers:{T01:{locked:'yes'}}},{texts:{T01:{value:'x',fontFamily:'url(evil)'}}},{images:{I01:{cropX:101}}},{images:{I01:{zoom:0}}},{added:[{id:'T01'}]},{added:Array(101).fill({})},{layers:{T01:{onClick:'evil'}}}])assert.throws(()=>parameters(l,input))});
+2 -1
View File
@@ -12,7 +12,8 @@ test('geometric templates retain their distinct silhouettes without bitmap masks
assert.equal(imageShape(layouts[4],layouts[4].elements.find(s=>s.id==='I03')).borderRadius,'50%');
assert.ok(imageShape(layouts[21],layouts[21].elements.find(s=>s.id==='I02')).clipPath.startsWith('polygon('));
assert.notEqual(imageShape(layouts[21],layouts[21].elements.find(s=>s.id==='I02')).clipPath,imageShape(layouts[26],layouts[26].elements.find(s=>s.id==='I02')).clipPath);
assert.equal(designUrl('ref-01','abc'),'/studio/layouts/ref-01?id=abc');
assert.equal(designUrl('ref-01','abc'),'/studio/template-layout?id=abc');
assert.equal(designUrl('standard-01','abc'),'/studio/template-layout?id=abc');
});
test('element IDs work on HTTP origins without secure-context randomUUID',()=>{const saved=Object.getOwnPropertyDescriptor(globalThis,'crypto');let calls=0;Object.defineProperty(globalThis,'crypto',{configurable:true,value:{getRandomValues(array){for(let i=0;i<array.length;i++)array[i]=++calls;return array}}});try{const first=newElementId(),second=newElementId();assert.match(first,/^user-[a-f0-9]{32}$/);assert.notEqual(first,second)}finally{Object.defineProperty(globalThis,'crypto',saved)}});
+12
View File
@@ -0,0 +1,12 @@
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {readFileSync} from 'node:fs';
import {registerHooks} from 'node:module';
registerHooks({resolve(specifier,context,next){try{return next(specifier,context)}catch(e){if(e.code==='ERR_MODULE_NOT_FOUND'&&specifier.startsWith('.'))return next(specifier+'.ts',context);throw e}}});
const {series,pageParameters}=await import('../src/lib/series.ts');
const layout=JSON.parse(readFileSync(new URL('../src/content/standard-layout.json',import.meta.url)));
// Fixture copied into the CI image; no build is performed by these tests.
const input=JSON.parse(readFileSync(new URL('../fixtures/autumn-journal.json',import.meta.url)));
test('a complete three-page document preserves copy and resolves shared visual tokens',()=>{const d=series(input.document,[layout]);assert.equal(d.pages.length,3);for(const page of d.pages){const p=pageParameters(d,page,layout);assert.equal(p.paper,d.style.palette.paper);assert.equal(p.texts.T01.fontFamily,'serif');assert.equal(p.texts.T01.value,page.parameters.texts.T01.value);assert.equal(p.layers.T01.x,10)}const change=structuredClone(d);change.style.palette.ink='#112233';change.style.spacing.margin=8;for(const page of change.pages){const p=pageParameters(change,page,layout);assert.equal(p.texts.T02.color,'#112233');assert.equal(p.layers.T01.x,8)}change.pages[1].parameters.texts.T01.color='#ABCDEF';assert.equal(pageParameters(change,change.pages[1],layout).texts.T01.color,'#ABCDEF')});
test('invalid page order, stale template placeholders, untracked images and unsafe fields fail',()=>{for(const mutate of [d=>d.pages.reverse(),d=>d.pages[1].key=d.pages[0].key,d=>delete d.pages[0].parameters.texts.T02,d=>d.pages[0].parameters.images.I01.assetId='f76134e7-6e4f-4261-b90c-3c523dc16307',d=>d.style.palette.ink='url(evil)',d=>d.style.spacing.gap=10,d=>d.pages[0].bindings.I01='title',d=>d.pages[0].parameters.images.I01.url='https://evil.test']){const d=structuredClone(input.document);mutate(d);assert.throws(()=>series(d,[layout]))}});
test('persistence and renderer use byte-identical series contracts',()=>assert.equal(readFileSync(new URL('../src/lib/series.ts',import.meta.url),'utf8'),readFileSync(new URL('../../waterfall-wiki/src/lib/series.ts',import.meta.url),'utf8')));
+11
View File
@@ -9,3 +9,14 @@ PG_PORT=18132
S3_PORT=18133
PROMPT_IMAGE=prompt:local
RELEASE_SHA=development
# Use HTTPS and secure cookies in production; HTTP acceptance requires explicit false.
AUTH_COOKIE_SECURE=true
ACCOUNT_PUBLIC_ORIGIN=http://8.211.154.134:18131
# Enable only behind an ingress that overwrites x-forwarded-for.
ACCOUNT_TRUST_PROXY=false
SMTP_HOST=smtpdm.aliyun.com
SMTP_PORT=465
SMTP_USERNAME=noreply@notice.entroia.com
SMTP_FROM=noreply@notice.entroia.com
SMTP_PASSWORD=
+49
View File
@@ -0,0 +1,49 @@
# Email accounts
`/account` offers registration, password login, email verification/resend, and logout.
`/api/account/session` returns the current account; other actions use same-origin POST.
Passwords require at least 12 characters and at most 256 UTF-8 bytes. Only verified
accounts can log in. Verification links expire after 30 minutes and are consumed once.
Session tokens expire after seven days; logout deletes the current token immediately.
Only token SHA-256 digests are stored. Passwords use individually salted scrypt.
## Runtime configuration
The existing production deployment reads `/etc/prompt.env` on ces-control. Set its
ownership to root and mode to 0600. The operator should update SMTP_PASSWORD there
through a secure remote session, without putting it in command arguments, repository,
CI output, release.env, or chat. Existing compose deployment reads this file without
copying credentials into the release directory. Do not run `docker compose config`
without `--quiet` against the production environment because interpolation shows secrets.
Docker administrators can inspect container environment; access to Docker is privileged.
Set ACCOUNT_PUBLIC_ORIGIN to the exact browser origin and SMTP_HOST=smtpdm.aliyun.com,
SMTP_PORT=465, SMTP_USERNAME and SMTP_FROM=noreply@notice.entroia.com. TLS certificate
validation stays enabled. Missing SMTP configuration returns a generic service failure;
resend with the registration password can recover an account whose initial delivery failed.
Keep AUTH_CONFIG_FILE configured for administrator access. Missing administrator
configuration fails closed. AUTH_DISABLED_FOR_TESTS is false by default and is enabled
only by the disposable legacy integration stack; never enable it in production.
Email account cookies never satisfy administrator authorization. With the current HTTP
acceptance origin use AUTH_COOKIE_SECURE=false; use true after enabling HTTPS.
Limits persist in PostgreSQL: 10 logins and 3 registration/resend attempts per email per
15 minutes, plus 300 requests per 15 minutes globally. ACCOUNT_TRUST_PROXY defaults
false. Enable it only when a trusted ingress overwrites x-forwarded-for; this changes
the global cap to 60 per trusted client address. Do not enable it on the direct public port.
Limits count failures and successes. Registration/resend always give the same success
response for existing/unknown emails, and login uses a dummy KDF for unknown accounts.
The rate limit itself can reveal recent activity for a known email; it does not reveal
whether an account exists. Synchronous SMTP latency can still expose a timing side channel;
a durable asynchronous mail queue would be needed to fully decouple delivery timing.
Logout remains available when login/mail limits are exhausted. No password recovery or
account deletion is included.
## Verification
ces-control runs crypto/origin tests, PostgreSQL verification-token replay races,
expired/revoked sessions, concurrent limit consumption and unverified login rejection.
Production-auth exercises verify that account cookies cannot authorize legacy writes.
Actual SMTP delivery and visual acceptance at http://8.211.154.134:18131/account require
operator runtime configuration and the normal manually approved deployment.
+9 -7
View File
@@ -6,13 +6,13 @@ RUN apt-get update \
&& rm -rf /var/lib/apt/lists/*
FROM base AS dependencies
WORKDIR /repo/apps/waterfall-wiki
RUN corepack enable && corepack prepare pnpm@11.25.0 --activate
COPY apps/waterfall-wiki/pnpm-lock.yaml ./
COPY pnpm-workspace.yaml /tmp/monorepo-policy.yaml
RUN node -e "const fs=require('fs');const text=fs.readFileSync('/tmp/monorepo-policy.yaml','utf8');fs.writeFileSync('pnpm-workspace.yaml', text.slice(text.indexOf('allowBuilds:')));"
RUN pnpm fetch --frozen-lockfile --fetch-timeout=300000 --network-concurrency=8
COPY pnpm-workspace.yaml /repo/pnpm-workspace.yaml
COPY packages/brand/ /repo/packages/brand/
COPY apps/waterfall-wiki/package.json ./
RUN pnpm install --offline --frozen-lockfile
RUN pnpm --filter @prompt/waterfall-wiki install --frozen-lockfile --fetch-timeout=300000 --network-concurrency=8
FROM dependencies AS build
# .dockerignore excludes docs and operational notes without extra legacy COPY snapshots.
@@ -24,14 +24,16 @@ USER node
CMD ["npm", "run", "test:integration"]
FROM base AS runtime
WORKDIR /app/apps/waterfall-wiki
ENV NODE_ENV=production HOSTNAME=0.0.0.0 PORT=3000 \
FFMPEG_PATH=/usr/bin/ffmpeg FFPROBE_PATH=/usr/bin/ffprobe
COPY --from=build --chown=node:node /app/.next/standalone/ ./
COPY --from=build --chown=node:node /app/dist/worker.cjs /app/dist/bootstrap.cjs /app/dist/publish-learning-series.cjs /app/dist/auth-cli.cjs ./
COPY --from=build --chown=node:node /app/db/ ./db/
COPY --from=build --chown=node:node /repo/apps/waterfall-wiki/.next/standalone/ /app/
COPY --from=build --chown=node:node /repo/apps/waterfall-wiki/dist/worker.cjs /repo/apps/waterfall-wiki/dist/bootstrap.cjs /repo/apps/waterfall-wiki/dist/publish-learning-series.cjs /repo/apps/waterfall-wiki/dist/auth-cli.cjs ./
COPY --from=build --chown=node:node /repo/apps/waterfall-wiki/db/ ./db/
COPY --chown=node:node apps/waterfall-wiki/scripts/container-health.mjs ./
COPY --chown=node:node apps/waterfall-wiki/scripts/import-character-packs.mjs apps/waterfall-wiki/scripts/verify-character-pack.mjs ./scripts/
COPY --chown=node:node apps/waterfall-wiki/seed/characters/ ./seed/characters/
COPY --chown=node:node apps/waterfall-wiki/src/content/managed-media.json ./src/content/managed-media.json
USER node
EXPOSE 3000
CMD ["node", "server.js"]
@@ -0,0 +1,5 @@
BEGIN;
-- The cover continues to use the existing design/post identity and archival lifecycle.
ALTER TABLE studio_designs ADD COLUMN IF NOT EXISTS document jsonb CHECK(document IS NULL OR jsonb_typeof(document)='object');
INSERT INTO schema_migrations(version) VALUES('013_studio_series') ON CONFLICT DO NOTHING;
COMMIT;
@@ -0,0 +1,22 @@
BEGIN;
CREATE TABLE account_users (
id uuid PRIMARY KEY,
email text NOT NULL UNIQUE,
password_hash text NOT NULL,
verified_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE account_tokens (
digest text PRIMARY KEY,
user_id uuid NOT NULL REFERENCES account_users(id) ON DELETE CASCADE,
kind text NOT NULL CHECK (kind IN ('verify','session')),
expires_at timestamptz NOT NULL
);
CREATE INDEX account_tokens_expiry ON account_tokens(expires_at);
CREATE TABLE account_limits (
key text PRIMARY KEY,
attempts integer NOT NULL,
window_start timestamptz NOT NULL
);
INSERT INTO schema_migrations(version) VALUES ('014_email_accounts');
COMMIT;
@@ -0,0 +1,7 @@
BEGIN;
CREATE TABLE guidance_documents(id text PRIMARY KEY CHECK(id ~ '^user-[a-z0-9-]{1,74}$'), owner_id uuid NOT NULL REFERENCES account_users(id), latest integer NOT NULL DEFAULT 0, deleted_at timestamptz, CHECK(id LIKE 'user-' || owner_id::text || '-%'));
CREATE TABLE guidance_revisions(document_id text NOT NULL REFERENCES guidance_documents(id), revision integer NOT NULL CHECK(revision>0), content jsonb NOT NULL, created_at timestamptz NOT NULL DEFAULT now(), PRIMARY KEY(document_id,revision));
CREATE FUNCTION guidance_immutable() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'Published revisions are immutable'; END $$;
CREATE TRIGGER guidance_immutable BEFORE UPDATE OR DELETE ON guidance_revisions FOR EACH ROW EXECUTE FUNCTION guidance_immutable();
INSERT INTO schema_migrations(version) VALUES('015_guidance_documents');
COMMIT;
@@ -0,0 +1,23 @@
BEGIN;
CREATE TABLE canvas_documents (
id uuid PRIMARY KEY, owner_id uuid NOT NULL REFERENCES account_users(id) ON DELETE CASCADE,
name text NOT NULL DEFAULT '未命名画布' CHECK(length(name) BETWEEN 1 AND 200),
revision integer NOT NULL DEFAULT 1 CHECK(revision>0), document_key text NOT NULL,
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX canvas_owner_updated ON canvas_documents(owner_id,updated_at DESC);
CREATE TABLE canvas_agent_links (
digest text PRIMARY KEY, canvas_id uuid NOT NULL REFERENCES canvas_documents(id) ON DELETE CASCADE,
expires_at timestamptz NOT NULL, last_seen_at timestamptz
);
CREATE INDEX canvas_link_expiry ON canvas_agent_links(canvas_id,expires_at);
CREATE TABLE canvas_tasks (
id uuid PRIMARY KEY, canvas_id uuid NOT NULL REFERENCES canvas_documents(id) ON DELETE CASCADE,
target_id text NOT NULL, source_revision integer NOT NULL,
status text NOT NULL DEFAULT 'queued' CHECK(status IN ('queued','running','succeeded','failed')),
request_key text NOT NULL, result_digest text, error text,
created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX canvas_target_active_task ON canvas_tasks(canvas_id,target_id) WHERE status IN ('queued','running');
INSERT INTO schema_migrations(version) VALUES ('016_canvas_workspace');
COMMIT;
@@ -0,0 +1,5 @@
BEGIN;
ALTER TABLE account_tokens DROP CONSTRAINT account_tokens_kind_check;
ALTER TABLE account_tokens ADD CONSTRAINT account_tokens_kind_check CHECK (kind IN ('verify','session','reset'));
INSERT INTO schema_migrations(version) VALUES ('017_password_reset');
COMMIT;
@@ -0,0 +1,163 @@
{
"schemaVersion": 2,
"name": "把秋天装进一页 · 银杏观察手记",
"document": {
"version": 1,
"brief": {
"topic": "银杏与秋日观察手记",
"audience": "喜欢日常摄影与视觉手帐的小红书读者",
"intent": "以封面、观察方法和行动结尾组成三页可发布图文",
"designIntent": "奶油纸底、森林墨色、银杏金点缀;宋体大标题与克制的无衬线正文;所有页面保留相同页边距和节奏",
"imageStrategy": "生成一幅原创银杏静物插画作为系列母图;每页复用素材,保留裁切配置,不把正文烘焙进图片"
},
"style": {
"version": "autumn-journal/1",
"palette": {
"paper": "#F3EFE5",
"ink": "#253C32",
"muted": "#718074",
"accent": "#C3A050"
},
"typography": {
"title": {
"fontFamily": "serif",
"fontSize": 8.4,
"fontWeight": 600,
"lineHeight": 1.15,
"letterSpacing": 0
},
"body": {
"fontFamily": "sans",
"fontSize": 3.1,
"fontWeight": 400,
"lineHeight": 1.65,
"letterSpacing": 0
},
"caption": {
"fontFamily": "sans",
"fontSize": 1.7,
"fontWeight": 500,
"lineHeight": 1.2,
"letterSpacing": 0
}
},
"spacing": {
"margin": 10,
"gap": 4
}
},
"sources": [],
"pages": [
{
"key": "cover",
"role": "cover",
"layoutId": "standard-01",
"intent": "用银杏静物与两行大标题建立主题",
"bindings": {
"T01": "title",
"T02": "body",
"T03": "caption"
},
"parameters": {
"texts": {
"T01": {
"value": "把秋天\n装进一页"
},
"T02": {
"value": "不必走很远。\n一片银杏,就是今天的风景。"
},
"T03": {
"value": "SEASONAL NOTES / 01"
}
},
"images": {
"I01": {
"fit": "cover",
"cropX": 50,
"cropY": 50,
"zoom": 1
}
},
"decorations": {}
}
},
{
"key": "observe",
"role": "body",
"layoutId": "standard-01",
"intent": "用三个观察动作提供可实践的方法",
"bindings": {
"T01": "title",
"T02": "body",
"T03": "caption"
},
"parameters": {
"texts": {
"T01": {
"value": "先看见\n再记录"
},
"T02": {
"value": "看叶脉,看光落下的方向。\n留住一片金黄,也留出一半空白。\n把感受写成短句,画面就有了呼吸。"
},
"T03": {
"value": "FIELD NOTES / 02"
}
},
"images": {
"I01": {
"fit": "cover",
"cropX": 65,
"cropY": 40,
"zoom": 1.25
}
},
"decorations": {}
}
},
{
"key": "ending",
"role": "ending",
"layoutId": "standard-01",
"intent": "以轻量行动和提问收尾",
"bindings": {
"T01": "title",
"T02": "body",
"T03": "caption"
},
"parameters": {
"texts": {
"T01": {
"value": "留一点白\n给下一次",
"align": "middle"
},
"T02": {
"value": "今天,捡起一片叶子。\n用三个词记录它的颜色、纹理与心情。\n你的秋天,会从哪一页开始?",
"align": "middle"
},
"T03": {
"value": "YOUR TURN / 03"
}
},
"images": {
"I01": {
"fit": "cover",
"cropX": 65,
"cropY": 50,
"zoom": 1
}
},
"decorations": {},
"layers": {
"I01": {
"x": 40,
"y": 55,
"width": 40,
"height": 40,
"radius": 20
}
}
}
}
]
}
}
+13 -2
View File
@@ -1,17 +1,28 @@
import { resolve } from "node:path";
import type { NextConfig } from "next";
import managedMedia from "./src/content/managed-media.json";
const nextConfig: NextConfig = {
turbopack: { root: resolve(process.cwd(), "../..") },
serverExternalPackages: ["playwright-core"],
async redirects(){return [
{source:'/studio/graphic',destination:'/graphic',permanent:true},
{source:'/studio/infinite_canvas',destination:'/infinite_canvas',permanent:true},
{source:'/studio',destination:'/workspace',permanent:false},
]},
async rewrites(){return [
...Object.entries(managedMedia).filter(([path])=>path.startsWith("apps/waterfall-wiki/public/")).map(([path,asset])=>({source:"/"+path.slice("apps/waterfall-wiki/public/".length),destination:"/api/media/"+asset.id})),
{source:'/graphic',destination:'http://prompt-studio:3000/studio/graphic'},
{source:'/infinite_canvas',destination:'http://prompt-studio:3000/studio/infinite_canvas'},
{source:"/studio/:path*",destination:"http://prompt-studio:3000/studio/:path*"},
{source:"/index.md",destination:"/api/guidance"},
{source:"/index.md",destination:"/api/agent-index"},
{source:"/skills/index.md",destination:"/api/guidance/skills"},
{source:"/skills/:path+/index.md",destination:"/api/guidance/skills/:path+"},
{source:"/agent",destination:"/api/agent-index"},
{source:"/agent/index.md",destination:"/api/agent-index"},
{source:"/skills/:skill/:doc.md",destination:"/api/skill-docs/:skill/:doc"},
]},
output: "standalone", outputFileTracingRoot: process.cwd(),
output: "standalone", outputFileTracingRoot: resolve(process.cwd(), "../.."),
outputFileTracingIncludes: {"/*":["./node_modules/playwright-core/**/*"]},
poweredByHeader: false,
};
+9 -5
View File
@@ -6,16 +6,17 @@
"node": ">=24 <25"
},
"scripts": {
"dev": "next dev",
"build": "next build && esbuild src/worker.ts --bundle --platform=node --format=cjs --external:pg-native --external:playwright-core --outfile=dist/worker.cjs && esbuild scripts/bootstrap.ts --bundle --platform=node --format=cjs --external:pg-native --external:playwright-core --outfile=dist/bootstrap.cjs && esbuild scripts/publish-learning-series.ts --bundle --platform=node --format=cjs --external:pg-native --external:playwright-core --outfile=dist/publish-learning-series.cjs && esbuild scripts/auth-cli.ts --bundle --platform=node --format=cjs --external:pg-native --outfile=dist/auth-cli.cjs && node scripts/prepare-standalone.mjs",
"dev": "pnpm brand:prepare && next dev",
"build": "pnpm brand:prepare && next build && esbuild src/worker.ts --bundle --platform=node --format=cjs --external:pg-native --external:playwright-core --outfile=dist/worker.cjs && esbuild scripts/bootstrap.ts --bundle --platform=node --format=cjs --external:pg-native --external:playwright-core --outfile=dist/bootstrap.cjs && esbuild scripts/publish-learning-series.ts --bundle --platform=node --format=cjs --external:pg-native --external:playwright-core --outfile=dist/publish-learning-series.cjs && esbuild scripts/auth-cli.ts --bundle --platform=node --format=cjs --external:pg-native --outfile=dist/auth-cli.cjs && node scripts/prepare-standalone.mjs",
"start": "next start",
"typecheck": "next typegen && tsc --noEmit",
"test": "tsx --test tests/api-documentation.test.ts tests/health.test.ts tests/model.test.ts tests/learning-series.test.ts tests/skill-docs.test.ts tests/wiki.test.ts tests/explore.test.ts tests/preview.test.ts tests/note-markdown.test.tsx tests/api-publication.test.ts tests/discovery.test.ts tests/auth.test.ts",
"test": "tsx --test tests/api-documentation.test.ts tests/health.test.ts tests/model.test.ts tests/learning-series.test.ts tests/skill-docs.test.ts tests/wiki.test.ts tests/explore.test.ts tests/preview.test.ts tests/note-markdown.test.tsx tests/api-publication.test.ts tests/discovery.test.ts tests/auth.test.ts tests/account.test.ts tests/guidance.test.ts tests/guidance-publishing.test.ts",
"test:smoke": "node scripts/smoke.mjs",
"storage:prepare": "tsx scripts/bootstrap.ts",
"content:publish-learning": "tsx scripts/publish-learning-series.ts",
"worker": "tsx src/worker.ts",
"test:integration": "tsx --test tests/integration.test.ts"
"test:integration": "tsx --test tests/integration.test.ts",
"brand:prepare": "node ../../packages/brand/scripts/prepare-app.mjs"
},
"dependencies": {
"@aws-sdk/client-s3": "3.1155.0",
@@ -23,6 +24,7 @@
"lucide-react": "1.61.0",
"mdast-util-from-markdown": "2.1.0",
"next": "16.4.2",
"nodemailer": "10.2.1",
"pg": "8.23.1",
"playwright-core": "1.56.1",
"qrcode": "1.5.4",
@@ -31,12 +33,14 @@
"react-markdown": "10.1.0",
"remark-gfm": "4.0.1",
"sharp": "0.35.5",
"zod": "4.6.7"
"zod": "4.6.7",
"@endoleap/brand": "workspace:*"
},
"devDependencies": {
"@tailwindcss/postcss": "4.3.3",
"@types/formidable": "3.5.1",
"@types/node": "24.19.2",
"@types/nodemailer": "7.0.4",
"@types/pg": "8.23.1",
"@types/qrcode": "1.5.6",
"@types/react": "19.3.0",
+217
View File
@@ -11,6 +11,9 @@ importers:
'@aws-sdk/client-s3':
specifier: 3.1155.0
version: 3.1155.0
'@endoleap/brand':
specifier: workspace:*
version: link:../../packages/brand
formidable:
specifier: 3.5.4
version: 3.5.4
@@ -23,6 +26,9 @@ importers:
next:
specifier: 16.4.2
version: 16.4.2(@types/node@24.19.2)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)
nodemailer:
specifier: 10.2.1
version: 10.2.1
pg:
specifier: 8.23.1
version: 8.23.1
@@ -60,6 +66,9 @@ importers:
'@types/node':
specifier: 24.19.2
version: 24.19.2
'@types/nodemailer':
specifier: 7.0.4
version: 7.0.4
'@types/pg':
specifier: 8.23.1
version: 8.23.1
@@ -99,42 +108,82 @@ packages:
resolution: {integrity: sha512-l3uMeIlzWVgj/RTJTh5sKYKtQz1la3cvWLUPn1cwFH4UZpcqTH66SgJHCeB2FOE9bEAjOdbmCyPKt9aw867Fng==}
engines: {node: '>=20.0.0'}
'@aws-sdk/client-sesv2@3.1158.0':
resolution: {integrity: sha512-CEhnnEhIUeyfVSEB/Q8Gm2YOu/vF6ONrQ09e9t3imkvTTBVbpMNDhVyFBlBfHxNDD63kgJ1p39DPicx5Er+Sxw==}
engines: {node: '>=20.0.0'}
'@aws-sdk/core@3.978.5':
resolution: {integrity: sha512-ruI0AWqcrfL20XDS6SwZIJFPOBZ6N0ADa9nLzWlBhxk1aoUjbUX3JkTR9vEmk7o35XpAHi68ahZ4T6RE2U9XyA==}
engines: {node: '>=20.0.0'}
'@aws-sdk/core@3.978.6':
resolution: {integrity: sha512-ykF/MiYoM15dgZwF/WLgjJXVbAK2wU6vjPigeEnOHwyUJ0rdlraO5uekecKNVPkvOnz+oju/5zljViQs1nI1FQ==}
engines: {node: '>=20.0.0'}
'@aws-sdk/credential-provider-env@3.972.76':
resolution: {integrity: sha512-YxmL1Ar7bV9ZkJvtv+lj+MF6le76NnZnbmgySyZuxpZaCv0/1lZ3KxKy6RIj1h9ui2hWnVjZxbmTMeO0y/NrQA==}
engines: {node: '>=20.0.0'}
'@aws-sdk/credential-provider-env@3.972.77':
resolution: {integrity: sha512-CN6dQCDo32a8n2gvm2HBeqGV5BNI9noG96nTj5bGO2HWtt0Rucu/A8pcnJIqkzILOHSuilnCk0Do8/7h6SOXhQ==}
engines: {node: '>=20.0.0'}
'@aws-sdk/credential-provider-http@3.972.78':
resolution: {integrity: sha512-3VHknDRngoebsaqyT1iR75b5cBT2zfEtODK6yYmYltljkK1HEAQLs2FEd1IpWqAeOYdMGaG9wikngWLTufY7RA==}
engines: {node: '>=20.0.0'}
'@aws-sdk/credential-provider-http@3.972.79':
resolution: {integrity: sha512-Netsu0nnmvP2HiN1ZFQBeUu+JehQIuubqz+j2kHDgeSE4HDzniCpIZ7RzoWABq+LTcbCi9W03hywifUagoVIWQ==}
engines: {node: '>=20.0.0'}
'@aws-sdk/credential-provider-ini@3.973.21':
resolution: {integrity: sha512-aLx9hbCX6mGAyv4ZZd8UagJ4RASHVJw6BFDSIyuKmLkqNL6H47EjBPK6qaJ4B5TDV6mc+vMa0SJFYyo4mkvVQg==}
engines: {node: '>=20.0.0'}
'@aws-sdk/credential-provider-ini@3.973.22':
resolution: {integrity: sha512-x7/5h58cqOoZR0RWUZC7v/cJOADQR42iSIQkvU+00HVHR4jh8ZpqEgRPGf2zei59iH5qP40lL3bkq49PuWl71g==}
engines: {node: '>=20.0.0'}
'@aws-sdk/credential-provider-login@3.972.83':
resolution: {integrity: sha512-9bJU6FjRacF4FK/QHf8Dyxhur7B1grRj8C1Yh4IkpoFW2exQflPdCv0VrbemLW9ZctLNle+AE7TETs9KNvuLYg==}
engines: {node: '>=20.0.0'}
'@aws-sdk/credential-provider-login@3.972.84':
resolution: {integrity: sha512-G9Ty1uIV0laEvEp2KiMbEhsuBAjgaD3KYrwVjAM5VUxpcIuPBqQI5riA7/qzrnSgYiAKQbubbgkwcqCICKCIxg==}
engines: {node: '>=20.0.0'}
'@aws-sdk/credential-provider-node@3.972.88':
resolution: {integrity: sha512-AG0KID5qhQPzrFkh5b0Cws1Amytvzo0L0Xj6hMgJXEwD6ZXAqninDhSNAWJiN5zU4YVVZnuidnqwPwYJ04IamA==}
engines: {node: '>=20.0.0'}
'@aws-sdk/credential-provider-node@3.972.89':
resolution: {integrity: sha512-O8xj7/sanrnXfFHu/LUujVBiiwn5mknYenU7xt1w6+yvhR1QoDZWXLFh1gLITaFVPtb1jWgEirC+kFNw+//tyw==}
engines: {node: '>=20.0.0'}
'@aws-sdk/credential-provider-process@3.972.76':
resolution: {integrity: sha512-pdhKFTXv1zPYhh92MTlp0jVbLWvUEV9v+rziuEkoskGGQ2iJskxHTO38nA8UAbT/N4IM3n8OZJ9rIY5IBO15/A==}
engines: {node: '>=20.0.0'}
'@aws-sdk/credential-provider-process@3.972.77':
resolution: {integrity: sha512-sWA8Jm+ASzkTxoqLizHWRbt8Gj3yZCmZ0hYdvg0uIxuLzWseYcfrDFzaSnkRWlEWSJV38rAWFimdrN0J4NR7Pg==}
engines: {node: '>=20.0.0'}
'@aws-sdk/credential-provider-sso@3.973.20':
resolution: {integrity: sha512-DlxrAB3MnjNLx/da7b89W+3DyJQYso/q7Ns15MsST2IV2nkAYQOYTUCaffNSjgrpMP99Dm2mV2jxzcbyF94ZfA==}
engines: {node: '>=20.0.0'}
'@aws-sdk/credential-provider-sso@3.973.21':
resolution: {integrity: sha512-KV2LM8fbYUhDzGEBnPwb+ISNfNlhmnpLS+y6A6VsJpg1EI9aUJ0+dRTxh2GrGdneFH0T4TiuboHxz8i6+M38JA==}
engines: {node: '>=20.0.0'}
'@aws-sdk/credential-provider-web-identity@3.972.82':
resolution: {integrity: sha512-LUo46IqPTJ4xKBsrLuoaxiv/D2GTBCcFDMD1y3jKIqPR/p7Rw14RJFZ0iRWftqZtyLPQX9JMGnSi3mBu4y/pNA==}
engines: {node: '>=20.0.0'}
'@aws-sdk/credential-provider-web-identity@3.972.83':
resolution: {integrity: sha512-iekYilmo2Re51fC7gMkEfZ7jFDXwehSCmXjDESaYQsdgYhsLQFG7rTYr3GBvRIyj+Fkda2NxbZX7hAZDaFD0JQ==}
engines: {node: '>=20.0.0'}
'@aws-sdk/middleware-sdk-s3@3.972.81':
resolution: {integrity: sha512-jfanv1pV0sJFZw6/DPiNjvK7B478god0zOum5yIWo+/+1Gy+CSXWpGBWx+3nBJyDvjcu/2kUMr4ADUp9BHH34Q==}
engines: {node: '>=20.0.0'}
@@ -143,6 +192,10 @@ packages:
resolution: {integrity: sha512-nKb8qu4QsnpZTq/6JIXc72mzuzfh7BK4sUQkpQznPec2z4W7Ob2xYkcM4i5e1yGTBnbJVeFv/0sf3jcz85hclA==}
engines: {node: '>=20.0.0'}
'@aws-sdk/nested-clients@3.997.51':
resolution: {integrity: sha512-DJK4iGIHWU8Jh8ODMvqFYcX9yGBMXV4tSZAvqlHF5EDd8hbmF3eFO9KIWOnA7wcQjPzEkyMMW1/FgZ9RU8oimQ==}
engines: {node: '>=20.0.0'}
'@aws-sdk/signature-v4-multi-region@3.996.49':
resolution: {integrity: sha512-nvuAZNRrRRVCiARRKsYlQ0VnXiEQiq7vXPH+UA+LSo+kN3Djl9eo/V2r0udkw85PnsNshmv2Em1clld5M3j67w==}
engines: {node: '>=20.0.0'}
@@ -151,6 +204,10 @@ packages:
resolution: {integrity: sha512-iIcQnYLhWuq9qOLQbIWFrxF3PF0svVeZvHnUS4DUnO4zUFmFHIyGyjqrntXG+e/5PUgfavV0pYfDIKHxTzN2LA==}
engines: {node: '>=20.0.0'}
'@aws-sdk/token-providers@3.1157.0':
resolution: {integrity: sha512-AeAzcwWRP2uANO2TjFpKjRdvOCzgfIu6YQl9Ceibd6E0gnq5dfOzujFnq2aeqgbLg4wRtXbUZE4gekCCPrz/OQ==}
engines: {node: '>=20.0.0'}
'@aws-sdk/types@3.975.0':
resolution: {integrity: sha512-WQlhs6juByzWOzj5fwxOx0mPJaEqMcG/sDpl3Id61B3XhKE63xY+0QWcnG+sWR/sCfprmreyUo1lM7IKZmHMzw==}
engines: {node: '>=20.0.0'}
@@ -566,6 +623,10 @@ packages:
resolution: {integrity: sha512-oRw0rB9QSUiCZfEs23G5F4Af+b2rQt1lItIICWu5IejFNd+uPM8SWqiKLMpQ6bX1Q7slsLbjuoE2niErFtkVPQ==}
engines: {node: '>=18.0.0'}
'@smithy/core@3.37.0':
resolution: {integrity: sha512-97imTcCyiPd8k0e9+ts/Sp0W65Dn9Ti357J7M09Ov4tI8Q1JDJVFSoAJtjEVKqfHEr3Pdo2jfolqKCaaiszH0g==}
engines: {node: '>=18.0.0'}
'@smithy/credential-provider-imds@4.5.2':
resolution: {integrity: sha512-A9uSdn72ozbRUSit0eib0TW7nXuNPlaeM0zcGkJ+nE6tFcSDbnmtwoxbTCFBukVQcszDAyvsd7+rTduPTXpygg==}
engines: {node: '>=18.0.0'}
@@ -578,6 +639,10 @@ packages:
resolution: {integrity: sha512-ThMkboGeONWXAelq9FvGsuJC4rOi+qyC4/zhUF58xYpxUg5sQKx2VXZYJmtNjr4dSuBJ1HeJXETQILCz3wOHvw==}
engines: {node: '>=18.0.0'}
'@smithy/node-http-handler@4.13.0':
resolution: {integrity: sha512-/GGcPoNJ2nzGytdAanpbx7mpp9Coa8LP+/Rpmbktr8YeLuIXs9B0SEz+X3AeonHdyzOIFJdKbN7jOZ+Tj+pMGw==}
engines: {node: '>=18.0.0'}
'@smithy/signature-v4@5.7.4':
resolution: {integrity: sha512-tHy0K0VtqNd5Y7Y41h0a0Lhh0L1GzC08dTWg0F7vRJWFtTENg7IZikf3wQkanYIRdb7ngoIPMTmqgUi401fEeQ==}
engines: {node: '>=18.0.0'}
@@ -705,6 +770,9 @@ packages:
'@types/node@24.19.2':
resolution: {integrity: sha512-93d49wCWJkCyN87MxLpNN6KXPCwFw7bVUg79mGUV1OS40JGWwVCr8n61DXCSqz7CV6/VfPkb7xec7uP2M+QHWQ==}
'@types/nodemailer@7.0.4':
resolution: {integrity: sha512-ee8fxWqOchH+Hv6MDDNNy028kwvVnLplrStm4Zf/3uHWw5zzo8FoYYeffpJtGs2wWysEumMH0ZIdMGMY1eMAow==}
'@types/pg@8.23.1':
resolution: {integrity: sha512-fKVHpikPdg4GKks3JuLEhvwSyvwzF23hnabPy6DD8ljVbC7+6J5dQzdv4arV6jqq57djnMgs1HKBxX4P8aBI3A==}
@@ -1159,6 +1227,10 @@ packages:
sass:
optional: true
nodemailer@10.2.1:
resolution: {integrity: sha512-ZrDNsPFoDMwefNs7sJwrYiEJ/CG4ClJWDZv05iNJsEBWa8GRDry1QJHTJxsuTAY9eDDKjX4k+q3SxslXJOao7w==}
engines: {node: '>=20.0.0'}
once@1.4.0:
resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
@@ -1464,6 +1536,18 @@ snapshots:
'@smithy/types': 4.19.0
tslib: 2.8.1
'@aws-sdk/client-sesv2@3.1158.0':
dependencies:
'@aws-sdk/core': 3.978.6
'@aws-sdk/credential-provider-node': 3.972.89
'@aws-sdk/signature-v4-multi-region': 3.996.49
'@aws-sdk/types': 3.975.0
'@smithy/core': 3.37.0
'@smithy/fetch-http-handler': 5.8.0
'@smithy/node-http-handler': 4.13.0
'@smithy/types': 4.19.0
tslib: 2.8.1
'@aws-sdk/core@3.978.5':
dependencies:
'@aws-sdk/types': 3.975.0
@@ -1475,6 +1559,17 @@ snapshots:
bowser: 2.14.1
tslib: 2.8.1
'@aws-sdk/core@3.978.6':
dependencies:
'@aws-sdk/types': 3.975.0
'@aws-sdk/xml-builder': 3.972.41
'@aws/lambda-invoke-store': 0.3.0
'@smithy/core': 3.37.0
'@smithy/signature-v4': 5.7.4
'@smithy/types': 4.19.0
bowser: 2.14.1
tslib: 2.8.1
'@aws-sdk/credential-provider-env@3.972.76':
dependencies:
'@aws-sdk/core': 3.978.5
@@ -1483,6 +1578,14 @@ snapshots:
'@smithy/types': 4.19.0
tslib: 2.8.1
'@aws-sdk/credential-provider-env@3.972.77':
dependencies:
'@aws-sdk/core': 3.978.6
'@aws-sdk/types': 3.975.0
'@smithy/core': 3.37.0
'@smithy/types': 4.19.0
tslib: 2.8.1
'@aws-sdk/credential-provider-http@3.972.78':
dependencies:
'@aws-sdk/core': 3.978.5
@@ -1493,6 +1596,16 @@ snapshots:
'@smithy/types': 4.19.0
tslib: 2.8.1
'@aws-sdk/credential-provider-http@3.972.79':
dependencies:
'@aws-sdk/core': 3.978.6
'@aws-sdk/types': 3.975.0
'@smithy/core': 3.37.0
'@smithy/fetch-http-handler': 5.8.0
'@smithy/node-http-handler': 4.13.0
'@smithy/types': 4.19.0
tslib: 2.8.1
'@aws-sdk/credential-provider-ini@3.973.21':
dependencies:
'@aws-sdk/core': 3.978.5
@@ -1509,6 +1622,22 @@ snapshots:
'@smithy/types': 4.19.0
tslib: 2.8.1
'@aws-sdk/credential-provider-ini@3.973.22':
dependencies:
'@aws-sdk/core': 3.978.6
'@aws-sdk/credential-provider-env': 3.972.77
'@aws-sdk/credential-provider-http': 3.972.79
'@aws-sdk/credential-provider-login': 3.972.84
'@aws-sdk/credential-provider-process': 3.972.77
'@aws-sdk/credential-provider-sso': 3.973.21
'@aws-sdk/credential-provider-web-identity': 3.972.83
'@aws-sdk/nested-clients': 3.997.51
'@aws-sdk/types': 3.975.0
'@smithy/core': 3.37.0
'@smithy/credential-provider-imds': 4.5.2
'@smithy/types': 4.19.0
tslib: 2.8.1
'@aws-sdk/credential-provider-login@3.972.83':
dependencies:
'@aws-sdk/core': 3.978.5
@@ -1518,6 +1647,15 @@ snapshots:
'@smithy/types': 4.19.0
tslib: 2.8.1
'@aws-sdk/credential-provider-login@3.972.84':
dependencies:
'@aws-sdk/core': 3.978.6
'@aws-sdk/nested-clients': 3.997.51
'@aws-sdk/types': 3.975.0
'@smithy/core': 3.37.0
'@smithy/types': 4.19.0
tslib: 2.8.1
'@aws-sdk/credential-provider-node@3.972.88':
dependencies:
'@aws-sdk/credential-provider-env': 3.972.76
@@ -1532,6 +1670,20 @@ snapshots:
'@smithy/types': 4.19.0
tslib: 2.8.1
'@aws-sdk/credential-provider-node@3.972.89':
dependencies:
'@aws-sdk/credential-provider-env': 3.972.77
'@aws-sdk/credential-provider-http': 3.972.79
'@aws-sdk/credential-provider-ini': 3.973.22
'@aws-sdk/credential-provider-process': 3.972.77
'@aws-sdk/credential-provider-sso': 3.973.21
'@aws-sdk/credential-provider-web-identity': 3.972.83
'@aws-sdk/types': 3.975.0
'@smithy/core': 3.37.0
'@smithy/credential-provider-imds': 4.5.2
'@smithy/types': 4.19.0
tslib: 2.8.1
'@aws-sdk/credential-provider-process@3.972.76':
dependencies:
'@aws-sdk/core': 3.978.5
@@ -1540,6 +1692,14 @@ snapshots:
'@smithy/types': 4.19.0
tslib: 2.8.1
'@aws-sdk/credential-provider-process@3.972.77':
dependencies:
'@aws-sdk/core': 3.978.6
'@aws-sdk/types': 3.975.0
'@smithy/core': 3.37.0
'@smithy/types': 4.19.0
tslib: 2.8.1
'@aws-sdk/credential-provider-sso@3.973.20':
dependencies:
'@aws-sdk/core': 3.978.5
@@ -1550,6 +1710,16 @@ snapshots:
'@smithy/types': 4.19.0
tslib: 2.8.1
'@aws-sdk/credential-provider-sso@3.973.21':
dependencies:
'@aws-sdk/core': 3.978.6
'@aws-sdk/nested-clients': 3.997.51
'@aws-sdk/token-providers': 3.1157.0
'@aws-sdk/types': 3.975.0
'@smithy/core': 3.37.0
'@smithy/types': 4.19.0
tslib: 2.8.1
'@aws-sdk/credential-provider-web-identity@3.972.82':
dependencies:
'@aws-sdk/core': 3.978.5
@@ -1559,6 +1729,15 @@ snapshots:
'@smithy/types': 4.19.0
tslib: 2.8.1
'@aws-sdk/credential-provider-web-identity@3.972.83':
dependencies:
'@aws-sdk/core': 3.978.6
'@aws-sdk/nested-clients': 3.997.51
'@aws-sdk/types': 3.975.0
'@smithy/core': 3.37.0
'@smithy/types': 4.19.0
tslib: 2.8.1
'@aws-sdk/middleware-sdk-s3@3.972.81':
dependencies:
'@aws-sdk/core': 3.978.5
@@ -1579,6 +1758,17 @@ snapshots:
'@smithy/types': 4.19.0
tslib: 2.8.1
'@aws-sdk/nested-clients@3.997.51':
dependencies:
'@aws-sdk/core': 3.978.6
'@aws-sdk/signature-v4-multi-region': 3.996.49
'@aws-sdk/types': 3.975.0
'@smithy/core': 3.37.0
'@smithy/fetch-http-handler': 5.8.0
'@smithy/node-http-handler': 4.13.0
'@smithy/types': 4.19.0
tslib: 2.8.1
'@aws-sdk/signature-v4-multi-region@3.996.49':
dependencies:
'@aws-sdk/types': 3.975.0
@@ -1595,6 +1785,15 @@ snapshots:
'@smithy/types': 4.19.0
tslib: 2.8.1
'@aws-sdk/token-providers@3.1157.0':
dependencies:
'@aws-sdk/core': 3.978.6
'@aws-sdk/nested-clients': 3.997.51
'@aws-sdk/types': 3.975.0
'@smithy/core': 3.37.0
'@smithy/types': 4.19.0
tslib: 2.8.1
'@aws-sdk/types@3.975.0':
dependencies:
'@smithy/types': 4.19.0
@@ -1852,6 +2051,11 @@ snapshots:
'@smithy/types': 4.19.0
tslib: 2.8.1
'@smithy/core@3.37.0':
dependencies:
'@smithy/types': 4.19.0
tslib: 2.8.1
'@smithy/credential-provider-imds@4.5.2':
dependencies:
'@smithy/core': 3.36.2
@@ -1870,6 +2074,12 @@ snapshots:
'@smithy/types': 4.19.0
tslib: 2.8.1
'@smithy/node-http-handler@4.13.0':
dependencies:
'@smithy/core': 3.37.0
'@smithy/types': 4.19.0
tslib: 2.8.1
'@smithy/signature-v4@5.7.4':
dependencies:
'@smithy/core': 3.36.2
@@ -1981,6 +2191,11 @@ snapshots:
dependencies:
undici-types: 7.24.6
'@types/nodemailer@7.0.4':
dependencies:
'@aws-sdk/client-sesv2': 3.1158.0
'@types/node': 24.19.2
'@types/pg@8.23.1':
dependencies:
'@types/node': 24.19.2
@@ -2629,6 +2844,8 @@ snapshots:
- '@types/node'
- babel-plugin-macros
nodemailer@10.2.1: {}
once@1.4.0:
dependencies:
wrappy: 1.0.2
Binary file not shown.

Before

Width:  |  Height:  |  Size: 204 KiB

+7 -5
View File
@@ -1,9 +1,11 @@
# PROMPT
# 织页 · Linked Guidance
> 创作素材与可相互引用的指导文档。
> 独立使用、按需连接的 Agent 指导文档。名称与 XML 协议仍处于实验阶段。
- [网站 Markdown 入口](/index.md)
- [全部指导文档](/skills/index.md)
- [Agent 阅读入口](/agent/index.md)
- [真实用户文档摘要(JSON)](/api/documents)
- [协议样例摘要(JSON)](/api/guides)
- [协议说明](/protocol)
- [人类入口](/)
每篇指导文档的网页地址加 /index.md,即为一一对应的 Markdown。沿文档链接继续阅读,可直接进入任意一篇,不必经过某个技能入口。
从单篇文档开始;只有当前任务需要时才沿条件链接读取其他文档。固定版本引用,限制读取深度与篇数。集合不是强制安装包,链接不授予执行权限。
Binary file not shown.

Before

Width:  |  Height:  |  Size: 694 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 MiB

+10 -1
View File
@@ -47,7 +47,16 @@ async function main() {
if (command === 'verify' || command === 'exercise') {
const request = (path:string,init:RequestInit={}) => fetch(base+path,{...init,redirect:'manual'});
const buildId = readFileSync('.next/BUILD_ID','utf8').trim();
for (const path of ['/','/library/import','/skills/shadow-puppet/egret-example.png','/studio',`/_next/data/${buildId}/index.json`]) {
for(const path of ['/','/workspace','/documents','/account'])assert.equal((await request(path)).status,200,'Public shell '+path);
for(const path of ['/favicon.ico','/brand/logo-light.svg','/brand/logo-dark.svg','/brand/icon-dark.svg','/apple-touch-icon.png']) assert.equal((await request(path)).status,200,'Public brand asset '+path);
const studio=await request('/studio');assert.equal(studio.status,307);assert.equal(studio.headers.get('location'),'/workspace');
for(const [from,to] of [['/studio/graphic','/graphic'],['/studio/infinite_canvas','/infinite_canvas']]){const r=await request(from);assert.equal(r.status,308);assert.equal(r.headers.get('location'),to)}
assert.equal((await request('/account')).status,200,'Public account page');
const homeData = await request(`/_next/data/${buildId}/index.json`);
assert.ok([200,404].includes(homeData.status),'Public homepage data is readable or absent for static pages');
assert.ok(!isLoginRedirect(homeData.headers.get('location') || homeData.headers.get('x-nextjs-redirect'),base));
assert.equal((await request('/api/posts',{method:'POST',headers:{Cookie:'wiki_account='+ 'a'.repeat(64)},body:'{}'})).status,401,'Account cookies never grant admin rights');
for (const path of ['/library/import','/skills/shadow-puppet/egret-example.png','/studio/layouts/ref-01',`/_next/data/${buildId}/library/import.json`]) {
const response = await request(path); assert.equal(response.status,307,path); const destination = response.headers.get('location') || response.headers.get('x-nextjs-redirect'); assert.ok(isLoginRedirect(destination,base),path+' must redirect to login: '+destination);
}
for (const path of ['/api/feed','/api/upload','/studio/api/export/test','/api/media/missing']) assert.equal((await request(path)).status,401,path);
@@ -1,17 +1,19 @@
import {readFile,readdir} from 'node:fs/promises';
import {readFile} from 'node:fs/promises';
import {fileURLToPath} from 'node:url';
import {join,dirname} from 'node:path';
import {createHash} from 'node:crypto';
import {requiredPackImages} from './verify-character-pack.mjs';
const root=join(dirname(fileURLToPath(import.meta.url)),'..','seed','characters');
const base=process.env.PROMPT_IMPORT_BASE_URL||'http://127.0.0.1:3000';
const media=JSON.parse(await readFile(join(root,'..','..','src','content','managed-media.json'),'utf8'));
const packFiles=pack=>Object.entries(media).filter(([name])=>name.startsWith(`apps/waterfall-wiki/seed/characters/${pack.directory}/`)).sort(([a],[b])=>a.localeCompare(b));
const packs=JSON.parse(await readFile(join(root,'packs.json'),'utf8'));
async function verify(pack){
let cursor='',post;const seen=new Set();
do{const response=await fetch(base+'/api/feed?'+new URLSearchParams({section:'images',...(cursor?{cursor}:{})}));if(!response.ok)throw Error('Cannot verify character feed: '+response.status);const page=await response.json();post=page.posts.find(p=>p.slug===pack.slug);cursor=page.nextCursor||'';if(cursor&&seen.has(cursor))throw Error('Character feed cursor did not advance');seen.add(cursor);}while(!post&&cursor);
if(!post)throw Error('Character pack missing: '+pack.slug);
const files=(await readdir(join(root,pack.directory))).filter(n=>n.endsWith('.png')).sort();if(files.length!==pack.count)throw Error('Seed file count mismatch: '+pack.slug);
const hashes=await Promise.all(files.map(async file=>createHash('sha256').update(await readFile(join(root,pack.directory,file))).digest('hex')));
const files=packFiles(pack);if(files.length!==pack.count)throw Error('Seed file count mismatch: '+pack.slug);
const hashes=files.map(([,asset])=>asset.sha256);
const originals=requiredPackImages(hashes,post.assets.filter(a=>a.kind==='image'));
for(const image of originals){const media=await fetch(`${base}/api/media/${image.id}`,{method:'HEAD'});if(!media.ok||media.headers.get('content-type')!=='image/png')throw Error('Original PNG unavailable');}
console.log(`Verified: /p/${pack.slug} (${originals.length} original PNGs preserved; editorial covers allowed)`);
@@ -20,10 +22,14 @@ for(const pack of packs){
const existing=await fetch(`${base}/p/${pack.slug}`);
if(existing.ok){console.log(`Already imported: /p/${pack.slug}`);await verify(pack);continue;}
if(existing.status!==404)throw new Error(`Cannot check pack ${pack.slug}: ${existing.status}`);
const files=(await readdir(join(root,pack.directory))).filter(n=>n.endsWith('.png')).sort();
const files=packFiles(pack);
if(files.length!==pack.count)throw new Error(`Expected ${pack.count} PNGs for ${pack.slug}, got ${files.length}`);
const data=new FormData();data.set('title',pack.title);data.set('slug',pack.slug);data.set('description',pack.description);
for(const file of files)data.append('files',new Blob([await readFile(join(root,pack.directory,file))],{type:'image/png'}),file);
for(const [name,asset] of files){
const response=await fetch(`${base}/api/media/${asset.id}`);if(!response.ok)throw Error('Cannot read migrated original: '+name);
const bytes=Buffer.from(await response.arrayBuffer());if(bytes.length!==asset.bytes||createHash('sha256').update(bytes).digest('hex')!==asset.sha256)throw Error('Original checksum mismatch: '+name);
data.append('files',new Blob([bytes],{type:'image/png'}),name.split('/').pop());
}
const response=await fetch(`${base}/api/skills/street-people`,{method:'POST',body:data});
if(response.status!==201&&response.status!==409)throw new Error(`Import ${pack.slug}: ${response.status} ${await response.text()}`);
if(response.status===409&&!(await fetch(`${base}/p/${pack.slug}`)).ok)throw new Error(`Conflicting import ${pack.slug}`);
@@ -1,4 +1,4 @@
import { cp,mkdir,access } from 'node:fs/promises';
await mkdir('.next/standalone/.next',{recursive:true});
await cp('.next/static','.next/standalone/.next/static',{recursive:true});
try{await access('public');await cp('public','.next/standalone/public',{recursive:true});}catch(e){if(e.code!=='ENOENT')throw e;}
await mkdir('.next/standalone/apps/waterfall-wiki/.next',{recursive:true});
await cp('.next/static','.next/standalone/apps/waterfall-wiki/.next/static',{recursive:true});
try{await access('public');await cp('public','.next/standalone/apps/waterfall-wiki/public',{recursive:true});}catch(e){if(e.code!=='ENOENT')throw e;}
+17 -4
View File
@@ -1,3 +1,5 @@
import {writeFile,unlink} from 'node:fs/promises';
import sharp from 'sharp';
import { spawn } from "node:child_process";
import assert from "node:assert/strict";
import { setTimeout } from "node:timers/promises";
@@ -15,8 +17,12 @@ async function availablePort() {
const port = process.env.SMOKE_PORT || await availablePort();
const release = "smoke-test";
const child = spawn(process.execPath, [".next/standalone/server.js"], {
env: { ...process.env, PORT: port, HOSTNAME: "127.0.0.1", RELEASE_SHA: release },
const previewFixture='.next/standalone/apps/waterfall-wiki/public/__smoke-preview.png';
await writeFile(previewFixture,await sharp({create:{width:48,height:48,channels:3,background:'#eee'}}).png().toBuffer());
const child = spawn(process.execPath, [".next/standalone/apps/waterfall-wiki/server.js"], {
// This loopback-only child checks retained resources without production credentials.
// Production keeps failing closed; the bypass requires both explicit test settings.
env: { ...process.env, AUTH_CONFIG_FILE: "", AUTH_DISABLED_FOR_TESTS: "true", PORT: port, HOSTNAME: "127.0.0.1", RELEASE_SHA: release },
stdio: "inherit",
});
try {
@@ -40,10 +46,17 @@ try {
const id=indexText.match(/\/explore\/([a-f0-9]{24})/)?.[1];assert.ok(id);
const agent=await fetch(`http://127.0.0.1:${port}/explore/${id}?agent_mode=True`);
assert.equal(agent.status,200);assert.match(agent.headers.get('content-type'),/text\/plain/);const agentText=await agent.text();assert.match(agentText,/^# /);assert.doesNotMatch(agentText,/^\s*(?:<!doctype html|<html)/i);
const preview=await fetch(`http://127.0.0.1:${port}/api/preview?source=${encodeURIComponent('/skills/shadow-puppet/egret-example.png')}&width=480`);
const preview=await fetch(`http://127.0.0.1:${port}/api/preview?source=${encodeURIComponent('/__smoke-preview.png')}&width=480`);
assert.equal(preview.status,200);assert.match(preview.headers.get('content-type'),/image\/webp/);assert.ok((await preview.arrayBuffer()).byteLength<=100000);
console.log("Production smoke test passed");
const home=await fetch(`http://127.0.0.1:${port}/`).then(r=>r.text());
for(const href of ['/graphic','/infinite_canvas','/account?next=/workspace'])assert.ok(home.includes(`href="${href}"`),href);
for(const [legacy,canonical] of [['/studio/graphic','/graphic'],['/studio/infinite_canvas','/infinite_canvas']]){
const redirect=await fetch(`http://127.0.0.1:${port}${legacy}`,{redirect:'manual'});
assert.equal(redirect.status,308);assert.equal(redirect.headers.get('location'),canonical);
}
console.log("Production smoke test passed, including landing entry points and canonical redirects");
} finally {
await unlink(previewFixture);
if (child.exitCode === null && child.signalCode === null) {
const exited = once(child, "exit");
child.kill("SIGTERM");
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 769 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 813 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 700 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 986 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 854 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 884 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 782 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1020 KiB

Some files were not shown because too many files have changed in this diff Show More