Compare commits

..

8 Commits

Author SHA1 Message Date
codex-bot 896c8bb9e5 Record preview synchronization and runtime identity
Unified application CI and deployment / static-checks (push) Successful in 6s
Remote development preview source sync / sync-source (push) Successful in 7s
Unified application CI and deployment / static-checks (pull_request) Successful in 5s
python CI and deployment / static-checks (pull_request) Successful in 3s
Unified application CI and deployment / verify-and-release (push) Has been skipped
Unified application CI and deployment / verify-and-release (pull_request) Has been skipped
python CI and deployment / verify-and-deploy (pull_request) Has been skipped
2026-11-04 14:34:45 +08:00
codex-bot 1df5790d23 Give the preview process ownership of its Next cache
Unified application CI and deployment / static-checks (push) Successful in 7s
Remote development preview source sync / sync-source (push) Successful in 1m28s
Unified application CI and deployment / static-checks (pull_request) Successful in 5s
python CI and deployment / static-checks (pull_request) Successful in 3s
Unified application CI and deployment / verify-and-release (push) Has been skipped
Unified application CI and deployment / verify-and-release (pull_request) Has been skipped
python CI and deployment / verify-and-deploy (pull_request) Has been skipped
2026-11-04 14:32:04 +08:00
codex-bot fe26f03cf4 Use numeric container ownership during preview setup
Unified application CI and deployment / static-checks (push) Successful in 5s
Remote development preview source sync / sync-source (push) Failing after 4m54s
Unified application CI and deployment / static-checks (pull_request) Successful in 5s
python CI and deployment / static-checks (pull_request) Successful in 3s
Unified application CI and deployment / verify-and-release (push) Has been skipped
Unified application CI and deployment / verify-and-release (pull_request) Has been skipped
python CI and deployment / verify-and-deploy (pull_request) Has been skipped
2026-11-04 14:25:41 +08:00
codex-bot 19e179dd20 Run a persistent remote development preview
Unified application CI and deployment / static-checks (push) Successful in 5s
Remote development preview source sync / sync-source (push) Failing after 17s
python CI and deployment / static-checks (push) Successful in 4s
Unified application CI and deployment / static-checks (pull_request) Successful in 5s
python CI and deployment / static-checks (pull_request) Successful in 4s
Unified application CI and deployment / verify-and-release (push) Has been skipped
python CI and deployment / verify-and-deploy (push) Has been skipped
Unified application CI and deployment / verify-and-release (pull_request) Has been skipped
python CI and deployment / verify-and-deploy (pull_request) Has been skipped
2026-11-04 14:24:01 +08:00
codex-bot 7f88ebba71 Run only static checks before main and gate full release on main push
Unified application CI and deployment / static-checks (push) Successful in 1m9s
python CI and deployment / static-checks (push) Successful in 1m7s
Unified application CI and deployment / static-checks (pull_request) Successful in 1m7s
python CI and deployment / static-checks (pull_request) Successful in 1m6s
Unified application CI and deployment / verify-and-release (push) Has been skipped
python CI and deployment / verify-and-deploy (push) Has been skipped
Unified application CI and deployment / verify-and-release (pull_request) Has been skipped
python CI and deployment / verify-and-deploy (pull_request) Has been skipped
2026-11-04 04:24:31 +08:00
codex-bot 0a77d03220 Cache dependency downloads by lockfile and preserve Studio runtime checks
python CI and deployment / verify-and-deploy (pull_request) Successful in 1m12s
Unified application CI and deployment / verify-and-release (pull_request) Has been cancelled
2026-11-04 04:02:48 +08:00
codex-bot 6654a1456b Unify Wiki and Studio application and simplify deployment
Unified application CI and deployment / verify-and-release (pull_request) Waiting to run
python CI and deployment / verify-and-deploy (pull_request) Successful in 1m12s
2026-11-04 03:41:16 +08:00
codex-bot 80d23f0e3d Improve Docker dependency caching and isolate Python runtime
Endoleap brand checks / verify (pull_request) Successful in 2m27s
creative workspace review checks / verify-deployed-canvas (pull_request) Successful in 3m10s
creative workspace review checks / verify (pull_request) Successful in 42m47s
python CI and deployment / verify-and-deploy (pull_request) Successful in 9m51s
2026-11-02 21:54:14 +08:00
130 changed files with 1198 additions and 1747 deletions
+15
View File
@@ -22,3 +22,18 @@ coverage
# Local authoring assets are uploaded via APIs, never application build inputs.
/content
# Apply local metadata, secrets and generated-output exclusions at every depth.
**/.git
**/.worktrees
**/.pnpm-store
**/.turbo
**/.nx
**/coverage
**/artifacts
**/.pytest_cache
**/*.egg-info
**/*.tsbuildinfo
**/.env
**/.env.*
!**/.env.example
+79
View File
@@ -0,0 +1,79 @@
name: Unified application CI and deployment
on:
pull_request:
branches: [main]
paths:
- 'apps/waterfall-wiki/**'
- 'packages/brand/**'
- 'scripts/**'
- 'Dockerfile'
- '.dockerignore'
- 'compose.yml'
- 'pnpm-workspace.yaml'
- '.gitea/workflows/app.yml'
push:
branches: ["**"]
paths:
- 'apps/waterfall-wiki/**'
- 'packages/brand/**'
- 'scripts/**'
- 'Dockerfile'
- '.dockerignore'
- 'compose.yml'
- 'pnpm-workspace.yaml'
- '.gitea/workflows/app.yml'
concurrency:
group: unified-app-${{ gitea.event.pull_request.number || gitea.ref }}
cancel-in-progress: false
jobs:
static-checks:
runs-on: ces-control
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 2
- name: Check patch, syntax and source contracts without building
run: |
git diff --check HEAD^ HEAD
for script in scripts/*.sh scripts/preview/*.sh; do bash -n "$script"; done
node scripts/check-studio-contracts.mjs
node --check scripts/preview/fingerprint.mjs
node --check scripts/verify-studio-visual.cjs
node --check apps/waterfall-wiki/scripts/infinite-canvas-smoke.cjs
node --check apps/waterfall-wiki/scripts/cleanup-ci-bucket.mjs
python3 -c 'import ast,pathlib; ast.parse(pathlib.Path("scripts/wiki-image-lifecycle.py").read_text())'
verify-and-release:
needs: static-checks
if: gitea.event_name == 'push' && gitea.ref == 'refs/heads/main'
runs-on: ces-control
timeout-minutes: 60
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Build and verify the unified application once
run: |
docker build --target integration -t "waterfall-wiki-tests:${{ gitea.sha }}" .
docker build --target runtime -t "waterfall-wiki:${{ gitea.sha }}" .
- name: Verify candidate using persistent test infrastructure
env:
WATERFALL_WIKI_IMAGE: waterfall-wiki:${{ gitea.sha }}
WATERFALL_WIKI_TEST_IMAGE: waterfall-wiki-tests:${{ gitea.sha }}
GITEA_RUN_ID: ${{ gitea.run_id }}
run: bash scripts/integration.sh
- name: Deploy unified Web and worker; verify production
if: gitea.event_name == 'push' && gitea.ref == 'refs/heads/main'
run: bash scripts/deploy.sh "${{ gitea.sha }}"
- name: Save production acceptance evidence
id: evidence
if: always() && gitea.event_name == 'push'
uses: actions/upload-artifact@v3
with:
name: unified-production-acceptance
path: artifacts/studio-acceptance/
if-no-files-found: ignore
retention-days: 7
- name: Remove uploaded local evidence
if: always() && steps.evidence.outcome == 'success'
run: rm -rf artifacts/studio-acceptance
-27
View File
@@ -1,27 +0,0 @@
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
@@ -1,80 +0,0 @@
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'
- 'tests/test_integration_cleanup.py'
- 'scripts/cleanup-review-images.sh'
- 'tests/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 tests/test_integration_cleanup.py
python3 tests/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
+32
View File
@@ -0,0 +1,32 @@
name: Remote development preview source sync
on:
push:
branches: ['**']
paths:
- 'apps/waterfall-wiki/**'
- 'packages/brand/**'
- 'pnpm-workspace.yaml'
- 'scripts/preview/**'
- '.gitea/workflows/preview.yml'
concurrency:
group: development-preview
cancel-in-progress: false
jobs:
sync-source:
# One preview slot follows one branch. Setup changes use an explicit refresh.
if: gitea.ref == format('refs/heads/{0}', vars.PREVIEW_BRANCH || 'codex/unified-web')
runs-on: ces-control
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Initialize once, otherwise synchronize files only
env:
SOURCE_SHA: ${{ gitea.sha }}
SOURCE_BRANCH: ${{ gitea.ref_name }}
run: |
if [ ! -f /opt/prompt-preview/ready ]; then
bash scripts/preview/init.sh "$PWD" waterfall-wiki-tests:6654a1456bdcef77d83ca84373dc36188e15bd12
fi
bash scripts/preview/sync.sh "$PWD" "$SOURCE_SHA" "$SOURCE_BRANCH"
+22 -1
View File
@@ -1,14 +1,35 @@
name: python CI and deployment
on:
push:
pull_request:
branches: [main]
paths:
- '.dockerignore'
- 'apps/python/**'
- 'scripts/deploy-python.sh'
- '.gitea/workflows/python.yml'
push:
branches: ["**"]
paths:
- '.dockerignore'
- 'apps/python/**'
- 'scripts/deploy-python.sh'
- '.gitea/workflows/python.yml'
jobs:
static-checks:
runs-on: ces-control
timeout-minutes: 5
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 2
- name: Check Python and deployment syntax without executing tests
run: |
git diff --check HEAD^ HEAD
bash -n scripts/deploy-python.sh
python3 -c 'import ast,pathlib; [ast.parse(p.read_text(),filename=str(p)) for p in pathlib.Path("apps/python").rglob("*.py")]'
verify-and-deploy:
needs: static-checks
if: gitea.ref == 'refs/heads/main' && gitea.event_name == 'push'
runs-on: ces-control
timeout-minutes: 45
-71
View File
@@ -1,71 +0,0 @@
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'
- 'tests/test_integration_cleanup.py'
- 'pnpm-workspace.yaml'
jobs:
verify-and-deploy:
if: gitea.ref == 'refs/heads/main' && gitea.event_name == 'push'
runs-on: ces-control
timeout-minutes: 60
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Check shared content and documentation contracts
run: |
python3 tests/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:
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
+10 -8
View File
@@ -8,14 +8,16 @@ This block is written and re-added by `next dev` — verify at `node_modules/nex
<!-- END:nextjs-agent-rules -->
## 构建与验收
## 交付与视觉验收
- 在 ces-control 上执行构建、产物生成和需要编译的检查。本地可执行差异审查及无需构建的轻量检查。
- 页面改动经主线部署后,在指定的 [服务器入口](http://8.211.154.134:18131/) 验收。不要启动本地网页预览。
- CI 触发条件与发布路径见 [构建与入口](docs/ci-and-entrypoints.md)。
- 用户指定服务器为唯一运行与验收入口:http://8.211.154.134:18131/。
- 页面改动必须先 push,等待 ces-control 的 CI/CD 成功部署,再直接访问服务器检查效果。
- 不启动本地网页预览,不使用 localhost / 127.0.0.1 页面进行视觉验收,也不把本地预览链接交付给用户。
- 类型检查、单元测试等不提供网页预览的检查可以本地执行;完整运行检查由 Docker CI 完成。
## 品牌
## 啡跃 Endoleap 品牌与主题
- 修改品牌色、主题或 Logo 前,查阅 [共享品牌包](packages/brand/README.md)。
- 使用 home-light、home-dark、canvas-light、canvas-dark 四套主题。画布背景保持中性近白或近黑,用户素材保持原色。
- 色板和 Logo 轮廓分别以品牌包的 `tokens/palette.json` 和 `tokens/geometry.json` 为准,更新时保持 CSS、类型、资产和清单一致。
- 共享品牌包位于 [`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 执行,不在本机启动预览或构建。
+7 -2
View File
@@ -12,16 +12,21 @@ 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 /repo/pnpm-workspace.yaml
COPY packages/brand/ /repo/packages/brand/
# Download cache depends on lockfile and workspace policy, not package scripts.
RUN pnpm fetch --lockfile-dir=/repo/apps/waterfall-wiki --fetch-timeout=300000 --network-concurrency=8
COPY packages/brand/package.json /repo/packages/brand/package.json
COPY apps/waterfall-wiki/package.json ./
RUN pnpm --filter @prompt/waterfall-wiki install --frozen-lockfile --fetch-timeout=300000 --network-concurrency=8
RUN pnpm --filter @prompt/waterfall-wiki install --offline --frozen-lockfile
FROM dependencies AS build
# .dockerignore excludes docs and operational notes without extra legacy COPY snapshots.
# Brand source changes invalidate compilation, not dependency installation.
COPY packages/brand/ /repo/packages/brand/
COPY apps/waterfall-wiki/ .
RUN pnpm typecheck && pnpm test && pnpm build && pnpm test:smoke
FROM build AS integration
ENV VERIFY_CANVAS_UI=1
USER node
CMD ["npm", "run", "test:integration"]
+89 -27
View File
@@ -1,42 +1,104 @@
# 啡跃 Endoleap
# PROMPT
啡跃把笔记、参考素材和创作工作台放在同一个网站。知识库用于浏览、搜索和发布内容,Studio 提供图文排版与无限画布。
把提示词、参考图、分步文档和创作工具收进同一个可连接的知识库。人可以浏览瀑布流、搜索和编辑;AI 可以从同一篇 post 读取纯文本、图片与 API 说明,再调用 Studio 生成作品。
[打开啡跃](http://8.211.154.134:18131/)
**PROMPT 是网站与项目品牌;`waterfall-wiki` 是知识库应用的内部名称。** 目录、包、CI、镜像和 Compose 服务使用内部名称,网站导航与页面标题使用 PROMPT。
| 入口 | 用途 |
线上入口:[PROMPT](http://8.211.154.134:18131/)。内容、媒体、API 与 Studio 共用登录入口。
## 从一篇 post 开始
每篇内容都有稳定的 `/explore/<id>` 地址。标题、标签、正文和封面可以修改,链接保持不变。数据库内容可以保留 24 位 public ID,内部仍使用 UUID;旧 `/p/<slug>`、`/skills/...` 等地址保留兼容跳转。
- **浏览**:首页按不同笔记的反向引用数优先排序。重复引用和自引用不重复计数,代码、图片与外站链接不冒充笔记引用。详情可以查看引用来源。
- **搜索**:标题、简介、正文、普通标签和系统标签共同匹配。支持多个关键词及双引号短语,先按相关性、再按引用数和时间排序;先筛选再分页。
- **标签**:普通标签使用 `#中国风`、`#贴纸` 等名称,可以创建、添加或移除。系统标签是固定列表,目前为“文档入口”和“参考图”,只能搜索并勾选,不能通过 `#` 创建。系统标签显示在图片角标,普通标签显示在文字区;分类页顶部按系统标签筛选。
- **编辑**:卡片与详情都能直接编辑标签;完整编辑器可以修改标题、简介、Markdown。保存使用 revision 检测并发冲突,避免旧窗口覆盖新修改。
- **素材**:原件和加工结果都是普通资产,可被多篇 post 引用。文档封面与实际参考素材分开,子文档可以有自己的封面,同时保留原参考图。
## 给 AI 的入口
在同一个 post 地址加 `?agent_mode=True` 可以读取纯文本正文和媒体链接。业务 API 的相同参数会先返回接口说明;已有查询参数时使用 `&agent_mode=True`。这些入口同样需要登录或有效 Bearer JWT。
| 地址 | 用途 |
| --- | --- |
| `/` | 浏览内容,打开 `/explore/<id>` 查看单篇笔记 |
| `/guide` | 浏览指导文档 |
| `/graphic` | 打开图文工作台 |
| `/infinite_canvas` | 打开无限画布 |
| `/api?agent_mode=True` | 查看业务 API 目录 |
| `/explore/<id>` | 人看的 post;加 `?agent_mode=True` 读纯文本 |
| `/api?agent_mode=True` | 业务 API 目录 |
| `/api/explore-feed` | 统一搜索、系统/普通标签筛选与分页 |
| `/api/notes/<id>` | GET 读取;PUT 编辑全文;PATCH 仅更新标签 |
| `/api/tags` | GET 查看普通/系统标签;POST 创建或复用普通标签 |
| `/api/upload` | 上传媒体;`assetOnly=true` 独立保存,不创建 post |
| `/api/posts` | 用正文和已有资产创建普通 post |
| `/api/media/<id>` | 媒体原件与衍生文件,支持 Range |
| `/api/preview` | 有尺寸上限的 WebP 预览,不接受任意远程 URL |
自动化客户端可以在支持的页面或业务接口上加 `?agent_mode=True` 获取纯文本。读写权限与认证要求见对应接口说明。
标签 PATCH 请求为 `{ "tags": ["标签 UUID"], "revision": "GET 返回的 revision" }`。它不会要求用户重输正文。Tag 对象的 `kind` 为 `normal` 或 `system`;系统标签还有稳定的 `system_key`。普通创建接口不允许提交 `kind` 或 `system_key`。
Wiki 中的教程是普通 post 与媒体数据,不需要为每份资料增加专用网页路由、JSON 文件端点或代码注册。
## Studio:从教程进入工具
Studio 负责交互式排版与无状态渲染,知识库负责教程、来源和入口。`/studio` 返回 PROMPT 首页;请从对应 post 打开具体工具。
27 套排版工具:`/studio/layouts/ref-01` 至 `/studio/layouts/ref-27`,每套对应原图 T/I/D 构件。目录与参数 API 为 `/studio/api/layouts` 和 `/studio/api/layouts/<id>`;POST 参数生成 SVG。效果通过 `/api/studio/designs` 存入 Wiki 现有 PostgreSQL,返回独立 ID,`/studio/designs/<id>` 可再次打开;PATCH 携带 revision 更新同一效果。Studio 不需要数据库连接或第二套数据库。详见 [27 套排版 API 教程](content/studio-tools/layouts-api.md)。
其他试版工具:
- `/studio/ideas/learning-skills-template`:ref-17 同一母版的四张占位封面。
- `/studio/ideas/layout-selection`:ref-01、ref-07、ref-26 三种构图;可修改文字与配色,核对构件边界。
- `/studio/api/export/<slug>`:GET 读取方案或初始 SVG;POST 校验自定义参数并生成 SVG/JSON。详细说明读取该地址的 `?agent_mode=True`。
渲染不自动保存作品,也不自动发布 post。需要保留的结果应下载后再通过普通媒体和 post API 发布。原图的 T/I/D 构件、画板比例与来源保留为可读取数据;文字替换和矢量重建的范围应在教程中明确。
## 仓库结构
| 路径 | 职责 |
| --- | --- |
| `apps/waterfall-wiki` | Next.js Pages Router 主站、账户、内容和媒体 API,以及后台媒体处理 |
| `apps/studio` | Next.js Pages Router 创作工作台及渲染 API,由主站提供统一入口 |
| `apps/python` | 按需运行的 PNG 和视频导出工具 |
| `packages/brand` | 共享主题、品牌资产及接入规范 |
| `content` | 排版源数据、发布教程和示例,供作者工具与契约检查读取 |
| `scripts` | 构建部署、运维与内容发布工具 |
| `tests` | 跨应用的仓库媒体与运维检查。应用自身的测试位于各自目录 |
| `apps/waterfall-wiki` | Next.js Pages Router 知识库、搜索、标签、媒体 API、认证与后台视频加工 |
| `apps/waterfall-wiki/src/studio` | 合并后的创作工具、排版渲染与编辑器模块 |
| `apps/python` | 按需运行的 PNG/视频导出 CLI,没有常驻网络服务 |
| `content` | 本地作者资料、教程及排版源图,不打入 Wiki 运行镜像 |
| `scripts` | CI 部署、运维、API 发布与作者工具 |
| `docs` | 数据模型、接口、认证与部署说明 |
## 验证与发布
两个 JavaScript 应用分别维护冻结的 pnpm 锁文件。根 Dockerfile 是 waterfall-wiki 的构建入口;应用目录的 Dockerfile 与它保持一致。
JavaScript 工作区使用 Node.js 24 和 pnpm 11.25.0。生产服务由 Docker Compose 编排,数据保存在 PostgreSQL 和 S3 兼容对象存储中。
## 环境与检查
需要编译或生成产物的验证在 ces-control 执行。发布工作流由匹配路径的 `main` 推送触发。页面验收使用部署后的指定服务器,详见 [AGENTS.md](AGENTS.md) 和 [构建与发布说明](docs/ci-and-entrypoints.md)。
运行环境:Node.js 24、pnpm 11.25.0、PostgreSQL 17、S3 兼容对象存储;视频处理使用 FFmpeg,部分导出使用 Chromium。Python 工具需要 Python 3.12+。
## 维护文档
```sh
pnpm install
pnpm --dir apps/waterfall-wiki typecheck
pnpm --dir apps/waterfall-wiki test
node scripts/check-studio-contracts.mjs
for script in scripts/*.sh; do bash -n "$script"; done
```
- [业务 API](docs/api.md)与[数据模型](docs/data-model.md)
- [动态码登录与运维](docs/dynamic-code-login.md)
- [指导文档入口](docs/guidance.md)与[发布协议](docs/linked-guidance/publishing.md)
- [对象存储](docs/s3-media.md)与[镜像保留策略](docs/wiki-image-retention.md)
- [Studio 排版 API 与示例](content/studio-tools/layouts-api.md)
- [共享品牌包](packages/brand/README.md)
完整集成测试只针对显式隔离的数据库、对象存储和生产容器运行,覆盖存储、视频加工、编辑冲突、真实搜索与引用、标签规则和登录保护。不要把生产存储配置拿来运行集成测试。
本项目以指定服务器为唯一网页运行与验收入口:先推送,再等待 ces-control CI/CD,最后直接检查服务器。不要启动本地网页预览,不用 localhost 页面作为视觉验收或交付入口。具体约定见 [AGENTS.md](AGENTS.md)。
## 部署与持久化
Web 应用由 `.gitea/workflows/app.yml` 统一验证与发布。Wiki 和 Studio 已合并成一个 Next.js 应用,一次编译、一个 Web 运行镜像,Web 与后台 worker 使用同一镜像。PostgreSQL/S3 基础服务长期运行,不在日常发布中重建。工作分支 push/PR 仅做轻量静态检查;只有 main push 才构建、运行测试并更新生产。完整步骤见 [统一部署流程](docs/unified-deployment.md)。
| 服务 | 入口/端口 |
| --- | --- |
| `waterfall-wiki` | 公网 `18131`,也是 Studio 的统一入口 |
| PostgreSQL | `127.0.0.1:18132` |
| 对象存储 | `127.0.0.1:18133` |
| 编辑器 | 与知识站共享 `18131`;原生路由 `/graphic`、`/infinite_canvas`,历史 `/studio` 页面与 API 保留兼容 |
| 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`。
部署在停止写入后备份数据库,再运行迁移和启动新版。失败时恢复兼容旧镜像的备份并回滚服务。生产不执行 `down -v`,也不清空对象文件。早期合集验证会遍历分页,不假定旧文章一定在第一页。
## 登录与自动化
共享验证器的 6 位 TOTP 动态码登录,JWT 保存在 HttpOnly Cookie 中;这属于单因素动态码登录,不是 2FA。密钥保存在服务器认证卷,不进入 Git、镜像或 HTTP 二维码接口。
服务器管理员在交互式 SSH 终端运行 `sudo /opt/prompt/auth qr` 绑定验证器。不要把二维码、动态密钥或 JWT 粘贴到聊天、日志和仓库。服务端自动化可使用 `node auth-cli.cjs run <脚本>`,在进程内为本机 API 请求附加短期凭据,不输出令牌。
更多说明:[登录与 SSH 绑定](docs/dynamic-code-login.md) · [API](docs/api.md) · [数据模型](docs/data-model.md) · [项目结构与部署](docs/monorepo.md)。
+16 -2
View File
@@ -1,8 +1,22 @@
FROM python:3.12-slim
FROM python:3.12-slim AS base
RUN apt-get update && apt-get install -y --no-install-recommends ffmpeg fonts-noto-cjk && rm -rf /var/lib/apt/lists/*
FROM base AS dependencies
ENV PATH="/opt/venv/bin:$PATH"
WORKDIR /app
RUN python -m venv /opt/venv
COPY apps/python/pyproject.toml ./
# Read the pinned requirements from the package metadata before copying source.
RUN python -c 'import subprocess, tomllib; p = tomllib.load(open("pyproject.toml", "rb")); subprocess.check_call(["python", "-m", "pip", "install", "--no-cache-dir", *p["build-system"]["requires"], *p["project"]["dependencies"]])'
FROM dependencies AS build
COPY apps/python/ .
RUN pip install --no-cache-dir . && python -m unittest discover -s tests -v
RUN pip install --no-cache-dir --no-deps --no-build-isolation . && python -m unittest discover -s tests -v
FROM base AS runtime
WORKDIR /app
ENV PATH="/opt/venv/bin:$PATH"
COPY --from=build /opt/venv /opt/venv
RUN useradd --create-home exporter
USER exporter
ENTRYPOINT ["prompt-export"]
-23
View File
@@ -1,23 +0,0 @@
FROM node:24-bookworm-slim AS build
WORKDIR /repo/apps/studio
RUN corepack enable && corepack prepare pnpm@11.25.0 --activate
COPY apps/studio/pnpm-lock.yaml ./
COPY pnpm-workspace.yaml /repo/pnpm-workspace.yaml
COPY packages/brand/ /repo/packages/brand/
COPY apps/studio/package.json ./
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 /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/apps/studio
ENV NODE_ENV=production HOSTNAME=0.0.0.0 PORT=3000
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"]
-7
View File
@@ -1,7 +0,0 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
import "./.next/types/root-params.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/pages/api-reference/config/typescript for more information.
-4
View File
@@ -1,4 +0,0 @@
import { resolve } from "node:path";
import type { NextConfig } from "next";
const config: NextConfig = { turbopack: { root: resolve(process.cwd(), "../..") }, basePath: "/studio", output: "standalone", outputFileTracingRoot: resolve(process.cwd(), "../.."), poweredByHeader: false };
export default config;
-29
View File
@@ -1,29 +0,0 @@
{
"name": "@prompt/studio",
"private": true,
"scripts": {
"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",
"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",
"@endoleap/brand": "workspace:*"
},
"devDependencies": {
"@tailwindcss/postcss": "4.3.3",
"@types/node": "24.19.2",
"@types/react": "19.3.0",
"@types/react-dom": "19.3.0",
"tailwindcss": "4.3.3",
"typescript": "5.9.3"
},
"packageManager": "pnpm@11.25.0"
}
-1033
View File
File diff suppressed because it is too large Load Diff
-3
View File
@@ -1,3 +0,0 @@
export default {
plugins: { "@tailwindcss/postcss": {} },
};
-78
View File
@@ -1,78 +0,0 @@
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']}};
}
-32
View File
@@ -1,32 +0,0 @@
export type Slot={id:string;kind:string;role:string;box:number[];direction:string;text:string|null;reading:string};
export type Layout={id:string;name:string;width:number;height:number;elements:Slot[]};
export type TextValue={value:string;color?:string;scale?:number;align?:'start'|'middle'|'end';fontFamily?:'sans'|'serif'|'mono';fontSize?:number;fontWeight?:number;lineHeight?:number;letterSpacing?:number};
export type ImageValue={assetId?:string;color?:string;fit?:'cover'|'contain';cropX?:number;cropY?:number;zoom?:number};
export type DecorationValue={color?:string;hidden?:boolean};
export type LayerValue={x?:number;y?:number;width?:number;height?:number;rotation?:number;opacity?:number;radius?:number;z?:number;hidden?:boolean;locked?:boolean};
export type AddedSlot=Slot & {shape?:'rectangle'|'ellipse'};
export type Parameters={texts:Record<string,TextValue>;images:Record<string,ImageValue>;decorations:Record<string,DecorationValue>;paper?:string;layers?:Record<string,LayerValue>;added?:AddedSlot[]};
export class LayoutParameterError extends Error{}
export const uuid=(v:unknown):v is string=>typeof v==='string'&&/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(v);
function fail(message:string):never{throw new LayoutParameterError(message)}
function object(v:unknown):Record<string,unknown>{if(!v||typeof v!=='object'||Array.isArray(v))fail('参数必须为 JSON 对象');return v as Record<string,unknown>}
function keys(v:Record<string,unknown>,allowed:string[]){if(Object.keys(v).some(k=>!allowed.includes(k)))fail('未知参数或构件 ID')}
function number(v:unknown,min:number,max:number){if(typeof v!=='number'||!Number.isFinite(v)||v<min||v>max)fail(`数值范围 ${min}–${max}`);return v}
function color(v:unknown){if(typeof v!=='string'||!/^#[a-f0-9]{6}$/i.test(v))fail('颜色使用 #RRGGBB');return v}
export function parameters(layout:Layout,input:unknown):Parameters{
const raw=object(input);keys(raw,['texts','images','decorations','paper','layers','added']);const result:Parameters={texts:{},images:{},decorations:{}};
const added:AddedSlot[]=[];
if(raw.added!==undefined){if(!Array.isArray(raw.added)||raw.added.length>100)fail('最多新增100个元素');for(const entry of raw.added){const v=object(entry);keys(v,['id','kind','role','box','direction','text','reading','shape']);if(typeof v.id!=='string'||!/^user-[a-zA-Z0-9-]{1,60}$/.test(v.id)||added.some(s=>s.id===v.id))fail('新增元素 ID 无效或重复');if(!['text','image','decoration'].includes(String(v.kind)))fail('新增元素类型无效');if(typeof v.role!=='string'||v.role.length>80)fail('图层名称最多80字');if(!Array.isArray(v.box)||v.box.length!==4)fail('box 为 x,y,width,height');const box=v.box.map((n,i)=>number(n,i<2?-10000:.1,10000));if(v.direction!=='horizontal'||v.reading!=='custom'||(v.text!==null&&(typeof v.text!=='string'||v.text.length>500)))fail('新增元素内容无效');if(v.shape!==undefined&&!['rectangle','ellipse'].includes(String(v.shape)))fail('无效形状');added.push({id:v.id,kind:String(v.kind),role:v.role,box,direction:'horizontal',text:v.text as string|null,reading:'custom',...(v.shape?{shape:v.shape as AddedSlot['shape']}:{})})}result.added=added}
const elements=[...layout.elements,...added];
if(raw.layers!==undefined){const entries=object(raw.layers);keys(entries,elements.map(e=>e.id));result.layers={};for(const [id,entry] of Object.entries(entries)){const v=object(entry);keys(v,['x','y','width','height','rotation','opacity','radius','z','hidden','locked']);const layer:Record<string,number|boolean>={};for(const [key,value] of Object.entries(v)){if(key==='hidden'||key==='locked'){if(typeof value!=='boolean')fail('图层开关必须为布尔值');layer[key]=value}else{const range:Record<string,number[]>={x:[-10000,10000],y:[-10000,10000],width:[.1,10000],height:[.1,10000],rotation:[-360,360],opacity:[0,1],radius:[0,5000],z:[-1000,1000]};layer[key]=number(value,range[key][0],range[key][1])}}result.layers[id]=layer}}
if(raw.paper!==undefined)result.paper=color(raw.paper);
for(const [group,kind] of [['texts','text'],['images','image'],['decorations','decoration']] as const){
const entries=object(raw[group]??{});keys(entries,elements.filter(e=>e.kind===kind).map(e=>e.id));
for(const [id,value] of Object.entries(entries)){const v=object(value);
if(group==='texts'){keys(v,['value','color','scale','align','fontFamily','fontSize','fontWeight','lineHeight','letterSpacing']);if(typeof v.value!=='string'||Array.from(v.value).length>500||v.value.split('\n').length>8)fail('文字最多 500 字 / 8 行');const t:TextValue={value:v.value};if(v.color!==undefined)t.color=color(v.color);if(v.scale!==undefined){if(typeof v.scale!=='number'||!Number.isFinite(v.scale)||v.scale<.25||v.scale>2)fail('scale 范围 0.25–2');t.scale=v.scale}if(v.align!==undefined){if(!['start','middle','end'].includes(String(v.align)))fail('无效 align');t.align=v.align as TextValue['align']}if(v.fontFamily!==undefined){if(!['sans','serif','mono'].includes(String(v.fontFamily)))fail('无效字体');t.fontFamily=v.fontFamily as TextValue['fontFamily']}for(const [key,min,max] of [['fontSize',.1,1000],['fontWeight',100,900],['lineHeight',.5,4],['letterSpacing',-20,100]] as const)if(v[key]!==undefined)t[key]=number(v[key],min,max);result.texts[id]=t;
}else if(group==='images'){keys(v,['assetId','color','fit','cropX','cropY','zoom']);const image:ImageValue={};if(v.assetId!==undefined){if(!uuid(v.assetId))fail('assetId 必须为已上传图片 UUID');image.assetId=v.assetId}if(v.color!==undefined)image.color=color(v.color);if(v.fit!==undefined){if(v.fit!=='cover'&&v.fit!=='contain')fail('fit 使用 cover 或 contain');image.fit=v.fit}for(const [key,min,max] of [['cropX',0,100],['cropY',0,100],['zoom',1,5]] as const)if(v[key]!==undefined)image[key]=number(v[key],min,max);result.images[id]=image;
}else{keys(v,['color','hidden']);const d:DecorationValue={};if(v.color!==undefined)d.color=color(v.color);if(v.hidden!==undefined){if(typeof v.hidden!=='boolean')fail('hidden 必须为布尔值');d.hidden=v.hidden}result.decorations[id]=d}
}
}
return result;
}
-42
View File
@@ -1,42 +0,0 @@
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;
}
-8
View File
@@ -1,8 +0,0 @@
import { useEffect } from 'react';
import { useRouter } from 'next/router';
import type { AppProps } from "next/app";
import "../styles/globals.css";
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
@@ -1,3 +0,0 @@
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
View File
@@ -1,3 +0,0 @@
import type {GetServerSideProps} from 'next';
export default function StudioEntry(){return null}
export const getServerSideProps:GetServerSideProps=async()=>({redirect:{destination:'/graphic',permanent:false}});
-24
View File
@@ -1,24 +0,0 @@
/* 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)}
-21
View File
@@ -1,21 +0,0 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"baseUrl": ".",
"paths": { "@/*": ["./src/*"] }
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}
+7 -2
View File
@@ -10,16 +10,21 @@ 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 /repo/pnpm-workspace.yaml
COPY packages/brand/ /repo/packages/brand/
# Download cache depends on lockfile and workspace policy, not package scripts.
RUN pnpm fetch --lockfile-dir=/repo/apps/waterfall-wiki --fetch-timeout=300000 --network-concurrency=8
COPY packages/brand/package.json /repo/packages/brand/package.json
COPY apps/waterfall-wiki/package.json ./
RUN pnpm --filter @prompt/waterfall-wiki install --frozen-lockfile --fetch-timeout=300000 --network-concurrency=8
RUN pnpm --filter @prompt/waterfall-wiki install --offline --frozen-lockfile
FROM dependencies AS build
# .dockerignore excludes docs and operational notes without extra legacy COPY snapshots.
# Brand source changes invalidate compilation, not dependency installation.
COPY packages/brand/ /repo/packages/brand/
COPY apps/waterfall-wiki/ .
RUN pnpm typecheck && pnpm test && pnpm build && pnpm test:smoke
FROM build AS integration
ENV VERIFY_CANVAS_UI=1
USER node
CMD ["npm", "run", "test:integration"]
+4 -3
View File
@@ -12,9 +12,10 @@ const nextConfig: NextConfig = {
]},
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:"/studio/api/:path*",destination:"/api/studio-ui/:path*"},
{source:"/studio/brand/:path*",destination:"/brand/:path*"},
{source:"/studio/favicon.ico",destination:"/favicon.ico"},
{source:"/studio/apple-touch-icon.png",destination:"/apple-touch-icon.png"},
{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+"},
+2 -2
View File
@@ -10,8 +10,8 @@
"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 tests/account.test.ts tests/guidance.test.ts tests/guidance-publishing.test.ts",
"test:smoke": "node scripts/smoke.mjs",
"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 && node --test tests/studio/*.test.mjs",
"test:smoke": "node scripts/smoke.mjs && node scripts/studio-smoke.mjs",
"storage:prepare": "tsx scripts/bootstrap.ts",
"content:publish-learning": "tsx scripts/publish-learning-series.ts",
"worker": "tsx src/worker.ts",
@@ -0,0 +1,18 @@
// Only invoked inside the dedicated test network with this run's bucket name.
import {S3Client,ListObjectsV2Command,DeleteObjectsCommand,DeleteBucketCommand} from '@aws-sdk/client-s3';
const bucket=process.env.S3_BUCKET;
if (!/^ci-[1-9][0-9]*$/.test(bucket || '')) throw Error('Refusing non-CI bucket');
const client=new S3Client({endpoint:'http://objects:9000',region:'us-east-1',forcePathStyle:true,credentials:{accessKeyId:process.env.S3_ACCESS_KEY,secretAccessKey:process.env.S3_SECRET_KEY}});
try {
// Re-list the first page after each deletion so no pagination token can skip keys.
for (;;) {
const result=await client.send(new ListObjectsV2Command({Bucket:bucket,MaxKeys:1000}));
const keys=(result.Contents || []).map(({Key})=>({Key}));
if (!keys.length) break;
const deleted=await client.send(new DeleteObjectsCommand({Bucket:bucket,Delete:{Objects:keys,Quiet:true}}));
if (deleted.Errors?.length) throw Error('CI object cleanup failed');
}
await client.send(new DeleteBucketCommand({Bucket:bucket}));
} catch (error) {
if (error.name !== 'NoSuchBucket') throw error;
}
@@ -9,8 +9,8 @@ const assetId='00000000-0000-4000-8000-000000000005';doc.sources=[{assetId,autho
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,[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);
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/waterfall-wiki/server.js'],{env:{...process.env,PORT:String(port),HOSTNAME:'127.0.0.1',AUTH_DISABLED_FOR_TESTS:'true',AUTH_CONFIG_FILE:'',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'),'/workspace');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'};
@@ -1,7 +1,8 @@
import { randomBytes, scrypt as derive, timingSafeEqual, createHash } from 'node:crypto';
import { promisify } from 'node:util';
const scrypt = promisify(derive);
export const ACCOUNT_COOKIE = 'wiki_account';
export const ACCOUNT_COOKIE = process.env.ACCOUNT_COOKIE_NAME || 'wiki_account';
if (!/^[A-Za-z0-9_-]{1,64}$/.test(ACCOUNT_COOKIE)) throw Error('Invalid ACCOUNT_COOKIE_NAME');
export const SESSION_TTL = 7 * 86400;
export function normalizeEmail(value: unknown) {
if (typeof value !== 'string') return null;
+2 -1
View File
@@ -1,7 +1,8 @@
import { createHash, createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
import { readFileSync } from 'node:fs';
export const SESSION_COOKIE = 'prompt_session';
export const SESSION_COOKIE = process.env.AUTH_COOKIE_NAME || 'prompt_session';
if (!/^[A-Za-z0-9_-]{1,64}$/.test(SESSION_COOKIE)) throw Error('Invalid AUTH_COOKIE_NAME');
export const SESSION_SECONDS = 7 * 24 * 60 * 60;
export type AuthConfig = { secret: string; jwtKey: string };
export function authConfig(): AuthConfig | null {
+4 -2
View File
@@ -5,9 +5,11 @@ import "@/styles/globals.css";
import "@/styles/skill-docs.css";
import "@/styles/learning-series.css";
import "@/styles/brand.css";
import "@/studio/styles/globals.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} />;
useEffect(() => { const scope = /^\/(?:studio\/)?(?: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]);
const studio = /^\/(?:studio(?:\/|$)|graphic(?:\/|$)|infinite_canvas(?:\/|$))/.test(router.pathname);
return studio ? <div className="studio-app"><Component {...pageProps} /></div> : <Component {...pageProps} />;
}
@@ -0,0 +1,14 @@
import { readFile } from 'node:fs/promises';
import type { NextApiRequest, NextApiResponse } from 'next';
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (process.env.PREVIEW_MODE !== 'true') return res.status(404).end();
if (req.method !== 'GET' && req.method !== 'HEAD') return res.status(405).end();
res.setHeader('Cache-Control', 'no-store');
try {
const status = JSON.parse(await readFile('/preview-status/status.json', 'utf8'));
return req.method === 'HEAD' ? res.status(200).end() : res.status(200).json({ ...status, mode: 'development' });
} catch {
return res.status(503).json({ error: 'Preview source has not synchronized yet' });
}
}
@@ -1,11 +1,11 @@
import {embedSvgMedia} from '../../../lib/layout-images';
import {embedSvgMedia} from '../../../../studio/lib/layout-images';
import {renderToStaticMarkup} from 'react-dom/server';
import {serveApiDocumentation} from '../../../lib/api-documentation';
import {serveApiDocumentation} from '../../../../studio/lib/api-documentation';
import type {NextApiRequest,NextApiResponse} from 'next';
import {ideas,ideaMarkdown} from '../../../content/ideas';
import {selections} from '../../../components/SourceCover';
import {renderCover} from '../../../lib/render-cover';
import {tutorialPost} from '../../../content/tool-links';
import {ideas,ideaMarkdown} from '../../../../studio/content/ideas';
import {selections} from '../../../../studio/components/SourceCover';
import {renderCover} from '../../../../studio/lib/render-cover';
import {tutorialPost} from '../../../../studio/content/tool-links';
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();}
@@ -1,3 +1,3 @@
import {serveApiDocumentation} from '../../lib/api-documentation';
import {serveApiDocumentation} from '../../../studio/lib/api-documentation';
import type {NextApiRequest,NextApiResponse} from "next";
export default function handler(req:NextApiRequest,res:NextApiResponse){if(serveApiDocumentation(req,res,'/studio/api/health'))return;res.setHeader("Cache-Control","no-store");if(!["GET","HEAD"].includes(req.method||""))return res.status(405).end();return req.method==="HEAD"?res.status(200).end():res.status(200).json({status:"ok",service:"prompt-studio"});}
@@ -1,3 +1,3 @@
import type {NextApiRequest,NextApiResponse} from 'next';
import {ideas} from '../../content/ideas';
import {ideas} from '../../../studio/content/ideas';
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'));}
@@ -1,12 +1,12 @@
import {embedSvgMedia} from '../../../lib/layout-images';
import {embedSvgMedia} from '../../../../studio/lib/layout-images';
import type {NextApiRequest,NextApiResponse} from 'next';
import {embedLayoutImages} from '../../../lib/layout-images';
import {embedLayoutImages} from '../../../../studio/lib/layout-images';
import {createElement} from 'react';
import {renderToStaticMarkup} from 'react-dom/server';
import layouts from '../../../content/templates/layouts.json';
import standardLayout from '../../../content/standard-layout.json';
import {LayoutCanvas} from '../../../components/LayoutCanvas';
import {parameters,LayoutParameterError} from '../../../lib/layout-parameters';
import layouts from '../../../../studio/content/templates/layouts.json';
import standardLayout from '../../../../studio/content/standard-layout.json';
import {LayoutCanvas} from '../../../../studio/components/LayoutCanvas';
import {parameters,LayoutParameterError} from '../../../../studio/lib/layout-parameters';
const workspaceSchema={
added:'可选数组,最多100项:{id:"user-唯一ID",kind:"text|image|decoration",role:"名称",box:[x,y,width,height],direction:"horizontal",text:"文字或null",reading:"custom",shape:"rectangle|ellipse(装饰可选)"}',
layers:'按原生或新增元素ID索引:x/y -10000..10000,width/height 0.1..10000,rotation -360..360,opacity 0..1,radius 0..5000,z -1000..1000,hidden/locked 布尔值;坐标和字号均为模板单位',
@@ -1,6 +1,6 @@
import type {NextApiRequest,NextApiResponse} from 'next';
import fixed from '../../../content/templates/layouts.json';
import standard from '../../../content/standard-layout.json';
import fixed from '../../../../studio/content/templates/layouts.json';
import standard from '../../../../studio/content/standard-layout.json';
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}));
@@ -1,11 +1,11 @@
import { ThemeToggle } from "../../components/ThemeToggle";
import { Brand } from "../../components/Brand";
import { ThemeToggle } from "../../studio/components/ThemeToggle";
import { Brand } from "../../studio/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';
import {newElementId} from '../../studio/lib/native-layout';
import styles from '../../studio/styles/graphic.module.css';
import {parseGraphicDocument,type GraphicItem as Item,type GraphicDocument as Doc} from '../../studio/lib/graphic-document';
const blank:Doc={name:'未命名设计',background:'#ffffff',items:[]};
const storage='studio.graphic.v1';
export default function Graphic(){
@@ -1,13 +1,13 @@
import { ThemeToggle } from "../../components/ThemeToggle";
import { Brand } from "../../components/Brand";
import { ThemeToggle } from "../../studio/components/ThemeToggle";
import { Brand } from "../../studio/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';
import s from '../../studio/styles/infinite-canvas.module.css';
import CanvasNodeView from '../../studio/components/canvas/CanvasNodeView';
import CanvasComposer from '../../studio/components/canvas/CanvasComposer';
import CanvasAgentPanel,{type CanvasAgentHandle} from '../../studio/components/canvas/CanvasAgentPanel';
import {parseCanvasDocument,deriveNode,connectNodes,removeCanvasNode,referencedNodes,agentTask,type CanvasNode as Card,type CanvasDocument as Doc} from '../../studio/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';
@@ -1,11 +1,11 @@
import type {GetServerSideProps} from 'next';
import {readDesign} from '../../lib/design-server';
import {designUrl} from '../../lib/native-layout';
import {readDesign} from '../../../studio/lib/design-server';
import {designUrl} from '../../../studio/lib/native-layout';
export default function DesignPage(){return null}
export const getServerSideProps:GetServerSideProps=async({params,req,res})=>{
res.setHeader('Cache-Control','private, no-store');
const design=await readDesign(params?.id,req);
if(!design)return {notFound:true};
// Next adds the configured /studio basePath to server redirects.
return {redirect:{destination:designUrl(design.layoutId,design.id).slice('/studio'.length),permanent:false}};
// The unified application has no basePath; keep the full historical URL.
return {redirect:{destination:designUrl(design.layoutId,design.id),permanent:false}};
};
@@ -1,10 +1,10 @@
import type {GetStaticPaths,GetStaticProps} from 'next';
import Head from 'next/head';
import Link from 'next/link';
import {LayoutSelection} from '../../components/LayoutSelection';
import {tutorialPost} from '../../content/tool-links';
import {TemplateTrial} from '../../components/TemplateTrial';
import {ideas,type Idea} from '../../content/ideas';
import {LayoutSelection} from '../../../studio/components/LayoutSelection';
import {tutorialPost} from '../../../studio/content/tool-links';
import {TemplateTrial} from '../../../studio/components/TemplateTrial';
import {ideas,type Idea} from '../../../studio/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}>← 啡跃使用教程</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}};
@@ -1,11 +1,11 @@
import type {GetServerSideProps} from 'next';
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';
import {NativeResult} from '../../../studio/components/NativeResult';
import type {Layout} from '../../../studio/lib/layout-parameters';
import fixedLayouts from '../../../studio/content/templates/layouts.json';
import standardLayout from '../../../studio/content/standard-layout.json';
const layouts=[...fixedLayouts,standardLayout];
import {nativeLayout,designUrl} from '../../lib/native-layout';
import {readDesign} from '../../lib/design-server';
import {nativeLayout,designUrl} from '../../../studio/lib/native-layout';
import {readDesign} from '../../../studio/lib/design-server';
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');
@@ -14,7 +14,7 @@ export const getServerSideProps:GetServerSideProps<{layout:Layout}>=async({param
if(query.id!==undefined){
const design=await readDesign(query.id,req);
if(!design||design.layoutId!==layout.id)return {notFound:true};
return {redirect:{destination:designUrl(design.layoutId,design.id).slice('/studio'.length),permanent:false}};
return {redirect:{destination:designUrl(design.layoutId,design.id),permanent:false}};
}
return {props:{layout:nativeLayout(layout)}};
};
@@ -1,11 +1,11 @@
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';
import {NativeSeries} from '../../studio/components/NativeSeries';
import {NativeResult} from '../../studio/components/NativeResult';
import fixedLayouts from '../../studio/content/templates/layouts.json';
import standardLayout from '../../studio/content/standard-layout.json';
import {readDesign} from '../../studio/lib/design-server';
import {nativeLayout,type Design} from '../../studio/lib/native-layout';
import type {Layout} from '../../studio/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}/>}
+1
View File
@@ -2,6 +2,7 @@ import { NextResponse, type NextRequest } from 'next/server';
import { authConfig, SESSION_COOKIE, validSession } from './lib/auth';
export function proxy(request: NextRequest) {
const path = request.nextUrl.pathname;
if (process.env.PREVIEW_MODE === 'true' && path === '/api/dev-preview' && ['GET','HEAD'].includes(request.method)) return NextResponse.next();
const publicBrandAssets = ['/favicon.ico','/apple-touch-icon.png','/brand/logo-light.svg','/brand/logo-dark.svg','/brand/icon-dark.svg','/studio/favicon.ico','/studio/apple-touch-icon.png','/studio/brand/logo-light.svg','/studio/brand/logo-dark.svg','/studio/brand/icon-dark.svg'];
if (['GET','HEAD'].includes(request.method) && publicBrandAssets.includes(path)) return NextResponse.next({headers:{'Referrer-Policy':'same-origin'}});
const responseHeaders = {'Cache-Control':'private, no-store', 'Referrer-Policy':'same-origin'};
@@ -0,0 +1 @@
export * from '../../lib/canvas-document.ts';
@@ -0,0 +1 @@
export * from '../../lib/layout-parameters.ts';
@@ -0,0 +1 @@
export * from '../../lib/series.ts';
@@ -1,7 +1,8 @@
@import "tailwindcss";
@import "@endoleap/brand/themes.css";
@import "@endoleap/brand/tailwind.css";
*{box-sizing:border-box}body{margin:0;color:var(--foreground-strong);background:var(--selection);font-family:Arial,"PingFang SC",sans-serif}a{color:inherit;text-decoration:none}button,a{cursor:pointer}button{border:0;padding:12px 22px;border-radius:24px}.workspace{display:grid;grid-template-columns:240px 1fr;min-height:100vh}aside{padding:38px 26px;border-right:1px solid var(--border);background:var(--surface)}aside a{display:block;margin-top:30px}.logo{color:var(--primary)}section{padding:70px 6vw}h1{font-size:40px;letter-spacing:-1.5px}section>p,.hint{color:var(--muted-foreground)}.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(260px,340px));gap:24px;margin-top:45px}.card{background:var(--surface);padding:14px;border-radius:22px;border:1px solid var(--border)}.cover{background:var(--selection);aspect-ratio:4/5;border-radius:14px;padding:36px;display:flex;align-items:center;font-size:35px;font-weight:bold}.card h2{font-size:18px}.card p{line-height:1.8;color:var(--muted-foreground)}.card span{color:var(--primary)}.hint{margin-top:40px;font-size:13px}.editor{max-width:1200px;margin:auto;padding:30px}header{display:flex;align-items:center;gap:24px}header a:first-child{margin-right:auto}.slides{display:grid;grid-template-columns:repeat(3,1fr);gap:20px;margin-top:40px}.slide{aspect-ratio:3/4;padding:35px;border-radius:20px;display:flex;flex-direction:column}.slide h2{font-size:38px;margin-top:auto}.slide p{line-height:1.9}.slide footer{margin-top:auto;font-size:12px}@media(max-width:760px){.workspace{grid-template-columns:1fr}aside{border-bottom:1px solid var(--border)}aside a{display:inline-block;margin:20px 20px 0 0}section{padding:30px}.slides{grid-template-columns:1fr}header{flex-wrap:wrap}h1{font-size:30px}}@media print{header{display:none}.slides{display:block}.slide{width:180mm;height:240mm;break-after:page;print-color-adjust:exact;border-radius:0}.editor>h1,.editor>p{display:none}}
@scope (.studio-app) {
*{box-sizing:border-box}:scope{margin:0;color:var(--foreground-strong);background:var(--selection);font-family:Arial,"PingFang SC",sans-serif}a{color:inherit;text-decoration:none}button,a{cursor:pointer}button{border:0;padding:12px 22px;border-radius:24px}.workspace{display:grid;grid-template-columns:240px 1fr;min-height:100vh}aside{padding:38px 26px;border-right:1px solid var(--border);background:var(--surface)}aside a{display:block;margin-top:30px}.logo{color:var(--primary)}section{padding:70px 6vw}h1{font-size:40px;letter-spacing:-1.5px}section>p,.hint{color:var(--muted-foreground)}.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(260px,340px));gap:24px;margin-top:45px}.card{background:var(--surface);padding:14px;border-radius:22px;border:1px solid var(--border)}.cover{background:var(--selection);aspect-ratio:4/5;border-radius:14px;padding:36px;display:flex;align-items:center;font-size:35px;font-weight:bold}.card h2{font-size:18px}.card p{line-height:1.8;color:var(--muted-foreground)}.card span{color:var(--primary)}.hint{margin-top:40px;font-size:13px}.editor{max-width:1200px;margin:auto;padding:30px}header{display:flex;align-items:center;gap:24px}header a:first-child{margin-right:auto}.slides{display:grid;grid-template-columns:repeat(3,1fr);gap:20px;margin-top:40px}.slide{aspect-ratio:3/4;padding:35px;border-radius:20px;display:flex;flex-direction:column}.slide h2{font-size:38px;margin-top:auto}.slide p{line-height:1.9}.slide footer{margin-top:auto;font-size:12px}@media(max-width:760px){.workspace{grid-template-columns:1fr}aside{border-bottom:1px solid var(--border)}aside a{display:inline-block;margin:20px 20px 0 0}section{padding:30px}.slides{grid-template-columns:1fr}header{flex-wrap:wrap}h1{font-size:30px}}@media print{header{display:none}.slides{display:block}.slide{width:180mm;height:240mm;break-after:page;print-color-adjust:exact;border-radius:0}.editor>h1,.editor>p{display:none}}
/* The reference art stays inside its native 113:174 canvas. */
.cover-template{padding:0;border-radius:0;display:block;aspect-ratio:113/174;background:#f4f2eb}
@@ -20,10 +21,10 @@
.trial-actions{display:grid;gap:8px}.trial-actions button{padding:11px 8px;border:1px solid var(--border);border-radius:4px;font-size:12px;background:var(--surface);color:var(--foreground)}.trial-actions .trial-primary{background:var(--primary);color:var(--primary-foreground);border-color:var(--selection-border)}.trial-actions button:hover{filter:brightness(.94)}
.trial-help{font-size:11px;line-height:1.7;color:var(--muted-foreground);margin-top:16px}.trial-notice{font-size:11px;color:var(--success);min-height:16px}
.trial-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:28px}.proof-card{margin:0;min-width:0}.proof-select{display:block;width:100%;padding:0;border-radius:0;background:var(--selection);box-shadow:0 3px 14px var(--shadow);outline:1px solid var(--ring);outline-offset:0;transition:outline-color .15s}.proof-active .proof-select{outline:2px solid var(--ring);outline-offset:5px}.proof-select:focus-visible{outline:3px solid var(--ring);outline-offset:5px}.proof-card figcaption{display:flex;gap:10px;align-items:center;padding:16px 0 0;font-size:11px;flex-wrap:wrap}.proof-card figcaption b{font-weight:500}.proof-card figcaption span{color:var(--muted-foreground)}.proof-card figcaption a{margin-left:auto;text-decoration:underline;text-underline-offset:3px;color:var(--muted-foreground)}
.trial-reference{margin-top:36px;padding:20px 0;border-top:1px solid var(--border);font-size:12px}.trial-reference summary{cursor:pointer}.trial-reference-body{display:grid;grid-template-columns:150px minmax(0,1fr);gap:24px;margin-top:22px;line-height:1.8}.trial-reference img{width:150px;height:auto;align-self:start}.trial-reference table{width:100%;border-collapse:collapse;font-size:11px;margin:16px 0}.trial-reference th,.trial-reference td{text-align:left;border-bottom:1px solid var(--border);padding:6px}.trial-reference a{text-decoration:underline}
.trial-reference{margin-top:36px;padding:20px 0;border-top:1px solid var(--border);font-size:12px}.trial-reference summary{cursor:pointer}.trial-reference-:scope{display:grid;grid-template-columns:150px minmax(0,1fr);gap:24px;margin-top:22px;line-height:1.8}.trial-reference img{width:150px;height:auto;align-self:start}.trial-reference table{width:100%;border-collapse:collapse;font-size:11px;margin:16px 0}.trial-reference th,.trial-reference td{text-align:left;border-bottom:1px solid var(--border);padding:6px}.trial-reference a{text-decoration:underline}
@media(min-width:1300px){.trial-grid{gap:40px}.trial-workspace{gap:48px}}
@media(max-width:900px){.template-trial{padding:22px}.trial-workspace{grid-template-columns:220px minmax(0,1fr);gap:24px}.trial-grid{gap:20px}.trial-controls{padding:16px}.trial-source{display:none}.trial-intro h1{font-size:36px}.trial-nav span{display:none}}
@media(max-width:650px){.trial-workspace{grid-template-columns:1fr}.trial-controls{position:static}.trial-controls fieldset{display:grid;grid-template-columns:1fr 1fr;gap:12px}.trial-actions{grid-template-columns:1fr 1fr}.trial-reference-body{grid-template-columns:1fr}.trial-grid{gap:24px}.trial-intro{padding:32px 0}.trial-intro h1{font-size:30px}}
@media(max-width:650px){.trial-workspace{grid-template-columns:1fr}.trial-controls{position:static}.trial-controls fieldset{display:grid;grid-template-columns:1fr 1fr;gap:12px}.trial-actions{grid-template-columns:1fr 1fr}.trial-reference-:scope{grid-template-columns:1fr}.trial-grid{gap:24px}.trial-intro{padding:32px 0}.trial-intro h1{font-size:30px}}
@media(max-width:440px){.trial-grid{grid-template-columns:1fr}.trial-nav{gap:10px}.template-trial{padding:18px}}
@page ref17Proof{size:113mm 174mm;margin:0}
@media print{.template-trial{padding:0;max-width:none}.trial-nav,.trial-intro,.trial-controls,.trial-reference,.proof-card figcaption{display:none}.trial-workspace,.trial-grid{display:block}.proof-card{page:ref17Proof;break-after:page;width:113mm;height:174mm}.proof-card:last-child{break-after:auto}.proof-select,.proof-active .proof-select{outline:none;box-shadow:none;width:113mm;height:174mm}.proof-select svg{width:113mm!important;height:174mm!important;print-color-adjust:exact}.proof-select svg g[aria-hidden=true]{display:none}}
@@ -93,3 +94,5 @@
.native-series{padding-top:24px}.series-page{margin:0 auto 32px}.series-page-number{text-align:center;font-size:12px;letter-spacing:.14em;color:var(--muted-foreground);margin:14px 0 0}
.native-export{padding:0;background:transparent;min-height:0}.native-export .series-page{margin:0}.native-export .native-layout{width:1080px;max-width:none;margin:0}
@media print{.native-series{padding:0}.series-page{margin:0;break-after:page}.series-page:last-child{break-after:auto}.series-page-number{display:none}}
}
@@ -1,7 +1,7 @@
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';
import {parseCanvasDocument,deriveNode,connectNodes,removeCanvasNode,referencedNodes,agentTask} from '../../src/studio/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',()=>{
@@ -61,5 +61,5 @@ test('a free requested position is retained and large nodes near the coordinate
});
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'));
assert.equal(readFileSync(new URL('../../src/studio/lib/canvas-document.ts',import.meta.url),'utf8'),"export * from '../../lib/canvas-document.ts';\n");
});
@@ -1,6 +1,6 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {parseGraphicDocument} from '../src/lib/graphic-document.ts';
import {parseGraphicDocument} from '../../src/studio/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)});
@@ -1,6 +1,6 @@
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {embedSvgMedia} from '../src/lib/layout-images.ts';
import {embedSvgMedia} from '../../src/studio/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;
@@ -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)));
import {parameters} from '../../src/studio/lib/layout-parameters.ts';
const layouts=JSON.parse(readFileSync(new URL('../../src/studio/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,/^\/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 parameters round-trip identically through Studio and Wiki validation',async()=>{const {parameters:wiki}=await import('../../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))});
@@ -1,8 +1,8 @@
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {readFileSync} from 'node:fs';
import {nativeLayout,defaultText,textSize,imageShape,designUrl,newElementId} from '../src/lib/native-layout.ts';
const layouts=JSON.parse(readFileSync(new URL('../src/content/templates/layouts.json',import.meta.url)));
import {nativeLayout,defaultText,textSize,imageShape,designUrl,newElementId} from '../../src/studio/lib/native-layout.ts';
const layouts=JSON.parse(readFileSync(new URL('../../src/studio/content/templates/layouts.json',import.meta.url)));
test('all 27 native pages are independent of source images and supply actual text',()=>{
for(const original of layouts){const layout=nativeLayout(original),serialized=JSON.stringify(layout);assert.ok(!serialized.includes('data:image'));assert.ok(!serialized.includes('raster'));assert.ok(!serialized.includes('source'));assert.ok(serialized.length<7000);
for(const slot of layout.elements.filter(s=>s.kind==='text')){assert.ok(defaultText(layout,slot).length);for(const text of [defaultText(layout,slot),'','新的文字\n第二行','<script>plain text</script>']){const size=textSize(layout,slot,text);assert.ok(Number.isFinite(size)&&size>0)}}
@@ -3,10 +3,9 @@ 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)));
const {series,pageParameters}=await import('../../src/studio/lib/series.ts');
const layout=JSON.parse(readFileSync(new URL('../../src/studio/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)));
const input=JSON.parse(readFileSync(new URL('../../fixtures/studio/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')));
+20 -4
View File
@@ -1,7 +1,11 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["dom", "dom.iterable", "esnext"],
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
@@ -14,8 +18,20 @@
"jsx": "react-jsx",
"incremental": true,
"baseUrl": ".",
"paths": { "@/*": ["./src/*"] }
"paths": {
"@/*": [
"./src/*"
]
},
"allowImportingTsExtensions": true
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts"
],
"exclude": [
"node_modules"
]
}
-20
View File
@@ -1,20 +0,0 @@
# Added only by isolated CI integration; no production ports or volumes.
services:
prompt-studio:
image: ${STUDIO_REVIEW_IMAGE:?Build the Studio review image first}
init: true
environment:
HOSTNAME: 0.0.0.0
PORT: "3000"
STUDIO_WIKI_ORIGIN: http://waterfall-wiki:3000
security_opt:
- no-new-privileges:true
cap_drop: [ALL]
healthcheck:
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3000/studio/api/health').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))"]
interval: 5s
timeout: 8s
retries: 24
tests:
environment:
VERIFY_CANVAS_UI: "1"
+11
View File
@@ -0,0 +1,11 @@
# Studio 固定构图视觉审查
2026-10-28:先搜索互联网设计 skills,再检查源文件。采用 [Anthropic frontend-design 原始 SKILL.md](https://github.com/anthropics/claude-code/blob/main/plugins/frontend-design/skills/frontend-design/SKILL.md) 中与本任务适用的字体层级、基于内容选择配色、保留构图个性及逐版审视方法;不采用其自由重构产品布局的方向,因为本任务要求保留27套构图及现有工具箱。只阅读说明,没有安装依赖或执行第三方代码。
搜索结果中的第三方技能聚合页只用于发现,不作为实现依据。另检查了 Impeccable 候选源路径,但该路径返回404,未读取到可信内容,因此不采用,也不声称安装或使用了该技能。
实现把每套构图的纸色、文字、主图位、副图位组织成独立四色色谱。蓝灰用于横向英文刊头,暖赭用于出血椭圆与斜题,苔绿用于竖题与同心圆;没有把构图改为统一卡片。竖向中文使用宋体栈,英文大刊头保留衬线,模块式构图加强字重与字距,小字替换成适合槽位的简短可编辑内容。原始画板、元素ID、裁切轮廓与主要坐标保持不变。
可视化目录用原生文字和几何展示构图,页面本身继续由现有 LayoutWorkbench 原生渲染。颜色、字体和几何显式参数优先于模板默认值;模板默认外观会跟随版本美化,要求固定外观的调用者应传入显式参数。
验收应在部署完成后仅访问指定服务器,检查27套页面、移动宽度、保存ID恢复、字体/几何扩展、图片网络请求,以及旧工具功能。原生页面不能以旧SVG导出代替视觉验收。
+2 -2
View File
@@ -4,13 +4,13 @@
`ces-control` 必须预装 Docker、Compose,并允许 Runner 访问持久 Docker daemon。CI 只检查环境,不安装系统包、不启停主机服务。当前服务器没有 buildx,先继续使用 daemon 自带层缓存;不要直接粘贴依赖 GitHub cache 服务的 `type=gha` 参数。后续若换 Docker daemon 或清理缓存,会重新冷构建。
Dockerfile 保持单次 COPY,`.dockerignore` 排除非构建输入;根 Markdown 和 docs 不再使应用构建层失效。发布由 main 上匹配路径的 push 触发。creative-workspace.yml 另对匹配路径的 PR 执行远程评审检查,包括隔离集成与已部署页面验收。public 下的 Markdown 属于产品资源。public 下的技能 Markdown 继续纳入构建。旧 builder 每条 COPY 实测约 47 秒,避免拆成多条 COPY 增加快照成本。完整路径保留类型检查、单元测试、smoke 与完整隔离栈集成测试。
Dockerfile 保持单次 COPY,`.dockerignore` 排除非构建输入;根 Markdown 和 docs 不再使应用构建层失效。只有 main 的 push 事件触发 CI/CD(对应 PR 合并后的主线更新),工作分支 push、PR 创建/更新和手动 dispatch 均不触发;三个应用均有同样的作业级守卫。main 对纯文档改动使用 paths,但 public 下的 Markdown 属于产品资源,必须构建部署。public 下的技能 Markdown 继续纳入构建。旧 builder 每条 COPY 实测约 47 秒,避免拆成多条 COPY 增加快照成本。完整路径保留类型检查、单元测试、smoke 与完整隔离栈集成测试。
当前页面为 Next.js SSR 与编译组件,改 TSX 仍需 next build。Web 快速路径只编译生产 Web,并以不可变的版本目录挂载完整 standalone 产物,保持版本与回滚一致;不重新编译 worker 或导出运行镜像。
## Web 快速发布
main 的 push 与当前已部署 SHA 比较;只有页面、组件、样式、技能文档数据和 public 改动才走 Web 路径,API、共享库、依赖、配置、worker、脚本及数据库变化均回退完整流水线。本地只做差异审查与轻量检查,匹配路径的 PR 在 ces-control 执行远程检查;依赖构建镜像缺失也回退完整流水线。
main 的 push 与当前已部署 SHA 比较;只有页面、组件、样式、技能文档数据和 public 改动才走 Web 路径,API、共享库、依赖、配置、worker、脚本及数据库变化均回退完整流水线。PR 阶段只做不会触发编译的阅读、差异审查与轻量配置检查,完整远程验证在用户手动合并到 main 后执行;依赖构建镜像缺失也回退完整流水线。
Web 路径复用当前 runtime 对应的已验证 integration 镜像作为工具环境,在临时容器构建生产 Web,不运行 docker build。编译缓存按基础镜像隔离持久保存。类型检查、单元测试和 Web smoke 仍同步执行,完整隔离栈测试只在全量路径执行。
+23
View File
@@ -0,0 +1,23 @@
# Docker 构建上下文与缓存
## 入口与忽略规则
所有 CI 构建命令都在仓库根目录使用 `.` 作为 context,包括以 `-f` 选择的子项目 Dockerfile。统一使用根 `.dockerignore`,递归排除依赖目录、Git 元数据、本机环境文件、编译输出、测试证据及缓存;保留 `.env.example` 供 Compose 校验使用。
普通的子目录 `.dockerignore` 不会自动叠加到根 context。当前 runner 使用 legacy builder,不依赖 Dockerfile 专属忽略文件或 BuildKit cache mount。若以后改用子目录 context,必须同时调整共享包与跨应用文件的输入路径,不能只在子目录添加忽略文件。
## 依赖层与源码层
- Wiki 的根 Dockerfile 是实际 CI 入口,`apps/waterfall-wiki/Dockerfile` 是对应副本,两者的功能内容保持同步;根文件额外保留镜像生命周期标签。
- 统一 Web 应用先根据锁文件与 workspace 配置执行 `pnpm fetch`,再复制清单并 `pnpm install --offline --frozen-lockfile`。修改 scripts 或品牌源码不会使下载层失效;基础镜像、pnpm 版本、平台或配置变化,以及 Docker 缓存清除仍可能要求重新下载。
- Python 从 `pyproject.toml` 读取固定运行依赖与构建后端要求,在独立依赖阶段安装。源码阶段安装当前包并运行单测,最终镜像仅继承系统运行环境并复制已验证的虚拟环境,不携带应用源码目录和测试目录。虚拟环境仍包含 pip 和构建后端,未声称完全剥离构建工具。
- 品牌包只作为 workspace 依赖随 Web 应用构建,不提供独立 Dockerfile、镜像构建工作流或常驻服务。专用品牌检查已停止自动运行,保留测试脚本供明确需要时在远程执行。
- 不因共享包缓存命中而漏掉消费者:品牌变更需触发 统一应用 PR 检查和 main 发布。根忽略规则变化也触发相关检查。
## 验证与限制
构建、打包与产物验证只在已确认的 `ces-control` runner 执行。Web 与 Python 的工作分支 push/PR 仅运行轻量静态检查;类型检查、单元测试、编译、镜像构建和部署都只在 main push 执行。不再单独构建品牌检查镜像。
本次没有升级 Docker builder,也没有承诺消除 legacy builder 的镜像层提交耗时;缓存层调整后的实际收益需比较后续 CI 日志。源码变化仍需构建,当前采用持久测试基础设施;本次应用合并仍保留完整集成验证,按影响范围选择测试留作后续优化。
本机不新增 Dev Containers/Tilt 启动方式;这类方案需要单独确认本地运行和编译授权。
Binary file not shown.

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

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