Compare commits

..

8 Commits

Author SHA1 Message Date
codex-bot 896c8bb9e5 Record preview synchronization and runtime identity
Unified application CI and deployment / static-checks (push) Successful in 6s
Remote development preview source sync / sync-source (push) Successful in 7s
Unified application CI and deployment / static-checks (pull_request) Successful in 5s
python CI and deployment / static-checks (pull_request) Successful in 3s
Unified application CI and deployment / verify-and-release (push) Has been skipped
Unified application CI and deployment / verify-and-release (pull_request) Has been skipped
python CI and deployment / verify-and-deploy (pull_request) Has been skipped
2026-11-04 14:34:45 +08:00
codex-bot 1df5790d23 Give the preview process ownership of its Next cache
Unified application CI and deployment / static-checks (push) Successful in 7s
Remote development preview source sync / sync-source (push) Successful in 1m28s
Unified application CI and deployment / static-checks (pull_request) Successful in 5s
python CI and deployment / static-checks (pull_request) Successful in 3s
Unified application CI and deployment / verify-and-release (push) Has been skipped
Unified application CI and deployment / verify-and-release (pull_request) Has been skipped
python CI and deployment / verify-and-deploy (pull_request) Has been skipped
2026-11-04 14:32:04 +08:00
codex-bot fe26f03cf4 Use numeric container ownership during preview setup
Unified application CI and deployment / static-checks (push) Successful in 5s
Remote development preview source sync / sync-source (push) Failing after 4m54s
Unified application CI and deployment / static-checks (pull_request) Successful in 5s
python CI and deployment / static-checks (pull_request) Successful in 3s
Unified application CI and deployment / verify-and-release (push) Has been skipped
Unified application CI and deployment / verify-and-release (pull_request) Has been skipped
python CI and deployment / verify-and-deploy (pull_request) Has been skipped
2026-11-04 14:25:41 +08:00
codex-bot 19e179dd20 Run a persistent remote development preview
Unified application CI and deployment / static-checks (push) Successful in 5s
Remote development preview source sync / sync-source (push) Failing after 17s
python CI and deployment / static-checks (push) Successful in 4s
Unified application CI and deployment / static-checks (pull_request) Successful in 5s
python CI and deployment / static-checks (pull_request) Successful in 4s
Unified application CI and deployment / verify-and-release (push) Has been skipped
python CI and deployment / verify-and-deploy (push) Has been skipped
Unified application CI and deployment / verify-and-release (pull_request) Has been skipped
python CI and deployment / verify-and-deploy (pull_request) Has been skipped
2026-11-04 14:24:01 +08:00
codex-bot 7f88ebba71 Run only static checks before main and gate full release on main push
Unified application CI and deployment / static-checks (push) Successful in 1m9s
python CI and deployment / static-checks (push) Successful in 1m7s
Unified application CI and deployment / static-checks (pull_request) Successful in 1m7s
python CI and deployment / static-checks (pull_request) Successful in 1m6s
Unified application CI and deployment / verify-and-release (push) Has been skipped
python CI and deployment / verify-and-deploy (push) Has been skipped
Unified application CI and deployment / verify-and-release (pull_request) Has been skipped
python CI and deployment / verify-and-deploy (pull_request) Has been skipped
2026-11-04 04:24:31 +08:00
codex-bot 0a77d03220 Cache dependency downloads by lockfile and preserve Studio runtime checks
python CI and deployment / verify-and-deploy (pull_request) Successful in 1m12s
Unified application CI and deployment / verify-and-release (pull_request) Has been cancelled
2026-11-04 04:02:48 +08:00
codex-bot 6654a1456b Unify Wiki and Studio application and simplify deployment
Unified application CI and deployment / verify-and-release (pull_request) Waiting to run
python CI and deployment / verify-and-deploy (pull_request) Successful in 1m12s
2026-11-04 03:41:16 +08:00
codex-bot 80d23f0e3d Improve Docker dependency caching and isolate Python runtime
Endoleap brand checks / verify (pull_request) Successful in 2m27s
creative workspace review checks / verify-deployed-canvas (pull_request) Successful in 3m10s
creative workspace review checks / verify (pull_request) Successful in 42m47s
python CI and deployment / verify-and-deploy (pull_request) Successful in 9m51s
2026-11-02 21:54:14 +08:00
167 changed files with 15191 additions and 46 deletions
+39
View File
@@ -0,0 +1,39 @@
.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
# Apply local metadata, secrets and generated-output exclusions at every depth.
**/.git
**/.worktrees
**/.pnpm-store
**/.turbo
**/.nx
**/coverage
**/artifacts
**/.pytest_cache
**/*.egg-info
**/*.tsbuildinfo
**/.env
**/.env.*
!**/.env.example
+79
View File
@@ -0,0 +1,79 @@
name: Unified application CI and deployment
on:
pull_request:
branches: [main]
paths:
- 'apps/waterfall-wiki/**'
- 'packages/brand/**'
- 'scripts/**'
- 'Dockerfile'
- '.dockerignore'
- 'compose.yml'
- 'pnpm-workspace.yaml'
- '.gitea/workflows/app.yml'
push:
branches: ["**"]
paths:
- 'apps/waterfall-wiki/**'
- 'packages/brand/**'
- 'scripts/**'
- 'Dockerfile'
- '.dockerignore'
- 'compose.yml'
- 'pnpm-workspace.yaml'
- '.gitea/workflows/app.yml'
concurrency:
group: unified-app-${{ gitea.event.pull_request.number || gitea.ref }}
cancel-in-progress: false
jobs:
static-checks:
runs-on: ces-control
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 2
- name: Check patch, syntax and source contracts without building
run: |
git diff --check HEAD^ HEAD
for script in scripts/*.sh scripts/preview/*.sh; do bash -n "$script"; done
node scripts/check-studio-contracts.mjs
node --check scripts/preview/fingerprint.mjs
node --check scripts/verify-studio-visual.cjs
node --check apps/waterfall-wiki/scripts/infinite-canvas-smoke.cjs
node --check apps/waterfall-wiki/scripts/cleanup-ci-bucket.mjs
python3 -c 'import ast,pathlib; ast.parse(pathlib.Path("scripts/wiki-image-lifecycle.py").read_text())'
verify-and-release:
needs: static-checks
if: gitea.event_name == 'push' && gitea.ref == 'refs/heads/main'
runs-on: ces-control
timeout-minutes: 60
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Build and verify the unified application once
run: |
docker build --target integration -t "waterfall-wiki-tests:${{ gitea.sha }}" .
docker build --target runtime -t "waterfall-wiki:${{ gitea.sha }}" .
- name: Verify candidate using persistent test infrastructure
env:
WATERFALL_WIKI_IMAGE: waterfall-wiki:${{ gitea.sha }}
WATERFALL_WIKI_TEST_IMAGE: waterfall-wiki-tests:${{ gitea.sha }}
GITEA_RUN_ID: ${{ gitea.run_id }}
run: bash scripts/integration.sh
- name: Deploy unified Web and worker; verify production
if: gitea.event_name == 'push' && gitea.ref == 'refs/heads/main'
run: bash scripts/deploy.sh "${{ gitea.sha }}"
- name: Save production acceptance evidence
id: evidence
if: always() && gitea.event_name == 'push'
uses: actions/upload-artifact@v3
with:
name: unified-production-acceptance
path: artifacts/studio-acceptance/
if-no-files-found: ignore
retention-days: 7
- name: Remove uploaded local evidence
if: always() && steps.evidence.outcome == 'success'
run: rm -rf artifacts/studio-acceptance
+32
View File
@@ -0,0 +1,32 @@
name: Remote development preview source sync
on:
push:
branches: ['**']
paths:
- 'apps/waterfall-wiki/**'
- 'packages/brand/**'
- 'pnpm-workspace.yaml'
- 'scripts/preview/**'
- '.gitea/workflows/preview.yml'
concurrency:
group: development-preview
cancel-in-progress: false
jobs:
sync-source:
# One preview slot follows one branch. Setup changes use an explicit refresh.
if: gitea.ref == format('refs/heads/{0}', vars.PREVIEW_BRANCH || 'codex/unified-web')
runs-on: ces-control
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Initialize once, otherwise synchronize files only
env:
SOURCE_SHA: ${{ gitea.sha }}
SOURCE_BRANCH: ${{ gitea.ref_name }}
run: |
if [ ! -f /opt/prompt-preview/ready ]; then
bash scripts/preview/init.sh "$PWD" waterfall-wiki-tests:6654a1456bdcef77d83ca84373dc36188e15bd12
fi
bash scripts/preview/sync.sh "$PWD" "$SOURCE_SHA" "$SOURCE_BRANCH"
+44
View File
@@ -0,0 +1,44 @@
name: python CI and deployment
on:
pull_request:
branches: [main]
paths:
- '.dockerignore'
- 'apps/python/**'
- 'scripts/deploy-python.sh'
- '.gitea/workflows/python.yml'
push:
branches: ["**"]
paths:
- '.dockerignore'
- 'apps/python/**'
- 'scripts/deploy-python.sh'
- '.gitea/workflows/python.yml'
jobs:
static-checks:
runs-on: ces-control
timeout-minutes: 5
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 2
- name: Check Python and deployment syntax without executing tests
run: |
git diff --check HEAD^ HEAD
bash -n scripts/deploy-python.sh
python3 -c 'import ast,pathlib; [ast.parse(p.read_text(),filename=str(p)) for p in pathlib.Path("apps/python").rglob("*.py")]'
verify-and-deploy:
needs: static-checks
if: gitea.ref == 'refs/heads/main' && gitea.event_name == 'push'
runs-on: ces-control
timeout-minutes: 45
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 }}"
@@ -0,0 +1,19 @@
name: Wiki image retention checks
on:
push:
branches: [main]
paths:
- 'scripts/wiki-image-lifecycle.py'
- 'tests/test_wiki_image_lifecycle.py'
- '.gitea/workflows/wiki-image-retention-checks.yml'
jobs:
retention-policy:
if: gitea.ref == 'refs/heads/main' && gitea.event_name == 'push'
runs-on: ces-control
timeout-minutes: 5
steps:
- uses: actions/checkout@v4
- name: Check retention without building or accessing the Docker daemon
run: |
bash -n scripts/deploy.sh
PYTHONDONTWRITEBYTECODE=1 python3 -m unittest discover -s tests -p 'test_wiki_image_lifecycle.py' -v
+12 -3
View File
@@ -8,7 +8,16 @@ This block is written and re-added by `next dev` — verify at `node_modules/nex
<!-- END:nextjs-agent-rules -->
## 构建与维护
## 交付与视觉验收
- 构建和网页运行使用用户确认的远程环境。本地可执行差异审查和无需构建的轻量检查。
- Wiki 和 Studio 共用 `apps/waterfall-wiki` 的 Pages Router、登录保护和品牌包。保留已发布的路由、媒体标识和数据库迁移。
- 用户指定服务器为唯一运行与验收入口: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 执行,不在本机启动预览或构建。
+46
View File
@@ -0,0 +1,46 @@
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
# Download cache depends on lockfile and workspace policy, not package scripts.
RUN pnpm fetch --lockfile-dir=/repo/apps/waterfall-wiki --fetch-timeout=300000 --network-concurrency=8
COPY packages/brand/package.json /repo/packages/brand/package.json
COPY apps/waterfall-wiki/package.json ./
RUN pnpm --filter @prompt/waterfall-wiki install --offline --frozen-lockfile
FROM dependencies AS build
# .dockerignore excludes docs and operational notes without extra legacy COPY snapshots.
# Brand source changes invalidate compilation, not dependency installation.
COPY packages/brand/ /repo/packages/brand/
COPY apps/waterfall-wiki/ .
RUN pnpm typecheck && pnpm test && pnpm build && pnpm test:smoke
FROM build AS integration
ENV VERIFY_CANVAS_UI=1
USER node
CMD ["npm", "run", "test:integration"]
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"]
+93 -15
View File
@@ -1,26 +1,104 @@
# 啡跃 Endoleap
# PROMPT
啡跃把笔记、参考素材和创作工作台放在同一个 Next.js 应用里。应用位于 `apps/waterfall-wiki`,共享主题与品牌资源位于 `packages/brand`。
把提示词、参考图、分步文档和创作工具收进同一个可连接的知识库。人可以浏览瀑布流、搜索和编辑;AI 可以从同一篇 post 读取纯文本、图片与 API 说明,再调用 Studio 生成作品。
| 入口 | 用途 |
**PROMPT 是网站与项目品牌;`waterfall-wiki` 是知识库应用的内部名称。** 目录、包、CI、镜像和 Compose 服务使用内部名称,网站导航与页面标题使用 PROMPT。
线上入口:[PROMPT](http://8.211.154.134:18131/)。内容、媒体、API 与 Studio 共用登录入口。
## 从一篇 post 开始
每篇内容都有稳定的 `/explore/<id>` 地址。标题、标签、正文和封面可以修改,链接保持不变。数据库内容可以保留 24 位 public ID,内部仍使用 UUID;旧 `/p/<slug>`、`/skills/...` 等地址保留兼容跳转。
- **浏览**:首页按不同笔记的反向引用数优先排序。重复引用和自引用不重复计数,代码、图片与外站链接不冒充笔记引用。详情可以查看引用来源。
- **搜索**:标题、简介、正文、普通标签和系统标签共同匹配。支持多个关键词及双引号短语,先按相关性、再按引用数和时间排序;先筛选再分页。
- **标签**:普通标签使用 `#中国风`、`#贴纸` 等名称,可以创建、添加或移除。系统标签是固定列表,目前为“文档入口”和“参考图”,只能搜索并勾选,不能通过 `#` 创建。系统标签显示在图片角标,普通标签显示在文字区;分类页顶部按系统标签筛选。
- **编辑**:卡片与详情都能直接编辑标签;完整编辑器可以修改标题、简介、Markdown。保存使用 revision 检测并发冲突,避免旧窗口覆盖新修改。
- **素材**:原件和加工结果都是普通资产,可被多篇 post 引用。文档封面与实际参考素材分开,子文档可以有自己的封面,同时保留原参考图。
## 给 AI 的入口
在同一个 post 地址加 `?agent_mode=True` 可以读取纯文本正文和媒体链接。业务 API 的相同参数会先返回接口说明;已有查询参数时使用 `&agent_mode=True`。这些入口同样需要登录或有效 Bearer JWT。
| 地址 | 用途 |
| --- | --- |
| `/` | 浏览内容,打开 `/explore/<id>` 查看笔记 |
| `/guide` | 浏览指导文档 |
| `/graphic` | 图文排版 |
| `/infinite_canvas` | 无限画布 |
| `/explore/<id>` | 人看的 post;加 `?agent_mode=True` 读纯文本 |
| `/api?agent_mode=True` | 业务 API 目录 |
| `/studio/api` | Studio 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 |
## 运行
标签 PATCH 请求为 `{ "tags": ["标签 UUID"], "revision": "GET 返回的 revision" }`。它不会要求用户重输正文。Tag 对象的 `kind` 为 `normal` 或 `system`;系统标签还有稳定的 `system_key`。普通创建接口不允许提交 `kind` 或 `system_key`。
在已确认的远程环境准备 Node.js 24、pnpm 11.25.0、PostgreSQL、S3 兼容对象存储和 FFmpeg。参考 [环境变量示例](apps/waterfall-wiki/.env.example),将变量注入 Web、初始化和媒体 worker 进程。邮箱注册与找回密码需要可用的 SMTP 服务。
Wiki 中的教程是普通 post 与媒体数据,不需要为每份资料增加专用网页路由、JSON 文件端点或代码注册。
在仓库根目录运行 `pnpm install --frozen-lockfile` 和 `pnpm build`。然后在 `apps/waterfall-wiki` 目录准备数据库和存储,初始化管理员认证并启动 Web。
## 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/waterfall-wiki/src/studio` | 合并后的创作工具、排版渲染与编辑器模块 |
| `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+。
```sh
node dist/bootstrap.cjs
node dist/auth-cli.cjs init
pnpm start
pnpm install
pnpm --dir apps/waterfall-wiki typecheck
pnpm --dir apps/waterfall-wiki test
node scripts/check-studio-contracts.mjs
for script in scripts/*.sh; do bash -n "$script"; done
```
媒体 worker 在同一目录作为独立进程运行 `node dist/worker.cjs`。绑定管理员验证器时,在远程交互终端执行 `node dist/auth-cli.cjs qr`。已有实例复用原数据库、对象存储和私有认证文件。
完整集成测试只针对显式隔离的数据库、对象存储和生产容器运行,覆盖存储、视频加工、编辑冲突、真实搜索与引用、标签规则和登录保护。不要把生产存储配置拿来运行集成测试。
本项目以指定服务器为唯一网页运行与验收入口:先推送,再等待 ces-control CI/CD,最后直接检查服务器。不要启动本地网页预览,不用 localhost 页面作为视觉验收或交付入口。具体约定见 [AGENTS.md](AGENTS.md)。
## 部署与持久化
Web 应用由 `.gitea/workflows/app.yml` 统一验证与发布。Wiki 和 Studio 已合并成一个 Next.js 应用,一次编译、一个 Web 运行镜像,Web 与后台 worker 使用同一镜像。PostgreSQL/S3 基础服务长期运行,不在日常发布中重建。工作分支 push/PR 仅做轻量静态检查;只有 main push 才构建、运行测试并更新生产。完整步骤见 [统一部署流程](docs/unified-deployment.md)。
| 服务 | 入口/端口 |
| --- | --- |
| `waterfall-wiki` | 公网 `18131`,也是 Studio 的统一入口 |
| PostgreSQL | `127.0.0.1:18132` |
| 对象存储 | `127.0.0.1:18133` |
| 编辑器 | 与知识站共享 `18131`;原生路由 `/graphic`、`/infinite_canvas`,历史 `/studio` 页面与 API 保留兼容 |
| Python | 无网络端口,按需运行 |
生产 Compose 项目仍为 `prompt`,部署根目录仍为 `/opt/prompt`。既有 `prompt_postgres_data`、`prompt_objects`、`prompt_auth_data` 卷与数据库身份保留;应用更名不会重新创建一套空存储。Compose 应用服务改为 `waterfall-wiki` 和 `waterfall-wiki-worker`,镜像参数为 `WATERFALL_WIKI_IMAGE` / `WATERFALL_WIKI_TEST_IMAGE`。
部署在停止写入后备份数据库,再运行迁移和启动新版。失败时恢复兼容旧镜像的备份并回滚服务。生产不执行 `down -v`,也不清空对象文件。早期合集验证会遍历分页,不假定旧文章一定在第一页。
## 登录与自动化
共享验证器的 6 位 TOTP 动态码登录,JWT 保存在 HttpOnly Cookie 中;这属于单因素动态码登录,不是 2FA。密钥保存在服务器认证卷,不进入 Git、镜像或 HTTP 二维码接口。
服务器管理员在交互式 SSH 终端运行 `sudo /opt/prompt/auth qr` 绑定验证器。不要把二维码、动态密钥或 JWT 粘贴到聊天、日志和仓库。服务端自动化可使用 `node auth-cli.cjs run <脚本>`,在进程内为本机 API 请求附加短期凭据,不输出令牌。
更多说明:[登录与 SSH 绑定](docs/dynamic-code-login.md) · [API](docs/api.md) · [数据模型](docs/data-model.md) · [项目结构与部署](docs/monorepo.md)。
+22
View File
@@ -0,0 +1,22 @@
FROM python:3.12-slim AS base
RUN apt-get update && apt-get install -y --no-install-recommends ffmpeg fonts-noto-cjk && rm -rf /var/lib/apt/lists/*
FROM base AS dependencies
ENV PATH="/opt/venv/bin:$PATH"
WORKDIR /app
RUN python -m venv /opt/venv
COPY apps/python/pyproject.toml ./
# Read the pinned requirements from the package metadata before copying source.
RUN python -c 'import subprocess, tomllib; p = tomllib.load(open("pyproject.toml", "rb")); subprocess.check_call(["python", "-m", "pip", "install", "--no-cache-dir", *p["build-system"]["requires"], *p["project"]["dependencies"]])'
FROM dependencies AS build
COPY apps/python/ .
RUN pip install --no-cache-dir --no-deps --no-build-isolation . && python -m unittest discover -s tests -v
FROM base AS runtime
WORKDIR /app
ENV PATH="/opt/venv/bin:$PATH"
COPY --from=build /opt/venv /opt/venv
RUN useradd --create-home exporter
USER exporter
ENTRYPOINT ["prompt-export"]
+12
View File
@@ -0,0 +1,12 @@
[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
@@ -0,0 +1,70 @@
"""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
@@ -0,0 +1,13 @@
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)
+18 -14
View File
@@ -1,18 +1,22 @@
# 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
# 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
S3_BUCKET=prompt
PORT=3000
AUTH_CONFIG_FILE=/path/to/private/auth.json
AUTH_COOKIE_SECURE=true
ACCOUNT_PUBLIC_ORIGIN=https://endoleap.example
ACCOUNT_TRUST_PROXY=false
WEB_BIND_ADDRESS=0.0.0.0
WEB_PORT=18131
PG_PORT=18132
S3_PORT=18133
PROMPT_IMAGE=prompt:local
RELEASE_SHA=development
# Email verification and recovery
SMTP_HOST=
# Use HTTPS and secure cookies in production; HTTP acceptance requires explicit false.
AUTH_COOKIE_SECURE=true
ACCOUNT_PUBLIC_ORIGIN=http://8.211.154.134:18131
# Enable only behind an ingress that overwrites x-forwarded-for.
ACCOUNT_TRUST_PROXY=false
SMTP_HOST=smtpdm.aliyun.com
SMTP_PORT=465
SMTP_USERNAME=
SMTP_FROM=
SMTP_USERNAME=noreply@notice.entroia.com
SMTP_FROM=noreply@notice.entroia.com
SMTP_PASSWORD=
+44
View File
@@ -0,0 +1,44 @@
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
# Download cache depends on lockfile and workspace policy, not package scripts.
RUN pnpm fetch --lockfile-dir=/repo/apps/waterfall-wiki --fetch-timeout=300000 --network-concurrency=8
COPY packages/brand/package.json /repo/packages/brand/package.json
COPY apps/waterfall-wiki/package.json ./
RUN pnpm --filter @prompt/waterfall-wiki install --offline --frozen-lockfile
FROM dependencies AS build
# .dockerignore excludes docs and operational notes without extra legacy COPY snapshots.
# Brand source changes invalidate compilation, not dependency installation.
COPY packages/brand/ /repo/packages/brand/
COPY apps/waterfall-wiki/ .
RUN pnpm typecheck && pnpm test && pnpm build && pnpm test:smoke
FROM build AS integration
ENV VERIFY_CANVAS_UI=1
USER node
CMD ["npm", "run", "test:integration"]
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"]
@@ -0,0 +1,163 @@
{
"schemaVersion": 2,
"name": "把秋天装进一页 · 银杏观察手记",
"document": {
"version": 1,
"brief": {
"topic": "银杏与秋日观察手记",
"audience": "喜欢日常摄影与视觉手帐的小红书读者",
"intent": "以封面、观察方法和行动结尾组成三页可发布图文",
"designIntent": "奶油纸底、森林墨色、银杏金点缀;宋体大标题与克制的无衬线正文;所有页面保留相同页边距和节奏",
"imageStrategy": "生成一幅原创银杏静物插画作为系列母图;每页复用素材,保留裁切配置,不把正文烘焙进图片"
},
"style": {
"version": "autumn-journal/1",
"palette": {
"paper": "#F3EFE5",
"ink": "#253C32",
"muted": "#718074",
"accent": "#C3A050"
},
"typography": {
"title": {
"fontFamily": "serif",
"fontSize": 8.4,
"fontWeight": 600,
"lineHeight": 1.15,
"letterSpacing": 0
},
"body": {
"fontFamily": "sans",
"fontSize": 3.1,
"fontWeight": 400,
"lineHeight": 1.65,
"letterSpacing": 0
},
"caption": {
"fontFamily": "sans",
"fontSize": 1.7,
"fontWeight": 500,
"lineHeight": 1.2,
"letterSpacing": 0
}
},
"spacing": {
"margin": 10,
"gap": 4
}
},
"sources": [],
"pages": [
{
"key": "cover",
"role": "cover",
"layoutId": "standard-01",
"intent": "用银杏静物与两行大标题建立主题",
"bindings": {
"T01": "title",
"T02": "body",
"T03": "caption"
},
"parameters": {
"texts": {
"T01": {
"value": "把秋天\n装进一页"
},
"T02": {
"value": "不必走很远。\n一片银杏,就是今天的风景。"
},
"T03": {
"value": "SEASONAL NOTES / 01"
}
},
"images": {
"I01": {
"fit": "cover",
"cropX": 50,
"cropY": 50,
"zoom": 1
}
},
"decorations": {}
}
},
{
"key": "observe",
"role": "body",
"layoutId": "standard-01",
"intent": "用三个观察动作提供可实践的方法",
"bindings": {
"T01": "title",
"T02": "body",
"T03": "caption"
},
"parameters": {
"texts": {
"T01": {
"value": "先看见\n再记录"
},
"T02": {
"value": "看叶脉,看光落下的方向。\n留住一片金黄,也留出一半空白。\n把感受写成短句,画面就有了呼吸。"
},
"T03": {
"value": "FIELD NOTES / 02"
}
},
"images": {
"I01": {
"fit": "cover",
"cropX": 65,
"cropY": 40,
"zoom": 1.25
}
},
"decorations": {}
}
},
{
"key": "ending",
"role": "ending",
"layoutId": "standard-01",
"intent": "以轻量行动和提问收尾",
"bindings": {
"T01": "title",
"T02": "body",
"T03": "caption"
},
"parameters": {
"texts": {
"T01": {
"value": "留一点白\n给下一次",
"align": "middle"
},
"T02": {
"value": "今天,捡起一片叶子。\n用三个词记录它的颜色、纹理与心情。\n你的秋天,会从哪一页开始?",
"align": "middle"
},
"T03": {
"value": "YOUR TURN / 03"
}
},
"images": {
"I01": {
"fit": "cover",
"cropX": 65,
"cropY": 50,
"zoom": 1
}
},
"decorations": {},
"layers": {
"I01": {
"x": 40,
"y": 55,
"width": 40,
"height": 40,
"radius": 20
}
}
}
}
]
}
}
@@ -0,0 +1,163 @@
{
"schemaVersion": 2,
"name": "把秋天装进一页 · 银杏观察手记",
"document": {
"version": 1,
"brief": {
"topic": "银杏与秋日观察手记",
"audience": "喜欢日常摄影与视觉手帐的小红书读者",
"intent": "以封面、观察方法和行动结尾组成三页可发布图文",
"designIntent": "奶油纸底、森林墨色、银杏金点缀;宋体大标题与克制的无衬线正文;所有页面保留相同页边距和节奏",
"imageStrategy": "生成一幅原创银杏静物插画作为系列母图;每页复用素材,保留裁切配置,不把正文烘焙进图片"
},
"style": {
"version": "autumn-journal/1",
"palette": {
"paper": "#F3EFE5",
"ink": "#253C32",
"muted": "#718074",
"accent": "#C3A050"
},
"typography": {
"title": {
"fontFamily": "serif",
"fontSize": 8.4,
"fontWeight": 600,
"lineHeight": 1.15,
"letterSpacing": 0
},
"body": {
"fontFamily": "sans",
"fontSize": 3.1,
"fontWeight": 400,
"lineHeight": 1.65,
"letterSpacing": 0
},
"caption": {
"fontFamily": "sans",
"fontSize": 1.7,
"fontWeight": 500,
"lineHeight": 1.2,
"letterSpacing": 0
}
},
"spacing": {
"margin": 10,
"gap": 4
}
},
"sources": [],
"pages": [
{
"key": "cover",
"role": "cover",
"layoutId": "standard-01",
"intent": "用银杏静物与两行大标题建立主题",
"bindings": {
"T01": "title",
"T02": "body",
"T03": "caption"
},
"parameters": {
"texts": {
"T01": {
"value": "把秋天\n装进一页"
},
"T02": {
"value": "不必走很远。\n一片银杏,就是今天的风景。"
},
"T03": {
"value": "SEASONAL NOTES / 01"
}
},
"images": {
"I01": {
"fit": "cover",
"cropX": 50,
"cropY": 50,
"zoom": 1
}
},
"decorations": {}
}
},
{
"key": "observe",
"role": "body",
"layoutId": "standard-01",
"intent": "用三个观察动作提供可实践的方法",
"bindings": {
"T01": "title",
"T02": "body",
"T03": "caption"
},
"parameters": {
"texts": {
"T01": {
"value": "先看见\n再记录"
},
"T02": {
"value": "看叶脉,看光落下的方向。\n留住一片金黄,也留出一半空白。\n把感受写成短句,画面就有了呼吸。"
},
"T03": {
"value": "FIELD NOTES / 02"
}
},
"images": {
"I01": {
"fit": "cover",
"cropX": 65,
"cropY": 40,
"zoom": 1.25
}
},
"decorations": {}
}
},
{
"key": "ending",
"role": "ending",
"layoutId": "standard-01",
"intent": "以轻量行动和提问收尾",
"bindings": {
"T01": "title",
"T02": "body",
"T03": "caption"
},
"parameters": {
"texts": {
"T01": {
"value": "留一点白\n给下一次",
"align": "middle"
},
"T02": {
"value": "今天,捡起一片叶子。\n用三个词记录它的颜色、纹理与心情。\n你的秋天,会从哪一页开始?",
"align": "middle"
},
"T03": {
"value": "YOUR TURN / 03"
}
},
"images": {
"I01": {
"fit": "cover",
"cropX": 65,
"cropY": 50,
"zoom": 1
}
},
"decorations": {},
"layers": {
"I01": {
"x": 40,
"y": 55,
"width": 40,
"height": 40,
"radius": 20
}
}
}
}
]
}
}
+2
View File
@@ -23,6 +23,8 @@ 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,
};
+5 -1
View File
@@ -7,11 +7,15 @@
},
"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/auth-cli.ts --bundle --platform=node --format=cjs --external:pg-native --outfile=dist/auth-cli.cjs",
"build": "pnpm brand:prepare && next build && esbuild src/worker.ts --bundle --platform=node --format=cjs --external:pg-native --external:playwright-core --outfile=dist/worker.cjs && esbuild scripts/bootstrap.ts --bundle --platform=node --format=cjs --external:pg-native --external:playwright-core --outfile=dist/bootstrap.cjs && esbuild scripts/publish-learning-series.ts --bundle --platform=node --format=cjs --external:pg-native --external:playwright-core --outfile=dist/publish-learning-series.cjs && esbuild scripts/auth-cli.ts --bundle --platform=node --format=cjs --external:pg-native --outfile=dist/auth-cli.cjs && node scripts/prepare-standalone.mjs",
"start": "next start",
"typecheck": "next typegen && tsc --noEmit",
"test": "tsx --test tests/api-documentation.test.ts tests/health.test.ts tests/model.test.ts tests/learning-series.test.ts tests/skill-docs.test.ts tests/wiki.test.ts tests/explore.test.ts tests/preview.test.ts tests/note-markdown.test.tsx tests/api-publication.test.ts tests/discovery.test.ts tests/auth.test.ts tests/account.test.ts tests/guidance.test.ts tests/guidance-publishing.test.ts && node --test tests/studio/*.test.mjs",
"test:smoke": "node scripts/smoke.mjs && node scripts/studio-smoke.mjs",
"storage:prepare": "tsx scripts/bootstrap.ts",
"content:publish-learning": "tsx scripts/publish-learning-series.ts",
"worker": "tsx src/worker.ts",
"test:integration": "tsx --test tests/integration.test.ts",
"brand:prepare": "node ../../packages/brand/scripts/prepare-app.mjs"
},
"dependencies": {
@@ -0,0 +1,18 @@
// Only invoked inside the dedicated test network with this run's bucket name.
import {S3Client,ListObjectsV2Command,DeleteObjectsCommand,DeleteBucketCommand} from '@aws-sdk/client-s3';
const bucket=process.env.S3_BUCKET;
if (!/^ci-[1-9][0-9]*$/.test(bucket || '')) throw Error('Refusing non-CI bucket');
const client=new S3Client({endpoint:'http://objects:9000',region:'us-east-1',forcePathStyle:true,credentials:{accessKeyId:process.env.S3_ACCESS_KEY,secretAccessKey:process.env.S3_SECRET_KEY}});
try {
// Re-list the first page after each deletion so no pagination token can skip keys.
for (;;) {
const result=await client.send(new ListObjectsV2Command({Bucket:bucket,MaxKeys:1000}));
const keys=(result.Contents || []).map(({Key})=>({Key}));
if (!keys.length) break;
const deleted=await client.send(new DeleteObjectsCommand({Bucket:bucket,Delete:{Objects:keys,Quiet:true}}));
if (deleted.Errors?.length) throw Error('CI object cleanup failed');
}
await client.send(new DeleteBucketCommand({Bucket:bucket}));
} catch (error) {
if (error.name !== 'NoSuchBucket') throw error;
}
@@ -0,0 +1,9 @@
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); }
@@ -0,0 +1,24 @@
const {chromium}=require('playwright-core');
const assert=require('node:assert/strict');
(async()=>{const browser=await chromium.launch({channel:'chrome',headless:true});try{const page=await browser.newPage({viewport:{width:1440,height:1000}});const errors=[];page.on('pageerror',e=>errors.push(e.message));
await page.route('**/api/account/session',r=>r.fulfill({json:{user:null}}));
await page.goto((process.env.STUDIO_TEST_ORIGIN||'http://8.211.154.134:18131')+'/graphic');
await page.getByRole('button',{name:'切换图层面板',exact:true}).click();
assert.equal(await page.getByRole('button',{name:'图层',exact:true}).isVisible(),false);
await page.getByRole('button',{name:'切换图层面板',exact:true}).click();
await page.getByRole('button',{name:'切换属性面板',exact:true}).click();
assert.equal(await page.getByRole('button',{name:'备份设计 JSON',exact:true}).isVisible(),false);
await page.getByRole('button',{name:'切换属性面板',exact:true}).click();
await page.getByRole('button',{name:'添加文字',exact:true}).click();
await page.getByRole('textbox',{name:'文字内容',exact:true}).fill('设计从这里开始');
await page.getByRole('button',{name:'添加矩形',exact:true}).click();
await page.getByRole('button',{name:'撤销',exact:true}).click();
await page.getByRole('button',{name:'重做',exact:true}).click();
await page.reload();await page.getByRole('button',{name:'文字',exact:true}).first().waitFor();
assert.ok(await page.evaluate(()=>localStorage.getItem('studio.graphic.v1').includes('设计从这里开始')));
const file=page.waitForEvent('download');await page.getByRole('button',{name:'导出 SVG',exact:true}).click();assert.ok((await file).suggestedFilename().endsWith('.svg'));
await page.getByRole('button',{name:'邮箱登录',exact:true}).click();await page.getByRole('dialog').waitFor();await page.keyboard.press('Escape');await page.getByRole('dialog').waitFor({state:'hidden'});
await page.screenshot({path:process.env.STUDIO_QA_SCREENSHOT||'graphic-qa.png',fullPage:true});
await page.setViewportSize({width:390,height:844});assert.equal(await page.evaluate(()=>document.documentElement.scrollWidth>innerWidth),false);
assert.deepEqual(errors,[]);console.log('Graphic editing, history, restore, SVG download, account dialog and mobile overflow checks passed.');
}finally{await browser.close()}})().catch(e=>{console.error(e);process.exitCode=1});
@@ -0,0 +1,38 @@
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);
}
@@ -0,0 +1,128 @@
// Isolated browser context against the designated deployed server only.
const {chromium}=require('playwright-core');
const assert=require('node:assert/strict');
const fs=require('node:fs/promises');
(async()=>{
const base=process.env.BASE_URL||'http://8.211.154.134:18131';
assert.equal(base,'http://8.211.154.134:18131');
const browser=await chromium.launch(process.env.CHROMIUM_PATH?{executablePath:process.env.CHROMIUM_PATH,headless:true,args:['--no-sandbox','--disable-dev-shm-usage']}:{channel:'chrome',headless:true});
let page;
try{
const context=await browser.newContext({viewport:{width:1600,height:1100},storageState:process.env.STORAGE_STATE||undefined});
page=await context.newPage();const errors=[];let choosers=0;
page.on('pageerror',e=>errors.push(e.message));page.on('filechooser',()=>choosers++);
const snapshot=()=>page.evaluate(()=>JSON.parse(localStorage.getItem('studio.infinite-canvas.v1')));
const node=id=>page.locator(`[data-node-id="${id}"]`);
const content=id=>node(id).locator('[data-node-content]');
const waitField=(id,key,value)=>page.waitForFunction(({id,key,value})=>JSON.parse(localStorage.getItem('studio.infinite-canvas.v1')).cards.find(n=>n.id===id)?.[key]===value,{id,key,value});
const composer=page.getByRole('region',{name:'节点编辑器'});
const capture=async name=>{if(process.env.SCREENSHOT_DIR){await fs.mkdir(process.env.SCREENSHOT_DIR,{recursive:true});await page.screenshot({path:process.env.SCREENSHOT_DIR+'/'+name+'.png'})}};
async function unobstructed(id){const box=await node(id).boundingBox();assert.ok(box);for(const card of (await snapshot()).cards.filter(n=>n.id!==id)){const other=await node(card.id).boundingBox();assert.ok(other);assert.equal(box.x<other.x+other.width&&box.x+box.width>other.x&&box.y<other.y+other.height&&box.y+box.height>other.y,false,`${id} overlaps ${card.id}`)}}
const close=(a,b)=>assert.ok(Math.abs(a-b)<2,`${a} ≈ ${b}`);
async function drag(handle,dx,dy){const b=await handle.boundingBox();assert.ok(b);await page.mouse.move(b.x+b.width/2,b.y+b.height/2);await page.mouse.down();await page.mouse.move(b.x+b.width/2+dx,b.y+b.height/2+dy,{steps:12});await page.mouse.up()}
const canvasResponse=await page.goto(base+'/infinite_canvas',{waitUntil:'commit',timeout:60000});
assert.ok(canvasResponse?.ok(),'Canvas document returns HTTP success');
await page.locator('button[data-theme-ready="true"]').waitFor();
await page.getByRole('button',{name:'从示例开始 ↗',exact:true}).click();
await page.getByRole('button',{name:'适应画布',exact:true}).click();
await content('brief').click({position:{x:20,y:20}});
assert.equal(await node('brief').getAttribute('data-selected'),'true');assert.equal(await node('brief').getAttribute('data-editing'),'false');
await page.waitForTimeout(700);
await content('brief').click({position:{x:20,y:20}});
await node('brief').getByRole('textbox',{name:'01 / 创意简报 内容'}).fill('选择后再次点击进入编辑');
await waitField('brief','text','选择后再次点击进入编辑');
await node('brief').getByRole('button',{name:'加粗',exact:true}).click();
assert.equal((await snapshot()).cards.find(n=>n.id==='brief').style.bold,true);
await node('brief').getByRole('button',{name:'斜体',exact:true}).click();
await node('brief').getByLabel('文字颜色',{exact:true}).fill('#78905e');
await node('brief').getByRole('combobox',{name:'文字样式',exact:true}).selectOption('24');
await page.waitForFunction(()=>{const n=JSON.parse(localStorage.getItem('studio.infinite-canvas.v1')).cards.find(n=>n.id==='brief');return n.style.bold&&n.style.italic&&n.style.color==='#78905e'&&n.style.size===24});
assert.deepEqual(await node('brief').getByRole('textbox',{name:'01 / 创意简报 内容'}).evaluate(el=>{const s=getComputedStyle(el);return {size:s.fontSize,weight:s.fontWeight,style:s.fontStyle,color:s.color}}),{size:'24px',weight:'700',style:'italic',color:'rgb(120, 144, 94)'});
const compact=(await snapshot()).cards.find(n=>n.id==='brief');
await node('brief').getByRole('button',{name:'展开文本节点',exact:true}).click();
const expanded=(await snapshot()).cards.find(n=>n.id==='brief');assert.ok(expanded.width>(compact.width||320)&&expanded.height>(compact.height||350));
await capture('canvas-text-format-desktop');
await page.getByRole('button',{name:'撤销',exact:true}).click();
assert.equal((await snapshot()).cards.find(n=>n.id==='brief').width,compact.width);assert.equal((await snapshot()).cards.find(n=>n.id==='brief').height,compact.height);
await page.keyboard.press('Escape');
const before=(await snapshot()).cards.find(n=>n.id==='brief');
const z=await page.locator('[class*=world]').evaluate(el=>new DOMMatrix(getComputedStyle(el).transform).a);
await drag(content('brief'),45,25);
const after=(await snapshot()).cards.find(n=>n.id==='brief');close(after.x-before.x,45/z);close(after.y-before.y,25/z);
assert.equal(await node('brief').getAttribute('data-editing'),'false');
await content('reference').click();assert.equal(await composer.count(),0);
await page.waitForTimeout(700);await content('reference').click();await composer.waitFor();assert.equal(choosers,0,'Entering image editing must not force a file picker');
const panelBefore=await composer.boundingBox(),imageBefore=await node('reference').boundingBox();
await capture('canvas-composer-desktop');
await page.getByRole('button',{name:'缩小',exact:true}).click();
const panelAfter=await composer.boundingBox(),imageAfter=await node('reference').boundingBox();
close(panelBefore.width,panelAfter.width);close(panelBefore.height,panelAfter.height);assert.ok(imageAfter.width<imageBefore.width);
const chooser=page.waitForEvent('filechooser');await composer.getByRole('button',{name:'上传图片',exact:true}).click();
await (await chooser).setFiles({name:'reference.png',mimeType:'image/png',buffer:Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+ip1sAAAAASUVORK5CYII=','base64')});
await page.waitForFunction(()=>!!JSON.parse(localStorage.getItem('studio.infinite-canvas.v1')).cards.find(n=>n.id==='reference').image);
await node('reference').getByRole('button',{name:'图片高清 ↗',exact:true}).click();
const upscale=(await snapshot()).cards.find(n=>n.operation==='upscale');assert.ok(upscale);
await unobstructed(upscale.id);
const download=page.waitForEvent('download');await composer.getByRole('button',{name:'导出任务',exact:true}).click();
const task=JSON.parse(await fs.readFile(await (await download).path(),'utf8'));
assert.equal(task.target.id,upscale.id);assert.equal(task.references[0].id,'reference');assert.ok(task.references[0].image);
await page.keyboard.press('Escape');await page.getByRole('button',{name:'适应画布',exact:true}).click();
await content('brief').click({position:{x:20,y:20}});
await node('brief').getByRole('button',{name:'引用 01 / 创意简报 生成',exact:true}).click();
await page.getByRole('dialog',{name:'画布操作'}).getByRole('button',{name:'脚本',exact:true}).click();
const script=(await snapshot()).cards.find(n=>n.kind==='script');assert.ok(script);
await unobstructed(script.id);
await composer.getByRole('textbox',{name:'创作要求',exact:true}).fill('基于简报写六个分镜');
await waitField(script.id,'instruction','基于简报写六个分镜');assert.equal((await snapshot()).cards.find(n=>n.id===script.id).text,'');
await composer.getByRole('button',{name:'画布参考',exact:true}).click();
await composer.getByRole('group',{name:'选择引用节点'}).getByRole('button',{name:'02 / 视觉参考',exact:true}).click();
assert.ok((await snapshot()).links.some(([a,b])=>a==='reference'&&b===script.id));
await composer.getByRole('button',{name:'移除引用 01 / 创意简报',exact:true}).click();
assert.ok(!(await snapshot()).links.some(([a,b])=>a==='brief'&&b===script.id));
await page.keyboard.press('Escape');
await page.getByRole('button',{name:'适应画布',exact:true}).click();
await content('reference').click();await node('reference').getByRole('button',{name:'02 / 视觉参考 操作',exact:true}).click();
await page.getByRole('button',{name:'删除节点',exact:true}).click();
assert.ok((await snapshot()).cards.some(n=>n.id===script.id));assert.ok((await snapshot()).links.every(l=>!l.includes('reference')));
await page.getByRole('button',{name:'撤销',exact:true}).click();assert.ok((await snapshot()).links.some(([a,b])=>a==='reference'&&b===script.id));
await page.keyboard.press('Escape');
await content('brief').click({position:{x:20,y:20}});
const priorWidth=(await snapshot()).cards.find(n=>n.id==='brief').width;
await drag(node('brief').getByRole('button',{name:'调整 01 / 创意简报 尺寸',exact:true}),50,30);
assert.ok((await snapshot()).cards.find(n=>n.id==='brief').width>(priorWidth||320));
await page.getByRole('button',{name:'撤销',exact:true}).click();assert.equal((await snapshot()).cards.find(n=>n.id==='brief').width,priorWidth);
await page.getByRole('button',{name:'重做',exact:true}).click();
await page.getByRole('button',{name:'移动画布',exact:true}).click();
await page.mouse.move(15,300);await page.mouse.down();await page.mouse.move(75,340,{steps:8});await page.mouse.up();
assert.equal(await page.evaluate(()=>{const path=[...document.querySelectorAll('svg path')].find(p=>p.getAttribute('d')?.startsWith('M '));const d=path.getAttribute('d').match(/-?\d+(?:\.\d+)?/g).map(Number),point=new DOMPoint(d[0],d[1]).matrixTransform(path.getScreenCTM()),card=document.querySelector('[data-node-id="brief"]').getBoundingClientRect();return Math.abs(point.x-card.right)<3&&Math.abs(point.y-(card.top+card.height/2))<3}),true);
const worldAt=()=>page.locator('[class*=world]').evaluate(el=>{const m=new DOMMatrix(getComputedStyle(el).transform);return {x:(1450-m.e)/m.a,y:(150-m.f)/m.d,z:m.a}});
const anchorBefore=await worldAt();await page.mouse.move(1450,150);await page.keyboard.down('Control');await page.mouse.wheel(0,-80);await page.keyboard.up('Control');
await page.waitForFunction(z=>new DOMMatrix(getComputedStyle(document.querySelector('[class*=world]')).transform).a!==z,anchorBefore.z);
const anchorAfter=await worldAt();close(anchorBefore.x,anchorAfter.x);close(anchorBefore.y,anchorAfter.y);
const saved=await snapshot();await page.reload({waitUntil:'commit',timeout:60000});await page.locator('button[data-theme-ready="true"]').waitFor();await page.waitForFunction(()=>!!localStorage.getItem('studio.infinite-canvas.v1'));assert.deepEqual(await snapshot(),saved);
const themeToggle=page.getByRole('button',{name:'切换明暗主题',exact:true});
await page.waitForFunction(()=>document.querySelector('[data-theme-ready]')?.dataset.themeReady==='true');
const initialMode=await page.evaluate(()=>document.documentElement.dataset.mode);
assert.ok(['light','dark'].includes(initialMode));
for(const mode of [initialMode==='light'?'dark':'light',initialMode]){
await themeToggle.click();
const matchesTheme=mode=>{
const root=document.documentElement,canvas=document.querySelector('main');
return root.dataset.mode===mode&&root.dataset.theme===`canvas-${mode}`&&localStorage.getItem('endoleap.theme')===mode&&getComputedStyle(canvas).backgroundColor===(mode==='dark'?'rgb(20, 20, 20)':'rgb(250, 250, 250)');
};
await page.waitForFunction(matchesTheme,mode);
await page.reload({waitUntil:'commit'});
await page.waitForFunction(()=>document.querySelector('[data-theme-ready]')?.dataset.themeReady==='true');
await page.waitForFunction(matchesTheme,mode);
assert.deepEqual(await snapshot(),saved,'Theme changes must preserve canvas content');
await capture('canvas-theme-'+mode);
}
if(process.env.SCREENSHOT_DIR){await fs.mkdir(process.env.SCREENSHOT_DIR,{recursive:true});await page.screenshot({path:process.env.SCREENSHOT_DIR+'/canvas-nodes-desktop.png'});}
await page.setViewportSize({width:390,height:844});assert.ok(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth));
const viewport=await page.locator('[class*=viewport]').boundingBox();assert.equal(viewport.width,390);assert.equal(viewport.height,844);
if(process.env.SCREENSHOT_DIR)await page.screenshot({path:process.env.SCREENSHOT_DIR+'/canvas-nodes-mobile.png'});
assert.deepEqual(errors,[]);console.log('Passed: selected-node re-click editing, drag, formatting, fixed composer, upload, references, task export, deletion/undo, reload, both persistent root themes and mobile bounds');
}catch(error){if(process.env.SCREENSHOT_DIR&&page){await fs.mkdir(process.env.SCREENSHOT_DIR,{recursive:true});await page.screenshot({path:process.env.SCREENSHOT_DIR+'/canvas-failure.png'}).catch(()=>{})}throw error}finally{await browser.close()}
})().catch(e=>{console.error(e);process.exitCode=1});
@@ -0,0 +1,4 @@
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;}
@@ -0,0 +1,5 @@
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
@@ -0,0 +1,65 @@
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;
}
}
@@ -0,0 +1,29 @@
/** CI-only isolated runtime checks, never a local preview workflow. */
import {readFileSync} from 'node:fs';
import {spawn} from 'node:child_process';import {once} from 'node:events';import {createServer} from 'node:net';import {createServer as createHttpServer} from 'node:http';import assert from 'node:assert/strict';import {setTimeout} from 'node:timers/promises';
const designId='00000000-0000-4000-8000-000000000001',missingId='00000000-0000-4000-8000-000000000002';
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/waterfall-wiki/server.js'],{env:{...process.env,PORT:String(port),HOSTNAME:'127.0.0.1',AUTH_DISABLED_FOR_TESTS:'true',AUTH_CONFIG_FILE:'',STUDIO_WIKI_ORIGIN:'http://127.0.0.1:'+wiki.address().port},stdio:'inherit'}),base='http://127.0.0.1:'+port;
try{let ready=false;for(let i=0;i<60;i++){try{if((await fetch(base+'/studio/api/health')).ok){ready=true;break}}catch{}await setTimeout(250)}assert.ok(ready);const entry=await fetch(base+'/studio',{redirect:'manual'});assert.equal(entry.status,307);assert.equal(entry.headers.get('location'),'/workspace');const library=await fetch(base+'/studio/graphic').then(r=>r.text());assert.ok(library.includes('GRAPHIC'));assert.ok(!library.includes('ref-27')); const catalog=await fetch(base+'/studio/api/layouts').then(r=>r.json());assert.equal(catalog.layouts.length,28);
for(const l of catalog.layouts){const page=await fetch(base+l.url);assert.equal(page.status,200,l.id);const html=await page.text();assert.ok(html.includes(l.name));assert.ok(html.includes('data-layout-id="'+l.id+'"'));assert.ok(!/<svg|<img|data:image|trial-controls|page-tools|workbench-top|contenteditable|编辑页面/.test(html),'Default page must use native elements, not source imagery');const schema=await fetch(base+l.api).then(r=>r.json());assert.equal(schema.id,l.id);for(const s of schema.elements)assert.ok(html.includes('data-element="'+s.id+'"'));if(l.family==='standard'){assert.equal((await fetch(base+l.api+'?format=svg')).status,400);continue;}const original=await fetch(base+l.api+'?format=svg',{headers:{cookie:'session=smoke-test'}}).then(r=>r.text());assert.ok(original.includes('data-layout-id="'+l.id+'"'));const text=schema.elements.find(e=>e.kind==='text');const result=await fetch(base+l.api,{method:'POST',headers:{'Content-Type':'application/json',cookie:'session=smoke-test'},body:JSON.stringify({texts:{[text.id]:{value:'API render <test>'}}})});assert.equal(result.status,200);assert.ok((await result.text()).includes('&lt;'));}
assert.equal(reads,0,'Default pages never load a saved design');assert.ok(mediaReads>0,'SVG exports load isolated media fixtures');
const headers={cookie:'session=smoke-test'};
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))}
@@ -0,0 +1,6 @@
/** 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;
}
@@ -1,7 +1,8 @@
import { randomBytes, scrypt as derive, timingSafeEqual, createHash } from 'node:crypto';
import { promisify } from 'node:util';
const scrypt = promisify(derive);
export const ACCOUNT_COOKIE = 'wiki_account';
export const ACCOUNT_COOKIE = process.env.ACCOUNT_COOKIE_NAME || 'wiki_account';
if (!/^[A-Za-z0-9_-]{1,64}$/.test(ACCOUNT_COOKIE)) throw Error('Invalid ACCOUNT_COOKIE_NAME');
export const SESSION_TTL = 7 * 86400;
export function normalizeEmail(value: unknown) {
if (typeof value !== 'string') return null;
+2 -1
View File
@@ -1,7 +1,8 @@
import { createHash, createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
import { readFileSync } from 'node:fs';
export const SESSION_COOKIE = 'prompt_session';
export const SESSION_COOKIE = process.env.AUTH_COOKIE_NAME || 'prompt_session';
if (!/^[A-Za-z0-9_-]{1,64}$/.test(SESSION_COOKIE)) throw Error('Invalid AUTH_COOKIE_NAME');
export const SESSION_SECONDS = 7 * 24 * 60 * 60;
export type AuthConfig = { secret: string; jwtKey: string };
export function authConfig(): AuthConfig | null {
File diff suppressed because one or more lines are too long
@@ -0,0 +1,14 @@
import { readFile } from 'node:fs/promises';
import type { NextApiRequest, NextApiResponse } from 'next';
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (process.env.PREVIEW_MODE !== 'true') return res.status(404).end();
if (req.method !== 'GET' && req.method !== 'HEAD') return res.status(405).end();
res.setHeader('Cache-Control', 'no-store');
try {
const status = JSON.parse(await readFile('/preview-status/status.json', 'utf8'));
return req.method === 'HEAD' ? res.status(200).end() : res.status(200).json({ ...status, mode: 'development' });
} catch {
return res.status(503).json({ error: 'Preview source has not synchronized yet' });
}
}
@@ -14,7 +14,7 @@ export const getServerSideProps:GetServerSideProps<{layout:Layout}>=async({param
if(query.id!==undefined){
const design=await readDesign(query.id,req);
if(!design||design.layoutId!==layout.id)return {notFound:true};
return {redirect:{destination:designUrl(design.layoutId,design.id).slice('/studio'.length),permanent:false}};
return {redirect:{destination:designUrl(design.layoutId,design.id),permanent:false}};
}
return {props:{layout:nativeLayout(layout)}};
};
+1
View File
@@ -2,6 +2,7 @@ import { NextResponse, type NextRequest } from 'next/server';
import { authConfig, SESSION_COOKIE, validSession } from './lib/auth';
export function proxy(request: NextRequest) {
const path = request.nextUrl.pathname;
if (process.env.PREVIEW_MODE === 'true' && path === '/api/dev-preview' && ['GET','HEAD'].includes(request.method)) return NextResponse.next();
const publicBrandAssets = ['/favicon.ico','/apple-touch-icon.png','/brand/logo-light.svg','/brand/logo-dark.svg','/brand/icon-dark.svg','/studio/favicon.ico','/studio/apple-touch-icon.png','/studio/brand/logo-light.svg','/studio/brand/logo-dark.svg','/studio/brand/icon-dark.svg'];
if (['GET','HEAD'].includes(request.method) && publicBrandAssets.includes(path)) return NextResponse.next({headers:{'Referrer-Policy':'same-origin'}});
const responseHeaders = {'Cache-Control':'private, no-store', 'Referrer-Policy':'same-origin'};
@@ -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://127.0.0.1:${process.env.PORT || '3000'}`;
const origin=process.env.STUDIO_WIKI_ORIGIN||'http://waterfall-wiki: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://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');}
for(const id of ids){const url=(process.env.STUDIO_WIKI_ORIGIN||'http://waterfall-wiki:3000')+'/api/preview?'+new URLSearchParams({source:'/api/media/'+id,width:'960'}),r=await fetch(url,{headers,signal:AbortSignal.timeout(15000),redirect:'error'});if(!r.ok||r.headers.get('content-type')?.split(';')[0]!=='image/webp')throw Error('图片预览不可访问,请检查图片 ID 与登录状态');const chunks:Buffer[]=[];const reader=r.body!.getReader();try{while(true){const {done,value}=await reader.read();if(done)break;total+=value.length;if(total>16*1024*1024)throw Error('图片预览总计超限');chunks.push(Buffer.from(value))}}finally{await reader.cancel()}sources[id]='data:image/webp;base64,'+Buffer.concat(chunks).toString('base64');}
return sources;
}
@@ -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://127.0.0.1:${process.env.PORT || '3000'}`)+path,{headers,redirect:'error',signal:AbortSignal.timeout(15000)});
const response=await fetch((process.env.STUDIO_WIKI_ORIGIN||'http://waterfall-wiki:3000')+path,{headers,redirect:'error',signal:AbortSignal.timeout(15000)});
const mime=response.headers.get('content-type')?.split(';')[0];
if(!response.ok||!mime||!['image/png','image/jpeg','image/webp'].includes(mime))throw Error('模板图片不可访问');
const chunks:Uint8Array[]=[];const reader=response.body!.getReader();
@@ -21,10 +21,10 @@
.trial-actions{display:grid;gap:8px}.trial-actions button{padding:11px 8px;border:1px solid var(--border);border-radius:4px;font-size:12px;background:var(--surface);color:var(--foreground)}.trial-actions .trial-primary{background:var(--primary);color:var(--primary-foreground);border-color:var(--selection-border)}.trial-actions button:hover{filter:brightness(.94)}
.trial-help{font-size:11px;line-height:1.7;color:var(--muted-foreground);margin-top:16px}.trial-notice{font-size:11px;color:var(--success);min-height:16px}
.trial-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:28px}.proof-card{margin:0;min-width:0}.proof-select{display:block;width:100%;padding:0;border-radius:0;background:var(--selection);box-shadow:0 3px 14px var(--shadow);outline:1px solid var(--ring);outline-offset:0;transition:outline-color .15s}.proof-active .proof-select{outline:2px solid var(--ring);outline-offset:5px}.proof-select:focus-visible{outline:3px solid var(--ring);outline-offset:5px}.proof-card figcaption{display:flex;gap:10px;align-items:center;padding:16px 0 0;font-size:11px;flex-wrap:wrap}.proof-card figcaption b{font-weight:500}.proof-card figcaption span{color:var(--muted-foreground)}.proof-card figcaption a{margin-left:auto;text-decoration:underline;text-underline-offset:3px;color:var(--muted-foreground)}
.trial-reference{margin-top:36px;padding:20px 0;border-top:1px solid var(--border);font-size:12px}.trial-reference summary{cursor:pointer}.trial-reference-body{display:grid;grid-template-columns:150px minmax(0,1fr);gap:24px;margin-top:22px;line-height:1.8}.trial-reference img{width:150px;height:auto;align-self:start}.trial-reference table{width:100%;border-collapse:collapse;font-size:11px;margin:16px 0}.trial-reference th,.trial-reference td{text-align:left;border-bottom:1px solid var(--border);padding:6px}.trial-reference a{text-decoration:underline}
.trial-reference{margin-top:36px;padding:20px 0;border-top:1px solid var(--border);font-size:12px}.trial-reference summary{cursor:pointer}.trial-reference-:scope{display:grid;grid-template-columns:150px minmax(0,1fr);gap:24px;margin-top:22px;line-height:1.8}.trial-reference img{width:150px;height:auto;align-self:start}.trial-reference table{width:100%;border-collapse:collapse;font-size:11px;margin:16px 0}.trial-reference th,.trial-reference td{text-align:left;border-bottom:1px solid var(--border);padding:6px}.trial-reference a{text-decoration:underline}
@media(min-width:1300px){.trial-grid{gap:40px}.trial-workspace{gap:48px}}
@media(max-width:900px){.template-trial{padding:22px}.trial-workspace{grid-template-columns:220px minmax(0,1fr);gap:24px}.trial-grid{gap:20px}.trial-controls{padding:16px}.trial-source{display:none}.trial-intro h1{font-size:36px}.trial-nav span{display:none}}
@media(max-width:650px){.trial-workspace{grid-template-columns:1fr}.trial-controls{position:static}.trial-controls fieldset{display:grid;grid-template-columns:1fr 1fr;gap:12px}.trial-actions{grid-template-columns:1fr 1fr}.trial-reference-body{grid-template-columns:1fr}.trial-grid{gap:24px}.trial-intro{padding:32px 0}.trial-intro h1{font-size:30px}}
@media(max-width:650px){.trial-workspace{grid-template-columns:1fr}.trial-controls{position:static}.trial-controls fieldset{display:grid;grid-template-columns:1fr 1fr;gap:12px}.trial-actions{grid-template-columns:1fr 1fr}.trial-reference-:scope{grid-template-columns:1fr}.trial-grid{gap:24px}.trial-intro{padding:32px 0}.trial-intro h1{font-size:30px}}
@media(max-width:440px){.trial-grid{grid-template-columns:1fr}.trial-nav{gap:10px}.template-trial{padding:18px}}
@page ref17Proof{size:113mm 174mm;margin:0}
@media print{.template-trial{padding:0;max-width:none}.trial-nav,.trial-intro,.trial-controls,.trial-reference,.proof-card figcaption{display:none}.trial-workspace,.trial-grid{display:block}.proof-card{page:ref17Proof;break-after:page;width:113mm;height:174mm}.proof-card:last-child{break-after:auto}.proof-select,.proof-active .proof-select{outline:none;box-shadow:none;width:113mm;height:174mm}.proof-select svg{width:113mm!important;height:174mm!important;print-color-adjust:exact}.proof-select svg g[aria-hidden=true]{display:none}}
@@ -0,0 +1,51 @@
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]);}
}
@@ -0,0 +1,7 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { normalizeEmail,validPassword,hashPassword,checkPassword,newToken,tokenDigest,accountCookie,trustedOrigin } from '../src/lib/account-crypto';
test('canonical email and bounded passwords',()=>{assert.equal(normalizeEmail(' Test@Example.COM '),'test@example.com');assert.equal(normalizeEmail('a@b\r\nBcc:x@y.com'),null);assert.equal(validPassword('short'),false);assert.equal(validPassword('界'.repeat(100)),false);});
test('salted passwords and constant size opaque token digests',async()=>{const a=await hashPassword('long enough password');const b=await hashPassword('long enough password');assert.notEqual(a,b);assert.equal(await checkPassword('long enough password',a),true);assert.equal(await checkPassword('wrong password',a),false);const t=newToken();assert.equal(t.length,64);assert.equal(tokenDigest(t).length,64);assert.notEqual(tokenDigest(t),t);});
test('origin checks reject missing and cross-site origins',()=>{const r=(origin?:string,site?:string)=>new Request('https://wiki.example/api/account/login',{headers:{...(origin?{origin}:{}),...(site?{'sec-fetch-site':site}:{})}});assert.equal(trustedOrigin(r()),false);assert.equal(trustedOrigin(r('https://evil.example')),false);assert.equal(trustedOrigin(r('https://wiki.example','cross-site')),false);assert.equal(trustedOrigin(r('https://wiki.example')),true);});
test('account cookie is distinct, HTTP-only and expires on logout',()=>{assert.match(accountCookie('abc'),/^wiki_account=abc;/);assert.match(accountCookie('abc'),/HttpOnly; SameSite=Lax/);assert.match(accountCookie('',true),/Max-Age=0/);});
@@ -0,0 +1,64 @@
import assert from 'node:assert/strict';
import {test} from 'node:test';
import type {NextApiRequest,NextApiResponse} from 'next';
import {apiDocuments,serveApiDocumentation} from '../src/lib/api-documentation';
import index from '../src/pages/api/index';
function response(){
const state={status:0,headers:{} as Record<string,string>,body:undefined as unknown};
const res={setHeader(name:string,value:string){state.headers[name]=value;return res;},status(code:number){state.status=code;return res;},send(body:unknown){state.body=body;return res;},json(body:unknown){state.body=body;return res;},end(){return res;}};
return {state,res:res as unknown as NextApiResponse};
}
const request=(method:string,agent_mode:unknown)=>({method,query:{agent_mode}} as unknown as NextApiRequest);
test('every business handler serves documentation before validating parameters or accessing dependencies',async()=>{
const routes:Record<string,string>={
'/api/canvases/{...path}':'canvases/[[...path]]','/api/explore-feed':'explore-feed','/api/feed':'feed','/api/notes/{id}':'notes/[id]',
'/api/posts/{id}/archive':'posts/[id]/archive','/api/posts':'posts/index','/api/tags':'tags','/api/upload':'upload',
'/api/media/{id}':'media/[id]','/api/preview':'preview',
'/api/skills/street-people':'skills/street-people','/api/health':'health','/api/ready':'ready',
};
for(const [path,route] of Object.entries(routes)){
const {default:handler}=await import('../src/pages/api/'+route+'.ts');
for(const method of ['GET','HEAD']){
const {state,res}=response();
await handler(request(method,'tRuE'),res);
assert.equal(state.status,200,path);
assert.equal(state.headers['Content-Type'],'text/plain; charset=utf-8',path);
if(method==='HEAD')assert.equal(state.body,undefined);else {
assert.ok(String(state.body).includes(path),path);
assert.ok(String(state.body).includes('参数'),path);
}
}
}
assert.deepEqual(Object.keys(routes).sort(),Object.keys(apiDocuments).sort());
});
test('normal calls and mutations do not enter documentation mode',()=>{
for(const [method,value] of [['GET',undefined],['GET','false'],['GET',['True','false']],['POST','True'],['PUT','True'],['DELETE','True']]){
const {state,res}=response();
assert.equal(serveApiDocumentation(request(method as string,value),res,'/api/posts'),false);
assert.equal(state.status,0);
}
});
test('index exposes every business endpoint and its text URL',()=>{
const {state,res}=response();index(request('GET','True'),res);
for(const path of [...Object.keys(apiDocuments),'/studio/api/export/{slug}','/studio/api/health','/api/studio/designs','/api/studio/designs/{id}','/api/studio/designs/{id}/post'])assert.ok(String(state.body).includes(path+'?agent_mode=True'));
});
test('health keeps its regular JSON response',async()=>{
const {default:handler}=await import('../src/pages/api/health');
const {state,res}=response();handler(request('GET',undefined),res);
assert.equal(state.status,200);assert.equal((state.body as {status:string}).status,'ok');
});
test('Studio docs describe native editing and atomic Wiki publication without accessing storage',async()=>{
for(const route of ['studio/designs/index','studio/designs/[id]','studio/designs/[id]/post']){
const {default:handler}=await import('../src/pages/api/'+route+'.ts');
const {state,res}=response();await handler(request('GET','True'),res);
assert.equal(state.status,200);assert.match(String(state.body),/postUrl/);assert.match(String(state.body),/整体替换/);assert.match(String(state.body),/同一数据库事务/);assert.match(String(state.body),/原生页面/);
}
});
@@ -0,0 +1,28 @@
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {randomUUID} from 'node:crypto';
import {postInput} from '../src/lib/write';
import {noteEditInput,withRevision} from '../src/lib/note-edits';
import {publicPostId} from '../src/lib/model';
import {entryText,type ExploreEntry} from '../src/lib/explore';
test('generic API accepts stable public IDs while retaining UUID storage and rejecting invalid IDs',()=>{
const publicId='0123456789abcdef01234567',stored=randomUUID();
assert.equal(postInput.parse({kind:'article',title:'文本与图像',publicId}).publicId,publicId);
for(const id of ['bad','ABCDEF0123456789abcdef01','1'.repeat(25)])assert.equal(postInput.safeParse({kind:'article',title:'x',publicId:id}).success,false);
assert.equal(publicPostId({id:stored,public_id:publicId}),publicId);assert.equal(publicPostId({id:stored}),stored);
});
test('media/cover edits are optional and change optimistic revisions independently of the text',()=>{
const a=randomUUID(),b=randomUUID();
const entry:ExploreEntry={id:'0123456789abcdef01234567',title:'笔记',description:'说明',markdown:'# 笔记\n\n正文',namespace:'Main',categories:[],imageCount:1,media:[],color:'#fff',assets:[{id:a,kind:'image',role:'content'}],coverAssetId:a};
const before=withRevision(entry).revision;
assert.notEqual(withRevision({...entry,coverAssetId:b}).revision,before);
assert.notEqual(withRevision({...entry,assets:[{id:b,kind:'image',role:'content'}]}).revision,before);
const input={title:'笔记',description:'说明',markdown:'正文',tags:[],revision:before};
assert.equal(noteEditInput.parse(input).assets,undefined);
assert.equal(noteEditInput.parse({...input,assets:[{id:a}],coverAssetId:null}).assets?.[0].role,'content');
});
test('AI text preserves JSON, code and media links as ordinary modalities',()=>{
const id=randomUUID(),markdown='# 文本说明\n\n```json\n{"type":"text","box":[1,2,3,4]}\n```\n\n```tsx\nexport default function Example(){return <div/>}\n```';
const entry:ExploreEntry={id,title:'文本说明',description:'可访问',markdown,namespace:'Main',categories:[],imageCount:1,media:[{kind:'image',url:'/api/media/'+randomUUID(),title:'标注图'}],color:'#fff'};
const text=entryText(entry);assert.ok(text.includes(markdown));assert.ok(text.includes(entry.media[0].url));assert.ok(text.includes('/explore/'+id));
});
+62
View File
@@ -0,0 +1,62 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {base32,totp,matchingStep,newAuthConfig,signSession,validSession,sessionCookie,safeReturn,authConfig,isLoginRedirect} from '../src/lib/auth';
import {proxy} from '../src/proxy';
import {NextRequest} from 'next/server';
import {mkdtempSync,writeFileSync,rmSync} from 'node:fs';
import {tmpdir} from 'node:os';
import {join} from 'node:path';
test('TOTP matches RFC 6238 SHA-1 test vectors',()=>{
const secret=base32(Buffer.from('12345678901234567890'));
for(const [time,expected] of [[59,'94287082'],[1111111109,'07081804'],[1111111111,'14050471'],[1234567890,'89005924'],[2000000000,'69279037'],[20000000000,'65353130']] as const) assert.equal(totp(secret,Math.floor(time/30),8),expected);
assert.equal(matchingStep(secret,totp(secret,100),3000000),100);
assert.equal(matchingStep(secret,totp(secret,99),3000000),99);
assert.equal(matchingStep(secret,totp(secret,98),3000000),null);
assert.equal(matchingStep(secret,'123',3000000),null);
});
test('JWT rejects tampering, expiry, wrong keys and malformed values',()=>{
const config=newAuthConfig(),token=signSession(config,1000);
assert.equal(validSession(token,config,1000),true);
assert.equal(validSession(token,config,1000+604800),false);
assert.equal(validSession(token,config,900),false);
assert.equal(validSession(token,newAuthConfig(),1000),false);
assert.equal(validSession(token+'x',config,1000),false);
assert.equal(validSession('eyJhbGciOiJub25lIn0.e30.',config,1000),false);
assert.equal(validSession(undefined,config),false);
});
test('cookies are HTTP-only and secure by default; redirects stay local',()=>{
assert.match(sessionCookie('test'),/HttpOnly; SameSite=Lax; Max-Age=604800; Secure$/);
assert.match(sessionCookie('',true),/Max-Age=0/);
for(const path of ['https://evil.test','//evil.test','/\\evil.test','/login?next=/','/\nLocation: bad']) assert.equal(safeReturn(path),'/');
assert.equal(safeReturn('/explore/123?q=test'),'/explore/123?q=test');
});
test('proxy exposes creative entry pages while protecting APIs, data and Studio documents; fails closed',()=>{
const temp=mkdtempSync(join(tmpdir(),'prompt-auth-')); const old=process.env.AUTH_CONFIG_FILE;
try{
const config=newAuthConfig(); process.env.AUTH_CONFIG_FILE=join(temp,'config.json'); writeFileSync(process.env.AUTH_CONFIG_FILE,JSON.stringify(config));
for(const path of ['/library/import','/skills/example.png','/_next/data/build/library/import.json','/studio/layouts/ref-01']) assert.equal(proxy(new NextRequest('http://wiki.test'+path)).status,307,path);
for(const path of ['/api/upload','/studio/api/export','/studio/api/layouts','/api/feed']) assert.equal(proxy(new NextRequest('http://wiki.test'+path)).status,401,path);
for(const path of ['/','/_next/data/build/index.json','/llms.txt','/index.md','/agent','/agent/index.md','/api/agent-index','/api/guides','/write','/documents','/api/documents','/api/documents/preview','/guide/welcome','/protocol','/api/guides/welcome','/account','/api/account/session','/login','/brand-mark.svg','/_next/static/chunk.js','/api/health','/api/ready']) assert.equal(proxy(new NextRequest('http://wiki.test'+path)).status,200,path);
const creativeEntries=['/favicon.ico','/apple-touch-icon.png','/brand/logo-light.svg','/brand/logo-dark.svg','/brand/icon-dark.svg','/studio/favicon.ico','/studio/apple-touch-icon.png','/studio/brand/logo-light.svg','/studio/brand/logo-dark.svg','/studio/brand/icon-dark.svg','/workspace','/graphic','/infinite_canvas','/studio','/studio/graphic','/studio/infinite_canvas','/studio/api','/studio/_next/static/chunk.js'];
for(const path of creativeEntries){
for(const method of ['GET','HEAD'])assert.equal(proxy(new NextRequest('http://wiki.test'+path,{method})).status,200,`${method} ${path}`);
assert.equal(proxy(new NextRequest('http://wiki.test'+path,{method:'POST'})).status,401,`POST ${path} must not bypass authentication`);
}
const headers={cookie:'prompt_session='+signSession(config),host:'wiki.test'};
assert.equal(proxy(new NextRequest('http://wiki.test/api/feed',{headers})).status,200);
assert.equal(proxy(new NextRequest('http://wiki.test/api/posts',{method:'POST',headers:{...headers,origin:'http://evil.test'}})).status,403);
assert.equal(proxy(new NextRequest('http://wiki.test/api/posts',{method:'POST',headers:{...headers,origin:'http://wiki.test'}})).status,200);
assert.equal(proxy(new NextRequest('http://wiki.test/api/posts',{method:'POST',headers:{cookie:'wiki_account='+ 'a'.repeat(64)}})).status,401);
assert.equal(proxy(new NextRequest('http://wiki.test/guide/welcome',{method:'POST'})).status,401);
for(const path of ['/api/guides','/api/agent-index']) assert.equal(proxy(new NextRequest('http://wiki.test'+path,{method:'POST'})).status,401,path);
writeFileSync(process.env.AUTH_CONFIG_FILE,'{}'); assert.throws(()=>authConfig()); assert.equal(proxy(new NextRequest('http://wiki.test/api/posts')).status,503);
delete process.env.AUTH_CONFIG_FILE;
assert.equal(proxy(new NextRequest('http://wiki.test/api/posts')).status,503,'Missing admin configuration fails closed');
}finally{if(old===undefined)delete process.env.AUTH_CONFIG_FILE;else process.env.AUTH_CONFIG_FILE=old;rmSync(temp,{recursive:true,force:true});}
});
test('login redirects recognize browser pages and Next.js data destinations',()=>{
const base='http://wiki.test';
for(const target of ['/login?next=%2F','http://wiki.test/login','/_next/data/build-123/login.json?next=%2F']) assert.equal(isLoginRedirect(target,base),true,target);
for(const target of [null,'','https://evil.test/login','//evil.test/login','/api/login.json','/_next/data/build-123/index.json']) assert.equal(isLoginRedirect(target,base),false,String(target));
});
@@ -0,0 +1,70 @@
/**
* Recover once from an idempotent document GET's transient proxy failure.
* This never retries hydration, theme assertions, or mutations, and shares one
* 60-second budget across both attempts rather than extending the deadline.
* @param {(timeout: number) => Promise<{ok(): boolean, status(): number} | null>} navigate
* @param {string} url
* @param {() => number} now
*/
export async function navigateBrandDocument(navigate, url, now = Date.now) {
const deadline = now() + 60000;
for (let attempt = 1; attempt <= 2; attempt++) {
const remaining = deadline - now();
if (remaining <= 0) throw new Error(`Document navigation budget exhausted: ${url}`);
try {
const response = await navigate(remaining);
if (response?.ok()) return response;
const status = response?.status();
if (attempt === 1 && [500, 502, 503, 504].includes(status ?? 0)) {
console.warn('Brand navigation proxy recovery', JSON.stringify({url, status, attempt}));
continue;
}
throw new Error(`Document navigation failed: ${url}, HTTP ${status ?? 'no response'}`);
} catch (error) {
if (attempt === 1 && /ECONNRESET|net::ERR_(?:CONNECTION_RESET|CONNECTION_CLOSED|EMPTY_RESPONSE|ABORTED)/.test(String(error))) {
console.warn('Brand navigation connection recovery', JSON.stringify({url, error: String(error), attempt}));
continue;
}
throw error;
}
}
throw new Error(`Document navigation failed: ${url}`);
}
/** Load a document fully before relying on server-rendered text or leaving it.
* Only document GETs may recover; application assertions remain outside retry.
* @param {import('playwright-core').Page} page
* @param {string} url
* @param {boolean} reload
*/
export async function visitBrowserDocument(page, url, reload = false) {
return navigateBrandDocument(async timeout => {
const started = Date.now();
const scripts = new Set(), failures = [];
const requested = request => { if (request.resourceType() === 'script') scripts.add(request); };
const failed = request => { if (scripts.has(request)) failures.push(request.failure()?.errorText || 'unknown script failure'); };
page.on('request', requested);
page.on('requestfailed', failed);
try {
const response = await (reload ? page.reload({waitUntil: 'domcontentloaded', timeout}) : page.goto(url, {waitUntil: 'domcontentloaded', timeout}));
if (response?.ok()) {
const remaining = timeout - (Date.now() - started);
if (remaining <= 0) throw new Error('Document hydration budget exhausted: ' + url);
try {
// Responsive layouts may hide the toggle. Its mounted ready marker
// proves hydration; each caller still checks the controls it needs.
// A cancelled script may be a preload duplicate. Hydration is the
// authority; recover only when cancellation actually blocks startup.
await page.locator('button[data-theme-ready="true"]').first().waitFor({state: 'attached', timeout: failures.length ? Math.min(5000, remaining) : remaining});
} catch (error) {
if (failures.length) throw new Error('Document bootstrap script request failed: ' + failures.join('; '), {cause: error});
throw error;
}
}
return response;
} finally {
page.off('request', requested);
page.off('requestfailed', failed);
}
}, url);
}
@@ -0,0 +1,95 @@
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {navigateBrandDocument, visitBrowserDocument} from './brand-navigation.mjs';
const response = status => ({ok: () => status === 200, status: () => status});
test('successful documents are fetched once', async () => {
let calls = 0;
await navigateBrandDocument(async () => { calls++; return response(200); }, '/canvas');
assert.equal(calls, 1);
});
test('one proxy failure can recover within the original deadline', async () => {
const timeouts = []; let elapsed = 0;
await navigateBrandDocument(async timeout => {
timeouts.push(timeout); elapsed = 15000;
return response(timeouts.length === 1 ? 500 : 200);
}, '/canvas', () => elapsed);
assert.deepEqual(timeouts, [60000, 45000]);
});
test('connection reset and interrupted bootstrap scripts can recover once', async () => {
for(const failure of ['net::ERR_CONNECTION_RESET','Document bootstrap script request failed: net::ERR_ABORTED']){
let calls = 0;
await navigateBrandDocument(async () => {
if (++calls === 1) throw new Error(failure);
return response(200);
}, '/canvas');
assert.equal(calls, 2);
}
});
test('persistent server failure is not hidden', async () => {
let calls = 0;
await assert.rejects(navigateBrandDocument(async () => { calls++; return response(502); }, '/canvas'), /HTTP 502/);
assert.equal(calls, 2);
});
test('missing routes, authentication failures and ordinary timeouts are not retried', async () => {
for (const status of [401, 403, 404]) {
let calls = 0;
await assert.rejects(navigateBrandDocument(async () => { calls++; return response(status); }, '/canvas'), /HTTP/);
assert.equal(calls, 1);
}
let calls = 0;
await assert.rejects(navigateBrandDocument(async () => { calls++; throw new Error('Timeout 60000ms exceeded'); }, '/canvas'), /Timeout/);
assert.equal(calls, 1);
});
test('recovery cannot extend the total navigation budget', async () => {
let calls = 0, elapsed = 0;
await assert.rejects(navigateBrandDocument(async () => {
calls++; elapsed = 60000; return response(503);
}, '/canvas', () => elapsed), /budget exhausted/);
assert.equal(calls, 1);
});
function browserFixture(failBootstrap = false, failHydration = false, blockedOnce = false, readyVisible = true) {
const listeners = new Map(), calls = [];
const page = {
on(event, fn) { if (!listeners.has(event)) listeners.set(event, new Set()); listeners.get(event).add(fn); },
off(event, fn) { listeners.get(event)?.delete(fn); },
async goto(url, options) {
calls.push(options.waitUntil);
const request = {resourceType: () => 'script', failure: () => ({errorText: 'net::ERR_ABORTED'})};
for (const fn of listeners.get('request') || []) fn(request);
if (failBootstrap && calls.length === 1) for (const fn of listeners.get('requestfailed') || []) fn(request);
return response(200);
},
locator() { return {first: () => ({waitFor: async options => { calls.push('hydrated'); if (!readyVisible && options.state !== 'attached') throw Error('Ready marker is hidden by responsive CSS'); if (failHydration || (blockedOnce && calls.length === 2)) throw Error('Hydration timeout'); }})}; },
};
return {page, calls, listeners};
}
test('document visit completes deferred scripts before checking hydration and removes listeners', async () => {
const {page, calls, listeners} = browserFixture();
await visitBrowserDocument(page, '/account');
assert.deepEqual(calls, ['domcontentloaded', 'hydrated']);
assert.ok([...listeners.values()].every(set => set.size === 0));
});
test('interrupted bootstrap retries only document loading before hydration', async () => {
const {page, calls} = browserFixture(true, false, true);
await visitBrowserDocument(page, '/account');
assert.deepEqual(calls, ['domcontentloaded', 'hydrated', 'domcontentloaded', 'hydrated']);
});
test('hydration failure is surfaced without retrying the application', async () => {
const {page, calls} = browserFixture(false, true);
await assert.rejects(visitBrowserDocument(page, '/account'), /Hydration timeout/);
assert.deepEqual(calls, ['domcontentloaded', 'hydrated']);
});
test('cancelled script does not reject an already hydrated document', async () => {
const {page, calls} = browserFixture(true);
await visitBrowserDocument(page, '/account');
assert.deepEqual(calls, ['domcontentloaded', 'hydrated']);
});
test('hydrated pages are ready even when responsive CSS hides the marker', async () => {
const {page, calls} = browserFixture(false, false, false, false);
await visitBrowserDocument(page, '/graphic');
assert.deepEqual(calls, ['domcontentloaded', 'hydrated']);
});
@@ -0,0 +1,75 @@
/** CI-only checks against the disposable stack, never against personal documents. */
import assert from 'node:assert/strict';
import { chromium, type Request } from 'playwright-core';
import { visitBrowserDocument } from './brand-navigation.mjs';
export async function verifyBrandUI(base: string) {
for (const prefix of process.env.VERIFY_CANVAS_UI === '1' ? ['', '/studio'] : ['']) {
const icon = await fetch(`${base}${prefix}/favicon.ico`);
assert.equal(icon.status, 200);
const bytes = new Uint8Array(await icon.arrayBuffer());
assert.deepEqual([...bytes.slice(0, 4)], [0, 0, 1, 0]);
assert.equal(bytes[4], 7, 'favicon includes seven resolutions');
for (const file of ['logo-light.svg', 'logo-dark.svg', 'icon-dark.svg']) {
const image = await fetch(`${base}${prefix}/brand/${file}`);
assert.equal(image.status, 200, prefix + '/' + file);
assert.match(await image.text(), /<svg/);
}
}
const browser = await chromium.launch({ executablePath: process.env.CHROMIUM_PATH || '/usr/bin/chromium', headless: true, args: ['--no-sandbox', '--disable-dev-shm-usage'] });
try {
const page = await browser.newPage({ viewport: { width: 1440, height: 960 } });
page.setDefaultTimeout(60000);page.setDefaultNavigationTimeout(60000);
const failures: string[] = [], scripts: {path:string;status?:number;ms:number;error?:string}[] = [];
const scriptStarted = new WeakMap<Request,number>();
page.on('request', request => { if(request.resourceType()==='script') scriptStarted.set(request,Date.now()); });
page.on('response', response => { if(response.request().resourceType()==='script') { scripts.push({path:new URL(response.url()).pathname,status:response.status(),ms:Date.now()-(scriptStarted.get(response.request())??Date.now())}); if(scripts.length>24)scripts.shift(); } });
page.on('pageerror', error => failures.push(error.message));
page.on('requestfailed', request => { failures.push(new URL(request.url()).pathname + ': ' + request.failure()?.errorText); if(request.resourceType()==='script') scripts.push({path:new URL(request.url()).pathname,ms:Date.now()-(scriptStarted.get(request)??Date.now()),error:request.failure()?.errorText}); });
const visit = async (path: string, theme: string) => {
const started = Date.now();
try {
await visitBrowserDocument(page, base + path);
await page.waitForFunction(expected => document.documentElement.dataset.theme === expected, theme);
console.log('Brand UI route ready:', path, theme, Date.now() - started);
} catch (error) {
console.error('Brand UI navigation diagnostics', JSON.stringify({path, url:page.url(), elapsedMs:Date.now()-started, failures:failures.slice(-20), scripts:scripts.slice(-24), controls:await page.locator('[data-theme-ready]').evaluateAll(elements=>elements.map(el=>({ready:el.getAttribute('data-theme-ready'),display:getComputedStyle(el).display}))), body:(await page.locator('body').innerText({timeout:3000}).catch(()=>'unavailable')).slice(0,1200)}));
throw error;
}
};
const background = (selector: string) => page.locator(selector).first().evaluate(el => getComputedStyle(el).backgroundColor);
await visit('/', 'home-light');
assert.equal(await background('body'), 'rgb(255, 246, 239)');
assert.ok((await page.title()).includes('啡跃'));
assert.ok((await page.locator('link[rel="icon"]').all()).length >= 2);
assert.equal(await page.locator('.endoleap-mark-light').first().isVisible(), true);
await page.getByRole('button', { name: '切换明暗主题', exact: true }).first().click();
await page.waitForFunction(() => document.documentElement.dataset.theme === 'home-dark');
assert.equal(await background('body'), 'rgb(28, 24, 22)');
assert.equal(await page.locator('.endoleap-mark-dark').first().isVisible(), true);
await visit('/account', 'home-dark');
if (process.env.VERIFY_CANVAS_UI === '1') {
await visit('/graphic', 'canvas-dark');
assert.equal(await background('body'), 'rgb(20, 20, 20)');
assert.equal(await page.locator('[class*="boardWrap"] > div').first().evaluate(el => getComputedStyle(el).backgroundColor), 'rgb(255, 255, 255)', 'artwork stays white in dark chrome');
await page.getByRole('button', { name: '切换明暗主题', exact: true }).first().click();
await page.waitForFunction(() => document.documentElement.dataset.theme === 'canvas-light');
await visit('/infinite_canvas', 'canvas-light');
assert.equal(await background('[class*="viewport"]'), 'rgb(250, 250, 250)');
} else {
await page.getByRole('button', { name: '切换明暗主题', exact: true }).first().click();
}
await visit('/', 'home-light');
await page.setViewportSize({ width: 390, height: 844 });
assert.equal(await page.locator('.endoleap-mark-light').first().isVisible(), true);
assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1), 'mobile home has no horizontal overflow');
for (const width of [390, 320]) {
await page.setViewportSize({ width, height: 844 });
assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1), 'Editorial home fits narrow screens');
const entry = await page.getByRole('navigation', {name: '主导航'}).getByRole('link', {name: /进入工作台/}).boundingBox();
assert.ok(entry && entry.x >= 0 && entry.x + entry.width <= width, 'Workspace entry is visible, not clipped');
assert.equal(await page.getByRole('link', {name: /去知识空间逛逛/}).getAttribute('href'), '/documents');
assert.equal(await page.getByRole('link', {name: '开始创作', exact: true}).getAttribute('href'), '/account?next=/workspace');
}
console.log('Endoleap logos, both favicon paths, persisted home/canvas themes and artwork color isolation passed.');
} finally { await browser.close(); }
}
@@ -0,0 +1,87 @@
import assert from 'node:assert/strict';
import {randomUUID} from 'node:crypto';
import {db} from '../src/lib/db';
import {newToken,tokenDigest,ACCOUNT_COOKIE} from '../src/lib/account-crypto';
import type {CanvasDocument} from '../src/lib/canvas-document';
export async function verifyCanvasWorkspace(base:string){
const identities=await Promise.all([0,1].map(async()=>{const id=randomUUID(),token=newToken();await db().query("INSERT INTO account_users(id,email,password_hash,verified_at) VALUES($1,$2,'test',now())",[id,id+'@example.com']);await db().query("INSERT INTO account_tokens(digest,user_id,kind,expires_at) VALUES($1,$2,'session',now()+interval '1 hour')",[tokenDigest(token),id]);return {[`cookie`]:ACCOUNT_COOKIE+'='+token}}));
const origin=process.env.ACCOUNT_PUBLIC_ORIGIN||base;
const call=(path:string,method='GET',body?:unknown,headers:Record<string,string>={})=>fetch(base+'/api/canvases'+path,{method,headers:{'Content-Type':'application/json',origin,...headers},...(body===undefined?{}:{body:JSON.stringify(body)})});
const doc={version:1,cards:[{id:'note',kind:'text',title:'简报',text:'自然光与城市清晨',x:0,y:0},{id:'script',kind:'script',title:'脚本',text:'',instruction:'写六个分镜',operation:'script',x:500,y:0}],links:[['note','script']]};
assert.equal((await call('','POST',{document:doc})).status,401);
const create=await call('','POST',{document:doc},identities[0]);assert.equal(create.status,201,await create.clone().text());const canvas=await create.json();assert.equal(canvas.revision,1);
const root='/'+canvas.id;
assert.equal((await call(root,'GET',undefined,identities[1])).status,404);
assert.equal((await call(root,'PUT',{revision:1,document:doc},{...identities[0],origin:'https://evil.example'})).status,403);
assert.equal((await call(root,'PUT',{revision:1,document:doc},{...identities[0],authorization:'Bearer invalid',origin:'https://evil.example'})).status,404,'A bearer request cannot fall back to an owner cookie');
assert.equal((await call(root,'PUT',{revision:1,document:{...doc,links:[['note','script'],['script','note']]}},identities[0])).status,400,'Imported cyclic graphs are rejected');
const linked=await call(root+'/link','POST',{},identities[0]);assert.equal(linked.status,201);const credentials=await linked.json();const agent={authorization:'Bearer '+credentials.token};
assert.equal((await call('','GET',undefined,agent)).status,401);
const other=await (await call('','POST',{document:doc},identities[0])).json();assert.equal((await call('/'+other.id,'GET',undefined,agent)).status,404,'A link cannot read another canvas');
assert.equal((await call(root,'PUT',{revision:1,document:doc},identities[0])).status,200);
assert.equal((await call(root,'PUT',{revision:1,document:doc},identities[0])).status,409);
async function queue(rev:number){const r=await call(root+'/tasks','POST',{revision:rev,targetId:'script'},identities[0]);assert.equal(r.status,201,await r.clone().text());return (await r.json()).id as string}
const concurrent=await Promise.all([0,1].map(()=>call('/'+other.id,'PUT',{revision:1,document:doc},identities[0])));
assert.deepEqual(concurrent.map(r=>r.status).sort(),[200,409],'Concurrent saves cannot lose an update');
const first=await queue(2);
assert.equal((await call(root+'/tasks','POST',{revision:2,targetId:'script'},identities[0])).status,409,'Only one live task per target');
const recipe=await (await call(root+'/tasks/'+first,'GET',undefined,agent)).json();assert.equal(recipe.request.references[0].text,doc.cards[0].text);assert.equal(recipe.request.instruction,'写六个分镜');
assert.equal((await call(root+'/tasks/'+first,'GET',undefined,identities[1])).status,404);
const result={status:'succeeded',result:{text:'第一镜:城市醒来。'}};
assert.equal((await call(root+'/tasks/'+first,'PATCH',result,agent)).status,409,'Result cannot arrive before claim');
assert.equal((await call(root+'/tasks/'+first,'PATCH',{status:'running'},agent)).status,200);
assert.equal((await call(root,'PUT',{revision:2,document:{...doc,cards:doc.cards.map(n=>({...n,x:n.x+10}))}},identities[0])).status,200);
assert.equal((await call(root+'/tasks/'+first,'PATCH',result,agent)).status,409,'Stale result must not overwrite newer user edits');
assert.equal((await call(root+'/tasks/'+first,'PATCH',{status:'failed',error:'依据新版本重新派发'},identities[0])).status,200);
const second=await queue(3);assert.equal((await call(root+'/tasks/'+second,'PATCH',{status:'running'},agent)).status,200);
assert.equal((await call(root+'/tasks/'+second,'PATCH',{status:'succeeded',result:{text:{invalid:true}}},agent)).status,400,'Malformed results do not mutate the canvas');
assert.equal((await call(root+'/tasks/'+second,'PATCH',{status:'succeeded',result:{image:'data:image/png;base64,AAAA'}},agent)).status,400,'A script node cannot accept an image result');
assert.equal((await call(root+'/tasks/'+second,'PATCH',result,agent)).status,200);
assert.equal((await call(root+'/tasks/'+second,'PATCH',result,agent)).status,200,'Identical completion is idempotent');
assert.equal((await call(root+'/tasks/'+second,'PATCH',{status:'succeeded',result:{text:'覆盖'}},agent)).status,409);
const final=await (await call(root,'GET',undefined,identities[0])).json();assert.equal(final.revision,4);assert.equal(final.document.cards.find((n:{id:string})=>n.id==='script').text,'第一镜:城市醒来。');assert.deepEqual(final.document.links,doc.links);
const png='data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+ip1sAAAAASUVORK5CYII=';
const imageDocument:CanvasDocument={version:1,cards:[
{id:'source',kind:'image',title:'参考图',text:'保留原图',image:png,x:0,y:0},
{id:'generated',kind:'image',title:'图生图',text:'',instruction:'保留构图,改为雪夜',operation:'generate',x:500,y:0},
{id:'upscaled',kind:'image',title:'图片高清',text:'',operation:'upscale',x:1000,y:0},
],links:[['source','generated'],['generated','upscaled']]};
const imageCreate=await call('','POST',{document:imageDocument},identities[0]);assert.equal(imageCreate.status,201);
const imageCanvas=await imageCreate.json(),imageRoot='/'+imageCanvas.id;
const imageLink=await call(imageRoot+'/link','POST',{},identities[0]);assert.equal(imageLink.status,201);
const imageAgent={authorization:'Bearer '+(await imageLink.json()).token};
for(const [index,targetId] of ['generated','upscaled'].entries()){
const queued=await call(imageRoot+'/tasks','POST',{revision:index+1,targetId},identities[0]);assert.equal(queued.status,201,await queued.clone().text());
const taskId=(await queued.json()).id,taskPath=imageRoot+'/tasks/'+taskId;
const snapshotResponse=await call(taskPath,'GET',undefined,imageAgent);assert.equal(snapshotResponse.status,200);
const snapshot=await snapshotResponse.json();assert.equal(snapshot.request.operation,index===0?'generate':'upscale');
assert.equal(snapshot.request.references[0].id,index===0?'source':'generated');
assert.equal(snapshot.request.references[0].image,png,'Image tasks carry the referenced image bytes');
assert.deepEqual(snapshot.request.result.allowedFields,['image','text']);
assert.equal((await call(taskPath,'PATCH',{status:'running'},imageAgent)).status,200);
const completion={status:'succeeded',result:{image:png,text:index===0?'雪夜图片结果':'高清图片结果'}};
const completed=await call(taskPath,'PATCH',completion,imageAgent);assert.equal(completed.status,200,await completed.clone().text());
assert.equal((await call(taskPath,'PATCH',completion,imageAgent)).status,200,'Image completion retries are idempotent');
const stateResponse=await call(imageRoot,'GET',undefined,identities[0]);assert.equal(stateResponse.status,200);
const state=await stateResponse.json();assert.equal(state.revision,index+2);
const cards=(state.document as CanvasDocument).cards;
assert.equal(cards.find(n=>n.id===targetId)?.image,png);assert.equal(cards.find(n=>n.id===targetId)?.text,completion.result.text);
assert.deepEqual(cards.find(n=>n.id==='source'),imageDocument.cards[0],'Generation preserves the reference node');
assert.deepEqual(state.document.links,imageDocument.links,'Image results preserve the reference graph');
if(index===0)assert.deepEqual(cards.find(n=>n.id==='upscaled'),imageDocument.cards[2],'Generation only updates its target');
else assert.equal(cards.find(n=>n.id==='generated')?.text,'雪夜图片结果','Upscaling preserves the previous generated image');
}
assert.equal((await call(imageRoot+'/link','DELETE',{},identities[0])).status,200);
assert.equal((await call(imageRoot,'GET',undefined,imageAgent)).status,404);
assert.equal((await call(root+'/link','POST',{},agent)).status,403,'A delegated agent cannot mint links');
assert.equal((await call(root+'/link','DELETE',{},identities[0])).status,200);
assert.equal((await call(root,'GET',undefined,agent)).status,404,'Revocation takes effect on the next request');
const rotated=await (await call(root+'/link','POST',{},identities[0])).json();
const expired={authorization:'Bearer '+rotated.token};
await db().query("UPDATE canvas_agent_links SET expires_at=now()-interval '1 second' WHERE canvas_id=$1",[canvas.id]);
assert.equal((await call(root,'GET',undefined,expired)).status,404,'Expired capabilities are rejected');
const rotations=await Promise.all([call(root+'/link','POST',{},identities[0]),call(root+'/link','POST',{},identities[0])]);
assert.ok(rotations.every(r=>r.status===201));
assert.equal((await db().query('SELECT count(*)::int AS count FROM canvas_agent_links WHERE canvas_id=$1',[canvas.id])).rows[0].count,1,'Concurrent rotation leaves one active capability');
const docs=await call(root+'?agent_mode=True');assert.equal(docs.status,200);assert.match(await docs.text(),/Authorization/);
}
@@ -0,0 +1,122 @@
import { visitBrowserDocument } from "./brand-navigation.mjs";
import assert from 'node:assert/strict';
import {randomUUID} from 'node:crypto';
import {chromium,type Request} from 'playwright-core';
import {db} from '../src/lib/db';
import {hashPassword,ACCOUNT_COOKIE} from '../src/lib/account-crypto';
export async function verifyCanvasAgentUI(base:string){
assert.equal(process.env.PROMPT_INTEGRATION_TEST,'1');
const owner=randomUUID(),email=owner+'@example.com',password='Canvas integration example1!';
await db().query("INSERT INTO account_users(id,email,password_hash,verified_at) VALUES($1,$2,$3,now())",[owner,email,await hashPassword(password)]);
const browser=await chromium.launch({executablePath:process.env.CHROMIUM_PATH||'/usr/bin/chromium',headless:true,args:['--no-sandbox','--disable-dev-shm-usage']});
const context=await browser.newContext({viewport:{width:1440,height:1000}}),errors:string[]=[];
const page=await context.newPage(),canvasRequests:string[]=[],pending=new Set<Request>(),network:string[]=[];
const started=Date.now(),navigation:{ms:number;event:string;path:string}[]=[];
const mark=(event:string)=>{navigation.push({ms:Date.now()-started,event,path:new URL(page.url()).pathname});if(navigation.length>60)navigation.shift()};
page.on('framenavigated',frame=>{if(frame===page.mainFrame())mark('framenavigated')});
page.on('domcontentloaded',()=>mark('domcontentloaded'));page.on('load',()=>mark('load'));
const requestName=(request:{method:()=>string;url:()=>string})=>request.method()+' '+new URL(request.url()).pathname;
page.on('request',request=>pending.add(request));
page.on('requestfinished',request=>pending.delete(request));
page.on('requestfailed',request=>{pending.delete(request);network.push(requestName(request)+': '+request.failure()?.errorText)});
page.on('response',response=>{const url=new URL(response.url());if(url.pathname.startsWith('/api/canvases'))canvasRequests.push(`${response.request().method()} ${url.pathname} ${response.status()}`)});
try{
page.setDefaultTimeout(60000);page.setDefaultNavigationTimeout(60000);page.on('pageerror',e=>errors.push(e.message));
mark('open landing');await visitBrowserDocument(page,base+'/');
await page.getByRole('heading',{name:/想法不该停在对话里/,level:1}).waitFor();
const accountSession=page.waitForResponse(response=>new URL(response.url()).pathname==='/api/account/session'&&response.request().method()==='GET');
const [,sessionResponse]=await Promise.all([page.getByRole('link',{name:'开始创作',exact:true}).click(),accountSession]);
assert.equal(sessionResponse.status(),200);assert.equal((await sessionResponse.json()).user,null);
await page.getByRole('heading',{name:'欢迎回来',exact:true,level:2}).waitFor();
await page.locator('button[data-theme-ready="true"]').first().waitFor();
mark('login form ready');
await page.getByLabel('邮箱地址',{exact:true}).fill(email);
await page.getByLabel('密码',{exact:true}).fill(password);
mark('before login click');await page.locator('form').getByRole('button',{name:'登录',exact:true}).click();
mark('after login click');
await page.waitForURL(url=>url.pathname==='/workspace',{waitUntil:'commit'});
mark('workspace URL accepted');
await page.getByRole('heading',{name:'今天,让哪个想法成形?',exact:true}).waitFor();
await page.locator('button[data-theme-ready="true"]').first().waitFor();
mark('workspace rendered');
const session=(await context.cookies()).find(c=>c.name===ACCOUNT_COOKIE);assert.ok(session?.httpOnly,'The actual login response establishes an HTTP-only account session');
const ownerHeaders={cookie:ACCOUNT_COOKIE+'='+session.value};
const canvasLink=page.getByRole('link',{name:'无限画布',exact:true});
assert.equal(await canvasLink.getAttribute('href'),'/infinite_canvas');
await visitBrowserDocument(page,base+'/infinite_canvas');
await page.getByRole('link',{name:email,exact:true}).waitFor({state:'attached'});
await page.getByRole('button',{name:'从示例开始 ↗',exact:true}).click();
const shot=page.locator('[data-node-id="shot"] [data-node-content]');
await shot.click();await shot.click();
const composer=page.getByRole('region',{name:'节点编辑器',exact:true});
await composer.getByRole('textbox',{name:'创作要求',exact:true}).fill('根据参考资料写六个分镜');
await composer.getByRole('button',{name:'交给 Agent',exact:true}).click();
const panel=page.getByRole('region',{name:'Agent 连接',exact:true});
await panel.getByText('任务已创建,等待你的 coding agent 领取',{exact:true}).waitFor();
const list=await fetch(base+'/api/canvases',{headers:ownerHeaders}).then(r=>r.json());assert.equal(list.canvases.length,1);
const root='/api/canvases/'+list.canvases[0].id;
const state=await fetch(base+root,{headers:ownerHeaders}).then(r=>r.json());assert.equal(state.tasks.length,1);assert.equal(state.tasks[0].status,'queued');
await panel.getByRole('button',{name:'生成连接凭据',exact:true}).click();
const credentials=panel.getByRole('textbox',{name:'Agent 连接凭据',exact:true});await credentials.waitFor();
await panel.getByRole('button',{name:'复制给 coding agent',exact:true}).click();
await panel.getByRole('status').filter({hasText:/凭据已复制|已选中凭据/}).waitFor();
const first=JSON.parse(await credentials.inputValue());assert.equal(first.canvasUrl,base+root);
const agentHeaders={'Content-Type':'application/json',authorization:first.authorization};
const task=root+'/tasks/'+state.tasks[0].id;
const recipe=await fetch(base+task,{headers:agentHeaders}).then(r=>r.json());assert.equal(recipe.request.instruction,'根据参考资料写六个分镜');assert.equal(recipe.request.references[0].id,'reference');
const patch=async(body:unknown)=>fetch(base+task,{method:'PATCH',headers:agentHeaders,body:JSON.stringify(body)});
assert.equal((await patch({status:'running'})).status,200);
await panel.getByText(/Agent 正在处理/).waitFor();
// This fixture verifies the external-client protocol and UI delivery, not model quality.
let generated='第一镜:晨光穿过窗边的植物。';
assert.equal((await patch({status:'succeeded',result:{text:generated}})).status,200);
await page.locator('[data-node-id="shot"] [data-node-content]').getByText(generated,{exact:true}).waitFor();
const updated=await fetch(base+root,{headers:ownerHeaders}).then(r=>r.json());
assert.equal(updated.document.cards.find((n:{id:string})=>n.id==='shot').instruction,'根据参考资料写六个分镜');
await panel.getByRole('button',{name:'关闭 Agent 连接',exact:true}).click();
await shot.click();await shot.click();
await composer.getByRole('textbox',{name:'创作要求',exact:true}).fill('再写一个新镜头');
const queued=page.waitForResponse(r=>r.request().method()==='POST'&&new URL(r.url()).pathname===root+'/tasks');
await composer.getByRole('button',{name:'交给 Agent',exact:true}).click();
const queuedResponse=await queued;assert.equal(queuedResponse.status(),201);const secondTask=await queuedResponse.json();
await panel.getByRole('button',{name:'关闭 Agent 连接',exact:true}).click();
const brief=page.locator('[data-node-id="brief"] [data-node-content]');await brief.click();await brief.click();
const localText=page.getByRole('textbox',{name:'01 / 创意简报 内容',exact:true});await localText.fill('用户尚未同步的本机修改');
const nextTask=root+'/tasks/'+secondTask.id;
const nextPatch=(body:unknown)=>fetch(base+nextTask,{method:'PATCH',headers:agentHeaders,body:JSON.stringify(body)});
assert.equal((await nextPatch({status:'running'})).status,200);
generated='第二镜:城市在雨后醒来。';
assert.equal((await nextPatch({status:'succeeded',result:{text:generated}})).status,200);
await page.getByRole('button',{name:'Agent · 在线画布',exact:true}).click();
await panel.getByRole('button',{name:'载入在线版本',exact:true}).waitFor();
assert.equal(await localText.inputValue(),'用户尚未同步的本机修改','Polling must not replace unsaved local work');
await panel.getByRole('button',{name:'载入在线版本',exact:true}).click();
await page.locator('[data-node-id="shot"] [data-node-content]').getByText(generated,{exact:true}).waitFor();
assert.equal(await page.locator('[data-node-id="brief"] [data-node-content]').textContent(),state.document.cards.find((n:{id:string})=>n.id==='brief').text);
await panel.getByRole('button',{name:'生成连接凭据',exact:true}).click();
await page.waitForFunction(old=>{const field=document.querySelector<HTMLTextAreaElement>('textarea[aria-label="Agent 连接凭据"]');return !!field&&JSON.parse(field.value).authorization!==old},first.authorization);
const second=JSON.parse(await credentials.inputValue());assert.equal((await fetch(base+root,{headers:agentHeaders})).status,404);
await panel.getByRole('button',{name:'撤销连接',exact:true}).click();
await panel.getByText('Agent 访问已撤销',{exact:true}).waitFor();
assert.equal((await fetch(base+root,{headers:{authorization:second.authorization}})).status,404);
await page.setViewportSize({width:390,height:844});
const bounds=await panel.boundingBox();assert.ok(bounds&&bounds.x>=0&&bounds.x+bounds.width<=390,'Agent panel stays within the mobile viewport');
await visitBrowserDocument(page,page.url(),true);
await page.getByRole('link',{name:email,exact:true,includeHidden:true}).waitFor({state:'attached'});
await page.getByRole('button',{name:'Agent',exact:true}).click();
const reopened=page.getByRole('region',{name:'Agent 连接',exact:true});
await reopened.getByRole('combobox',{name:'打开在线画布',exact:true}).selectOption(list.canvases[0].id);
await reopened.getByRole('button',{name:'载入在线版本',exact:true}).click();
await page.locator('[data-node-id="shot"] [data-node-content]').getByText(generated,{exact:true}).waitFor();
assert.deepEqual(errors,[]);
console.log('Canvas navigation timeline',JSON.stringify(navigation));
console.log('Canvas UI dispatched a real stored task, received an external-client fixture result, rotated/revoked access and restored its online document');
}catch(error){
const statuses=await page.getByRole('status').allTextContents().catch(()=>[]);
const headings=await page.getByRole('heading').allTextContents().catch(()=>[]);
const documentState=await page.evaluate(()=>({readyState:document.readyState,navigation:performance.getEntriesByType('navigation').map(entry=>{const timing=entry as PerformanceNavigationTiming;return {type:timing.type,domContentLoadedEventEnd:timing.domContentLoadedEventEnd,loadEventEnd:timing.loadEventEnd}})})).catch(()=>null);
console.error('Canvas agent UI diagnostics',JSON.stringify({url:page.url(),browserConnected:browser.isConnected(),headings,statuses,documentState,navigation,pending:[...pending].map(requestName),network:network.slice(-20),canvasRequests:canvasRequests.slice(-20),errors}));
throw error;
}finally{await context.close();await browser.close()}
}
@@ -0,0 +1,3 @@
import {test} from 'node:test';import assert from 'node:assert/strict';import {requiredPackImages} from '../scripts/verify-character-pack.mjs';
test('editorial covers do not invalidate preserved original packs',()=>{const originals=[{id:'a',sha256:'hash-a'},{id:'b',sha256:'hash-b'}];assert.deepEqual(requiredPackImages(['hash-a','hash-b'],[{id:'cover',sha256:'cover'},...originals]),originals)});
test('extra images cannot disguise missing or replaced originals',()=>{assert.throws(()=>requiredPackImages(['a','b'],[{sha256:'a'},{sha256:'x'},{sha256:'y'}]));assert.throws(()=>requiredPackImages(['a','a'],[{sha256:'a'}]));assert.equal(requiredPackImages(['a','a'],[{sha256:'a'},{sha256:'a'}]).length,2)});
@@ -0,0 +1,31 @@
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {backlinksFor,documentLinks,rankDocuments,discoveryPage,InvalidDiscoveryCursor,type SearchDocument} from '../src/lib/discovery';
const doc=(id:string,patch:Partial<SearchDocument>={}):SearchDocument=>({id,title:'普通笔记 '+id,description:'',markdown:'',categories:[],namespace:'Main',createdAt:'2026-01-01',...patch});
test('backlinks count distinct documents, resolve UUID aliases and ignore self/external/image/code links',()=>{
const a=doc('a',{aliases:['aaaa-1111'],markdown:'[self](/explore/a)'}),b=doc('b',{markdown:'[inline](/explore/a) [again](http://8.211.154.134:18131/explore/aaaa-1111?agent_mode=True#x)\n\n[reference][target]\n\n[target]: /api/notes/a\n\n![image](/explore/c)\n\n`[code](/explore/c)`\n\n```md\n[fenced](/explore/c)\n```\n[external](https://example.com/explore/c)'}),c=doc('c',{markdown:'[a](/explore/a)'});
const refs=backlinksFor([a,b,c]);assert.deepEqual(refs.get('a')?.map(r=>r.id),['b','c']);assert.equal(refs.get('c')?.length,0);assert.equal(documentLinks(b.markdown).length,4);
});
test('search includes body, tags and descriptions; matches all terms, casefolds and ranks title above body',()=>{
const documents=[doc('a',{markdown:'正文专属关键词 React 实践',categories:['课程']}),doc('b',{title:'REACT 实践',description:'正文专属关键词'}),doc('c',{description:'React 介绍'})];
assert.deepEqual(rankDocuments(documents,'react 实践').map(r=>r.doc.id),['b','a']);
assert.deepEqual(rankDocuments(documents,'课程 正文专属关键词').map(r=>r.doc.id),['a']);
assert.deepEqual(rankDocuments(documents,'"react 实践"').map(r=>r.doc.id),['b','a']);assert.equal(rankDocuments(documents,'不存在').length,0);
});
test('default order favors inbound links and filters the entire corpus before paginating',()=>{
const documents=Array.from({length:52},(_,i)=>doc(i.toString(16).padStart(3,'0'),{categories:i%2?['wanted']:[],markdown:i?'[target](/explore/000)':''}));
const ranked=rankDocuments(documents);assert.equal(ranked[0].doc.id,'000');assert.equal(ranked[0].backlinkCount,51);
const filtered=rankDocuments(documents,'','wanted'),first=discoveryPage(filtered,'','wanted'),second=discoveryPage(filtered,'','wanted',first.nextCursor!);
assert.equal(first.total,26);assert.equal(first.rows.length,24);assert.equal(second.rows.length,2);assert.equal(second.nextCursor,null);assert.equal(new Set([...first.rows,...second.rows].map(r=>r.doc.id)).size,26);
assert.throws(()=>discoveryPage(filtered,'different','wanted',first.nextCursor!),InvalidDiscoveryCursor);assert.throws(()=>discoveryPage(filtered,'','wanted','bad'),InvalidDiscoveryCursor);
});
test('pasted full URLs count but disguised link labels and inline code do not',()=>{
const source=doc('b',{markdown:'http://8.211.154.134:18131/explore/a。\n\n[http://8.211.154.134:18131/explore/c](https://example.com)\n\n`http://8.211.154.134:18131/explore/c`'});
const refs=backlinksFor([doc('a'),source,doc('c')]);assert.equal(refs.get('a')?.length,1);assert.equal(refs.get('c')?.length,0);
});
test('system categories filter independently of ordinary tags and are searchable',()=>{
const ranked=rankDocuments([doc('a',{categories:['中国风'],systemTags:['文档入口','document-entry']}),doc('b',{categories:['中国风']})],'','document-entry');
assert.deepEqual(ranked.map(r=>r.doc.id),['a']);assert.equal(rankDocuments([doc('a',{categories:['中国风'],systemTags:['文档入口']})],'中国风 文档入口').length,1);
});
+17
View File
@@ -0,0 +1,17 @@
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {staticEntries,getEntry,entryText} from '../src/lib/explore';
import {pageId,previewUrl} from '../src/content/page-links';
import {guidanceDocuments} from '../src/content/skills/guidance';
import {getServerSideProps} from '../src/pages/explore/[id]';
test('every public document has a distinct persisted ID and canonical entry',async()=>{assert.equal(new Set(staticEntries.map(e=>e.id)).size,staticEntries.length);for(const e of staticEntries){assert.match(e.id,/^[a-f0-9]{24}$/);assert.equal((await getEntry(e.id))?.title,e.title);}for(const doc of guidanceDocuments)assert.ok(pageId('guidance:'+doc.path));assert.equal(await getEntry('unknown'),null);});
test('agent_mode on the canonical URL emits text with media links and no HTML',async()=>{const entry=staticEntries.find(e=>e.media.length)!;let body='';const headers:Record<string,string>={};const response={setHeader:(k:string,v:string)=>headers[k]=v,end:(value:string)=>{body=value}};await getServerSideProps({params:{id:entry.id},query:{agent_mode:'True'},res:response,req:{method:'GET'}} as never);assert.match(headers['Content-Type'],/text\/plain/);assert.ok(body.includes(entry.title));assert.ok(body.includes(entry.media[0].url));assert.ok(body.startsWith('# '+entry.title));assert.ok(!body.includes('!['));assert.equal(body,entryText((await getEntry(entry.id))!));});
test('preview URLs always use bounded image endpoints',()=>{const url=new URL(previewUrl('/api/media/id?variant=poster',480),'http://test');assert.equal(url.pathname,'/api/preview');assert.equal(url.searchParams.get('source'),'/api/media/id?variant=poster');assert.equal(url.searchParams.get('width'),'480');});
test('independent notes expose their own description, tags and complete image counts',async()=>{
for(const entry of staticEntries){assert.ok(entry.description.trim());assert.equal(entry.imageCount,entry.media.filter(m=>m.kind==='image').length);assert.equal(new Set(entry.media.map(m=>m.url)).size,entry.media.length);}
const note=(await getEntry(pageId('guidance:shadow-puppet')))!;
assert.equal(note.imageCount,2);assert.ok(note.categories.includes('照片入皮影'));assert.ok(entryText(note).includes('Description: '+note.description));
const workflow=(await getEntry(pageId('guidance:shadow-puppet/workflow')))!;
assert.notEqual(workflow.description,note.description);assert.notEqual(workflow.id,note.id);
});
@@ -0,0 +1,38 @@
import assert from 'node:assert/strict';
import {readFile} from 'node:fs/promises';
import {randomUUID} from 'node:crypto';
import {db} from '../src/lib/db';
export async function verifyFlatNoteMigration(){
const client=await db().connect();const schema='migration_'+randomUUID().replaceAll('-','');
try{
await client.query(`CREATE SCHEMA ${schema}`);await client.query(`SET search_path TO ${schema}`);
for(const name of ['001_library','002_page_snapshots','003_note_descriptions'])await client.query(await readFile(`db/${name}.sql`,'utf8'));
const [video,picture,post,tag,duplicate,parent]=Array.from({length:6},()=>randomUUID());
await client.query(`INSERT INTO assets(id,kind,storage_key,mime_type,byte_size) VALUES($1,'video','original','video/mp4',100),($2,'image','snapshot','image/png',50)`,[video,picture]);
await client.query(`INSERT INTO posts(id,slug,kind,title,page_key) VALUES($1,'old-note','article','保留的笔记','old-code')`,[post]);
await client.query(`INSERT INTO post_assets(post_id,asset_id,position) VALUES($1,$2,0)`,[post,video]);
await client.query(`INSERT INTO page_snapshots(post_id,page_index,asset_id,revision) VALUES($1,1,$2,'old-revision')`,[post,picture]);
await client.query(`INSERT INTO render_jobs(post_id,status,revision) VALUES($1,'pending','old-revision')`,[post]);
await client.query(`INSERT INTO asset_variants(asset_id,role,storage_key,mime_type,byte_size) VALUES($1,'poster','old-poster','image/jpeg',20)`,[video]);
await client.query(`INSERT INTO media_jobs(id,asset_id) VALUES($1,$2)`,[randomUUID(),video]);
await client.query(`INSERT INTO tags(id,slug,name) VALUES($1,'parent','上级'),($2,'same','重复')`,[parent,tag]);
await client.query(`INSERT INTO tags(id,parent_id,slug,name) VALUES($1,$2,'same','重复')`,[duplicate,parent]);
await client.query(`INSERT INTO post_tags(post_id,tag_id) VALUES($1,$2),($1,$3)`,[post,tag,duplicate]);
await client.query(`INSERT INTO asset_tags(asset_id,tag_id) VALUES($1,$2)`,[video,duplicate]);
await client.query(await readFile('db/004_flat_notes.sql','utf8'));
assert.equal((await client.query('SELECT count(*)::integer n FROM post_assets WHERE post_id=$1 AND asset_id=$2',[post,picture])).rows[0].n,1,'Previously generated image remains linked');
const processed=(await client.query(`SELECT a.storage_key,pa.role FROM post_assets pa JOIN assets a ON a.id=pa.asset_id WHERE pa.post_id=$1 AND a.derivation='poster'`,[post])).rows;
assert.deepEqual(processed,[{storage_key:'old-poster',role:'poster'}],'Processed file uses its existing object and a normal resource link');
assert.equal((await client.query('SELECT count(*)::integer n FROM post_tags WHERE post_id=$1',[post])).rows[0].n,1);
assert.equal((await client.query('SELECT count(*)::integer n FROM asset_tags WHERE asset_id=$1',[video])).rows[0].n,1,'Merging equal tags retains asset associations');
assert.equal((await client.query('SELECT count(*)::integer n FROM media_jobs')).rows[0].n,1,'Video processing queue survives');
assert.equal((await client.query(`SELECT count(*)::integer n FROM information_schema.columns WHERE table_schema=$1 AND column_name IN ('parent_id','page_key')`,[schema])).rows[0].n,0);
await client.query(await readFile('db/008_system_tags.sql','utf8'));
assert.equal((await client.query("SELECT count(*)::integer n FROM tags WHERE kind='system'")).rows[0].n,2);
assert.ok((await client.query("SELECT 1 FROM tags WHERE kind='normal'")).rowCount);
assert.equal((await client.query('SELECT count(*)::integer n FROM post_tags WHERE post_id=$1',[post])).rows[0].n,1,'System tag migration retains ordinary associations');
await assert.rejects(client.query("INSERT INTO tags(id,slug,name,kind) VALUES($1,'invalid-system','Invalid system','system')",[randomUUID()]));
}finally{
await client.query('ROLLBACK');await client.query('SET search_path TO public');await client.query(`DROP SCHEMA IF EXISTS ${schema} CASCADE`);client.release();
}
}
@@ -0,0 +1,87 @@
import { visitBrowserDocument } from "./brand-navigation.mjs";
import assert from 'node:assert/strict';
import {verifyGuidanceWorkspaceUI} from './guidance-workspace-ui';
import {chromium} from 'playwright-core';
import {randomUUID} from 'node:crypto';
import {newToken,tokenDigest,ACCOUNT_COOKIE} from '../src/lib/account-crypto';
import {guidanceXml,type Guidance} from '../src/content/guides/catalog';
import {db} from '../src/lib/db';
import {publishDocument,resolveDocument,withdrawDocument,discoverDocuments} from '../src/lib/guidance-store';
export async function verifyGuidancePublishing(base:string){
const owner=randomUUID(),other=randomUUID(),id='user-'+owner+'-document';
for(const uid of [owner,other])await db().query("INSERT INTO account_users(id,email,password_hash,verified_at) VALUES($1,$2,'test',now())",[uid,uid+'@example.com']);
const doc:Guidance={id,revision:1,kind:'context',title:'实际数据库文档',summary:'摘要',applies:'条件',body:'正文',steps:[],checks:[],links:[],tags:[]};
await assert.rejects(()=>publishDocument(other,doc),/INVALID_ID/);
await publishDocument(other,{...doc,id:'user-'+other+'-document'});
await publishDocument(owner,doc);assert.equal((await resolveDocument(id,1))?.title,doc.title);
await assert.rejects(()=>publishDocument(other,{...doc,revision:2}),/INVALID_ID/);
const concurrent=await Promise.allSettled([publishDocument(owner,{...doc,revision:2,body:'新正文'}),publishDocument(owner,{...doc,revision:2})]);assert.equal(concurrent.filter(r=>r.status==='fulfilled').length,1);
assert.equal((await resolveDocument(id,1))?.body,'正文');assert.equal(await resolveDocument(id,999),null);
await assert.rejects(()=>db().query('UPDATE guidance_revisions SET content=$2 WHERE document_id=$1',[id,{}]),/immutable/);
assert.equal(await withdrawDocument(other,id),false);assert.equal(await withdrawDocument(owner,id),true);assert.equal(await resolveDocument(id),null);assert.ok(await resolveDocument(id,1));assert.ok(!(await discoverDocuments()).some(d=>d.id===id));
const token=newToken();await db().query("INSERT INTO account_tokens(digest,user_id,kind,expires_at) VALUES($1,$2,'session',now()+interval '1 hour')",[tokenDigest(token),owner]);
const headers={'Content-Type':'application/json',origin:process.env.ACCOUNT_PUBLIC_ORIGIN||base,cookie:ACCOUNT_COOKIE+'='+token};
const httpDoc={...doc,id:'user-'+owner+'-http',tags:['往返标签']};
const request=(path:string,method:string,body?:unknown,extra:Record<string,string>={})=>fetch(base+'/api/documents'+path,{method,headers:{...headers,...extra},...(body?{body:typeof body==='string'?body:JSON.stringify(body)}:{})});
assert.equal((await request('','POST',httpDoc,{cookie:''})).status,401);
assert.equal((await request('','POST',httpDoc,{origin:'https://untrusted.example'})).status,403);
const preview=await request('/preview','POST',httpDoc);assert.equal(preview.status,200,await preview.clone().text());assert.equal(await resolveDocument(httpDoc.id,1),null);
const created=await request('','POST',guidanceXml(httpDoc),{'Content-Type':'application/xml'});assert.equal(created.status,201,await created.clone().text());
assert.equal((await request('','POST',httpDoc)).status,409);
assert.equal((await request('','POST',{...httpDoc,id:'user-'+other+'-http'})).status,400);
const own=await fetch(base+'/api/documents?author='+owner).then(r=>r.json());assert.ok(own.documents.some((d:{id:string})=>d.id===httpDoc.id));assert.ok(own.documents.every((d:{author:string})=>d.author===owner));assert.equal((await fetch(base+'/api/documents?author=invalid')).status,400);
const xml=await fetch(base+'/api/documents/'+httpDoc.id+'?revision=1&format=xml');assert.equal(xml.status,200);assert.match(await xml.text(),/<tag>往返标签<\/tag>/);
const page=await fetch(base+'/documents/'+httpDoc.id+'?revision=1');assert.equal(page.status,200);assert.ok((await page.text()).includes('实际数据库文档'));
const directory=await fetch(base+'/api/documents');assert.equal(directory.status,200);assert.ok((await directory.json()).documents.some((d:{id:string})=>d.id===httpDoc.id),'Published document must appear in the public API');
// The document discovery list hydrates from the real API; the homepage is the product landing page.
const browser=await chromium.launch({executablePath:process.env.CHROMIUM_PATH||'/usr/bin/chromium',headless:true,args:['--no-sandbox','--disable-dev-shm-usage']});
try{
const page=await browser.newPage();page.setDefaultTimeout(60000);page.setDefaultNavigationTimeout(60000);
const navigationDiagnostics:string[]=[],inflight=new Set<string>();
const record=(value:string)=>{navigationDiagnostics.push(value.slice(0,1800));if(navigationDiagnostics.length>24)navigationDiagnostics.shift();};
page.on('pageerror',error=>record('pageerror: '+error.message));
page.on('console',message=>{if(message.type()==='error')record('console: '+message.text());});
page.on('request',request=>inflight.add(request.url()));
page.on('requestfinished',request=>inflight.delete(request.url()));
page.on('requestfailed',request=>{inflight.delete(request.url());record('requestfailed: '+new URL(request.url()).pathname+' '+request.failure()?.errorText)});
page.on('response',response=>{const path=new URL(response.url()).pathname;if(path.startsWith('/api/')||path.includes('/_next/data/'))void response.text().then(body=>record(`data ${response.status()} ${path}: ${body.slice(0,1400)}`)).catch(()=>{});else if(response.status()>=400||response.request().resourceType()==='script')record(`resource ${response.status()} ${path}`);});
try{
const link=`a[href="/documents/${httpDoc.id}?revision=1"]`;
const navigation=await visitBrowserDocument(page,base+'/documents');record('directory HTTP '+navigation?.status());
await page.locator(link).waitFor({state:'visible'});assert.equal(await page.locator(link).getByRole('heading').textContent(),httpDoc.title);
// Published matches must not inherit the examples' empty state; submitted filters survive reload.
const search=page.getByRole('textbox',{name:'搜索文档'});
await search.fill(httpDoc.title);await search.press('Enter');
await page.waitForURL(url=>url.searchParams.get('q')===httpDoc.title,{waitUntil:'commit'});
await page.getByRole('status').filter({hasText:'找到 2 篇文档'}).waitFor();
assert.equal(await page.getByText('没有匹配文档',{exact:false}).count(),0);
await visitBrowserDocument(page,page.url(),true);
await page.getByRole('status').filter({hasText:'找到 2 篇文档'}).waitFor();
// The matching count can also describe the unfiltered list before router hydration.
await page.waitForFunction(title=>document.querySelector<HTMLInputElement>('input[aria-label="搜索文档"]')?.value===title,httpDoc.title);
assert.equal(await search.inputValue(),httpDoc.title);
await page.getByRole('tab',{name:/事实依据/}).click();
await page.waitForURL(url=>url.searchParams.get('kind')==='evidence',{waitUntil:'commit'});
await page.getByRole('heading',{name:'没有匹配文档',exact:true}).waitFor();
await page.getByRole('button',{name:'清除所有筛选'}).click();
await page.waitForURL(url=>url.search==='',{waitUntil:'commit'});
await page.locator(link).waitFor({state:'visible'});
await page.waitForFunction(()=>document.querySelector<HTMLInputElement>('input[aria-label="搜索文档"]')?.value==='');
assert.equal(await search.inputValue(),'');
await page.getByRole('navigation',{name:'主导航',exact:true}).getByRole('link',{name:'社区文档',exact:true}).click();await page.locator(link).waitFor({state:'visible'});
await page.locator(link).click();
await page.waitForURL(url=>url.pathname==='/documents/'+httpDoc.id&&url.searchParams.get('revision')==='1',{waitUntil:'commit'});
await page.getByRole('heading',{name:httpDoc.title,exact:true,level:1}).waitFor();
assert.equal(await page.locator('h1').textContent(),httpDoc.title);
await visitBrowserDocument(page,base+'/write');await page.getByRole('heading',{name:'发布独立文档',exact:true,level:1}).waitFor();assert.equal(await page.locator('h1').textContent(),'发布独立文档');
}catch(error){console.error('Document navigation diagnostics',JSON.stringify({url:page.url(),headings:await page.locator('h1').allTextContents(),body:(await page.locator('body').innerText()).slice(0,2200),inflight:[...inflight].map(url=>new URL(url).pathname),events:navigationDiagnostics}));throw error;}
await page.close();
await verifyGuidanceWorkspaceUI(base,browser);
console.log('Workspace desktop/mobile, filters, reading, account and publishing assertions passed in a fresh browser context');
}finally{await browser.close();}
assert.equal((await request('/'+httpDoc.id,'DELETE',undefined,{cookie:''})).status,401);
assert.equal((await request('/'+httpDoc.id,'DELETE')).status,200);
assert.equal((await fetch(base+'/api/documents/'+httpDoc.id)).status,404);
assert.equal((await fetch(base+'/api/documents/'+httpDoc.id+'?revision=1')).status,200);
}
@@ -0,0 +1,10 @@
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {parseDocumentXml,documentInput} from '../src/lib/guidance-input';
import {guidanceXml} from '../src/content/guides/catalog';
const doc={id:'user-test',revision:1,kind:'guide' as const,title:'A & B',summary:'摘要',applies:'适用',body:'<正文>',steps:['一步'],checks:[],links:[],tags:[]};
test('XML canonical round trip and strict rejection',()=>{const xml=guidanceXml(doc);assert.deepEqual(parseDocumentXml(xml),doc);for(const bad of [xml.replace('<title>','<title extra="1">'),xml.replace('</body>','</body><title>duplicate</title>'),xml.replace('urn:linked-guidance:0.1','urn:linked-guidance:0.2'),xml.replace('&amp;','&unknown;'),xml.replace('<guidance','<!DOCTYPE guidance><guidance'),xml.replace('revision="1"','revision="1" revision="2"'),xml.replace('<body>','<body><script>')])assert.throws(()=>parseDocumentXml(bad));assert.throws(()=>documentInput.parse({...doc,admin:true}));assert.throws(()=>documentInput.parse({...doc,id:'review-changes'}));});
test('XML rejects forbidden Unicode, CDATA terminator and non-XML whitespace',()=>{const xml=guidanceXml(doc);for(const bad of [xml.replace('摘要','bad]]>value'),xml.replace('摘要','bad\u0000value'),xml.replace('摘要','bad\ud800value'),xml+'\u00a0',xml.replace(' id=', '\u00a0id=')])assert.throws(()=>parseDocumentXml(bad));assert.throws(()=>documentInput.parse({...doc,body:'\u000b正文'}));assert.equal(parseDocumentXml(xml.replace('摘要','😀摘要')).summary,'😀摘要');});
test('tags round trip and quoted attribute greater-than remains text',()=>{const value={...doc,tags:['标签'],links:[{target:'source-trust',revision:1,relation:'context' as const,when:'a > b',label:'来源'}]};const xml=guidanceXml(value);assert.deepEqual(parseDocumentXml(xml),value);assert.deepEqual(parseDocumentXml(xml.replace('a &gt; b','a > b')),value);assert.throws(()=>parseDocumentXml(xml.replace('when="a &gt; b"','when="a < b"')));});
@@ -0,0 +1,380 @@
/** UI fixtures run in a fresh context; real storage and authentication have separate coverage. */
import assert from "node:assert/strict";
import { visitBrowserDocument } from "./brand-navigation.mjs";
import { mkdir } from "node:fs/promises";
import type { Browser } from "playwright-core";
import { documentInput, parseDocumentXml } from "../src/lib/guidance-input";
import { guidanceXml, type Guidance } from "../src/content/guides/catalog";
const owner = "11111111-1111-4111-8111-111111111111";
const authored: Guidance = {
id: "user-" + owner + "-review",
revision: 1,
kind: "guide",
title: "准备一次清晰的代码审查",
summary: "用明确的问题和证据,完成一次有用的代码审查。",
applies: "提交合并请求前",
body: "先理解改动目标,再核对实现与验证。",
steps: [],
checks: [],
links: [],
tags: [],
};
export async function verifyGuidanceWorkspaceUI(base: string, browser: Browser) {
const context = await browser.newContext({
viewport: { width: 1440, height: 1000 },
});
let signedIn = false,
previewError = false,
requestedAuthor: string | null = null,
published: Guidance | null = null;
const errors: string[] = [];
let sessionReplies = 0;
try {
await context.route("**/api/account/session", async (route) => {
await route.fulfill({
status: 200,
json: {
user: signedIn
? {
id: owner,
email: "editor@example.com",
verified_at: "2026-10-29T00:00:00Z",
}
: null,
},
});
sessionReplies += 1;
});
await context.route(
(url) => url.pathname === "/api/documents",
async (route) => {
if (route.request().method() === "GET") {
requestedAuthor = new URL(route.request().url()).searchParams.get(
"author",
);
return route.fulfill({
json: { documents: [{ ...authored, author: owner }] },
});
}
published = documentInput.parse(route.request().postDataJSON());
return route.fulfill({ status: 201, json: { document: published } });
},
);
await context.route("**/api/documents/preview", (route) => {
if (previewError)
return route.fulfill({
status: 400,
json: { error: "输入不符合协议", fields: ["applies"] },
});
let document: Guidance;
try {
document =
route.request().headers()["content-type"] === "application/xml"
? parseDocumentXml(route.request().postData() || "")
: documentInput.parse(route.request().postDataJSON());
} catch {
return route.fulfill({
status: 400,
json: { error: "输入不符合协议" },
});
}
return route.fulfill({
json: {
document,
xml: guidanceXml(document),
links: document.links.map((link) => ({ ...link, resolved: true })),
},
});
});
await context.route("**/api/documents/" + authored.id, (route) =>
route.fulfill({ json: { document: authored } }),
);
const page = await context.newPage();
page.setDefaultTimeout(60000);
page.setDefaultNavigationTimeout(60000);
page.on("pageerror", (error) => errors.push(error.message));
page.on("crash", () => errors.push("Browser renderer crashed"));
const pending = new Set<string>(), network: string[] = [];
page.on("request", request => pending.add(request.url()));
page.on("requestfinished", request => pending.delete(request.url()));
page.on("requestfailed", request => {pending.delete(request.url());network.push(new URL(request.url()).pathname+": "+request.failure()?.errorText);});
async function navigate(url: string, reload = false) {
const previousSessionReplies = sessionReplies;
try {
const navigation = await visitBrowserDocument(page, url, reload);
assert.ok(navigation?.ok(), "Navigation returns a successful document");
if (new URL(url).pathname === "/account") {
await page.getByRole("heading", { name: signedIn ? "你的账户" : "欢迎回来", exact: true }).waitFor();
} else {
await page.getByText(signedIn ? "editor@example.com" : "登录你的账户",{exact:true}).first().waitFor({state:"attached"});
}
await page.getByText("正在检查账户状态…", {exact: true}).waitFor({state: "hidden"});
assert.ok(sessionReplies > previousSessionReplies, "The new document requests its account fixture and renders the resulting account state");
} catch (error) {
const probeStarted=Date.now();
const server=await fetch(url,{signal:AbortSignal.timeout(3000)}).then(async response=>{await response.body?.cancel();return {status:response.status,elapsedMs:Date.now()-probeStarted}}).catch(error=>({error:error instanceof Error?error.name:"unknown",elapsedMs:Date.now()-probeStarted}));
console.error("Workspace UI navigation diagnostics",JSON.stringify({url:page.url(),browserConnected:browser.isConnected(),server,pending:[...pending].map(value=>new URL(value).pathname),network:network.slice(-20),errors,body:(await page.locator("body").innerText().catch(()=>"unavailable")).slice(0,1600)}));
throw error;
}
}
await navigate(base + "/documents");
await page
.getByRole("heading", { name: authored.title, exact: true })
.waitFor();
assert.equal(await page.title(), "社区文档 · 啡跃");
const search = page.getByRole("textbox", { name: "搜索文档" });
await search.fill("不存在的文档");
await search.press("Enter");
await page
.getByRole("heading", { name: "没有匹配文档", exact: true })
.waitFor();
await navigate(page.url(), true);
await page.getByRole("heading",{name:"没有匹配文档",exact:true}).waitFor();
await page.waitForFunction(() => document.querySelector<HTMLInputElement>('input[aria-label="搜索文档"]')?.value === "不存在的文档");
assert.equal(await search.inputValue(), "不存在的文档");
await page
.getByRole("button", { name: "清除所有筛选", exact: true })
.click();
await page
.getByRole("heading", { name: authored.title, exact: true })
.waitFor();
await page.getByRole("tab", { name: /事实依据/ }).focus();
await page.keyboard.press("Home");
await page.getByRole("tab", { name: /全部文档/ }).waitFor();
if (process.env.WORKSPACE_SCREENSHOT_DIR) {
await mkdir(process.env.WORKSPACE_SCREENSHOT_DIR, { recursive: true });
await page.screenshot({
path: process.env.WORKSPACE_SCREENSHOT_DIR + "/directory.png",
fullPage: true,
});
}
await navigate(base + "/guide/review-changes?revision=1");
await page.getByRole("tab", { name: "XML 源文档" }).click();
assert.match(await page.locator("pre").innerText(), /linked-guidance/);
await page.getByRole("tab", { name: /版本记录/ }).click();
await page.getByText("当前阅读", { exact: true }).waitFor();
await page.getByRole("tab", { name: "文档概览" }).click();
if (process.env.WORKSPACE_SCREENSHOT_DIR)
await page.screenshot({
path: process.env.WORKSPACE_SCREENSHOT_DIR + "/reader.png",
fullPage: true,
});
await navigate(base + "/account");
assert.equal(await page.getByRole("complementary").count(), 0, "Account page has no workspace sidebar");
assert.equal(await page.getByRole("link", { name: "创建文档", exact: true }).count(), 0);
await page.getByRole("link", { name: "返回首页", exact: true }).waitFor();
const accountFits = async () => assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1 && document.documentElement.scrollHeight <= innerHeight + 1), "Account page fits the viewport on both axes");
await accountFits();
await context.route("**/api/account/login", route => route.fulfill({status: 401, json: {error: "测试登录失败,请重试"}}));
await page.getByLabel("邮箱地址", {exact: true}).fill("fixture@example.com");
await page.getByLabel("密码", {exact: true}).fill("fixture-password-long");
await page.waitForFunction(() => document.getAnimations().every(animation => animation.playState !== "running"));
const beforeMessage = await page.getByLabel("邮箱地址", {exact: true}).boundingBox();
await page.locator("form").getByRole("button", {name: "登录", exact: true}).click();
await page.getByRole("alert").getByText("测试登录失败,请重试").waitFor();
const afterMessage = await page.getByLabel("邮箱地址", {exact: true}).boundingBox();
assert.equal(afterMessage?.y, beforeMessage?.y, "Status messages must not move the form");
await accountFits();
await page.getByRole("button", {name: "关闭提示", exact: true}).click();
await context.route("**/api/account/forgot", route => route.fulfill({json: {ok: true, message: "若账户符合条件,重设链接将发送到邮箱。"}}));
await page.getByRole("button", {name: "忘记密码?", exact: true}).click();
await page.getByRole("heading", {name: "找回你的账户", exact: true}).waitFor();
await page.getByLabel("邮箱地址", {exact: true}).fill("fixture@example.com");
await page.getByRole("button", {name: "发送重设链接", exact: true}).click();
await page.getByRole("status").getByText("若账户符合条件,重设链接将发送到邮箱。").waitFor();
await page.getByRole("button", {name: "返回登录", exact: true}).click();
await page.getByRole("button", { name: "创建账户", exact: true }).click();
await page.getByRole("heading", { name: "创建你的账户" }).waitFor();
await page
.getByLabel("密码", { exact: true })
.fill("a-long-example-password");
await page.getByRole("button", { name: "显示密码" }).click();
assert.equal(
await page.getByLabel("密码", { exact: true }).getAttribute("type"),
"text",
);
await page.getByRole("button", { name: "隐藏密码" }).click();
await page.getByLabel("密码", { exact: true }).fill("");
if (process.env.WORKSPACE_SCREENSHOT_DIR)
await page.screenshot({
path: process.env.WORKSPACE_SCREENSHOT_DIR + "/account.png",
fullPage: true,
});
for (const viewport of [{width: 390, height: 844}, {width: 320, height: 568}]) {
await page.setViewportSize(viewport);
await accountFits();
await page.getByLabel("邮箱地址", {exact: true}).waitFor({state: "visible"});
}
await page.setViewportSize({width: 1440, height: 1000});
await context.route("**/api/account/reset", async route => {
const body = route.request().postDataJSON();
assert.equal(body.token, "f".repeat(64));
assert.equal(body.password, "replacement-fixture-password");
await route.fulfill({json: {ok: true}});
});
await visitBrowserDocument(page, base + "/account?reset=" + "f".repeat(64));
await page.getByRole("heading", {name: "设置新密码", exact: true}).waitFor();
assert.equal(new URL(page.url()).searchParams.has("reset"), false, "Recovery token is removed from the visible URL");
await page.getByLabel("新密码", {exact: true}).fill("replacement-fixture-password");
await page.getByLabel("确认新密码", {exact: true}).fill("replacement-fixture-password");
await page.getByRole("button", {name: "保存新密码", exact: true}).click();
await page.getByRole("heading", {name: "欢迎回来", exact: true}).waitFor();
await accountFits();
await navigate(base + "/write");
await page.getByRole("heading", { name: "登录后开始编写" }).waitFor();
signedIn = true;
await navigate(page.url(), true);
await page.getByLabel("文档短名", { exact: true }).fill("workspace-test");
await page.getByLabel("标题", { exact: false }).fill("界面回归验证文档");
await page
.getByLabel("一句话摘要", { exact: false })
.fill("可独立使用的指导");
await page
.getByLabel("适用条件", { exact: false })
.fill("检查界面流程时");
await page
.getByLabel("正文", { exact: false })
.fill("先判断适用条件,再阅读正文。");
await page
.getByLabel("建议步骤", { exact: true })
.fill("第一步\n\n第二步");
previewError = true;
await page
.getByRole("button", { name: "校验并预览", exact: true })
.click();
await page.getByRole("alert").filter({ hasText: "适用条件" }).waitFor();
previewError = false;
await page
.getByRole("button", { name: "校验并预览", exact: true })
.click();
await page.getByRole("dialog").waitFor({ state: "visible" });
await page.getByRole("button", { name: "返回编辑" }).click();
assert.equal(
await page.getByLabel("正文", { exact: false }).inputValue(),
"先判断适用条件,再阅读正文。",
);
await page.getByRole("button", { name: "添加条件链接" }).click();
await page.getByRole("button", { name: "移除关联文档 1" }).click();
if (process.env.WORKSPACE_SCREENSHOT_DIR) {
await page.evaluate(() => window.scrollTo(0, 0));
await page.screenshot({
path: process.env.WORKSPACE_SCREENSHOT_DIR + "/editor.png",
fullPage: true,
});
}
await page.getByRole("button", { name: "发布文档", exact: true }).click();
await page
.getByRole("status")
.filter({ hasText: "已公开发布" })
.waitFor();
assert.ok(published);
assert.deepEqual((published as Guidance).steps, ["第一步", "第二步"]);
assert.equal(
(published as Guidance).id,
"user-" + owner + "-workspace-test",
);
await navigate(base + "/documents?scope=mine");
await page
.getByRole("heading", { name: authored.title, exact: true })
.waitFor();
assert.equal(requestedAuthor, owner);
await navigate(base + "/write?id=" + authored.id);
await page.waitForFunction(title=>Array.from(document.querySelectorAll<HTMLInputElement>("input")).some(input=>input.value===title),authored.title);
assert.equal(
await page.getByLabel("文档短名", { exact: true }).isDisabled(),
true,
);
assert.equal(
await page.getByLabel("标题", { exact: false }).inputValue(),
authored.title,
);
await page.getByRole("button", { name: "协议 XML", exact: true }).click();
const xmlInput = page.getByLabel("XML 输入");
assert.match(await xmlInput.inputValue(), /revision="2"/);
await xmlInput.fill(
(await xmlInput.inputValue()).replace(
authored.title,
"通过 XML 更新的文档",
),
);
await page
.getByRole("button", { name: "可视化编辑", exact: true })
.click();
assert.equal(
await page.getByLabel("标题", { exact: false }).inputValue(),
"通过 XML 更新的文档",
);
await page.setViewportSize({ width: 390, height: 844 });
await navigate(base + "/documents");
assert.equal(
await page.evaluate(
() => document.documentElement.scrollWidth <= window.innerWidth,
),
true,
);
await page.getByRole("button", { name: "打开导航" }).click();
await page
.getByRole("dialog", { name: "工作区导航" })
.waitFor({ state: "visible" });
await page.keyboard.press("Escape");
assert.equal(
await page.getByRole("dialog", { name: "工作区导航" }).isVisible(),
false,
);
if (process.env.WORKSPACE_SCREENSHOT_DIR)
await page.screenshot({
path: process.env.WORKSPACE_SCREENSHOT_DIR + "/mobile-directory.png",
fullPage: true,
});
await navigate(base + "/guide/review-changes?revision=1");
assert.equal(
await page.evaluate(
() => document.documentElement.scrollWidth <= window.innerWidth,
),
true,
);
if (process.env.WORKSPACE_SCREENSHOT_DIR)
await page.screenshot({
path: process.env.WORKSPACE_SCREENSHOT_DIR + "/mobile-reader.png",
fullPage: true,
});
await navigate(base + "/account");
assert.equal(
await page.evaluate(
() => document.documentElement.scrollWidth <= window.innerWidth,
),
true,
);
await navigate(base + "/write");
await page.getByLabel("文档短名", { exact: true }).waitFor();
await page.getByRole("button", { name: "协议 XML", exact: true }).click();
await page.getByLabel("XML 输入").waitFor();
assert.equal(
await page.evaluate(
() => document.documentElement.scrollWidth <= window.innerWidth,
),
true,
);
if (process.env.WORKSPACE_SCREENSHOT_DIR)
await page.screenshot({
path: process.env.WORKSPACE_SCREENSHOT_DIR + "/mobile-editor.png",
fullPage: true,
});
await navigate(base + "/protocol");
assert.equal(
await page.evaluate(
() => document.documentElement.scrollWidth <= window.innerWidth,
),
true,
);
if (process.env.WORKSPACE_SCREENSHOT_DIR)
await page.screenshot({
path: process.env.WORKSPACE_SCREENSHOT_DIR + "/mobile-protocol.png",
fullPage: true,
});
assert.deepEqual(errors, []);
} finally {
await context.close();
}
}
@@ -0,0 +1,13 @@
import { test } from "node:test";
import { chromium } from "playwright-core";
import { verifyGuidanceWorkspaceUI } from "./guidance-workspace-ui";
const base = process.env.WORKSPACE_TEST_URL;
test("workspace filters, reading tabs, account states and editor work on desktop and mobile", {skip: !base}, async () => {
const browser = await chromium.launch({
executablePath: process.env.CHROMIUM_PATH || "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
headless: true,
args: ["--no-sandbox", "--disable-dev-shm-usage"],
});
try { await verifyGuidanceWorkspaceUI(base!, browser); }
finally { await browser.close(); }
});
@@ -0,0 +1,16 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {guides,findGuide,guidanceXml} from '../src/content/guides/catalog';
test('every conditional link resolves to its exact revision',()=>{for(const g of guides){assert.ok(g.body&&g.applies&&g.summary);for(const l of g.links){assert.ok(findGuide(l.target,l.revision),`${g.id} -> ${l.target}`);assert.ok(l.when);}}});
test('missing fixed revisions do not silently resolve latest',()=>{assert.equal(findGuide(guides[0].id,999),undefined);assert.equal(findGuide('missing'),undefined);});
test('XML escapes text and attribute markup without introducing entities',()=>{const g={...guides[0],title:'<script> & "quoted"',links:[{...guides[0].links[0],when:'a" b< c&'}]};const xml=guidanceXml(g);assert.ok(xml.includes('&lt;script&gt; &amp; &quot;quoted&quot;'));assert.ok(xml.includes('when="a&quot; b&lt; c&amp;"'));assert.ok(!xml.includes('<script>'));assert.ok(!xml.includes('<!DOCTYPE'));});
import {linkedGuidanceIndex} from '../src/lib/guidance-discovery';
test('agent entry discovers both corpora and fixes adoption scope',()=>{
const entry=linkedGuidanceIndex();
for(const path of ['/api/documents','/api/guides','/protocol'])assert.ok(entry.includes(path));
for(const guide of guides)assert.ok(entry.includes(`/api/guides/${guide.id}?revision=${guide.revision}&format=xml`));
assert.ok(entry.includes('(id, revision)'));
assert.ok(entry.includes('8 篇、深度 3 层'));
assert.ok(!entry.includes('/skills/index.md'));
});
+15
View File
@@ -0,0 +1,15 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { healthResponse } from "../src/lib/health";
test("health identifies the deployed release", () => {
assert.deepEqual(healthResponse("GET", "abc123"), {
statusCode: 200,
body: { status: "ok", service: "prompt", release: "abc123" },
});
});
test("HEAD is supported and mutations are rejected", () => {
assert.equal(healthResponse("HEAD").statusCode, 200);
assert.equal(healthResponse("POST").statusCode, 405);
});
@@ -0,0 +1,311 @@
import {verifyBrandUI} from './brand-ui-integration';
import {verifyCanvasWorkspace} from './canvas-integration';
import {verifyCanvasAgentUI} from './canvas-ui-integration';
import assert from 'node:assert/strict';
import {test} from 'node:test';
import {spawn} from 'node:child_process';
import {once} from 'node:events';
import {createServer} from 'node:net';
import {setTimeout} from 'node:timers/promises';
import {mkdtemp,readFile,writeFile,rm} from 'node:fs/promises';
import {tmpdir} from 'node:os';
import {join} from 'node:path';
import {randomUUID,createHash} from 'node:crypto';
import {putFile} from '../src/lib/storage';
import managedMedia from '../src/content/managed-media.json';
import sharp from 'sharp';
import {db} from '../src/lib/db';
import {probe,run} from '../src/lib/probe';
import seriesExample from '../fixtures/autumn-journal.json';
import {verifyGuidancePublishing} from './guidance-integration';
import {verifyEmailAccounts} from './account-integration';
import {verifyFlatNoteMigration} from './flat-note-migration';
import {staticEntries} from '../src/lib/explore';
import {publishLearningSeries} from '../src/lib/publish-learning-series';
// Browser acceptance gets its own deadline; it must not consume the existing business-flow budget.
test('Endoleap icons, persisted themes and artwork isolation in the real browser', {timeout:300000,skip:!process.env.PROMPT_TEST_BASE_URL}, async()=>{
assert.equal(process.env.PROMPT_INTEGRATION_TEST,'1');
const base=process.env.PROMPT_TEST_BASE_URL;assert.ok(base);
await verifyBrandUI(base);
});
test('real PostgreSQL + S3 notes, flat tags, resource sharing and video processing', {timeout:600000}, async()=>{
assert.equal(process.env.PROMPT_INTEGRATION_TEST,'1','Run only against an explicitly isolated test database');
const socket=createServer();socket.listen(0,'127.0.0.1');await once(socket,'listening');const address=socket.address();assert.ok(address&&typeof address!=='string');const port=address.port;await new Promise<void>(r=>socket.close(()=>r()));
const external=process.env.PROMPT_TEST_BASE_URL;
const base=external||`http://127.0.0.1:${port}`;
const server=external?null:spawn(process.execPath,['.next/standalone/apps/waterfall-wiki/server.js'],{env:{...process.env,PORT:String(port),HOSTNAME:'127.0.0.1'},stdio:'inherit'});
const worker=external?null:spawn(process.execPath,['dist/worker.cjs'],{env:process.env,stdio:'inherit'});const dir=await mkdtemp(join(tmpdir(),'prompt-test-'));
const request=async(path:string,body?:unknown)=>{const res=await fetch(base+path,body?{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)}:undefined);assert.ok(res.ok,`${path}: ${res.status} ${res.ok?'':await res.text()}`);return res.json();};
try{
let ready=false;for(let i=0;i<60;i++){try{if((await fetch(base+'/api/health')).ok){ready=true;break;}}catch{}await setTimeout(250);}assert.ok(ready);
for(const route of ['/','/images','/videos','/audio','/articles','/motion','/library/import','/tools/doodle','/tools/skills','/tools/skills/street-people','/tools/skills/shadow-puppet'])assert.equal((await fetch(base+route)).status,200,route);
if(!external)await verifyBrandUI(base);
await verifyEmailAccounts(base);
await verifyCanvasWorkspace(base);
if(process.env.VERIFY_CANVAS_UI==='1')await verifyCanvasAgentUI(base);
await verifyGuidancePublishing(base);
await verifyFlatNoteMigration();
const root=await request('/api/tags',{name:'线条',slug:'lines'});const child=await request('/api/tags',{name:'白色',slug:'white'});
const sameTag=await request('/api/tags',{name:'#线条'});assert.equal(sameTag.id,root.id,'Hashtag creation reuses the globally unique tag');
assert.equal((await db().query("SELECT count(*)::integer AS n FROM information_schema.columns WHERE table_schema=current_schema() AND table_name='tags' AND column_name='parent_id'")).rows[0].n,0);
const a=await sharp({create:{width:160,height:220,channels:3,background:'#b8c9af'}}).png().toBuffer();
const upload=async(kind:string,files:{data:Uint8Array;name:string;mime:string}[])=>{const form=new FormData();form.set('kind',kind);form.set('title','测试收藏 '+kind);form.set('description','可以复用的独立文字');form.set('tags',JSON.stringify([child.id]));files.forEach(f=>form.append('files',new Blob([new Uint8Array(f.data)],{type:f.mime}),f.name));const res=await fetch(base+'/api/upload',{method:'POST',body:form});assert.equal(res.status,201,await res.clone().text());return res.json();};
const first=await upload('image',[{data:a,name:'a.png',mime:'image/png'},{data:a,name:'b.png',mime:'image/png'}]);
const reused=await request('/api/posts',{kind:'image',title:'同一张图的另一种用法',description:'同一素材的独立说明',assets:[{id:first.assets[0]}],tags:[root.id]});
assert.equal((await db().query('SELECT count(*)::integer AS n FROM post_assets WHERE asset_id=$1',[first.assets[0]])).rows[0].n,2);
const filtered=await request('/api/feed?section=images&path=lines');assert.equal(filtered.posts.length,1);assert.equal(filtered.posts.find((p:{id:string})=>p.id===reused.id).description,'同一素材的独立说明');assert.equal((await request('/api/feed?section=images&path=white')).posts.length,1);
const empty=randomUUID();await db().query("INSERT INTO posts(id,slug,kind,title) VALUES($1,$2,'image','空对象')",[empty,empty]);assert.equal((await request('/api/feed')).posts.length,2);
assert.equal((await request('/api/feed')).contributions.length,0,'Import does not invent contribution rules');
await db().query("INSERT INTO contribution_events(id,kind,subject_type,amount) VALUES($1,'test.import','asset',2),($2,'test.edit','post',3)",[randomUUID(),randomUUID()]);assert.equal((await request('/api/feed')).contributions.reduce((n:number,c:{total:number})=>n+c.total,0),5);
const img=await fetch(base+'/api/media/'+first.assets[0],{headers:{Range:'bytes=0-9'}});assert.equal(img.status,206);assert.equal((await img.arrayBuffer()).byteLength,10);
const lightweight=await fetch(base+'/api/preview?source='+encodeURIComponent('/api/media/'+first.assets[0])+'&width=480');assert.equal(lightweight.status,200);assert.match(lightweight.headers.get('content-type')||'',/image\/webp/);assert.ok((await lightweight.arrayBuffer()).byteLength<=100000);
const highQuality=await fetch(base+'/api/preview?source='+encodeURIComponent('/api/media/'+first.assets[0])+'&width=2160&profile=export');assert.equal(highQuality.status,200);assert.match(highQuality.headers.get('content-type')||'',/image\/webp/);const exportMeta=await sharp(Buffer.from(await highQuality.arrayBuffer())).metadata();assert.equal(exportMeta.width,160);assert.equal(exportMeta.height,220);
assert.equal((await fetch(base+'/api/preview?source='+encodeURIComponent('/api/media/'+first.assets[0])+'&width=2160')).status,400);
const textEntry=await fetch(base+'/explore/'+reused.id+'?agent_mode=True');assert.equal(textEntry.status,200);assert.match(textEntry.headers.get('content-type')||'',/text\/plain/);assert.ok((await textEntry.text()).includes(first.assets[0]));
assert.equal((await fetch(base+'/images/lines/missing')).status,404);assert.equal((await fetch(base+'/p/'+reused.slug)).status,200);
for(const sound of [true,false]){
const path=join(dir,sound?'sound.mp4':'silent.mp4');const args=['-y','-v','error','-f','lavfi','-i','color=c=blue:s=160x120:r=10'];if(sound)args.push('-f','lavfi','-i','sine=frequency=440:sample_rate=44100');args.push('-t','0.5','-c:v','libx264','-pix_fmt','yuv420p');if(sound)args.push('-c:a','aac');args.push(path);await run('ffmpeg',args);
const post=await upload('video',[{data:await readFile(path),name:'video.mp4',mime:'video/mp4'}]);let asset;
for(let i=0;i<120;i++){asset=(await db().query('SELECT * FROM assets WHERE id=$1',[post.assets[0]])).rows[0];if(asset.processing_status==='ready'||asset.processing_status==='failed')break;await setTimeout(500);}
assert.equal(asset.processing_status,'ready',asset.processing_error);assert.equal(asset.has_audio,sound);
const variants:string[]=(await db().query('SELECT derivation AS role FROM assets WHERE source_asset_id=$1',[asset.id])).rows.map(r=>r.role);assert.ok(variants.includes('silent_video'));assert.ok(variants.includes('poster'));assert.equal(variants.includes('audio_track'),sound);
const linked=(await request('/api/feed?section=videos')).posts.find((p:{id:string})=>p.id===post.id);assert.ok(linked.assets.some((a:{role:string})=>a.role==='poster'));
const videoNote=await fetch(base+'/explore/'+post.id+'?agent_mode=True').then(r=>r.text());assert.ok(videoNote.includes('静音视频'));
const reusedVideo=await request('/api/posts',{kind:'video',title:'复用已加工的视频',assets:[{id:asset.id}]});assert.ok((await db().query("SELECT 1 FROM post_assets WHERE post_id=$1 AND role='silent_video'",[reusedVideo.id])).rowCount);
const clip:Response=await fetch(base+'/api/media/'+asset.id+'?variant=silent_video');assert.equal(clip.status,200);const derived=join(dir,'derived.mp4');await writeFile(derived,Buffer.from(await clip.arrayBuffer()));assert.equal((await probe(derived)).streams.some(s=>s.codec_type==='audio'),false);
}
assert.equal((await db().query("SELECT count(*)::integer AS n FROM assets WHERE kind='audio' AND derivation='audio_track'")).rows[0].n,1,'Video soundtracks are ordinary reusable assets');
const audio=join(dir,'audio.wav');await run('ffmpeg',['-y','-v','error','-f','lavfi','-i','sine=frequency=220','-t','0.2',audio]);await upload('audio',[{data:await readFile(audio),name:'audio.wav',mime:'audio/wav'}]);assert.equal((await request('/api/feed?section=audio')).posts.length,1);
for(const removed of ['/library/assets','/library/tags'])assert.equal((await fetch(base+removed)).status,404);
assert.equal((await db().query("SELECT 1 FROM schema_migrations WHERE version='009_studio_designs'")).rowCount,1,'Studio migration must be recorded for repeat deployments');
// Studio owns no second database: designs use this same isolated PostgreSQL.
for(let i=1;i<=27;i++){const layoutId='ref-'+String(i).padStart(2,'0'),saved=await request('/api/studio/designs',{layoutId,name:'布局 '+i,parameters:{}});assert.equal(saved.design.layoutId,layoutId);assert.equal(saved.design.revision,1);assert.ok(saved.design.postId);assert.equal(saved.design.postUrl,'/explore/'+saved.design.postId);assert.equal(saved.design.url,'/studio/template-layout?id='+saved.design.id);const wiki=await request('/api/notes/'+saved.design.postId);assert.ok(wiki.entry.editableMarkdown.includes(saved.design.url));const read=await request('/api/studio/designs/'+saved.design.id);assert.deepEqual(read.design.parameters,{texts:{},images:{},decorations:{}});}
// Exercise actual HTTP + PostgreSQL transactions, not a mocked persistence layer.
const seriesBody=structuredClone(seriesExample);
const group=await request('/api/studio/series',seriesBody);assert.equal(group.design.schemaVersion,2);assert.equal(group.design.document.pages.length,3);assert.ok(group.design.postId);
const seriesId=group.design.id;
const exportPlan=await request('/api/studio/series/'+seriesId+'/export');assert.equal(exportPlan.pages.length,3);assert.equal(exportPlan.pages[0].height,1440);
const patchSeries={...seriesBody,revision:1,name:'Updated series'};
const edits=await Promise.all([1,2].map(()=>fetch(base+'/api/studio/series/'+seriesId,{method:'PATCH',headers:{'Content-Type':'application/json'},body:JSON.stringify(patchSeries)})));assert.deepEqual(edits.map(r=>r.status).sort(),[200,409]);
const badSeries=structuredClone(seriesBody);const absentAsset=randomUUID();Object.assign(badSeries.document.pages[1].parameters.images.I01,{assetId:absentAsset});(badSeries.document.sources as unknown[]).push({assetId:absentAsset,author:'CI',license:'test',url:'generated',prompt:'fixture',tool:'test'});
const countBefore=(await db().query('SELECT count(*) FROM studio_designs')).rows[0].count;
assert.equal((await fetch(base+'/api/studio/series',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(badSeries)})).status,400);assert.equal((await db().query('SELECT count(*) FROM studio_designs')).rows[0].count,countBefore);
assert.equal((await fetch(base+'/api/studio/designs/'+seriesId,{method:'PATCH',headers:{'Content-Type':'application/json'},body:JSON.stringify({name:'wrong API',revision:2,parameters:{}})})).status,409);
assert.equal((await fetch(base+'/api/studio/series/'+seriesId,{method:'DELETE',headers:{'Content-Type':'application/json'},body:'{"revision":2}'})).status,200);
assert.equal((await fetch(base+'/api/studio/series/'+seriesId+'/export')).status,404);
assert.equal((await request('/api/studio/series/'+seriesId,{revision:3,archived:false})).design.revision,4);
const design=await request('/api/studio/designs',{layoutId:'ref-01',name:'可修改效果',parameters:{texts:{T01:{value:'新的标题'}},images:{I01:{assetId:first.assets[0],fit:'contain'}}}});
const designEdit={name:'更新效果',parameters:{texts:{T01:{value:'修改后'}}},revision:design.design.revision};
const patches=await Promise.all([1,2].map(()=>fetch(base+'/api/studio/designs/'+design.design.id,{method:'PATCH',headers:{'Content-Type':'application/json'},body:JSON.stringify(designEdit)})));assert.deepEqual(patches.map(r=>r.status).sort(),[200,409]);
const designRead=await request('/api/studio/designs/'+design.design.id);assert.equal(designRead.design.revision,2);assert.equal(designRead.design.parameters.texts.T01.value,'修改后');
assert.equal(designRead.design.postId,design.design.postId,'Updating a page reuses its Wiki post');
const linkedWiki=await request('/api/notes/'+design.design.postId);assert.equal(linkedWiki.entry.title,'更新效果');
assert.equal(linkedWiki.entry.coverAssetId,first.assets[0],'An uploaded page image supplies the initial cover');
const repeated=await Promise.all([1,2].map(()=>request('/api/studio/designs/'+design.design.id+'/post',{})));
assert.ok(repeated.every(r=>r.created===false&&r.design.postId===design.design.postId&&r.design.revision===2));
const manualBody=linkedWiki.entry.editableMarkdown+'\n\n我的补充说明';
assert.equal((await fetch(base+'/api/notes/'+design.design.postId,{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify({title:'我的页面笔记',description:'独立的笔记说明',markdown:manualBody,tags:[],revision:linkedWiki.entry.revision})})).status,200);
const nextDesign=await fetch(base+'/api/studio/designs/'+design.design.id,{method:'PATCH',headers:{'Content-Type':'application/json'},body:JSON.stringify({...designEdit,name:'再改页面名称',revision:2})});assert.equal(nextDesign.status,200);
const retainedWiki=await request('/api/notes/'+design.design.postId);assert.equal(retainedWiki.entry.title,'我的页面笔记');assert.equal(retainedWiki.entry.editableMarkdown,manualBody);
const legacyId=randomUUID();await db().query("INSERT INTO studio_designs(id,layout_id,name,parameters) VALUES($1,'ref-01','旧保存页面',$2)",[legacyId,JSON.stringify({texts:{},images:{},decorations:{}})]);
const repaired=await Promise.all([1,2].map(()=>fetch(base+'/api/studio/designs/'+legacyId+'/post',{method:'POST',headers:{'Content-Type':'application/json'},body:'{}'})));
assert.deepEqual(repaired.map(r=>r.status).sort(),[200,201]);const repairs=await Promise.all(repaired.map(r=>r.json()));assert.equal(repairs[0].design.postId,repairs[1].design.postId);assert.equal(repairs[0].design.revision,1);
assert.equal((await db().query("SELECT count(*)::integer AS n FROM posts WHERE slug=$1",['studio-design-'+legacyId])).rows[0].n,1);
assert.equal((await fetch(base+'/api/studio/designs/'+randomUUID()+'/post',{method:'POST',headers:{'Content-Type':'application/json'},body:'{}'})).status,404);
// Both families share content validation and revision-guarded, recoverable CRUD.
for(const layoutId of ['standard-01','ref-07']){
const created=(await request('/api/studio/designs',{schemaVersion:1,layoutId,name:'CRUD '+layoutId,parameters:{texts:{T01:{value:'API 标题'}},images:{I01:{assetId:first.assets[0],fit:'cover',cropX:25,zoom:1.5}},added:[{id:'user-caption',kind:'text',role:'补充',box:[1,2,30,10],direction:'horizontal',text:'自定义',reading:'custom'}]}})).design;
assert.equal(created.family,layoutId==='standard-01'?'standard':'fixed');assert.equal(created.schemaVersion,1);
const call=(method:string,body:unknown)=>fetch(base+'/api/studio/designs/'+created.id,{method,headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
assert.equal((await call('PATCH',{name:'新标题',parameters:created.parameters,revision:1})).status,200);
assert.equal((await call('DELETE',{revision:1})).status,409);
const deleted=await call('DELETE',{revision:2});assert.equal(deleted.status,200);assert.equal((await deleted.json()).design.archived,true);
const read=(await request('/api/studio/designs/'+created.id)).design;assert.equal(read.revision,3);assert.deepEqual(read.parameters,created.parameters);
assert.equal((await request('/api/notes/'+created.postId)).entry.archived,true);
assert.ok(!(await request('/api/studio/designs?layoutId='+layoutId)).designs.some((d:{id:string})=>d.id===created.id));
assert.ok((await request('/api/studio/designs?archived=true&layoutId='+layoutId)).designs.some((d:{id:string})=>d.id===created.id));
assert.equal((await call('PATCH',{name:'禁止修改归档',parameters:{},revision:3})).status,409);
assert.equal((await call('POST',{revision:3,archived:false})).status,200);
assert.equal((await request('/api/notes/'+created.postId)).entry.archived,false);
assert.equal((await request('/api/studio/designs/'+created.id)).design.revision,4);
}
assert.equal((await fetch(base+'/api/studio/designs',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({schemaVersion:2,layoutId:'standard-01',name:'未来版本',parameters:{}})})).status,400);
// A post failure must roll back the design and text assets as one unit.
const beforeFailure=(await db().query('SELECT (SELECT count(*) FROM studio_designs)::integer AS designs,(SELECT count(*) FROM posts)::integer AS posts,(SELECT count(*) FROM assets)::integer AS assets')).rows[0];
await db().query(`CREATE FUNCTION reject_studio_post_test() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF NEW.title='atomic-studio-post-test' THEN RAISE EXCEPTION 'test publication failure'; END IF; RETURN NEW; END $$; CREATE TRIGGER reject_studio_post_test BEFORE INSERT ON posts FOR EACH ROW EXECUTE FUNCTION reject_studio_post_test()`);
try{assert.equal((await fetch(base+'/api/studio/designs',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({layoutId:'ref-01',name:'atomic-studio-post-test',parameters:{}})})).status,500)}finally{await db().query('DROP TRIGGER reject_studio_post_test ON posts; DROP FUNCTION reject_studio_post_test()')}
assert.deepEqual((await db().query('SELECT (SELECT count(*) FROM studio_designs)::integer AS designs,(SELECT count(*) FROM posts)::integer AS posts,(SELECT count(*) FROM assets)::integer AS assets')).rows[0],beforeFailure);
// The migration adopts an existing manual post instead of publishing a duplicate.
const adoptedId=randomUUID(),adoptedUrl='/studio/layouts/ref-01?id='+adoptedId,adoptedPost=await request('/api/posts',{kind:'article',title:'已补录的保存页',slug:'studio-design-'+adoptedId,text:'[打开页面]('+adoptedUrl+')'});
await db().query("INSERT INTO studio_designs(id,layout_id,name,parameters) VALUES($1,'ref-01','已补录的保存页',$2)",[adoptedId,JSON.stringify({texts:{},images:{},decorations:{}})]);
await db().query(await readFile(new URL('../db/010_studio_design_posts.sql',import.meta.url),'utf8'));
const adopted=await request('/api/studio/designs/'+adoptedId);assert.equal(adopted.design.postId,adoptedPost.id);assert.equal((await request('/api/studio/designs/'+adoptedId+'/post',{})).created,false);
const designPage=await request('/api/studio/designs?layoutId=ref-01&limit=1');assert.equal(designPage.designs.length,1);assert.ok(designPage.nextCursor);assert.equal((await request('/api/studio/designs?layoutId=ref-01&limit=1&before='+designPage.nextCursor)).designs.length,1);
for(const body of [{layoutId:'ref-28',name:'bad',parameters:{}},{layoutId:'ref-01',name:'bad',parameters:{texts:{I01:{value:'wrong kind'}}}},{layoutId:'ref-01',name:'bad',parameters:{images:{I01:{assetId:randomUUID()}}}}])assert.equal((await fetch(base+'/api/studio/designs',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)})).status,400);
assert.equal((await fetch(base+'/api/studio/designs/'+randomUUID())).status,404);
assert.ok((await fetch(base+'/api/studio/designs?agent_mode=True').then(r=>r.text())).includes('revision'));
// Isolated S3 uses synthetic fixtures, never credentials or production objects.
const testCoverFile=join(dir,'learning-cover.png');
const testCoverBytes=await sharp({create:{width:1080,height:1440,channels:3,background:'#e8eadc'}}).png().toBuffer();
await writeFile(testCoverFile,testCoverBytes);
const testCover={...managedMedia['apps/waterfall-wiki/public/learning-skills/cover.png'],id:randomUUID(),key:'integration/learning-cover.png',sha256:createHash('sha256').update(testCoverBytes).digest('hex'),bytes:testCoverBytes.length};
await putFile(testCover.key,testCoverFile,'image/png');
const publishTestSeries=()=>publishLearningSeries(testCover);
const published=await publishTestSeries();await publishTestSeries();
const articles=(await request('/api/feed?section=articles&path=learning-skills')).posts;
assert.equal(articles.length,3,'Repeated publication must not duplicate posts');
assert.equal(new Set(articles.map((p:{cover_asset_id:string})=>p.cover_asset_id)).size,1,'Three posts share one independent image asset');
assert.equal((await db().query('SELECT count(*)::integer AS n FROM post_assets WHERE asset_id=$1',[published.coverId])).rows[0].n,3);
for(const slug of published.published){const response=await fetch(base+'/p/'+slug);assert.equal(response.status,200);assert.ok((await response.text()).includes('内容 ID'))}
assert.equal((await request('/api/feed?section=articles&path=learning-skills')).posts.length,3);
const coverResponse=await fetch(base+'/api/media/'+published.coverId);assert.equal(coverResponse.headers.get('content-type'),'image/png');
const coverInfo=await sharp(Buffer.from(await coverResponse.arrayBuffer())).metadata();assert.equal(coverInfo.width,1080);assert.equal(coverInfo.height,1440);
assert.equal((await request('/api/feed')).contributions.reduce((n:number,c:{total:number})=>n+c.total,0),5,'Editorial publication does not invent contribution events');
for(const slug of published.published){
assert.equal((await fetch(base+`/api/posts/${slug}/snapshots`)).status,404);
assert.equal((await fetch(base+`/p/${slug}/pages/1`)).status,404);
}
const article=await request('/api/posts',{kind:'article',title:'无代码页面的笔记',text:'普通正文与 [引用](https://example.com)'});
const articlePage=await fetch(base+'/explore/'+article.id).then(r=>r.text());assert.ok(articlePage.includes('note-inline-link'));
const remaining=(await db().query("SELECT to_regclass('asset_variants') AS variants,to_regclass('page_snapshots') AS snapshots,to_regclass('render_jobs') AS renders")).rows[0];assert.deepEqual(remaining,{variants:null,snapshots:null,renders:null});
// Both file-backed Wiki pages and media notes can be edited on their stable IDs.
const note=staticEntries.find(e=>e.namespace==='Main'&&!e.media.length)!;
const editable=await request('/api/notes/'+note.id);
const newTag=await request('/api/tags',{name:'#在线编辑'});
const updatedBody='## 新内容\n\n- 项目一\n - 子项目\n- [相关笔记](/explore/'+reused.id+')';
const edit=async(id:string,payload:unknown)=>fetch(base+'/api/notes/'+id,{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)});
const payload={title:'已编辑的 Wiki 笔记',description:'修改后的预览描述',markdown:updatedBody,tags:[newTag.id],revision:editable.entry.revision};
const saved=await edit(note.id,payload);assert.equal(saved.status,200,await saved.clone().text());
assert.equal((await request('/api/notes/'+note.id)).entry.title,payload.title);
assert.equal((await edit(note.id,{...payload,title:'旧窗口写入'})).status,409,'Stale editor must not overwrite a newer save');
const updatedText=await fetch(base+'/explore/'+note.id+'?agent_mode=True').then(r=>r.text());assert.ok(updatedText.includes(updatedBody));assert.ok(updatedText.includes('在线编辑'));
const updatedHtml=await fetch(base+'/explore/'+note.id).then(r=>r.text());assert.ok(updatedHtml.includes('<ul>'));assert.ok(updatedHtml.includes('<li>'));assert.ok(updatedHtml.includes('复制网页链接'));assert.ok(updatedHtml.includes('复制纯文本链接'));assert.ok(!updatedHtml.includes('原文件 ↗'));
const editedPreview=(await request('/api/explore-feed?category='+encodeURIComponent('在线编辑'))).entries.find((e:{id:string})=>e.id===note.id);assert.equal(editedPreview.description,payload.description);
assert.ok((await fetch(base+'/api/wiki-index').then(r=>r.text())).includes(payload.title));
const storedPost=(await request('/api/notes/'+first.id)).entry;
const oldCaption=(await db().query("SELECT a.id FROM post_assets pa JOIN assets a ON a.id=pa.asset_id WHERE pa.post_id=$1 AND a.kind='text'",[first.id])).rows[0].id;
const captionReuse=await request('/api/posts',{kind:'article',title:'共享文本的笔记',assets:[{id:oldCaption,role:'caption'}]});
const savedPost=await edit(first.id,{title:'修改后的图片笔记',description:'图片仍保留',markdown:'新的正文',tags:[newTag.id],revision:storedPost.revision});assert.equal(savedPost.status,200,await savedPost.clone().text());
const updatedPost=await request('/api/notes/'+first.id);assert.equal(updatedPost.entry.imageCount,2);assert.equal(updatedPost.entry.editableMarkdown,'新的正文');
assert.equal((await request('/api/notes/'+captionReuse.id)).entry.editableMarkdown,'可以复用的独立文字','Editing one note must not mutate shared text');
const authored=(await request('/api/feed?section=articles')).posts.find((p:{slug:string})=>p.slug===published.published[0]);const authoredState=await request('/api/notes/'+authored.id);
assert.equal((await edit(authored.id,{title:'保留用户修改',description:'部署不能覆盖',markdown:'手动修改的正文',tags:[newTag.id],revision:authoredState.entry.revision})).status,200);
await publishTestSeries();const retained=await request('/api/notes/'+authored.id);assert.equal(retained.entry.title,'保留用户修改');assert.equal(retained.entry.editableMarkdown,'手动修改的正文');
assert.equal((await request('/api/notes/'+note.id)).entry.title,payload.title,'Static edits outlive content publication');
assert.equal((await fetch(base+'/api/notes/missing')).status,404);
assert.equal((await fetch(base+'/api/notes/'+note.id,{method:'DELETE'})).status,405);
// API-only publication: upload independent assets, retain a stable public ID, then update cover.
const postCountBefore=(await db().query('SELECT count(*)::integer AS n FROM posts')).rows[0].n;
const assetsForm=new FormData();assetsForm.set('kind','image');assetsForm.set('assetOnly','true');
assetsForm.append('files',new Blob([new Uint8Array(a)],{type:'image/png'}),'standalone-a.png');
const secondImage=await sharp({create:{width:160,height:220,channels:3,background:'#d7c7b5'}}).png().toBuffer();
assetsForm.append('files',new Blob([new Uint8Array(secondImage)],{type:'image/png'}),'standalone-b.png');
const uploadedOnly=await fetch(base+'/api/upload',{method:'POST',body:assetsForm});assert.equal(uploadedOnly.status,201,await uploadedOnly.clone().text());
const independent=await uploadedOnly.json();assert.equal(independent.assets.length,2);assert.deepEqual(independent.files.map((file:{filename:string})=>file.filename).sort(),['standalone-a.png','standalone-b.png']);assert.deepEqual(independent.files.map((file:{id:string})=>file.id),independent.assets,'file metadata matches the returned asset IDs');
assert.equal((await db().query('SELECT count(*)::integer AS n FROM posts')).rows[0].n,postCountBefore,'assetOnly never creates a post');
const publicId=note.id;
const apiBody='## 纯文本资料\n\n```json\n{"type":"text","box":[1,2,3,4]}\n```\n\n```tsx\nexport default function Demo(){return <div/>}\n```';
const publishedNote=await request('/api/posts',{kind:'article',title:'API 发布资料',slug:'api-public-'+randomUUID(),publicId,text:apiBody,assets:independent.assets.map((id:string)=>({id})),coverAssetId:independent.assets[0],tags:[child.id]});
assert.equal(publishedNote.publicId,publicId);assert.ok(!(await fetch(base+'/api/wiki-index').then(r=>r.text())).includes('['+payload.title+']'),'Migrated code entries are not listed under stale titles');assert.equal(publishedNote.url,'/explore/'+publicId);
const publicEntry=(await request('/api/notes/'+publicId)).entry;assert.equal(publicEntry.id,publicId);assert.equal(publicEntry.postSlug,publishedNote.slug);assert.equal(publicEntry.coverAssetId,independent.assets[0]);
assert.equal((await request('/api/explore-feed')).entries.filter((e:{id:string})=>e.id===publicId).length,1,'API note replaces rather than duplicates its static predecessor');
const publicText=await fetch(base+'/explore/'+publicId+'?agent_mode=True').then(r=>r.text());assert.ok(publicText.includes(apiBody));assert.ok(publicText.includes(independent.assets[0]));
const coverPayload={title:publicEntry.title,description:publicEntry.description,markdown:publicEntry.editableMarkdown,tags:[child.id],revision:publicEntry.revision,coverAssetId:independent.assets[1]};
const coverSaved=await edit(publicId,coverPayload);assert.equal(coverSaved.status,200,await coverSaved.clone().text());
const afterCover=(await request('/api/notes/'+publicId)).entry;assert.equal(afterCover.coverAssetId,independent.assets[1]);assert.equal(afterCover.media[0].url,'/api/media/'+independent.assets[1]);assert.notEqual(afterCover.revision,publicEntry.revision);
assert.equal((await edit(publicId,coverPayload)).status,409,'Stale cover edits cannot overwrite current media');
const replaceMedia=await edit(publicId,{...coverPayload,revision:afterCover.revision,assets:[{id:independent.assets[1],role:'content'},{id:independent.assets[0],role:'caption'}]});assert.equal(replaceMedia.status,200,await replaceMedia.clone().text());
const finalPublic=(await request('/api/notes/'+publicId)).entry;assert.equal(finalPublic.imageCount,1);assert.equal(finalPublic.assets.length,2);assert.equal(finalPublic.editableMarkdown,apiBody);
const duplicatePublic=await fetch(base+'/api/posts',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({kind:'article',title:'不覆盖已有 ID',publicId,text:'x'})});assert.equal(duplicatePublic.status,409);
assert.equal((await fetch(base+'/p/'+publishedNote.slug,{redirect:'manual'})).headers.get('location'),'/explore/'+publicId);
for(const removed of ['/layout-reference/index.html','/layout-reference/ref-01/index.html','/layout-reference/index.json'])assert.equal((await fetch(base+removed)).status,404,removed);
for(const doc of ['SKILL','style','upload','checks']){const r=await fetch(base+`/skills/street-people/${doc}.md`);assert.equal(r.status,200);assert.ok(r.headers.get('content-type')?.includes('text/markdown'));const text=await r.text();assert.ok(text.startsWith('# '));assert.ok(!text.includes('<html'));}
for(const doc of ['SKILL','workflow','visual-grammar','prompt','delivery']){
const path=`/skills/shadow-puppet/${doc}.md`;const response=await fetch(base+path);assert.equal(response.status,200,path);assert.ok(response.headers.get('content-type')?.includes('text/markdown'));assert.ok((await response.text()).startsWith('# '));
const head=await fetch(base+path,{method:'HEAD'});assert.equal(head.status,200);assert.equal(await head.text(),'');
if(doc!=='SKILL')assert.equal((await fetch(base+'/tools/skills/shadow-puppet/'+doc)).status,200);
}
for(const image of ['egret-example','dancer-example']){
const asset=managedMedia[`apps/waterfall-wiki/public/skills/shadow-puppet/${image}.png` as keyof typeof managedMedia];
const key='integration/legacy-'+asset.id+'.png';await putFile(key,testCoverFile,'image/png');
await db().query("INSERT INTO assets(id,kind,filename,storage_key,mime_type,byte_size,sha256) VALUES($1,'image',$2,$3,'image/png',$4,$5)",[asset.id,image+'.png',key,testCoverBytes.length,testCover.sha256]);
}
for(const image of ['egret-example','dancer-example'])assert.equal((await fetch(base+`/skills/shadow-puppet/${image}.png`,{method:'HEAD'})).status,200);
for(const path of ['/skills/missing/SKILL.md','/skills/shadow-puppet/missing.md','/tools/skills/shadow-puppet/missing'])assert.equal((await fetch(base+path)).status,404,path);
assert.equal((await fetch(base+'/api/skill-docs/shadow-puppet/SKILL',{method:'POST'})).status,405);
for(const path of ['/agent','/agent/index.md','/index.md']){
const entry=await fetch(base+path);assert.equal(entry.status,200,path);assert.match(entry.headers.get('content-type')||'',/text\/markdown/);const text=await entry.text();assert.ok(text.includes('/api/documents'));assert.ok(text.includes('/api/guides'));assert.ok(!text.includes('/skills/index.md'));
}
const machineIndex=await fetch(base+'/api/guides');assert.equal(machineIndex.status,200);assert.equal((await machineIndex.json()).protocol,'linked-guidance/0.1');
const home=await fetch(base+'/').then(r=>r.text());
for(const href of ['/documents','/graphic','/infinite_canvas','/account?next=/workspace'])assert.ok(home.includes('href="'+href+'"'),`Landing page links to ${href}`);
assert.ok(home.includes('让灵感,有形。'));
for(const [oldPath,newPath] of [['/studio/graphic','/graphic'],['/studio/infinite_canvas','/infinite_canvas']]){
const response=await fetch(base+oldPath,{redirect:'manual'});assert.equal(response.status,308);assert.equal(response.headers.get('location'),newPath);
}
// Legacy media and skill functionality stays covered by its own routes above and below.
const spec=await request('/api/skills/street-people');assert.equal(spec.generation.executor,'coding-agent');
assert.equal((await fetch(base+'/api/skills/street-people?format=md')).headers.get('content-type'),'text/markdown; charset=utf-8');
const skillUpload=async(bytes:Uint8Array,slug:string)=>{const f=new FormData();f.set('slug',slug);f.append('files',new Blob([new Uint8Array(bytes)],{type:'image/png'}),'person.png');return fetch(base+'/api/skills/street-people',{method:'POST',body:f});};
const skillSlug='skill-'+randomUUID();
assert.equal((await skillUpload(a,skillSlug)).status,400,'Opaque PNG rejected');
const opaqueAlpha=await sharp({create:{width:64,height:64,channels:4,background:'#fff'}}).png().toBuffer();
assert.equal((await skillUpload(opaqueAlpha,skillSlug)).status,400,'Unused alpha channel rejected');
const transparent=await sharp({create:{width:64,height:64,channels:4,background:'#0000'}}).composite([{input:await sharp({create:{width:32,height:32,channels:4,background:'#faf4e5'}}).png().toBuffer(),left:16,top:16}]).png().toBuffer();
const created=await skillUpload(transparent,skillSlug);assert.equal(created.status,201,await created.clone().text());const character=await created.json();assert.equal(character.slug,skillSlug);assert.equal(character.url,'/p/'+skillSlug);
assert.equal((await skillUpload(transparent,skillSlug)).status,409,'Stable slug prevents duplicate posts');
const stored=await fetch(base+'/api/media/'+character.assets[0]);assert.deepEqual(Buffer.from(await stored.arrayBuffer()),transparent,'PNG alpha and original bytes preserved in S3');
assert.equal((await fetch(base+character.url)).status,200);
// A real stored body-only match and backlinks must update through the public API.
const targetNote=await request('/api/posts',{kind:'article',title:'引用目标',text:'仅在正文中的索引验证 OrchidLexicon',tags:[root.id]});
const referring=await request('/api/posts',{kind:'article',title:'引用来源',text:`[目标](${targetNote.url}) [重复](${targetNote.url}?agent_mode=True)`,tags:[]});
const targetEntry=(await request('/api/notes/'+targetNote.id)).entry;
assert.equal(targetEntry.backlinkCount,1);assert.equal(targetEntry.backlinks[0].id,referring.id);
const bodySearch=await request('/api/explore-feed?q=orchidlexicon');assert.equal(bodySearch.total,1);assert.equal(bodySearch.entries[0].id,targetNote.id);
const mixedSearch=await request('/api/explore-feed?q='+encodeURIComponent('OrchidLexicon 线条'));assert.equal(mixedSearch.total,1);
const removeReference=await request('/api/notes/'+referring.id);
const editResponse=await fetch(base+'/api/notes/'+referring.id,{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify({title:'引用来源',description:'更新后撤回引用',markdown:'引用已经移除',tags:[],revision:removeReference.entry.revision})});assert.equal(editResponse.status,200);
assert.equal((await request('/api/notes/'+targetNote.id)).entry.backlinkCount,0);
// System labels have a closed catalogue; ordinary # labels remain user-created.
const allLabels=await request('/api/tags'),systems=allLabels.filter((t:{kind:string})=>t.kind==='system');assert.equal(systems.length,2);
const entryLabel=systems.find((t:{system_key:string})=>t.system_key==='document-entry');assert.ok(entryLabel);
for(const body of [{name:'非法系统项',kind:'system'},{name:'#文档入口'}]){
const response=await fetch(base+'/api/tags',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});assert.equal(response.status,400);
}
const plain=await request('/api/tags',{name:'#可随时添加'});assert.equal(plain.kind,'normal');
const beforeTags=(await request('/api/notes/'+targetNote.id)).entry;
const patchTags=(payload:unknown)=>fetch(base+'/api/notes/'+targetNote.id,{method:'PATCH',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)});
const textLinksBefore=(await db().query("SELECT a.id FROM post_assets pa JOIN assets a ON a.id=pa.asset_id WHERE pa.post_id=$1 AND a.kind='text' ORDER BY a.id",[targetNote.id])).rows;
const assigned=await patchTags({tags:[plain.id,entryLabel.id],revision:beforeTags.revision});assert.equal(assigned.status,200,await assigned.clone().text());
const afterTags=(await request('/api/notes/'+targetNote.id)).entry;assert.deepEqual(afterTags.categories,[plain.name]);assert.equal(afterTags.systemTags[0].system_key,'document-entry');assert.equal(afterTags.editableMarkdown,beforeTags.editableMarkdown);
assert.deepEqual((await db().query("SELECT a.id FROM post_assets pa JOIN assets a ON a.id=pa.asset_id WHERE pa.post_id=$1 AND a.kind='text' ORDER BY a.id",[targetNote.id])).rows,textLinksBefore,'Tag-only PATCH preserves the existing text assets');
const systemFeed=await request('/api/explore-feed?category='+encodeURIComponent(entryLabel.name));assert.ok(systemFeed.entries.some((e:{id:string})=>e.id===targetNote.id));
assert.equal((await patchTags({tags:[],revision:beforeTags.revision})).status,409);
assert.equal((await patchTags({tags:[],revision:afterTags.revision})).status,200);
const cleared=(await request('/api/notes/'+targetNote.id)).entry;assert.equal(cleared.categories.length,0);assert.equal(cleared.systemTags.length,0);
const categoriesHtml=await fetch(base+'/categories').then(r=>r.text());const tabs=categoriesHtml.match(/aria-label="系统标签分类"[\s\S]*?<\/nav>/)?.[0]||'';assert.ok(tabs.includes('文档入口'));assert.ok(tabs.includes('参考图'));assert.ok(!tabs.includes(plain.name));
// Archive is a reversible visibility change, including code-backed public IDs.
const archiveTarget=(await request('/api/notes/'+publishedNote.publicId)).entry;
const archive=(value:boolean)=>fetch(base+'/api/posts/'+publishedNote.publicId+'/archive',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({archived:value})});
const archivedResponse=await archive(true);assert.equal(archivedResponse.status,200);const archived=(await archivedResponse.json()).post;assert.equal(archived.archived,true);assert.ok(archived.archivedAt);
assert.equal((await (await archive(true)).json()).post.archivedAt,archived.archivedAt,'Repeated archive preserves the archive timestamp');
const archivedEntry=(await request('/api/notes/'+publishedNote.publicId)).entry;assert.equal(archivedEntry.archived,true);assert.equal(archivedEntry.editableMarkdown,archiveTarget.editableMarkdown);assert.deepEqual(archivedEntry.assets,archiveTarget.assets);assert.notEqual(archivedEntry.revision,archiveTarget.revision);
assert.ok(!(await request('/api/explore-feed?q='+encodeURIComponent(archiveTarget.title))).entries.some((e:{id:string})=>e.id===publishedNote.publicId),'Archived database note must not reappear from its static predecessor');
assert.ok(!(await request('/api/feed?q='+encodeURIComponent(archiveTarget.title))).posts.some((p:{id:string})=>p.id===publishedNote.id));
assert.ok((await request('/api/feed?archived=true&q='+encodeURIComponent(archiveTarget.title))).posts.some((p:{id:string})=>p.id===publishedNote.id));
assert.ok(!(await fetch(base+'/api/wiki-index').then(r=>r.text())).includes('['+archiveTarget.title+']'));
assert.equal((await fetch(base+'/explore/'+publishedNote.publicId)).status,200,'Direct archived links remain readable for recovery');
const restored=await archive(false);assert.equal(restored.status,200);assert.equal((await restored.json()).post.archived,false);
assert.ok((await request('/api/explore-feed?q='+encodeURIComponent(archiveTarget.title))).entries.some((e:{id:string})=>e.id===publishedNote.publicId));
assert.equal((await fetch(base+'/api/feed?archived=invalid')).status,400);
assert.equal((await fetch(base+'/api/posts/'+randomUUID()+'/archive',{method:'POST',headers:{'Content-Type':'application/json'},body:'{"archived":true}'})).status,404);
}finally{
for(const child of [worker,server])if(child&&child.exitCode===null&&child.signalCode===null){const stopped=once(child,'exit');child.kill('SIGTERM');await stopped;}
await rm(dir,{recursive:true,force:true});await db().end();
}
});
@@ -0,0 +1,16 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { chapters, seriesCover } from "../src/content/learning-skills/data";
test("the authored series has three distinct chapters of ten sourced skills and one cover",()=>{
assert.equal(chapters.length,3);assert.equal(new Set(chapters.map(c=>c.key)).size,3);
assert.equal(new Set(chapters.map(c=>c.slug)).size,3);assert.equal(seriesCover,"/learning-skills/cover.png");
for(const chapter of chapters){
assert.equal(chapter.skills.length,10);assert.equal(new Set(chapter.skills.map(s=>s.source)).size,10);
for(const skill of chapter.skills){
const source=new URL(skill.source);assert.equal(source.protocol,"https:");assert.equal(source.hostname,"github.com");
assert.ok(skill.title&&skill.input&&skill.output&&skill.prompt&&skill.note);
assert.equal(skill.sample.length,3);
}
}
});
+8
View File
@@ -0,0 +1,8 @@
import assert from 'node:assert/strict';
import {test} from 'node:test';
import {randomUUID} from 'node:crypto';
import {decodeCursor,encodeCursor,validSection} from '../src/lib/model';
test('timeline cursor preserves timestamp and ID, rejects malformed values',()=>{
const date='2026-10-22T00:00:00.000Z',id=randomUUID();assert.deepEqual(decodeCursor(encodeCursor(date,id)),[date,id]);assert.throws(()=>decodeCursor('invalid'));assert.equal(decodeCursor(),null);
});
test('only the five content sections are recognized',()=>{assert.equal(validSection('videos')?.kind,'video');assert.equal(validSection('unknown'),undefined);});
@@ -0,0 +1,18 @@
import React from 'react';
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {renderToStaticMarkup} from 'react-dom/server';
import {NoteMarkdown} from '../src/components/NoteMarkdown';
const render=(text:string)=>renderToStaticMarkup(<NoteMarkdown text={text}/>);
test('Markdown renders nested bullets, numbered and task lists as semantic lists',()=>{
const html=render('- 第一项\n - 子项目\n- 第二项\n\n3. 第三步\n4. 第四步\n\n- [x] 完成\n- [ ] 待办');
assert.match(html,/<ul>/);assert.match(html,/<li>第一项\n<ul>/);assert.match(html,/<ol start="3">/);assert.match(html,/type="checkbox"/);assert.match(html,/checked/);assert.ok(!html.includes('- 第一项'));
});
test('Markdown keeps fenced code, tables, strong text and subtle links',()=>{
const html=render('## 标题\n\n**重点** 和 [相关笔记](/explore/abc?agent_mode=True)\n\n| 名称 | 值 |\n| --- | --- |\n| 条目 | 1 |\n\n```ts\nconst items = [1, 2];\n```');
assert.match(html,/<h2>标题/);assert.match(html,/<strong>重点/);assert.match(html,/<table>/);assert.match(html,/<pre><code class="language-ts">/);assert.match(html,/note-inline-link/);assert.match(html,/href="\/explore\/abc"/);
});
test('Untrusted Markdown cannot execute HTML or javascript links and images stay links',()=>{
const html=render('<script>alert(1)</script>\n\n[unsafe](javascript:alert)\n\n![图片说明](/api/media/example)');
assert.ok(!html.includes('<script>'));assert.ok(!html.includes('href="javascript:'));assert.ok(!html.includes('<img'));assert.match(html,/href="\/api\/media\/example"/);
});
@@ -0,0 +1,8 @@
import {test} from 'node:test';
import assert from 'node:assert/strict';
import sharp from 'sharp';
import {compressPreview,previewWidth,staticPreviewPath,cachedPreview} from '../src/lib/preview';
test('preview input validation rejects traversal and arbitrary sizes',()=>{for(const value of [1,10000,'bad'])assert.equal(previewWidth(value),null);assert.equal(previewWidth(480),480);for(const source of ['https://example.com/x.png','/../secret.png','//remote/x.png','/x.svg','/x.png?foo=bar'])assert.throws(()=>staticPreviewPath(source,'/public'));assert.equal(staticPreviewPath('/images/a.png','/public'),'/public/images/a.png');});
test('large PNG becomes a bounded WebP with no upscaling',async()=>{const original=await sharp({create:{width:2200,height:3000,channels:3,background:'#f0e2d2'}}).png().toBuffer();const preview=await compressPreview(original,480);const info=await sharp(preview).metadata();assert.equal(info.format,'webp');assert.equal(info.width,480);assert.ok(preview.length<=100000);});
test('identical concurrent requests share one transform and reuse the disk result',async()=>{let loads=0;const input=await sharp({create:{width:40,height:40,channels:3,background:'#fff'}}).png().toBuffer();const identity='test-'+Date.now();const load=async()=>{loads++;return input};const [a,b]=await Promise.all([cachedPreview(identity,480,load),cachedPreview(identity,480,load)]);assert.deepEqual(a.bytes,b.bytes);assert.equal(loads,1);await cachedPreview(identity,480,load);assert.equal(loads,1);});
test('export derivatives keep resolution and use an independent cache identity',async()=>{assert.equal(previewWidth(2160,'export'),2160);assert.equal(previewWidth(2160),null);assert.equal(previewWidth(480,'export'),null);const input=await sharp({create:{width:1800,height:1200,channels:3,background:'#eddfbd'}}).png().toBuffer();const identity='export-'+Date.now();const result=await cachedPreview(identity,2160,async()=>input,true);const meta=await sharp(result.bytes).metadata();assert.equal(meta.width,1800);assert.equal(meta.height,1200);const normal=await cachedPreview(identity,480,async()=>input);assert.notEqual(result.etag,normal.etag)});
@@ -0,0 +1,59 @@
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {skillDocuments,skillResources,documentMarkdown,getSkill,skillDocumentMarkdown,skillCatalog} from '../src/content/skills/catalog';
test('human and agent documents share a complete linked resource registry',()=>{
for(const doc of skillDocuments){const md=documentMarkdown(doc.id)!;assert.ok(md.startsWith('# '+doc.title));for(const block of doc.blocks){if(block.text)assert.ok(md.includes(block.text));for(const id of block.refs||[])assert.ok(skillResources.some(r=>r.id===id))}assert.ok(!md.includes('/p/street-people-'));}
assert.equal(documentMarkdown('missing'),null);assert.equal(new Set(skillResources.map(r=>r.id)).size,skillResources.length);
for(const doc of skillDocuments)assert.ok(skillResources.some(r=>r.id===doc.id&&r.human&&r.href.endsWith('.md')));
});
test('original collections retain their authored documents and API resources',()=>{
for(const card of skillCatalog){
const skill=getSkill(card.id)!;assert.ok(skill);assert.equal(skill.id,card.id);
assert.equal(new Set(skill.resources.map(r=>r.id)).size,skill.resources.length);
for(const doc of skill.documents){
const md=skillDocumentMarkdown(card.id,doc.id)!;assert.ok(md.startsWith('# '+doc.title));
assert.ok(skill.resources.some(r=>r.id===doc.id&&r.kind==='document'&&r.human));
for(const block of doc.blocks){if(block.text)assert.ok(md.includes(block.text));for(const id of block.refs||[])assert.ok(skill.resources.some(r=>r.id===id),card.id+': '+id);}
}
}
assert.equal(getSkill('__proto__'),undefined);assert.equal(skillDocumentMarkdown('missing','index'),null);assert.equal(skillDocumentMarkdown('shadow-puppet','missing'),null);
const shadow=getSkill('shadow-puppet')!;
assert.equal(shadow.resources.filter(r=>r.kind==='document').length,5);
assert.equal(shadow.resources.filter(r=>r.kind==='image').length,2);
assert.equal(shadow.resources.find(r=>r.id==='upload-api')?.href,'/api/upload');
assert.equal(shadow.resources.find(r=>r.id==='media-api')?.template,true);
});
test('every guidance page has a matching Markdown URL and all document links resolve globally',async()=>{
const {guidanceDocuments,getGuidance,guidanceMarkdown,guidanceIndexMarkdown,humanDocumentUrl,markdownDocumentUrl,guidanceBacklinks}=await import('../src/content/skills/guidance');
assert.equal(new Set(guidanceDocuments.map(d=>d.path)).size,guidanceDocuments.length);
for(const doc of guidanceDocuments){
for(const r of doc.resources)if(r.documentPath)assert.ok(getGuidance(r.documentPath),r.documentPath);
const text=guidanceMarkdown(doc.path)!;
assert.ok(text.startsWith('# '+doc.title));
assert.equal(markdownDocumentUrl(doc.path),humanDocumentUrl(doc.path)+'?agent_mode=True');
assert.ok(text.includes(humanDocumentUrl(doc.path)));
for(const block of doc.blocks){
if(block.text)assert.ok(text.includes(block.text));
for(const id of block.refs||[]){const r=doc.resources.find(r=>r.id===id);assert.ok(r,doc.path+': '+id);if(r.documentPath){assert.ok(getGuidance(r.documentPath));assert.ok(text.includes(markdownDocumentUrl(r.documentPath)));}}
}
assert.ok(guidanceIndexMarkdown().includes(markdownDocumentUrl(doc.path)));
}
assert.ok(guidanceIndexMarkdown(true).includes('/skills/index.md'));
assert.equal(getGuidance('__proto__'),undefined);assert.equal(guidanceMarkdown('missing'),null);
const shared=guidanceBacklinks('image-delivery').map(d=>d.path);
assert.ok(shared.includes('street-people/checks'));assert.ok(shared.includes('shadow-puppet/delivery'));
assert.ok(guidanceMarkdown('image-delivery')!.includes(markdownDocumentUrl('street-people/checks')));
assert.ok(guidanceMarkdown('image-delivery')!.includes(markdownDocumentUrl('shadow-puppet/delivery')));
});
test('Markdown counterpart responses support GET and HEAD and reject writes or missing documents',async()=>{
const {sendMarkdown}=await import('../src/lib/markdown-response');
const {guidanceMarkdown}=await import('../src/content/skills/guidance');
for(const [method,text,expected] of [['GET',guidanceMarkdown('shadow-puppet/prompt'),200],['HEAD',guidanceMarkdown('shadow-puppet/prompt'),200],['POST',guidanceMarkdown('shadow-puppet/prompt'),405],['GET',guidanceMarkdown('missing'),404]] as const){
const result={code:0,body:null as string|null,headers:{} as Record<string,string>,setHeader(k:string,v:string){this.headers[k]=v;return this},status(code:number){this.code=code;return this},send(body:string){this.body=body;return this},end(){return this}};
sendMarkdown({method} as import('next').NextApiRequest,result as unknown as import('next').NextApiResponse,text);
assert.equal(result.code,expected);if(method==='GET'&&expected===200){assert.match(result.headers['Content-Type'],/text\/markdown/);assert.ok(result.body?.startsWith('# '));}else assert.equal(result.body,null);
}
});
@@ -0,0 +1,65 @@
import test from 'node:test';
import {readFileSync} from 'node:fs';
import assert from 'node:assert/strict';
import {parseCanvasDocument,deriveNode,connectNodes,removeCanvasNode,referencedNodes,agentTask} from '../../src/studio/lib/canvas-document.ts';
const note={id:'brief',kind:'text',title:'创意简报',text:'清晨的城市',x:20,y:50};
const doc={version:1,cards:[note],links:[]};
test('existing v1 browser drafts remain readable and untrusted fields are discarded',()=>{
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/studio/lib/canvas-document.ts',import.meta.url),'utf8'),"export * from '../../lib/canvas-document.ts';\n");
});
@@ -0,0 +1,8 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {parseGraphicDocument} from '../../src/studio/lib/graphic-document.ts';
const item={id:'one',kind:'text',name:'标题',text:'<script>文本</script>',x:20,y:20,w:300,h:100,fill:'#123456',size:32,hidden:false,locked:false};
const doc={name:'作品',background:'#ffffff',items:[item]};
test('graphic backup restores editable text and ignores unrelated properties',()=>{assert.deepEqual(parseGraphicDocument(JSON.stringify({...doc,extra:'ignored'})),doc)});
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}]})));});
@@ -0,0 +1,27 @@
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {embedSvgMedia} from '../../src/studio/lib/layout-images.ts';
const id='a05dc841-240a-5a3a-b01b-d589fc21cd36';
test('SVG export fetches each internal media once with the caller authentication',async()=>{
const original=globalThis.fetch;let calls=0;
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;}
});
@@ -0,0 +1,7 @@
import {test} from 'node:test';import assert from 'node:assert/strict';import {readFileSync} from 'node:fs';
import {parameters} from '../../src/studio/lib/layout-parameters.ts';
const layouts=JSON.parse(readFileSync(new URL('../../src/studio/content/templates/layouts.json',import.meta.url)));
test('27 layouts preserve unique typed source slots and complete shapes',()=>{assert.equal(layouts.length,27);for(const [i,l] of layouts.entries()){assert.equal(l.id,'ref-'+String(i+1).padStart(2,'0'));assert.equal(new Set(l.elements.map(s=>s.id)).size,l.elements.length);for(const s of l.elements){assert.match(s.id,/^[TID]\d{2}$/);assert.equal(s.kind,({T:'text',I:'image',D:'decoration'})[s.id[0]]);assert.ok(s.box[0]>=0&&s.box[1]>=0&&s.box[0]+s.box[2]<=l.width&&s.box[1]+s.box[3]<=l.height);assert.match(s.raster,/^\/api\/media\/[a-f0-9-]{36}$/);if(s.kind==='image')assert.match(s.shape,/^\/api\/media\/[a-f0-9-]{36}$/)}assert.deepEqual(parameters(l,{}),{texts:{},images:{},decorations:{}})}});
test('parameters reject mismatched slots, unsafe URLs and excessive text',()=>{const l=layouts[0];for(const input of [{texts:{I01:{value:'bad'}}},{images:{I01:{url:'https://evil.test'}}},{texts:{T01:{value:'x',scale:3}}},{texts:{T01:{value:'x'.repeat(501)}}},{paper:'url(x)'},{decorations:{D01:{hidden:'true'}}},null,[]])assert.throws(()=>parameters(l,input));const p=parameters(l,{texts:{T01:{value:'<script>not markup</script>',scale:1.2,align:'middle'}},images:{I01:{color:'#abcdef',fit:'contain'}},decorations:{D01:{hidden:true}}});assert.equal(p.texts.T01.value,'<script>not markup</script>');assert.equal(p.decorations.D01.hidden,true)});
test('workspace parameters round-trip identically through Studio and Wiki validation',async()=>{const {parameters:wiki}=await import('../../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))});
@@ -0,0 +1,19 @@
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/studio/lib/native-layout.ts';
const layouts=JSON.parse(readFileSync(new URL('../../src/studio/content/templates/layouts.json',import.meta.url)));
test('all 27 native pages are independent of source images and supply actual text',()=>{
for(const original of layouts){const layout=nativeLayout(original),serialized=JSON.stringify(layout);assert.ok(!serialized.includes('data:image'));assert.ok(!serialized.includes('raster'));assert.ok(!serialized.includes('source'));assert.ok(serialized.length<7000);
for(const slot of layout.elements.filter(s=>s.kind==='text')){assert.ok(defaultText(layout,slot).length);for(const text of [defaultText(layout,slot),'','新的文字\n第二行','<script>plain text</script>']){const size=textSize(layout,slot,text);assert.ok(Number.isFinite(size)&&size>0)}}
}
});
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)}});
@@ -0,0 +1,11 @@
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/studio/lib/series.ts');
const layout=JSON.parse(readFileSync(new URL('../../src/studio/content/standard-layout.json',import.meta.url)));
// Fixture copied into the CI image; no build is performed by these tests.
const input=JSON.parse(readFileSync(new URL('../../fixtures/studio/autumn-journal.json',import.meta.url)));
test('a complete three-page document preserves copy and resolves shared visual tokens',()=>{const d=series(input.document,[layout]);assert.equal(d.pages.length,3);for(const page of d.pages){const p=pageParameters(d,page,layout);assert.equal(p.paper,d.style.palette.paper);assert.equal(p.texts.T01.fontFamily,'serif');assert.equal(p.texts.T01.value,page.parameters.texts.T01.value);assert.equal(p.layers.T01.x,10)}const change=structuredClone(d);change.style.palette.ink='#112233';change.style.spacing.margin=8;for(const page of change.pages){const p=pageParameters(change,page,layout);assert.equal(p.texts.T02.color,'#112233');assert.equal(p.layers.T01.x,8)}change.pages[1].parameters.texts.T01.color='#ABCDEF';assert.equal(pageParameters(change,change.pages[1],layout).texts.T01.color,'#ABCDEF')});
test('invalid page order, stale template placeholders, untracked images and unsafe fields fail',()=>{for(const mutate of [d=>d.pages.reverse(),d=>d.pages[1].key=d.pages[0].key,d=>delete d.pages[0].parameters.texts.T02,d=>d.pages[0].parameters.images.I01.assetId='f76134e7-6e4f-4261-b90c-3c523dc16307',d=>d.style.palette.ink='url(evil)',d=>d.style.spacing.gap=10,d=>d.pages[0].bindings.I01='title',d=>d.pages[0].parameters.images.I01.url='https://evil.test']){const d=structuredClone(input.document);mutate(d);assert.throws(()=>series(d,[layout]))}});
+5
View File
@@ -0,0 +1,5 @@
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {wikiPages,findWikiPage,wikiMarkdown,wikiPath} from '../src/content/wiki';
test('every wiki card resolves to the same AI-visible page',()=>{for(const page of wikiPages){const key=page.namespace+':'+page.slug;assert.equal(findWikiPage(key),page);assert.ok(wikiMarkdown(page).includes(page.body));assert.equal(decodeURIComponent(wikiPath(page)),'/wiki/'+key);}});
test('category membership crosses namespaces and rejects unknown categories',()=>{const page=findWikiPage('Category:创作方法');assert.ok(page?.body.includes('Main:'));assert.ok(page?.body.includes('Template:'));assert.equal(findWikiPage('Category:missing'),undefined);assert.equal(findWikiPage('Unknown:welcome'),undefined);});
+127
View File
@@ -0,0 +1,127 @@
x-waterfall-wiki-environment: &waterfall-wiki-environment
HOSTNAME: 0.0.0.0
PORT: "3000"
DATABASE_URL: postgresql://prompt:${POSTGRES_PASSWORD:?Set a unique PostgreSQL password}@postgres:5432/prompt
S3_ENDPOINT: http://objects:9000
S3_ACCESS_KEY: ${S3_ACCESS_KEY:?Set a unique S3 access key}
S3_SECRET_KEY: ${S3_SECRET_KEY:?Set a unique S3 secret key}
S3_BUCKET: ${S3_BUCKET:-prompt}
RELEASE_SHA: ${RELEASE_SHA:-development}
FFMPEG_PATH: /usr/bin/ffmpeg
FFPROBE_PATH: /usr/bin/ffprobe
CHROMIUM_PATH: /usr/bin/chromium
RENDER_ORIGIN: http://waterfall-wiki:3000
AUTH_CONFIG_FILE: ${AUTH_CONFIG_FILE:-}
AUTH_DISABLED_FOR_TESTS: ${AUTH_DISABLED_FOR_TESTS:-false}
AUTH_COOKIE_SECURE: ${AUTH_COOKIE_SECURE:-true}
ACCOUNT_PUBLIC_ORIGIN: ${ACCOUNT_PUBLIC_ORIGIN:-http://8.211.154.134:18131}
ACCOUNT_TRUST_PROXY: ${ACCOUNT_TRUST_PROXY:-false}
SMTP_HOST: ${SMTP_HOST:-smtpdm.aliyun.com}
SMTP_PORT: ${SMTP_PORT:-465}
SMTP_USERNAME: ${SMTP_USERNAME:-noreply@notice.entroia.com}
SMTP_FROM: ${SMTP_FROM:-noreply@notice.entroia.com}
SMTP_PASSWORD: ${SMTP_PASSWORD:-}
x-waterfall-wiki: &waterfall-wiki
image: ${WATERFALL_WIKI_IMAGE:-waterfall-wiki:local}
restart: unless-stopped
environment: *waterfall-wiki-environment
init: true
volumes:
- auth_data:/auth:ro
security_opt:
- no-new-privileges:true
cap_drop: [ALL]
depends_on:
postgres:
condition: service_healthy
objects:
condition: service_started
services:
waterfall-wiki:
<<: *waterfall-wiki
ports:
- "${WEB_BIND_ADDRESS:-0.0.0.0}:${WEB_PORT:-18131}:3000"
healthcheck:
test: ["CMD", "node", "container-health.mjs"]
interval: 5s
timeout: 8s
retries: 24
start_period: 10s
waterfall-wiki-worker:
<<: *waterfall-wiki
command: ["node", "worker.cjs"]
stop_grace_period: 11m
healthcheck:
test: ["CMD", "node", "-e", "const f=require('fs');try{if(Date.now()-f.statSync('/tmp/prompt-worker-health').mtimeMs>60000)process.exit(1)}catch{process.exit(1)}"]
interval: 15s
timeout: 5s
retries: 4
start_period: 10s
auth-admin:
<<: *waterfall-wiki
profiles: [tools]
restart: "no"
user: root
cap_drop: []
cap_add: [CHOWN, FOWNER, DAC_OVERRIDE]
volumes:
- auth_data:/auth
command: ["node", "auth-cli.cjs", "init"]
migrate:
<<: *waterfall-wiki
profiles: [tools]
restart: "no"
command: ["node", "bootstrap.cjs"]
publish:
<<: *waterfall-wiki
profiles: [tools]
restart: "no"
command: ["node", "publish-learning-series.cjs"]
tests:
image: ${WATERFALL_WIKI_TEST_IMAGE:-waterfall-wiki:tests}
profiles: [tools]
init: true
environment:
<<: *waterfall-wiki-environment
PROMPT_INTEGRATION_TEST: "1"
PROMPT_TEST_BASE_URL: http://waterfall-wiki:3000
depends_on:
waterfall-wiki:
condition: service_healthy
waterfall-wiki-worker:
condition: service_healthy
postgres:
image: postgres:17-alpine
restart: unless-stopped
environment:
POSTGRES_DB: prompt
POSTGRES_USER: prompt
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?Set a unique PostgreSQL password}
ports:
- "127.0.0.1:${PG_PORT:-18132}:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U prompt -d prompt"]
interval: 3s
timeout: 3s
retries: 30
objects:
image: rustfs/rustfs:1.0.3
restart: unless-stopped
environment:
RUSTFS_ACCESS_KEY: ${S3_ACCESS_KEY:?Set a unique S3 access key}
RUSTFS_SECRET_KEY: ${S3_SECRET_KEY:?Set a unique S3 secret key}
RUSTFS_VOLUMES: /data
RUSTFS_ADDRESS: ":9000"
RUSTFS_CONSOLE_ENABLE: "false"
ports:
- "127.0.0.1:${S3_PORT:-18133}:9000"
volumes:
- objects:/data
volumes:
auth_data:
postgres_data:
objects:
+19
View File
@@ -0,0 +1,19 @@
# 原图排版的本地创作数据
本目录是发布客户端的输入,不在 Wiki 应用的 public、pages 或内容注册表中,不提供网页或静态子 URL。
- `assets/`:原图、裁片、分类标注、构件 PNG、遮罩及新 Router 封面。
- `manifest.json`:27 张的源坐标、类型、文字与图形位置,使用 `asset:` 本地逻辑引用。
- `post-ids.json`:保留的 28 个普通 post 稳定 ID。
- `annotations.json` / `ocr-evidence.json` / `group-text.ts`:识别、人工复核与同词合并的源资料。
- `renderer.tsx`:作为 Markdown 代码示例上传的创作端组件,不由 Wiki 编译或运行。
本地重建封面:`node scripts/layout-reference-cover.mjs`。
发布预检:`node scripts/publish-layout-reference.mjs --dry-run`。
实际发布设置 `WIKI_BASE` 为指定 Wiki、`WIKI_PUBLISH_STATE` 为独立的本地检查点路径后运行发布脚本。客户端只调用 `/api/upload`、`/api/posts`、`/api/notes/{id}`、`/api/tags`。先单独上传 PNG,再将媒体 UUID、构件 JSON、源码文本组合为普通 post。所有图片链接都会转换为 `/api/media/{id}`;不上传 HTML 页面、JSON 文件端点或 ZIP 页面。
检查点记录站点、素材摘要到 UUID 的映射及已发布 post,支持中断后继续。更新已存在的同 slug post 使用 revision、媒体关联与封面字段;遇到占用 ID 则停止,避免覆盖其他内容。请为不同站点使用不同检查点。
对外是 27 张设计 post + 1 篇 Router。Wiki 不需要为这些内容增加路由、组件或代码注册;客户端生成的正文可通过 `?agent_mode=True` 读取。原图低清,未知微字保留独立字形,不猜写。
File diff suppressed because it is too large Load Diff
+18
View File
@@ -0,0 +1,18 @@
export type Box=[number,number,number,number];
export type TextRegion={box:Box;direction:string};
/** Associate residual glyphs with an existing semantic word/paragraph, never create a second word ID. */
export function textMergeTarget(fragment:Box,regions:TextRegion[]):number{
const[x,y,w,h]=fragment;let result=-1,best=Infinity;
for(const[index,r]of regions.entries()){
const[a,b,c,d]=r.box,vertical=r.direction==='vertical';
const gapX=Math.max(a-x-w,x-a-c,0),gapY=Math.max(b-y-h,y-b-d,0);
const overlapX=Math.max(0,Math.min(x+w,a+c)-Math.max(x,a)),overlapY=Math.max(0,Math.min(y+h,b+d)-Math.max(y,b));
const sameBaseline=overlapY/Math.max(1,Math.min(h,d))>=.55&&gapX<=Math.max(3,Math.min(7,d*.4));
const sameColumn=overlapX/Math.max(1,Math.min(w,c))>=.55&&gapY<=Math.max(2,Math.min(4,vertical?c*.3:2));
if(!(sameBaseline||sameColumn))continue;
// Nearby small footer blocks remain independent when the fragment has no baseline/column affinity.
const score=gapX+gapY+(vertical?(1-overlapX/Math.max(1,Math.min(w,c))):(1-overlapY/Math.max(1,Math.min(h,d))));
if(score<best){result=index;best=score}
}return result;
}
export function unionBoxes(a:Box,b:Box):Box{const x=Math.min(a[0],b[0]),y=Math.min(a[1],b[1]);return[x,y,Math.max(a[0]+a[2],b[0]+b[2])-x,Math.max(a[1]+a[3],b[1]+b[3])-y]}
File diff suppressed because it is too large Load Diff
+493
View File
@@ -0,0 +1,493 @@
[
{
"file" : "ref-01.png",
"lines" : [
{
"box" : [
0.073746314917825531,
0.12865497059314956,
0.31268436578171094,
0.062378167641325533
],
"confidence" : 0.5,
"text" : "BULUBLUE"
}
]
},
{
"file" : "ref-02.png",
"lines" : [
]
},
{
"file" : "ref-03.png",
"lines" : [
{
"box" : [
0.022132919255381901,
0.028650177161836932,
0.33292714615314328,
0.089135101994567756
],
"confidence" : 0.5,
"text" : "蓝羽毛"
}
]
},
{
"file" : "ref-04.png",
"lines" : [
]
},
{
"file" : "ref-05.png",
"lines" : [
]
},
{
"file" : "ref-06.png",
"lines" : [
]
},
{
"file" : "ref-07.png",
"lines" : [
{
"box" : [
0.026785715591931197,
0.75581395330534029,
0.23511904761904764,
0.067829457364341095
],
"confidence" : 1,
"text" : "2025"
},
{
"box" : [
0.52921506067374158,
0.90859448832286593,
0.43264130183628624,
0.051028077916581482
],
"confidence" : 0.30000001192092896,
"text" : "能車贤好雞 蓋羽毛"
}
]
},
{
"file" : "ref-08.png",
"lines" : [
{
"box" : [
0.041297932751125623,
0.019267822827299441,
0.89380530973451322,
0.061657032755298657
],
"confidence" : 0.5,
"text" : "BULUBLUE"
},
{
"box" : [
0.041297933952802367,
0.078998073513166345,
0.91150442477876115,
0.088631984585741841
],
"confidence" : 0.5,
"text" : "蓝羽毛设计"
}
]
},
{
"file" : "ref-09.png",
"lines" : [
]
},
{
"file" : "ref-10.png",
"lines" : [
{
"box" : [
1.2867459860650037e-09,
0.32755298650587994,
0.7463126843657818,
0.1502890173410405
],
"confidence" : 0.30000001192092896,
"text" : "DUE"
}
]
},
{
"file" : "ref-11.png",
"lines" : [
{
"box" : [
0.0029239778265106767,
0.044315992678227389,
0.47660818713450293,
0.11560693641618491
],
"confidence" : 1,
"text" : "蓝羽毛"
},
{
"box" : [
0.026110667628392013,
0.17872041532632799,
0.60859738177026224,
0.11654760842148737
],
"confidence" : 1,
"text" : "版式灵感"
}
]
},
{
"file" : "ref-12.png",
"lines" : [
{
"box" : [
0.014880952149470902,
-3.6751579557403602e-10,
0.2678571428571429,
0.19845857418111745
],
"confidence" : 1,
"text" : "版"
}
]
},
{
"file" : "ref-13.png",
"lines" : [
{
"box" : [
0.07309941752558477,
0.067437379901252426,
0.8421052631578948,
0.10404624277456653
],
"confidence" : 0.30000001192092896,
"text" : "版式=灵感"
}
]
},
{
"file" : "ref-14.png",
"lines" : [
{
"box" : [
0.07374630927974439,
0.45785440622605367,
0.81710914454277295,
0.049808429118773923
],
"confidence" : 0.30000001192092896,
"text" : "置羽毛 版式煲感设计分享"
}
]
},
{
"file" : "ref-15.png",
"lines" : [
{
"box" : [
0.043711436004017792,
0.054446479151035843,
0.68638665335518967,
0.089565615203798576
],
"confidence" : 1,
"text" : "bulublue"
},
{
"box" : [
0.043607735502465307,
0.1459897803830712,
0.5169511976696195,
0.10108402377148817
],
"confidence" : 1,
"text" : "design"
}
]
},
{
"file" : "ref-16.png",
"lines" : [
{
"box" : [
0.085458582825798823,
0.67023348199897037,
0.28041027147861358,
0.037182361404330733
],
"confidence" : 0.30000001192092896,
"text" : "經引毛飯式"
}
]
},
{
"file" : "ref-17.png",
"lines" : [
{
"box" : [
0.050147494284660782,
0.065134099832375503,
0.5663716814159292,
0.068965517241379226
],
"confidence" : 1,
"text" : "Bulublue"
},
{
"box" : [
0.046612091924946461,
0.13624270198706689,
0.48789676452456682,
0.076173599652403667
],
"confidence" : 0.30000001192092896,
"text" : "Design"
},
{
"box" : [
0.44788804184380798,
0.30109252421060173,
0.54965164949760315,
0.075975871177468735
],
"confidence" : 0.5,
"text" : "高级感版式,"
}
]
},
{
"file" : "ref-18.png",
"lines" : [
{
"box" : [
0.03818345576318917,
0.6239403036164336,
0.68469503847195334,
0.083537017705340055
],
"confidence" : 0.5,
"text" : "Bulublue"
},
{
"box" : [
0.036029248937117057,
0.69494847907178836,
0.73614549214861047,
0.14112464495545596
],
"confidence" : 0.30000001192092896,
"text" : "Design m"
}
]
},
{
"file" : "ref-19.png",
"lines" : [
{
"box" : [
0.041297934983846013,
0.015594541432284448,
0.22713864306784656,
0.2007797270955165
],
"confidence" : 1,
"text" : "高"
},
{
"box" : [
0.032448377516592908,
0.70760233846856724,
0.23893805309734517,
0.22807017543859653
],
"confidence" : 1,
"text" : "排"
},
{
"box" : [
0.69616519158554568,
-5.3118909271177017e-10,
0.23303834808259583,
0.21247563352826515
],
"confidence" : 1,
"text" : "级"
},
{
"box" : [
0.70501474906895278,
0.71734892719298249,
0.2271386430678467,
0.21832358674463936
],
"confidence" : 1,
"text" : "版"
}
]
},
{
"file" : "ref-20.png",
"lines" : [
{
"box" : [
0.19446010141722203,
0.58329392990046902,
0.31818376507675439,
0.099253917245736178
],
"confidence" : 0.30000001192092896,
"text" : "高级"
}
]
},
{
"file" : "ref-21.png",
"lines" : [
{
"box" : [
0.032163742013888892,
0.65125240855009636,
0.10818713450292397,
0.046242774566473965
],
"confidence" : 1,
"text" : "25"
},
{
"box" : [
0.70095725028416789,
0.02139049098390966,
0.2679496675904034,
0.69436710188613915
],
"confidence" : 0.30000001192092896,
"text" : "Bulublue:"
},
{
"box" : [
0.74242488044068555,
0.72010859462359933,
0.21105667025025121,
0.049185508478124196
],
"confidence" : 1,
"text" : "2025"
}
]
},
{
"file" : "ref-22.png",
"lines" : [
]
},
{
"file" : "ref-23.png",
"lines" : [
{
"box" : [
0.017024611458375291,
0.45011763555939854,
0.9542548642521016,
0.15596627819445708
],
"confidence" : 1,
"text" : "BULUBLUE"
}
]
},
{
"file" : "ref-24.png",
"lines" : [
{
"box" : [
0.027486666794077769,
0.00811422680517071,
0.61448201317351125,
0.19655708194703103
],
"confidence" : 1,
"text" : "蓝羽毛版式"
}
]
},
{
"file" : "ref-25.png",
"lines" : [
{
"box" : [
0.064896738135557824,
0.23121387201950871,
0.082595906778071132,
0.26011560693641611
],
"confidence" : 0.30000001192092896,
"text" : "高設•版式"
}
]
},
{
"file" : "ref-26.png",
"lines" : [
{
"box" : [
0.011695904385964868,
0.017341040546724473,
0.65497076023391809,
0.07707129094412335
],
"confidence" : 0.5,
"text" : "Format 58"
},
{
"box" : [
0.014619884705353111,
0.14065510587668595,
0.43274853801169588,
0.08285163776493254
],
"confidence" : 0.5,
"text" : "Bulubl"
},
{
"box" : [
0.017385159403405345,
0.23650755152999758,
0.58218874568827672,
0.083824973574952666
],
"confidence" : 1,
"text" : "Bulublue"
}
]
},
{
"file" : "ref-27.png",
"lines" : [
{
"box" : [
-0.021385720300243924,
0.43859550876054387,
0.83889416285923546,
0.20254762043324548
],
"confidence" : 0.5,
"text" : "蓝羽毛-版式"
}
]
}
]
+30
View File
@@ -0,0 +1,30 @@
{
"router": "8eb8c76500580f7a4b56ba3c",
"ref-01": "eb937b42be92a9f6feee684f",
"ref-02": "1179d1b1a5f21a99fa0cf542",
"ref-03": "f3079f1dbb242180e04b602b",
"ref-04": "b7a5981ea582ed94384af2ae",
"ref-05": "a01a0ab78461632b8cb148f9",
"ref-06": "c524f9c3858052ebcc5ab8ef",
"ref-07": "7d4cefdfada28bbe9e570af3",
"ref-08": "e53f7620a90d3f8076c44326",
"ref-09": "18aff77f5f032eda1a2cd04b",
"ref-10": "d5906c34d3efb6fdbcb40da8",
"ref-11": "d1718fd03494af0c8c95e4c4",
"ref-12": "f78131152506cbf7dec7506a",
"ref-13": "d3c5eae33973291530d1118e",
"ref-14": "b042317268286cff3e52d145",
"ref-15": "acbebf9c77fb2432be877bf0",
"ref-16": "d3b2ec816983aadb5b4690be",
"ref-17": "7b1b2fc5b9690213bbbe07ac",
"ref-18": "5b376e57c70ce8a49cf4e491",
"ref-19": "246c33f9fff157ab9a4c1df7",
"ref-20": "b5db869088ed8296d8329f83",
"ref-21": "fd6b7caabcf3077f9bdc32c7",
"ref-22": "8561d1fd6f23335373eab1ba",
"ref-23": "30f3978167ba3da8770ec5de",
"ref-24": "6c72e53a6e2f497852f98f04",
"ref-25": "51c0712b17a8372a5fa151df",
"ref-26": "f356b77194288909ee4352b4",
"ref-27": "b30a9778355b7132633f51b3"
}
+24
View File
@@ -0,0 +1,24 @@
import React from 'react';
import type {CSSProperties} from 'react';
type Part={id:string;kind:string;role:string;box:number[];asset:string;text?:string|null;direction?:string;mask?:string;underlay?:string};
export type Layout={id:string;width:number;height:number;background:string;elements:Part[]};
// The manifest is copied from the post's JSON block. This file is consumer code, not a Wiki route.
export function LayoutPoster({id,layout,text={},images={},assetBase=''}:{id:string;layout:Layout;text?:Record<string,string>;images?:Record<string,string>;assetBase?:string}){
if(layout.id!==id)throw Error('版面 ID 不一致');
for(const[kind,values]of [['text',text],['image',images]] as const)for(const key of Object.keys(values))if(!layout.elements.some(e=>key===id+'/'+e.id&&kind===e.kind))throw Error('构件类型或所属版面不一致:'+key);
const src=(url:string)=>url.startsWith('/')?assetBase+url:url;
return <figure aria-label={id} style={{margin:0,width:'min(100%,672px)',containerType:'inline-size'}}>
<div style={{position:'relative',aspectRatio:`${layout.width}/${layout.height}`,isolation:'isolate',background:'#f4f3ee'}}>
<img src={src(layout.background)} alt="" style={{position:'absolute',inset:0,width:'100%',height:'100%'}}/>
{layout.elements.map(e=>{
const[x,y,w,h]=e.box,key=id+'/'+e.id,position:CSSProperties={position:'absolute',left:x/layout.width*100+'%',top:y/layout.height*100+'%',width:w/layout.width*100+'%',height:h/layout.height*100+'%',zIndex:e.kind==='text'?3:e.kind==='decoration'?2:1};
if(e.kind==='text'&&Object.hasOwn(text,key)){
if(e.direction==='curved')throw Error('弧形文字须使用本设计专用 SVG 路径,不能直接改成直排');
const vertical=e.direction==='vertical'||e.direction==='rotated',lines=text[key].split('\n'),units=Math.max(1,...lines.map(line=>Array.from(line).reduce((n,c)=>n+(/[\u3400-\u9fff]/.test(c)?1:.56),0))),size=vertical?Math.min(w,h/Math.max(1,text[key].length)):Math.min(h/(lines.length*1.1),w/units);
return <React.Fragment key={key}>{e.underlay&&<img src={src(e.underlay)} alt="" style={position}/>}<span style={{...position,fontSize:size/layout.width*100+'cqw',lineHeight:1.1,whiteSpace:'pre-wrap',writingMode:vertical?'vertical-rl':undefined,textOrientation:e.direction==='rotated'?'sideways':undefined,transform:e.direction==='angled'?'rotate(-12deg)':undefined,fontFamily:'serif',color:'#222'}}>{text[key]}</span></React.Fragment>;
}
return <img key={key} data-component={key} data-kind={e.kind} src={e.kind==='image'&&images[key]?images[key]:src(e.asset)} alt={e.kind==='text'?e.text||e.role+'(未辨识原字形)':''} style={{...position,objectFit:'cover',maskImage:e.kind==='image'&&images[key]&&e.mask?`url("${src(e.mask)}")`:undefined,maskSize:'100% 100%'}}/>;
})}
</div>
</figure>;
}
+17
View File
@@ -0,0 +1,17 @@
import {readRepositoryMedia} from '../../scripts/managed-media.mjs';
import {createRequire} from 'node:module';
import {readFile,access} from 'node:fs/promises';
import {fileURLToPath} from 'node:url';
import path from 'node:path';
import assert from 'node:assert/strict';
const root=path.resolve(path.dirname(fileURLToPath(import.meta.url)),'../..'),require=createRequire(path.join(root,'apps/waterfall-wiki/package.json')),sharp=require('sharp');
const layouts=JSON.parse(await readFile(path.join(root,'content/layout-reference/manifest.json'),'utf8'));
const file=url=>readRepositoryMedia('content/layout-reference/assets/'+url.slice('asset:'.length));
assert.equal(layouts.length,27);assert.equal(new Set(layouts.map(l=>l.id)).size,27);
for(const l of layouts){
const actual=await sharp(await file(l.background)).composite(await Promise.all(l.elements.map(async e=>({input:await file(e.asset),left:e.box[0],top:e.box[1]})))).removeAlpha().raw().toBuffer(),expected=await sharp(await file(l.reference)).removeAlpha().raw().toBuffer();
assert.ok(actual.equals(expected),l.id);
for(const e of l.elements)for(const key of ['asset','mask','underlay'])if(e[key])await file(e[key]);
}
assert.deepEqual(layouts[0].elements.filter(e=>e.kind==='text').map(e=>e.id),['T01','T02']);
console.log('27 authoring manifests and 217 classified components verified; all physical composites equal their source crops.');
+163
View File
@@ -0,0 +1,163 @@
{
"schemaVersion": 2,
"name": "把秋天装进一页 · 银杏观察手记",
"document": {
"version": 1,
"brief": {
"topic": "银杏与秋日观察手记",
"audience": "喜欢日常摄影与视觉手帐的小红书读者",
"intent": "以封面、观察方法和行动结尾组成三页可发布图文",
"designIntent": "奶油纸底、森林墨色、银杏金点缀;宋体大标题与克制的无衬线正文;所有页面保留相同页边距和节奏",
"imageStrategy": "生成一幅原创银杏静物插画作为系列母图;每页复用素材,保留裁切配置,不把正文烘焙进图片"
},
"style": {
"version": "autumn-journal/1",
"palette": {
"paper": "#F3EFE5",
"ink": "#253C32",
"muted": "#718074",
"accent": "#C3A050"
},
"typography": {
"title": {
"fontFamily": "serif",
"fontSize": 8.4,
"fontWeight": 600,
"lineHeight": 1.15,
"letterSpacing": 0
},
"body": {
"fontFamily": "sans",
"fontSize": 3.1,
"fontWeight": 400,
"lineHeight": 1.65,
"letterSpacing": 0
},
"caption": {
"fontFamily": "sans",
"fontSize": 1.7,
"fontWeight": 500,
"lineHeight": 1.2,
"letterSpacing": 0
}
},
"spacing": {
"margin": 10,
"gap": 4
}
},
"sources": [],
"pages": [
{
"key": "cover",
"role": "cover",
"layoutId": "standard-01",
"intent": "用银杏静物与两行大标题建立主题",
"bindings": {
"T01": "title",
"T02": "body",
"T03": "caption"
},
"parameters": {
"texts": {
"T01": {
"value": "把秋天\n装进一页"
},
"T02": {
"value": "不必走很远。\n一片银杏,就是今天的风景。"
},
"T03": {
"value": "SEASONAL NOTES / 01"
}
},
"images": {
"I01": {
"fit": "cover",
"cropX": 50,
"cropY": 50,
"zoom": 1
}
},
"decorations": {}
}
},
{
"key": "observe",
"role": "body",
"layoutId": "standard-01",
"intent": "用三个观察动作提供可实践的方法",
"bindings": {
"T01": "title",
"T02": "body",
"T03": "caption"
},
"parameters": {
"texts": {
"T01": {
"value": "先看见\n再记录"
},
"T02": {
"value": "看叶脉,看光落下的方向。\n留住一片金黄,也留出一半空白。\n把感受写成短句,画面就有了呼吸。"
},
"T03": {
"value": "FIELD NOTES / 02"
}
},
"images": {
"I01": {
"fit": "cover",
"cropX": 65,
"cropY": 40,
"zoom": 1.25
}
},
"decorations": {}
}
},
{
"key": "ending",
"role": "ending",
"layoutId": "standard-01",
"intent": "以轻量行动和提问收尾",
"bindings": {
"T01": "title",
"T02": "body",
"T03": "caption"
},
"parameters": {
"texts": {
"T01": {
"value": "留一点白\n给下一次",
"align": "middle"
},
"T02": {
"value": "今天,捡起一片叶子。\n用三个词记录它的颜色、纹理与心情。\n你的秋天,会从哪一页开始?",
"align": "middle"
},
"T03": {
"value": "YOUR TURN / 03"
}
},
"images": {
"I01": {
"fit": "cover",
"cropX": 65,
"cropY": 50,
"zoom": 1
}
},
"decorations": {},
"layers": {
"I01": {
"x": 40,
"y": 55,
"width": 40,
"height": 40,
"radius": 20
}
}
}
}
]
}
}
+11
View File
@@ -0,0 +1,11 @@
# Studio 固定构图视觉审查
2026-10-28:先搜索互联网设计 skills,再检查源文件。采用 [Anthropic frontend-design 原始 SKILL.md](https://github.com/anthropics/claude-code/blob/main/plugins/frontend-design/skills/frontend-design/SKILL.md) 中与本任务适用的字体层级、基于内容选择配色、保留构图个性及逐版审视方法;不采用其自由重构产品布局的方向,因为本任务要求保留27套构图及现有工具箱。只阅读说明,没有安装依赖或执行第三方代码。
搜索结果中的第三方技能聚合页只用于发现,不作为实现依据。另检查了 Impeccable 候选源路径,但该路径返回404,未读取到可信内容,因此不采用,也不声称安装或使用了该技能。
实现把每套构图的纸色、文字、主图位、副图位组织成独立四色色谱。蓝灰用于横向英文刊头,暖赭用于出血椭圆与斜题,苔绿用于竖题与同心圆;没有把构图改为统一卡片。竖向中文使用宋体栈,英文大刊头保留衬线,模块式构图加强字重与字距,小字替换成适合槽位的简短可编辑内容。原始画板、元素ID、裁切轮廓与主要坐标保持不变。
可视化目录用原生文字和几何展示构图,页面本身继续由现有 LayoutWorkbench 原生渲染。颜色、字体和几何显式参数优先于模板默认值;模板默认外观会跟随版本美化,要求固定外观的调用者应传入显式参数。
验收应在部署完成后仅访问指定服务器,检查27套页面、移动宽度、保存ID恢复、字体/几何扩展、图片网络请求,以及旧工具功能。原生页面不能以旧SVG导出代替视觉验收。
+216
View File
@@ -0,0 +1,216 @@
# Studio 排版 API · 标准图文与 27 套固定构图
把本篇与图片生成文档一起交给 coding agent。agent 自行选择图片、配色、字体、构图与参数,通过 HTTP 保存渲染描述,得到可持续更新的原生 HTML/CSS 页面。数据库只保存模板关联、文字、素材引用和渲染参数,不保存截图或任意 HTML。
[可选:查看模板示例](/studio) · [读取实时 API 契约](/api/studio/designs?agent_mode=True) · [照片入皮影](/explore/3c98946754c33309d73d5284) · [街头小人](/explore/4ce3283fd98bb03a8fb39a19)
## agent 的交付流程
1. 按下文读取模板 API,选择 layoutId,并获取该模板实际的 T/I/D 元素 ID。
2. 上传图片取得 assetId,把图片、文字和参数提交给创建 API。
3. 从创建响应读取 `design.id` 与 `design.url`,把完整结果链接交给用户,例如 `http://8.211.154.134:18131/studio/template-layout?id=实际返回的UUID`。
4. 用户直接打开该链接看结果;修改内容继续调用 PATCH,同一 ID 与结果链接保持不变。
不要编造 ID,也不要把模板预览链接当作最终结果。API 保存成功并返回真实 UUID 才算完成。以下文档足以通过 HTTP 完成流程,无需使用编辑器。
## 多页作品:从创作 brief 到可发布文件(schemaVersion 2)
多页作品由 coding agent 完成创作决策与图像生成,网站负责封闭参数验证、事务保存与原生渲染。无需模型服务、模型密钥或编辑器。先形成主题、读者、意图、逐页分镜,再生成需要的图片,最后通过 HTTP 保存整组作品。封面、正文、结尾共用一个真实 UUID、修订号和 Wiki post。
### 完整内容与视觉模型
创建 `POST /api/studio/series`,body 为 `{schemaVersion:2,name,document}`。读取/整体修订使用 `GET/PATCH /api/studio/series/{id}`;PATCH 额外提供最新 revision。最大512KB,未知字段会拒绝。document:
- `version:1`:多页文档结构版本。
- `brief`:topic、audience、intent、designIntent、imageStrategy,均为必填非空文字,最多2000字符。分别说明主题、读者、内容目标、完整设计描述和配图策略。
- `style.version`:agent 指定的样式版本标识(最多80字符),如 `autumn-journal/1`。它用于记录创作版本;不锁定未来网站渲染器,精确交付请同时保留导出文件和 manifest。
- `style.palette`:paper、ink、muted、accent,均为 #RRGGBB。
- `style.typography`:title、body、caption 三种层级,每项包含 fontFamily(sans/serif/mono)、fontSize(0.1–40)、fontWeight(100–900)、lineHeight(0.5–4)、letterSpacing(-5–10)。字号和字距以120宽参考画板为单位,固定模板按画板宽度同比换算。
- `style.spacing`:margin(4–16)、gap(1–10),并要求 `2*margin+4*gap<=48`。标准排版据此计算标题、图片、正文和分隔线的位置;固定模板保留原构图,用 layers 显式调整位置。
- `sources`:最多100项,每项 assetId、author、license、url、prompt、tool 全部必填,文字最多4000字符。url 是 http(s) 来源地址或 `generated`。用户照片记录来源与授权;生成图记录完整提示词和工具,不把 AI 图写成摄影原作。所有被页面引用的图片必须有来源记录。
- `pages`:本工作流支持3–18页(这是本服务的约束,不宣称是平台实时上限)。每页 key 唯一(小写字母/数字/连字符,最多60位)、role(首项 cover,中间 body,末项 ending)、layoutId、intent(最多1000字符)、bindings、parameters。parameters 继续使用下文封闭的单页参数契约。
- `pages[].bindings`:把实际文字元素 ID 映射到 title/body/caption,例如 `{T01:"title",T02:"body",T03:"caption"}`。被绑定元素必须提供文案。共享字体、墨色和纸色会参与真实渲染;单元素显式值优先覆盖。所有可见文字必须提供或明确隐藏,避免默认占位文案泄漏。图片默认色与装饰默认色取 accent。
标准排版可直接沿用 T01/T02/T03/I01/D01;固定排版先读对应模板 API,元素不能混用。agent 必须控制标题长度和正文密度,不能把“已保存”当成“已验收”。同组图片建议统一比例;导出器遇到混合比例会拒绝,agent 应改用同一画板并调整内容布局。
### 一次保存,一条直接结果链接
```sh
# series.json 包含完整 document,示例文件见仓库 content/studio-examples/autumn-journal.json
curl -fsS -X POST "$BASE/api/studio/series" \
-H "Authorization: Bearer $PROMPT_TOKEN" -H 'Content-Type: application/json' \
--data-binary @series.json
# 返回 {design}:schemaVersion=2、document、id、revision、url、postId 等
curl -fsS -H "Authorization: Bearer $PROMPT_TOKEN" "$BASE/api/studio/series/$ID"
```
创建响应的 `design.url` 仍是 `/studio/template-layout?id={UUID}`;直接展示全部页,无需选模板。`&page=2` 只显示第2页,非法页码返回404。页面是服务端原生文字与 CSS,图片走 /api/preview。创建与全部页验证、Wiki post 处于同一事务,任一页无效整组不保存。PATCH 整体替换 document,revision 冲突返回409;先重新读取并合并。单页 PATCH 接口会拒绝修改多页作品,避免只改封面导致组内不一致。可从 designs 列表发现作品,schemaVersion 区分1与2。
归档 `DELETE /api/studio/series/{id}` body `{revision}`;恢复 `POST` 同一路径 body `{revision,archived:false}`。归档后结果页和导出计划返回404,API 仍能读取并恢复;参数、素材和 Wiki post 保留。更新复用原 Wiki post,手工编辑过的正文不被覆盖。
### 高质量导出与验收
`GET /api/studio/series/{id}/export` 返回固定顺序的导出计划:id、revision、每页 key/role/number、filename、width/height、原生渲染 url。计划本身不生成位图。示例统一为3:4、1080×1440 PNG;其他固定模板保留自己的画板比例,不拉伸。该尺寸是本流程的交付规格;发布前还须在小红书当前发布界面复核裁切及文件限制,本站不自动发布。
导出 URL 带 `&page=N&export=1`,画布在1080 CSS像素下重新布局原生文字,不把860宽或手机截图放大。图片使用 `/api/preview?source=...&width=2160&profile=export`:WebP质量95,不为常规缩略图预算降低尺寸,不放大小原件。普通展示仍使用240/480/960/1280及100KB/220KB预算。
在用户已确认的 **ces-control / aliyun-ces-control** 上运行仓库 `scripts/export-studio-series.cjs`,使用既有测试镜像的 playwright-core、sharp、Chromium 和中文字体。只访问 `http://8.211.154.134:18131`,认证保留进程内。设置 STUDIO_DESIGN_ID、STUDIO_EXPORT_DIR、AUTH_CONFIG_FILE(或由现有登录方式提供的 PROMPT_TOKEN),不在命令行或文件中存令牌。不得在用户本机启动编译、容器构建或预览服务器。
脚本逐页检查字体和图片加载、文字溢出、图片原生分辨率与裁切缩放、相同画板比例、页序、最终PNG像素、导出前后revision一致;记录实际字体、SHA256及manifest。额外生成桌面1200宽和手机390宽整组截图供视觉检查。自动检查通过后,agent 仍须逐页看PNG,核对中文缺字、主体裁切、阅读顺序、留白和系列一致性。检查失败修订 document,重新导出;不能把失败文件当成可发布结果。
最终交付:真实设计结果链接、按01/02/03排序的PNG、完整design.json、manifest.json、素材来源与提示词、简短发布文案。生成图、保存成功、导出成功、人工视觉通过、PR待合并、线上部署验收应分别如实报告。未合并代码的能力不可写成线上已可用。
### 可复现三页示例
仓库 `content/studio-examples/autumn-journal.json` 是完整的三页内容与样式文档:封面“把秋天装进一页”、正文“先看见再记录”、结尾“留一点白给下一次”。它不含虚构素材 UUID,空图片位仅用于契约测试。真实制作时先生成银杏母图,再上传取得assetId,在每页 I01 加入该 UUID,并在 sources 记录完整来源。不要把无图夹具当作已完成作品。
推荐在 ces-control 的仓库目录直接执行 `bash scripts/deliver-studio-series.sh /absolute/path/series.json /absolute/path/ginkgo-master.png /absolute/path/provenance.json /absolute/path/output-directory`:该脚本复用已部署认证卷和匹配的CI镜像,依次保存、导出并留下证据,不启动构建或部署。成品位于 output-directory/revision-N/;已成功导出的修订会核对哈希后复用,后续修订使用新目录,避免覆盖或混入旧页。
底层保存命令 `node /app/auth-cli.cjs run /delivery/scripts/save-studio-series.mjs /absolute/path/series.json /absolute/path/ginkgo-master.png /absolute/path/provenance.json /absolute/path/output-directory` 经既有 auth-cli run 在远程执行,上传素材并填写所有页面引用后,保存真实作品,写出请求、响应和状态文件。重复运行会读取已保存状态,防止在成功后重复创建;出现网络结果不明时先核对素材/设计列表,不盲目重试。脚本仅在传入文件后写入作品,不自动发布到小红书。
## 两类排版,同一内容模型
- **标准排版**:`standard-01`,120×160 单位。沿用原 Studio 图文页的标题、正文、栏目结构,增加主题图片与分隔线。T01 主标题、T02 正文、T03 栏目、I01 图片、D01 分隔线。所有内容和位置可设置,也能增加原生元素。
- **固定排版**:`ref-01` 至 `ref-27`,保留各自画板、出血、圆形、斜切、竖题和字体构图。先读取模板,按实际 T/I/D ID 填入内容。不能假定各模板拥有相同元素。
两类单页都使用 `/api/studio/designs` 的真实 CRUD。创建额外指定 layoutId;更新从 URL 确定 ID,模板关联不能修改。更换模板应创建新记录,避免把旧元素 ID 错套到新模板。调用方无需打开 `/studio` 或操作编辑器。模板由 agent 经 API 选择,图片和文字由 API 保存,用户只打开结果链接。现有独立工具箱功能保持可用。
## 1. 由 agent 通过 API 发现模板
以下 curl 使用指定服务器。`PROMPT_TOKEN` 由用户通过现有登录方式提供,只保存在进程环境,不放 URL 或日志。也可使用已登录浏览器的同源 Cookie;浏览器写请求需要同源 Origin。未登录 API 返回 401。
```sh
BASE=http://8.211.154.134:18131
curl -fsS -H "Authorization: Bearer $PROMPT_TOKEN" "$BASE/studio/api/layouts"
curl -fsS -H "Authorization: Bearer $PROMPT_TOKEN" "$BASE/studio/api/layouts/standard-01"
curl -fsS -H "Authorization: Bearer $PROMPT_TOKEN" "$BASE/studio/api/layouts/ref-17"
```
目录返回 `version, layouts, saveApi, catalogUrl, documentationUrl`。每项包含 `id,name,family,url,api`,family 为 standard/fixed。模板返回尺寸、元素 ID/类型/role/box/direction/text、默认 parameters、workspaceSchema 与页面 URL。`?agent_mode=True` 返回纯文本文档,业务请求移除此参数。
## 2. 生成图片,上传一次,显示压缩衍生图
先让 coding agent 按生成图片文档产出文件;Studio 不暗中调用生成模型。图片可以来自用户素材或生成结果。上传走现有素材 API:
```sh
curl -fsS -H "Authorization: Bearer $PROMPT_TOKEN" \
-F kind=image -F assetOnly=true -F files=@hero.png "$BASE/api/upload"
```
返回 `assets` UUID 数组及对应 `files` 元数据。取 `assets[0]` 作为 assetId。上传不单独发帖;设计创建时才自动关联 Wiki post。不要把 Base64、任意远程 URL、HTML 或 SVG 塞入参数。
原素材保留在素材库,**页面展示只请求压缩 WebP**。以 assetId 引用素材,不必自行再上传缩略图:
```sh
curl -fsS -H "Authorization: Bearer $PROMPT_TOKEN" \
"$BASE/api/preview?source=%2Fapi%2Fmedia%2FASSET_UUID&width=960" -o hero-preview.webp
```
width 只允许 240/480/960/1280;小图预算 100KB,大图预算 220KB,复杂图片会降低尺寸/质量。原生页面按槽位显示宽度和像素密度选择 srcset 衍生图,保留 fit/cropX/cropY/zoom。目录使用原生文字与 CSS 色块,不加载参考原图;旧工具箱参考图也使用压缩预览。`/api/media/{id}` 是素材原件读取接口,不应用作展示图片的 src。
## 3. 创建:POST /api/studio/designs
创建和更新共用内容字段 `name,parameters`;`schemaVersion` 可省略,单页当前支持 1(多页使用上述独立 series API 的2)。创建额外传 `layoutId`,不传 revision 或 id。
```sh
curl -fsS -X POST "$BASE/api/studio/designs" \
-H "Authorization: Bearer $PROMPT_TOKEN" -H 'Content-Type: application/json' \
-d '{"schemaVersion":1,"layoutId":"standard-01","name":"观察日常","parameters":{"paper":"#edf0ef","texts":{"T01":{"value":"观察日常\n记录灵感","fontFamily":"serif","color":"#23424b"},"T02":{"value":"让每一页只讲一件事。","lineHeight":1.4}},"images":{"I01":{"color":"#73929a","fit":"cover","cropX":50,"cropY":45,"zoom":1}},"decorations":{"D01":{"color":"#73929a"}}}}'
```
有图片时在 I01 加 `assetId:"上传返回的UUID"`。固定排版只需改为所选 `layoutId`,并按 schema 选择元素 ID。例如 ref-01 支持 `texts.T01`、`texts.T02` 与 `images.I01`。
201 返回 `{design}`。design 包含:
| 字段 | 含义 |
| --- | --- |
| id / layoutId / family | 持久化 UUID、不可变模板 ID、standard 或 fixed |
| schemaVersion / revision | 参数契约版本(1)、记录修订号(从1递增) |
| name / parameters | 名称与规范化内容;省略的分组返回空对象 |
| url / renderUrl | 同一个原生 HTML/CSS 页面 URL |
| schemaUrl | 当前模板参数地址 |
| postId / postUrl | 自动创建并在后续更新中复用的 Wiki post |
| archived / archivedAt | 可恢复归档状态与时间 |
| createdAt / updatedAt | 创建与最后修改时间 |
| legacySvgUrl | 固定排版的旧 SVG 导出地址,标准排版为 null |
创建设计与 Wiki post 在同一数据库事务内完成,失败整体回滚。更新同一设计复用同一 post;手工编辑过的 Wiki 正文、标题、标签、封面不会被参数更新覆盖。
## 4. 读取与打开
```sh
ID=创建返回的UUID
curl -fsS -H "Authorization: Bearer $PROMPT_TOKEN" "$BASE/api/studio/designs/$ID"
curl -fsS -H "Authorization: Bearer $PROMPT_TOKEN" "$BASE/api/studio/designs?layoutId=standard-01&limit=20"
```
读取返回 `{design}`,没有副作用。列表默认只列未归档记录;`archived=true` 只列归档记录;limit 为1–50,默认20;`nextCursor` 作为下一页 before。layoutId 可省略。
直接打开创建响应中的 `design.url`:`/studio/template-layout?id={UUID}`。这个 UUID 对应数据库里 API 保存的模板、图片与文字,服务端按记录渲染最终 HTML/CSS。链接中无需携带 layoutId,无需打开 `/studio` 选择模板,也没有编辑按钮、双击编辑或右键编辑。`standard-01` 与 `ref-17` 是模板 ID,不是保存记录的 UUID。
`/studio/designs/{UUID}` 与旧 `/studio/layouts/{layoutId}?id={UUID}` 兼容跳转到同一个结果页。缺失、非法、不存在或已归档的 ID 返回404,不会显示空模板或编辑器;旧链接模板与记录不匹配也返回404。归档设计仍能经 API 读取以恢复。
## 5. 更新:PATCH /api/studio/designs/{id}
先 GET,保留所有未改字段,再整体替换 parameters;不是深度合并。以下假设刚读到 revision=1:
```sh
curl -fsS -X PATCH "$BASE/api/studio/designs/$ID" \
-H "Authorization: Bearer $PROMPT_TOKEN" -H 'Content-Type: application/json' \
-d '{"name":"观察日常 · 修订","revision":1,"parameters":{"texts":{"T01":{"value":"新的标题"}},"images":{},"decorations":{}}}'
```
200 返回新 design,revision 增加。过期 revision 返回409,重新 GET 并合并后再提交;不要盲目重试。不接受 layoutId、id、url、postId 等返回字段;不能直接把整个 design 作为 body。另存副本使用 POST 创建新 UUID。
## 6. 删除与恢复
DELETE 采用可恢复归档,不物理删除参数、素材或 Wiki post。和更新一样必须提供最新 revision;自动同步关联 post 的归档状态。
```sh
curl -fsS -X DELETE "$BASE/api/studio/designs/$ID" \
-H "Authorization: Bearer $PROMPT_TOKEN" -H 'Content-Type: application/json' -d '{"revision":2}'
# 删除后读取新 revision,再恢复。此例删除后为3:
curl -fsS -X POST "$BASE/api/studio/designs/$ID" \
-H "Authorization: Bearer $PROMPT_TOKEN" -H 'Content-Type: application/json' -d '{"revision":3,"archived":false}'
```
两者200返回 `{design}`,revision 递增。归档记录 PATCH 返回409,先恢复。重复写入仍需最新 revision;409不会修改数据。独立归档 Wiki post 只影响笔记发现状态,不等于删除设计。旧设计缺 Wiki 入口可调用 `POST /api/studio/designs/{id}/post`,body `{}`,首次201、已有200;不改变 revision。
## 参数清单(两类共用)
parameters 顶层允许 texts/images/decorations/paper/layers/added;全部可省略,不能添加未知字段。最大请求64KB。颜色都为 `#RRGGBB`。坐标与字号使用模板单位,不是 CSS 像素。
| 分组 | 字段与约束 |
| --- | --- |
| texts.{文字ID} | value 必填,可空,最多500字/8行;color;scale 0.25–2;align start/middle/end;fontFamily sans/serif/mono;fontSize 0.1–1000;fontWeight 100–900;lineHeight 0.5–4;letterSpacing -20–100 |
| images.{图片ID} | assetId(已存在的图片 UUID);color;fit cover/contain;cropX/cropY 0–100(默认50);zoom 1–5(默认1) |
| decorations.{装饰ID} | color;hidden 布尔值 |
| paper | 纸张颜色 |
| 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 布尔值 |
| added | 最多100项:id 为 user- 加1–60位字母/数字/连字符,唯一;kind text/image/decoration;role 最多80字;box 为 [x,y,width,height];direction 必须 horizontal;text 为文字或null;reading 必须 custom;装饰可带 shape rectangle/ellipse |
新增元素先在 added 声明,然后在 texts/images/decorations/layers 用该 ID 配置。locked 控制工作区交互,不禁止 agent 更新。数据是封闭结构,无脚本执行。未来扩展新增契约版本;v1 不接受未知字段/版本。现有省略 schemaVersion 的请求继续按v1处理,旧记录无需重建,布局 ID 和页面 UUID 保持不变。默认视觉可随模板美化变化;要求固定外观时显式设置配色、字体和图层参数。
错误:400 参数/版本/构件类型/图片 ID 不合法;401 未登录;404 模板或设计不存在;409 修订冲突或已归档;405 方法不支持;413 请求超限;500 服务失败。压缩预览失败可能返回503与 Retry-After。
## 现有工具箱与兼容导出
工具功能保留:[ref-17 同一母版四张试版](/studio/ideas/learning-skills-template)、[ref-01/07/26 三种构图试版](/studio/ideas/layout-selection)、[最初图文页](/studio/ideas/first-idea)。旧封面工具文档已合并到本篇,旧文档 URL 保留为归档说明,避免重复顶级入口。
旧无状态 API:`/studio/api/export/learning-skills-template` 和 `/studio/api/export/layout-selection` 支持 GET/POST,不保存设计。前者参数 slide=0..3、heading(1–6字)、body(最多360字/3行)、color/accent、masthead(2项);后者 layoutId=ref-01/ref-07/ref-26、fields(对应T ID,1–120字/4行)、color/accent。GET 默认返回方案 JSON;GET format=svg 导出初始图;POST format=svg(默认)导出 SVG,format=json 返回参数,最大32KB。first-idea 仅支持 GET 方案/Markdown。
```sh
curl -fsS -X POST "$BASE/studio/api/export/learning-skills-template?format=svg" \
-H "Authorization: Bearer $PROMPT_TOKEN" -H 'Content-Type: application/json' \
-d '{"slide":1,"heading":"笔记复习","color":"#7a8b7e"}' -o cover.svg
curl -fsS -X POST "$BASE/studio/api/export/layout-selection?format=svg" \
-H "Authorization: Bearer $PROMPT_TOKEN" -H 'Content-Type: application/json' \
-d '{"layoutId":"ref-01","fields":{"T01":"STUDY"},"color":"#83918a"}' -o selection.svg
```
固定排版 `/studio/api/layouts/ref-01` 的 GET format=svg、POST 仍提供旧分层 SVG;POST format=json 只验证和返回规范化参数。**旧 SVG 不反映 added、layers、扩展字体和裁切参数**,不能用来证明原生效果。standard-01 不支持 SVG。原生页面应直接打开返回 URL,;多页可按上文通过导出器生成PNG。代码可扩展渲染能力,但每次是否使用、选择哪些参数均由 coding agent 决定。
## 固定排版索引
{{LAYOUTS}}
+129
View File
@@ -0,0 +1,129 @@
这篇 post 是两件 Studio 封面工具的使用入口与 API 教程。工具负责可编辑预览和渲染;PROMPT 保存说明、图片、来源链接与调用示例。当前只做模板与主题占位,不填入具体 Skills 合集。
## 打开工具
- [同一母版/四张试版](/studio/ideas/learning-skills-template):一张空白母版,加笔记复习、教学资料、英语学习三张占位封面。四张共用 ref-17 九个构件的坐标;可统一改英文刊头,分别改中文标题、简介和配色。
- [三种构图/模板选择工具](/studio/ideas/layout-selection):切换 ref-01、ref-07、ref-26,每种构图独立编辑文字、主图色、副图色。切换模板保留当前页内各模板的修改;刷新恢复初始值。
Studio 没有独立目录首页;访问 /studio 会回到 PROMPT。请从本文的具体工具链接进入。页面上的下载可以保存 SVG、参数 JSON;工具不自动把试版发布为 Wiki post。
## 先看图,再选模板
{{GALLERY}}
## 构件对应与来源
| 工具 | 模板 | 构图 | 构件数 |
| --- | --- | --- | --- |
| 同一母版四张试版 | [ref-17](/explore/7b1b2fc5b9690213bbbe07ac) | 两行英文刊头、圆形叠标、左下主图、右侧窄栏 | 9 |
| 三种构图 | [ref-01](/explore/eb937b42be92a9f6feee684f) | 双矩形错位、品牌字标、左下说明 | 6 |
| 三种构图 | [ref-07](/explore/7d4cefdfada28bbe9e570af3) | 同心圆主图、环形文字、底部年份与说明 | 7 |
| 三种构图 | {{REF26}} | 三行英文、右侧竖排标题、曲线与小图 | 11 |
完整来源:[27 张原图 Router](/explore/8eb8c76500580f7a4b56ba3c)。各原图 post 内有文本、图片、原画板与 T/I/D 坐标,阅读纯文本可加 ?agent_mode=True。构件必须带上完整键,例如 ref-01/T01;不同模板不能混用同名构件。
T 是可编辑文字,I 是图片位或色块,D 是装饰。ref-01 的品牌字标只有一个 T01,左下说明为 T02,没有重复 T03。工具保持每张原图的画板比例。ref-17 为重新绘制的文字、色块与线条;另外三种模板将图位和装饰按原图构件重建为干净矢量,文字重新排版;原始蒙版和装饰 PNG 保留在 JSON 中供核对。它们是来源约束下的试版,替换内容后的字形与色调不会与原图逐像素相同。原图分层含旧字镂空和低分辨率边缘,不能直接当成可编辑模板的最终图层使用;工具中的几何重建保留原位置与构图,但不承诺复原未知的原始矢量控制点。
## API:读取模板与原始试版
接口统一使用 PROMPT 的同一域名。所有接口说明先读:
- [Studio API 目录](/studio/api)
- [封面工具 API 纯文本说明](/studio/api/export/learning-skills-template?agent_mode=True)
```bash
BASE='http://8.211.154.134:18131'
# version:2,四张试版、共享模板、原始几何和来源链接
curl "$BASE/studio/api/export/learning-skills-template"
# 第 2 张初始封面,slide 从 0 开始
curl "$BASE/studio/api/export/learning-skills-template?format=svg&slide=1" -o notes.svg
# version:3,三种模板的 slots、fields、内嵌图位蒙版与装饰
curl "$BASE/studio/api/export/layout-selection"
# 指定原图模板
curl "$BASE/studio/api/export/layout-selection?format=svg&layoutId=ref-07" -o circle.svg
```
GET 省略 format 返回 JSON;format=markdown 返回文本资料;format=svg 返回初始封面。JSON 是 AI 可读取的几何与文字数据,SVG 是可独立打开的图像。
## API:提交自己的参数生成 SVG
POST 是无状态渲染,不修改 Wiki 或工具初始数据。Content-Type 为 application/json;format=svg 为默认值,format=json 返回经过校验的参数与模板。请求最大 32KB,返回的正式 SVG 不带构件辅助线。
```bash
curl -X POST "$BASE/studio/api/export/learning-skills-template?format=svg" \
-H 'Content-Type: application/json' \
--data '{"slide":1,"heading":"笔记复习","body":"课程 · 笔记\n复习主题占位\n正文稍后填入","color":"#7a8b7e","accent":"#d5ddcf","masthead":["STUDY","Skills"]}' \
-o notes-custom.svg
curl -X POST "$BASE/studio/api/export/layout-selection?format=svg" \
-H 'Content-Type: application/json' \
--data '{"layoutId":"ref-01","fields":{"T01":"STUDY","T02":"笔记复习\n主题说明占位\n正文稍后填入"},"color":"#83918a","accent":"#c3cec5"}' \
-o ref-01-custom.svg
```
四张试版参数:slide 为 0–3 的整数;heading 为 1–6 字;body 最多 360 字、3 行;masthead 为两行英文,每行 1–8 个英文字母或空格。多模板参数:layoutId 必填;fields 只能使用目标模板真实存在的 T 编号,每项 1–120 字、最多 4 行;未提供的字段保留默认值。颜色必须是 #RRGGBB。未知模板、构件 ID、参数、超长文字或无效颜色返回 400;不存在的工具返回 404;不支持的方法返回 405;请求过大返回 413。
## HTML / CSS 使用
只需展示初始封面时,直接把 SVG 接口作为图像,无需部署新页面或复制图层:
```html
<figure class="cover">
<img src="http://8.211.154.134:18131/studio/api/export/learning-skills-template?format=svg&slide=1" alt="笔记复习占位封面">
</figure>
<style>
.cover { width: min(100%, 452px); margin: 0; }
.cover img { display: block; width: 100%; height: auto; }
</style>
```
## React 使用
```tsx
export function TemplateCover({ slide = 1 }: { slide?: number }) {
return <img
src={`http://8.211.154.134:18131/studio/api/export/learning-skills-template?format=svg&slide=${slide}`}
alt="同一母版的合集封面"
style={{ width: '100%', height: 'auto', display: 'block' }}
/>;
}
```
自定义渲染:通过你的服务端调用 POST,保存返回的 SVG 或将其作为图像响应交给浏览器。接口没有开放任意跨站 JavaScript 的 CORS;跨域浏览器可以用 img 展示 GET 图片,POST 则从你自己的后端调用。
## Next.js 使用
```ts
// app/api/cover/route.ts:为自己应用生成 SVG,不把远端 SVG 插入 innerHTML。
export async function GET() {
const upstream = await fetch(
'http://8.211.154.134:18131/studio/api/export/layout-selection?format=svg',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
layoutId: 'ref-26',
fields: { T01: 'Study 01', T02: 'Learn', T03: 'Create', T04: '学习合集' },
color: '#7c8e9b'
}),
cache: 'no-store'
}
);
if (!upstream.ok) return new Response('封面生成失败', { status: 502 });
return new Response(await upstream.arrayBuffer(), {
headers: { 'Content-Type': 'image/svg+xml; charset=utf-8' }
});
}
// 页面中使用 <img src="/api/cover" alt="学习合集封面" />。
```
如果自己的站点使用 HTTPS,应通过同源后端代理此 HTTP 服务,避免浏览器混合内容限制。
## 试版验收
1. 四张同母版封面的 ref-17 构件数、坐标、图层次序完全相同;只替换文字与颜色。
2. 多模板工具的 ref-01、ref-07、ref-26 分别有 6、7、11 个构件,不能偷换构图。
3. T 文本可编辑,I 图片位是占位色块,D 装饰不冒充文字;原图未辨识的微字不猜写为“原文”。
4. 浏览器预览与 API 使用同一渲染组件。修改后不溢出;环形、竖排、两矩形错位保持原位置。
5. SVG 独立打开无缺图;API 不需要先访问 Studio 首页;教程可作为普通 Wiki post 的纯文本读取。
6. 本阶段只有模板和主题占位,没有自动生成或发布三篇具体 Skills 合集。
+16
View File
@@ -0,0 +1,16 @@
# 业务 API 说明
业务 API 目录:`/api`(JSON)或 `/api?agent_mode=True`(纯文本)。
每个业务接口都使用原路径加 `agent_mode=True` 返回 `text/plain; charset=utf-8` 的接口说明,包含用途、方法、参数、响应、调用示例和错误。查询值不区分大小写;已有查询参数时使用 `&agent_mode=True`。使用 GET 或 HEAD 读取说明,HEAD 不含正文;说明不查询数据库或对象存储,不要求动态 ID 实际存在。
例如:
- `/api/upload?agent_mode=True`
- `/api/notes/{id}?agent_mode=True`
- `/api/preview?source=%2Fexample.png&agent_mode=True`
- `/studio/api/export/{slug}?agent_mode=True`
业务调用时移除该参数。POST、PUT 等写入方法保持原有行为,不会因该参数变成文档请求。页面正文和 Markdown 兼容接口继续使用原有规则。
Wiki 文档注册表位于 `apps/waterfall-wiki/src/lib/api-documentation.ts`,由各业务路由在 handler 开头调用;目录从同一注册表生成。Studio 文档位于 `apps/studio/src/lib/api-documentation.ts`,随创作站独立部署。
+21
View File
@@ -0,0 +1,21 @@
# 构建与入口
人类默认从 `/` 进入;对应 Markdown 根入口为 `/index.md`,指导文档目录为 `/skills` 与 `/skills/index.md`。每篇网页地址后加 `/index.md` 即为同内容的 Markdown,允许跨分组引用。`/agent`、`/agent/index.md` 和旧技能文档 URL 兼容保留;`/llms.txt` 提供发现链接。详见 `docs/guidance.md`。
`ces-control` 必须预装 Docker、Compose,并允许 Runner 访问持久 Docker daemon。CI 只检查环境,不安装系统包、不启停主机服务。当前服务器没有 buildx,先继续使用 daemon 自带层缓存;不要直接粘贴依赖 GitHub cache 服务的 `type=gha` 参数。后续若换 Docker daemon 或清理缓存,会重新冷构建。
Dockerfile 保持单次 COPY,`.dockerignore` 排除非构建输入;根 Markdown 和 docs 不再使应用构建层失效。只有 main 的 push 事件触发 CI/CD(对应 PR 合并后的主线更新),工作分支 push、PR 创建/更新和手动 dispatch 均不触发;三个应用均有同样的作业级守卫。main 对纯文档改动使用 paths,但 public 下的 Markdown 属于产品资源,必须构建部署。public 下的技能 Markdown 继续纳入构建。旧 builder 每条 COPY 实测约 47 秒,避免拆成多条 COPY 增加快照成本。完整路径保留类型检查、单元测试、smoke 与完整隔离栈集成测试。
当前页面为 Next.js SSR 与编译组件,改 TSX 仍需 next build。Web 快速路径只编译生产 Web,并以不可变的版本目录挂载完整 standalone 产物,保持版本与回滚一致;不重新编译 worker 或导出运行镜像。
## Web 快速发布
main 的 push 与当前已部署 SHA 比较;只有页面、组件、样式、技能文档数据和 public 改动才走 Web 路径,API、共享库、依赖、配置、worker、脚本及数据库变化均回退完整流水线。PR 阶段只做不会触发编译的阅读、差异审查与轻量配置检查,完整远程验证在用户手动合并到 main 后执行;依赖构建镜像缺失也回退完整流水线。
Web 路径复用当前 runtime 对应的已验证 integration 镜像作为工具环境,在临时容器构建生产 Web,不运行 docker build。编译缓存按基础镜像隔离持久保存。类型检查、单元测试和 Web smoke 仍同步执行,完整隔离栈测试只在全量路径执行。
完整 standalone 产物存入 `/opt/prompt/web-artifacts/<sha>.<run>`,只读挂载到 Web 容器;仅重建 Web,保留 runtime 镜像、worker、Postgres 和 RustFS。切换前检查 main 与当前发布,部署锁与全量流程共用;失败自动恢复旧 Web Compose 配置。发布文件保留变量表达式,不写入生产秘密。健康检查通过后切换 current;原完整发布也能回滚到 Web 快速发布。
首次接入流水线变更仍走完整验证。2026-10-23 workflow 291 实测:Web 构建与发布91秒,拉取代码44秒,合计2分15秒,不包含共享 Runner 排队。运行镜像复用,worker、Postgres、RustFS 容器 ID 均不变;编译缓存已持久化。不能承诺 HMR 的毫秒级反馈。每次发布完整 Web 产物,不把个别 chunk 混入正在运行的 Next.js 版本。
工作流变更不会自动取消旧配置已创建的运行。历史 PR 排队任务须单独取消,不能将其当作新触发规则仍然生效的证据。
+33
View File
@@ -0,0 +1,33 @@
# 数据模型与网址约定
唯一具有树形结构的是 `tags`。素材、素材包和对象存储路径都不组成目录树。
```mermaid
erDiagram
posts ||--o{ post_assets : references
assets ||--o{ post_assets : reused_by
posts ||--o{ post_tags : classified_by
tags ||--o{ post_tags : groups
assets ||--o{ asset_tags : classified_by
tags ||--o{ asset_tags : groups
tags o|--o{ tags : single_parent
assets ||--o{ asset_variants : owns
assets ||--o| media_jobs : processed_by
```
- `assets`:每个图片、视频、独立音频或文字片段有稳定 UUID。二进制引用 S3 key;文字保存在 body。宽高、时长、原件是否有声音、处理状态记录在素材上。SHA256 用于识别内容,但不会把不同导入擅自合并。同一素材可以被任意多个素材包引用。
- `asset_variants`:视频衍生物,以 `(asset_id, role)` 唯一。无声画面、原视频音轨和封面始终挂在原视频下。没有 `audio` 类型的新资产,因此音轨不会混入独立音频素材。
- `posts`:展示单位,稳定 slug、类型、创建时间、标题等元数据。`cover_asset_id` 是图片指针;`page_key` 只引用代码注册的图文或动态组件,不保存可执行代码。正文和描述不是 post 上的重复字段。
- `post_assets`:记录素材 ID、展示顺序及 content/caption 角色。删除素材包只移除关联,不删除素材;有引用的素材不能直接删除。
- `tags`:相邻表结构,每个节点仅一个 parent;数据库约束拒绝自引用和环,同层 slug 唯一,包括根节点。新增、移动时用事务锁串行化树修改。
- `asset_tags`、`post_tags`:两套多对多关联。导入时可同时给素材和素材包分类,之后互相独立。修改素材分类不会悄悄改动所有引用它的素材包。
- `media_jobs`:数据库任务队列,支持并发安全领取、崩溃租约恢复、三次重试。状态变化不会创建新的素材包。
- `contribution_events`:以细分事件作为事实记录,展示时按日聚合。subject ID 不强制外键,历史事件不因对象删除而消失。唯一幂等键用于将来防止重复计数。当前不配置任何自动触发器。
分类网址是“内容类型入口 + 从根到叶的 slug 链”。例如标签树 `style → white-line` 可以同时用于 `/images/style/white-line` 和 `/videos/style/white-line`。同一个标签跨不同内容类型共享其身份,内容类型入口只负责过滤素材包类型。父标签筛选包含后代标签;结果按 post 去重。
素材包详情始终使用 `/p/<slug>`,更换分类不改变它的网址。同一份二进制在多个素材包中仍只有一个 asset ID 和对象引用。
媒体包必须有对应类型的非空文件引用才进入瀑布流;图文和动态作品必须有已注册的组件。只有标题、空关联或未知组件的对象都被过滤。不存在的分类网址返回 404,不退回全部内容。
迁移保存在 `db/` 并在 `schema_migrations` 记录;迁移过程加会话锁。不能通过应用回退删除数据库或对象卷。
+23
View File
@@ -0,0 +1,23 @@
# Docker 构建上下文与缓存
## 入口与忽略规则
所有 CI 构建命令都在仓库根目录使用 `.` 作为 context,包括以 `-f` 选择的子项目 Dockerfile。统一使用根 `.dockerignore`,递归排除依赖目录、Git 元数据、本机环境文件、编译输出、测试证据及缓存;保留 `.env.example` 供 Compose 校验使用。
普通的子目录 `.dockerignore` 不会自动叠加到根 context。当前 runner 使用 legacy builder,不依赖 Dockerfile 专属忽略文件或 BuildKit cache mount。若以后改用子目录 context,必须同时调整共享包与跨应用文件的输入路径,不能只在子目录添加忽略文件。
## 依赖层与源码层
- Wiki 的根 Dockerfile 是实际 CI 入口,`apps/waterfall-wiki/Dockerfile` 是对应副本,两者的功能内容保持同步;根文件额外保留镜像生命周期标签。
- 统一 Web 应用先根据锁文件与 workspace 配置执行 `pnpm fetch`,再复制清单并 `pnpm install --offline --frozen-lockfile`。修改 scripts 或品牌源码不会使下载层失效;基础镜像、pnpm 版本、平台或配置变化,以及 Docker 缓存清除仍可能要求重新下载。
- Python 从 `pyproject.toml` 读取固定运行依赖与构建后端要求,在独立依赖阶段安装。源码阶段安装当前包并运行单测,最终镜像仅继承系统运行环境并复制已验证的虚拟环境,不携带应用源码目录和测试目录。虚拟环境仍包含 pip 和构建后端,未声称完全剥离构建工具。
- 品牌包只作为 workspace 依赖随 Web 应用构建,不提供独立 Dockerfile、镜像构建工作流或常驻服务。专用品牌检查已停止自动运行,保留测试脚本供明确需要时在远程执行。
- 不因共享包缓存命中而漏掉消费者:品牌变更需触发 统一应用 PR 检查和 main 发布。根忽略规则变化也触发相关检查。
## 验证与限制
构建、打包与产物验证只在已确认的 `ces-control` runner 执行。Web 与 Python 的工作分支 push/PR 仅运行轻量静态检查;类型检查、单元测试、编译、镜像构建和部署都只在 main push 执行。不再单独构建品牌检查镜像。
本次没有升级 Docker builder,也没有承诺消除 legacy builder 的镜像层提交耗时;缓存层调整后的实际收益需比较后续 CI 日志。源码变化仍需构建,当前采用持久测试基础设施;本次应用合并仍保留完整集成验证,按影响范围选择测试留作后续优化。
本机不新增 Dev Containers/Tilt 启动方式;这类方案需要单独确认本地运行和编译授权。
+46
View File
@@ -0,0 +1,46 @@
# 动态码登录与 SSH 绑定
生产全站使用共享验证器的 6 位动态码登录,无账号密码。它是单因素 TOTP 登录,不是两个因素的 2FA。每 30 秒更新,允许前后一个时间步偏差。登录后签发 HS256 JWT,保存在 HttpOnly、SameSite=Lax Cookie 中,有效期 7 天,不放 localStorage。
## 管理员扫码
SSH 登录部署服务器,在交互终端执行(不要放到 CI、日志或重定向到文件):
```sh
sudo /opt/prompt/auth qr
```
手机用 Google Authenticator、Microsoft Authenticator 或其他 TOTP 验证器扫描终端二维码。多人可以分别扫同一张二维码;动态码相同,不能区分操作人。不要把二维码发到公开聊天或网页。二维码不通过任何 HTTP 接口提供,也不写入源码或镜像;服务器 Docker 卷 `prompt_auth_data` 持久保存密钥,应用只读挂载,配置文件权限 0600、目录 0700。更新和容器重启不会更换二维码。
也可以从电脑直接执行(SSH 用户按自己的服务器配置替换):
```sh
ssh -t <服务器用户>@8.211.154.134 'sudo /opt/prompt/auth qr'
```
网站只需输入当前动态码。验证码成功使用后不能再次使用;多人同时登录时,后登录者需等下一组动态码。全站共用每 5 分钟最多 20 次尝试的限额,计数与已用时间步保存在 PostgreSQL 中,容器重启不会重置。
## 丢失手机、撤销访问或密钥泄漏
```sh
sudo /opt/prompt/auth rotate --confirm
sudo /opt/prompt/auth qr
```
轮换会同时使所有人的旧验证器及现有 JWT 失效,需要所有人重新扫码。恢复权限依赖服务器 SSH 管理权,没有公开重置入口。退出登录清除本机 Cookie;已复制的 JWT 在过期或密钥轮换前仍有效。
## 自动化和健康检查
除登录页、登录/退出接口、Next.js 代码资源及最小健康检查外,页面、数据接口、媒体、文档及 `/studio` 均要求登录。Studio 直连 18134 端口只绑定回环地址,不能绕过公网入口登录。登录 Cookie 的写入操作校验同源;API 可使用 `Authorization: Bearer <JWT>`。
服务器部署检查通过 `node auth-cli.cjs run <脚本路径>` 在进程内给本机请求加入短期 JWT,不打印令牌。人工自动化可通过 `sudo /opt/prompt/auth token` 在交互终端获取有效 1 小时的令牌,权限等同共享登录,请妥善保存。外部客户端需要自行携带令牌;已有匿名下载/发布脚本需要相应调整。
## HTTP 与 HTTPS
当前入口是 HTTP,为兼容现有地址,首次部署在 `/etc/prompt.env` 添加 `AUTH_COOKIE_SECURE=false`。这只能防止未授权访问,HTTP 仍可能被窃听。启用 HTTPS 后将其改为 `true` 并重新创建 web 容器,使 Cookie 带 Secure。应用默认 Secure;只有明确设置 false 才兼容 HTTP。代理环境必须保留真实 Host 和 Origin。
## CI、部署与回退
Docker CI 先完成原有集成测试,再启用独立测试密钥验证未登录拦截、真实动态码登录、并发重放拒绝、JWT/Cookie、跨站拒绝、限速以及重启后的访问保护。二维码不会输出到 CI 日志。生产部署自动初始化密钥、运行数据库迁移并验证访问保护;失败沿用部署脚本的镜像和数据库备份回退。认证配置损坏或丢失时返回 503,不会开放访问。
手动回退使用 `git revert <本次提交>` 后推送,等待 CI 部署。007 迁移仅新增认证计数表,不修改既有笔记与素材。`prompt_auth_data` 不应删除;回退旧版本将恢复原来的匿名访问行为。
Binary file not shown.

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

+55
View File
@@ -0,0 +1,55 @@
# 本阶段逐文件变更说明
- `Dockerfile`:分离依赖下载与离线安装层,保留跨源码改动的下载缓存。
- `apps/studio/Dockerfile`:配置 /studio 访问前缀,并同步创作站页面、导出与 AI 索引链接。
- `apps/studio/next.config.ts`:配置 /studio 访问前缀,并同步创作站页面、导出与 AI 索引链接。
- `apps/studio/src/pages/api/index.ts`:配置 /studio 访问前缀,并同步创作站页面、导出与 AI 索引链接。
- `apps/studio/src/pages/ideas/[slug].tsx`:配置 /studio 访问前缀,并同步创作站页面、导出与 AI 索引链接。
- `apps/studio/src/pages/index.tsx`:配置 /studio 访问前缀,并同步创作站页面、导出与 AI 索引链接。
- `apps/waterfall-wiki/Dockerfile`:分离依赖下载与离线安装层,保留跨源码改动的下载缓存。
- `apps/waterfall-wiki/next.config.ts`:更新样式或生产烟测以支持统一详情和轻量展示。
- `apps/waterfall-wiki/package.json`:更新样式或生产烟测以支持统一详情和轻量展示。
- `apps/waterfall-wiki/scripts/smoke.mjs`:更新样式或生产烟测以支持统一详情和轻量展示。
- `apps/waterfall-wiki/src/components/ArticleGallery.tsx`:更新内容展示、压缩预览、首页分类导航或左右分栏详情交互。
- `apps/waterfall-wiki/src/components/Feed.tsx`:更新内容展示、压缩预览、首页分类导航或左右分栏详情交互。
- `apps/waterfall-wiki/src/components/Shell.tsx`:更新内容展示、压缩预览、首页分类导航或左右分栏详情交互。
- `apps/waterfall-wiki/src/components/SkillCard.tsx`:更新内容展示、压缩预览、首页分类导航或左右分栏详情交互。
- `apps/waterfall-wiki/src/components/SkillDocs.tsx`:更新内容展示、压缩预览、首页分类导航或左右分栏详情交互。
- `apps/waterfall-wiki/src/content/skills/guidance.ts`:维护持久内容 ID、统一地址与文档引用。
- `apps/waterfall-wiki/src/lib/library.ts`:实现内容统一读取或安全、限流、缓存的图片压缩。
- `apps/waterfall-wiki/src/pages/api/wiki-index.ts`:新增或更新统一 ID 页面、纯文本模式、分类、旧地址跳转或轻量预览 API。
- `apps/waterfall-wiki/src/pages/index.tsx`:新增或更新统一 ID 页面、纯文本模式、分类、旧地址跳转或轻量预览 API。
- `apps/waterfall-wiki/src/pages/p/[slug].tsx`:新增或更新统一 ID 页面、纯文本模式、分类、旧地址跳转或轻量预览 API。
- `apps/waterfall-wiki/src/pages/skills/[[...path]].tsx`:新增或更新统一 ID 页面、纯文本模式、分类、旧地址跳转或轻量预览 API。
- `apps/waterfall-wiki/src/pages/tools/skills/[skill]/[[...doc]].tsx`:新增或更新统一 ID 页面、纯文本模式、分类、旧地址跳转或轻量预览 API。
- `apps/waterfall-wiki/src/pages/tools/skills/street-people.tsx`:新增或更新统一 ID 页面、纯文本模式、分类、旧地址跳转或轻量预览 API。
- `apps/waterfall-wiki/src/pages/tools/skills/street-people/[doc].tsx`:新增或更新统一 ID 页面、纯文本模式、分类、旧地址跳转或轻量预览 API。
- `apps/waterfall-wiki/src/pages/wiki/[key].tsx`:新增或更新统一 ID 页面、纯文本模式、分类、旧地址跳转或轻量预览 API。
- `apps/waterfall-wiki/src/styles/globals.css`:更新样式或生产烟测以支持统一详情和轻量展示。
- `apps/waterfall-wiki/tests/integration.test.ts`:验证稳定 ID、纯文本响应、图片体积与缓存、旧文档引用及集成行为。
- `apps/waterfall-wiki/tests/skill-docs.test.ts`:验证稳定 ID、纯文本响应、图片体积与缓存、旧文档引用及集成行为。
- `docs/monorepo.md`:记录新入口、兼容关系、图片预览和构建缓存行为。
- `pnpm-workspace.yaml`:关闭运行命令前的自动依赖重装,使用显式安装与冻结锁文件。
- `scripts/deploy-studio.sh`:连接 Wiki Docker 网络、验证新路径并按 Studio 相关改动判断过期发布。
- `apps/waterfall-wiki/src/components/ExploreDetail.tsx`:更新内容展示、压缩预览、首页分类导航或左右分栏详情交互。
- `apps/waterfall-wiki/src/components/ExploreFeed.tsx`:更新内容展示、压缩预览、首页分类导航或左右分栏详情交互。
- `apps/waterfall-wiki/src/content/page-ids.json`:维护持久内容 ID、统一地址与文档引用。
- `apps/waterfall-wiki/src/content/page-links.ts`:维护持久内容 ID、统一地址与文档引用。
- `apps/waterfall-wiki/src/lib/explore.ts`:实现内容统一读取或安全、限流、缓存的图片压缩。
- `apps/waterfall-wiki/src/lib/preview.ts`:实现内容统一读取或安全、限流、缓存的图片压缩。
- `apps/waterfall-wiki/src/pages/api/explore-feed.ts`:新增或更新统一 ID 页面、纯文本模式、分类、旧地址跳转或轻量预览 API。
- `apps/waterfall-wiki/src/pages/api/preview.ts`:新增或更新统一 ID 页面、纯文本模式、分类、旧地址跳转或轻量预览 API。
- `apps/waterfall-wiki/src/pages/categories.tsx`:新增或更新统一 ID 页面、纯文本模式、分类、旧地址跳转或轻量预览 API。
- `apps/waterfall-wiki/src/pages/explore/`:新增或更新统一 ID 页面、纯文本模式、分类、旧地址跳转或轻量预览 API。
- `apps/waterfall-wiki/tests/explore.test.ts`:验证稳定 ID、纯文本响应、图片体积与缓存、旧文档引用及集成行为。
- `apps/waterfall-wiki/tests/preview.test.ts`:验证稳定 ID、纯文本响应、图片体积与缓存、旧文档引用及集成行为。
- `apps/studio/src/pages/api/health.ts`:新增明确的健康检查接口,避免把 API 目录的 index 文件错误理解为 /api/index 路由。
- `scripts/deploy-studio.sh`:健康检查修正为 `/studio/api/health`。
- `apps/studio/src/pages/index.tsx`:AI 索引入口修正为实际 `/studio/api`。
- `scripts/deploy.sh`:Wiki 发布过期检查限定 Wiki 相关路径,避免 Studio 单独更新时阻止已验证的 Wiki 发布。
- `apps/waterfall-wiki/tests/integration.test.ts`:移除已按需求删除的旧工具入口文案断言,改为检查 ID 链接和分类;新增真实存储图片的压缩体积检查,以及数据库内容的 agent_mode 响应检查。
- `apps/waterfall-wiki/src/content/page-links.ts`:默认预览尺寸改为已支持的 480 像素档位。
+35
View File
@@ -0,0 +1,35 @@
# 扁平笔记、标签与统一资源:变更说明
本阶段保留 posts.slug(旧链接与导入去重)、cover_asset_id(明确封面)、media_jobs(异步视频处理)及 contribution_events。删除 page_key、tags.parent_id、asset_variants、page_snapshots、render_jobs;原文件不删除,旧截图和加工文件转换为普通 assets/post_assets 引用。所有笔记平等,不引入附属文档关系。
## 文件与目的
- `apps/waterfall-wiki/db/004_flat_notes.sql`:新增事务迁移。保存旧截图引用,将加工文件转换为普通资源,合并同名标签且保留笔记/资源关联,再移除旧字段和表。
- `apps/waterfall-wiki/src/lib/model.ts`:更新扁平标签和资源来源字段,移除代码页绑定与截图数量字段。
- `apps/waterfall-wiki/src/lib/library.ts`:取消递归标签查询和代码页注册过滤;从普通资源计算加工类型,允许直接保存文字笔记。
- `apps/waterfall-wiki/src/lib/asset-links.ts`:新增共享视频资源的加工结果关联逻辑,锁住笔记以避免并发追加占用同一位置。
- `apps/waterfall-wiki/src/lib/write.ts`:创建笔记不再依赖 page_key,复用视频时补齐已有加工文件引用。
- `apps/waterfall-wiki/src/worker.ts`:仅保留视频任务,FFmpeg 输出进入普通资源表并关联到使用该视频的笔记,继续支持重试。
- `apps/waterfall-wiki/src/lib/publish-learning-series.ts`:发布完整文字和扁平标签,保留已有图片和封面,不再排截图任务;同步静态文档中可见的标签名称。
- `apps/waterfall-wiki/src/lib/explore.ts`:直接从普通资源展示笔记,图片计数排除视频加工文件;正文中提供加工资源链接,不再读取截图状态或代码页面。
- `apps/waterfall-wiki/src/lib/api.ts`:移除“必须注册代码页面”的旧错误映射。
- `apps/waterfall-wiki/src/pages/api/media/[id].ts`、`apps/waterfall-wiki/src/pages/api/preview.ts`:直接读取普通加工资源,同时兼容已有 variant 媒体地址,继续提供小文件预览。
- `apps/waterfall-wiki/src/pages/api/tags.ts`:名称全局唯一,支持带 # 创建;同名请求复用已有标签,不需要 parentId 或手写 slug。
- `apps/waterfall-wiki/src/components/TagInput.tsx`:新增 #标签输入、回车创建、现有标签建议和移除交互。
- `apps/waterfall-wiki/src/pages/library/import.tsx`:接入标签输入,成功后直接进入 ID 地址,更新视频处理提示。
- `apps/waterfall-wiki/src/components/ExploreDetail.tsx`:正文链接增加小图标和轻量行内样式。
- `apps/waterfall-wiki/src/styles/globals.css`:新增行内链接和标签输入样式及焦点反馈。
- `apps/waterfall-wiki/src/components/Feed.tsx`:旧列表组件改用平级标签,不再寻找子分类。
- `apps/waterfall-wiki/src/pages/p/[slug].tsx`:精简为兼容旧地址的跳转,移除失效的旧详情渲染代码。
- 删除 `apps/waterfall-wiki/src/lib/snapshots.ts`、`apps/waterfall-wiki/src/components/ArticleGallery.tsx`、`apps/waterfall-wiki/src/pages/api/posts/[slug]/snapshots.ts`、`apps/waterfall-wiki/src/pages/p/[slug]/pages/[page].tsx`、`apps/waterfall-wiki/scripts/wait-snapshots.mjs`:移除截图执行器、轮询界面、任务 API、画布路由与部署等待脚本。
- 删除 `apps/waterfall-wiki/src/content/registry.tsx`:移除 Wiki 代码作品注册绑定。
- `Dockerfile`、`apps/waterfall-wiki/Dockerfile`:不再复制截图等待脚本,保留现有依赖层缓存。
- `scripts/deploy.sh`:停止写入后备份,迁移失败时恢复兼容旧镜像的数据库,再恢复服务;删除截图等待与共享封面假设。
- `apps/waterfall-wiki/tests/flat-note-migration.ts`:新增隔离数据库 schema 的真实迁移验证,覆盖旧图片、视频文件、同名标签关联及保留的视频任务。
- `apps/waterfall-wiki/tests/integration.test.ts`:改为验证平级标签复用、无代码绑定的文字笔记、普通视频加工资源引用及已移除的截图入口。
- `docs/monorepo.md`:更新存储、迁移、媒体引用与截图流程退役说明。
- `docs/flat-notes-stage.md`:本阶段逐文件变更说明。
## 验证
本地 TypeScript 检查通过,18 项单元测试通过,部署脚本语法与 diff 空白检查通过。完整 PostgreSQL/S3 迁移、视频加工、生产构建与上线验证由 Wiki CI 执行。Studio 和 Python 源码未修改。
+19
View File
@@ -0,0 +1,19 @@
# 指导文档的两种呈现
指导文档共用一份内容。每篇都有独立网页 URL,后面加 `/index.md` 就是 Markdown 版本:
| 网页 | Markdown |
| --- | --- |
| `/` | `/index.md` |
| `/skills` | `/skills/index.md` |
| `/skills/shadow-puppet` | `/skills/shadow-puppet/index.md` |
| `/skills/shadow-puppet/prompt` | `/skills/shadow-puppet/prompt/index.md` |
| `/skills/image-delivery` | `/skills/image-delivery/index.md` |
这里的 skills 指指导文档,不要求使用外部技能包规范,也不要求先从某个 SKILL.md 进入。旧 `/agent`、`/agent/index.md`、`/tools/skills/...`、`/skills/<name>/SKILL.md` 和 `<doc>.md` 地址兼容保留。
`src/content/skills/guidance.ts` 是全局平面文档注册表。路径是文档的稳定标识;分组只用于浏览,不隔离引用。已有两套文档继续从 catalog 读取内容,注册表为它们生成成对 URL、共享资源引用和反向引用;新增独立文档直接注册唯一 path,无需归属某个技能。
网页正文的文档链接进入网页,Markdown 正文的文档链接进入 Markdown。图片和 API 继续使用原始资源地址。复制按钮复制当前这一篇的 Markdown 地址,每页 HTML head 也声明对应地址。反向引用从正文链接计算,两种呈现显示相同关系。
共用的 image-delivery 文档由 street-people/checks 和 shadow-puppet/delivery 引用,同时链接回各自的具体验收协议。它证明引用可以跨分组,不复制内容。
+34
View File
@@ -0,0 +1,34 @@
# 学习资料自己做
三个代码编写的图文作品,每篇 8 张 3:4 画板、10 项工具。三篇共用一张 1080×1440 PNG 封面,通过同一个 S3 图片资产引用,不复制图片。
| 篇章 | 详情路径 | 主题 |
| --- | --- | --- |
| 01 | `/p/learning-skills-notes` | 笔记、网课与复习 |
| 02 | `/p/learning-skills-teaching` | 练习、试卷与教学 |
| 03 | `/p/learning-skills-english` | 词汇、精读、口语与写作 |
内容位于 `src/content/learning-skills/data.ts`,版式位于 `Series.tsx` 和 `src/styles/learning-series.css`。每篇依次为共同封面、索引、五张双工具介绍、使用路线与来源;支持分页、索引跳转、连续阅读与同系列跳转。所有小样标注为排版示意;GitHub 来源已于 2026-10-23 核对,尚未逐项安装实测。
`public/learning-skills/cover.svg` 是封面设计源稿,PNG 成品保存在 S3,路径及校验值登记于 `managed-media.json`,旧 `/learning-skills/cover.png` 路径通过媒体 API 提供,不依赖服务器安装中文字体重新渲染。
## 实际发布
存储初始化不会自动插入示例帖子。生产发布时,明确执行 Compose 的 `publish` 一次性容器,运行 `publish-learning-series.cjs`,在真实 PostgreSQL/S3 中发布这三篇。
发布使用稳定 ID、数据库事务和锁;重复发布更新相同作品,保留创建时间,不新增重复帖子、封面资产或贡献记录。遇到相同 slug 被其他作品占用时失败,不覆盖其他作品。封面对象按内容摘要命名,图片与独立文字资产通过 `post_assets` 引用。分类置于学习工具及对应的笔记复习、教学资料、英语学习分支。
Docker 集成测试会重复执行发布,检查三个作品、共用封面的三次引用、封面尺寸与媒体读取、分类筛选、详情 HTTP 200,以及没有擅自增加贡献。
## 验收约定
先推送 Git,再等 CI/CD 成功,最后直接访问服务器 http://8.211.154.134:18131/ 验收。不要启动本地网页预览,不把 localhost 作为展示或交付入口。
## 独立网页与真实截图(新版)
每篇笔记仍使用 `/p/<slug>`;每一张图的独立 HTML/CSS/TypeScript 原稿使用 `/p/<slug>/pages/1` 至 `/pages/8`。这些原稿没有网站导航与笔记说明,固定为 1080×1440;第一张封面也是可渲染的 SVG DOM,而非直接嵌入原来的 PNG。
笔记页展示 `page_snapshots` 引用的 S3 PNG,可打开原稿、刷新当前一张或更新全部。POST `/api/posts/<slug>/snapshots` 接收可选 `{page: 1..8}`,GET 返回状态与截图列表。服务只允许代码登记的页面,不接受任意网址。后台 worker 使用 Chromium 截图,等待字体就绪,并检查内容没有压住页脚;网络请求只允许内部 Web 服务。完整一轮成功后在事务中更新引用,失败保留旧图。图片按摘要去重,三篇封面继续共用素材。
部署使用 Docker 内 Chromium 与中文字体,等待 24 张当前版本截图全部完成才确认发布。没有本地浏览器预览。
+38
View File
@@ -0,0 +1,38 @@
# 交付验收记录
所有 PR 由用户手动合并,只有 main 更新触发 ces-control CI/CD。用户后续明确允许在当前项目目录内本地开发和测试;本次 SaaS 重构使用独立工作区,结束后清理自建临时依赖、预览和构建产物。
## 已部署功能的实际证据
2026-10-29,main run 508 / job 556 成功:类型检查、51 项单元测试、生产构建、smoke、真实 PostgreSQL/S3/Chromium 集成、认证检查与生产部署全部通过。验收入口为 http://8.211.154.134:18131/ ,健康检查返回应用版本 `9a603cf87e9b726a566ae68f4f479030e0d02629`。随后 #18 仅修改工作流与说明,没有应用代码变更。
| 用户要求 | 已有证据 |
| ----------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 研究 LLM Wiki 与新语法 | protocol.md 引用 Karpathy 原始 gist;独立 XML、条件链接、关系、预算与版本规则是本项目提出的实验协议,不声称 Agent 已原生支持 |
| SkillHub 截图与设计分析 | chat 中已截取首页、目录、详情概述/文件/版本/评论/评测、登录、作者、发布限制;补充插件、SkillPay、大赛、使用指南、专家包与企业专区首屏。受限发布编辑器未完整验收 |
| 单篇可独立采用 | 公开入口 GET/HEAD、Agent 索引、样例与持久文档读取均已验证;不要求安装整个集合 |
| 条件链接与固定版本 | 实际预览解析 source-trust 固定版本;缺失版本返回 404;XML 条件和标签往返已验证 |
| 邮箱、密码、邮箱验证 | 指定真实收件箱收到注册邮件并确认位于 INBOX;未验证登录 401,验证一次 200、重复消费 400,验证后登录 200 |
| 普通账户权限隔离 | 普通邮箱会话调用旧管理员写 API 返回 401;匿名预览 401,跨来源写入 403 |
| 真实发布与版本 | 实际发布 v1/v2,旧版本正文保持不变,旧版本号重复发布返回 409;目录返回最新版本 |
| 撤下与退出 | 验收文档已撤下,最新入口 404、固定历史版本 200、公开目录不再列出;注销后会话为空 |
| 保留旧界面 | src/legacy/WaterfallHome.tsx 与旧 Shell/ExploreFeed 保留,新入口不再使用旧首页;旧内容路由仍在集成测试覆盖范围内 |
| 独立分支、人工合并 | #6–#18 由用户合并;当前 SaaS 重构使用 codex/wiki-saas,交付 PR 等待用户手动合并 |
## 邮件与敏感信息
SMTP 运行时配置位于服务器 root 所有、0600 权限的 /etc/prompt.env。密码、注册验证令牌与测试账户密码不进入仓库、PR、截图或报告。早期 SMTP 诊断邮件只证明投递;上述后续真实注册验证才证明完整账户流程。验收文档已撤下;固定历史版本按产品约定保留。
## 本次 SaaS 视觉重构
详细设计参考与页面规范见 ../wiki-saas/DESIGN.md。重构范围为公共外壳、目录、社区/我的文档、阅读、账户、编辑与协议页。
本地独立预览中的 UI 回归使用明确的 API 测试夹具,覆盖 URL 筛选恢复、清除、键盘标签导航、阅读/XML/版本、账户模式与密码可见性、发布登录门槛、表单错误保留输入、校验预览、发布输入规范化、现有文档编辑、XML 往返、移动端导航关闭及无横向溢出。该测试不代替上述真实数据库、SMTP 和权限验证;已接入 main 集成测试流程。
类型检查、51 项单元测试、生产构建与 standalone smoke 已通过。生产构建保留 preview.ts 动态文件跟踪的既有警告。
实际检查了 1440px 桌面目录/阅读/账户/编辑器,以及 390px 手机目录/阅读/XML 编辑器/协议截图。原生桌面浏览器当时因 Mac 锁定不可操作,截图由项目隔离的无头 Chromium 回归测试生成,未将其表述为线上部署验收。
## 剩余交付边界
SaaS 改版尚未合并和部署。PR 就绪后等待用户手动合并,届时由正常 main CI/CD 运行真实存储集成与部署,再在指定服务器复核新页面。不得把旧版本的成功 CI 或本地夹具截图称为新版线上验证。
+67
View File
@@ -0,0 +1,67 @@
# 页面、组件与功能设计
## 产品单位
本项目的核心单位是一个可直接打开、引用和采用的文档版本。作者、集合和站点负责组织与发现;它们不构成必须一起下载或使用的安装包。用户把单篇地址交给 coding agent 或 personal agent,agent 根据适用条件阅读,再判断是否需要打开某个关联文档。
暂用“织页 / Linked Guidance”作为工作名称,不视为已确定品牌。第一版以文档工作台为主,保留可搜索目录、清晰身份、阅读视图、源文档视图和关联信息。
## 参考取舍
| 观察到的模式 | SkillHub 实际表现 | 本项目的对应设计 |
| --- | --- | --- |
| 首页导向 | 巨大标题、安装提示词、服务轮播、趋势榜 | 解释独立文档概念,直接提供搜索与入口文档 |
| 可扫描目录 | 名称、分类、摘要、图标、收藏与下载 | 名称、适用任务、关系类型、版本;未产生的热度不展示 |
| 详情身份 | 命名空间、作者、版本、认证、评分 | 稳定文档 ID、作者身份、精确版本;身份不等于内容正确 |
| 阅读与源内容 | 概述、文件标签、Markdown 正文 | 阅读与 XML 标签;XML 是指导交换格式 |
| 固定操作侧栏 | 复制安装提示词、ZIP、Agent 安装 | 复制单篇引用、查看实际机器接口、解释何时读下一页 |
| 版本追溯 | 时间线、版本说明与下载 | 不可变修订号、版本导航;固定链接不悄悄升级 |
| 作者空间 | 头像/简介/关注/统计、作品搜索与排序 | 作者身份与其文档;集合是可选导航层 |
| 发布入口 | 匿名时显示登录要求 | 已验证邮箱身份;结构化表单与 XML 输入共用校验 |
Hugging Face 和 GitHub 的身份栏、版本与内容/文件分栏可用作信息层级参考。本项目不将仓库树作为默认知识组织入口。已补截评论与评测详情:评论采用独立卡片、热门/最新排序、点赞与回复;评测采用 TRACE 五维说明、雷达图、综合评级和分项进度条。两者的证据来源不同,不能将自动评分直接视为实际任务成功率。
## 页面与路由
| 页面 | 用户要完成的事 | 页面需要的状态 |
| --- | --- | --- |
| 首页 `/` | 找到与眼前任务相关的一篇文档 | 搜索、种类筛选、真实结果、空结果、加载失败、协议样例标识 |
| 单篇 `/documents/:id?revision=N` | 阅读与引用一个准确版本 | 身份、种类、适用条件、正文、步骤、检查项、条件链接、版本导航 |
| 编辑 `/write` | 创建一篇或更新自己的文档 | 已验证/未登录、表单/XML 模式、校验错误、预览、发布中、冲突、成功链接 |
| 文档目录 `/documents` | 浏览真实用户内容 | 摘要结果、空库、服务不可用 |
| 账户 `/account` | 注册、验证、登录与退出 | 密码规则、邮件发送反馈、过期/重复验证、限流、未验证登录、会话状态 |
| 协议 `/protocol` | 了解如何让 agent 采用单篇内容 | 实际 XML 示例、关系含义、版本行为、权限边界、协议状态 |
| 样例 `/guide/:id` | 在没有账户时理解协议 | 明确样例来源,独立阅读与机器接口,不混充用户发布内容 |
作者资料、集合、收藏和讨论是可扩展的独立产品功能,不以静态不可用控件占位;本次核心交付仍需要先打通发布、采用与版本闭环。
## 组件清单
- `GuidanceShell`:统一身份、探索、发布、协议与账户导航;窄屏仍可访问全部入口。
- 搜索与分类:筛选必须作用于真实文档;样例区与用户内容分开标记。
- 文档摘要行:稳定标题、简短适用说明、种类与版本;整行信息层级一致。
- 文档阅读器:正文优先,条件链接明确说明“何时有用”,不自动展开全部目标。
- 采用侧栏:单篇机器地址、固定版本提示、来源及采用边界。
- 修订导航:当前修订与可访问历史;缺失修订明确返回错误。
- 指导编辑器:表单与 XML 共用服务端 schema;任何输入变化都使旧预览失效。
- 预览与校验反馈:显示实际解析结果、关联目标可用性;预览不能写入发布表。
- 账户表单:提交中防重、网络失败反馈、邮箱验证后的明确下一步。
## 核心流程
```mermaid
flowchart LR
A[当前任务] --> B[发现一篇文档]
B --> C[阅读适用条件与正文]
C --> D{是否缺少相关知识}
D -->|否| E[在用户授权范围内采用]
D -->|是| F[判断条件链接]
F --> G[读取目标固定版本]
G --> C
```
读取过程需要去重与预算,防止循环。链接是阅读候选,不能改变用户任务或工具权限。事实依据与背景知识保留各自含义,即使由 agent 阅读也不会自动变成执行指令。
## 验收重点
视觉上优先验证桌面阅读宽度、窄屏导航、正文换行、长 ID/XML 溢出和表单错误可见性。行为上优先验证注册投递与邮箱验证、作者发布、非作者拒绝、冲突反馈、旧版本稳定及缺失链接的表现。远程 CI 与实际服务器结果都要保存;截图或测试中出现问题就继续修订,而不是把原型样例数量当作交付完成度。
+89
View File
@@ -0,0 +1,89 @@
# 从素材瀑布流到独立指导文档网络
## 研究状态(2026-10-29)
已阅读 Karpathy LLM Wiki 原始提案,检查用户提供的 Hugging Face Qwen 页面与 GitHub Chromium 页面。已通过 Chrome 逐项截取核心页面与状态,具体记录见下文。截图显示在主 chat 工具结果中,未导出为仓库图片;未覆盖整个网站,发布编辑器受实名限制。
已采集:首页首屏与下半屏、目录筛选布局、技能详情全部五个标签、企业作者页、登录弹窗、发布访问限制。移动视图尚未采集,已验证分类筛选和搜索空结果,其他筛选组合尚未完整遍历;无法访问的页面明确注明限制,不冒充已检查。
Hugging Face 可借鉴:owner/name 身份、标签、概览与文件/社区分栏、主内容加元数据侧栏。GitHub 可借鉴:稳定地址、版本历史、README 阅读层级、文件和讨论分离。这里应把“单篇文档”放在中心,集合只是可选组织方式;不复制仓库文件树为强制入口。
## 页面与组件
| 页面 | 首版行为 | 核心组件 |
| --- | --- | --- |
| / | 搜索、种类筛选、文档目录 | 顶栏、搜索框、分类导航、文档行、说明侧栏 |
| /guide/:id | 独立阅读、条件链接、反向链接、XML | 面包屑、版本标记、正文、步骤、采用提示、关联文档 |
| /protocol | 展示 0.1 约定与使用边界 | 语法示例、读取流程、协议状态 |
| /account | 注册、验证邮箱、登录、退出 | 表单、错误反馈、会话状态 |
| 作者与集合 | 后续真实用户内容组织 | 作者简介、非强制集合、收藏 |
| /write、/documents/:id | 持久化发布、作者更新、固定版本阅读与撤下 | 结构化/XML 编辑器、校验、预览、版本导航;差异比较后续实现 |
第一阶段提供真实可读样例和单篇机器接口。账户系统独立 PR;内容创作、收藏、讨论不可用静态按钮冒充完成。后续将原素材中的适用指导迁移为独立文档,保留原始来源,迁移前不删除。
## 技术与验收
沿用 Next.js Pages Router、TypeScript、现有 PostgreSQL、Tailwind 基础;旧首页保存到 src/legacy/WaterfallHome.tsx,原组件保留。新 UI 使用独立 CSS Module,避免影响原素材和 Studio 页面。认证保留管理员动态码,邮箱用户具有独立身份,不能获得现有管理员写权限。
构建只在用户确认的 ces-control;页面验收只访问 http://8.211.154.134:18131/。远程 CI、部署、浏览器验证分别记录结果。所有 PR 均由用户手动合并,主 chat 不执行 merge 或自动合并;不把 PR 创建视为部署成功。
## 已观察的 SkillHub 页面
- `/`:白底黑字、宽屏顶栏、大面积留白、巨大居中标题、复制给 AI 安装条;下方轮播服务卡、近期飙升/下载榜、插件、个人企业发布与商业服务。营销与发现混合。
- `/skills?sortBy=trending`:标题与总数,排序标签、来源/场景/API Key 三个筛选、搜索框、列表/卡片切换。列表以图标、名称、认证、分类、截断摘要、收藏和下载统计组成,细灰边框和分隔线。
- `/skills/zcwl/multi-search-engine`:面包屑、图标与名称、命名空间、AI 评分、安全、作者、摘要与标签;概述/文件/评论/版本历史/评测报告五个标签。正文占宽约三分之二,右栏为安装提示词、ZIP、Agent 安装与元数据。
- 文件标签:观察到 references、scripts 文件夹和 SKILL.md、共 4 文件;侧栏有 token 预估、下载收藏、作者、安全报告、内容指纹、数字签名与相关推荐。文件树仍将整个包作为单位。
本项目取舍:保留清晰命名空间、列表可扫描性、阅读与源文档切换;将安装栏改为“采用单篇”,将包文件树改为条件链接和反向引用。暂不复制评分、下载热度、认证徽章,避免没有真实数据的信任暗示。当前暖绿原型是独立视觉探索,需完成参考截图与服务器验收后修订。
## 第二轮截图补充
已在主 chat 逐项截取:文件树与安装侧栏、元数据与签名侧栏、版本历史、手机号登录弹窗、企业作者页、发布访问限制。截图为 Chrome 原生窗口实拍,包含浏览器框;暂未存为仓库图片文件。
版本历史为时间线,显示当前版本标记、日期、说明、展开/下载控件;安装右栏在不同标签间保持一致。登录弹窗区分个人/团队,个人有微信和手机号模式,手机号模式包含验证码输入、协议勾选、主按钮;没有执行登录或同意协议。作者页包含头像/名称/认证/简介、关注、横向统计条、左侧排序分类、右侧搜索与作品目录。发布入口 `/dashboard/publish` 匿名访问明确显示“需要登录才能访问”,无法检查其内部编辑器,不假定内部布局。
待补:详情评论和评测标签、首页下半屏的独立截图、移动端;插件、付费服务、大赛作为非核心周边功能,不进入本次开发首版。第一版发布使用结构化指导编辑器,支持 XML 校验与预览,不沿用未知的 SkillHub 内部发布流程。
## 第三轮截图与发现
在独立 Chrome 窗口补截了详情“评论”和“评测报告”:评论输入区限制 500 字,支持表情和最多 9 张图片,下方以热门/最新排序展示独立评论卡,包含点赞与回复。没有发表评论、点赞或上传内容。评测报告使用紫色信息卡、五维雷达图、综合分数、评级摘要及分项进度条,并注明基于 AI 自动检测、结果供参考。
当前公开评论包含使用故障反馈,而页面仍展示高自动评分。本记录只说明两种信息同时存在,不核实或转述评论中的故障结论。对本项目的启发是把“作者身份”“静态校验”“真实任务验证”和“用户反馈”分别表达,不能将它们合成一个未经说明的可信徽章。
本轮发现浏览器已有用户登录状态;进入 `/dashboard/publish` 仍显示“需要完成实名认证”,尚不能查看编辑器。没有进入实名认证或提交资料。此前匿名登录限制与本轮实名限制属于不同访问状态,均须保留,不能声称已研究发布表单。
## 第四轮:首页下半屏截图与首版取舍
本轮补截首页榜单、个人与企业发布流程、商业服务区、企业展示区、专家包入口及页脚。榜单以黑底第一名大卡和两列紧凑列表突出排行;发布流程用个人/企业双列分步说明,辅以操作界面图片;商业区以灰蓝底大面板展示按调用计费的服务示意。企业区使用横排浅灰卡片,专家包以横幅和单一浏览按钮收束,页脚列出服务/隐私协议、来源和版权说明。这里只记录可见设计,不将示意收益或排名视作已核实数据。
首版采用三种可复用结构:可扫描的文档列表、宽正文加窄元数据侧栏、清晰的发布步骤及校验反馈。首页不展示无真实来源的排行、收益、企业背书或专家包。可选集合以后可以作为导航,但每篇文档始终保有独立地址、独立版本和独立适用范围,不要求整组加载。
## 当前实现与交付状态
原型、邮箱账户及持久化发布已由用户合并(PR #6/#7/#8/#9),运行时仍需 ces-control CI 成功并部署后验收。PR #11 修正首页 data URL 的旧测试预期;PR #12 修复 llms.txt、Markdown 入口和 /api/guides 的公开只读发现规则,均等待用户手动合并。单篇 XML/JSON、条件链接、固定版本与作者权限已有实现和测试代码,不等于远程验证已经通过。
## 第五轮:目录筛选与空结果
实测 `/skills?sortBy=trending` 的“所有场景分类”菜单,选择开发编程后地址变为 `/skills?category=dev-programming&sortBy=trending`,结果数量随之更新。输入不匹配关键词后,地址同时保留 keyword/category/sortBy,显示 0 个结果、空结果插画和“清除所有筛选”按钮。菜单、分类结果和空状态均已在主 chat 截图。
本项目对应修复在 PR #13:分类数量包含用户文档与样例,避免只因样例未命中而误报全局无结果;区分加载失败与真实空结果;搜索提交、分类和快捷主题写入 URL,可刷新恢复并清除。真实数据库与 Chromium 回归代码已提交,远程 CI 尚待执行。
## 第六轮:其他主要页面模板
在新建 Chrome 研究窗口补截以下页面首屏,未安装插件、购买服务、投稿或执行页面中的命令:
| 实际页面 | 可见结构 | 对本项目的取舍 |
| --- | --- | --- |
| `/plugins` | 大标题与数量、横向安装说明条、分类按钮、搜索/排序/视图切换、来源与统计列表 | 复用目录控件层级;独立文档采用读取地址,不要求安装运行时 |
| `/skillpay` | 巨大居中标题、普通/付费服务双栏对照、上架与接入流程按钮 | 说明页可用清晰对照,但本次不引入交易或调用计费 |
| `/contest` | 期数与状态、主题大标题、投稿/作品按钮、三联图片、数据及后续赛事说明 | 活动运营是独立模块;不作为文档发现或可信度的基础 |
| `/tutorials` | 左侧分组文档树、中间长文与表格、右侧本篇目录、阅读进度 | 协议与作者指南可借鉴锚点目录;文档本身不绑定整棵目录 |
| `/skillspackage` | 行业分类横栏、列表/卡片切换、专家角色与技能数量 | 只把集合视为可选导航;保留单篇独立入口 |
| `/skillspackage/tech-test-automation` | 概述/技能模块标签、串联工作步骤、右栏整包安装提示与 ZIP | 本项目使用每条链接的 when/relation/revision,Agent 根据当前任务决定下一篇,不能默认执行全组步骤 |
| `/enterprise-zone` | 精选/最新/热门三块彩色面板、行业筛选、搜索和企业卡片区域 | 作者来源可展示;认证与热门数据不能代替单篇的适用条件和实际验证 |
企业专区截图仍包含加载骨架,后续读取超时;未据此声称卡片全部加载或交互已验证。活动页截图包含入场渐显效果,只用于识别版式;没有核实其运营数字。窄屏参考尚未采集,服务器视觉验收仍须覆盖窄屏。
截至本轮,已覆盖 SkillHub 主要公开页面模板和核心详情状态;实名受限的发布编辑器、个人后台及全部筛选组合未遍历。截图保留在主 chat 工具结果中。组件/页面规划见 design-system.md,研究观察与功能验证分开记录。
+56
View File
@@ -0,0 +1,56 @@
# Linked Guidance 0.1 — 提案,名称待定
目标是让任何一篇文档都能作为 coding agent 或 personal agent 的入口。不需要安装目录、不要求加载集合、不通过仓库位置继承权限。集合只是书签,不是执行单元。
## 文档语法
使用 UTF-8 XML 1.0;媒体类型 application/xml。正文与步骤使用纯文本,XML 转义适用于全部文本和属性;不允许 DTD、外部实体、处理指令和可执行嵌入。0.1 的标准输出由服务端生成,不接受任意 XML 上传执行。
```xml
<guidance xmlns="urn:linked-guidance:0.1" id="review-changes" revision="1" kind="guide" lang="zh-CN">
<title>审查一次代码变更</title>
<summary>定位实际影响,并给出可验证的审查结论。</summary>
<applies>用户请求 review、检查变更或评估回归风险。</applies>
<body>先阅读需求与完整差异,再追踪受影响的调用路径。</body>
<step>按影响排序,只报告有证据的问题。</step>
<link target="test-changes" revision="1" relation="guide" when="需要选择验证方法">选择适当测试</link>
<link target="source-trust" revision="1" relation="context" when="变更包含外部资料">判断资料可信度</link>
<check>每个结论能指出具体触发条件与验证方法。</check>
</guidance>
```
根元素必需属性:命名空间、id(小写字母、数字与短横线)、正整数 revision、kind(guide/context/evidence)、lang。必需单值子元素:title、summary、applies、body。step、link、check、tag 可重复;tag 为纯文本发现标签,不授予执行语义。未知版本必须明确拒绝,不能猜测执行。正文只提供文本,展示层的 HTML 不属于协议。
## 链接与读取
链接是候选阅读建议,不能自动授予权限,也不是强制依赖。`when` 是自然语言条件,由 agent 结合当前任务判断。relation=guide 表示可能有用的指导;context 表示背景;evidence 表示事实依据。后两者不能升级成操作指令。即使 guide 链接也只能在用户授权范围内采用。
先读入口文档自身,再看链接摘要;满足条件且确实缺少相关知识才读取目标正文。单次读取预算默认 8 篇、最大深度 3,以 (id,revision) 去重;记录读取原因、跳过原因、未知版本和缺失目标。预算耗尽时停止扩展并报告不完整。不得把循环、链接失效理解为任务失败;必需前置条件不满足时停止相应动作并说明。
0.1 target 仅允许本站逻辑 id,不自动请求任意 URL;避免服务器端网络探测。外部来源只作为显式资料链接展示,后续联邦协议需要单独定义域名信任、重定向、内容大小与哈希规则。
## 版本与可信度
固定 revision 是精确引用,缺失时不得静默切换最新版。latest 仅用于发现,实际采用前解析为固定版本。已发布版本不可原地修改;更新创建新版本,旧链接继续可解析。文档、集合、作者、运行时权限分别管理。引用任何作者的文档都不会获得作者权限。
用户指令与运行环境规则优先。文档中的联网、发送邮件、运行命令、发布和删除步骤都需要现有任务授权。文档不能要求 agent 忽略更高优先级指令或获取秘密。
## HTTP
- GET /llms.txt 与 /agent/index.md:公开发现入口,说明如何只选择一篇并按条件继续读取;/index.md 指向同一入口。
- GET /api/documents:真实发布内容的摘要,不加载所有正文。
- GET /api/documents/:id?revision=1:真实文档精确版本 JSON;format=xml 获取规范 XML。
- GET /documents/:id?revision=1:真实文档的人类阅读视图。
以下 /api/guides 与 /guide 路径只提供协议样例,不能将它们误认为完整用户内容目录。
- GET /api/guides:发现摘要、种类和固定版本,不包含整套正文。
- GET /api/guides/:id?revision=1:单篇 JSON;format=xml 获取单篇规范 XML。
- GET /guide/:id:人类阅读界面,单篇正文、条件链接、反向链接、版本。
- 每次 agent 采用文档时记录实际版本与来源,而不是只记录集合名称。
0.1 是最小可检验原型,不宣称现有 agent 已原生支持。agent 可通过阅读 XML 采用约定;MCP、编辑器插件、联邦索引属于后续适配。
## 研究依据
Karpathy 的原始提案 https://gist.github.com/karpathy/442a6bf555914893e9891c11519de94f 描述 raw/wiki/schema、ingest/query/lint 和持久交叉引用。本方案借鉴持久链接与渐进读取,新增独立指导单元、条件链接、关系类型、明确版本和授权边界。它不是该提案的原有 XML 标准。

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