Merge pull request '合并 Wiki 与 Studio,精简为单应用仓库' (#49) from codex/unify-and-prune into main

Reviewed-on: #49
Reviewed-by: Zayden-Jung <zayden.jung@entroia.com>
This commit was merged in pull request #49.
This commit is contained in:
2026-11-04 14:37:17 +00:00
219 changed files with 127 additions and 16313 deletions
-24
View File
@@ -1,24 +0,0 @@
.git
**/.next
**/node_modules
**/dist
coverage
.env
.env.*
!.env.example
*.tsbuildinfo
.DS_Store
.agents
.codex
# Documentation and CI metadata are not application build inputs.
/docs
/.gitea
/README.md
/AGENTS.md
**/__pycache__
**/.venv
# Local authoring assets are uploaded via APIs, never application build inputs.
/content
-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'
- 'scripts/test-integration-cleanup.py'
- 'scripts/cleanup-review-images.sh'
- 'scripts/test-review-image-cleanup.py'
concurrency:
group: creative-workspace-${{ gitea.event.pull_request.number }}
cancel-in-progress: true
jobs:
verify-deployed-canvas:
runs-on: ces-control
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- name: Verify production acceptance against the designated deployed server
run: |
node --check scripts/verify-studio-visual.cjs
node --check apps/studio/scripts/infinite-canvas-smoke.cjs
bash -n scripts/verify-studio-production.sh
bash scripts/verify-studio-production.sh
- name: Save server acceptance evidence
id: acceptance_evidence
if: always()
uses: actions/upload-artifact@v3
with:
name: studio-production-acceptance
path: artifacts/studio-acceptance/
if-no-files-found: ignore
retention-days: 7
- name: Remove temporary acceptance output after upload
if: always() && steps.acceptance_evidence.outcome == 'success'
run: rm -rf artifacts/studio-acceptance
verify:
runs-on: ces-control
timeout-minutes: 60
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Check patch formatting
run: |
git diff --check HEAD^ HEAD
bash -n scripts/integration.sh scripts/cleanup-integration.sh scripts/verify-studio-production.sh scripts/cleanup-review-images.sh
node --check scripts/verify-studio-visual.cjs
python3 scripts/test-integration-cleanup.py
python3 scripts/test-review-image-cleanup.py
node --test apps/waterfall-wiki/tests/brand-navigation.test.mjs
- name: Verify Studio types, tests, build and smoke
run: docker build -f apps/studio/Dockerfile -t "creative-studio-review:${{ gitea.sha }}" .
- name: Build Wiki integration and runtime images
run: |
docker build --target integration -t "creative-wiki-tests:${{ gitea.sha }}" .
docker build --target runtime -t "creative-wiki-review:${{ gitea.sha }}" .
- name: Verify isolated PostgreSQL, S3 and browser integration
env:
STUDIO_REVIEW_IMAGE: creative-studio-review:${{ gitea.sha }}
WATERFALL_WIKI_IMAGE: creative-wiki-review:${{ gitea.sha }}
WATERFALL_WIKI_TEST_IMAGE: creative-wiki-tests:${{ gitea.sha }}
GITEA_RUN_ID: ${{ gitea.run_id }}
run: bash scripts/integration.sh
- name: Clean this run disposable integration resources even after cancellation
if: always()
env:
GITEA_RUN_ID: ${{ gitea.run_id }}
run: bash scripts/cleanup-integration.sh
- name: Remove this run disposable review image tags
if: always()
env:
REVIEW_SHA: ${{ gitea.sha }}
run: bash scripts/cleanup-review-images.sh
-23
View File
@@ -1,23 +0,0 @@
name: python CI and deployment
on:
push:
branches: [main]
paths:
- 'apps/python/**'
- 'scripts/deploy-python.sh'
- '.gitea/workflows/python.yml'
jobs:
verify-and-deploy:
if: gitea.ref == 'refs/heads/main' && gitea.event_name == 'push'
runs-on: ces-control
timeout-minutes: 45
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Build and verify python
run: docker build -f apps/python/Dockerfile -t "prompt-python:${{ gitea.sha }}" .
- name: Release python
if: gitea.ref == 'refs/heads/main' && gitea.event_name == 'push'
run: bash scripts/deploy-python.sh "${{ gitea.sha }}"
-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'
- 'scripts/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 scripts/test-integration-cleanup.py
bash -n scripts/integration.sh scripts/cleanup-integration.sh
node scripts/check-studio-contracts.mjs
node --check scripts/export-studio-series.cjs
node --check scripts/save-studio-series.mjs
bash -n scripts/publish-studio-guide.sh scripts/deliver-studio-series.sh
- name: Build and verify Studio
run: docker build -f apps/studio/Dockerfile -t "prompt-studio:${{ gitea.sha }}" .
- name: Build, verify, deploy and retain wiki images
env:
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
@@ -1,19 +0,0 @@
name: Wiki image retention checks
on:
push:
branches: [main]
paths:
- 'scripts/wiki-image-lifecycle.py'
- 'tests/test_wiki_image_lifecycle.py'
- '.gitea/workflows/wiki-image-retention-checks.yml'
jobs:
retention-policy:
if: gitea.ref == 'refs/heads/main' && gitea.event_name == 'push'
runs-on: ces-control
timeout-minutes: 5
steps:
- uses: actions/checkout@v4
- name: Check retention without building or accessing the Docker daemon
run: |
bash -n scripts/deploy.sh
PYTHONDONTWRITEBYTECODE=1 python3 -m unittest discover -s tests -p 'test_wiki_image_lifecycle.py' -v
+3 -12
View File
@@ -8,16 +8,7 @@ This block is written and re-added by `next dev` — verify at `node_modules/nex
<!-- END:nextjs-agent-rules -->
## 交付与视觉验收
## 构建与维护
- 用户指定服务器为唯一运行与验收入口:http://8.211.154.134:18131/。
- 页面改动必须先 push,等待 ces-control 的 CI/CD 成功部署,再直接访问服务器检查效果。
- 不启动本地网页预览,不使用 localhost / 127.0.0.1 页面进行视觉验收,也不把本地预览链接交付给用户。
- 类型检查、单元测试等不提供网页预览的检查可以本地执行;完整运行检查由 Docker CI 完成。
## 啡跃 Endoleap 品牌与主题
- 共享品牌包位于 [`packages/brand`](packages/brand/README.md)。修改品牌色、主题或标志前,阅读包内的 [品牌边界](packages/brand/docs/overview.md)、[配色与交互规范](packages/brand/docs/colors.md)、[Tailwind / shadcn 接入](packages/brand/docs/integration.md) 和 [Logo 规则](packages/brand/docs/logo.md)。不在仓库根 docs 重复维护一套品牌规范。
- 四套正式主题为 home-light、home-dark、canvas-light、canvas-dark;不创建 drafts,不恢复 proposed 命名。画布 Light / Dark 背景保持中性近白 / 近黑,用户素材与作品不整体染色。
- 基础色只在 tokens/palette.json 定义,主题引用色板键;Logo 轮廓只在 tokens/geometry.json 定义。保持 CSS、类型声明、资产与清单一致,不通过图片模型重画已固定的图形。
- 本地仅运行不生成产物的轻量读取检查。品牌导出、Tailwind/TypeScript 编译和 SVG 栅格化检查必须通过已确认的 ces-control Docker CI 执行,不在本机启动预览或构建。
- 构建和网页运行使用用户确认的远程环境。本地可执行差异审查和无需构建的轻量检查。
- Wiki 和 Studio 共用 `apps/waterfall-wiki` 的 Pages Router、登录保护和品牌包。保留已发布的路由、媒体标识和数据库迁移。
-41
View File
@@ -1,41 +0,0 @@
FROM node:24-bookworm-slim AS base
LABEL io.entroia.project="waterfall-wiki"
ENV NEXT_TELEMETRY_DISABLED=1
WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends ffmpeg ca-certificates chromium fonts-noto-cjk \
&& rm -rf /var/lib/apt/lists/*
FROM base AS dependencies
WORKDIR /repo/apps/waterfall-wiki
LABEL io.entroia.wiki.build="true"
RUN corepack enable && corepack prepare pnpm@11.25.0 --activate
COPY apps/waterfall-wiki/pnpm-lock.yaml ./
COPY pnpm-workspace.yaml /repo/pnpm-workspace.yaml
COPY packages/brand/ /repo/packages/brand/
COPY apps/waterfall-wiki/package.json ./
RUN pnpm --filter @prompt/waterfall-wiki install --frozen-lockfile --fetch-timeout=300000 --network-concurrency=8
FROM dependencies AS build
# .dockerignore excludes docs and operational notes without extra legacy COPY snapshots.
COPY apps/waterfall-wiki/ .
RUN pnpm typecheck && pnpm test && pnpm build && pnpm test:smoke
FROM build AS integration
USER node
CMD ["npm", "run", "test:integration"]
FROM base AS runtime
WORKDIR /app/apps/waterfall-wiki
ENV NODE_ENV=production HOSTNAME=0.0.0.0 PORT=3000 \
FFMPEG_PATH=/usr/bin/ffmpeg FFPROBE_PATH=/usr/bin/ffprobe
COPY --from=build --chown=node:node /repo/apps/waterfall-wiki/.next/standalone/ /app/
COPY --from=build --chown=node:node /repo/apps/waterfall-wiki/dist/worker.cjs /repo/apps/waterfall-wiki/dist/bootstrap.cjs /repo/apps/waterfall-wiki/dist/publish-learning-series.cjs /repo/apps/waterfall-wiki/dist/auth-cli.cjs ./
COPY --from=build --chown=node:node /repo/apps/waterfall-wiki/db/ ./db/
COPY --chown=node:node apps/waterfall-wiki/scripts/container-health.mjs ./
COPY --chown=node:node apps/waterfall-wiki/scripts/import-character-packs.mjs apps/waterfall-wiki/scripts/verify-character-pack.mjs ./scripts/
COPY --chown=node:node apps/waterfall-wiki/seed/characters/ ./seed/characters/
COPY --chown=node:node apps/waterfall-wiki/src/content/managed-media.json ./src/content/managed-media.json
USER node
EXPOSE 3000
CMD ["node", "server.js"]
+15 -93
View File
@@ -1,104 +1,26 @@
# PROMPT
# 啡跃 Endoleap
把提示词、参考图、分步文档和创作工具收进同一个可连接的知识库。人可以浏览瀑布流、搜索和编辑;AI 可以从同一篇 post 读取纯文本、图片与 API 说明,再调用 Studio 生成作品。
啡跃把笔记、参考素材和创作工作台放在同一个 Next.js 应用里。应用位于 `apps/waterfall-wiki`,共享主题与品牌资源位于 `packages/brand`。
**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>` | 人看的 post;加 `?agent_mode=True` 读纯文本 |
| `/` | 浏览内容,打开 `/explore/<id>` 查看笔记 |
| `/guide` | 浏览指导文档 |
| `/graphic` | 图文排版 |
| `/infinite_canvas` | 无限画布 |
| `/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 |
| `/studio/api` | Studio API 目录 |
标签 PATCH 请求为 `{ "tags": ["标签 UUID"], "revision": "GET 返回的 revision" }`。它不会要求用户重输正文。Tag 对象的 `kind` 为 `normal` 或 `system`;系统标签还有稳定的 `system_key`。普通创建接口不允许提交 `kind` 或 `system_key`。
## 运行
Wiki 中的教程是普通 post 与媒体数据,不需要为每份资料增加专用网页路由、JSON 文件端点或代码注册。
在已确认的远程环境准备 Node.js 24、pnpm 11.25.0、PostgreSQL、S3 兼容对象存储和 FFmpeg。参考 [环境变量示例](apps/waterfall-wiki/.env.example),将变量注入 Web、初始化和媒体 worker 进程。邮箱注册与找回密码需要可用的 SMTP 服务。
## 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 创作工具与封面渲染 API |
| `apps/python` | 按需运行的 PNG/视频导出 CLI,没有常驻网络服务 |
| `content` | 本地作者资料、教程及排版源图,不打入 Wiki 运行镜像 |
| `scripts` | CI 部署、运维、API 发布与作者工具 |
| `docs` | 数据模型、接口、认证与部署说明 |
两个 JavaScript 应用分别维护冻结的 pnpm 锁文件。根 Dockerfile 是 waterfall-wiki 的构建入口;应用目录的 Dockerfile 与它保持一致。
## 环境与检查
运行环境:Node.js 24、pnpm 11.25.0、PostgreSQL 17、S3 兼容对象存储;视频处理使用 FFmpeg,部分导出使用 Chromium。Python 工具需要 Python 3.12+。
在仓库根目录运行 `pnpm install --frozen-lockfile` 和 `pnpm build`。然后在 `apps/waterfall-wiki` 目录准备数据库和存储,初始化管理员认证并启动 Web。
```sh
pnpm install
pnpm --dir apps/waterfall-wiki typecheck
pnpm --dir apps/waterfall-wiki test
pnpm --dir apps/studio typecheck
bash -n scripts/deploy.sh scripts/integration.sh scripts/deploy-studio.sh
node dist/bootstrap.cjs
node dist/auth-cli.cjs init
pnpm start
```
完整集成测试只针对显式隔离的数据库、对象存储和生产容器运行,覆盖存储、视频加工、编辑冲突、真实搜索与引用、标签规则和登录保护。不要把生产存储配置拿来运行集成测试。
本项目以指定服务器为唯一网页运行与验收入口:先推送,再等待 ces-control CI/CD,最后直接检查服务器。不要启动本地网页预览,不用 localhost 页面作为视觉验收或交付入口。具体约定见 [AGENTS.md](AGENTS.md)。
## 部署与持久化
Web 应用由 `waterfall-wiki.yml` 统一发布,两个应用的路径都能触发它:先检查并构建 Studio,再完成 Wiki 的类型检查、单元测试、生产构建、冒烟及隔离 PostgreSQL/S3 集成测试并部署 Wiki,随后部署 Studio,最后在指定服务器验收。顺序在同一个 ces-control 作业内保证,避免独立作业互相等待。PR 检查也运行隔离集成测试,但不部署。Python 检查仍由 `python.yml` 承担;文档与作者数据本身不触发应用部署。
| 服务 | 入口/端口 |
| --- | --- |
| `waterfall-wiki` | 公网 `18131`,也是 Studio 的统一入口 |
| PostgreSQL | `127.0.0.1:18132` |
| 对象存储 | `127.0.0.1:18133` |
| Studio | `127.0.0.1:18134`;编辑器公网入口为 `/graphic`、`/infinite_canvas`,资源和 API 保留 `/studio` 代理 |
| Python | 无网络端口,按需运行 |
生产 Compose 项目仍为 `prompt`,部署根目录仍为 `/opt/prompt`。既有 `prompt_postgres_data`、`prompt_objects`、`prompt_auth_data` 卷与数据库身份保留;应用更名不会重新创建一套空存储。Compose 应用服务改为 `waterfall-wiki` 和 `waterfall-wiki-worker`,镜像参数为 `WATERFALL_WIKI_IMAGE` / `WATERFALL_WIKI_TEST_IMAGE`。
部署在停止写入后备份数据库,再运行迁移和启动新版。失败时恢复兼容旧镜像的备份并回滚服务。生产不执行 `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)。
媒体 worker 在同一目录作为独立进程运行 `node dist/worker.cjs`。绑定管理员验证器时,在远程交互终端执行 `node dist/auth-cli.cjs qr`。已有实例复用原数据库、对象存储和私有认证文件。
-8
View File
@@ -1,8 +0,0 @@
FROM python:3.12-slim
RUN apt-get update && apt-get install -y --no-install-recommends ffmpeg fonts-noto-cjk && rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY apps/python/ .
RUN pip install --no-cache-dir . && python -m unittest discover -s tests -v
RUN useradd --create-home exporter
USER exporter
ENTRYPOINT ["prompt-export"]
-12
View File
@@ -1,12 +0,0 @@
[build-system]
requires = ["setuptools==75.8.0"]
build-backend = "setuptools.build_meta"
[project]
name = "prompt-tools"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = ["Pillow==11.1.0"]
[project.scripts]
prompt-export = "prompt_tools.export:main"
[tool.setuptools.packages.find]
where = ["src"]
-70
View File
@@ -1,70 +0,0 @@
"""Render a Studio JSON bundle into PNG slides and optionally MP4."""
import argparse
import json
import subprocess
from pathlib import Path
from PIL import Image, ImageDraw, ImageFont
def validate(bundle):
if bundle.get("version") != 1 or not isinstance(bundle.get("slides"), list) or not bundle["slides"]:
raise ValueError("Expected a version 1 Studio bundle with slides")
for slide in bundle["slides"]:
if not all(isinstance(slide.get(k), str) for k in ("heading", "body", "color")):
raise ValueError("Invalid slide")
return bundle["slides"]
def wrap(draw, text, font, width):
lines = []
for paragraph in text.split("\n"):
line = ""
for char in paragraph:
if line and draw.textlength(line + char, font=font) > width:
lines.append(line)
line = ""
line += char
lines.append(line)
return lines
def render(bundle, output, font_path):
slides = validate(bundle)
output.mkdir(parents=True, exist_ok=True)
heading = ImageFont.truetype(str(font_path), 76)
body = ImageFont.truetype(str(font_path), 38)
for index, slide in enumerate(slides, 1):
image = Image.new("RGB", (1080, 1440), slide["color"])
draw = ImageDraw.Draw(image)
draw.text((80, 80), f"PROMPT / {index:02}", font=body, fill="#353535")
y = 330
for text, font, gap in [(slide["heading"], heading, 108), (slide["body"], body, 65)]:
for line in wrap(draw, text, font, 920):
if y + gap > 1360:
raise ValueError(f"Slide {index} overflows; split the text into more slides")
draw.text((80, y), line, font=font, fill="#242424")
y += gap
y += 70
image.save(output / f"slide-{index:03}.png")
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("bundle", type=Path)
parser.add_argument("--output", type=Path, required=True)
parser.add_argument("--font", type=Path, required=True, help="CJK font TTF/OTF/TTC")
parser.add_argument("--video", action="store_true")
args = parser.parse_args()
bundle = json.loads(args.bundle.read_text())
render(bundle, args.output, args.font)
if args.video:
# Use an explicit temporary input sequence so old PNGs cannot leak into a video.
import tempfile
import shutil
with tempfile.TemporaryDirectory() as staging:
for index in range(1, len(bundle["slides"]) + 1):
shutil.copy(args.output / f"slide-{index:03}.png", Path(staging) / f"slide-{index:03}.png")
subprocess.run(["ffmpeg", "-y", "-framerate", "1/4", "-i", str(Path(staging) / "slide-%03d.png"), "-vf", "fps=30,format=yuv420p", "-c:v", "libx264", "-movflags", "+faststart", str(args.output / "video.mp4")], check=True)
if __name__ == "__main__":
main()
-13
View File
@@ -1,13 +0,0 @@
import unittest
from prompt_tools.export import validate, wrap
class ExportTests(unittest.TestCase):
def test_bad_bundle(self):
for bundle in [{}, {"version": 1, "slides": []}, {"version": 1, "slides": [{}]}]:
with self.assertRaises(ValueError): validate(bundle)
def test_wrap_preserves_content(self):
class Draw:
def textlength(self, text, font): return len(text)
self.assertEqual(wrap(Draw(), "中文内容测试", None, 3), ["中文内", "容测试"])
def test_valid_bundle(self):
slides = [{"heading": "标题", "body": "正文", "color": "#fff"}]
self.assertEqual(validate({"version": 1, "slides": slides}), slides)
-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"]
-163
View File
@@ -1,163 +0,0 @@
{
"schemaVersion": 2,
"name": "把秋天装进一页 · 银杏观察手记",
"document": {
"version": 1,
"brief": {
"topic": "银杏与秋日观察手记",
"audience": "喜欢日常摄影与视觉手帐的小红书读者",
"intent": "以封面、观察方法和行动结尾组成三页可发布图文",
"designIntent": "奶油纸底、森林墨色、银杏金点缀;宋体大标题与克制的无衬线正文;所有页面保留相同页边距和节奏",
"imageStrategy": "生成一幅原创银杏静物插画作为系列母图;每页复用素材,保留裁切配置,不把正文烘焙进图片"
},
"style": {
"version": "autumn-journal/1",
"palette": {
"paper": "#F3EFE5",
"ink": "#253C32",
"muted": "#718074",
"accent": "#C3A050"
},
"typography": {
"title": {
"fontFamily": "serif",
"fontSize": 8.4,
"fontWeight": 600,
"lineHeight": 1.15,
"letterSpacing": 0
},
"body": {
"fontFamily": "sans",
"fontSize": 3.1,
"fontWeight": 400,
"lineHeight": 1.65,
"letterSpacing": 0
},
"caption": {
"fontFamily": "sans",
"fontSize": 1.7,
"fontWeight": 500,
"lineHeight": 1.2,
"letterSpacing": 0
}
},
"spacing": {
"margin": 10,
"gap": 4
}
},
"sources": [],
"pages": [
{
"key": "cover",
"role": "cover",
"layoutId": "standard-01",
"intent": "用银杏静物与两行大标题建立主题",
"bindings": {
"T01": "title",
"T02": "body",
"T03": "caption"
},
"parameters": {
"texts": {
"T01": {
"value": "把秋天\n装进一页"
},
"T02": {
"value": "不必走很远。\n一片银杏,就是今天的风景。"
},
"T03": {
"value": "SEASONAL NOTES / 01"
}
},
"images": {
"I01": {
"fit": "cover",
"cropX": 50,
"cropY": 50,
"zoom": 1
}
},
"decorations": {}
}
},
{
"key": "observe",
"role": "body",
"layoutId": "standard-01",
"intent": "用三个观察动作提供可实践的方法",
"bindings": {
"T01": "title",
"T02": "body",
"T03": "caption"
},
"parameters": {
"texts": {
"T01": {
"value": "先看见\n再记录"
},
"T02": {
"value": "看叶脉,看光落下的方向。\n留住一片金黄,也留出一半空白。\n把感受写成短句,画面就有了呼吸。"
},
"T03": {
"value": "FIELD NOTES / 02"
}
},
"images": {
"I01": {
"fit": "cover",
"cropX": 65,
"cropY": 40,
"zoom": 1.25
}
},
"decorations": {}
}
},
{
"key": "ending",
"role": "ending",
"layoutId": "standard-01",
"intent": "以轻量行动和提问收尾",
"bindings": {
"T01": "title",
"T02": "body",
"T03": "caption"
},
"parameters": {
"texts": {
"T01": {
"value": "留一点白\n给下一次",
"align": "middle"
},
"T02": {
"value": "今天,捡起一片叶子。\n用三个词记录它的颜色、纹理与心情。\n你的秋天,会从哪一页开始?",
"align": "middle"
},
"T03": {
"value": "YOUR TURN / 03"
}
},
"images": {
"I01": {
"fit": "cover",
"cropX": 65,
"cropY": 50,
"zoom": 1
}
},
"decorations": {},
"layers": {
"I01": {
"x": 40,
"y": 55,
"width": 40,
"height": 40,
"radius": 20
}
}
}
}
]
}
}
-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": {} },
};
-24
View File
@@ -1,24 +0,0 @@
const {chromium}=require('playwright-core');
const assert=require('node:assert/strict');
(async()=>{const browser=await chromium.launch({channel:'chrome',headless:true});try{const page=await browser.newPage({viewport:{width:1440,height:1000}});const errors=[];page.on('pageerror',e=>errors.push(e.message));
await page.route('**/api/account/session',r=>r.fulfill({json:{user:null}}));
await page.goto((process.env.STUDIO_TEST_ORIGIN||'http://8.211.154.134:18131')+'/graphic');
await page.getByRole('button',{name:'切换图层面板',exact:true}).click();
assert.equal(await page.getByRole('button',{name:'图层',exact:true}).isVisible(),false);
await page.getByRole('button',{name:'切换图层面板',exact:true}).click();
await page.getByRole('button',{name:'切换属性面板',exact:true}).click();
assert.equal(await page.getByRole('button',{name:'备份设计 JSON',exact:true}).isVisible(),false);
await page.getByRole('button',{name:'切换属性面板',exact:true}).click();
await page.getByRole('button',{name:'添加文字',exact:true}).click();
await page.getByRole('textbox',{name:'文字内容',exact:true}).fill('设计从这里开始');
await page.getByRole('button',{name:'添加矩形',exact:true}).click();
await page.getByRole('button',{name:'撤销',exact:true}).click();
await page.getByRole('button',{name:'重做',exact:true}).click();
await page.reload();await page.getByRole('button',{name:'文字',exact:true}).first().waitFor();
assert.ok(await page.evaluate(()=>localStorage.getItem('studio.graphic.v1').includes('设计从这里开始')));
const file=page.waitForEvent('download');await page.getByRole('button',{name:'导出 SVG',exact:true}).click();assert.ok((await file).suggestedFilename().endsWith('.svg'));
await page.getByRole('button',{name:'邮箱登录',exact:true}).click();await page.getByRole('dialog').waitFor();await page.keyboard.press('Escape');await page.getByRole('dialog').waitFor({state:'hidden'});
await page.screenshot({path:process.env.STUDIO_QA_SCREENSHOT||'graphic-qa.png',fullPage:true});
await page.setViewportSize({width:390,height:844});assert.equal(await page.evaluate(()=>document.documentElement.scrollWidth>innerWidth),false);
assert.deepEqual(errors,[]);console.log('Graphic editing, history, restore, SVG download, account dialog and mobile overflow checks passed.');
}finally{await browser.close()}})().catch(e=>{console.error(e);process.exitCode=1});
@@ -1,128 +0,0 @@
// Isolated browser context against the designated deployed server only.
const {chromium}=require('playwright-core');
const assert=require('node:assert/strict');
const fs=require('node:fs/promises');
(async()=>{
const base=process.env.BASE_URL||'http://8.211.154.134:18131';
assert.equal(base,'http://8.211.154.134:18131');
const browser=await chromium.launch(process.env.CHROMIUM_PATH?{executablePath:process.env.CHROMIUM_PATH,headless:true,args:['--no-sandbox','--disable-dev-shm-usage']}:{channel:'chrome',headless:true});
let page;
try{
const context=await browser.newContext({viewport:{width:1600,height:1100},storageState:process.env.STORAGE_STATE||undefined});
page=await context.newPage();const errors=[];let choosers=0;
page.on('pageerror',e=>errors.push(e.message));page.on('filechooser',()=>choosers++);
const snapshot=()=>page.evaluate(()=>JSON.parse(localStorage.getItem('studio.infinite-canvas.v1')));
const node=id=>page.locator(`[data-node-id="${id}"]`);
const content=id=>node(id).locator('[data-node-content]');
const waitField=(id,key,value)=>page.waitForFunction(({id,key,value})=>JSON.parse(localStorage.getItem('studio.infinite-canvas.v1')).cards.find(n=>n.id===id)?.[key]===value,{id,key,value});
const composer=page.getByRole('region',{name:'节点编辑器'});
const capture=async name=>{if(process.env.SCREENSHOT_DIR){await fs.mkdir(process.env.SCREENSHOT_DIR,{recursive:true});await page.screenshot({path:process.env.SCREENSHOT_DIR+'/'+name+'.png'})}};
async function unobstructed(id){const box=await node(id).boundingBox();assert.ok(box);for(const card of (await snapshot()).cards.filter(n=>n.id!==id)){const other=await node(card.id).boundingBox();assert.ok(other);assert.equal(box.x<other.x+other.width&&box.x+box.width>other.x&&box.y<other.y+other.height&&box.y+box.height>other.y,false,`${id} overlaps ${card.id}`)}}
const close=(a,b)=>assert.ok(Math.abs(a-b)<2,`${a} ≈ ${b}`);
async function drag(handle,dx,dy){const b=await handle.boundingBox();assert.ok(b);await page.mouse.move(b.x+b.width/2,b.y+b.height/2);await page.mouse.down();await page.mouse.move(b.x+b.width/2+dx,b.y+b.height/2+dy,{steps:12});await page.mouse.up()}
const canvasResponse=await page.goto(base+'/infinite_canvas',{waitUntil:'commit',timeout:60000});
assert.ok(canvasResponse?.ok(),'Canvas document returns HTTP success');
await page.locator('button[data-theme-ready="true"]').waitFor();
await page.getByRole('button',{name:'从示例开始 ↗',exact:true}).click();
await page.getByRole('button',{name:'适应画布',exact:true}).click();
await content('brief').click({position:{x:20,y:20}});
assert.equal(await node('brief').getAttribute('data-selected'),'true');assert.equal(await node('brief').getAttribute('data-editing'),'false');
await page.waitForTimeout(700);
await content('brief').click({position:{x:20,y:20}});
await node('brief').getByRole('textbox',{name:'01 / 创意简报 内容'}).fill('选择后再次点击进入编辑');
await waitField('brief','text','选择后再次点击进入编辑');
await node('brief').getByRole('button',{name:'加粗',exact:true}).click();
assert.equal((await snapshot()).cards.find(n=>n.id==='brief').style.bold,true);
await node('brief').getByRole('button',{name:'斜体',exact:true}).click();
await node('brief').getByLabel('文字颜色',{exact:true}).fill('#78905e');
await node('brief').getByRole('combobox',{name:'文字样式',exact:true}).selectOption('24');
await page.waitForFunction(()=>{const n=JSON.parse(localStorage.getItem('studio.infinite-canvas.v1')).cards.find(n=>n.id==='brief');return n.style.bold&&n.style.italic&&n.style.color==='#78905e'&&n.style.size===24});
assert.deepEqual(await node('brief').getByRole('textbox',{name:'01 / 创意简报 内容'}).evaluate(el=>{const s=getComputedStyle(el);return {size:s.fontSize,weight:s.fontWeight,style:s.fontStyle,color:s.color}}),{size:'24px',weight:'700',style:'italic',color:'rgb(120, 144, 94)'});
const compact=(await snapshot()).cards.find(n=>n.id==='brief');
await node('brief').getByRole('button',{name:'展开文本节点',exact:true}).click();
const expanded=(await snapshot()).cards.find(n=>n.id==='brief');assert.ok(expanded.width>(compact.width||320)&&expanded.height>(compact.height||350));
await capture('canvas-text-format-desktop');
await page.getByRole('button',{name:'撤销',exact:true}).click();
assert.equal((await snapshot()).cards.find(n=>n.id==='brief').width,compact.width);assert.equal((await snapshot()).cards.find(n=>n.id==='brief').height,compact.height);
await page.keyboard.press('Escape');
const before=(await snapshot()).cards.find(n=>n.id==='brief');
const z=await page.locator('[class*=world]').evaluate(el=>new DOMMatrix(getComputedStyle(el).transform).a);
await drag(content('brief'),45,25);
const after=(await snapshot()).cards.find(n=>n.id==='brief');close(after.x-before.x,45/z);close(after.y-before.y,25/z);
assert.equal(await node('brief').getAttribute('data-editing'),'false');
await content('reference').click();assert.equal(await composer.count(),0);
await page.waitForTimeout(700);await content('reference').click();await composer.waitFor();assert.equal(choosers,0,'Entering image editing must not force a file picker');
const panelBefore=await composer.boundingBox(),imageBefore=await node('reference').boundingBox();
await capture('canvas-composer-desktop');
await page.getByRole('button',{name:'缩小',exact:true}).click();
const panelAfter=await composer.boundingBox(),imageAfter=await node('reference').boundingBox();
close(panelBefore.width,panelAfter.width);close(panelBefore.height,panelAfter.height);assert.ok(imageAfter.width<imageBefore.width);
const chooser=page.waitForEvent('filechooser');await composer.getByRole('button',{name:'上传图片',exact:true}).click();
await (await chooser).setFiles({name:'reference.png',mimeType:'image/png',buffer:Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+ip1sAAAAASUVORK5CYII=','base64')});
await page.waitForFunction(()=>!!JSON.parse(localStorage.getItem('studio.infinite-canvas.v1')).cards.find(n=>n.id==='reference').image);
await node('reference').getByRole('button',{name:'图片高清 ↗',exact:true}).click();
const upscale=(await snapshot()).cards.find(n=>n.operation==='upscale');assert.ok(upscale);
await unobstructed(upscale.id);
const download=page.waitForEvent('download');await composer.getByRole('button',{name:'导出任务',exact:true}).click();
const task=JSON.parse(await fs.readFile(await (await download).path(),'utf8'));
assert.equal(task.target.id,upscale.id);assert.equal(task.references[0].id,'reference');assert.ok(task.references[0].image);
await page.keyboard.press('Escape');await page.getByRole('button',{name:'适应画布',exact:true}).click();
await content('brief').click({position:{x:20,y:20}});
await node('brief').getByRole('button',{name:'引用 01 / 创意简报 生成',exact:true}).click();
await page.getByRole('dialog',{name:'画布操作'}).getByRole('button',{name:'脚本',exact:true}).click();
const script=(await snapshot()).cards.find(n=>n.kind==='script');assert.ok(script);
await unobstructed(script.id);
await composer.getByRole('textbox',{name:'创作要求',exact:true}).fill('基于简报写六个分镜');
await waitField(script.id,'instruction','基于简报写六个分镜');assert.equal((await snapshot()).cards.find(n=>n.id===script.id).text,'');
await composer.getByRole('button',{name:'画布参考',exact:true}).click();
await composer.getByRole('group',{name:'选择引用节点'}).getByRole('button',{name:'02 / 视觉参考',exact:true}).click();
assert.ok((await snapshot()).links.some(([a,b])=>a==='reference'&&b===script.id));
await composer.getByRole('button',{name:'移除引用 01 / 创意简报',exact:true}).click();
assert.ok(!(await snapshot()).links.some(([a,b])=>a==='brief'&&b===script.id));
await page.keyboard.press('Escape');
await page.getByRole('button',{name:'适应画布',exact:true}).click();
await content('reference').click();await node('reference').getByRole('button',{name:'02 / 视觉参考 操作',exact:true}).click();
await page.getByRole('button',{name:'删除节点',exact:true}).click();
assert.ok((await snapshot()).cards.some(n=>n.id===script.id));assert.ok((await snapshot()).links.every(l=>!l.includes('reference')));
await page.getByRole('button',{name:'撤销',exact:true}).click();assert.ok((await snapshot()).links.some(([a,b])=>a==='reference'&&b===script.id));
await page.keyboard.press('Escape');
await content('brief').click({position:{x:20,y:20}});
const priorWidth=(await snapshot()).cards.find(n=>n.id==='brief').width;
await drag(node('brief').getByRole('button',{name:'调整 01 / 创意简报 尺寸',exact:true}),50,30);
assert.ok((await snapshot()).cards.find(n=>n.id==='brief').width>(priorWidth||320));
await page.getByRole('button',{name:'撤销',exact:true}).click();assert.equal((await snapshot()).cards.find(n=>n.id==='brief').width,priorWidth);
await page.getByRole('button',{name:'重做',exact:true}).click();
await page.getByRole('button',{name:'移动画布',exact:true}).click();
await page.mouse.move(15,300);await page.mouse.down();await page.mouse.move(75,340,{steps:8});await page.mouse.up();
assert.equal(await page.evaluate(()=>{const path=[...document.querySelectorAll('svg path')].find(p=>p.getAttribute('d')?.startsWith('M '));const d=path.getAttribute('d').match(/-?\d+(?:\.\d+)?/g).map(Number),point=new DOMPoint(d[0],d[1]).matrixTransform(path.getScreenCTM()),card=document.querySelector('[data-node-id="brief"]').getBoundingClientRect();return Math.abs(point.x-card.right)<3&&Math.abs(point.y-(card.top+card.height/2))<3}),true);
const worldAt=()=>page.locator('[class*=world]').evaluate(el=>{const m=new DOMMatrix(getComputedStyle(el).transform);return {x:(1450-m.e)/m.a,y:(150-m.f)/m.d,z:m.a}});
const anchorBefore=await worldAt();await page.mouse.move(1450,150);await page.keyboard.down('Control');await page.mouse.wheel(0,-80);await page.keyboard.up('Control');
await page.waitForFunction(z=>new DOMMatrix(getComputedStyle(document.querySelector('[class*=world]')).transform).a!==z,anchorBefore.z);
const anchorAfter=await worldAt();close(anchorBefore.x,anchorAfter.x);close(anchorBefore.y,anchorAfter.y);
const saved=await snapshot();await page.reload({waitUntil:'commit',timeout:60000});await page.locator('button[data-theme-ready="true"]').waitFor();await page.waitForFunction(()=>!!localStorage.getItem('studio.infinite-canvas.v1'));assert.deepEqual(await snapshot(),saved);
const themeToggle=page.getByRole('button',{name:'切换明暗主题',exact:true});
await page.waitForFunction(()=>document.querySelector('[data-theme-ready]')?.dataset.themeReady==='true');
const initialMode=await page.evaluate(()=>document.documentElement.dataset.mode);
assert.ok(['light','dark'].includes(initialMode));
for(const mode of [initialMode==='light'?'dark':'light',initialMode]){
await themeToggle.click();
const matchesTheme=mode=>{
const root=document.documentElement,canvas=document.querySelector('main');
return root.dataset.mode===mode&&root.dataset.theme===`canvas-${mode}`&&localStorage.getItem('endoleap.theme')===mode&&getComputedStyle(canvas).backgroundColor===(mode==='dark'?'rgb(20, 20, 20)':'rgb(250, 250, 250)');
};
await page.waitForFunction(matchesTheme,mode);
await page.reload({waitUntil:'commit'});
await page.waitForFunction(()=>document.querySelector('[data-theme-ready]')?.dataset.themeReady==='true');
await page.waitForFunction(matchesTheme,mode);
assert.deepEqual(await snapshot(),saved,'Theme changes must preserve canvas content');
await capture('canvas-theme-'+mode);
}
if(process.env.SCREENSHOT_DIR){await fs.mkdir(process.env.SCREENSHOT_DIR,{recursive:true});await page.screenshot({path:process.env.SCREENSHOT_DIR+'/canvas-nodes-desktop.png'});}
await page.setViewportSize({width:390,height:844});assert.ok(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth));
const viewport=await page.locator('[class*=viewport]').boundingBox();assert.equal(viewport.width,390);assert.equal(viewport.height,844);
if(process.env.SCREENSHOT_DIR)await page.screenshot({path:process.env.SCREENSHOT_DIR+'/canvas-nodes-mobile.png'});
assert.deepEqual(errors,[]);console.log('Passed: selected-node re-click editing, drag, formatting, fixed composer, upload, references, task export, deletion/undo, reload, both persistent root themes and mobile bounds');
}catch(error){if(process.env.SCREENSHOT_DIR&&page){await fs.mkdir(process.env.SCREENSHOT_DIR,{recursive:true});await page.screenshot({path:process.env.SCREENSHOT_DIR+'/canvas-failure.png'}).catch(()=>{})}throw error}finally{await browser.close()}
})().catch(e=>{console.error(e);process.exitCode=1});
-29
View File
@@ -1,29 +0,0 @@
/** CI-only isolated runtime checks, never a local preview workflow. */
import {readFileSync} from 'node:fs';
import {spawn} from 'node:child_process';import {once} from 'node:events';import {createServer} from 'node:net';import {createServer as createHttpServer} from 'node:http';import assert from 'node:assert/strict';import {setTimeout} from 'node:timers/promises';
const designId='00000000-0000-4000-8000-000000000001',missingId='00000000-0000-4000-8000-000000000002';
const standardId='00000000-0000-4000-8000-000000000003';
const seriesId='00000000-0000-4000-8000-000000000004';
const doc=JSON.parse(readFileSync(new URL('../fixtures/autumn-journal.json',import.meta.url))).document;
const assetId='00000000-0000-4000-8000-000000000005';doc.sources=[{assetId,author:'CI fixture',license:'test only',url:'generated',prompt:'test image',tool:'fixture'}];for(const p of doc.pages)p.parameters.images.I01.assetId=assetId;
let reads=0,mediaReads=0;
const wiki=createHttpServer((req,res)=>{assert.equal(req.headers.cookie,'session=smoke-test');if(/^\/api\/media\/[0-9a-f-]{36}$/.test(req.url)){mediaReads++;res.setHeader('Content-Type','image/png');return res.end(Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+j3ioAAAAASUVORK5CYII=','base64'))}reads++;res.setHeader('Content-Type','application/json');if(req.url.endsWith(seriesId))return res.end(JSON.stringify({design:{id:seriesId,name:'完整三页作品',layoutId:'standard-01',revision:1,url:'/studio/template-layout?id='+seriesId,parameters:doc.pages[0].parameters,document:doc}}));const standard=req.url.endsWith(standardId);if(!standard&&!req.url.endsWith(designId)){res.writeHead(404);return res.end('{}')}res.end(JSON.stringify({design:{id:standard?standardId:designId,name:'已保存的实际页面',layoutId:standard?'standard-01':'ref-01',revision:3,url:'/studio/designs/'+designId,parameters:{added:[{id:'user-smoke',kind:'text',role:'Added text',box:[10,10,20,10],direction:'horizontal',text:'新增元素已恢复',reading:'custom'}],layers:{T01:{x:12,rotation:25,opacity:.7},'user-smoke':{z:12}},texts:{T01:{value:'确定的页面文字',color:'#123456'}},images:{I01:{color:'#abcdef'}},decorations:{D01:{hidden:true}},paper:'#eeeeee'}}}))});
wiki.listen(0,'127.0.0.1');await once(wiki,'listening');
const socket=createServer();socket.listen(0,'127.0.0.1');await once(socket,'listening');const port=socket.address().port;await new Promise(r=>socket.close(r));const child=spawn(process.execPath,[process.env.STUDIO_STANDALONE_SERVER || '.next/standalone/apps/studio/server.js'],{env:{...process.env,PORT:String(port),HOSTNAME:'127.0.0.1',STUDIO_WIKI_ORIGIN:'http://127.0.0.1:'+wiki.address().port},stdio:'inherit'}),base='http://127.0.0.1:'+port;
try{let ready=false;for(let i=0;i<60;i++){try{if((await fetch(base+'/studio/api/health')).ok){ready=true;break}}catch{}await setTimeout(250)}assert.ok(ready);const entry=await fetch(base+'/studio',{redirect:'manual'});assert.equal(entry.status,307);assert.equal(entry.headers.get('location'),'/studio/graphic');const library=await fetch(base+'/studio/graphic').then(r=>r.text());assert.ok(library.includes('GRAPHIC'));assert.ok(!library.includes('ref-27')); const catalog=await fetch(base+'/studio/api/layouts').then(r=>r.json());assert.equal(catalog.layouts.length,28);
for(const l of catalog.layouts){const page=await fetch(base+l.url);assert.equal(page.status,200,l.id);const html=await page.text();assert.ok(html.includes(l.name));assert.ok(html.includes('data-layout-id="'+l.id+'"'));assert.ok(!/<svg|<img|data:image|trial-controls|page-tools|workbench-top|contenteditable|编辑页面/.test(html),'Default page must use native elements, not source imagery');const schema=await fetch(base+l.api).then(r=>r.json());assert.equal(schema.id,l.id);for(const s of schema.elements)assert.ok(html.includes('data-element="'+s.id+'"'));if(l.family==='standard'){assert.equal((await fetch(base+l.api+'?format=svg')).status,400);continue;}const original=await fetch(base+l.api+'?format=svg',{headers:{cookie:'session=smoke-test'}}).then(r=>r.text());assert.ok(original.includes('data-layout-id="'+l.id+'"'));const text=schema.elements.find(e=>e.kind==='text');const result=await fetch(base+l.api,{method:'POST',headers:{'Content-Type':'application/json',cookie:'session=smoke-test'},body:JSON.stringify({texts:{[text.id]:{value:'API render <test>'}}})});assert.equal(result.status,200);assert.ok((await result.text()).includes('&lt;'));}
assert.equal(reads,0,'Default pages never load a saved design');assert.ok(mediaReads>0,'SVG exports load isolated media fixtures');
const headers={cookie:'session=smoke-test'};
for(const id of [designId,standardId]){
const saved=await fetch(base+'/studio/template-layout?id='+id,{headers});assert.equal(saved.status,200);const savedHtml=await saved.text();assert.ok(savedHtml.includes('确定的页面文字'));assert.ok(savedHtml.includes('新增元素已恢复'));assert.ok(savedHtml.includes('data-element="user-smoke"'));assert.ok(savedHtml.includes('rotate(25deg)'));assert.ok(savedHtml.includes('opacity:0.7'));assert.ok(savedHtml.includes('data-design-id="'+id+'"'));assert.ok(savedHtml.includes('#123456'));assert.ok(!savedHtml.includes('data-element="D01"'));assert.ok(!savedHtml.includes('data:image'));assert.ok(!/page-tools|workbench-top|contenteditable|编辑页面/.test(savedHtml));
}
const complete=await fetch(base+'/studio/template-layout?id='+seriesId,{headers});assert.equal(complete.status,200);const completeHtml=await complete.text(),artwork=completeHtml.match(/<main[\s\S]*?<\/main>/)[0];assert.equal((artwork.match(/data-page-key=/g)||[]).length,3);assert.ok(artwork.includes('data-style-version="autumn-journal/1"'));assert.ok(artwork.includes('把秋天'));assert.ok(artwork.includes('先看见'));assert.ok(artwork.includes('留一点白'));assert.ok(!/button|contenteditable|workbench-top/.test(artwork));
const second=await fetch(base+'/studio/template-layout?id='+seriesId+'&page=2&export=1',{headers});assert.equal(second.status,200);const secondHtml=(await second.text()).match(/<main[\s\S]*?<\/main>/)[0];assert.equal((secondHtml.match(/data-page-key=/g)||[]).length,1);assert.ok(secondHtml.includes('data-page="2"'));assert.ok(secondHtml.includes('height:1440px'));assert.ok(secondHtml.includes('width=2160'));assert.ok(secondHtml.includes('profile=export'));assert.ok(secondHtml.includes('Noto Serif CJK SC'));
for(const suffix of ['0','4','bad'])assert.equal((await fetch(base+'/studio/template-layout?id='+seriesId+'&page='+suffix,{headers})).status,404);
for(const path of ['/studio/template-layout','/studio/template-layout?id=invalid','/studio/template-layout?id='+missingId,'/studio/template-layout?id='+designId+'&id='+standardId])assert.equal((await fetch(base+path,{headers})).status,404);
assert.equal((await fetch(base+'/studio/layouts/ref-02?id='+designId,{headers})).status,404);
assert.equal((await fetch(base+'/studio/layouts/ref-01?id='+missingId,{headers})).status,404);
const beforeInvalid=reads;assert.equal((await fetch(base+'/studio/layouts/ref-01?id=invalid',{headers})).status,404);assert.equal(reads,beforeInvalid);
const legacy=await fetch(base+'/studio/designs/'+designId,{headers,redirect:'manual'});assert.equal(legacy.status,307);assert.equal(legacy.headers.get('location'),'/studio/template-layout?id='+designId);
const old=await fetch(base+'/studio/layouts/ref-01?id='+designId,{headers,redirect:'manual'});assert.equal(old.status,307);assert.equal(old.headers.get('location'),'/studio/template-layout?id='+designId);
assert.equal((await fetch(base+'/studio/layouts/ref-28')).status,404);assert.equal((await fetch(base+'/studio/api/layouts/ref-01',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({texts:{I01:{value:'bad'}}})})).status,400);console.log('All 27 native pages, server-loaded saved IDs, legacy redirects and render APIs passed');}finally{child.kill('SIGTERM');await once(child,'exit');await new Promise(r=>wiki.close(r))}
-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)}
@@ -1,65 +0,0 @@
import test from 'node:test';
import {readFileSync} from 'node:fs';
import assert from 'node:assert/strict';
import {parseCanvasDocument,deriveNode,connectNodes,removeCanvasNode,referencedNodes,agentTask} from '../src/lib/canvas-document.ts';
const note={id:'brief',kind:'text',title:'创意简报',text:'清晨的城市',x:20,y:50};
const doc={version:1,cards:[note],links:[]};
test('existing v1 browser drafts remain readable and untrusted fields are discarded',()=>{
assert.deepEqual(parseCanvasDocument(JSON.stringify({...doc,token:'ignored',cards:[{...note,html:'ignored'}]})),doc);
for(const bad of [{...doc,cards:[note,note]},{...doc,links:[['brief','missing']]},{...doc,cards:[{...note,image:'https://example.com/x.png'}]},{...doc,cards:[{...note,style:{color:'url(javascript:bad)'}}]}])assert.throws(()=>parseCanvasDocument(bad));
});
test('derived image and script nodes preserve directed references across save and restore',()=>{
const picture={...note,id:'picture',kind:'image',title:'参考图',image:'data:image/png;base64,AAAA',operation:'generate'};
const next=deriveNode(doc,'brief',picture);
const restored=parseCanvasDocument(JSON.stringify(deriveNode(next,'picture',{...note,id:'script',kind:'script',operation:'script'})));
assert.equal(referencedNodes(restored,'picture')[0].id,'brief');assert.equal(referencedNodes(restored,'script')[0].id,'picture');
assert.throws(()=>connectNodes(restored,'script','brief'),/循环/);
assert.throws(()=>parseCanvasDocument({...restored,links:[...restored.links,['script','brief']]}),/循环/);
const removed=removeCanvasNode(restored,'picture');assert.deepEqual(removed.links,[]);assert.equal(removed.cards.length,2);
});
test('agent task captures current referenced content and validates upscale inputs',()=>{
const next=deriveNode(doc,'brief',{...note,id:'out',kind:'script',operation:'script'});
assert.equal(agentTask(next,'out').references[0].text,'清晨的城市');
assert.equal(agentTask(next,'out').result.targetId,'out');
assert.throws(()=>agentTask({...next,cards:next.cards.map(n=>n.id==='out'?{...n,operation:'upscale'}:n)},'out'),/引用一张图片/);
});
test('instructions stay distinct from generated content in agent requests',()=>{
const result={...note,id:'script',kind:'script',text:'已有脚本正文',instruction:'改写为六个分镜',operation:'script'};
const restored=parseCanvasDocument(JSON.stringify(deriveNode(doc,'brief',result)));
assert.equal(agentTask(restored,'script').instruction,'改写为六个分镜');
assert.equal(restored.cards.find(n=>n.id==='script').text,'已有脚本正文');
});
test('editing an image passes the current image to the external agent',()=>{
const image={...note,id:'image',kind:'image',instruction:'把背景改为雪夜',image:'data:image/png;base64,AAAA'};
assert.equal(agentTask({version:1,cards:[image],links:[]},'image').sourceImage,image.image);
});
const overlaps=(a,b)=>a.x<b.x+(b.width||320)&&a.x+(a.width||320)>b.x&&a.y<b.y+(b.height||350)&&a.y+(a.height||350)>b.y;
test('branching the example keeps the source image accessible after image and script derivation',()=>{
const scene={version:1,cards:[
{...note,x:70,y:140},
{...note,id:'reference',kind:'image',x:460,y:80},
{...note,id:'shot',kind:'prompt',x:850,y:160},
],links:[['brief','reference'],['reference','shot']]};
const image=deriveNode(scene,'reference',{...note,id:'hd',kind:'image',operation:'upscale',x:930,y:80,width:360,height:300});
const result=deriveNode(image,'brief',{...note,id:'script',kind:'script',operation:'script',x:540,y:140,width:360,height:300});
assert.deepEqual(result.cards.slice(0,3),scene.cards,'Existing content and positions stay unchanged');
for(const added of result.cards.slice(3))for(const other of result.cards.filter(n=>n.id!==added.id))assert.equal(overlaps(added,other),false,`${added.id} blocks ${other.id}`);
assert.equal(referencedNodes(result,'hd')[0].id,'reference');
assert.equal(referencedNodes(result,'script')[0].id,'brief');
assert.deepEqual(parseCanvasDocument(JSON.stringify(result)),result);
});
test('a free requested position is retained and large nodes near the coordinate limit can still branch',()=>{
const free=deriveNode(doc,'brief',{...note,id:'free',x:2000,y:50});
assert.deepEqual({x:free.cards[1].x,y:free.cards[1].y},{x:2000,y:50});
const edge={version:1,cards:[{...note,x:90000,y:99600,width:1200,height:1200}],links:[]};
const result=deriveNode(edge,'brief',{...note,id:'edge',x:90500,y:99700,width:360,height:300});
assert.equal(overlaps(result.cards[0],result.cards[1]),false);
assert.deepEqual(parseCanvasDocument(JSON.stringify(result)),result);
});
test('agent and browser parse the same canvas document contract',()=>{
assert.equal(readFileSync(new URL('../src/lib/canvas-document.ts',import.meta.url),'utf8'),readFileSync(new URL('../../waterfall-wiki/src/lib/canvas-document.ts',import.meta.url),'utf8'));
});
@@ -1,8 +0,0 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {parseGraphicDocument} from '../src/lib/graphic-document.ts';
const item={id:'one',kind:'text',name:'标题',text:'<script>文本</script>',x:20,y:20,w:300,h:100,fill:'#123456',size:32,hidden:false,locked:false};
const doc={name:'作品',background:'#ffffff',items:[item]};
test('graphic backup restores editable text and ignores unrelated properties',()=>{assert.deepEqual(parseGraphicDocument(JSON.stringify({...doc,extra:'ignored'})),doc)});
test('graphic backup rejects corrupted layers, duplicate IDs and unbounded geometry',()=>{for(const items of [[{...item,text:null}],[item,item],[{...item,x:Infinity}],[{...item,w:-1}],[{...item,fill:'url(https://example.com)'}]])assert.throws(()=>parseGraphicDocument(JSON.stringify({...doc,items})));});
test('graphic backup disallows active SVG and remote image loads',()=>{for(const src of ['https://example.com/image.png','javascript:alert(1)','data:image/svg+xml;base64,PHN2Zz4='])assert.throws(()=>parseGraphicDocument(JSON.stringify({...doc,items:[{...item,kind:'image',src}]})));});
-27
View File
@@ -1,27 +0,0 @@
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {embedSvgMedia} from '../src/lib/layout-images.ts';
const id='a05dc841-240a-5a3a-b01b-d589fc21cd36';
test('SVG export fetches each internal media once with the caller authentication',async()=>{
const original=globalThis.fetch;let calls=0;
try{
globalThis.fetch=async(url,options)=>{calls++;assert.equal(url,'http://waterfall-wiki:3000/api/media/'+id);assert.equal(options.headers.cookie,'session=fixture');assert.equal(options.redirect,'error');return new Response(Buffer.from('fixture'),{headers:{'content-type':'image/png'}});};
const svg=await embedSvgMedia({headers:{cookie:'session=fixture'}},`<svg><image href="/api/media/${id}"/><image href="/api/media/${id}"/></svg>`);
assert.equal(calls,1);assert.equal((svg.match(/data:image\/png;base64,Zml4dHVyZQ==/g)||[]).length,2);assert.ok(!svg.includes('/api/media/'));
}finally{globalThis.fetch=original;}
});
test('SVG export rejects unavailable or non-image objects',async()=>{
const original=globalThis.fetch;
try{for(const response of [new Response('missing',{status:404}),new Response('<svg/>',{headers:{'content-type':'image/svg+xml'}})]){
globalThis.fetch=async()=>response;await assert.rejects(()=>embedSvgMedia({headers:{}},`<image href="/api/media/${id}"/>`),/模板图片不可访问/);
}}finally{globalThis.fetch=original;}
});
test('SVG exports without managed images do not access storage',async()=>{
assert.equal(await embedSvgMedia({headers:{}},'<svg><text>Title</text></svg>'),'<svg><text>Title</text></svg>');
});
test('SVG export uses the configured isolated Wiki origin',async()=>{
const original=globalThis.fetch,previous=process.env.STUDIO_WIKI_ORIGIN;
try{process.env.STUDIO_WIKI_ORIGIN='http://127.0.0.1:19000';globalThis.fetch=async url=>{assert.equal(url,'http://127.0.0.1:19000/api/media/'+id);return new Response('fixture',{headers:{'content-type':'image/png'}})};assert.ok((await embedSvgMedia({headers:{}},`<image href="/api/media/${id}"/>`)).includes('data:image/png;base64,'));}
finally{globalThis.fetch=original;if(previous===undefined)delete process.env.STUDIO_WIKI_ORIGIN;else process.env.STUDIO_WIKI_ORIGIN=previous;}
});
@@ -1,7 +0,0 @@
import {test} from 'node:test';import assert from 'node:assert/strict';import {readFileSync} from 'node:fs';
import {parameters} from '../src/lib/layout-parameters.ts';
const layouts=JSON.parse(readFileSync(new URL('../src/content/templates/layouts.json',import.meta.url)));
test('27 layouts preserve unique typed source slots and complete shapes',()=>{assert.equal(layouts.length,27);for(const [i,l] of layouts.entries()){assert.equal(l.id,'ref-'+String(i+1).padStart(2,'0'));assert.equal(new Set(l.elements.map(s=>s.id)).size,l.elements.length);for(const s of l.elements){assert.match(s.id,/^[TID]\d{2}$/);assert.equal(s.kind,({T:'text',I:'image',D:'decoration'})[s.id[0]]);assert.ok(s.box[0]>=0&&s.box[1]>=0&&s.box[0]+s.box[2]<=l.width&&s.box[1]+s.box[3]<=l.height);assert.match(s.raster,/^\/api\/media\/[a-f0-9-]{36}$/);if(s.kind==='image')assert.match(s.shape,/^\/api\/media\/[a-f0-9-]{36}$/)}assert.deepEqual(parameters(l,{}),{texts:{},images:{},decorations:{}})}});
test('parameters reject mismatched slots, unsafe URLs and excessive text',()=>{const l=layouts[0];for(const input of [{texts:{I01:{value:'bad'}}},{images:{I01:{url:'https://evil.test'}}},{texts:{T01:{value:'x',scale:3}}},{texts:{T01:{value:'x'.repeat(501)}}},{paper:'url(x)'},{decorations:{D01:{hidden:'true'}}},null,[]])assert.throws(()=>parameters(l,input));const p=parameters(l,{texts:{T01:{value:'<script>not markup</script>',scale:1.2,align:'middle'}},images:{I01:{color:'#abcdef',fit:'contain'}},decorations:{D01:{hidden:true}}});assert.equal(p.texts.T01.value,'<script>not markup</script>');assert.equal(p.decorations.D01.hidden,true)});
test('workspace parameters round-trip identically through Studio and Wiki validation',async()=>{const {parameters:wiki}=await import('../../waterfall-wiki/src/lib/layout-parameters.ts');for(const l of layouts){const added={id:'user-example',kind:'text',role:'Added text',box:[3,4,20,12],direction:'horizontal',text:'Hello',reading:'custom'};const input={added:[added],layers:{T01:{x:2,y:4,width:15,height:10,rotation:25,opacity:.7,radius:2,z:10,hidden:false,locked:true},'user-example':{x:8,z:20}},texts:{T01:{value:'Updated',fontFamily:'serif',fontSize:4,fontWeight:700,lineHeight:1.6,letterSpacing:.2},'user-example':{value:'Hello'}},images:{I01:{fit:'cover',cropX:15,cropY:85,zoom:2}}};assert.deepEqual(parameters(l,input),wiki(l,input));assert.deepEqual(parameters(l,parameters(l,input)),parameters(l,input))}});
test('workspace validation rejects unbounded, mismatched and executable properties',()=>{const l=layouts[0];for(const input of [{layers:{unknown:{x:2}}},{layers:{T01:{rotation:Infinity}}},{layers:{T01:{width:0}}},{layers:{T01:{opacity:2}}},{layers:{T01:{locked:'yes'}}},{texts:{T01:{value:'x',fontFamily:'url(evil)'}}},{images:{I01:{cropX:101}}},{images:{I01:{zoom:0}}},{added:[{id:'T01'}]},{added:Array(101).fill({})},{layers:{T01:{onClick:'evil'}}}])assert.throws(()=>parameters(l,input))});
-19
View File
@@ -1,19 +0,0 @@
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)));
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)}}
}
});
test('geometric templates retain their distinct silhouettes without bitmap masks',()=>{
assert.equal(imageShape(layouts[4],layouts[4].elements.find(s=>s.id==='I03')).borderRadius,'50%');
assert.ok(imageShape(layouts[21],layouts[21].elements.find(s=>s.id==='I02')).clipPath.startsWith('polygon('));
assert.notEqual(imageShape(layouts[21],layouts[21].elements.find(s=>s.id==='I02')).clipPath,imageShape(layouts[26],layouts[26].elements.find(s=>s.id==='I02')).clipPath);
assert.equal(designUrl('ref-01','abc'),'/studio/template-layout?id=abc');
assert.equal(designUrl('standard-01','abc'),'/studio/template-layout?id=abc');
});
test('element IDs work on HTTP origins without secure-context randomUUID',()=>{const saved=Object.getOwnPropertyDescriptor(globalThis,'crypto');let calls=0;Object.defineProperty(globalThis,'crypto',{configurable:true,value:{getRandomValues(array){for(let i=0;i<array.length;i++)array[i]=++calls;return array}}});try{const first=newElementId(),second=newElementId();assert.match(first,/^user-[a-f0-9]{32}$/);assert.notEqual(first,second)}finally{Object.defineProperty(globalThis,'crypto',saved)}});
-12
View File
@@ -1,12 +0,0 @@
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {readFileSync} from 'node:fs';
import {registerHooks} from 'node:module';
registerHooks({resolve(specifier,context,next){try{return next(specifier,context)}catch(e){if(e.code==='ERR_MODULE_NOT_FOUND'&&specifier.startsWith('.'))return next(specifier+'.ts',context);throw e}}});
const {series,pageParameters}=await import('../src/lib/series.ts');
const layout=JSON.parse(readFileSync(new URL('../src/content/standard-layout.json',import.meta.url)));
// Fixture copied into the CI image; no build is performed by these tests.
const input=JSON.parse(readFileSync(new URL('../fixtures/autumn-journal.json',import.meta.url)));
test('a complete three-page document preserves copy and resolves shared visual tokens',()=>{const d=series(input.document,[layout]);assert.equal(d.pages.length,3);for(const page of d.pages){const p=pageParameters(d,page,layout);assert.equal(p.paper,d.style.palette.paper);assert.equal(p.texts.T01.fontFamily,'serif');assert.equal(p.texts.T01.value,page.parameters.texts.T01.value);assert.equal(p.layers.T01.x,10)}const change=structuredClone(d);change.style.palette.ink='#112233';change.style.spacing.margin=8;for(const page of change.pages){const p=pageParameters(change,page,layout);assert.equal(p.texts.T02.color,'#112233');assert.equal(p.layers.T01.x,8)}change.pages[1].parameters.texts.T01.color='#ABCDEF';assert.equal(pageParameters(change,change.pages[1],layout).texts.T01.color,'#ABCDEF')});
test('invalid page order, stale template placeholders, untracked images and unsafe fields fail',()=>{for(const mutate of [d=>d.pages.reverse(),d=>d.pages[1].key=d.pages[0].key,d=>delete d.pages[0].parameters.texts.T02,d=>d.pages[0].parameters.images.I01.assetId='f76134e7-6e4f-4261-b90c-3c523dc16307',d=>d.style.palette.ink='url(evil)',d=>d.style.spacing.gap=10,d=>d.pages[0].bindings.I01='title',d=>d.pages[0].parameters.images.I01.url='https://evil.test']){const d=structuredClone(input.document);mutate(d);assert.throws(()=>series(d,[layout]))}});
test('persistence and renderer use byte-identical series contracts',()=>assert.equal(readFileSync(new URL('../src/lib/series.ts',import.meta.url),'utf8'),readFileSync(new URL('../../waterfall-wiki/src/lib/series.ts',import.meta.url),'utf8')));
-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"]
}
+13 -17
View File
@@ -1,22 +1,18 @@
# Replace all three placeholders before starting Docker Compose.
POSTGRES_PASSWORD=replace-with-a-long-random-password
S3_ACCESS_KEY=replace-with-a-unique-access-key
S3_SECRET_KEY=replace-with-a-long-random-secret
# Export to the web, storage bootstrap and worker processes.
DATABASE_URL=postgresql://endoleap:replace-me@127.0.0.1:5432/endoleap
S3_ENDPOINT=http://127.0.0.1:9000
S3_ACCESS_KEY=replace-me
S3_SECRET_KEY=replace-me
S3_BUCKET=prompt
WEB_BIND_ADDRESS=0.0.0.0
WEB_PORT=18131
PG_PORT=18132
S3_PORT=18133
PROMPT_IMAGE=prompt:local
RELEASE_SHA=development
# Use HTTPS and secure cookies in production; HTTP acceptance requires explicit false.
PORT=3000
AUTH_CONFIG_FILE=/path/to/private/auth.json
AUTH_COOKIE_SECURE=true
ACCOUNT_PUBLIC_ORIGIN=http://8.211.154.134:18131
# Enable only behind an ingress that overwrites x-forwarded-for.
ACCOUNT_PUBLIC_ORIGIN=https://endoleap.example
ACCOUNT_TRUST_PROXY=false
SMTP_HOST=smtpdm.aliyun.com
# Email verification and recovery
SMTP_HOST=
SMTP_PORT=465
SMTP_USERNAME=noreply@notice.entroia.com
SMTP_FROM=noreply@notice.entroia.com
SMTP_USERNAME=
SMTP_FROM=
SMTP_PASSWORD=
-39
View File
@@ -1,39 +0,0 @@
FROM node:24-bookworm-slim AS base
ENV NEXT_TELEMETRY_DISABLED=1
WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends ffmpeg ca-certificates chromium fonts-noto-cjk \
&& rm -rf /var/lib/apt/lists/*
FROM base AS dependencies
WORKDIR /repo/apps/waterfall-wiki
RUN corepack enable && corepack prepare pnpm@11.25.0 --activate
COPY apps/waterfall-wiki/pnpm-lock.yaml ./
COPY pnpm-workspace.yaml /repo/pnpm-workspace.yaml
COPY packages/brand/ /repo/packages/brand/
COPY apps/waterfall-wiki/package.json ./
RUN pnpm --filter @prompt/waterfall-wiki install --frozen-lockfile --fetch-timeout=300000 --network-concurrency=8
FROM dependencies AS build
# .dockerignore excludes docs and operational notes without extra legacy COPY snapshots.
COPY apps/waterfall-wiki/ .
RUN pnpm typecheck && pnpm test && pnpm build && pnpm test:smoke
FROM build AS integration
USER node
CMD ["npm", "run", "test:integration"]
FROM base AS runtime
WORKDIR /app/apps/waterfall-wiki
ENV NODE_ENV=production HOSTNAME=0.0.0.0 PORT=3000 \
FFMPEG_PATH=/usr/bin/ffmpeg FFPROBE_PATH=/usr/bin/ffprobe
COPY --from=build --chown=node:node /repo/apps/waterfall-wiki/.next/standalone/ /app/
COPY --from=build --chown=node:node /repo/apps/waterfall-wiki/dist/worker.cjs /repo/apps/waterfall-wiki/dist/bootstrap.cjs /repo/apps/waterfall-wiki/dist/publish-learning-series.cjs /repo/apps/waterfall-wiki/dist/auth-cli.cjs ./
COPY --from=build --chown=node:node /repo/apps/waterfall-wiki/db/ ./db/
COPY --chown=node:node apps/waterfall-wiki/scripts/container-health.mjs ./
COPY --chown=node:node apps/waterfall-wiki/scripts/import-character-packs.mjs apps/waterfall-wiki/scripts/verify-character-pack.mjs ./scripts/
COPY --chown=node:node apps/waterfall-wiki/seed/characters/ ./seed/characters/
COPY --chown=node:node apps/waterfall-wiki/src/content/managed-media.json ./src/content/managed-media.json
USER node
EXPOSE 3000
CMD ["node", "server.js"]
@@ -1,163 +0,0 @@
{
"schemaVersion": 2,
"name": "把秋天装进一页 · 银杏观察手记",
"document": {
"version": 1,
"brief": {
"topic": "银杏与秋日观察手记",
"audience": "喜欢日常摄影与视觉手帐的小红书读者",
"intent": "以封面、观察方法和行动结尾组成三页可发布图文",
"designIntent": "奶油纸底、森林墨色、银杏金点缀;宋体大标题与克制的无衬线正文;所有页面保留相同页边距和节奏",
"imageStrategy": "生成一幅原创银杏静物插画作为系列母图;每页复用素材,保留裁切配置,不把正文烘焙进图片"
},
"style": {
"version": "autumn-journal/1",
"palette": {
"paper": "#F3EFE5",
"ink": "#253C32",
"muted": "#718074",
"accent": "#C3A050"
},
"typography": {
"title": {
"fontFamily": "serif",
"fontSize": 8.4,
"fontWeight": 600,
"lineHeight": 1.15,
"letterSpacing": 0
},
"body": {
"fontFamily": "sans",
"fontSize": 3.1,
"fontWeight": 400,
"lineHeight": 1.65,
"letterSpacing": 0
},
"caption": {
"fontFamily": "sans",
"fontSize": 1.7,
"fontWeight": 500,
"lineHeight": 1.2,
"letterSpacing": 0
}
},
"spacing": {
"margin": 10,
"gap": 4
}
},
"sources": [],
"pages": [
{
"key": "cover",
"role": "cover",
"layoutId": "standard-01",
"intent": "用银杏静物与两行大标题建立主题",
"bindings": {
"T01": "title",
"T02": "body",
"T03": "caption"
},
"parameters": {
"texts": {
"T01": {
"value": "把秋天\n装进一页"
},
"T02": {
"value": "不必走很远。\n一片银杏,就是今天的风景。"
},
"T03": {
"value": "SEASONAL NOTES / 01"
}
},
"images": {
"I01": {
"fit": "cover",
"cropX": 50,
"cropY": 50,
"zoom": 1
}
},
"decorations": {}
}
},
{
"key": "observe",
"role": "body",
"layoutId": "standard-01",
"intent": "用三个观察动作提供可实践的方法",
"bindings": {
"T01": "title",
"T02": "body",
"T03": "caption"
},
"parameters": {
"texts": {
"T01": {
"value": "先看见\n再记录"
},
"T02": {
"value": "看叶脉,看光落下的方向。\n留住一片金黄,也留出一半空白。\n把感受写成短句,画面就有了呼吸。"
},
"T03": {
"value": "FIELD NOTES / 02"
}
},
"images": {
"I01": {
"fit": "cover",
"cropX": 65,
"cropY": 40,
"zoom": 1.25
}
},
"decorations": {}
}
},
{
"key": "ending",
"role": "ending",
"layoutId": "standard-01",
"intent": "以轻量行动和提问收尾",
"bindings": {
"T01": "title",
"T02": "body",
"T03": "caption"
},
"parameters": {
"texts": {
"T01": {
"value": "留一点白\n给下一次",
"align": "middle"
},
"T02": {
"value": "今天,捡起一片叶子。\n用三个词记录它的颜色、纹理与心情。\n你的秋天,会从哪一页开始?",
"align": "middle"
},
"T03": {
"value": "YOUR TURN / 03"
}
},
"images": {
"I01": {
"fit": "cover",
"cropX": 65,
"cropY": 50,
"zoom": 1
}
},
"decorations": {},
"layers": {
"I01": {
"x": 40,
"y": 55,
"width": 40,
"height": 40,
"radius": 20
}
}
}
}
]
}
}
+4 -5
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+"},
@@ -22,8 +23,6 @@ const nextConfig: NextConfig = {
{source:"/agent/index.md",destination:"/api/agent-index"},
{source:"/skills/:skill/:doc.md",destination:"/api/skill-docs/:skill/:doc"},
]},
output: "standalone", outputFileTracingRoot: resolve(process.cwd(), "../.."),
outputFileTracingIncludes: {"/*":["./node_modules/playwright-core/**/*"]},
poweredByHeader: false,
};
+1 -5
View File
@@ -7,15 +7,11 @@
},
"scripts": {
"dev": "pnpm brand:prepare && next dev",
"build": "pnpm brand:prepare && next build && esbuild src/worker.ts --bundle --platform=node --format=cjs --external:pg-native --external:playwright-core --outfile=dist/worker.cjs && esbuild scripts/bootstrap.ts --bundle --platform=node --format=cjs --external:pg-native --external:playwright-core --outfile=dist/bootstrap.cjs && esbuild scripts/publish-learning-series.ts --bundle --platform=node --format=cjs --external:pg-native --external:playwright-core --outfile=dist/publish-learning-series.cjs && esbuild scripts/auth-cli.ts --bundle --platform=node --format=cjs --external:pg-native --outfile=dist/auth-cli.cjs && node scripts/prepare-standalone.mjs",
"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/auth-cli.ts --bundle --platform=node --format=cjs --external:pg-native --outfile=dist/auth-cli.cjs",
"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",
"storage:prepare": "tsx scripts/bootstrap.ts",
"content:publish-learning": "tsx scripts/publish-learning-series.ts",
"worker": "tsx src/worker.ts",
"test:integration": "tsx --test tests/integration.test.ts",
"brand:prepare": "node ../../packages/brand/scripts/prepare-app.mjs"
},
"dependencies": {
@@ -1,9 +0,0 @@
try {
const port = process.env.PORT || '3000';
const [health, ready] = await Promise.all([
fetch(`http://127.0.0.1:${port}/api/health`, { signal: AbortSignal.timeout(5000) }),
fetch(`http://127.0.0.1:${port}/api/ready`, { signal: AbortSignal.timeout(5000) }),
]);
const data = await health.json();
if (!health.ok || !ready.ok || data.service !== 'prompt' || data.release !== (process.env.RELEASE_SHA || 'development')) process.exit(1);
} catch { process.exit(1); }
@@ -1,38 +0,0 @@
import {readFile} from 'node:fs/promises';
import {fileURLToPath} from 'node:url';
import {join,dirname} from 'node:path';
import {createHash} from 'node:crypto';
import {requiredPackImages} from './verify-character-pack.mjs';
const root=join(dirname(fileURLToPath(import.meta.url)),'..','seed','characters');
const base=process.env.PROMPT_IMPORT_BASE_URL||'http://127.0.0.1:3000';
const media=JSON.parse(await readFile(join(root,'..','..','src','content','managed-media.json'),'utf8'));
const packFiles=pack=>Object.entries(media).filter(([name])=>name.startsWith(`apps/waterfall-wiki/seed/characters/${pack.directory}/`)).sort(([a],[b])=>a.localeCompare(b));
const packs=JSON.parse(await readFile(join(root,'packs.json'),'utf8'));
async function verify(pack){
let cursor='',post;const seen=new Set();
do{const response=await fetch(base+'/api/feed?'+new URLSearchParams({section:'images',...(cursor?{cursor}:{})}));if(!response.ok)throw Error('Cannot verify character feed: '+response.status);const page=await response.json();post=page.posts.find(p=>p.slug===pack.slug);cursor=page.nextCursor||'';if(cursor&&seen.has(cursor))throw Error('Character feed cursor did not advance');seen.add(cursor);}while(!post&&cursor);
if(!post)throw Error('Character pack missing: '+pack.slug);
const files=packFiles(pack);if(files.length!==pack.count)throw Error('Seed file count mismatch: '+pack.slug);
const hashes=files.map(([,asset])=>asset.sha256);
const originals=requiredPackImages(hashes,post.assets.filter(a=>a.kind==='image'));
for(const image of originals){const media=await fetch(`${base}/api/media/${image.id}`,{method:'HEAD'});if(!media.ok||media.headers.get('content-type')!=='image/png')throw Error('Original PNG unavailable');}
console.log(`Verified: /p/${pack.slug} (${originals.length} original PNGs preserved; editorial covers allowed)`);
}
for(const pack of packs){
const existing=await fetch(`${base}/p/${pack.slug}`);
if(existing.ok){console.log(`Already imported: /p/${pack.slug}`);await verify(pack);continue;}
if(existing.status!==404)throw new Error(`Cannot check pack ${pack.slug}: ${existing.status}`);
const files=packFiles(pack);
if(files.length!==pack.count)throw new Error(`Expected ${pack.count} PNGs for ${pack.slug}, got ${files.length}`);
const data=new FormData();data.set('title',pack.title);data.set('slug',pack.slug);data.set('description',pack.description);
for(const [name,asset] of files){
const response=await fetch(`${base}/api/media/${asset.id}`);if(!response.ok)throw Error('Cannot read migrated original: '+name);
const bytes=Buffer.from(await response.arrayBuffer());if(bytes.length!==asset.bytes||createHash('sha256').update(bytes).digest('hex')!==asset.sha256)throw Error('Original checksum mismatch: '+name);
data.append('files',new Blob([bytes],{type:'image/png'}),name.split('/').pop());
}
const response=await fetch(`${base}/api/skills/street-people`,{method:'POST',body:data});
if(response.status!==201&&response.status!==409)throw new Error(`Import ${pack.slug}: ${response.status} ${await response.text()}`);
if(response.status===409&&!(await fetch(`${base}/p/${pack.slug}`)).ok)throw new Error(`Conflicting import ${pack.slug}`);
console.log(`Imported: /p/${pack.slug} (${files.length} transparent PNGs)`);
await verify(pack);
}
@@ -1,4 +0,0 @@
import { cp,mkdir,access } from 'node:fs/promises';
await mkdir('.next/standalone/apps/waterfall-wiki/.next',{recursive:true});
await cp('.next/static','.next/standalone/apps/waterfall-wiki/.next/static',{recursive:true});
try{await access('public');await cp('public','.next/standalone/apps/waterfall-wiki/public',{recursive:true});}catch(e){if(e.code!=='ENOENT')throw e;}
@@ -1,5 +0,0 @@
import { db } from "../src/lib/db";
import { publishLearningSeries } from "../src/lib/publish-learning-series";
publishLearningSeries().then(result=>console.log(`Published learning series: ${result.published.join(", ")}; one shared cover: ${result.coverId}`))
.catch(error=>{console.error("Learning series publication failed:",error instanceof Error?error.message:"Unknown error");process.exitCode=1;})
.finally(()=>db().end());
-65
View File
@@ -1,65 +0,0 @@
import {writeFile,unlink} from 'node:fs/promises';
import sharp from 'sharp';
import { spawn } from "node:child_process";
import assert from "node:assert/strict";
import { setTimeout } from "node:timers/promises";
import { createServer } from "node:net";
import { once } from "node:events";
async function availablePort() {
const server = createServer();
server.listen(0, "127.0.0.1");
await once(server, "listening");
const port = server.address().port;
await new Promise((resolve, reject) => server.close(error => error ? reject(error) : resolve()));
return String(port);
}
const port = process.env.SMOKE_PORT || await availablePort();
const release = "smoke-test";
const previewFixture='.next/standalone/apps/waterfall-wiki/public/__smoke-preview.png';
await writeFile(previewFixture,await sharp({create:{width:48,height:48,channels:3,background:'#eee'}}).png().toBuffer());
const child = spawn(process.execPath, [".next/standalone/apps/waterfall-wiki/server.js"], {
// This loopback-only child checks retained resources without production credentials.
// Production keeps failing closed; the bypass requires both explicit test settings.
env: { ...process.env, AUTH_CONFIG_FILE: "", AUTH_DISABLED_FOR_TESTS: "true", PORT: port, HOSTNAME: "127.0.0.1", RELEASE_SHA: release },
stdio: "inherit",
});
try {
let ready = false;
for (let i = 0; i < 40; i++) {
if (child.exitCode !== null) throw new Error(`Server exited: ${child.exitCode}`);
try {
const response = await fetch(`http://127.0.0.1:${port}/api/health`);
assert.equal(response.status, 200);
assert.deepEqual(await response.json(), { status: "ok", service: "prompt", release });
ready = true;
break;
} catch { await setTimeout(500); }
}
assert.ok(ready, "Standalone production server must be healthy");
const response = await fetch(`http://127.0.0.1:${port}/api/health`, { method: "POST" });
assert.equal(response.status, 405);
const indexResponse=await fetch(`http://127.0.0.1:${port}/api/wiki-index`);
assert.equal(indexResponse.status,200);
const indexText=await indexResponse.text();
const id=indexText.match(/\/explore\/([a-f0-9]{24})/)?.[1];assert.ok(id);
const agent=await fetch(`http://127.0.0.1:${port}/explore/${id}?agent_mode=True`);
assert.equal(agent.status,200);assert.match(agent.headers.get('content-type'),/text\/plain/);const agentText=await agent.text();assert.match(agentText,/^# /);assert.doesNotMatch(agentText,/^\s*(?:<!doctype html|<html)/i);
const preview=await fetch(`http://127.0.0.1:${port}/api/preview?source=${encodeURIComponent('/__smoke-preview.png')}&width=480`);
assert.equal(preview.status,200);assert.match(preview.headers.get('content-type'),/image\/webp/);assert.ok((await preview.arrayBuffer()).byteLength<=100000);
const home=await fetch(`http://127.0.0.1:${port}/`).then(r=>r.text());
for(const href of ['/graphic','/infinite_canvas','/account?next=/workspace'])assert.ok(home.includes(`href="${href}"`),href);
for(const [legacy,canonical] of [['/studio/graphic','/graphic'],['/studio/infinite_canvas','/infinite_canvas']]){
const redirect=await fetch(`http://127.0.0.1:${port}${legacy}`,{redirect:'manual'});
assert.equal(redirect.status,308);assert.equal(redirect.headers.get('location'),canonical);
}
console.log("Production smoke test passed, including landing entry points and canonical redirects");
} finally {
await unlink(previewFixture);
if (child.exitCode === null && child.signalCode === null) {
const exited = once(child, "exit");
child.kill("SIGTERM");
await exited;
}
}
@@ -1,6 +0,0 @@
/** Extra editorial covers are allowed; every original seed image must remain attached. */
export function requiredPackImages(hashes,images){
const remaining=[...images],originals=[];
for(const hash of hashes){const index=remaining.findIndex(image=>image.sha256===hash);if(index<0)throw Error('Original seed image missing from character pack');originals.push(...remaining.splice(index,1));}
return originals;
}
File diff suppressed because one or more lines are too long
+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} />;
}
@@ -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');
@@ -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}/>}
@@ -0,0 +1 @@
export * from '../../lib/canvas-document.ts';
@@ -9,7 +9,7 @@ import type {Design} from './native-layout';
/** Only forward the session to our fixed private Wiki service, never a request Host. */
export async function readDesign(id:unknown,req:IncomingMessage):Promise<Design|null>{
if(!uuid(id))return null;
const origin=process.env.STUDIO_WIKI_ORIGIN||'http://waterfall-wiki:3000';
const origin=process.env.STUDIO_WIKI_ORIGIN||`http://127.0.0.1:${process.env.PORT || '3000'}`;
const headers:Record<string,string>={};
if(req.headers.cookie)headers.cookie=req.headers.cookie;
if(req.headers.authorization)headers.authorization=req.headers.authorization;
@@ -4,7 +4,7 @@ import type {Parameters} from './layout-parameters';
export async function embedLayoutImages(req:NextApiRequest,params:Parameters){
const ids=[...new Set(Object.values(params.images).flatMap(v=>v.assetId?[v.assetId]:[]))],sources:Record<string,string>={};let total=0;
const headers:Record<string,string>={};if(req.headers.authorization)headers.authorization=req.headers.authorization;if(req.headers.cookie)headers.cookie=req.headers.cookie;
for(const id of ids){const url=(process.env.STUDIO_WIKI_ORIGIN||'http://waterfall-wiki:3000')+'/api/preview?'+new URLSearchParams({source:'/api/media/'+id,width:'960'}),r=await fetch(url,{headers,signal:AbortSignal.timeout(15000),redirect:'error'});if(!r.ok||r.headers.get('content-type')?.split(';')[0]!=='image/webp')throw Error('图片预览不可访问,请检查图片 ID 与登录状态');const chunks:Buffer[]=[];const reader=r.body!.getReader();try{while(true){const {done,value}=await reader.read();if(done)break;total+=value.length;if(total>16*1024*1024)throw Error('图片预览总计超限');chunks.push(Buffer.from(value))}}finally{await reader.cancel()}sources[id]='data:image/webp;base64,'+Buffer.concat(chunks).toString('base64');}
for(const id of ids){const url=(process.env.STUDIO_WIKI_ORIGIN||`http://127.0.0.1:${process.env.PORT || '3000'}`)+'/api/preview?'+new URLSearchParams({source:'/api/media/'+id,width:'960'}),r=await fetch(url,{headers,signal:AbortSignal.timeout(15000),redirect:'error'});if(!r.ok||r.headers.get('content-type')?.split(';')[0]!=='image/webp')throw Error('图片预览不可访问,请检查图片 ID 与登录状态');const chunks:Buffer[]=[];const reader=r.body!.getReader();try{while(true){const {done,value}=await reader.read();if(done)break;total+=value.length;if(total>16*1024*1024)throw Error('图片预览总计超限');chunks.push(Buffer.from(value))}}finally{await reader.cancel()}sources[id]='data:image/webp;base64,'+Buffer.concat(chunks).toString('base64');}
return sources;
}
@@ -14,7 +14,7 @@ export async function embedSvgMedia(req:NextApiRequest,svg:string){
const headers:Record<string,string>={};if(req.headers.authorization)headers.authorization=req.headers.authorization;if(req.headers.cookie)headers.cookie=req.headers.cookie;
let total=0;
for(const path of paths){
const response=await fetch((process.env.STUDIO_WIKI_ORIGIN||'http://waterfall-wiki:3000')+path,{headers,redirect:'error',signal:AbortSignal.timeout(15000)});
const response=await fetch((process.env.STUDIO_WIKI_ORIGIN||`http://127.0.0.1:${process.env.PORT || '3000'}`)+path,{headers,redirect:'error',signal:AbortSignal.timeout(15000)});
const mime=response.headers.get('content-type')?.split(';')[0];
if(!response.ok||!mime||!['image/png','image/jpeg','image/webp'].includes(mime))throw Error('模板图片不可访问');
const chunks:Uint8Array[]=[];const reader=response.body!.getReader();
@@ -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}
@@ -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,51 +0,0 @@
import assert from 'node:assert/strict';
import { randomUUID } from 'node:crypto';
import { db } from '../src/lib/db';
import { hashPassword,newToken,tokenDigest } from '../src/lib/account-crypto';
import { resetPassword,loginAccount,verifyAccount,currentAccount,logoutAccount,rateLimit } from '../src/lib/account';
export async function verifyEmailAccounts(base: string) {
const id=randomUUID(), email=id+'@example.com', password='a secure test password';
const request=(token:string)=>new Request('https://wiki.example',{headers:{cookie:'wiki_account='+token}});
try {
await db().query('INSERT INTO account_users(id,email,password_hash) VALUES ($1,$2,$3)',[id,email,await hashPassword(password)]);
assert.equal(await loginAccount(email,password),null,'unverified accounts cannot log in');
const verification=newToken();await db().query("INSERT INTO account_tokens VALUES ($1,$2,'verify',now()+interval '1 minute')",[tokenDigest(verification),id]);
const results=await Promise.all([verifyAccount(verification),verifyAccount(verification)]);assert.deepEqual(results.sort(),[false,true]);
assert.equal(await loginAccount(email,'incorrect password'),null);
const token=await loginAccount(email,password);assert.ok(token);
assert.equal((await currentAccount(request(token))).id,id);
await db().query("UPDATE account_tokens SET expires_at=now()-interval '1 second' WHERE digest=$1",[tokenDigest(token)]);
assert.equal(await currentAccount(request(token)),null,'expired sessions rejected');
const fresh=await loginAccount(email,password);assert.ok(fresh);await logoutAccount(request(fresh));assert.equal(await currentAccount(request(fresh)),null,'logout revokes token');
const oldSession=await loginAccount(email,password);assert.ok(oldSession);
const resets=[newToken(),newToken()],replacement='a new secure test password';
for(const value of resets)await db().query("INSERT INTO account_tokens VALUES ($1,$2,'reset',now()+interval '30 minutes')",[tokenDigest(value),id]);
const resetResults=await Promise.all(resets.map(value=>resetPassword(value,replacement)));
assert.deepEqual(resetResults.sort(),[false,true],'Only one concurrent reset may succeed for one account');
assert.equal(await currentAccount(request(oldSession)),null,'Reset revokes existing sessions');
assert.equal(await loginAccount(email,password),null,'Original password is no longer accepted');
assert.ok(await loginAccount(email,replacement));
assert.equal(await resetPassword(resets[0],password),false,'Reset links are single-use, including sibling links');
const expired=newToken();await db().query("INSERT INTO account_tokens VALUES ($1,$2,'reset',now()-interval '1 minute')",[tokenDigest(expired),id]);
assert.equal(await resetPassword(expired,password),false);
const wrongKind=newToken();await db().query("INSERT INTO account_tokens VALUES ($1,$2,'verify',now()+interval '30 minutes')",[tokenDigest(wrongKind),id]);
assert.equal(await resetPassword(wrongKind,password),false,'Email verification cannot reset a password');
assert.equal(await resetPassword(newToken(),'short'),false);
const post=(action:string,body:object,origin=process.env.ACCOUNT_PUBLIC_ORIGIN||base)=>fetch(base+'/api/account/'+action,{method:'POST',headers:{'Content-Type':'application/json',Origin:origin},body:JSON.stringify(body)});
assert.equal((await post('forgot',{email},'https://untrusted.invalid')).status,403,'Cross-origin recovery is rejected');
const unknown=await post('forgot',{email:randomUUID()+'@example.com'});
assert.equal(unknown.status,200,'Unknown email receives the same non-enumerating response without sending mail');
assert.match((await unknown.json()).message,/若该邮箱/);
const httpToken=newToken(),httpPassword='http replacement secure password';
const priorHttpSession=await loginAccount(email,replacement);assert.ok(priorHttpSession);
await db().query("INSERT INTO account_tokens VALUES ($1,$2,'reset',now()+interval '30 minutes')",[tokenDigest(httpToken),id]);
const changed=await post('reset',{token:httpToken,password:httpPassword});
assert.equal(changed.status,200);
assert.match(changed.headers.get('set-cookie')||'',/Max-Age=0/);
assert.equal(await currentAccount(request(priorHttpSession)),null);
assert.ok(await loginAccount(email,httpPassword),'HTTP recovery changes the stored password');
assert.equal((await post('reset',{token:httpToken,password:replacement})).status,400,'HTTP recovery rejects a consumed token');
const key=randomUUID();assert.deepEqual(await Promise.all(Array.from({length:5},()=>rateLimit(key,3))).then(a=>a.sort()),[false,false,true,true,true]);
await db().query('DELETE FROM account_limits WHERE key=$1',[tokenDigest(key)]);
} finally {await db().query('DELETE FROM account_users WHERE id=$1',[id]);}
}

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