Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5d74251032 |
@@ -1,39 +0,0 @@
|
||||
.git
|
||||
**/.next
|
||||
**/node_modules
|
||||
**/dist
|
||||
coverage
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
*.tsbuildinfo
|
||||
.DS_Store
|
||||
.agents
|
||||
.codex
|
||||
|
||||
# Documentation and CI metadata are not application build inputs.
|
||||
/docs
|
||||
/.gitea
|
||||
/README.md
|
||||
/AGENTS.md
|
||||
|
||||
**/__pycache__
|
||||
**/.venv
|
||||
|
||||
# Local authoring assets are uploaded via APIs, never application build inputs.
|
||||
/content
|
||||
|
||||
# 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
|
||||
@@ -1,79 +0,0 @@
|
||||
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
|
||||
@@ -1,32 +0,0 @@
|
||||
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"
|
||||
@@ -1,44 +0,0 @@
|
||||
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 }}"
|
||||
@@ -1,19 +0,0 @@
|
||||
name: Wiki image retention checks
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'scripts/wiki-image-lifecycle.py'
|
||||
- 'tests/test_wiki_image_lifecycle.py'
|
||||
- '.gitea/workflows/wiki-image-retention-checks.yml'
|
||||
jobs:
|
||||
retention-policy:
|
||||
if: gitea.ref == 'refs/heads/main' && gitea.event_name == 'push'
|
||||
runs-on: ces-control
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Check retention without building or accessing the Docker daemon
|
||||
run: |
|
||||
bash -n scripts/deploy.sh
|
||||
PYTHONDONTWRITEBYTECODE=1 python3 -m unittest discover -s tests -p 'test_wiki_image_lifecycle.py' -v
|
||||
@@ -8,16 +8,7 @@ This block is written and re-added by `next dev` — verify at `node_modules/nex
|
||||
|
||||
<!-- END:nextjs-agent-rules -->
|
||||
|
||||
## 交付与视觉验收
|
||||
## 构建与维护
|
||||
|
||||
- 用户指定服务器为唯一运行与验收入口:http://8.211.154.134:18131/。
|
||||
- 页面改动必须先 push,等待 ces-control 的 CI/CD 成功部署,再直接访问服务器检查效果。
|
||||
- 不启动本地网页预览,不使用 localhost / 127.0.0.1 页面进行视觉验收,也不把本地预览链接交付给用户。
|
||||
- 类型检查、单元测试等不提供网页预览的检查可以本地执行;完整运行检查由 Docker CI 完成。
|
||||
|
||||
## 啡跃 Endoleap 品牌与主题
|
||||
|
||||
- 共享品牌包位于 [`packages/brand`](packages/brand/README.md)。修改品牌色、主题或标志前,阅读包内的 [品牌边界](packages/brand/docs/overview.md)、[配色与交互规范](packages/brand/docs/colors.md)、[Tailwind / shadcn 接入](packages/brand/docs/integration.md) 和 [Logo 规则](packages/brand/docs/logo.md)。不在仓库根 docs 重复维护一套品牌规范。
|
||||
- 四套正式主题为 home-light、home-dark、canvas-light、canvas-dark;不创建 drafts,不恢复 proposed 命名。画布 Light / Dark 背景保持中性近白 / 近黑,用户素材与作品不整体染色。
|
||||
- 基础色只在 tokens/palette.json 定义,主题引用色板键;Logo 轮廓只在 tokens/geometry.json 定义。保持 CSS、类型声明、资产与清单一致,不通过图片模型重画已固定的图形。
|
||||
- 本地仅运行不生成产物的轻量读取检查。品牌导出、Tailwind/TypeScript 编译和 SVG 栅格化检查必须通过已确认的 ces-control Docker CI 执行,不在本机启动预览或构建。
|
||||
- 构建和网页运行使用用户确认的远程环境。本地可执行差异审查和无需构建的轻量检查。
|
||||
- Wiki 和 Studio 共用 `apps/waterfall-wiki` 的 Pages Router、登录保护和品牌包。保留已发布的路由、媒体标识和数据库迁移。
|
||||
|
||||
-46
@@ -1,46 +0,0 @@
|
||||
FROM node:24-bookworm-slim AS base
|
||||
LABEL io.entroia.project="waterfall-wiki"
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
WORKDIR /app
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ffmpeg ca-certificates chromium fonts-noto-cjk \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
FROM base AS dependencies
|
||||
WORKDIR /repo/apps/waterfall-wiki
|
||||
LABEL io.entroia.wiki.build="true"
|
||||
RUN corepack enable && corepack prepare pnpm@11.25.0 --activate
|
||||
COPY apps/waterfall-wiki/pnpm-lock.yaml ./
|
||||
COPY pnpm-workspace.yaml /repo/pnpm-workspace.yaml
|
||||
# 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"]
|
||||
@@ -1,104 +1,26 @@
|
||||
# PROMPT
|
||||
# 啡跃 Endoleap
|
||||
|
||||
把提示词、参考图、分步文档和创作工具收进同一个可连接的知识库。人可以浏览瀑布流、搜索和编辑;AI 可以从同一篇 post 读取纯文本、图片与 API 说明,再调用 Studio 生成作品。
|
||||
啡跃把笔记、参考素材和创作工作台放在同一个 Next.js 应用里。应用位于 `apps/waterfall-wiki`,共享主题与品牌资源位于 `packages/brand`。
|
||||
|
||||
**PROMPT 是网站与项目品牌;`waterfall-wiki` 是知识库应用的内部名称。** 目录、包、CI、镜像和 Compose 服务使用内部名称,网站导航与页面标题使用 PROMPT。
|
||||
|
||||
线上入口:[PROMPT](http://8.211.154.134:18131/)。内容、媒体、API 与 Studio 共用登录入口。
|
||||
|
||||
## 从一篇 post 开始
|
||||
|
||||
每篇内容都有稳定的 `/explore/<id>` 地址。标题、标签、正文和封面可以修改,链接保持不变。数据库内容可以保留 24 位 public ID,内部仍使用 UUID;旧 `/p/<slug>`、`/skills/...` 等地址保留兼容跳转。
|
||||
|
||||
- **浏览**:首页按不同笔记的反向引用数优先排序。重复引用和自引用不重复计数,代码、图片与外站链接不冒充笔记引用。详情可以查看引用来源。
|
||||
- **搜索**:标题、简介、正文、普通标签和系统标签共同匹配。支持多个关键词及双引号短语,先按相关性、再按引用数和时间排序;先筛选再分页。
|
||||
- **标签**:普通标签使用 `#中国风`、`#贴纸` 等名称,可以创建、添加或移除。系统标签是固定列表,目前为“文档入口”和“参考图”,只能搜索并勾选,不能通过 `#` 创建。系统标签显示在图片角标,普通标签显示在文字区;分类页顶部按系统标签筛选。
|
||||
- **编辑**:卡片与详情都能直接编辑标签;完整编辑器可以修改标题、简介、Markdown。保存使用 revision 检测并发冲突,避免旧窗口覆盖新修改。
|
||||
- **素材**:原件和加工结果都是普通资产,可被多篇 post 引用。文档封面与实际参考素材分开,子文档可以有自己的封面,同时保留原参考图。
|
||||
|
||||
## 给 AI 的入口
|
||||
|
||||
在同一个 post 地址加 `?agent_mode=True` 可以读取纯文本正文和媒体链接。业务 API 的相同参数会先返回接口说明;已有查询参数时使用 `&agent_mode=True`。这些入口同样需要登录或有效 Bearer JWT。
|
||||
|
||||
| 地址 | 用途 |
|
||||
| 入口 | 用途 |
|
||||
| --- | --- |
|
||||
| `/explore/<id>` | 人看的 post;加 `?agent_mode=True` 读纯文本 |
|
||||
| `/` | 浏览内容,打开 `/explore/<id>` 查看笔记 |
|
||||
| `/guide` | 浏览指导文档 |
|
||||
| `/graphic` | 图文排版 |
|
||||
| `/infinite_canvas` | 无限画布 |
|
||||
| `/api?agent_mode=True` | 业务 API 目录 |
|
||||
| `/api/explore-feed` | 统一搜索、系统/普通标签筛选与分页 |
|
||||
| `/api/notes/<id>` | GET 读取;PUT 编辑全文;PATCH 仅更新标签 |
|
||||
| `/api/tags` | GET 查看普通/系统标签;POST 创建或复用普通标签 |
|
||||
| `/api/upload` | 上传媒体;`assetOnly=true` 独立保存,不创建 post |
|
||||
| `/api/posts` | 用正文和已有资产创建普通 post |
|
||||
| `/api/media/<id>` | 媒体原件与衍生文件,支持 Range |
|
||||
| `/api/preview` | 有尺寸上限的 WebP 预览,不接受任意远程 URL |
|
||||
| `/studio/api` | Studio API 目录 |
|
||||
|
||||
标签 PATCH 请求为 `{ "tags": ["标签 UUID"], "revision": "GET 返回的 revision" }`。它不会要求用户重输正文。Tag 对象的 `kind` 为 `normal` 或 `system`;系统标签还有稳定的 `system_key`。普通创建接口不允许提交 `kind` 或 `system_key`。
|
||||
## 运行
|
||||
|
||||
Wiki 中的教程是普通 post 与媒体数据,不需要为每份资料增加专用网页路由、JSON 文件端点或代码注册。
|
||||
在已确认的远程环境准备 Node.js 24、pnpm 11.25.0、PostgreSQL、S3 兼容对象存储和 FFmpeg。参考 [环境变量示例](apps/waterfall-wiki/.env.example),将变量注入 Web、初始化和媒体 worker 进程。邮箱注册与找回密码需要可用的 SMTP 服务。
|
||||
|
||||
## Studio:从教程进入工具
|
||||
|
||||
Studio 负责交互式排版与无状态渲染,知识库负责教程、来源和入口。`/studio` 返回 PROMPT 首页;请从对应 post 打开具体工具。
|
||||
|
||||
27 套排版工具:`/studio/layouts/ref-01` 至 `/studio/layouts/ref-27`,每套对应原图 T/I/D 构件。目录与参数 API 为 `/studio/api/layouts` 和 `/studio/api/layouts/<id>`;POST 参数生成 SVG。效果通过 `/api/studio/designs` 存入 Wiki 现有 PostgreSQL,返回独立 ID,`/studio/designs/<id>` 可再次打开;PATCH 携带 revision 更新同一效果。Studio 不需要数据库连接或第二套数据库。详见 [27 套排版 API 教程](content/studio-tools/layouts-api.md)。
|
||||
|
||||
其他试版工具:
|
||||
|
||||
- `/studio/ideas/learning-skills-template`:ref-17 同一母版的四张占位封面。
|
||||
- `/studio/ideas/layout-selection`:ref-01、ref-07、ref-26 三种构图;可修改文字与配色,核对构件边界。
|
||||
- `/studio/api/export/<slug>`:GET 读取方案或初始 SVG;POST 校验自定义参数并生成 SVG/JSON。详细说明读取该地址的 `?agent_mode=True`。
|
||||
|
||||
渲染不自动保存作品,也不自动发布 post。需要保留的结果应下载后再通过普通媒体和 post API 发布。原图的 T/I/D 构件、画板比例与来源保留为可读取数据;文字替换和矢量重建的范围应在教程中明确。
|
||||
|
||||
## 仓库结构
|
||||
|
||||
| 路径 | 职责 |
|
||||
| --- | --- |
|
||||
| `apps/waterfall-wiki` | Next.js Pages Router 知识库、搜索、标签、媒体 API、认证与后台视频加工 |
|
||||
| `apps/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+。
|
||||
在仓库根目录运行 `pnpm install --frozen-lockfile` 和 `pnpm build`。然后在 `apps/waterfall-wiki` 目录准备数据库和存储,初始化管理员认证并启动 Web。
|
||||
|
||||
```sh
|
||||
pnpm install
|
||||
pnpm --dir apps/waterfall-wiki typecheck
|
||||
pnpm --dir apps/waterfall-wiki test
|
||||
node scripts/check-studio-contracts.mjs
|
||||
for script in scripts/*.sh; do bash -n "$script"; done
|
||||
node dist/bootstrap.cjs
|
||||
node dist/auth-cli.cjs init
|
||||
pnpm start
|
||||
```
|
||||
|
||||
完整集成测试只针对显式隔离的数据库、对象存储和生产容器运行,覆盖存储、视频加工、编辑冲突、真实搜索与引用、标签规则和登录保护。不要把生产存储配置拿来运行集成测试。
|
||||
|
||||
本项目以指定服务器为唯一网页运行与验收入口:先推送,再等待 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)。
|
||||
媒体 worker 在同一目录作为独立进程运行 `node dist/worker.cjs`。绑定管理员验证器时,在远程交互终端执行 `node dist/auth-cli.cjs qr`。已有实例复用原数据库、对象存储和私有认证文件。
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
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"]
|
||||
@@ -1,12 +0,0 @@
|
||||
[build-system]
|
||||
requires = ["setuptools==75.8.0"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
[project]
|
||||
name = "prompt-tools"
|
||||
version = "0.1.0"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = ["Pillow==11.1.0"]
|
||||
[project.scripts]
|
||||
prompt-export = "prompt_tools.export:main"
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["src"]
|
||||
@@ -1,70 +0,0 @@
|
||||
"""Render a Studio JSON bundle into PNG slides and optionally MP4."""
|
||||
import argparse
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
|
||||
def validate(bundle):
|
||||
if bundle.get("version") != 1 or not isinstance(bundle.get("slides"), list) or not bundle["slides"]:
|
||||
raise ValueError("Expected a version 1 Studio bundle with slides")
|
||||
for slide in bundle["slides"]:
|
||||
if not all(isinstance(slide.get(k), str) for k in ("heading", "body", "color")):
|
||||
raise ValueError("Invalid slide")
|
||||
return bundle["slides"]
|
||||
|
||||
|
||||
def wrap(draw, text, font, width):
|
||||
lines = []
|
||||
for paragraph in text.split("\n"):
|
||||
line = ""
|
||||
for char in paragraph:
|
||||
if line and draw.textlength(line + char, font=font) > width:
|
||||
lines.append(line)
|
||||
line = ""
|
||||
line += char
|
||||
lines.append(line)
|
||||
return lines
|
||||
|
||||
|
||||
def render(bundle, output, font_path):
|
||||
slides = validate(bundle)
|
||||
output.mkdir(parents=True, exist_ok=True)
|
||||
heading = ImageFont.truetype(str(font_path), 76)
|
||||
body = ImageFont.truetype(str(font_path), 38)
|
||||
for index, slide in enumerate(slides, 1):
|
||||
image = Image.new("RGB", (1080, 1440), slide["color"])
|
||||
draw = ImageDraw.Draw(image)
|
||||
draw.text((80, 80), f"PROMPT / {index:02}", font=body, fill="#353535")
|
||||
y = 330
|
||||
for text, font, gap in [(slide["heading"], heading, 108), (slide["body"], body, 65)]:
|
||||
for line in wrap(draw, text, font, 920):
|
||||
if y + gap > 1360:
|
||||
raise ValueError(f"Slide {index} overflows; split the text into more slides")
|
||||
draw.text((80, y), line, font=font, fill="#242424")
|
||||
y += gap
|
||||
y += 70
|
||||
image.save(output / f"slide-{index:03}.png")
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("bundle", type=Path)
|
||||
parser.add_argument("--output", type=Path, required=True)
|
||||
parser.add_argument("--font", type=Path, required=True, help="CJK font TTF/OTF/TTC")
|
||||
parser.add_argument("--video", action="store_true")
|
||||
args = parser.parse_args()
|
||||
bundle = json.loads(args.bundle.read_text())
|
||||
render(bundle, args.output, args.font)
|
||||
if args.video:
|
||||
# Use an explicit temporary input sequence so old PNGs cannot leak into a video.
|
||||
import tempfile
|
||||
import shutil
|
||||
with tempfile.TemporaryDirectory() as staging:
|
||||
for index in range(1, len(bundle["slides"]) + 1):
|
||||
shutil.copy(args.output / f"slide-{index:03}.png", Path(staging) / f"slide-{index:03}.png")
|
||||
subprocess.run(["ffmpeg", "-y", "-framerate", "1/4", "-i", str(Path(staging) / "slide-%03d.png"), "-vf", "fps=30,format=yuv420p", "-c:v", "libx264", "-movflags", "+faststart", str(args.output / "video.mp4")], check=True)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,13 +0,0 @@
|
||||
import unittest
|
||||
from prompt_tools.export import validate, wrap
|
||||
class ExportTests(unittest.TestCase):
|
||||
def test_bad_bundle(self):
|
||||
for bundle in [{}, {"version": 1, "slides": []}, {"version": 1, "slides": [{}]}]:
|
||||
with self.assertRaises(ValueError): validate(bundle)
|
||||
def test_wrap_preserves_content(self):
|
||||
class Draw:
|
||||
def textlength(self, text, font): return len(text)
|
||||
self.assertEqual(wrap(Draw(), "中文内容测试", None, 3), ["中文内", "容测试"])
|
||||
def test_valid_bundle(self):
|
||||
slides = [{"heading": "标题", "body": "正文", "color": "#fff"}]
|
||||
self.assertEqual(validate({"version": 1, "slides": slides}), slides)
|
||||
@@ -1,22 +1,18 @@
|
||||
# Replace all three placeholders before starting Docker Compose.
|
||||
POSTGRES_PASSWORD=replace-with-a-long-random-password
|
||||
S3_ACCESS_KEY=replace-with-a-unique-access-key
|
||||
S3_SECRET_KEY=replace-with-a-long-random-secret
|
||||
# Export to the web, storage bootstrap and worker processes.
|
||||
DATABASE_URL=postgresql://endoleap:replace-me@127.0.0.1:5432/endoleap
|
||||
S3_ENDPOINT=http://127.0.0.1:9000
|
||||
S3_ACCESS_KEY=replace-me
|
||||
S3_SECRET_KEY=replace-me
|
||||
S3_BUCKET=prompt
|
||||
WEB_BIND_ADDRESS=0.0.0.0
|
||||
WEB_PORT=18131
|
||||
PG_PORT=18132
|
||||
S3_PORT=18133
|
||||
PROMPT_IMAGE=prompt:local
|
||||
RELEASE_SHA=development
|
||||
|
||||
# Use HTTPS and secure cookies in production; HTTP acceptance requires explicit false.
|
||||
PORT=3000
|
||||
AUTH_CONFIG_FILE=/path/to/private/auth.json
|
||||
AUTH_COOKIE_SECURE=true
|
||||
ACCOUNT_PUBLIC_ORIGIN=http://8.211.154.134:18131
|
||||
# Enable only behind an ingress that overwrites x-forwarded-for.
|
||||
ACCOUNT_PUBLIC_ORIGIN=https://endoleap.example
|
||||
ACCOUNT_TRUST_PROXY=false
|
||||
SMTP_HOST=smtpdm.aliyun.com
|
||||
|
||||
# Email verification and recovery
|
||||
SMTP_HOST=
|
||||
SMTP_PORT=465
|
||||
SMTP_USERNAME=noreply@notice.entroia.com
|
||||
SMTP_FROM=noreply@notice.entroia.com
|
||||
SMTP_USERNAME=
|
||||
SMTP_FROM=
|
||||
SMTP_PASSWORD=
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
FROM node:24-bookworm-slim AS base
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
WORKDIR /app
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ffmpeg ca-certificates chromium fonts-noto-cjk \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
FROM base AS dependencies
|
||||
WORKDIR /repo/apps/waterfall-wiki
|
||||
RUN corepack enable && corepack prepare pnpm@11.25.0 --activate
|
||||
COPY apps/waterfall-wiki/pnpm-lock.yaml ./
|
||||
COPY pnpm-workspace.yaml /repo/pnpm-workspace.yaml
|
||||
# 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"]
|
||||
@@ -1,163 +0,0 @@
|
||||
{
|
||||
"schemaVersion": 2,
|
||||
"name": "把秋天装进一页 · 银杏观察手记",
|
||||
"document": {
|
||||
"version": 1,
|
||||
"brief": {
|
||||
"topic": "银杏与秋日观察手记",
|
||||
"audience": "喜欢日常摄影与视觉手帐的小红书读者",
|
||||
"intent": "以封面、观察方法和行动结尾组成三页可发布图文",
|
||||
"designIntent": "奶油纸底、森林墨色、银杏金点缀;宋体大标题与克制的无衬线正文;所有页面保留相同页边距和节奏",
|
||||
"imageStrategy": "生成一幅原创银杏静物插画作为系列母图;每页复用素材,保留裁切配置,不把正文烘焙进图片"
|
||||
},
|
||||
"style": {
|
||||
"version": "autumn-journal/1",
|
||||
"palette": {
|
||||
"paper": "#F3EFE5",
|
||||
"ink": "#253C32",
|
||||
"muted": "#718074",
|
||||
"accent": "#C3A050"
|
||||
},
|
||||
"typography": {
|
||||
"title": {
|
||||
"fontFamily": "serif",
|
||||
"fontSize": 8.4,
|
||||
"fontWeight": 600,
|
||||
"lineHeight": 1.15,
|
||||
"letterSpacing": 0
|
||||
},
|
||||
"body": {
|
||||
"fontFamily": "sans",
|
||||
"fontSize": 3.1,
|
||||
"fontWeight": 400,
|
||||
"lineHeight": 1.65,
|
||||
"letterSpacing": 0
|
||||
},
|
||||
"caption": {
|
||||
"fontFamily": "sans",
|
||||
"fontSize": 1.7,
|
||||
"fontWeight": 500,
|
||||
"lineHeight": 1.2,
|
||||
"letterSpacing": 0
|
||||
}
|
||||
},
|
||||
"spacing": {
|
||||
"margin": 10,
|
||||
"gap": 4
|
||||
}
|
||||
},
|
||||
"sources": [],
|
||||
"pages": [
|
||||
{
|
||||
"key": "cover",
|
||||
"role": "cover",
|
||||
"layoutId": "standard-01",
|
||||
"intent": "用银杏静物与两行大标题建立主题",
|
||||
"bindings": {
|
||||
"T01": "title",
|
||||
"T02": "body",
|
||||
"T03": "caption"
|
||||
},
|
||||
"parameters": {
|
||||
"texts": {
|
||||
"T01": {
|
||||
"value": "把秋天\n装进一页"
|
||||
},
|
||||
"T02": {
|
||||
"value": "不必走很远。\n一片银杏,就是今天的风景。"
|
||||
},
|
||||
"T03": {
|
||||
"value": "SEASONAL NOTES / 01"
|
||||
}
|
||||
},
|
||||
"images": {
|
||||
"I01": {
|
||||
"fit": "cover",
|
||||
"cropX": 50,
|
||||
"cropY": 50,
|
||||
"zoom": 1
|
||||
}
|
||||
},
|
||||
"decorations": {}
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "observe",
|
||||
"role": "body",
|
||||
"layoutId": "standard-01",
|
||||
"intent": "用三个观察动作提供可实践的方法",
|
||||
"bindings": {
|
||||
"T01": "title",
|
||||
"T02": "body",
|
||||
"T03": "caption"
|
||||
},
|
||||
"parameters": {
|
||||
"texts": {
|
||||
"T01": {
|
||||
"value": "先看见\n再记录"
|
||||
},
|
||||
"T02": {
|
||||
"value": "看叶脉,看光落下的方向。\n留住一片金黄,也留出一半空白。\n把感受写成短句,画面就有了呼吸。"
|
||||
},
|
||||
"T03": {
|
||||
"value": "FIELD NOTES / 02"
|
||||
}
|
||||
},
|
||||
"images": {
|
||||
"I01": {
|
||||
"fit": "cover",
|
||||
"cropX": 65,
|
||||
"cropY": 40,
|
||||
"zoom": 1.25
|
||||
}
|
||||
},
|
||||
"decorations": {}
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "ending",
|
||||
"role": "ending",
|
||||
"layoutId": "standard-01",
|
||||
"intent": "以轻量行动和提问收尾",
|
||||
"bindings": {
|
||||
"T01": "title",
|
||||
"T02": "body",
|
||||
"T03": "caption"
|
||||
},
|
||||
"parameters": {
|
||||
"texts": {
|
||||
"T01": {
|
||||
"value": "留一点白\n给下一次",
|
||||
"align": "middle"
|
||||
},
|
||||
"T02": {
|
||||
"value": "今天,捡起一片叶子。\n用三个词记录它的颜色、纹理与心情。\n你的秋天,会从哪一页开始?",
|
||||
"align": "middle"
|
||||
},
|
||||
"T03": {
|
||||
"value": "YOUR TURN / 03"
|
||||
}
|
||||
},
|
||||
"images": {
|
||||
"I01": {
|
||||
"fit": "cover",
|
||||
"cropX": 65,
|
||||
"cropY": 50,
|
||||
"zoom": 1
|
||||
}
|
||||
},
|
||||
"decorations": {},
|
||||
"layers": {
|
||||
"I01": {
|
||||
"x": 40,
|
||||
"y": 55,
|
||||
"width": 40,
|
||||
"height": 40,
|
||||
"radius": 20
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,163 +0,0 @@
|
||||
{
|
||||
"schemaVersion": 2,
|
||||
"name": "把秋天装进一页 · 银杏观察手记",
|
||||
"document": {
|
||||
"version": 1,
|
||||
"brief": {
|
||||
"topic": "银杏与秋日观察手记",
|
||||
"audience": "喜欢日常摄影与视觉手帐的小红书读者",
|
||||
"intent": "以封面、观察方法和行动结尾组成三页可发布图文",
|
||||
"designIntent": "奶油纸底、森林墨色、银杏金点缀;宋体大标题与克制的无衬线正文;所有页面保留相同页边距和节奏",
|
||||
"imageStrategy": "生成一幅原创银杏静物插画作为系列母图;每页复用素材,保留裁切配置,不把正文烘焙进图片"
|
||||
},
|
||||
"style": {
|
||||
"version": "autumn-journal/1",
|
||||
"palette": {
|
||||
"paper": "#F3EFE5",
|
||||
"ink": "#253C32",
|
||||
"muted": "#718074",
|
||||
"accent": "#C3A050"
|
||||
},
|
||||
"typography": {
|
||||
"title": {
|
||||
"fontFamily": "serif",
|
||||
"fontSize": 8.4,
|
||||
"fontWeight": 600,
|
||||
"lineHeight": 1.15,
|
||||
"letterSpacing": 0
|
||||
},
|
||||
"body": {
|
||||
"fontFamily": "sans",
|
||||
"fontSize": 3.1,
|
||||
"fontWeight": 400,
|
||||
"lineHeight": 1.65,
|
||||
"letterSpacing": 0
|
||||
},
|
||||
"caption": {
|
||||
"fontFamily": "sans",
|
||||
"fontSize": 1.7,
|
||||
"fontWeight": 500,
|
||||
"lineHeight": 1.2,
|
||||
"letterSpacing": 0
|
||||
}
|
||||
},
|
||||
"spacing": {
|
||||
"margin": 10,
|
||||
"gap": 4
|
||||
}
|
||||
},
|
||||
"sources": [],
|
||||
"pages": [
|
||||
{
|
||||
"key": "cover",
|
||||
"role": "cover",
|
||||
"layoutId": "standard-01",
|
||||
"intent": "用银杏静物与两行大标题建立主题",
|
||||
"bindings": {
|
||||
"T01": "title",
|
||||
"T02": "body",
|
||||
"T03": "caption"
|
||||
},
|
||||
"parameters": {
|
||||
"texts": {
|
||||
"T01": {
|
||||
"value": "把秋天\n装进一页"
|
||||
},
|
||||
"T02": {
|
||||
"value": "不必走很远。\n一片银杏,就是今天的风景。"
|
||||
},
|
||||
"T03": {
|
||||
"value": "SEASONAL NOTES / 01"
|
||||
}
|
||||
},
|
||||
"images": {
|
||||
"I01": {
|
||||
"fit": "cover",
|
||||
"cropX": 50,
|
||||
"cropY": 50,
|
||||
"zoom": 1
|
||||
}
|
||||
},
|
||||
"decorations": {}
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "observe",
|
||||
"role": "body",
|
||||
"layoutId": "standard-01",
|
||||
"intent": "用三个观察动作提供可实践的方法",
|
||||
"bindings": {
|
||||
"T01": "title",
|
||||
"T02": "body",
|
||||
"T03": "caption"
|
||||
},
|
||||
"parameters": {
|
||||
"texts": {
|
||||
"T01": {
|
||||
"value": "先看见\n再记录"
|
||||
},
|
||||
"T02": {
|
||||
"value": "看叶脉,看光落下的方向。\n留住一片金黄,也留出一半空白。\n把感受写成短句,画面就有了呼吸。"
|
||||
},
|
||||
"T03": {
|
||||
"value": "FIELD NOTES / 02"
|
||||
}
|
||||
},
|
||||
"images": {
|
||||
"I01": {
|
||||
"fit": "cover",
|
||||
"cropX": 65,
|
||||
"cropY": 40,
|
||||
"zoom": 1.25
|
||||
}
|
||||
},
|
||||
"decorations": {}
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "ending",
|
||||
"role": "ending",
|
||||
"layoutId": "standard-01",
|
||||
"intent": "以轻量行动和提问收尾",
|
||||
"bindings": {
|
||||
"T01": "title",
|
||||
"T02": "body",
|
||||
"T03": "caption"
|
||||
},
|
||||
"parameters": {
|
||||
"texts": {
|
||||
"T01": {
|
||||
"value": "留一点白\n给下一次",
|
||||
"align": "middle"
|
||||
},
|
||||
"T02": {
|
||||
"value": "今天,捡起一片叶子。\n用三个词记录它的颜色、纹理与心情。\n你的秋天,会从哪一页开始?",
|
||||
"align": "middle"
|
||||
},
|
||||
"T03": {
|
||||
"value": "YOUR TURN / 03"
|
||||
}
|
||||
},
|
||||
"images": {
|
||||
"I01": {
|
||||
"fit": "cover",
|
||||
"cropX": 65,
|
||||
"cropY": 50,
|
||||
"zoom": 1
|
||||
}
|
||||
},
|
||||
"decorations": {},
|
||||
"layers": {
|
||||
"I01": {
|
||||
"x": 40,
|
||||
"y": 55,
|
||||
"width": 40,
|
||||
"height": 40,
|
||||
"radius": 20
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -23,8 +23,6 @@ const nextConfig: NextConfig = {
|
||||
{source:"/agent/index.md",destination:"/api/agent-index"},
|
||||
{source:"/skills/:skill/:doc.md",destination:"/api/skill-docs/:skill/:doc"},
|
||||
]},
|
||||
output: "standalone", outputFileTracingRoot: resolve(process.cwd(), "../.."),
|
||||
outputFileTracingIncludes: {"/*":["./node_modules/playwright-core/**/*"]},
|
||||
poweredByHeader: false,
|
||||
};
|
||||
|
||||
|
||||
@@ -7,15 +7,11 @@
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "pnpm brand:prepare && next dev",
|
||||
"build": "pnpm brand:prepare && next build && esbuild src/worker.ts --bundle --platform=node --format=cjs --external:pg-native --external:playwright-core --outfile=dist/worker.cjs && esbuild scripts/bootstrap.ts --bundle --platform=node --format=cjs --external:pg-native --external:playwright-core --outfile=dist/bootstrap.cjs && esbuild scripts/publish-learning-series.ts --bundle --platform=node --format=cjs --external:pg-native --external:playwright-core --outfile=dist/publish-learning-series.cjs && esbuild scripts/auth-cli.ts --bundle --platform=node --format=cjs --external:pg-native --outfile=dist/auth-cli.cjs && node scripts/prepare-standalone.mjs",
|
||||
"build": "pnpm brand:prepare && next build && esbuild src/worker.ts --bundle --platform=node --format=cjs --external:pg-native --external:playwright-core --outfile=dist/worker.cjs && esbuild scripts/bootstrap.ts --bundle --platform=node --format=cjs --external:pg-native --external:playwright-core --outfile=dist/bootstrap.cjs && esbuild scripts/auth-cli.ts --bundle --platform=node --format=cjs --external:pg-native --outfile=dist/auth-cli.cjs",
|
||||
"start": "next start",
|
||||
"typecheck": "next typegen && tsc --noEmit",
|
||||
"test": "tsx --test tests/api-documentation.test.ts tests/health.test.ts tests/model.test.ts tests/learning-series.test.ts tests/skill-docs.test.ts tests/wiki.test.ts tests/explore.test.ts tests/preview.test.ts tests/note-markdown.test.tsx tests/api-publication.test.ts tests/discovery.test.ts tests/auth.test.ts tests/account.test.ts tests/guidance.test.ts tests/guidance-publishing.test.ts && 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": {
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
// 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;
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
try {
|
||||
const port = process.env.PORT || '3000';
|
||||
const [health, ready] = await Promise.all([
|
||||
fetch(`http://127.0.0.1:${port}/api/health`, { signal: AbortSignal.timeout(5000) }),
|
||||
fetch(`http://127.0.0.1:${port}/api/ready`, { signal: AbortSignal.timeout(5000) }),
|
||||
]);
|
||||
const data = await health.json();
|
||||
if (!health.ok || !ready.ok || data.service !== 'prompt' || data.release !== (process.env.RELEASE_SHA || 'development')) process.exit(1);
|
||||
} catch { process.exit(1); }
|
||||
@@ -1,24 +0,0 @@
|
||||
const {chromium}=require('playwright-core');
|
||||
const assert=require('node:assert/strict');
|
||||
(async()=>{const browser=await chromium.launch({channel:'chrome',headless:true});try{const page=await browser.newPage({viewport:{width:1440,height:1000}});const errors=[];page.on('pageerror',e=>errors.push(e.message));
|
||||
await page.route('**/api/account/session',r=>r.fulfill({json:{user:null}}));
|
||||
await page.goto((process.env.STUDIO_TEST_ORIGIN||'http://8.211.154.134:18131')+'/graphic');
|
||||
await page.getByRole('button',{name:'切换图层面板',exact:true}).click();
|
||||
assert.equal(await page.getByRole('button',{name:'图层',exact:true}).isVisible(),false);
|
||||
await page.getByRole('button',{name:'切换图层面板',exact:true}).click();
|
||||
await page.getByRole('button',{name:'切换属性面板',exact:true}).click();
|
||||
assert.equal(await page.getByRole('button',{name:'备份设计 JSON',exact:true}).isVisible(),false);
|
||||
await page.getByRole('button',{name:'切换属性面板',exact:true}).click();
|
||||
await page.getByRole('button',{name:'添加文字',exact:true}).click();
|
||||
await page.getByRole('textbox',{name:'文字内容',exact:true}).fill('设计从这里开始');
|
||||
await page.getByRole('button',{name:'添加矩形',exact:true}).click();
|
||||
await page.getByRole('button',{name:'撤销',exact:true}).click();
|
||||
await page.getByRole('button',{name:'重做',exact:true}).click();
|
||||
await page.reload();await page.getByRole('button',{name:'文字',exact:true}).first().waitFor();
|
||||
assert.ok(await page.evaluate(()=>localStorage.getItem('studio.graphic.v1').includes('设计从这里开始')));
|
||||
const file=page.waitForEvent('download');await page.getByRole('button',{name:'导出 SVG',exact:true}).click();assert.ok((await file).suggestedFilename().endsWith('.svg'));
|
||||
await page.getByRole('button',{name:'邮箱登录',exact:true}).click();await page.getByRole('dialog').waitFor();await page.keyboard.press('Escape');await page.getByRole('dialog').waitFor({state:'hidden'});
|
||||
await page.screenshot({path:process.env.STUDIO_QA_SCREENSHOT||'graphic-qa.png',fullPage:true});
|
||||
await page.setViewportSize({width:390,height:844});assert.equal(await page.evaluate(()=>document.documentElement.scrollWidth>innerWidth),false);
|
||||
assert.deepEqual(errors,[]);console.log('Graphic editing, history, restore, SVG download, account dialog and mobile overflow checks passed.');
|
||||
}finally{await browser.close()}})().catch(e=>{console.error(e);process.exitCode=1});
|
||||
@@ -1,38 +0,0 @@
|
||||
import {readFile} from 'node:fs/promises';
|
||||
import {fileURLToPath} from 'node:url';
|
||||
import {join,dirname} from 'node:path';
|
||||
import {createHash} from 'node:crypto';
|
||||
import {requiredPackImages} from './verify-character-pack.mjs';
|
||||
const root=join(dirname(fileURLToPath(import.meta.url)),'..','seed','characters');
|
||||
const base=process.env.PROMPT_IMPORT_BASE_URL||'http://127.0.0.1:3000';
|
||||
const media=JSON.parse(await readFile(join(root,'..','..','src','content','managed-media.json'),'utf8'));
|
||||
const packFiles=pack=>Object.entries(media).filter(([name])=>name.startsWith(`apps/waterfall-wiki/seed/characters/${pack.directory}/`)).sort(([a],[b])=>a.localeCompare(b));
|
||||
const packs=JSON.parse(await readFile(join(root,'packs.json'),'utf8'));
|
||||
async function verify(pack){
|
||||
let cursor='',post;const seen=new Set();
|
||||
do{const response=await fetch(base+'/api/feed?'+new URLSearchParams({section:'images',...(cursor?{cursor}:{})}));if(!response.ok)throw Error('Cannot verify character feed: '+response.status);const page=await response.json();post=page.posts.find(p=>p.slug===pack.slug);cursor=page.nextCursor||'';if(cursor&&seen.has(cursor))throw Error('Character feed cursor did not advance');seen.add(cursor);}while(!post&&cursor);
|
||||
if(!post)throw Error('Character pack missing: '+pack.slug);
|
||||
const files=packFiles(pack);if(files.length!==pack.count)throw Error('Seed file count mismatch: '+pack.slug);
|
||||
const hashes=files.map(([,asset])=>asset.sha256);
|
||||
const originals=requiredPackImages(hashes,post.assets.filter(a=>a.kind==='image'));
|
||||
for(const image of originals){const media=await fetch(`${base}/api/media/${image.id}`,{method:'HEAD'});if(!media.ok||media.headers.get('content-type')!=='image/png')throw Error('Original PNG unavailable');}
|
||||
console.log(`Verified: /p/${pack.slug} (${originals.length} original PNGs preserved; editorial covers allowed)`);
|
||||
}
|
||||
for(const pack of packs){
|
||||
const existing=await fetch(`${base}/p/${pack.slug}`);
|
||||
if(existing.ok){console.log(`Already imported: /p/${pack.slug}`);await verify(pack);continue;}
|
||||
if(existing.status!==404)throw new Error(`Cannot check pack ${pack.slug}: ${existing.status}`);
|
||||
const files=packFiles(pack);
|
||||
if(files.length!==pack.count)throw new Error(`Expected ${pack.count} PNGs for ${pack.slug}, got ${files.length}`);
|
||||
const data=new FormData();data.set('title',pack.title);data.set('slug',pack.slug);data.set('description',pack.description);
|
||||
for(const [name,asset] of files){
|
||||
const response=await fetch(`${base}/api/media/${asset.id}`);if(!response.ok)throw Error('Cannot read migrated original: '+name);
|
||||
const bytes=Buffer.from(await response.arrayBuffer());if(bytes.length!==asset.bytes||createHash('sha256').update(bytes).digest('hex')!==asset.sha256)throw Error('Original checksum mismatch: '+name);
|
||||
data.append('files',new Blob([bytes],{type:'image/png'}),name.split('/').pop());
|
||||
}
|
||||
const response=await fetch(`${base}/api/skills/street-people`,{method:'POST',body:data});
|
||||
if(response.status!==201&&response.status!==409)throw new Error(`Import ${pack.slug}: ${response.status} ${await response.text()}`);
|
||||
if(response.status===409&&!(await fetch(`${base}/p/${pack.slug}`)).ok)throw new Error(`Conflicting import ${pack.slug}`);
|
||||
console.log(`Imported: /p/${pack.slug} (${files.length} transparent PNGs)`);
|
||||
await verify(pack);
|
||||
}
|
||||
@@ -1,128 +0,0 @@
|
||||
// Isolated browser context against the designated deployed server only.
|
||||
const {chromium}=require('playwright-core');
|
||||
const assert=require('node:assert/strict');
|
||||
const fs=require('node:fs/promises');
|
||||
(async()=>{
|
||||
const base=process.env.BASE_URL||'http://8.211.154.134:18131';
|
||||
assert.equal(base,'http://8.211.154.134:18131');
|
||||
const browser=await chromium.launch(process.env.CHROMIUM_PATH?{executablePath:process.env.CHROMIUM_PATH,headless:true,args:['--no-sandbox','--disable-dev-shm-usage']}:{channel:'chrome',headless:true});
|
||||
let page;
|
||||
try{
|
||||
const context=await browser.newContext({viewport:{width:1600,height:1100},storageState:process.env.STORAGE_STATE||undefined});
|
||||
page=await context.newPage();const errors=[];let choosers=0;
|
||||
page.on('pageerror',e=>errors.push(e.message));page.on('filechooser',()=>choosers++);
|
||||
const snapshot=()=>page.evaluate(()=>JSON.parse(localStorage.getItem('studio.infinite-canvas.v1')));
|
||||
const node=id=>page.locator(`[data-node-id="${id}"]`);
|
||||
const content=id=>node(id).locator('[data-node-content]');
|
||||
const waitField=(id,key,value)=>page.waitForFunction(({id,key,value})=>JSON.parse(localStorage.getItem('studio.infinite-canvas.v1')).cards.find(n=>n.id===id)?.[key]===value,{id,key,value});
|
||||
const composer=page.getByRole('region',{name:'节点编辑器'});
|
||||
const capture=async name=>{if(process.env.SCREENSHOT_DIR){await fs.mkdir(process.env.SCREENSHOT_DIR,{recursive:true});await page.screenshot({path:process.env.SCREENSHOT_DIR+'/'+name+'.png'})}};
|
||||
async function unobstructed(id){const box=await node(id).boundingBox();assert.ok(box);for(const card of (await snapshot()).cards.filter(n=>n.id!==id)){const other=await node(card.id).boundingBox();assert.ok(other);assert.equal(box.x<other.x+other.width&&box.x+box.width>other.x&&box.y<other.y+other.height&&box.y+box.height>other.y,false,`${id} overlaps ${card.id}`)}}
|
||||
const close=(a,b)=>assert.ok(Math.abs(a-b)<2,`${a} ≈ ${b}`);
|
||||
async function drag(handle,dx,dy){const b=await handle.boundingBox();assert.ok(b);await page.mouse.move(b.x+b.width/2,b.y+b.height/2);await page.mouse.down();await page.mouse.move(b.x+b.width/2+dx,b.y+b.height/2+dy,{steps:12});await page.mouse.up()}
|
||||
const canvasResponse=await page.goto(base+'/infinite_canvas',{waitUntil:'commit',timeout:60000});
|
||||
assert.ok(canvasResponse?.ok(),'Canvas document returns HTTP success');
|
||||
await page.locator('button[data-theme-ready="true"]').waitFor();
|
||||
await page.getByRole('button',{name:'从示例开始 ↗',exact:true}).click();
|
||||
await page.getByRole('button',{name:'适应画布',exact:true}).click();
|
||||
await content('brief').click({position:{x:20,y:20}});
|
||||
assert.equal(await node('brief').getAttribute('data-selected'),'true');assert.equal(await node('brief').getAttribute('data-editing'),'false');
|
||||
await page.waitForTimeout(700);
|
||||
await content('brief').click({position:{x:20,y:20}});
|
||||
await node('brief').getByRole('textbox',{name:'01 / 创意简报 内容'}).fill('选择后再次点击进入编辑');
|
||||
await waitField('brief','text','选择后再次点击进入编辑');
|
||||
await node('brief').getByRole('button',{name:'加粗',exact:true}).click();
|
||||
assert.equal((await snapshot()).cards.find(n=>n.id==='brief').style.bold,true);
|
||||
await node('brief').getByRole('button',{name:'斜体',exact:true}).click();
|
||||
await node('brief').getByLabel('文字颜色',{exact:true}).fill('#78905e');
|
||||
await node('brief').getByRole('combobox',{name:'文字样式',exact:true}).selectOption('24');
|
||||
await page.waitForFunction(()=>{const n=JSON.parse(localStorage.getItem('studio.infinite-canvas.v1')).cards.find(n=>n.id==='brief');return n.style.bold&&n.style.italic&&n.style.color==='#78905e'&&n.style.size===24});
|
||||
assert.deepEqual(await node('brief').getByRole('textbox',{name:'01 / 创意简报 内容'}).evaluate(el=>{const s=getComputedStyle(el);return {size:s.fontSize,weight:s.fontWeight,style:s.fontStyle,color:s.color}}),{size:'24px',weight:'700',style:'italic',color:'rgb(120, 144, 94)'});
|
||||
const compact=(await snapshot()).cards.find(n=>n.id==='brief');
|
||||
await node('brief').getByRole('button',{name:'展开文本节点',exact:true}).click();
|
||||
const expanded=(await snapshot()).cards.find(n=>n.id==='brief');assert.ok(expanded.width>(compact.width||320)&&expanded.height>(compact.height||350));
|
||||
await capture('canvas-text-format-desktop');
|
||||
await page.getByRole('button',{name:'撤销',exact:true}).click();
|
||||
assert.equal((await snapshot()).cards.find(n=>n.id==='brief').width,compact.width);assert.equal((await snapshot()).cards.find(n=>n.id==='brief').height,compact.height);
|
||||
await page.keyboard.press('Escape');
|
||||
const before=(await snapshot()).cards.find(n=>n.id==='brief');
|
||||
const z=await page.locator('[class*=world]').evaluate(el=>new DOMMatrix(getComputedStyle(el).transform).a);
|
||||
await drag(content('brief'),45,25);
|
||||
const after=(await snapshot()).cards.find(n=>n.id==='brief');close(after.x-before.x,45/z);close(after.y-before.y,25/z);
|
||||
assert.equal(await node('brief').getAttribute('data-editing'),'false');
|
||||
|
||||
await content('reference').click();assert.equal(await composer.count(),0);
|
||||
await page.waitForTimeout(700);await content('reference').click();await composer.waitFor();assert.equal(choosers,0,'Entering image editing must not force a file picker');
|
||||
const panelBefore=await composer.boundingBox(),imageBefore=await node('reference').boundingBox();
|
||||
await capture('canvas-composer-desktop');
|
||||
await page.getByRole('button',{name:'缩小',exact:true}).click();
|
||||
const panelAfter=await composer.boundingBox(),imageAfter=await node('reference').boundingBox();
|
||||
close(panelBefore.width,panelAfter.width);close(panelBefore.height,panelAfter.height);assert.ok(imageAfter.width<imageBefore.width);
|
||||
const chooser=page.waitForEvent('filechooser');await composer.getByRole('button',{name:'上传图片',exact:true}).click();
|
||||
await (await chooser).setFiles({name:'reference.png',mimeType:'image/png',buffer:Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+ip1sAAAAASUVORK5CYII=','base64')});
|
||||
await page.waitForFunction(()=>!!JSON.parse(localStorage.getItem('studio.infinite-canvas.v1')).cards.find(n=>n.id==='reference').image);
|
||||
await node('reference').getByRole('button',{name:'图片高清 ↗',exact:true}).click();
|
||||
const upscale=(await snapshot()).cards.find(n=>n.operation==='upscale');assert.ok(upscale);
|
||||
await unobstructed(upscale.id);
|
||||
const download=page.waitForEvent('download');await composer.getByRole('button',{name:'导出任务',exact:true}).click();
|
||||
const task=JSON.parse(await fs.readFile(await (await download).path(),'utf8'));
|
||||
assert.equal(task.target.id,upscale.id);assert.equal(task.references[0].id,'reference');assert.ok(task.references[0].image);
|
||||
|
||||
await page.keyboard.press('Escape');await page.getByRole('button',{name:'适应画布',exact:true}).click();
|
||||
await content('brief').click({position:{x:20,y:20}});
|
||||
await node('brief').getByRole('button',{name:'引用 01 / 创意简报 生成',exact:true}).click();
|
||||
await page.getByRole('dialog',{name:'画布操作'}).getByRole('button',{name:'脚本',exact:true}).click();
|
||||
const script=(await snapshot()).cards.find(n=>n.kind==='script');assert.ok(script);
|
||||
await unobstructed(script.id);
|
||||
await composer.getByRole('textbox',{name:'创作要求',exact:true}).fill('基于简报写六个分镜');
|
||||
await waitField(script.id,'instruction','基于简报写六个分镜');assert.equal((await snapshot()).cards.find(n=>n.id===script.id).text,'');
|
||||
await composer.getByRole('button',{name:'画布参考',exact:true}).click();
|
||||
await composer.getByRole('group',{name:'选择引用节点'}).getByRole('button',{name:'02 / 视觉参考',exact:true}).click();
|
||||
assert.ok((await snapshot()).links.some(([a,b])=>a==='reference'&&b===script.id));
|
||||
await composer.getByRole('button',{name:'移除引用 01 / 创意简报',exact:true}).click();
|
||||
assert.ok(!(await snapshot()).links.some(([a,b])=>a==='brief'&&b===script.id));
|
||||
await page.keyboard.press('Escape');
|
||||
await page.getByRole('button',{name:'适应画布',exact:true}).click();
|
||||
await content('reference').click();await node('reference').getByRole('button',{name:'02 / 视觉参考 操作',exact:true}).click();
|
||||
await page.getByRole('button',{name:'删除节点',exact:true}).click();
|
||||
assert.ok((await snapshot()).cards.some(n=>n.id===script.id));assert.ok((await snapshot()).links.every(l=>!l.includes('reference')));
|
||||
await page.getByRole('button',{name:'撤销',exact:true}).click();assert.ok((await snapshot()).links.some(([a,b])=>a==='reference'&&b===script.id));
|
||||
await page.keyboard.press('Escape');
|
||||
await content('brief').click({position:{x:20,y:20}});
|
||||
const priorWidth=(await snapshot()).cards.find(n=>n.id==='brief').width;
|
||||
await drag(node('brief').getByRole('button',{name:'调整 01 / 创意简报 尺寸',exact:true}),50,30);
|
||||
assert.ok((await snapshot()).cards.find(n=>n.id==='brief').width>(priorWidth||320));
|
||||
await page.getByRole('button',{name:'撤销',exact:true}).click();assert.equal((await snapshot()).cards.find(n=>n.id==='brief').width,priorWidth);
|
||||
await page.getByRole('button',{name:'重做',exact:true}).click();
|
||||
await page.getByRole('button',{name:'移动画布',exact:true}).click();
|
||||
await page.mouse.move(15,300);await page.mouse.down();await page.mouse.move(75,340,{steps:8});await page.mouse.up();
|
||||
assert.equal(await page.evaluate(()=>{const path=[...document.querySelectorAll('svg path')].find(p=>p.getAttribute('d')?.startsWith('M '));const d=path.getAttribute('d').match(/-?\d+(?:\.\d+)?/g).map(Number),point=new DOMPoint(d[0],d[1]).matrixTransform(path.getScreenCTM()),card=document.querySelector('[data-node-id="brief"]').getBoundingClientRect();return Math.abs(point.x-card.right)<3&&Math.abs(point.y-(card.top+card.height/2))<3}),true);
|
||||
const worldAt=()=>page.locator('[class*=world]').evaluate(el=>{const m=new DOMMatrix(getComputedStyle(el).transform);return {x:(1450-m.e)/m.a,y:(150-m.f)/m.d,z:m.a}});
|
||||
const anchorBefore=await worldAt();await page.mouse.move(1450,150);await page.keyboard.down('Control');await page.mouse.wheel(0,-80);await page.keyboard.up('Control');
|
||||
await page.waitForFunction(z=>new DOMMatrix(getComputedStyle(document.querySelector('[class*=world]')).transform).a!==z,anchorBefore.z);
|
||||
const anchorAfter=await worldAt();close(anchorBefore.x,anchorAfter.x);close(anchorBefore.y,anchorAfter.y);
|
||||
const saved=await snapshot();await page.reload({waitUntil:'commit',timeout:60000});await page.locator('button[data-theme-ready="true"]').waitFor();await page.waitForFunction(()=>!!localStorage.getItem('studio.infinite-canvas.v1'));assert.deepEqual(await snapshot(),saved);
|
||||
const themeToggle=page.getByRole('button',{name:'切换明暗主题',exact:true});
|
||||
await page.waitForFunction(()=>document.querySelector('[data-theme-ready]')?.dataset.themeReady==='true');
|
||||
const initialMode=await page.evaluate(()=>document.documentElement.dataset.mode);
|
||||
assert.ok(['light','dark'].includes(initialMode));
|
||||
for(const mode of [initialMode==='light'?'dark':'light',initialMode]){
|
||||
await themeToggle.click();
|
||||
const matchesTheme=mode=>{
|
||||
const root=document.documentElement,canvas=document.querySelector('main');
|
||||
return root.dataset.mode===mode&&root.dataset.theme===`canvas-${mode}`&&localStorage.getItem('endoleap.theme')===mode&&getComputedStyle(canvas).backgroundColor===(mode==='dark'?'rgb(20, 20, 20)':'rgb(250, 250, 250)');
|
||||
};
|
||||
await page.waitForFunction(matchesTheme,mode);
|
||||
await page.reload({waitUntil:'commit'});
|
||||
await page.waitForFunction(()=>document.querySelector('[data-theme-ready]')?.dataset.themeReady==='true');
|
||||
await page.waitForFunction(matchesTheme,mode);
|
||||
assert.deepEqual(await snapshot(),saved,'Theme changes must preserve canvas content');
|
||||
await capture('canvas-theme-'+mode);
|
||||
}
|
||||
if(process.env.SCREENSHOT_DIR){await fs.mkdir(process.env.SCREENSHOT_DIR,{recursive:true});await page.screenshot({path:process.env.SCREENSHOT_DIR+'/canvas-nodes-desktop.png'});}
|
||||
await page.setViewportSize({width:390,height:844});assert.ok(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth));
|
||||
const viewport=await page.locator('[class*=viewport]').boundingBox();assert.equal(viewport.width,390);assert.equal(viewport.height,844);
|
||||
if(process.env.SCREENSHOT_DIR)await page.screenshot({path:process.env.SCREENSHOT_DIR+'/canvas-nodes-mobile.png'});
|
||||
assert.deepEqual(errors,[]);console.log('Passed: selected-node re-click editing, drag, formatting, fixed composer, upload, references, task export, deletion/undo, reload, both persistent root themes and mobile bounds');
|
||||
}catch(error){if(process.env.SCREENSHOT_DIR&&page){await fs.mkdir(process.env.SCREENSHOT_DIR,{recursive:true});await page.screenshot({path:process.env.SCREENSHOT_DIR+'/canvas-failure.png'}).catch(()=>{})}throw error}finally{await browser.close()}
|
||||
})().catch(e=>{console.error(e);process.exitCode=1});
|
||||
@@ -1,4 +0,0 @@
|
||||
import { cp,mkdir,access } from 'node:fs/promises';
|
||||
await mkdir('.next/standalone/apps/waterfall-wiki/.next',{recursive:true});
|
||||
await cp('.next/static','.next/standalone/apps/waterfall-wiki/.next/static',{recursive:true});
|
||||
try{await access('public');await cp('public','.next/standalone/apps/waterfall-wiki/public',{recursive:true});}catch(e){if(e.code!=='ENOENT')throw e;}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { db } from "../src/lib/db";
|
||||
import { publishLearningSeries } from "../src/lib/publish-learning-series";
|
||||
publishLearningSeries().then(result=>console.log(`Published learning series: ${result.published.join(", ")}; one shared cover: ${result.coverId}`))
|
||||
.catch(error=>{console.error("Learning series publication failed:",error instanceof Error?error.message:"Unknown error");process.exitCode=1;})
|
||||
.finally(()=>db().end());
|
||||
@@ -1,65 +0,0 @@
|
||||
import {writeFile,unlink} from 'node:fs/promises';
|
||||
import sharp from 'sharp';
|
||||
import { spawn } from "node:child_process";
|
||||
import assert from "node:assert/strict";
|
||||
import { setTimeout } from "node:timers/promises";
|
||||
import { createServer } from "node:net";
|
||||
import { once } from "node:events";
|
||||
|
||||
async function availablePort() {
|
||||
const server = createServer();
|
||||
server.listen(0, "127.0.0.1");
|
||||
await once(server, "listening");
|
||||
const port = server.address().port;
|
||||
await new Promise((resolve, reject) => server.close(error => error ? reject(error) : resolve()));
|
||||
return String(port);
|
||||
}
|
||||
|
||||
const port = process.env.SMOKE_PORT || await availablePort();
|
||||
const release = "smoke-test";
|
||||
const previewFixture='.next/standalone/apps/waterfall-wiki/public/__smoke-preview.png';
|
||||
await writeFile(previewFixture,await sharp({create:{width:48,height:48,channels:3,background:'#eee'}}).png().toBuffer());
|
||||
const child = spawn(process.execPath, [".next/standalone/apps/waterfall-wiki/server.js"], {
|
||||
// This loopback-only child checks retained resources without production credentials.
|
||||
// Production keeps failing closed; the bypass requires both explicit test settings.
|
||||
env: { ...process.env, AUTH_CONFIG_FILE: "", AUTH_DISABLED_FOR_TESTS: "true", PORT: port, HOSTNAME: "127.0.0.1", RELEASE_SHA: release },
|
||||
stdio: "inherit",
|
||||
});
|
||||
try {
|
||||
let ready = false;
|
||||
for (let i = 0; i < 40; i++) {
|
||||
if (child.exitCode !== null) throw new Error(`Server exited: ${child.exitCode}`);
|
||||
try {
|
||||
const response = await fetch(`http://127.0.0.1:${port}/api/health`);
|
||||
assert.equal(response.status, 200);
|
||||
assert.deepEqual(await response.json(), { status: "ok", service: "prompt", release });
|
||||
ready = true;
|
||||
break;
|
||||
} catch { await setTimeout(500); }
|
||||
}
|
||||
assert.ok(ready, "Standalone production server must be healthy");
|
||||
const response = await fetch(`http://127.0.0.1:${port}/api/health`, { method: "POST" });
|
||||
assert.equal(response.status, 405);
|
||||
const indexResponse=await fetch(`http://127.0.0.1:${port}/api/wiki-index`);
|
||||
assert.equal(indexResponse.status,200);
|
||||
const indexText=await indexResponse.text();
|
||||
const id=indexText.match(/\/explore\/([a-f0-9]{24})/)?.[1];assert.ok(id);
|
||||
const agent=await fetch(`http://127.0.0.1:${port}/explore/${id}?agent_mode=True`);
|
||||
assert.equal(agent.status,200);assert.match(agent.headers.get('content-type'),/text\/plain/);const agentText=await agent.text();assert.match(agentText,/^# /);assert.doesNotMatch(agentText,/^\s*(?:<!doctype html|<html)/i);
|
||||
const preview=await fetch(`http://127.0.0.1:${port}/api/preview?source=${encodeURIComponent('/__smoke-preview.png')}&width=480`);
|
||||
assert.equal(preview.status,200);assert.match(preview.headers.get('content-type'),/image\/webp/);assert.ok((await preview.arrayBuffer()).byteLength<=100000);
|
||||
const home=await fetch(`http://127.0.0.1:${port}/`).then(r=>r.text());
|
||||
for(const href of ['/graphic','/infinite_canvas','/account?next=/workspace'])assert.ok(home.includes(`href="${href}"`),href);
|
||||
for(const [legacy,canonical] of [['/studio/graphic','/graphic'],['/studio/infinite_canvas','/infinite_canvas']]){
|
||||
const redirect=await fetch(`http://127.0.0.1:${port}${legacy}`,{redirect:'manual'});
|
||||
assert.equal(redirect.status,308);assert.equal(redirect.headers.get('location'),canonical);
|
||||
}
|
||||
console.log("Production smoke test passed, including landing entry points and canonical redirects");
|
||||
} finally {
|
||||
await unlink(previewFixture);
|
||||
if (child.exitCode === null && child.signalCode === null) {
|
||||
const exited = once(child, "exit");
|
||||
child.kill("SIGTERM");
|
||||
await exited;
|
||||
}
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
/** CI-only isolated runtime checks, never a local preview workflow. */
|
||||
import {readFileSync} from 'node:fs';
|
||||
import {spawn} from 'node:child_process';import {once} from 'node:events';import {createServer} from 'node:net';import {createServer as createHttpServer} from 'node:http';import assert from 'node:assert/strict';import {setTimeout} from 'node:timers/promises';
|
||||
const designId='00000000-0000-4000-8000-000000000001',missingId='00000000-0000-4000-8000-000000000002';
|
||||
const standardId='00000000-0000-4000-8000-000000000003';
|
||||
const seriesId='00000000-0000-4000-8000-000000000004';
|
||||
const doc=JSON.parse(readFileSync(new URL('../fixtures/autumn-journal.json',import.meta.url))).document;
|
||||
const assetId='00000000-0000-4000-8000-000000000005';doc.sources=[{assetId,author:'CI fixture',license:'test only',url:'generated',prompt:'test image',tool:'fixture'}];for(const p of doc.pages)p.parameters.images.I01.assetId=assetId;
|
||||
let reads=0,mediaReads=0;
|
||||
const wiki=createHttpServer((req,res)=>{assert.equal(req.headers.cookie,'session=smoke-test');if(/^\/api\/media\/[0-9a-f-]{36}$/.test(req.url)){mediaReads++;res.setHeader('Content-Type','image/png');return res.end(Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+j3ioAAAAASUVORK5CYII=','base64'))}reads++;res.setHeader('Content-Type','application/json');if(req.url.endsWith(seriesId))return res.end(JSON.stringify({design:{id:seriesId,name:'完整三页作品',layoutId:'standard-01',revision:1,url:'/studio/template-layout?id='+seriesId,parameters:doc.pages[0].parameters,document:doc}}));const standard=req.url.endsWith(standardId);if(!standard&&!req.url.endsWith(designId)){res.writeHead(404);return res.end('{}')}res.end(JSON.stringify({design:{id:standard?standardId:designId,name:'已保存的实际页面',layoutId:standard?'standard-01':'ref-01',revision:3,url:'/studio/designs/'+designId,parameters:{added:[{id:'user-smoke',kind:'text',role:'Added text',box:[10,10,20,10],direction:'horizontal',text:'新增元素已恢复',reading:'custom'}],layers:{T01:{x:12,rotation:25,opacity:.7},'user-smoke':{z:12}},texts:{T01:{value:'确定的页面文字',color:'#123456'}},images:{I01:{color:'#abcdef'}},decorations:{D01:{hidden:true}},paper:'#eeeeee'}}}))});
|
||||
wiki.listen(0,'127.0.0.1');await once(wiki,'listening');
|
||||
const socket=createServer();socket.listen(0,'127.0.0.1');await once(socket,'listening');const port=socket.address().port;await new Promise(r=>socket.close(r));const child=spawn(process.execPath,[process.env.STUDIO_STANDALONE_SERVER || '.next/standalone/apps/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('<'));}
|
||||
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))}
|
||||
@@ -1,6 +0,0 @@
|
||||
/** Extra editorial covers are allowed; every original seed image must remain attached. */
|
||||
export function requiredPackImages(hashes,images){
|
||||
const remaining=[...images],originals=[];
|
||||
for(const hash of hashes){const index=remaining.findIndex(image=>image.sha256===hash);if(index<0)throw Error('Original seed image missing from character pack');originals.push(...remaining.splice(index,1));}
|
||||
return originals;
|
||||
}
|
||||
@@ -1,8 +1,7 @@
|
||||
import { randomBytes, scrypt as derive, timingSafeEqual, createHash } from 'node:crypto';
|
||||
import { promisify } from 'node:util';
|
||||
const scrypt = promisify(derive);
|
||||
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 ACCOUNT_COOKIE = 'wiki_account';
|
||||
export const SESSION_TTL = 7 * 86400;
|
||||
export function normalizeEmail(value: unknown) {
|
||||
if (typeof value !== 'string') return null;
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { createHash, createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
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_COOKIE = 'prompt_session';
|
||||
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
@@ -1,14 +0,0 @@
|
||||
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),permanent:false}};
|
||||
return {redirect:{destination:designUrl(design.layoutId,design.id).slice('/studio'.length),permanent:false}};
|
||||
}
|
||||
return {props:{layout:nativeLayout(layout)}};
|
||||
};
|
||||
|
||||
@@ -2,7 +2,6 @@ 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://waterfall-wiki:3000';
|
||||
const origin=process.env.STUDIO_WIKI_ORIGIN||`http://127.0.0.1:${process.env.PORT || '3000'}`;
|
||||
const headers:Record<string,string>={};
|
||||
if(req.headers.cookie)headers.cookie=req.headers.cookie;
|
||||
if(req.headers.authorization)headers.authorization=req.headers.authorization;
|
||||
|
||||
@@ -4,7 +4,7 @@ import type {Parameters} from './layout-parameters';
|
||||
export async function embedLayoutImages(req:NextApiRequest,params:Parameters){
|
||||
const ids=[...new Set(Object.values(params.images).flatMap(v=>v.assetId?[v.assetId]:[]))],sources:Record<string,string>={};let total=0;
|
||||
const headers:Record<string,string>={};if(req.headers.authorization)headers.authorization=req.headers.authorization;if(req.headers.cookie)headers.cookie=req.headers.cookie;
|
||||
for(const id of ids){const url=(process.env.STUDIO_WIKI_ORIGIN||'http://waterfall-wiki:3000')+'/api/preview?'+new URLSearchParams({source:'/api/media/'+id,width:'960'}),r=await fetch(url,{headers,signal:AbortSignal.timeout(15000),redirect:'error'});if(!r.ok||r.headers.get('content-type')?.split(';')[0]!=='image/webp')throw Error('图片预览不可访问,请检查图片 ID 与登录状态');const chunks:Buffer[]=[];const reader=r.body!.getReader();try{while(true){const {done,value}=await reader.read();if(done)break;total+=value.length;if(total>16*1024*1024)throw Error('图片预览总计超限');chunks.push(Buffer.from(value))}}finally{await reader.cancel()}sources[id]='data:image/webp;base64,'+Buffer.concat(chunks).toString('base64');}
|
||||
for(const id of ids){const url=(process.env.STUDIO_WIKI_ORIGIN||`http://127.0.0.1:${process.env.PORT || '3000'}`)+'/api/preview?'+new URLSearchParams({source:'/api/media/'+id,width:'960'}),r=await fetch(url,{headers,signal:AbortSignal.timeout(15000),redirect:'error'});if(!r.ok||r.headers.get('content-type')?.split(';')[0]!=='image/webp')throw Error('图片预览不可访问,请检查图片 ID 与登录状态');const chunks:Buffer[]=[];const reader=r.body!.getReader();try{while(true){const {done,value}=await reader.read();if(done)break;total+=value.length;if(total>16*1024*1024)throw Error('图片预览总计超限');chunks.push(Buffer.from(value))}}finally{await reader.cancel()}sources[id]='data:image/webp;base64,'+Buffer.concat(chunks).toString('base64');}
|
||||
return sources;
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ export async function embedSvgMedia(req:NextApiRequest,svg:string){
|
||||
const headers:Record<string,string>={};if(req.headers.authorization)headers.authorization=req.headers.authorization;if(req.headers.cookie)headers.cookie=req.headers.cookie;
|
||||
let total=0;
|
||||
for(const path of paths){
|
||||
const response=await fetch((process.env.STUDIO_WIKI_ORIGIN||'http://waterfall-wiki:3000')+path,{headers,redirect:'error',signal:AbortSignal.timeout(15000)});
|
||||
const response=await fetch((process.env.STUDIO_WIKI_ORIGIN||`http://127.0.0.1:${process.env.PORT || '3000'}`)+path,{headers,redirect:'error',signal:AbortSignal.timeout(15000)});
|
||||
const mime=response.headers.get('content-type')?.split(';')[0];
|
||||
if(!response.ok||!mime||!['image/png','image/jpeg','image/webp'].includes(mime))throw Error('模板图片不可访问');
|
||||
const chunks:Uint8Array[]=[];const reader=response.body!.getReader();
|
||||
|
||||
@@ -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-: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}
|
||||
.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}
|
||||
@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-:scope{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-body{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}}
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { db } from '../src/lib/db';
|
||||
import { hashPassword,newToken,tokenDigest } from '../src/lib/account-crypto';
|
||||
import { resetPassword,loginAccount,verifyAccount,currentAccount,logoutAccount,rateLimit } from '../src/lib/account';
|
||||
export async function verifyEmailAccounts(base: string) {
|
||||
const id=randomUUID(), email=id+'@example.com', password='a secure test password';
|
||||
const request=(token:string)=>new Request('https://wiki.example',{headers:{cookie:'wiki_account='+token}});
|
||||
try {
|
||||
await db().query('INSERT INTO account_users(id,email,password_hash) VALUES ($1,$2,$3)',[id,email,await hashPassword(password)]);
|
||||
assert.equal(await loginAccount(email,password),null,'unverified accounts cannot log in');
|
||||
const verification=newToken();await db().query("INSERT INTO account_tokens VALUES ($1,$2,'verify',now()+interval '1 minute')",[tokenDigest(verification),id]);
|
||||
const results=await Promise.all([verifyAccount(verification),verifyAccount(verification)]);assert.deepEqual(results.sort(),[false,true]);
|
||||
assert.equal(await loginAccount(email,'incorrect password'),null);
|
||||
const token=await loginAccount(email,password);assert.ok(token);
|
||||
assert.equal((await currentAccount(request(token))).id,id);
|
||||
await db().query("UPDATE account_tokens SET expires_at=now()-interval '1 second' WHERE digest=$1",[tokenDigest(token)]);
|
||||
assert.equal(await currentAccount(request(token)),null,'expired sessions rejected');
|
||||
const fresh=await loginAccount(email,password);assert.ok(fresh);await logoutAccount(request(fresh));assert.equal(await currentAccount(request(fresh)),null,'logout revokes token');
|
||||
const oldSession=await loginAccount(email,password);assert.ok(oldSession);
|
||||
const resets=[newToken(),newToken()],replacement='a new secure test password';
|
||||
for(const value of resets)await db().query("INSERT INTO account_tokens VALUES ($1,$2,'reset',now()+interval '30 minutes')",[tokenDigest(value),id]);
|
||||
const resetResults=await Promise.all(resets.map(value=>resetPassword(value,replacement)));
|
||||
assert.deepEqual(resetResults.sort(),[false,true],'Only one concurrent reset may succeed for one account');
|
||||
assert.equal(await currentAccount(request(oldSession)),null,'Reset revokes existing sessions');
|
||||
assert.equal(await loginAccount(email,password),null,'Original password is no longer accepted');
|
||||
assert.ok(await loginAccount(email,replacement));
|
||||
assert.equal(await resetPassword(resets[0],password),false,'Reset links are single-use, including sibling links');
|
||||
const expired=newToken();await db().query("INSERT INTO account_tokens VALUES ($1,$2,'reset',now()-interval '1 minute')",[tokenDigest(expired),id]);
|
||||
assert.equal(await resetPassword(expired,password),false);
|
||||
const wrongKind=newToken();await db().query("INSERT INTO account_tokens VALUES ($1,$2,'verify',now()+interval '30 minutes')",[tokenDigest(wrongKind),id]);
|
||||
assert.equal(await resetPassword(wrongKind,password),false,'Email verification cannot reset a password');
|
||||
assert.equal(await resetPassword(newToken(),'short'),false);
|
||||
const post=(action:string,body:object,origin=process.env.ACCOUNT_PUBLIC_ORIGIN||base)=>fetch(base+'/api/account/'+action,{method:'POST',headers:{'Content-Type':'application/json',Origin:origin},body:JSON.stringify(body)});
|
||||
assert.equal((await post('forgot',{email},'https://untrusted.invalid')).status,403,'Cross-origin recovery is rejected');
|
||||
const unknown=await post('forgot',{email:randomUUID()+'@example.com'});
|
||||
assert.equal(unknown.status,200,'Unknown email receives the same non-enumerating response without sending mail');
|
||||
assert.match((await unknown.json()).message,/若该邮箱/);
|
||||
const httpToken=newToken(),httpPassword='http replacement secure password';
|
||||
const priorHttpSession=await loginAccount(email,replacement);assert.ok(priorHttpSession);
|
||||
await db().query("INSERT INTO account_tokens VALUES ($1,$2,'reset',now()+interval '30 minutes')",[tokenDigest(httpToken),id]);
|
||||
const changed=await post('reset',{token:httpToken,password:httpPassword});
|
||||
assert.equal(changed.status,200);
|
||||
assert.match(changed.headers.get('set-cookie')||'',/Max-Age=0/);
|
||||
assert.equal(await currentAccount(request(priorHttpSession)),null);
|
||||
assert.ok(await loginAccount(email,httpPassword),'HTTP recovery changes the stored password');
|
||||
assert.equal((await post('reset',{token:httpToken,password:replacement})).status,400,'HTTP recovery rejects a consumed token');
|
||||
const key=randomUUID();assert.deepEqual(await Promise.all(Array.from({length:5},()=>rateLimit(key,3))).then(a=>a.sort()),[false,false,true,true,true]);
|
||||
await db().query('DELETE FROM account_limits WHERE key=$1',[tokenDigest(key)]);
|
||||
} finally {await db().query('DELETE FROM account_users WHERE id=$1',[id]);}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
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/);});
|
||||
@@ -1,64 +0,0 @@
|
||||
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),/原生页面/);
|
||||
}
|
||||
});
|
||||
@@ -1,28 +0,0 @@
|
||||
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));
|
||||
});
|
||||
@@ -1,62 +0,0 @@
|
||||
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));
|
||||
});
|
||||
@@ -1,70 +0,0 @@
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
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']);
|
||||
});
|
||||
@@ -1,75 +0,0 @@
|
||||
/** 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(); }
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
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/);
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
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()}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
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)});
|
||||
@@ -1,31 +0,0 @@
|
||||
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\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);
|
||||
});
|
||||
@@ -1,17 +0,0 @@
|
||||
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);
|
||||
});
|
||||
@@ -1,38 +0,0 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
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);
|
||||
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
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('&','&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 > b','a > b')),value);assert.throws(()=>parseDocumentXml(xml.replace('when="a > b"','when="a < b"')));});
|
||||
@@ -1,380 +0,0 @@
|
||||
/** 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();
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
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(); }
|
||||
});
|
||||
@@ -1,16 +0,0 @@
|
||||
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('<script> & "quoted"'));assert.ok(xml.includes('when="a" b< c&"'));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'));
|
||||
});
|
||||
@@ -1,15 +0,0 @@
|
||||
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);
|
||||
});
|
||||
@@ -1,311 +0,0 @@
|
||||
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();
|
||||
}
|
||||
});
|
||||
@@ -1,16 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -1,8 +0,0 @@
|
||||
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);});
|
||||
@@ -1,18 +0,0 @@
|
||||
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');
|
||||
assert.ok(!html.includes('<script>'));assert.ok(!html.includes('href="javascript:'));assert.ok(!html.includes('<img'));assert.match(html,/href="\/api\/media\/example"/);
|
||||
});
|
||||
@@ -1,8 +0,0 @@
|
||||
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)});
|
||||
@@ -1,59 +0,0 @@
|
||||
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);
|
||||
}
|
||||
});
|
||||
@@ -1,65 +0,0 @@
|
||||
import test from 'node:test';
|
||||
import {readFileSync} from 'node:fs';
|
||||
import assert from 'node:assert/strict';
|
||||
import {parseCanvasDocument,deriveNode,connectNodes,removeCanvasNode,referencedNodes,agentTask} from '../../src/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");
|
||||
});
|
||||
@@ -1,8 +0,0 @@
|
||||
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}]})));});
|
||||
@@ -1,27 +0,0 @@
|
||||
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;}
|
||||
});
|
||||
@@ -1,7 +0,0 @@
|
||||
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))});
|
||||
@@ -1,19 +0,0 @@
|
||||
import {test} from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {readFileSync} from 'node:fs';
|
||||
import {nativeLayout,defaultText,textSize,imageShape,designUrl,newElementId} from '../../src/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)}});
|
||||
@@ -1,11 +0,0 @@
|
||||
import {test} from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {readFileSync} from 'node:fs';
|
||||
import {registerHooks} from 'node:module';
|
||||
registerHooks({resolve(specifier,context,next){try{return next(specifier,context)}catch(e){if(e.code==='ERR_MODULE_NOT_FOUND'&&specifier.startsWith('.'))return next(specifier+'.ts',context);throw e}}});
|
||||
const {series,pageParameters}=await import('../../src/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]))}});
|
||||
@@ -1,5 +0,0 @@
|
||||
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
@@ -1,127 +0,0 @@
|
||||
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:
|
||||
@@ -1,19 +0,0 @@
|
||||
# 原图排版的本地创作数据
|
||||
|
||||
本目录是发布客户端的输入,不在 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
@@ -1,18 +0,0 @@
|
||||
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
@@ -1,493 +0,0 @@
|
||||
[
|
||||
{
|
||||
"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" : "蓝羽毛-版式"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -1,30 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
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>;
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
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.');
|
||||
@@ -1,163 +0,0 @@
|
||||
{
|
||||
"schemaVersion": 2,
|
||||
"name": "把秋天装进一页 · 银杏观察手记",
|
||||
"document": {
|
||||
"version": 1,
|
||||
"brief": {
|
||||
"topic": "银杏与秋日观察手记",
|
||||
"audience": "喜欢日常摄影与视觉手帐的小红书读者",
|
||||
"intent": "以封面、观察方法和行动结尾组成三页可发布图文",
|
||||
"designIntent": "奶油纸底、森林墨色、银杏金点缀;宋体大标题与克制的无衬线正文;所有页面保留相同页边距和节奏",
|
||||
"imageStrategy": "生成一幅原创银杏静物插画作为系列母图;每页复用素材,保留裁切配置,不把正文烘焙进图片"
|
||||
},
|
||||
"style": {
|
||||
"version": "autumn-journal/1",
|
||||
"palette": {
|
||||
"paper": "#F3EFE5",
|
||||
"ink": "#253C32",
|
||||
"muted": "#718074",
|
||||
"accent": "#C3A050"
|
||||
},
|
||||
"typography": {
|
||||
"title": {
|
||||
"fontFamily": "serif",
|
||||
"fontSize": 8.4,
|
||||
"fontWeight": 600,
|
||||
"lineHeight": 1.15,
|
||||
"letterSpacing": 0
|
||||
},
|
||||
"body": {
|
||||
"fontFamily": "sans",
|
||||
"fontSize": 3.1,
|
||||
"fontWeight": 400,
|
||||
"lineHeight": 1.65,
|
||||
"letterSpacing": 0
|
||||
},
|
||||
"caption": {
|
||||
"fontFamily": "sans",
|
||||
"fontSize": 1.7,
|
||||
"fontWeight": 500,
|
||||
"lineHeight": 1.2,
|
||||
"letterSpacing": 0
|
||||
}
|
||||
},
|
||||
"spacing": {
|
||||
"margin": 10,
|
||||
"gap": 4
|
||||
}
|
||||
},
|
||||
"sources": [],
|
||||
"pages": [
|
||||
{
|
||||
"key": "cover",
|
||||
"role": "cover",
|
||||
"layoutId": "standard-01",
|
||||
"intent": "用银杏静物与两行大标题建立主题",
|
||||
"bindings": {
|
||||
"T01": "title",
|
||||
"T02": "body",
|
||||
"T03": "caption"
|
||||
},
|
||||
"parameters": {
|
||||
"texts": {
|
||||
"T01": {
|
||||
"value": "把秋天\n装进一页"
|
||||
},
|
||||
"T02": {
|
||||
"value": "不必走很远。\n一片银杏,就是今天的风景。"
|
||||
},
|
||||
"T03": {
|
||||
"value": "SEASONAL NOTES / 01"
|
||||
}
|
||||
},
|
||||
"images": {
|
||||
"I01": {
|
||||
"fit": "cover",
|
||||
"cropX": 50,
|
||||
"cropY": 50,
|
||||
"zoom": 1
|
||||
}
|
||||
},
|
||||
"decorations": {}
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "observe",
|
||||
"role": "body",
|
||||
"layoutId": "standard-01",
|
||||
"intent": "用三个观察动作提供可实践的方法",
|
||||
"bindings": {
|
||||
"T01": "title",
|
||||
"T02": "body",
|
||||
"T03": "caption"
|
||||
},
|
||||
"parameters": {
|
||||
"texts": {
|
||||
"T01": {
|
||||
"value": "先看见\n再记录"
|
||||
},
|
||||
"T02": {
|
||||
"value": "看叶脉,看光落下的方向。\n留住一片金黄,也留出一半空白。\n把感受写成短句,画面就有了呼吸。"
|
||||
},
|
||||
"T03": {
|
||||
"value": "FIELD NOTES / 02"
|
||||
}
|
||||
},
|
||||
"images": {
|
||||
"I01": {
|
||||
"fit": "cover",
|
||||
"cropX": 65,
|
||||
"cropY": 40,
|
||||
"zoom": 1.25
|
||||
}
|
||||
},
|
||||
"decorations": {}
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "ending",
|
||||
"role": "ending",
|
||||
"layoutId": "standard-01",
|
||||
"intent": "以轻量行动和提问收尾",
|
||||
"bindings": {
|
||||
"T01": "title",
|
||||
"T02": "body",
|
||||
"T03": "caption"
|
||||
},
|
||||
"parameters": {
|
||||
"texts": {
|
||||
"T01": {
|
||||
"value": "留一点白\n给下一次",
|
||||
"align": "middle"
|
||||
},
|
||||
"T02": {
|
||||
"value": "今天,捡起一片叶子。\n用三个词记录它的颜色、纹理与心情。\n你的秋天,会从哪一页开始?",
|
||||
"align": "middle"
|
||||
},
|
||||
"T03": {
|
||||
"value": "YOUR TURN / 03"
|
||||
}
|
||||
},
|
||||
"images": {
|
||||
"I01": {
|
||||
"fit": "cover",
|
||||
"cropX": 65,
|
||||
"cropY": 50,
|
||||
"zoom": 1
|
||||
}
|
||||
},
|
||||
"decorations": {},
|
||||
"layers": {
|
||||
"I01": {
|
||||
"x": 40,
|
||||
"y": 55,
|
||||
"width": 40,
|
||||
"height": 40,
|
||||
"radius": 20
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
# 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导出代替视觉验收。
|
||||
@@ -1,216 +0,0 @@
|
||||
# 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}}
|
||||
@@ -1,129 +0,0 @@
|
||||
这篇 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
@@ -1,16 +0,0 @@
|
||||
# 业务 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`,随创作站独立部署。
|
||||
@@ -1,21 +0,0 @@
|
||||
# 构建与入口
|
||||
|
||||
人类默认从 `/` 进入;对应 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 排队任务须单独取消,不能将其当作新触发规则仍然生效的证据。
|
||||
@@ -1,33 +0,0 @@
|
||||
# 数据模型与网址约定
|
||||
|
||||
唯一具有树形结构的是 `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` 记录;迁移过程加会话锁。不能通过应用回退删除数据库或对象卷。
|
||||
@@ -1,23 +0,0 @@
|
||||
# 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 启动方式;这类方案需要单独确认本地运行和编译授权。
|
||||
@@ -1,46 +0,0 @@
|
||||
# 动态码登录与 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.
|
Before Width: | Height: | Size: 139 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 150 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 44 KiB |
@@ -1,55 +0,0 @@
|
||||
# 本阶段逐文件变更说明
|
||||
|
||||
- `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 像素档位。
|
||||
@@ -1,35 +0,0 @@
|
||||
# 扁平笔记、标签与统一资源:变更说明
|
||||
|
||||
本阶段保留 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 源码未修改。
|
||||
@@ -1,19 +0,0 @@
|
||||
# 指导文档的两种呈现
|
||||
|
||||
指导文档共用一份内容。每篇都有独立网页 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 引用,同时链接回各自的具体验收协议。它证明引用可以跨分组,不复制内容。
|
||||
@@ -1,34 +0,0 @@
|
||||
# 学习资料自己做
|
||||
|
||||
三个代码编写的图文作品,每篇 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 张当前版本截图全部完成才确认发布。没有本地浏览器预览。
|
||||
@@ -1,38 +0,0 @@
|
||||
# 交付验收记录
|
||||
|
||||
所有 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 或本地夹具截图称为新版线上验证。
|
||||
@@ -1,67 +0,0 @@
|
||||
# 页面、组件与功能设计
|
||||
|
||||
## 产品单位
|
||||
|
||||
本项目的核心单位是一个可直接打开、引用和采用的文档版本。作者、集合和站点负责组织与发现;它们不构成必须一起下载或使用的安装包。用户把单篇地址交给 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 与实际服务器结果都要保存;截图或测试中出现问题就继续修订,而不是把原型样例数量当作交付完成度。
|
||||
@@ -1,89 +0,0 @@
|
||||
# 从素材瀑布流到独立指导文档网络
|
||||
|
||||
## 研究状态(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,研究观察与功能验证分开记录。
|
||||
@@ -1,56 +0,0 @@
|
||||
# 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
Reference in New Issue
Block a user