Compare commits
78 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 558039a8de | |||
| ef290f769b | |||
| 38a71e04ca | |||
| 7f397fd3a2 | |||
| 5a317008df | |||
| 602263084f | |||
| 5b4644749d | |||
| 7656f0438c | |||
| ca08f44137 | |||
| b31a4835ee | |||
| fd96b6d9e9 | |||
| ee72835ea3 | |||
| 6800a1b1c4 | |||
| 1da1114d52 | |||
| 8c48812a11 | |||
| 88d68f3d3c | |||
| 6819db40a8 | |||
| b2381a1cdf | |||
| 150e4b3af8 | |||
| e36b8aea6c | |||
| 517c31ca04 | |||
| fafdae66bb | |||
| a45d43f5f7 | |||
| 5b68c5b84e | |||
| 5c068ace81 | |||
| f4397db869 | |||
| 9e5c5abbdf | |||
| 8b949484c1 | |||
| 9887a5958a | |||
| cc6b376f56 | |||
| cfa3d426a0 | |||
| f7c6681c47 | |||
| b76642e06d | |||
| 2570ea13bd | |||
| d316b0de03 | |||
| 2a324c4d19 | |||
| 628449735b | |||
| 7dca4495bb | |||
| 30ac83cd85 | |||
| aa3c0cf11d | |||
| ada5338af5 | |||
| a3935c5adc | |||
| abc6eafd0c | |||
| 861f1b13f6 | |||
| 8062dbc878 | |||
| ff91a281f1 | |||
| 74e058bb44 | |||
| c405f47916 | |||
| 0849268232 | |||
| ab5ea7876b | |||
| a589f51698 | |||
| 40c1023a86 | |||
| 2cc6f1f543 | |||
| a3435c463f | |||
| fcc0aba810 | |||
| cdcd4c4b94 | |||
| 041620e2a5 | |||
| dd367e40ac | |||
| 3d48f5042d | |||
| 71da117335 | |||
| d2eaabe0da | |||
| 95673d5c54 | |||
| a0c20972ac | |||
| 0c8cf7c4cd | |||
| f4f0d67306 | |||
| fb6f29db76 | |||
| 2bcd5992eb | |||
| 15542e977a | |||
| 5ab0ca78b2 | |||
| 8ab67def5f | |||
| 8944e59ead | |||
| 67dea99cd1 | |||
| de851085dd | |||
| af4e9b13ed | |||
| b1e6083996 | |||
| 7f7fc28e57 | |||
| bd8ccbb4a1 | |||
| d072aeeaa0 |
@@ -0,0 +1,27 @@
|
||||
name: Endoleap brand checks
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'packages/brand/**'
|
||||
- 'pnpm-workspace.yaml'
|
||||
- 'package.json'
|
||||
- 'AGENTS.md'
|
||||
- '.gitea/workflows/brand.yml'
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'packages/brand/**'
|
||||
- 'pnpm-workspace.yaml'
|
||||
- '.gitea/workflows/brand.yml'
|
||||
concurrency:
|
||||
group: brand-${{ gitea.event.pull_request.number || gitea.ref }}
|
||||
cancel-in-progress: true
|
||||
jobs:
|
||||
verify:
|
||||
runs-on: ces-control
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Verify brand assets, Tailwind compilation and typed exports in Docker
|
||||
run: docker build -f packages/brand/Dockerfile -t "endoleap-brand-check:${{ gitea.sha }}" .
|
||||
@@ -7,10 +7,39 @@ on:
|
||||
- 'apps/waterfall-wiki/**'
|
||||
- '.gitea/workflows/**'
|
||||
- 'scripts/*studio*'
|
||||
- 'compose.creative-review.yml'
|
||||
- 'scripts/integration.sh'
|
||||
- 'scripts/cleanup-integration.sh'
|
||||
- 'tests/test_integration_cleanup.py'
|
||||
- 'scripts/cleanup-review-images.sh'
|
||||
- 'tests/test_review_image_cleanup.py'
|
||||
concurrency:
|
||||
group: creative-workspace-${{ gitea.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
jobs:
|
||||
verify-deployed-canvas:
|
||||
runs-on: ces-control
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Verify production acceptance against the designated deployed server
|
||||
run: |
|
||||
node --check scripts/verify-studio-visual.cjs
|
||||
node --check apps/studio/scripts/infinite-canvas-smoke.cjs
|
||||
bash -n scripts/verify-studio-production.sh
|
||||
bash scripts/verify-studio-production.sh
|
||||
- name: Save server acceptance evidence
|
||||
id: acceptance_evidence
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: studio-production-acceptance
|
||||
path: artifacts/studio-acceptance/
|
||||
if-no-files-found: ignore
|
||||
retention-days: 7
|
||||
- name: Remove temporary acceptance output after upload
|
||||
if: always() && steps.acceptance_evidence.outcome == 'success'
|
||||
run: rm -rf artifacts/studio-acceptance
|
||||
verify:
|
||||
runs-on: ces-control
|
||||
timeout-minutes: 60
|
||||
@@ -19,7 +48,13 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Check patch formatting
|
||||
run: git diff --check HEAD^ HEAD
|
||||
run: |
|
||||
git diff --check HEAD^ HEAD
|
||||
bash -n scripts/integration.sh scripts/cleanup-integration.sh scripts/verify-studio-production.sh scripts/cleanup-review-images.sh
|
||||
node --check scripts/verify-studio-visual.cjs
|
||||
python3 tests/test_integration_cleanup.py
|
||||
python3 tests/test_review_image_cleanup.py
|
||||
node --test apps/waterfall-wiki/tests/brand-navigation.test.mjs
|
||||
- name: Verify Studio types, tests, build and smoke
|
||||
run: docker build -f apps/studio/Dockerfile -t "creative-studio-review:${{ gitea.sha }}" .
|
||||
- name: Build Wiki integration and runtime images
|
||||
@@ -28,7 +63,18 @@ jobs:
|
||||
docker build --target runtime -t "creative-wiki-review:${{ gitea.sha }}" .
|
||||
- name: Verify isolated PostgreSQL, S3 and browser integration
|
||||
env:
|
||||
STUDIO_REVIEW_IMAGE: creative-studio-review:${{ gitea.sha }}
|
||||
WATERFALL_WIKI_IMAGE: creative-wiki-review:${{ gitea.sha }}
|
||||
WATERFALL_WIKI_TEST_IMAGE: creative-wiki-tests:${{ gitea.sha }}
|
||||
GITEA_RUN_ID: ${{ gitea.run_id }}
|
||||
run: bash scripts/integration.sh
|
||||
- name: Clean this run disposable integration resources even after cancellation
|
||||
if: always()
|
||||
env:
|
||||
GITEA_RUN_ID: ${{ gitea.run_id }}
|
||||
run: bash scripts/cleanup-integration.sh
|
||||
- name: Remove this run disposable review image tags
|
||||
if: always()
|
||||
env:
|
||||
REVIEW_SHA: ${{ gitea.sha }}
|
||||
run: bash scripts/cleanup-review-images.sh
|
||||
|
||||
@@ -16,6 +16,8 @@ on:
|
||||
- 'scripts/infra.sh'
|
||||
- 'scripts/auth-admin.sh'
|
||||
- 'scripts/integration.sh'
|
||||
- 'scripts/cleanup-integration.sh'
|
||||
- 'tests/test_integration_cleanup.py'
|
||||
- 'pnpm-workspace.yaml'
|
||||
|
||||
jobs:
|
||||
@@ -29,6 +31,8 @@ jobs:
|
||||
fetch-depth: 0
|
||||
- name: Check shared content and documentation contracts
|
||||
run: |
|
||||
python3 tests/test_integration_cleanup.py
|
||||
bash -n scripts/integration.sh scripts/cleanup-integration.sh
|
||||
node scripts/check-studio-contracts.mjs
|
||||
node --check scripts/export-studio-series.cjs
|
||||
node --check scripts/save-studio-series.mjs
|
||||
@@ -60,3 +64,8 @@ jobs:
|
||||
- name: Remove temporary acceptance output after upload
|
||||
if: always() && steps.acceptance_evidence.outcome == 'success'
|
||||
run: rm -rf artifacts/studio-acceptance
|
||||
- name: Clean this run disposable integration resources even after cancellation
|
||||
if: always()
|
||||
env:
|
||||
GITEA_RUN_ID: ${{ gitea.run_id }}
|
||||
run: bash scripts/cleanup-integration.sh
|
||||
|
||||
+10
@@ -18,3 +18,13 @@ __pycache__/
|
||||
*.webp
|
||||
*.gif
|
||||
*.avif
|
||||
|
||||
# Approved, versioned brand resources are package source assets.
|
||||
!packages/brand/assets/**/*.png
|
||||
!packages/brand/docs/logo-preview.png
|
||||
/apps/studio/public/brand/
|
||||
/apps/studio/public/favicon.ico
|
||||
/apps/studio/public/apple-touch-icon.png
|
||||
/apps/waterfall-wiki/public/brand/
|
||||
/apps/waterfall-wiki/public/favicon.ico
|
||||
/apps/waterfall-wiki/public/apple-touch-icon.png
|
||||
|
||||
@@ -8,9 +8,14 @@ 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 完成。
|
||||
- 在 ces-control 上执行构建、产物生成和需要编译的检查。本地可执行差异审查及无需构建的轻量检查。
|
||||
- 页面改动经主线部署后,在指定的 [服务器入口](http://8.211.154.134:18131/) 验收。不要启动本地网页预览。
|
||||
- CI 触发条件与发布路径见 [构建与入口](docs/ci-and-entrypoints.md)。
|
||||
|
||||
## 品牌
|
||||
|
||||
- 修改品牌色、主题或 Logo 前,查阅 [共享品牌包](packages/brand/README.md)。
|
||||
- 使用 home-light、home-dark、canvas-light、canvas-dark 四套主题。画布背景保持中性近白或近黑,用户素材保持原色。
|
||||
- 色板和 Logo 轮廓分别以品牌包的 `tokens/palette.json` 和 `tokens/geometry.json` 为准,更新时保持 CSS、类型、资产和清单一致。
|
||||
|
||||
+8
-7
@@ -7,14 +7,14 @@ RUN apt-get update \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
FROM base AS dependencies
|
||||
WORKDIR /repo/apps/waterfall-wiki
|
||||
LABEL io.entroia.wiki.build="true"
|
||||
RUN corepack enable && corepack prepare pnpm@11.25.0 --activate
|
||||
COPY apps/waterfall-wiki/pnpm-lock.yaml ./
|
||||
COPY pnpm-workspace.yaml /tmp/monorepo-policy.yaml
|
||||
RUN node -e "const fs=require('fs');const text=fs.readFileSync('/tmp/monorepo-policy.yaml','utf8');fs.writeFileSync('pnpm-workspace.yaml', text.slice(text.indexOf('allowBuilds:')));"
|
||||
RUN pnpm fetch --frozen-lockfile --fetch-timeout=300000 --network-concurrency=8
|
||||
COPY pnpm-workspace.yaml /repo/pnpm-workspace.yaml
|
||||
COPY packages/brand/ /repo/packages/brand/
|
||||
COPY apps/waterfall-wiki/package.json ./
|
||||
RUN pnpm install --offline --frozen-lockfile
|
||||
RUN pnpm --filter @prompt/waterfall-wiki install --frozen-lockfile --fetch-timeout=300000 --network-concurrency=8
|
||||
|
||||
FROM dependencies AS build
|
||||
# .dockerignore excludes docs and operational notes without extra legacy COPY snapshots.
|
||||
@@ -26,11 +26,12 @@ USER node
|
||||
CMD ["npm", "run", "test:integration"]
|
||||
|
||||
FROM base AS runtime
|
||||
WORKDIR /app/apps/waterfall-wiki
|
||||
ENV NODE_ENV=production HOSTNAME=0.0.0.0 PORT=3000 \
|
||||
FFMPEG_PATH=/usr/bin/ffmpeg FFPROBE_PATH=/usr/bin/ffprobe
|
||||
COPY --from=build --chown=node:node /app/.next/standalone/ ./
|
||||
COPY --from=build --chown=node:node /app/dist/worker.cjs /app/dist/bootstrap.cjs /app/dist/publish-learning-series.cjs /app/dist/auth-cli.cjs ./
|
||||
COPY --from=build --chown=node:node /app/db/ ./db/
|
||||
COPY --from=build --chown=node:node /repo/apps/waterfall-wiki/.next/standalone/ /app/
|
||||
COPY --from=build --chown=node:node /repo/apps/waterfall-wiki/dist/worker.cjs /repo/apps/waterfall-wiki/dist/bootstrap.cjs /repo/apps/waterfall-wiki/dist/publish-learning-series.cjs /repo/apps/waterfall-wiki/dist/auth-cli.cjs ./
|
||||
COPY --from=build --chown=node:node /repo/apps/waterfall-wiki/db/ ./db/
|
||||
COPY --chown=node:node apps/waterfall-wiki/scripts/container-health.mjs ./
|
||||
COPY --chown=node:node apps/waterfall-wiki/scripts/import-character-packs.mjs apps/waterfall-wiki/scripts/verify-character-pack.mjs ./scripts/
|
||||
COPY --chown=node:node apps/waterfall-wiki/seed/characters/ ./seed/characters/
|
||||
|
||||
@@ -1,104 +1,42 @@
|
||||
# PROMPT
|
||||
# 啡跃 Endoleap
|
||||
|
||||
把提示词、参考图、分步文档和创作工具收进同一个可连接的知识库。人可以浏览瀑布流、搜索和编辑;AI 可以从同一篇 post 读取纯文本、图片与 API 说明,再调用 Studio 生成作品。
|
||||
啡跃把笔记、参考素材和创作工作台放在同一个网站。知识库用于浏览、搜索和发布内容,Studio 提供图文排版与无限画布。
|
||||
|
||||
**PROMPT 是网站与项目品牌;`waterfall-wiki` 是知识库应用的内部名称。** 目录、包、CI、镜像和 Compose 服务使用内部名称,网站导航与页面标题使用 PROMPT。
|
||||
[打开啡跃](http://8.211.154.134:18131/)
|
||||
|
||||
线上入口:[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` 读纯文本 |
|
||||
| `/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 |
|
||||
| `/` | 浏览内容,打开 `/explore/<id>` 查看单篇笔记 |
|
||||
| `/guide` | 浏览指导文档 |
|
||||
| `/graphic` | 打开图文工作台 |
|
||||
| `/infinite_canvas` | 打开无限画布 |
|
||||
| `/api?agent_mode=True` | 查看业务 API 目录 |
|
||||
|
||||
标签 PATCH 请求为 `{ "tags": ["标签 UUID"], "revision": "GET 返回的 revision" }`。它不会要求用户重输正文。Tag 对象的 `kind` 为 `normal` 或 `system`;系统标签还有稳定的 `system_key`。普通创建接口不允许提交 `kind` 或 `system_key`。
|
||||
|
||||
Wiki 中的教程是普通 post 与媒体数据,不需要为每份资料增加专用网页路由、JSON 文件端点或代码注册。
|
||||
|
||||
## Studio:从教程进入工具
|
||||
|
||||
Studio 负责交互式排版与无状态渲染,知识库负责教程、来源和入口。`/studio` 返回 PROMPT 首页;请从对应 post 打开具体工具。
|
||||
|
||||
27 套排版工具:`/studio/layouts/ref-01` 至 `/studio/layouts/ref-27`,每套对应原图 T/I/D 构件。目录与参数 API 为 `/studio/api/layouts` 和 `/studio/api/layouts/<id>`;POST 参数生成 SVG。效果通过 `/api/studio/designs` 存入 Wiki 现有 PostgreSQL,返回独立 ID,`/studio/designs/<id>` 可再次打开;PATCH 携带 revision 更新同一效果。Studio 不需要数据库连接或第二套数据库。详见 [27 套排版 API 教程](content/studio-tools/layouts-api.md)。
|
||||
|
||||
其他试版工具:
|
||||
|
||||
- `/studio/ideas/learning-skills-template`:ref-17 同一母版的四张占位封面。
|
||||
- `/studio/ideas/layout-selection`:ref-01、ref-07、ref-26 三种构图;可修改文字与配色,核对构件边界。
|
||||
- `/studio/api/export/<slug>`:GET 读取方案或初始 SVG;POST 校验自定义参数并生成 SVG/JSON。详细说明读取该地址的 `?agent_mode=True`。
|
||||
|
||||
渲染不自动保存作品,也不自动发布 post。需要保留的结果应下载后再通过普通媒体和 post API 发布。原图的 T/I/D 构件、画板比例与来源保留为可读取数据;文字替换和矢量重建的范围应在教程中明确。
|
||||
自动化客户端可以在支持的页面或业务接口上加 `?agent_mode=True` 获取纯文本。读写权限与认证要求见对应接口说明。
|
||||
|
||||
## 仓库结构
|
||||
|
||||
| 路径 | 职责 |
|
||||
| --- | --- |
|
||||
| `apps/waterfall-wiki` | Next.js Pages Router 知识库、搜索、标签、媒体 API、认证与后台视频加工 |
|
||||
| `apps/studio` | 独立 Next.js 创作工具与封面渲染 API |
|
||||
| `apps/python` | 按需运行的 PNG/视频导出 CLI,没有常驻网络服务 |
|
||||
| `content` | 本地作者资料、教程及排版源图,不打入 Wiki 运行镜像 |
|
||||
| `scripts` | CI 部署、运维、API 发布与作者工具 |
|
||||
| `docs` | 数据模型、接口、认证与部署说明 |
|
||||
| `apps/waterfall-wiki` | Next.js Pages Router 主站、账户、内容和媒体 API,以及后台媒体处理 |
|
||||
| `apps/studio` | Next.js Pages Router 创作工作台及渲染 API,由主站提供统一入口 |
|
||||
| `apps/python` | 按需运行的 PNG 和视频导出工具 |
|
||||
| `packages/brand` | 共享主题、品牌资产及接入规范 |
|
||||
| `content` | 排版源数据、发布教程和示例,供作者工具与契约检查读取 |
|
||||
| `scripts` | 构建部署、运维与内容发布工具 |
|
||||
| `tests` | 跨应用的仓库媒体与运维检查。应用自身的测试位于各自目录 |
|
||||
|
||||
两个 JavaScript 应用分别维护冻结的 pnpm 锁文件。根 Dockerfile 是 waterfall-wiki 的构建入口;应用目录的 Dockerfile 与它保持一致。
|
||||
## 验证与发布
|
||||
|
||||
## 环境与检查
|
||||
JavaScript 工作区使用 Node.js 24 和 pnpm 11.25.0。生产服务由 Docker Compose 编排,数据保存在 PostgreSQL 和 S3 兼容对象存储中。
|
||||
|
||||
运行环境:Node.js 24、pnpm 11.25.0、PostgreSQL 17、S3 兼容对象存储;视频处理使用 FFmpeg,部分导出使用 Chromium。Python 工具需要 Python 3.12+。
|
||||
需要编译或生成产物的验证在 ces-control 执行。发布工作流由匹配路径的 `main` 推送触发。页面验收使用部署后的指定服务器,详见 [AGENTS.md](AGENTS.md) 和 [构建与发布说明](docs/ci-and-entrypoints.md)。
|
||||
|
||||
```sh
|
||||
pnpm install
|
||||
pnpm --dir apps/waterfall-wiki typecheck
|
||||
pnpm --dir apps/waterfall-wiki test
|
||||
pnpm --dir apps/studio typecheck
|
||||
bash -n scripts/deploy.sh scripts/integration.sh scripts/deploy-studio.sh
|
||||
```
|
||||
## 维护文档
|
||||
|
||||
完整集成测试只针对显式隔离的数据库、对象存储和生产容器运行,覆盖存储、视频加工、编辑冲突、真实搜索与引用、标签规则和登录保护。不要把生产存储配置拿来运行集成测试。
|
||||
|
||||
本项目以指定服务器为唯一网页运行与验收入口:先推送,再等待 ces-control CI/CD,最后直接检查服务器。不要启动本地网页预览,不用 localhost 页面作为视觉验收或交付入口。具体约定见 [AGENTS.md](AGENTS.md)。
|
||||
|
||||
## 部署与持久化
|
||||
|
||||
Web 应用由 `waterfall-wiki.yml` 统一发布,两个应用的路径都能触发它:先检查并构建 Studio,再完成 Wiki 的类型检查、单元测试、生产构建、冒烟及隔离 PostgreSQL/S3 集成测试并部署 Wiki,随后部署 Studio,最后在指定服务器验收。顺序在同一个 ces-control 作业内保证,避免独立作业互相等待。PR 检查也运行隔离集成测试,但不部署。Python 检查仍由 `python.yml` 承担;文档与作者数据本身不触发应用部署。
|
||||
|
||||
| 服务 | 入口/端口 |
|
||||
| --- | --- |
|
||||
| `waterfall-wiki` | 公网 `18131`,也是 Studio 的统一入口 |
|
||||
| PostgreSQL | `127.0.0.1:18132` |
|
||||
| 对象存储 | `127.0.0.1:18133` |
|
||||
| Studio | `127.0.0.1:18134`;编辑器公网入口为 `/graphic`、`/infinite_canvas`,资源和 API 保留 `/studio` 代理 |
|
||||
| Python | 无网络端口,按需运行 |
|
||||
|
||||
生产 Compose 项目仍为 `prompt`,部署根目录仍为 `/opt/prompt`。既有 `prompt_postgres_data`、`prompt_objects`、`prompt_auth_data` 卷与数据库身份保留;应用更名不会重新创建一套空存储。Compose 应用服务改为 `waterfall-wiki` 和 `waterfall-wiki-worker`,镜像参数为 `WATERFALL_WIKI_IMAGE` / `WATERFALL_WIKI_TEST_IMAGE`。
|
||||
|
||||
部署在停止写入后备份数据库,再运行迁移和启动新版。失败时恢复兼容旧镜像的备份并回滚服务。生产不执行 `down -v`,也不清空对象文件。早期合集验证会遍历分页,不假定旧文章一定在第一页。
|
||||
|
||||
## 登录与自动化
|
||||
|
||||
共享验证器的 6 位 TOTP 动态码登录,JWT 保存在 HttpOnly Cookie 中;这属于单因素动态码登录,不是 2FA。密钥保存在服务器认证卷,不进入 Git、镜像或 HTTP 二维码接口。
|
||||
|
||||
服务器管理员在交互式 SSH 终端运行 `sudo /opt/prompt/auth qr` 绑定验证器。不要把二维码、动态密钥或 JWT 粘贴到聊天、日志和仓库。服务端自动化可使用 `node auth-cli.cjs run <脚本>`,在进程内为本机 API 请求附加短期凭据,不输出令牌。
|
||||
|
||||
更多说明:[登录与 SSH 绑定](docs/dynamic-code-login.md) · [API](docs/api.md) · [数据模型](docs/data-model.md) · [项目结构与部署](docs/monorepo.md)。
|
||||
- [业务 API](docs/api.md)与[数据模型](docs/data-model.md)
|
||||
- [动态码登录与运维](docs/dynamic-code-login.md)
|
||||
- [指导文档入口](docs/guidance.md)与[发布协议](docs/linked-guidance/publishing.md)
|
||||
- [对象存储](docs/s3-media.md)与[镜像保留策略](docs/wiki-image-retention.md)
|
||||
- [Studio 排版 API 与示例](content/studio-tools/layouts-api.md)
|
||||
- [共享品牌包](packages/brand/README.md)
|
||||
|
||||
+11
-10
@@ -1,22 +1,23 @@
|
||||
FROM node:24-bookworm-slim AS build
|
||||
WORKDIR /app
|
||||
WORKDIR /repo/apps/studio
|
||||
RUN corepack enable && corepack prepare pnpm@11.25.0 --activate
|
||||
COPY apps/studio/pnpm-lock.yaml ./
|
||||
COPY pnpm-workspace.yaml /tmp/monorepo-policy.yaml
|
||||
RUN node -e "const fs=require('fs');const text=fs.readFileSync('/tmp/monorepo-policy.yaml','utf8');fs.writeFileSync('pnpm-workspace.yaml', text.slice(text.indexOf('allowBuilds:')));"
|
||||
RUN pnpm fetch --frozen-lockfile --fetch-timeout=300000 --network-concurrency=8
|
||||
COPY pnpm-workspace.yaml /repo/pnpm-workspace.yaml
|
||||
COPY packages/brand/ /repo/packages/brand/
|
||||
COPY apps/studio/package.json ./
|
||||
RUN pnpm install --offline --frozen-lockfile
|
||||
RUN pnpm --filter @prompt/studio install --frozen-lockfile --fetch-timeout=300000 --network-concurrency=8
|
||||
COPY apps/studio/ .
|
||||
# Compare the two public parameter validators inside the isolated CI build.
|
||||
COPY apps/waterfall-wiki/src/lib/layout-parameters.ts /waterfall-wiki/src/lib/layout-parameters.ts
|
||||
COPY apps/waterfall-wiki/src/lib/series.ts /waterfall-wiki/src/lib/series.ts
|
||||
COPY apps/waterfall-wiki/src/lib/layout-parameters.ts /repo/apps/waterfall-wiki/src/lib/layout-parameters.ts
|
||||
COPY apps/waterfall-wiki/src/lib/series.ts /repo/apps/waterfall-wiki/src/lib/series.ts
|
||||
COPY apps/waterfall-wiki/src/lib/canvas-document.ts /repo/apps/waterfall-wiki/src/lib/canvas-document.ts
|
||||
RUN pnpm typecheck && pnpm test && pnpm build && pnpm test:smoke
|
||||
FROM node:24-bookworm-slim
|
||||
WORKDIR /app
|
||||
WORKDIR /app/apps/studio
|
||||
ENV NODE_ENV=production HOSTNAME=0.0.0.0 PORT=3000
|
||||
COPY --from=build --chown=node:node /app/.next/standalone/ ./
|
||||
COPY --from=build --chown=node:node /app/.next/static/ ./.next/static/
|
||||
COPY --from=build --chown=node:node /repo/apps/studio/.next/standalone/ /app/
|
||||
COPY --from=build --chown=node:node /repo/apps/studio/.next/static/ ./.next/static/
|
||||
COPY --from=build --chown=node:node /repo/apps/studio/public/ ./public/
|
||||
USER node
|
||||
EXPOSE 3000
|
||||
CMD ["node", "server.js"]
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { resolve } from "node:path";
|
||||
import type { NextConfig } from "next";
|
||||
const config: NextConfig = { basePath: "/studio", output: "standalone", outputFileTracingRoot: process.cwd(), poweredByHeader: false };
|
||||
const config: NextConfig = { turbopack: { root: resolve(process.cwd(), "../..") }, basePath: "/studio", output: "standalone", outputFileTracingRoot: resolve(process.cwd(), "../.."), poweredByHeader: false };
|
||||
export default config;
|
||||
|
||||
@@ -2,18 +2,20 @@
|
||||
"name": "@prompt/studio",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev -p 3001",
|
||||
"build": "next build",
|
||||
"dev": "pnpm brand:prepare && next dev -p 3001",
|
||||
"build": "pnpm brand:prepare && next build",
|
||||
"start": "next start",
|
||||
"typecheck": "next typegen && tsc --noEmit",
|
||||
"test": "node --test tests/*.test.mjs",
|
||||
"test:smoke": "node scripts/smoke.mjs"
|
||||
"test:smoke": "node scripts/smoke.mjs",
|
||||
"brand:prepare": "node ../../packages/brand/scripts/prepare-app.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"next": "16.4.2",
|
||||
"react": "19.3.1",
|
||||
"react-dom": "19.3.1",
|
||||
"lucide-react": "1.61.0"
|
||||
"lucide-react": "1.61.0",
|
||||
"@endoleap/brand": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "4.3.3",
|
||||
|
||||
Generated
+3
@@ -8,6 +8,9 @@ importers:
|
||||
|
||||
.:
|
||||
dependencies:
|
||||
'@endoleap/brand':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/brand
|
||||
lucide-react:
|
||||
specifier: 1.61.0
|
||||
version: 1.61.0(@types/react@19.3.0)(react@19.3.1)
|
||||
|
||||
@@ -1,91 +1,128 @@
|
||||
// Supply authorized browser state for the server. Local acceptance was explicitly authorized for this revision.
|
||||
// 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;
|
||||
assert.ok(['http://8.211.154.134:18131','http://127.0.0.1:3024'].includes(base),'Use the designated server or the explicitly authorized local preview');
|
||||
const browser=await chromium.launch(process.env.CHROMIUM_PATH?{executablePath:process.env.CHROMIUM_PATH,headless:true}:{channel:'chrome',headless:true});
|
||||
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});
|
||||
const page=await context.newPage(),errors=[];page.on('pageerror',e=>errors.push(e.message));
|
||||
await page.goto(base+'/infinite_canvas');
|
||||
assert.equal(new URL(page.url()).pathname,'/infinite_canvas','Login required; authenticate without weakening protection');
|
||||
await page.getByRole('heading',{name:'无限画布 / Infinite canvas'}).waitFor();
|
||||
await page.getByRole('button',{name:'从示例开始 ↗',exact:true}).click();
|
||||
// A fresh context is used so this test cannot overwrite a human browser draft.
|
||||
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 box=await handle.boundingBox();assert.ok(box);await page.mouse.move(box.x+box.width/2,box.y+box.height/2);await page.mouse.down();await page.mouse.move(box.x+box.width/2+dx,box.y+box.height/2+dy,{steps:12});await page.mouse.up()}
|
||||
for(const scale of [0,1]){
|
||||
await page.getByRole('button',{name:'适应画布',exact:true}).click();
|
||||
if(scale)await page.getByRole('button',{name:'放大',exact:true}).click();
|
||||
const z=await page.locator('[class*=world]').evaluate(el=>new DOMMatrix(getComputedStyle(el).transform).a);
|
||||
for(const id of ['brief','reference']){
|
||||
const before=(await snapshot()).cards.find(c=>c.id===id);
|
||||
await drag(node(id).locator(':scope > div').nth(1),45,25);
|
||||
const after=(await snapshot()).cards.find(c=>c.id===id);close(after.x-before.x,45/z);close(after.y-before.y,25/z);
|
||||
}
|
||||
}
|
||||
// Double-click content enters editing without moving or creating nodes.
|
||||
const nodeCount=(await snapshot()).cards.length;
|
||||
const before=(await snapshot()).cards.find(c=>c.id==='brief');
|
||||
await node('brief').locator('textarea').dblclick({force:true});
|
||||
assert.equal(await node('brief').locator('textarea').getAttribute('readonly'),null);
|
||||
await node('brief').locator('textarea').fill('双击后直接编辑');
|
||||
assert.equal((await snapshot()).cards.length,nodeCount);
|
||||
assert.equal((await snapshot()).cards.find(c=>c.id==='brief').x,before.x);
|
||||
assert.equal((await snapshot()).cards.find(c=>c.id==='brief').y,before.y);
|
||||
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 chooser=page.waitForEvent('filechooser');
|
||||
await node('reference').getByRole('button',{name:/替换.*图片/}).dblclick();
|
||||
const imagePicker=await chooser;
|
||||
await imagePicker.setFiles({name:'replacement.png',mimeType:'image/png',buffer:Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jC1sAAAAASUVORK5CYII=','base64')});
|
||||
await page.waitForFunction(()=>JSON.parse(localStorage.getItem('studio.infinite-canvas.v1')).cards.find(c=>c.id==='reference').image?.startsWith('data:image/png;base64,'));
|
||||
assert.equal((await snapshot()).cards.length,nodeCount);
|
||||
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');
|
||||
assert.equal(await node('brief').locator('textarea').getAttribute('readonly'),'');await node('brief').getByRole('button',{name:/^编辑 /}).click();await node('brief').locator('textarea').fill('真实工具内编辑 · 输入不能移动节点');
|
||||
const edited=(await snapshot()).cards.find(c=>c.id==='brief');assert.equal(edited.x,before.x);assert.equal(edited.y,before.y);
|
||||
await drag(node('brief').locator('textarea'),20,10);const inEdit=(await snapshot()).cards.find(c=>c.id==='brief');assert.equal(inEdit.x,edited.x);assert.equal(inEdit.y,edited.y);await node('brief').getByRole('button',{name:/^完成编辑 /}).click();const resize=node('brief').getByRole('button',{name:/调整/});await drag(resize,60,40);
|
||||
assert.ok((await snapshot()).cards.find(c=>c.id==='brief').width>320);
|
||||
await page.getByRole('button',{name:'撤销',exact:true}).click();assert.equal((await snapshot()).cards.find(c=>c.id==='brief').width,undefined);
|
||||
await page.getByRole('button',{name:'重做',exact:true}).click();assert.ok((await snapshot()).cards.find(c=>c.id==='brief').width>320);
|
||||
// Each SVG endpoint must agree with the actual transformed node edge, after resize and pan.
|
||||
async function wires(){const mismatch=await page.evaluate(()=>{
|
||||
return [...document.querySelectorAll('svg path')].filter(p=>p.getAttribute('d')?.startsWith('M ')).slice(0,1).some(p=>{const d=p.getAttribute('d').match(/-?\d+(?:\.\d+)?/g).map(Number),world=p.closest('div').getBoundingClientRect();const matrix=p.getScreenCTM();const pt=new DOMPoint(d[0],d[1]).matrixTransform(matrix);const card=document.querySelector('[data-node-id="brief"]').getBoundingClientRect();return Math.abs(pt.x-card.right)>3||Math.abs(pt.y-(card.top+card.height/2))>3});
|
||||
});assert.equal(mismatch,false)}
|
||||
// The first link starts at brief; check it specifically.
|
||||
await wires();
|
||||
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();
|
||||
const viewport=page.locator('[class*="viewport"]');const box=await viewport.boundingBox();await page.mouse.move(box.x+20,box.y+20);await page.mouse.down();await page.mouse.move(box.x+100,box.y+80,{steps:8});await page.mouse.up();
|
||||
await wires();
|
||||
const saved=await snapshot(),view=await page.evaluate(()=>localStorage.getItem('studio.infinite-canvas.v1.view'));
|
||||
await page.reload();await page.getByRole('heading',{name:'无限画布 / Infinite canvas'}).waitFor();assert.deepEqual(await snapshot(),saved);assert.equal(await page.evaluate(()=>localStorage.getItem('studio.infinite-canvas.v1.view')),view);
|
||||
// Zoom must preserve the world coordinate beneath the pointer.
|
||||
const vp=await viewport.boundingBox();assert.equal(vp.x,0);assert.equal(vp.y,0);assert.equal(vp.width,1600);assert.equal(vp.height,1100);
|
||||
const anchor={x:vp.x+vp.width*.65,y:vp.y+vp.height*.25};
|
||||
const worldAt=()=>page.locator('[class*=world]').evaluate((el,p)=>{const m=new DOMMatrix(getComputedStyle(el).transform);return {x:(p.x-m.e)/m.a,y:(p.y-m.f)/m.d}},anchor);
|
||||
const point=await worldAt();await page.mouse.move(anchor.x,anchor.y);await page.keyboard.down('Control');await page.mouse.wheel(0,-80);await page.keyboard.up('Control');
|
||||
await page.waitForFunction(old=>JSON.parse(localStorage.getItem('studio.infinite-canvas.v1.view')).zoom!==JSON.parse(old).zoom,view);
|
||||
const pointAfter=await worldAt();close(point.x,pointAfter.x);close(point.y,pointAfter.y);
|
||||
await page.getByRole('button',{name:'选择工具',exact:true}).click();
|
||||
await page.getByRole('button',{name:'添加笔记',exact:true}).click();
|
||||
assert.equal((await snapshot()).cards.length,saved.cards.length+1);
|
||||
await page.getByRole('button',{name:/操作$/}).last().click();
|
||||
await page.getByRole('button',{name:'复制节点',exact:true}).click();assert.equal((await snapshot()).cards.length,saved.cards.length+2);
|
||||
await page.getByRole('button',{name:'删除节点',exact:true}).click();assert.equal((await snapshot()).cards.length,saved.cards.length+1);
|
||||
await page.getByRole('button',{name:'撤销',exact:true}).click();assert.equal((await snapshot()).cards.length,saved.cards.length+2);
|
||||
await page.getByRole('button',{name:'重做',exact:true}).click();assert.equal((await snapshot()).cards.length,saved.cards.length+1);
|
||||
if(await page.getByRole('button',{name:'关闭画布操作',exact:true}).isVisible())await page.getByRole('button',{name:'关闭画布操作',exact:true}).click();
|
||||
// Return to the saved three-node document through undo.
|
||||
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.getByRole('button',{name:'适应画布',exact:true}).click();await page.reload();await page.getByRole('heading',{name:'无限画布 / Infinite canvas'}).waitFor();
|
||||
const dock=await page.getByRole('navigation',{name:'画布工具'}).boundingBox();assert.ok(dock.y>950);assert.ok(dock.x>500);
|
||||
await page.getByRole('button',{name:'切换明暗主题'}).click();assert.equal(await page.locator('main').getAttribute('data-theme'),'light');await page.reload();await page.waitForFunction(()=>document.querySelector('main').dataset.theme==='light');
|
||||
if(process.env.SCREENSHOT_DIR)await page.screenshot({path:process.env.SCREENSHOT_DIR+'/canvas-light.png'});
|
||||
await page.getByRole('button',{name:'切换明暗主题'}).click();
|
||||
if(process.env.SCREENSHOT_DIR){await fs.mkdir(process.env.SCREENSHOT_DIR,{recursive:true});await page.screenshot({path:process.env.SCREENSHOT_DIR+'/canvas-rebuild.png'});await page.setViewportSize({width:390,height:844});assert.ok(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth));const mobile=await viewport.boundingBox();assert.equal(mobile.width,390);assert.equal(mobile.height,844);await page.getByRole('button',{name:'适应画布',exact:true}).click();await page.screenshot({path:process.env.SCREENSHOT_DIR+'/canvas-mobile.png'});}
|
||||
assert.deepEqual(errors,[]);console.log('Passed: two-node drag at two scales, inline input, resize, undo/redo, wires, pan, reload, pointer zoom, CRUD and full-screen desktop/mobile');
|
||||
}finally{await browser.close()}
|
||||
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});
|
||||
|
||||
@@ -9,7 +9,7 @@ const assetId='00000000-0000-4000-8000-000000000005';doc.sources=[{assetId,autho
|
||||
let reads=0,mediaReads=0;
|
||||
const wiki=createHttpServer((req,res)=>{assert.equal(req.headers.cookie,'session=smoke-test');if(/^\/api\/media\/[0-9a-f-]{36}$/.test(req.url)){mediaReads++;res.setHeader('Content-Type','image/png');return res.end(Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+j3ioAAAAASUVORK5CYII=','base64'))}reads++;res.setHeader('Content-Type','application/json');if(req.url.endsWith(seriesId))return res.end(JSON.stringify({design:{id:seriesId,name:'完整三页作品',layoutId:'standard-01',revision:1,url:'/studio/template-layout?id='+seriesId,parameters:doc.pages[0].parameters,document:doc}}));const standard=req.url.endsWith(standardId);if(!standard&&!req.url.endsWith(designId)){res.writeHead(404);return res.end('{}')}res.end(JSON.stringify({design:{id:standard?standardId:designId,name:'已保存的实际页面',layoutId:standard?'standard-01':'ref-01',revision:3,url:'/studio/designs/'+designId,parameters:{added:[{id:'user-smoke',kind:'text',role:'Added text',box:[10,10,20,10],direction:'horizontal',text:'新增元素已恢复',reading:'custom'}],layers:{T01:{x:12,rotation:25,opacity:.7},'user-smoke':{z:12}},texts:{T01:{value:'确定的页面文字',color:'#123456'}},images:{I01:{color:'#abcdef'}},decorations:{D01:{hidden:true}},paper:'#eeeeee'}}}))});
|
||||
wiki.listen(0,'127.0.0.1');await once(wiki,'listening');
|
||||
const socket=createServer();socket.listen(0,'127.0.0.1');await once(socket,'listening');const port=socket.address().port;await new Promise(r=>socket.close(r));const child=spawn(process.execPath,[process.env.STUDIO_STANDALONE_SERVER || '.next/standalone/server.js'],{env:{...process.env,PORT:String(port),HOSTNAME:'127.0.0.1',STUDIO_WIKI_ORIGIN:'http://127.0.0.1:'+wiki.address().port},stdio:'inherit'}),base='http://127.0.0.1:'+port;
|
||||
const socket=createServer();socket.listen(0,'127.0.0.1');await once(socket,'listening');const port=socket.address().port;await new Promise(r=>socket.close(r));const child=spawn(process.execPath,[process.env.STUDIO_STANDALONE_SERVER || '.next/standalone/apps/studio/server.js'],{env:{...process.env,PORT:String(port),HOSTNAME:'127.0.0.1',STUDIO_WIKI_ORIGIN:'http://127.0.0.1:'+wiki.address().port},stdio:'inherit'}),base='http://127.0.0.1:'+port;
|
||||
try{let ready=false;for(let i=0;i<60;i++){try{if((await fetch(base+'/studio/api/health')).ok){ready=true;break}}catch{}await setTimeout(250)}assert.ok(ready);const entry=await fetch(base+'/studio',{redirect:'manual'});assert.equal(entry.status,307);assert.equal(entry.headers.get('location'),'/studio/graphic');const library=await fetch(base+'/studio/graphic').then(r=>r.text());assert.ok(library.includes('GRAPHIC'));assert.ok(!library.includes('ref-27')); const catalog=await fetch(base+'/studio/api/layouts').then(r=>r.json());assert.equal(catalog.layouts.length,28);
|
||||
for(const l of catalog.layouts){const page=await fetch(base+l.url);assert.equal(page.status,200,l.id);const html=await page.text();assert.ok(html.includes(l.name));assert.ok(html.includes('data-layout-id="'+l.id+'"'));assert.ok(!/<svg|<img|data:image|trial-controls|page-tools|workbench-top|contenteditable|编辑页面/.test(html),'Default page must use native elements, not source imagery');const schema=await fetch(base+l.api).then(r=>r.json());assert.equal(schema.id,l.id);for(const s of schema.elements)assert.ok(html.includes('data-element="'+s.id+'"'));if(l.family==='standard'){assert.equal((await fetch(base+l.api+'?format=svg')).status,400);continue;}const original=await fetch(base+l.api+'?format=svg',{headers:{cookie:'session=smoke-test'}}).then(r=>r.text());assert.ok(original.includes('data-layout-id="'+l.id+'"'));const text=schema.elements.find(e=>e.kind==='text');const result=await fetch(base+l.api,{method:'POST',headers:{'Content-Type':'application/json',cookie:'session=smoke-test'},body:JSON.stringify({texts:{[text.id]:{value:'API render <test>'}}})});assert.equal(result.status,200);assert.ok((await result.text()).includes('<'));}
|
||||
assert.equal(reads,0,'Default pages never load a saved design');assert.ok(mediaReads>0,'SVG exports load isolated media fixtures');
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
/** The approved mark is copied from @endoleap/brand during the remote build. */
|
||||
export function Brand({ compact = false }: { compact?: boolean }) {
|
||||
return <span className="endoleap-brand"><img src="/studio/brand/logo-light.svg" className="endoleap-mark endoleap-mark-light" alt="" width={32} height={32}/><img src="/studio/brand/logo-dark.svg" className="endoleap-mark endoleap-mark-dark" alt="" width={32} height={32}/><span className="endoleap-wordmark">啡跃{!compact && <small>Endoleap</small>}</span></span>;
|
||||
}
|
||||
@@ -10,5 +10,5 @@ export function LayoutSelection(){
|
||||
async function download(format:'svg'|'json'){
|
||||
try{setStatus('正在生成…');const r=await fetch('/studio/api/export/layout-selection?format='+format,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({layoutId:layout.id,...current})});if(!r.ok)throw Error();const url=URL.createObjectURL(await r.blob()),a=document.createElement('a');a.href=url;a.download=layout.id+'.'+format;a.click();setTimeout(()=>URL.revokeObjectURL(url),500);setStatus('已导出当前模板。')}catch{setStatus('导出失败,请重试。')}
|
||||
}
|
||||
return <main className="template-trial"><Head><title>三种排版 · Studio 工具</title></Head><header className="trial-nav"><a href={tutorialPost}>← PROMPT 使用教程</a><span>STUDIO / LAYOUT SELECTION</span></header><section className="trial-intro"><div><small>REF-01 / REF-07 / REF-26</small><h1>三个构图,各有位置。</h1><p>切换真实原图对应的模板,分别试文字与配色。图位与装饰按教程构件重建为干净矢量;当前只放占位内容。</p></div></section><div className="trial-workspace"><aside className="trial-controls"><h2>选择与编辑</h2><label>排版<select value={active} onChange={e=>{setActive(Number(e.target.value));setStatus('')}}>{selections.map((l,i)=><option key={l.id} value={i}>{l.id} · {l.name}</option>)}</select></label>{layout.slots.filter(s=>s.kind==='text').map(s=><label key={s.id}>{s.id} · {s.role}<textarea rows={current.fields[s.id]?.includes('\n')?3:1} maxLength={120} value={current.fields[s.id]||''} onChange={e=>update({fields:{...current.fields,[s.id]:e.target.value}})}/></label>)}<div className="trial-colors"><label>主图色<input type="color" value={current.color} onChange={e=>update({color:e.target.value})}/></label><label>副图色<input type="color" value={current.accent} onChange={e=>update({accent:e.target.value})}/></label></div><label className="trial-switch"><input type="checkbox" checked={guides} onChange={e=>setGuides(e.target.checked)}/>显示构件边界</label><div className="trial-actions"><button className="trial-primary" onClick={()=>void download('svg')}>下载当前 SVG</button><button onClick={()=>void download('json')}>下载参数 JSON</button></div><p className="trial-help">每张模板单独保留本页修改,刷新恢复初始值。下载 SVG 为无边界线的正式图;原始低分辨率图层在 JSON 中保留作对照。</p><p role="status">{status}</p></aside><div><div className="selection-preview"><SourceCover layout={layout} values={current} guides={guides}/></div><div className="selection-thumbs">{selections.map((l,i)=><button key={l.id} aria-pressed={active===i} onClick={()=>setActive(i)}><SourceCover layout={l} values={values[i]}/><span>{l.id}</span></button>)}</div><details className="trial-reference"><summary>原图与构件坐标</summary><div className="trial-reference-body"><img src={referencePreview(layout.referenceUrl)} alt={layout.name+'原图'}/><div><a href={layout.tutorialUrl}>阅读这张原图的教程 ↗</a><table><thead><tr><th>ID</th><th>构件</th><th>x,y,w,h</th></tr></thead><tbody>{layout.slots.map(s=><tr key={s.id}><td>{s.id}</td><td>{s.role}</td><td>{s.box.join(', ')}</td></tr>)}</tbody></table></div></div></details></div></div></main>;
|
||||
return <main className="template-trial"><Head><title>三种排版 · 啡跃 Endoleap 工具</title></Head><header className="trial-nav"><a href={tutorialPost}>← 啡跃使用教程</a><span>STUDIO / LAYOUT SELECTION</span></header><section className="trial-intro"><div><small>REF-01 / REF-07 / REF-26</small><h1>三个构图,各有位置。</h1><p>切换真实原图对应的模板,分别试文字与配色。图位与装饰按教程构件重建为干净矢量;当前只放占位内容。</p></div></section><div className="trial-workspace"><aside className="trial-controls"><h2>选择与编辑</h2><label>排版<select value={active} onChange={e=>{setActive(Number(e.target.value));setStatus('')}}>{selections.map((l,i)=><option key={l.id} value={i}>{l.id} · {l.name}</option>)}</select></label>{layout.slots.filter(s=>s.kind==='text').map(s=><label key={s.id}>{s.id} · {s.role}<textarea rows={current.fields[s.id]?.includes('\n')?3:1} maxLength={120} value={current.fields[s.id]||''} onChange={e=>update({fields:{...current.fields,[s.id]:e.target.value}})}/></label>)}<div className="trial-colors"><label>主图色<input type="color" value={current.color} onChange={e=>update({color:e.target.value})}/></label><label>副图色<input type="color" value={current.accent} onChange={e=>update({accent:e.target.value})}/></label></div><label className="trial-switch"><input type="checkbox" checked={guides} onChange={e=>setGuides(e.target.checked)}/>显示构件边界</label><div className="trial-actions"><button className="trial-primary" onClick={()=>void download('svg')}>下载当前 SVG</button><button onClick={()=>void download('json')}>下载参数 JSON</button></div><p className="trial-help">每张模板单独保留本页修改,刷新恢复初始值。下载 SVG 为无边界线的正式图;原始低分辨率图层在 JSON 中保留作对照。</p><p role="status">{status}</p></aside><div><div className="selection-preview"><SourceCover layout={layout} values={current} guides={guides}/></div><div className="selection-thumbs">{selections.map((l,i)=><button key={l.id} aria-pressed={active===i} onClick={()=>setActive(i)}><SourceCover layout={l} values={values[i]}/><span>{l.id}</span></button>)}</div><details className="trial-reference"><summary>原图与构件坐标</summary><div className="trial-reference-body"><img src={referencePreview(layout.referenceUrl)} alt={layout.name+'原图'}/><div><a href={layout.tutorialUrl}>阅读这张原图的教程 ↗</a><table><thead><tr><th>ID</th><th>构件</th><th>x,y,w,h</th></tr></thead><tbody>{layout.slots.map(s=><tr key={s.id}><td>{s.id}</td><td>{s.role}</td><td>{s.box.join(', ')}</td></tr>)}</tbody></table></div></div></details></div></div></main>;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { ThemeToggle } from "./ThemeToggle";
|
||||
import { Brand } from "./Brand";
|
||||
import {CurvedCopy} from './NativeCurvedCopy';
|
||||
import {useEffect,useRef,useState,type CSSProperties,type KeyboardEvent,type PointerEvent} from 'react';
|
||||
import {ArrowLeft,MousePointer2,Type,Square,Circle,ImagePlus,Layers,SlidersHorizontal,Undo2,Redo2,Copy,Trash2,Eye,EyeOff,Lock,Unlock,ChevronUp,ChevronDown,Minus,Plus,Scan,Save} from 'lucide-react';
|
||||
@@ -55,7 +57,7 @@ export function LayoutWorkbench({layout,design:initialDesign}:LayoutPageProps){
|
||||
const selectedColor=slot?(slot.kind==='text'?params.texts[slot.id]?.color||textColor(layout.id,slot):slot.kind==='image'?params.images[slot.id]?.color||String(imageShape(layout,slot).backgroundColor):params.decorations[slot.id]?.color||'#83908a'):'';
|
||||
return <main className={'native-layout-page'+(editing?' is-editing':'')}>
|
||||
<Head><title>{name} · Prompt</title><meta name="description" content={layout.name+' · 可直接编辑的排版页面'}/></Head>
|
||||
{editing&&<header className="workbench-top"><a href="/explore/8eb8c76500580f7a4b56ba3c" aria-label="返回目录"><ArrowLeft size={18}/></a><span className="studio-mark">P<span>STUDIO</span></span><input aria-label="页面名称" value={name} maxLength={120} onChange={e=>{setName(e.target.value);setDirty(true)}}/><span className="save-indicator">{busy?'正在处理…':dirty?'有未保存的修改':design?'已保存':'尚未保存'}</span><button title="撤销 ⌘Z" disabled={!history.length||busy} onClick={undo}><Undo2 size={17}/></button><button title="重做 ⇧⌘Z" disabled={!future.length||busy} onClick={redo}><Redo2 size={17}/></button><button onClick={()=>{setEditing(false);setTextEditing(null);setSelected(null)}}>查看页面</button><button className="primary" disabled={busy||!name.trim()} onClick={()=>void save()}><Save size={15}/>保存</button></header>}
|
||||
{editing&&<header className="workbench-top"><a href="/explore/8eb8c76500580f7a4b56ba3c" aria-label="返回目录"><ArrowLeft size={18}/></a><a href="/workspace" className="studio-mark"><Brand compact/></a><ThemeToggle/><input aria-label="页面名称" value={name} maxLength={120} onChange={e=>{setName(e.target.value);setDirty(true)}}/><span className="save-indicator">{busy?'正在处理…':dirty?'有未保存的修改':design?'已保存':'尚未保存'}</span><button title="撤销 ⌘Z" disabled={!history.length||busy} onClick={undo}><Undo2 size={17}/></button><button title="重做 ⇧⌘Z" disabled={!future.length||busy} onClick={redo}><Redo2 size={17}/></button><button onClick={()=>{setEditing(false);setTextEditing(null);setSelected(null)}}>查看页面</button><button className="primary" disabled={busy||!name.trim()} onClick={()=>void save()}><Save size={15}/>保存</button></header>}
|
||||
{editing&&<aside className={'workbench-layers '+(panel==='layers'?'mobile-open':'')} inert={busy}><div className="panel-heading"><Layers size={16}/><strong>图层与素材</strong><span>{elements.length}</span></div><div className="insert-tools"><button onClick={()=>add('text')}><Type/>文字</button><button onClick={()=>add('decoration','rectangle')}><Square/>矩形</button><button onClick={()=>add('decoration','ellipse')}><Circle/>椭圆</button><button onClick={()=>add('image')}><ImagePlus/>图片</button></div><div className="layers-caption">{layout.id.toUpperCase()} <span>从上到下排列</span></div><div className="layer-list">{[...elements].sort((a,b)=>(layer(b).z??(b.kind==='text'?3:b.kind==='image'?1:2))-(layer(a).z??(a.kind==='text'?3:a.kind==='image'?1:2))).map(s=><div key={s.id} className={'layer-row '+(selected===s.id?'active':'')+(layer(s).hidden?' muted':'')}><button className="layer-select" onClick={()=>{setSelected(s.id);setTextEditing(null)}}>{s.kind==='text'?<Type size={14}/>:s.kind==='image'?<ImagePlus size={14}/>:<Square size={14}/>}<span>{s.role}</span></button><button title="显示或隐藏图层" aria-label={s.role+'显示或隐藏'} onClick={()=>updateLayer(s,{hidden:!layer(s).hidden})}>{layer(s).hidden?<EyeOff size={13}/>:<Eye size={13}/>}</button><button title="锁定或解锁图层" aria-label={s.role+'锁定或解锁'} onClick={()=>updateLayer(s,{locked:!layer(s).locked})}>{layer(s).locked?<Lock size={13}/>:<Unlock size={13}/>}</button></div>)}</div><p className="workbench-tip">拖动移动 · 双击文字改写<br/>⌘/Ctrl D 复制 · 方向键微调<br/>Shift + 方向键移动 10 单位</p></aside>}
|
||||
<div ref={viewport} className={editing?'workbench-viewport':'page-viewport'} onClick={e=>{if(e.target===e.currentTarget){setSelected(null);setTextEditing(null)}}}>
|
||||
{editing&&<div className="canvas-caption">{layout.name}<span>{layout.width} × {layout.height}</span></div>}
|
||||
|
||||
@@ -12,8 +12,8 @@ export function TemplateTrial({idea}:{idea:Idea}){
|
||||
const download=(text:string,type:string,name:string)=>{const url=URL.createObjectURL(new Blob([text],{type})),a=document.createElement('a');a.href=url;a.download=name;a.click();setTimeout(()=>URL.revokeObjectURL(url),500)};
|
||||
async function exportSvg(){await document.fonts.ready;const svg=document.querySelector(`[data-proof="${selected.key}"] svg`);if(!svg)return;download(new XMLSerializer().serializeToString(svg),'image/svg+xml',`ref-17-${selected.key}.svg`);setNotice('已下载当前封面 SVG,可继续编辑文字与图形。')}
|
||||
const reset=()=>{setSlides(idea.slides);setMasthead(template.masthead);setGuides(false);setNotice('已恢复初始试版。')};
|
||||
return <main className="template-trial"><Head><title>{idea.title} · Studio</title><link rel="alternate" type="text/markdown" href={'/studio/api/export/'+idea.slug+'?format=markdown'}/></Head>
|
||||
<header className="trial-nav"><a href={tutorialPost}>← PROMPT 使用教程</a><span>STUDIO / TEMPLATE PROOF</span><a href={template.tutorialUrl} target="_blank" rel="noreferrer">排版教程 ↗</a></header>
|
||||
return <main className="template-trial"><Head><title>{idea.title} · 啡跃 Endoleap</title><link rel="alternate" type="text/markdown" href={'/studio/api/export/'+idea.slug+'?format=markdown'}/></Head>
|
||||
<header className="trial-nav"><a href={tutorialPost}>← 啡跃使用教程</a><span>STUDIO / TEMPLATE PROOF</span><a href={template.tutorialUrl} target="_blank" rel="noreferrer">排版教程 ↗</a></header>
|
||||
<section className="trial-intro"><div><small>REF-17 · 同一母版 / 四张试版</small><h1>先试封面,再填内容。</h1><p>英文刊头、圆形叠标、左下主图与右侧窄栏保持同一套坐标。三组合集先用主题占位,暂不放入具体 Skills。</p></div><div className="trial-source"><b>01 母版</b><span>02 笔记复习</span><span>03 教学资料</span><span>04 英语学习</span></div></section>
|
||||
<div className="trial-workspace"><aside className="trial-controls"><h2>试一试</h2><label>当前封面<select value={active} onChange={e=>{setActive(Number(e.target.value));setNotice('')}}>{slides.map((s,i)=><option value={i} key={s.key}>{s.label}</option>)}</select></label>
|
||||
<fieldset><legend>共享英文刊头</legend><label>第一行<input value={masthead[0]} maxLength={8} onChange={e=>setMasthead([e.target.value.toUpperCase().replace(/[^A-Z ]/g,''),masthead[1]])}/></label><label>第二行<input value={masthead[1]} maxLength={8} onChange={e=>setMasthead([masthead[0],e.target.value.replace(/[^A-Za-z ]/g,'')])}/></label></fieldset>
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
export function ThemeToggle() {
|
||||
const [dark, setDark] = useState(false);
|
||||
const [ready, setReady] = useState(false);
|
||||
useEffect(() => { const sync = () => setDark(document.documentElement.dataset.mode === 'dark'); sync(); setReady(true); window.addEventListener('endoleap-theme', sync); return () => window.removeEventListener('endoleap-theme', sync); }, []);
|
||||
return <button type="button" data-theme-ready={ready} className="endoleap-theme-toggle" aria-label="切换明暗主题" title={dark ? '切换浅色模式' : '切换深色模式'} onClick={() => { const mode = document.documentElement.dataset.mode === 'dark' ? 'light' : 'dark'; const scope = document.documentElement.dataset.theme?.startsWith('canvas-') ? 'canvas' : 'home'; document.documentElement.dataset.mode = mode; document.documentElement.dataset.theme = `${scope}-${mode}`; try { localStorage.setItem('endoleap.theme', mode); } catch {} window.dispatchEvent(new Event('endoleap-theme')); }}>◐<span>{dark ? '浅色' : '深色'}</span></button>;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import {forwardRef,useEffect,useImperativeHandle,useRef,useState} from 'react';
|
||||
import {parseCanvasDocument,type CanvasDocument} from '../../lib/canvas-document';
|
||||
import s from '../../styles/canvas-agent.module.css';
|
||||
type Task={id:string;targetId:string;status:'queued'|'running'|'succeeded'|'failed';error?:string};
|
||||
type State={id:string;name:string;revision:number;document:CanvasDocument;tasks:Task[]};
|
||||
type LinkState={expiresAt:string;lastSeenAt:string|null};
|
||||
export type CanvasAgentHandle={queue:(target:string)=>Promise<void>};
|
||||
type Props={document:CanvasDocument;email:string;onLoad:(document:CanvasDocument)=>void;onFeedback:(text:string)=>void};
|
||||
async function api(path:string,method='GET',body?:unknown){const r=await fetch('/api/canvases'+path,{method,headers:body?{'Content-Type':'application/json'}:undefined,...(body?{body:JSON.stringify(body)}:{})});const data=await r.json();if(!r.ok)throw Error(data.error||'在线画布暂时不可用');return data;}
|
||||
const names={queued:'等待 Agent',running:'Agent 正在处理',succeeded:'已完成',failed:'已结束'};
|
||||
export default forwardRef<CanvasAgentHandle,Props>(function CanvasAgentPanel(p,ref){
|
||||
const [open,setOpen]=useState(false),[busy,setBusy]=useState(false),[message,setMessage]=useState(''),[remote,setRemote]=useState<State|null>(null),[pending,setPending]=useState<State|null>(null),[connection,setConnection]=useState<LinkState|null>(null),[credential,setCredential]=useState(''),[items,setItems]=useState<{id:string;name:string}[]>([]);
|
||||
const live=useRef(p.document),synced=useRef(''),current=useRef<State|null>(null),locked=useRef(false),account=useRef(p.email),generation=useRef(0),credentialField=useRef<HTMLTextAreaElement>(null);
|
||||
live.current=p.document;account.current=p.email;
|
||||
function update(state:State){current.current=state;setRemote(state);}
|
||||
function apply(state:State){state={...state,document:parseCanvasDocument(state.document)};synced.current=JSON.stringify(state.document);update(state);setPending(null);p.onLoad(state.document);}
|
||||
async function run(action:()=>Promise<void>){if(locked.current)return;locked.current=true;setBusy(true);try{await action()}catch(e){const text=e instanceof Error?e.message:'操作失败';setMessage(text);p.onFeedback(text)}finally{locked.current=false;setBusy(false)}}
|
||||
useEffect(()=>{generation.current++;current.current=null;synced.current='';setRemote(null);setPending(null);setConnection(null);setCredential('');setItems([]);if(!p.email)return;const epoch=generation.current;void api('').then(data=>{if(epoch===generation.current)setItems(data.canvases)}).catch(()=>{});},[p.email]);
|
||||
useEffect(()=>{if(!remote||!p.email)return;const id=remote.id,epoch=generation.current;let cancelled=false;const poll=async()=>{try{const state:State=await api('/'+id);if(cancelled||epoch!==generation.current||current.current?.id!==id||locked.current)return;if(state.revision!==current.current.revision){if(JSON.stringify(live.current)===synced.current){apply(state);setMessage('已同步 Agent 的画布更新')}else{setPending(state);setMessage('在线画布有更新,请选择要保留的版本')}}else update(state);const link=await api('/'+id+'/link');if(!cancelled&&epoch===generation.current)setConnection(link.link)}catch(e){if(!cancelled)setMessage(e instanceof Error?e.message:'同步失败')}};void poll();const timer=setInterval(()=>void poll(),5000);return()=>{cancelled=true;clearInterval(timer)};},[remote?.id,p.email]);
|
||||
async function save():Promise<State>{if(!account.current)throw Error('请先登录邮箱账户');if(pending)throw Error('请先处理在线版本冲突');const doc=live.current,serialized=JSON.stringify(doc),old=current.current;const state:State=old?serialized===synced.current?old:await api('/'+old.id,'PUT',{revision:old.revision,document:doc}):await api('','POST',{document:doc,name:'灵感画布 '+new Date().toLocaleDateString()});if(account.current!==p.email)throw Error('账户已切换,请重新连接');synced.current=serialized;update(state);setMessage('已保存在线画布');return state;}
|
||||
useImperativeHandle(ref,()=>({queue:async target=>{setOpen(true);await run(async()=>{const state=await save();if(JSON.stringify(live.current)!==synced.current)throw Error('画布刚有修改,请重新派发任务');const task:Task=await api('/'+state.id+'/tasks','POST',{revision:state.revision,targetId:target});if(account.current!==p.email)return;update({...state,tasks:[task,...state.tasks]});setMessage('任务已创建,等待你的 coding agent 领取');p.onFeedback('任务已创建,等待 Agent')})}}));
|
||||
async function connect(){const state=await save(),link=await api('/'+state.id+'/link','POST',{});if(account.current!==p.email)return;setConnection({expiresAt:link.expiresAt,lastSeenAt:null});setCredential(JSON.stringify({canvasUrl:location.origin+'/api/canvases/'+state.id,authorization:'Bearer '+link.token,documentation:location.origin+link.documentation,expiresAt:link.expiresAt},null,2));setMessage('连接凭据已生成,有效期 1 小时');}
|
||||
async function copyCredential(){
|
||||
if(navigator.clipboard?.writeText){try{await navigator.clipboard.writeText(credential);setMessage('凭据已复制');return}catch{}}
|
||||
const field=credentialField.current;if(!field)return;field.focus();field.select();
|
||||
let copied=false;try{copied=document.execCommand('copy')}catch{}
|
||||
setMessage(copied?'凭据已复制':'已选中凭据,请按 Ctrl+C 或 ⌘C 复制');
|
||||
}
|
||||
const dirty=!!remote&&JSON.stringify(p.document)!==synced.current;
|
||||
return <div className={s.root}><button onClick={()=>setOpen(v=>!v)} aria-expanded={open}>Agent{remote?' · 在线画布':''}</button>{open&&<section className={s.panel} aria-label="Agent 连接"><header><strong>你的 coding agent</strong><button aria-label="关闭 Agent 连接" onClick={()=>setOpen(false)}>×</button></header>{!p.email?<a href="/account">登录后连接在线画布 ↗</a>:<>
|
||||
<div className={s.actions}><button disabled={busy||!!pending} onClick={()=>void run(async()=>{await save()})}>{remote?(dirty?'保存修改':'已同步'):'保存为在线画布'}</button><button disabled={busy||!!pending} onClick={()=>void run(connect)}>生成连接凭据</button>{connection&&<button disabled={busy} onClick={()=>void run(async()=>{await api('/'+current.current!.id+'/link','DELETE',{});setConnection(null);setCredential('');setMessage('Agent 访问已撤销')})}>撤销连接</button>}</div>
|
||||
{!remote&&<label>打开在线画布<select aria-label="打开在线画布" defaultValue="" disabled={busy} onChange={e=>{const id=e.target.value;if(id)void run(async()=>{const state:State=await api('/'+id);parseCanvasDocument(state.document);setPending(state);setMessage('加载在线画布将替换当前草稿,请先导出需要保留的内容')})}}><option value="">选择画布</option>{items.map(item=><option value={item.id} key={item.id}>{item.name} · {item.id.slice(0,8)}</option>)}</select></label>}
|
||||
{connection&&<small>{connection.lastSeenAt?'Agent 最近访问:'+new Date(connection.lastSeenAt).toLocaleTimeString():'等待 Agent 首次访问'} · 到期 {new Date(connection.expiresAt).toLocaleTimeString()}</small>}
|
||||
{credential&&<><textarea ref={credentialField} aria-label="Agent 连接凭据" readOnly value={credential}/><button onClick={()=>void copyCredential()}>复制给 coding agent</button></>}
|
||||
{pending&&<div className={s.conflict}><button disabled={busy} onClick={()=>{apply(pending);setMessage('已载入在线版本')}}>载入在线版本</button><button disabled={busy} onClick={()=>{setPending(null);current.current=null;setRemote(null);synced.current='';setConnection(null);setCredential('');setMessage('已保留本机草稿,可另存为新画布')}}>保留草稿并断开</button></div>}
|
||||
{!!remote?.tasks.length&&<ul aria-label="画布任务">{remote.tasks.slice(0,8).map(task=><li key={task.id}><span>{p.document.cards.find(n=>n.id===task.targetId)?.title||'已移除节点'} · {names[task.status]}</span>{task.error&&<small>{task.error}</small>}{['queued','running'].includes(task.status)&&<button disabled={busy} onClick={()=>void run(async()=>{await api('/'+remote.id+'/tasks/'+task.id,'PATCH',{status:'failed',error:'用户结束任务'});setMessage('任务已结束,可修改后重新派发')})}>结束任务</button>}</li>)}</ul>}
|
||||
</>}<p role="status">{message}</p><a href="/api/canvases?agent_mode=True" target="_blank" rel="noreferrer">接入文档 ↗</a></section>}</div>
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
import {useEffect,useState} from 'react';
|
||||
import {ImagePlus,FileText,Plus,X,Maximize,Download,ArrowUp} from 'lucide-react';
|
||||
import type {CanvasNode} from '../../lib/canvas-document';
|
||||
import s from '../../styles/canvas-composer.module.css';
|
||||
type Props={pickerRequest:number;feedback:string;node:CanvasNode;nodes:CanvasNode[];references:CanvasNode[];onChange:(text:string)=>void;onRename:(title:string)=>void;onBeginEdit:()=>void;onUpload:()=>void;onAddReference:(id:string)=>void;onRemoveReference:(id:string)=>void;onQueue:()=>void;onExport:()=>void;onClose:()=>void};
|
||||
export default function CanvasComposer(p:Props){const [expanded,expand]=useState(false),[choosing,choose]=useState(false);useEffect(()=>{if(p.pickerRequest)choose(true)},[p.pickerRequest]);return <section className={s.composer} data-expanded={expanded} aria-label="节点编辑器"><header><span>{p.node.kind==='image'?<ImagePlus size={16}/>:<FileText size={16}/>} <input aria-label="节点名称" maxLength={200} value={p.node.title} onFocus={p.onBeginEdit} onChange={e=>p.onRename(e.target.value)}/></span><div>{p.node.kind==='image'&&<button onClick={p.onUpload}><Plus size={15}/>上传图片</button>}<button aria-expanded={choosing} onClick={()=>choose(v=>!v)}><Plus size={15}/>画布参考</button><button aria-label="展开编辑器" aria-pressed={expanded} onClick={()=>expand(v=>!v)}><Maximize size={16}/></button><button aria-label="关闭节点编辑器" onClick={p.onClose}><X size={17}/></button></div></header>
|
||||
{choosing&&<div className={s.picker} role="group" aria-label="选择引用节点">{p.nodes.filter(n=>n.id!==p.node.id&&!p.references.some(r=>r.id===n.id)).map(n=><button key={n.id} onClick={()=>{p.onAddReference(n.id);choose(false)}}>{n.kind==='image'?<ImagePlus size={15}/>:<FileText size={15}/>} {n.title}</button>)}{p.nodes.length<2&&<span>先在画布上添加一个参考节点</span>}</div>}
|
||||
{!!p.references.length&&<div className={s.references}>{p.references.map((n,i)=><div className={s.reference} key={n.id}>{n.image?<img src={n.image} alt={n.title}/>:<FileText size={24}/>}<span>{i+1}</span><small>{n.title}</small><button aria-label={`移除引用 ${n.title}`} onClick={()=>p.onRemoveReference(n.id)}><X size={12}/></button></div>)}</div>}
|
||||
<textarea aria-label="创作要求" maxLength={10000} value={p.node.instruction??p.node.text} onFocus={p.onBeginEdit} onChange={e=>p.onChange(e.target.value)} placeholder={p.node.kind==='script'?'描述剧情片段、故事或分镜要求…':'描述画面,或写下对参考图的修改要求…'}/><footer><span role="status">{p.feedback||<>{p.node.operation==='upscale'?'图片高清':p.node.kind==='script'?'分镜脚本':'创作任务'} · {p.references.length} 个引用</>}</span><div><button onClick={p.onExport}><Download size={15}/>导出任务</button><button onClick={p.onQueue}><ArrowUp size={15}/>交给 Agent</button></div></footer></section>}
|
||||
@@ -0,0 +1,16 @@
|
||||
import {useRef,type PointerEvent} from 'react';
|
||||
import {Type,ImagePlus,FileText,Plus,Bold,Italic,Maximize,MoreHorizontal} from 'lucide-react';
|
||||
import type {CanvasNode,TextStyle} from '../../lib/canvas-document';
|
||||
import s from '../../styles/canvas-node.module.css';
|
||||
type Props={node:CanvasNode;selected:boolean;editing:boolean;hand:boolean;onSelect:()=>void;onChooseReference:()=>void;onEdit:()=>void;onDrag:(e:PointerEvent<HTMLElement>)=>void;onResize:(e:PointerEvent<HTMLElement>)=>void;onChange:(text:string)=>void;onRename:(title:string)=>void;onStyle:(style:TextStyle)=>void;onReference:(e:PointerEvent<HTMLButtonElement>|React.MouseEvent<HTMLButtonElement>)=>void;onMenu:(e:React.MouseEvent<HTMLButtonElement>)=>void;onUpload:()=>void;onUpscale:()=>void;onExpand:()=>void;onBeginEdit:()=>void};
|
||||
export default function CanvasNodeView(p:Props){
|
||||
const {node:n}=p;const gesture=useRef<{selected:boolean;x:number;y:number;moved:boolean}|null>(null);
|
||||
const icon=n.kind==='image'?<ImagePlus size={15}/>:n.kind==='script'?<FileText size={15}/>:<Type size={15}/>;
|
||||
const style={fontWeight:n.style?.bold?700:400,fontStyle:n.style?.italic?'italic':'normal',fontSize:n.style?.size||15,color:n.style?.color};
|
||||
return <article data-node-id={n.id} data-editing={p.editing} data-selected={p.selected} className={s.node} style={{left:n.x,top:n.y,width:n.width||320,height:n.height||350}} onPointerDown={e=>{gesture.current=null;if(p.hand||(p.editing&&n.kind==='text')||e.button!==0||(e.target as HTMLElement).closest('button,input,select'))return;gesture.current={selected:p.selected,x:e.clientX,y:e.clientY,moved:false};e.stopPropagation();p.onSelect();p.onDrag(e)}} onPointerMove={e=>{const g=gesture.current;if(g&&Math.hypot(e.clientX-g.x,e.clientY-g.y)>=4)g.moved=true}} onClick={e=>{if((e.target as HTMLElement).closest('button,input,select'))return;const g=gesture.current;if(!p.hand&&!p.editing&&g?.selected&&!g.moved)p.onEdit();gesture.current=null}} onKeyDown={e=>{if(e.target!==e.currentTarget)return;if(e.key==='Enter'){e.preventDefault();p.onEdit()}}} tabIndex={0} aria-label={n.title}>
|
||||
<header className={s.title}>{icon}<input aria-label="节点名称" value={n.title} maxLength={200} onFocus={p.onBeginEdit} onChange={e=>p.onRename(e.target.value)} onPointerDown={e=>e.stopPropagation()}/>{p.selected&&<button aria-label={`${n.title} 操作`} onClick={e=>{e.stopPropagation();p.onMenu(e)}}><MoreHorizontal size={17}/></button>}</header>
|
||||
{p.editing&&n.kind==='text'&&<div className={s.format} onPointerDown={e=>e.stopPropagation()}><input aria-label="文字颜色" type="color" value={n.style?.color||'#dddddd'} onChange={e=>p.onStyle({...n.style,color:e.target.value})}/><select aria-label="文字样式" value={n.style?.size||15} onChange={e=>p.onStyle({...n.style,size:Number(e.target.value)})}><option value={15}>正文</option><option value={24}>小标题</option><option value={32}>标题</option></select><button aria-label="加粗" aria-pressed={!!n.style?.bold} onClick={()=>p.onStyle({...n.style,bold:!n.style?.bold})}><Bold size={17}/></button><button aria-label="斜体" aria-pressed={!!n.style?.italic} onClick={()=>p.onStyle({...n.style,italic:!n.style?.italic})}><Italic size={17}/></button><button aria-label="展开文本节点" onClick={p.onExpand}><Maximize size={17}/></button></div>}
|
||||
{n.kind==='image'?<div data-node-content className={s.image}>{n.image?<img src={n.image} alt={n.title} draggable={false}/>:<div className={s.empty}><ImagePlus size={48} strokeWidth={1}/><button onClick={e=>{e.stopPropagation();p.onUpload()}}>上传图片</button></div>}{p.selected&&n.image&&<button className={s.upscale} onClick={e=>{e.stopPropagation();p.onUpscale()}}>图片高清 ↗</button>}</div>:p.editing&&n.kind==='text'?<textarea data-node-content autoFocus aria-label={`${n.title} 内容`} style={style} value={n.text} maxLength={10000} placeholder="输入内容…" onFocus={p.onBeginEdit} onChange={e=>p.onChange(e.target.value)}/>:<div data-node-content className={s.content} style={style}>{n.text?<p>{n.text}</p>:<div className={s.empty}>{icon}<button onClick={e=>{e.stopPropagation();p.onEdit()}}>{n.kind==='text'?'自己编写内容':n.kind==='script'?'编写脚本':'填写创作要求'}</button></div>}</div>}
|
||||
{p.selected&&<><button className={`${s.reference} ${s.incoming}`} aria-label={`为 ${n.title} 添加参考`} onClick={e=>{e.stopPropagation();p.onChooseReference()}}><Plus size={18}/></button><button className={s.reference} aria-label={`引用 ${n.title} 生成`} onClick={e=>{e.stopPropagation();p.onReference(e)}}><Plus size={18}/></button><button className={s.resize} aria-label={`调整 ${n.title} 尺寸`} onPointerDown={e=>{e.stopPropagation();gesture.current=null;p.onResize(e)}} onClick={e=>e.stopPropagation()}>↘</button></>}
|
||||
</article>;
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
export type NodeKind = 'text' | 'image' | 'prompt' | 'script';
|
||||
export type TextStyle = {bold?:boolean;italic?:boolean;color?:string;size?:number};
|
||||
export type CanvasNode = {
|
||||
id:string;kind:NodeKind;title:string;text:string;x:number;y:number;
|
||||
image?:string;width?:number;height?:number;style?:TextStyle;
|
||||
instruction?:string;
|
||||
operation?:'generate'|'upscale'|'script';
|
||||
};
|
||||
export type CanvasDocument = {version:1;cards:CanvasNode[];links:[string,string][]};
|
||||
const kinds:NodeKind[]=['text','image','prompt','script'];
|
||||
const object=(v:unknown):v is Record<string,unknown>=>!!v&&typeof v==='object'&&!Array.isArray(v);
|
||||
const bounded=(v:unknown,min:number,max:number)=>typeof v==='number'&&Number.isFinite(v)&&v>=min&&v<=max;
|
||||
const text=(v:unknown,max:number)=>typeof v==='string'&&v.length<=max;
|
||||
const image=(v:unknown)=>typeof v==='string'&&v.length<3000000&&/^data:image\/(png|jpeg|webp);base64,[A-Za-z0-9+/=]+$/.test(v);
|
||||
|
||||
export function parseCanvasDocument(value:unknown):CanvasDocument {
|
||||
const d=typeof value==='string'?JSON.parse(value):value;
|
||||
if(!object(d)||d.version!==1||!Array.isArray(d.cards)||d.cards.length>80||!Array.isArray(d.links)||d.links.length>200)throw Error('画布格式不正确');
|
||||
const ids=new Set<string>();
|
||||
const cards:CanvasNode[]=d.cards.map((n:unknown)=>{
|
||||
if(!object(n)||!text(n.id,100)||!n.id||ids.has(n.id as string)||!kinds.includes(n.kind as NodeKind)||!text(n.title,200)||!text(n.text,10000)||!bounded(n.x,-100000,100000)||!bounded(n.y,-100000,100000))throw Error('节点格式不正确');
|
||||
if(n.width!==undefined&&!bounded(n.width,180,1200)||n.height!==undefined&&!bounded(n.height,160,1200)||n.image!==undefined&&!image(n.image))throw Error('节点尺寸或图片不正确');
|
||||
if(n.instruction!==undefined&&!text(n.instruction,10000))throw Error('创作要求格式不正确');
|
||||
if(n.operation!==undefined&&!['generate','upscale','script'].includes(n.operation as string))throw Error('生成任务类型不正确');
|
||||
let style:TextStyle|undefined;
|
||||
if(n.style!==undefined){
|
||||
if(!object(n.style)||n.style.bold!==undefined&&typeof n.style.bold!=='boolean'||n.style.italic!==undefined&&typeof n.style.italic!=='boolean'||n.style.color!==undefined&&(typeof n.style.color!=='string'||!/^#[a-f\d]{6}$/i.test(n.style.color))||n.style.size!==undefined&&!bounded(n.style.size,10,72))throw Error('文字样式不正确');
|
||||
style={...(n.style.bold===undefined?{}:{bold:n.style.bold as boolean}),...(n.style.italic===undefined?{}:{italic:n.style.italic as boolean}),...(n.style.color===undefined?{}:{color:n.style.color as string}),...(n.style.size===undefined?{}:{size:n.style.size as number})};
|
||||
}
|
||||
ids.add(n.id as string);
|
||||
return {id:n.id as string,kind:n.kind as NodeKind,title:n.title as string,text:n.text as string,x:n.x as number,y:n.y as number,...(n.width===undefined?{}:{width:n.width as number}),...(n.height===undefined?{}:{height:n.height as number}),...(n.image===undefined?{}:{image:n.image as string}),...(style?{style}:{}),...(n.instruction===undefined?{}:{instruction:n.instruction as string}),...(n.operation===undefined?{}:{operation:n.operation as CanvasNode['operation']})};
|
||||
});
|
||||
const pairs=new Set<string>();
|
||||
const links:[string,string][]=d.links.map((l:unknown)=>{
|
||||
if(!Array.isArray(l)||l.length!==2||l[0]===l[1]||!l.every(id=>typeof id==='string'&&ids.has(id)))throw Error('引用节点不存在');
|
||||
const key=JSON.stringify(l);if(pairs.has(key))throw Error('引用重复');pairs.add(key);return [l[0],l[1]];
|
||||
});
|
||||
const outgoing=new Map<string,string[]>(),indegree=new Map(cards.map(n=>[n.id,0]));
|
||||
for(const [from,to] of links){outgoing.set(from,[...(outgoing.get(from)||[]),to]);indegree.set(to,indegree.get(to)!+1)}
|
||||
const roots=cards.filter(n=>indegree.get(n.id)===0).map(n=>n.id);let visited=0;
|
||||
while(roots.length){const id=roots.pop()!;visited++;for(const to of outgoing.get(id)||[]){const count=indegree.get(to)!-1;indegree.set(to,count);if(!count)roots.push(to)}}
|
||||
if(visited!==cards.length)throw Error('引用不能形成循环');
|
||||
return {version:1,cards,links};
|
||||
}
|
||||
|
||||
export function referencedNodes(doc:CanvasDocument,target:string):CanvasNode[]{
|
||||
return doc.links.filter(([,to])=>to===target).map(([from])=>doc.cards.find(n=>n.id===from)!).filter(Boolean);
|
||||
}
|
||||
export function connectNodes(doc:CanvasDocument,from:string,to:string):CanvasDocument {
|
||||
if(from===to||![from,to].every(id=>doc.cards.some(n=>n.id===id)))throw Error('请选择不同的有效节点');
|
||||
if(doc.links.some(([a,b])=>a===from&&b===to))return doc;
|
||||
if(doc.links.length>=200)throw Error('最多 200 条引用');
|
||||
const seen=new Set<string>();const pending=[to];
|
||||
while(pending.length){const id=pending.pop()!;if(id===from)throw Error('引用不能形成循环');if(seen.has(id))continue;seen.add(id);pending.push(...doc.links.filter(([a])=>a===id).map(([,b])=>b));}
|
||||
return {...doc,links:[...doc.links,[from,to]]};
|
||||
}
|
||||
export function removeCanvasNode(doc:CanvasDocument,id:string):CanvasDocument {
|
||||
return {...doc,cards:doc.cards.filter(n=>n.id!==id),links:doc.links.filter(l=>!l.includes(id))};
|
||||
}
|
||||
export function deriveNode(doc:CanvasDocument,source:string,node:CanvasNode):CanvasDocument {
|
||||
if(doc.cards.length>=80||doc.cards.some(n=>n.id===node.id))throw Error('节点数量或标识不正确');
|
||||
const target=parseCanvasDocument({version:1,cards:[node],links:[]}).cards[0];
|
||||
for(let attempt=0;attempt<=doc.cards.length+1;attempt++){
|
||||
const blockers=doc.cards.filter(n=>target.x<n.x+(n.width||320)+64&&target.x+(target.width||320)+64>n.x&&target.y<n.y+(n.height||350)+64&&target.y+(target.height||350)+64>n.y);
|
||||
if(!blockers.length)return parseCanvasDocument(connectNodes({...doc,cards:[...doc.cards,target]},source,target.id));
|
||||
const nextY=Math.max(...blockers.map(n=>n.y+(n.height||350)+64));
|
||||
target.y=nextY>100000?-100000:nextY;
|
||||
}
|
||||
throw Error('请移动现有节点后再创建引用节点');
|
||||
}
|
||||
export function agentTask(doc:CanvasDocument,id:string){
|
||||
const node=doc.cards.find(n=>n.id===id);if(!node)throw Error('目标节点不存在');
|
||||
const instruction=node.instruction??node.text;
|
||||
if(!instruction.trim()&&node.operation!=='upscale')throw Error('请先填写创作要求');
|
||||
const references=referencedNodes(doc,id);
|
||||
if(node.operation==='upscale'&&!references.some(n=>n.kind==='image'&&n.image))throw Error('请先引用一张图片');
|
||||
return {schema:'studio.canvas-task/1',target:{id:node.id,kind:node.kind,title:node.title},operation:node.operation||'generate',instruction,...(node.image?{sourceImage:node.image}:{}),references:references.map(n=>({id:n.id,kind:n.kind,title:n.title,text:n.text,...(n.image?{image:n.image}:{}),...(n.style?{style:n.style}:{})})),result:{targetId:node.id,allowedFields:node.kind==='image'?['image','text']:['text']}};
|
||||
}
|
||||
@@ -1,3 +1,8 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
import type { AppProps } from "next/app";
|
||||
import "../styles/globals.css";
|
||||
export default function App({Component,pageProps}:AppProps){return <Component {...pageProps}/>;}
|
||||
import "../styles/brand.css";
|
||||
export default function App({Component,pageProps}:AppProps){const router = useRouter();
|
||||
useEffect(() => { const scope = /^\/(?:graphic|infinite_canvas|layouts|template-layout)(?:\/|$)/.test(router.pathname) ? 'canvas' : 'home'; document.documentElement.dataset.theme = `${scope}-${document.documentElement.dataset.mode === 'dark' ? 'dark' : 'light'}`; }, [router.pathname]);
|
||||
return <Component {...pageProps}/>;}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
import { Html, Head, Main, NextScript } from 'next/document';
|
||||
const initializeTheme = String.raw`(function(){try{var mode=localStorage.getItem('endoleap.theme')==='dark'?'dark':'light';var canvas=/^\/(?:studio\/)?(?:graphic|infinite_canvas|layouts|template-layout)(?:\/|$)/.test(location.pathname);document.documentElement.dataset.mode=mode;document.documentElement.dataset.theme=(canvas?'canvas':'home')+'-'+mode;}catch(e){}})();`;
|
||||
export default function Document() { return <Html lang="zh-CN" data-theme="home-light" data-mode="light" suppressHydrationWarning><Head><link rel="icon" href="/studio/favicon.ico?v=endoleap-1.2" sizes="any"/><link rel="icon" type="image/svg+xml" href="/studio/brand/icon-dark.svg?v=endoleap-1.2"/><link rel="apple-touch-icon" href="/studio/apple-touch-icon.png"/><script dangerouslySetInnerHTML={{__html:initializeTheme}}/></Head><body><Main/><NextScript/></body></Html>; }
|
||||
@@ -1,3 +1,3 @@
|
||||
import type {NextApiRequest,NextApiResponse} from 'next';
|
||||
import {ideas} from '../../content/ideas';
|
||||
export default function handler(_:NextApiRequest,res:NextApiResponse){res.setHeader('Content-Type','text/markdown; charset=utf-8');res.send('# 创作站\n\n- [27 套排版目录与 API](/studio/api/layouts?agent_mode=True)\n- [多页作品与统一视觉](/api/studio/series?agent_mode=True)\n- [按 ID 保存效果](/api/studio/designs?agent_mode=True)\n\n'+ideas.map(i=>`- [${i.title}](/studio/ideas/${i.slug}) · [正文](/studio/api/export/${i.slug}?format=markdown)`).join('\n'));}
|
||||
export default function handler(_:NextApiRequest,res:NextApiResponse){res.setHeader('Content-Type','text/markdown; charset=utf-8');res.send('# 创作站\n\n- [在线画布、引用与 Agent 任务](/api/canvases?agent_mode=True)\n- [27 套排版目录与 API](/studio/api/layouts?agent_mode=True)\n- [多页作品与统一视觉](/api/studio/series?agent_mode=True)\n- [按 ID 保存效果](/api/studio/designs?agent_mode=True)\n\n'+ideas.map(i=>`- [${i.title}](/studio/ideas/${i.slug}) · [正文](/studio/api/export/${i.slug}?format=markdown)`).join('\n'));}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { ThemeToggle } from "../../components/ThemeToggle";
|
||||
import { Brand } from "../../components/Brand";
|
||||
import Head from 'next/head';
|
||||
import {useEffect,useRef,useState,type PointerEvent} from 'react';
|
||||
import {MousePointer2,Type,Square,Circle,Plus,Minus,Undo2,Redo2,Download,Layers,ImagePlus,Trash2,Copy,Eye,EyeOff,Lock,Unlock,Scan,ChevronRight,ArrowUpRight,PenTool,X,Hand,Link2,BookOpen,PanelLeft,PanelRight} from 'lucide-react';
|
||||
@@ -34,13 +36,13 @@ export default function Graphic(){
|
||||
function download(content:string,type:string,extension:string){const url=URL.createObjectURL(new Blob([content],{type}));const a=document.createElement('a');a.href=url;a.download=doc.name+'.'+extension;a.click();setTimeout(()=>URL.revokeObjectURL(url),1000)}
|
||||
function exportSVG(){const esc=(s:string)=>s.replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]!));const svg=doc.items.filter(i=>!i.hidden).map(i=>i.kind==='text'?`<text x="${i.x}" y="${i.y+i.size}" font-family="sans-serif" font-size="${i.size}" fill="${esc(i.fill)}">${i.text.split('\n').map((line,n)=>`<tspan x="${i.x}" dy="${n?i.size*1.3:0}">${esc(line)}</tspan>`).join('')}</text>`:i.kind==='image'?`<image x="${i.x}" y="${i.y}" width="${i.w}" height="${i.h}" href="${esc(i.src||'')}" preserveAspectRatio="xMidYMid slice"/>`:i.kind==='ellipse'?`<ellipse cx="${i.x+i.w/2}" cy="${i.y+i.h/2}" rx="${i.w/2}" ry="${i.h/2}" fill="${esc(i.fill)}"/>`:`<rect x="${i.x}" y="${i.y}" width="${i.w}" height="${i.h}" fill="${esc(i.fill)}"/>`).join('');download(`<svg xmlns="http://www.w3.org/2000/svg" width="800" height="1000" viewBox="0 0 800 1000"><rect width="800" height="1000" fill="${esc(doc.background)}"/>${svg}</svg>`,'image/svg+xml','svg');}
|
||||
async function account(e:React.FormEvent<HTMLFormElement>){e.preventDefault();setBusy(true);setMessage('');const data=new FormData(e.currentTarget);try{const r=await fetch('/api/account/'+mode,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({email:data.get('email'),password:data.get('password')})});const d=await r.json();setMessage(d.error||d.message||'登录成功');if(r.ok){await refresh();if(mode==='login')setAuth(false);}}catch{setMessage('账户服务暂时无法连接')}finally{setBusy(false)}}
|
||||
return <div className={styles.app} data-layers={showLayers} data-properties={showProps}><Head><title>{doc.name + " · Studio Graphic"}</title><meta name="description" content="在 Studio 中自由编排文字、图形与图片。"/></Head>
|
||||
<header className={styles.top}><a href="/workspace" className={styles.brand}><span>✳</span> studio<span className={styles.badge}>GRAPHIC</span></a><button aria-label="切换图层面板" aria-pressed={showLayers} onClick={()=>setShowLayers(v=>!v)}><PanelLeft size={17}/></button><button aria-label="切换属性面板" aria-pressed={showProps} onClick={()=>setShowProps(v=>!v)}><PanelRight size={17}/></button><ChevronRight size={14}/><input aria-label="设计名称" value={doc.name} maxLength={100} onChange={e=>change({...doc,name:e.target.value})}/><span className={styles.saved} role="status">{status}</span><button title="撤销" aria-label="撤销" disabled={!past.length} onClick={undo}><Undo2 size={17}/></button><button title="重做" aria-label="重做" disabled={!future.length} onClick={redo}><Redo2 size={17}/></button><button onClick={()=>setAuth(true)} className={styles.account}>{user?user.email:'邮箱登录'}</button><button className={styles.primary} onClick={exportSVG}><Download size={15}/>导出 SVG</button></header>
|
||||
return <div className={styles.app} data-layers={showLayers} data-properties={showProps}><Head><title>{doc.name + " · 啡跃 Endoleap Graphic"}</title><meta name="description" content="在 Studio 中自由编排文字、图形与图片。"/></Head>
|
||||
<header className={styles.top}><a href="/workspace" className={styles.brand}><Brand compact/><span className={styles.badge}>GRAPHIC</span></a><button aria-label="切换图层面板" aria-pressed={showLayers} onClick={()=>setShowLayers(v=>!v)}><PanelLeft size={17}/></button><button aria-label="切换属性面板" aria-pressed={showProps} onClick={()=>setShowProps(v=>!v)}><PanelRight size={17}/></button><ChevronRight size={14}/><input aria-label="设计名称" value={doc.name} maxLength={100} onChange={e=>change({...doc,name:e.target.value})}/><span className={styles.saved} role="status">{status}</span><button title="撤销" aria-label="撤销" disabled={!past.length} onClick={undo}><Undo2 size={17}/></button><button title="重做" aria-label="重做" disabled={!future.length} onClick={redo}><Redo2 size={17}/></button><ThemeToggle/><button onClick={()=>setAuth(true)} className={styles.account}>{user?user.email:'邮箱登录'}</button><button className={styles.primary} onClick={exportSVG}><Download size={15}/>导出 SVG</button></header>
|
||||
<div className={styles.body}><aside className={styles.left}><div className={styles.tabs}>{['图层','素材','Agent'].map(t=><button key={t} aria-pressed={tab===t} onClick={()=>setTab(t)}>{t}</button>)}</div><div className={styles.pageTitle}><span>页面 01</span><span>800 × 1000</span></div>{tab==='Agent'?<div className={styles.agentPanel}><Link2 size={24}/><h3>与你的 Agent 一起设计</h3><p>将接入文档交给 coding agent,使用已有的模板与创作 API。</p><a href="/studio/api" target="_blank" rel="noreferrer">查看接入文档 ↗</a><a href="/documents" target="_blank" rel="noreferrer"><BookOpen size={15}/>知识与教程 ↗</a><small>在 Agent 中打开文档,查看可用的创作接口与使用方式。</small></div>:tab==='图层'?<><div className={styles.layers}>{[...doc.items].reverse().map(i=><div className={styles.layer} data-selected={i.id===selected} key={i.id}><button onClick={()=>setSelected(i.id)}>{i.kind==='text'?<Type size={14}/>:<Square size={14}/>}<span>{i.name}</span></button><button aria-label={i.hidden?'显示图层':'隐藏图层'} onClick={()=>change({...doc,items:doc.items.map(n=>n.id===i.id?{...n,hidden:!n.hidden}:n)})}>{i.hidden?<EyeOff size={13}/>:<Eye size={13}/>}</button><button aria-label={i.locked?'解锁图层':'锁定图层'} onClick={()=>change({...doc,items:doc.items.map(n=>n.id===i.id?{...n,locked:!n.locked}:n)})}>{i.locked?<Lock size={13}/>:<Unlock size={13}/>}</button></div>)}{!doc.items.length&&<div className={styles.emptyLayers}><Layers size={23}/><p>好设计,从第一笔开始</p><small>从下方工具栏添加文字与图形</small></div>}</div><button className={styles.addLayer} onClick={()=>add('text')}><Plus size={15}/>添加图层</button></>:<div className={styles.assets}><h3>基础元素</h3><button onClick={()=>add('rect')}><Square/>矩形</button><button onClick={()=>add('ellipse')}><Circle/>椭圆</button><button onClick={()=>add('text')}><Type/>文字</button><button onClick={()=>upload.current?.click()}><ImagePlus/>上传图片</button><p>你的画布,由你定义。支持 PNG、JPEG、WebP。</p></div>}<div className={styles.leftFooter}><a href="/infinite_canvas">Infinite Canvas <ArrowUpRight size={14}/></a><small>⌘ Z 撤销 · 方向键微调 · Delete 删除</small></div></aside>
|
||||
<main ref={viewport} className={styles.viewport} onPointerDown={e=>{setSelected('');panBoard(e)}}><div className={styles.canvasLabel}>画板 01 <span>800 × 1000</span></div><div className={styles.boardWrap} style={{width:800*zoom,height:1000*zoom,transform:`translate(${offset.x}px,${offset.y}px)`}}><div ref={board} className={styles.board} style={{background:doc.background,transform:`scale(${zoom})`}}>{doc.items.filter(i=>!i.hidden).map(i=><div key={i.id} tabIndex={0} role="button" aria-label={i.name} aria-pressed={selected===i.id} onFocus={()=>setSelected(i.id)} onPointerDown={e=>drag(e,i)} className={styles.element} data-selected={selected===i.id} style={{left:i.x,top:i.y,width:i.w,height:i.h,borderRadius:i.kind==='ellipse'?'50%':0,background:i.kind==='rect'||i.kind==='ellipse'?i.fill:undefined,color:i.fill,fontSize:i.size,whiteSpace:'pre-wrap'}}>{i.kind==='text'?i.text:i.kind==='image'?<img src={i.src} alt={i.name} draggable={false}/>:null}{selected===i.id&&<><i className={styles.corner} onPointerDown={resize}/><span className={styles.measure}>{Math.round(i.w)} × {Math.round(i.h)}</span></>}</div>)}{!doc.items.length&&<div className={styles.blankHint}><PenTool size={34} strokeWidth={1}/><h1>留白,是创作的起点。</h1><p>添加文字、图形,或放入你的第一张图片。</p><button onPointerDown={e=>e.stopPropagation()} onClick={()=>add('text')}>开始设计 <ArrowUpRight size={16}/></button></div>}</div></div><div className={styles.tools} onPointerDown={e=>e.stopPropagation()}><button title="选择" aria-label="选择工具" aria-pressed={tool==='select'} onClick={()=>{setTool('select');setSelected('')}}><MousePointer2 size={20}/></button><button title="抓手工具" aria-label="抓手工具" aria-pressed={tool==='hand'} onClick={()=>setTool('hand')}><Hand size={19}/></button><button title="文字" aria-label="添加文字" onClick={()=>add('text')}><Type size={20}/></button><button title="矩形" aria-label="添加矩形" onClick={()=>add('rect')}><Square size={19}/></button><button title="椭圆" aria-label="添加椭圆" onClick={()=>add('ellipse')}><Circle size={19}/></button><button title="图片" aria-label="上传图片" onClick={()=>upload.current?.click()}><ImagePlus size={19}/></button><span/><button aria-label="缩小" onClick={()=>setZoom(z=>Math.max(.15,z-.1))}><Minus size={16}/></button><output>{Math.round(zoom*100)}%</output><button aria-label="放大" onClick={()=>setZoom(z=>Math.min(2,z+.1))}><Plus size={16}/></button><button aria-label="适应画布" onClick={fit}><Scan size={18}/></button></div></main>
|
||||
<aside className={styles.right}><div className={styles.inspectorTitle}>设计属性<span>Design</span></div>{item?<div className={styles.props}><label>图层名称<input maxLength={200} value={item.name} disabled={item.locked} onChange={e=>patch({name:e.target.value})}/></label><h3>布局</h3><div className={styles.fields}>{(['x','y','w','h'] as const).map(k=><label key={k}>{({x:'X',y:'Y',w:'宽',h:'高'})[k]}<input type="number" value={Math.round(item[k])} disabled={item.locked} onChange={e=>{const value=Number(e.target.value);if(Number.isFinite(value))patch({[k]:k==='w'||k==='h'?Math.max(1,Math.min(4000,value)):Math.max(-4000,Math.min(4000,value))})}}/></label>)}</div>{item.kind==='text'&&<><h3>文字</h3><textarea aria-label="文字内容" value={item.text} maxLength={4000} disabled={item.locked} onChange={e=>patch({text:e.target.value})}/><label>字号<input type="number" min={8} max={240} value={item.size} disabled={item.locked} onChange={e=>patch({size:Math.max(8,Math.min(240,Number(e.target.value)||8))})}/></label></>}<h3>填色</h3><label className={styles.color}><input type="color" value={item.fill} disabled={item.locked} onChange={e=>patch({fill:e.target.value})}/>{item.fill.toUpperCase()}</label><div className={styles.actions}><button onClick={duplicate}><Copy size={15}/>复制</button><button disabled={item.locked} onClick={remove}><Trash2 size={15}/>删除</button></div><button disabled={item.locked} onClick={()=>{const n=doc.items.findIndex(i=>i.id===item.id);if(n>0){const items=[...doc.items];[items[n-1],items[n]]=[items[n],items[n-1]];change({...doc,items})}}}>下移一层</button></div>:<div className={styles.props}><h3>画板</h3><p>自由画布 · 竖版</p><div className={styles.dimensions}>W <b>800</b> H <b>1000</b></div><h3>背景颜色</h3><label className={styles.color}><input type="color" value={doc.background} onChange={e=>change({...doc,background:e.target.value})}/>{doc.background.toUpperCase()}</label><div className={styles.note}><MousePointer2 size={20}/><p>选中一个图层,<br/>调整属于它的每个细节。</p></div></div>}<div className={styles.rightFooter}><button onClick={()=>download(JSON.stringify(doc,null,2),'application/json','json')}><Download size={15}/>备份设计 JSON</button><button onClick={()=>restore.current?.click()}>导入设计 JSON</button><p>草稿仅保存在此浏览器。<br/>邮箱登录共用全站账户。</p></div></aside></div>
|
||||
<input ref={restore} hidden type="file" accept="application/json,.json" onChange={async e=>{const f=e.target.files?.[0];e.target.value='';if(!f)return;try{if(f.size>5*1024*1024)throw Error('文件不得超过 5MB');const restored=parseGraphicDocument(await f.text());change(restored);setSelected('');}catch(error){setStatus(error instanceof Error?error.message:'导入失败')}}}/>
|
||||
<input ref={upload} hidden type="file" accept="image/png,image/jpeg,image/webp" onChange={e=>{const f=e.target.files?.[0];if(!f)return;if(f.size>3*1024*1024||!['image/png','image/jpeg','image/webp'].includes(f.type)){setStatus('请使用 3MB 以内的 PNG / JPEG / WebP');return}const reader=new FileReader();reader.onload=()=>add('image',String(reader.result));reader.readAsDataURL(f);e.target.value=''}}/>
|
||||
{auth&&<div className={styles.overlay}><div ref={dialog} className={styles.modal} role="dialog" aria-modal="true" aria-label="邮箱账户"><button className={styles.close} aria-label="关闭" onClick={()=>setAuth(false)}><X size={20}/></button><span className={styles.modalIcon}>✳</span><h2>{user?'你的 Studio 账户':'把灵感带进 Studio'}</h2>{user?<><p>{user.email}</p><button onClick={async()=>{try{const r=await fetch('/api/account/logout',{method:'POST',headers:{'Content-Type':'application/json'},body:'{}'});if(!r.ok)throw Error();setUser(null);setAuth(false)}catch{setMessage('退出失败,请重试')}}}>退出登录</button></>:<><p>使用知识站的同一个邮箱账户</p><div className={styles.tabs}>{[['login','登录'],['register','注册'],['resend','重发邮件']].map(([v,t])=><button key={v} aria-pressed={mode===v} onClick={()=>setMode(v)}>{t}</button>)}</div><form onSubmit={account}><label>邮箱<input autoFocus required type="email" name="email" autoComplete="email" maxLength={254}/></label><label>密码<input required type="password" name="password" minLength={12} maxLength={256} autoComplete={mode==='register'?'new-password':'current-password'}/></label><small>密码至少 12 字符;新账户需通过邮件验证。</small><button className={styles.primary} disabled={busy}>{busy?'处理中…':mode==='login'?'登录 Studio':mode==='register'?'创建账户':'重发验证邮件'}</button></form></>}<p role="status">{message}</p></div></div>}
|
||||
{auth&&<div className={styles.overlay}><div ref={dialog} className={styles.modal} role="dialog" aria-modal="true" aria-label="邮箱账户"><button className={styles.close} aria-label="关闭" onClick={()=>setAuth(false)}><X size={20}/></button><span className={styles.modalIcon}><Brand compact/></span><h2>{user?'你的 Studio 账户':'把灵感带进 Studio'}</h2>{user?<><p>{user.email}</p><button onClick={async()=>{try{const r=await fetch('/api/account/logout',{method:'POST',headers:{'Content-Type':'application/json'},body:'{}'});if(!r.ok)throw Error();setUser(null);setAuth(false)}catch{setMessage('退出失败,请重试')}}}>退出登录</button></>:<><p>使用知识站的同一个邮箱账户</p><div className={styles.tabs}>{[['login','登录'],['register','注册'],['resend','重发邮件']].map(([v,t])=><button key={v} aria-pressed={mode===v} onClick={()=>setMode(v)}>{t}</button>)}</div><form onSubmit={account}><label>邮箱<input autoFocus required type="email" name="email" autoComplete="email" maxLength={254}/></label><label>密码<input required type="password" name="password" minLength={12} maxLength={256} autoComplete={mode==='register'?'new-password':'current-password'}/></label><small>密码至少 12 字符;新账户需通过邮件验证。</small><button className={styles.primary} disabled={busy}>{busy?'处理中…':mode==='login'?'登录 Studio':mode==='register'?'创建账户':'重发验证邮件'}</button></form></>}<p role="status">{message}</p></div></div>}
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -5,6 +5,6 @@ import {LayoutSelection} from '../../components/LayoutSelection';
|
||||
import {tutorialPost} from '../../content/tool-links';
|
||||
import {TemplateTrial} from '../../components/TemplateTrial';
|
||||
import {ideas,type Idea} from '../../content/ideas';
|
||||
export default function IdeaPage({idea}:{idea:Idea}){if(idea.tool==='layout-selection')return <LayoutSelection/>;if(idea.template)return <TemplateTrial idea={idea}/>;return <main className="editor"><Head><title>{idea.title} · 创作站</title><link rel="alternate" type="text/markdown" href={'/studio/api/export/'+idea.slug+'?format=markdown'}/></Head><header><a href={tutorialPost}>← PROMPT 使用教程</a><a href={'/studio/api/export/'+idea.slug}>导出内容包 ↓</a><a href={'/studio/api/export/'+idea.slug+'?format=markdown'}>Markdown ↓</a><button onClick={()=>window.print()}>打印 / PDF</button></header><h1>{idea.title}</h1><p>{idea.summary}</p><div className="slides">{idea.slides.map((s,i)=><article className="slide" key={i} style={{background:s.color}}><small>PROMPT STUDIO / {String(i+1).padStart(2,'0')}</small><h2>{s.heading}</h2><p>{s.body}</p><footer>记录灵感 · 自由表达</footer></article>)}</div></main>}
|
||||
export default function IdeaPage({idea}:{idea:Idea}){if(idea.tool==='layout-selection')return <LayoutSelection/>;if(idea.template)return <TemplateTrial idea={idea}/>;return <main className="editor"><Head><title>{idea.title} · 创作站</title><link rel="alternate" type="text/markdown" href={'/studio/api/export/'+idea.slug+'?format=markdown'}/></Head><header><a href={tutorialPost}>← 啡跃使用教程</a><a href={'/studio/api/export/'+idea.slug}>导出内容包 ↓</a><a href={'/studio/api/export/'+idea.slug+'?format=markdown'}>Markdown ↓</a><button onClick={()=>window.print()}>打印 / PDF</button></header><h1>{idea.title}</h1><p>{idea.summary}</p><div className="slides">{idea.slides.map((s,i)=><article className="slide" key={i} style={{background:s.color}}><small>ENDOLEAP / {String(i+1).padStart(2,'0')}</small><h2>{s.heading}</h2><p>{s.body}</p><footer>记录灵感 · 自由表达</footer></article>)}</div></main>}
|
||||
export const getStaticPaths:GetStaticPaths=async()=>({paths:ideas.map(i=>({params:{slug:i.slug}})),fallback:false});
|
||||
export const getStaticProps:GetStaticProps=async({params})=>{const idea=ideas.find(i=>i.slug===params?.slug);return idea?{props:{idea}}:{notFound:true}};
|
||||
|
||||
@@ -1,43 +1,60 @@
|
||||
import { ThemeToggle } from "../../components/ThemeToggle";
|
||||
import { Brand } from "../../components/Brand";
|
||||
import Head from 'next/head';
|
||||
import {useEffect,useRef,useState} from 'react';
|
||||
import {ArrowLeft,Plus,MousePointer2,Hand,ImagePlus,Type,Workflow,Undo2,Redo2,Minus,Maximize,Download,Upload,Trash2,Link2,Sparkles,ChevronRight,Sun,Moon} from 'lucide-react';
|
||||
import s from '../../styles/infinite-canvas.module.css';
|
||||
type Card={id:string;kind:'text'|'image'|'prompt';title:string;text:string;x:number;y:number;image?:string;width?:number;height?:number};
|
||||
type Doc={version:1;cards:Card[];links:[string,string][]};
|
||||
import CanvasNodeView from '../../components/canvas/CanvasNodeView';
|
||||
import CanvasComposer from '../../components/canvas/CanvasComposer';
|
||||
import CanvasAgentPanel,{type CanvasAgentHandle} from '../../components/canvas/CanvasAgentPanel';
|
||||
import {parseCanvasDocument,deriveNode,connectNodes,removeCanvasNode,referencedNodes,agentTask,type CanvasNode as Card,type CanvasDocument as Doc} from '../../lib/canvas-document';
|
||||
const example:Doc={version:1,cards:[{id:'brief',kind:'text',title:'01 / 创意简报',text:'把日常,拍成一场电影。\n\n一段关于城市清晨的视觉散文。低饱和色彩、自然光与细腻颗粒,让每一帧都有呼吸感。',x:70,y:140},{id:'reference',kind:'image',title:'02 / 视觉参考',text:'晨光 · 建筑 · 胶片质感\n上传你的参考图片',x:460,y:80},{id:'shot',kind:'prompt',title:'03 / 镜头构思',text:'清晨,阳光穿过窗边的植物,落在安静的房间里。缓慢推进镜头,35mm 胶片,温暖柔和的光线。',x:850,y:160}],links:[['brief','reference'],['reference','shot']]};
|
||||
const initial:Doc={version:1,cards:[],links:[]};
|
||||
const key='studio.infinite-canvas.v1';
|
||||
function valid(v:unknown):v is Doc{const d=v as Doc;return !!d&&d.version===1&&Array.isArray(d.cards)&&d.cards.length<=80&&d.cards.every(c=>c&&typeof c.id==='string'&&c.id.length<100&&['text','image','prompt'].includes(c.kind)&&typeof c.title==='string'&&c.title.length<=200&&typeof c.text==='string'&&c.text.length<=10000&&Number.isFinite(c.x)&&Number.isFinite(c.y)&&Math.abs(c.x)<100000&&Math.abs(c.y)<100000&& (c.width===undefined||(Number.isFinite(c.width)&&c.width>=260&&c.width<=1200))&&(c.height===undefined||(Number.isFinite(c.height)&&c.height>=300&&c.height<=1200))&&(!c.image||(/^data:image\/(png|jpeg|webp);base64,/.test(c.image)&&c.image.length<3000000)))&&new Set(d.cards.map(c=>c.id)).size===d.cards.length&&Array.isArray(d.links)&&d.links.length<=200&&d.links.every(l=>Array.isArray(l)&&l.length===2&&l[0]!==l[1]&&l.every(id=>d.cards.some(c=>c.id===id)));}
|
||||
export default function InfiniteCanvas(){
|
||||
const [doc,setDoc]=useState<Doc>(initial),[selected,select]=useState('shot'),[zoom,setZoom]=useState(.8),[pan,setPan]=useState({x:0,y:0}),[tool,setTool]=useState('select'),[past,setPast]=useState<Doc[]>([]),[future,setFuture]=useState<Doc[]>([]),[status,setStatus]=useState('示例项目 · 尚未保存'),[ready,setReady]=useState(false),[email,setEmail]=useState(''),[link,setLink]=useState(''),[menu,setMenu]=useState<{x:number;y:number}|null>(null),[theme,setTheme]=useState<'light'|'dark'>('dark'),[editing,setEditing]=useState('');
|
||||
const [doc,setDoc]=useState<Doc>(initial),[selected,select]=useState('shot'),[zoom,setZoom]=useState(.8),[pan,setPan]=useState({x:0,y:0}),[tool,setTool]=useState('select'),[past,setPast]=useState<Doc[]>([]),[future,setFuture]=useState<Doc[]>([]),[status,setStatus]=useState('示例项目 · 尚未保存'),[ready,setReady]=useState(false),[email,setEmail]=useState(''),[link,setLink]=useState(''),[menu,setMenu]=useState<{x:number;y:number}|null>(null),[editing,setEditing]=useState(''),[refMenu,setRefMenu]=useState(''),[taskFeedback,setTaskFeedback]=useState(''),[composerTarget,setComposerTarget]=useState(''),[pickerRequest,setPickerRequest]=useState(0);
|
||||
const file=useRef<HTMLInputElement>(null),json=useRef<HTMLInputElement>(null),viewport=useRef<HTMLDivElement>(null),drag=useRef<{id:string;x:number;y:number;ox:number;oy:number;before:Doc;resize?:boolean}|null>(null);
|
||||
const pickerSequence=useRef(0),agent=useRef<CanvasAgentHandle>(null);
|
||||
const active=doc.cards.find(c=>c.id===selected);
|
||||
useEffect(()=>{try{const raw=localStorage.getItem(key);if(raw){const d=JSON.parse(raw);if(valid(d)){setDoc(d);setStatus('已恢复本机草稿')}}}catch{setStatus('草稿读取失败,可导入 JSON 恢复')}try{const v=JSON.parse(localStorage.getItem(key+'.view')||'null');if(v&&Number.isFinite(v.zoom)&&v.zoom>=.2&&v.zoom<=2&&Number.isFinite(v.x)&&Number.isFinite(v.y)){setZoom(v.zoom);setPan({x:v.x,y:v.y})}}catch{}try{const t=localStorage.getItem(key+'.theme');setTheme(t==='light'?'light':'dark')}catch{}setReady(true);fetch('/api/account/session').then(r=>r.ok?r.json():null).then(d=>setEmail(d?.user?.email||'')).catch(()=>{});},[]);
|
||||
useEffect(()=>{try{const raw=localStorage.getItem(key);if(raw){const d=parseCanvasDocument(raw);setDoc(d);setStatus('已恢复本机草稿')}}catch{setStatus('草稿读取失败,可导入 JSON 恢复')}try{const v=JSON.parse(localStorage.getItem(key+'.view')||'null');if(v&&Number.isFinite(v.zoom)&&v.zoom>=.2&&v.zoom<=2&&Number.isFinite(v.x)&&Number.isFinite(v.y)){setZoom(v.zoom);setPan({x:v.x,y:v.y})}}catch{}setReady(true);fetch('/api/account/session').then(r=>r.ok?r.json():null).then(d=>setEmail(d?.user?.email||'')).catch(()=>{});},[]);
|
||||
useEffect(()=>{if(!ready)return;try{localStorage.setItem(key,JSON.stringify(doc));setStatus('已保存到此浏览器')}catch{setStatus('本机空间不足,请导出项目备份')}},[doc,ready]);
|
||||
useEffect(()=>{if(ready)try{localStorage.setItem(key+'.view',JSON.stringify({...pan,zoom}))}catch{}},[pan,zoom,ready]);
|
||||
useEffect(()=>{const el=viewport.current;if(!el)return;const block=(e:WheelEvent)=>{if(!(e.target as HTMLElement).closest('textarea,input,select'))e.preventDefault()};el.addEventListener('wheel',block,{passive:false});return()=>el.removeEventListener('wheel',block)},[]);
|
||||
useEffect(()=>{const keys=(e:KeyboardEvent)=>{if(e.key==='Escape'){setMenu(null);setEditing('');return}if((e.target as HTMLElement).closest('input,textarea,select,[contenteditable]'))return;if((e.ctrlKey||e.metaKey)&&e.key.toLowerCase()==='z'){e.preventDefault();if(e.shiftKey)redo();else undo()}if((e.ctrlKey||e.metaKey)&&e.key.toLowerCase()==='d'){e.preventDefault();duplicate()}};window.addEventListener('keydown',keys);return()=>window.removeEventListener('keydown',keys)});
|
||||
useEffect(()=>{if(ready)try{localStorage.setItem(key+'.theme',theme)}catch{}},[theme,ready]);
|
||||
useEffect(()=>{const keys=(e:KeyboardEvent)=>{if(e.key==='Escape'){setMenu(null);setRefMenu('');setEditing('');return}if((e.target as HTMLElement).closest('input,textarea,select,[contenteditable]'))return;if((e.ctrlKey||e.metaKey)&&e.key.toLowerCase()==='z'){e.preventDefault();if(e.shiftKey)redo();else undo()}if((e.ctrlKey||e.metaKey)&&e.key.toLowerCase()==='d'){e.preventDefault();duplicate()}};window.addEventListener('keydown',keys);return()=>window.removeEventListener('keydown',keys)});
|
||||
|
||||
function scaleAt(next:number,cx?:number,cy?:number){const el=viewport.current;if(!el)return;const r=el.getBoundingClientRect(),x=cx===undefined?r.width/2:cx-r.left,y=cy===undefined?r.height/2:cy-r.top;const z=Math.max(.2,Math.min(2,next));setPan({x:x-(x-pan.x)*z/zoom,y:y-(y-pan.y)*z/zoom});setZoom(z)}
|
||||
function duplicate(){if(!active||doc.cards.length>=80)return;const id=Array.from(crypto.getRandomValues(new Uint8Array(16)),b=>b.toString(16).padStart(2,'0')).join('');commit({...doc,cards:[...doc.cards,{...active,id,title:active.title.slice(0,197)+' 副本',x:active.x+40,y:active.y+40}]});select(id)}
|
||||
function commit(next:Doc){setPast(p=>[...p.slice(-29),doc]);setFuture([]);setDoc(next)}
|
||||
function add(kind:Card['kind'],image?:string){if(doc.cards.length>=80){setStatus('最多 80 个节点');return}const id=Array.from(crypto.getRandomValues(new Uint8Array(16)),b=>b.toString(16).padStart(2,'0')).join('');const rect=viewport.current?.getBoundingClientRect();commit({...doc,cards:[...doc.cards,{id,kind,title:kind==='text'?'灵感笔记':kind==='image'?'参考素材':'生成提示词',text:'',x:((menu?.x??(rect?.width||700)/2)-pan.x)/zoom-160,y:((menu?.y??(rect?.height||600)/2)-pan.y)/zoom-100,image}]});select(id)}
|
||||
function change(field:'title'|'text',value:string,id=selected){setDoc(d=>({...d,cards:d.cards.map(c=>c.id===id?{...c,[field]:value}:c)}))}
|
||||
function add(kind:Card['kind'],image?:string){if(doc.cards.length>=80){setStatus('最多 80 个节点');return}const id=Array.from(crypto.getRandomValues(new Uint8Array(16)),b=>b.toString(16).padStart(2,'0')).join('');const rect=viewport.current?.getBoundingClientRect();commit({...doc,cards:[...doc.cards,{id,kind,title:kind==='text'?'灵感笔记':kind==='image'?'图片节点':kind==='script'?'脚本生成器':'生成提示词',text:'',x:((menu?.x??(rect?.width||700)/2)-pan.x)/zoom-160,y:((menu?.y??(rect?.height||600)/2)-pan.y)/zoom-100,image}]});select(id)}
|
||||
function change(field:'title'|'text'|'instruction',value:string,id=selected){setDoc(d=>({...d,cards:d.cards.map(c=>c.id===id?{...c,[field]:value}:c)}))}
|
||||
function beginEdit(){setPast(p=>[...p.slice(-29),doc]);setFuture([])}
|
||||
function undo(){if(!past.length)return;setFuture(f=>[doc,...f]);setDoc(past[past.length-1]);setPast(p=>p.slice(0,-1))}
|
||||
function redo(){if(!future.length)return;setPast(p=>[...p,doc]);setDoc(future[0]);setFuture(f=>f.slice(1))}
|
||||
function fit(){const el=viewport.current;if(!el||!doc.cards.length)return;const minX=Math.min(...doc.cards.map(c=>c.x)),minY=Math.min(...doc.cards.map(c=>c.y));const w=Math.max(...doc.cards.map(c=>c.x+(c.width||320)))-minX,h=Math.max(...doc.cards.map(c=>c.y+(c.height||350)))-minY;const z=Math.max(.2,Math.min(1,(el.clientWidth-80)/w,(el.clientHeight-100)/h));setZoom(z);setPan({x:(el.clientWidth-w*z)/2-minX*z,y:(el.clientHeight-h*z)/2-minY*z})}
|
||||
function selectNode(id:string){if(id!==selected)setEditing('');select(id)}
|
||||
function editNode(id:string){setPickerRequest(0);setComposerTarget('');select(id);setEditing(id);setMenu(null);setRefMenu('');setTaskFeedback('');setTool('select')}
|
||||
function patchNode(id:string,patch:Partial<Card>){commit({...doc,cards:doc.cards.map(n=>n.id===id?{...n,...patch}:n)})}
|
||||
function derive(source:string,kind:Card['kind'],operation:Card['operation']='generate'){
|
||||
const parent=doc.cards.find(n=>n.id===source);if(!parent)return;
|
||||
const id=Array.from(crypto.getRandomValues(new Uint8Array(16)),b=>b.toString(16).padStart(2,'0')).join('');
|
||||
const title=operation==='upscale'?'图片高清':kind==='script'?'脚本生成器':kind==='image'?'图片节点':'文本节点';
|
||||
try{commit(deriveNode(doc,source,{id,kind,operation,title,text:'',x:parent.x+(parent.width||320)+150,y:parent.y,width:360,height:300}));editNode(id);setComposerTarget(id)}catch(e){setStatus(e instanceof Error?e.message:'无法创建节点')}
|
||||
}
|
||||
function connect(from:string,to:string){try{const next=connectNodes(doc,from,to);if(next!==doc)commit(next);setTaskFeedback('')}catch(e){setTaskFeedback(e instanceof Error?e.message:'无法引用节点')}}
|
||||
function exportTask(id:string){try{const task=agentTask(doc,id),url=URL.createObjectURL(new Blob([JSON.stringify(task,null,2)],{type:'application/json'}));const a=document.createElement('a');a.href=url;a.download=`canvas-task-${id}.json`;a.click();setTimeout(()=>URL.revokeObjectURL(url),1000);setTaskFeedback('Agent 任务已导出')}catch(e){setTaskFeedback(e instanceof Error?e.message:'无法导出任务')}}
|
||||
function exportDoc(){const url=URL.createObjectURL(new Blob([JSON.stringify(doc,null,2)],{type:'application/json'}));const a=document.createElement('a');a.href=url;a.download='studio-canvas.json';a.click();setTimeout(()=>URL.revokeObjectURL(url),1000)}
|
||||
return <main className={s.app} data-theme={theme}><Head><title>无限画布 · Studio</title><meta name="description" content="在无限画布上组织灵感、参考素材与镜头提示词"/></Head>
|
||||
<header className={s.header}><a href="/workspace" className={s.back} aria-label="返回 Studio"><ArrowLeft size={18}/></a><a href="/workspace" className={s.brand}>STUDIO<span>✳</span></a><i/><span className={s.crumb}>工作空间 <ChevronRight size={13}/></span><strong>未命名的灵感宇宙</strong><span className={s.badge}>本机草稿</span><div className={s.spacer}/><button aria-label="切换明暗主题" title={theme==='dark'?'切换浅色模式':'切换深色模式'} onClick={()=>setTheme(t=>t==='dark'?'light':'dark')}>{theme==='dark'?<Sun size={17}/>:<Moon size={17}/>}</button><a href="/account">{email||'登录账户'}</a><button className={s.export} onClick={exportDoc}><Download size={15}/>导出项目</button></header>
|
||||
return <main className={s.app}><Head><title>无限画布 · 啡跃 Endoleap</title><meta name="description" content="在无限画布上组织灵感、参考素材与镜头提示词"/></Head>
|
||||
<header className={s.header}><a href="/workspace" className={s.back} aria-label="返回 Studio"><ArrowLeft size={18}/></a><a href="/workspace" className={s.brand}><Brand compact/></a><i/><span className={s.crumb}>工作空间 <ChevronRight size={13}/></span><strong>未命名的灵感宇宙</strong><span className={s.badge}>本机草稿</span><div className={s.spacer}/><CanvasAgentPanel ref={agent} document={doc} email={email} onLoad={next=>{setDoc(next);setPast([]);setFuture([]);setEditing('');select('')}} onFeedback={setTaskFeedback}/><ThemeToggle/><a href="/account">{email||'登录账户'}</a><button className={s.export} onClick={exportDoc}><Download size={15}/>导出项目</button></header>
|
||||
<div className={s.workspace}><nav className={s.rail} aria-label="画布工具"><button title="选择工具" aria-label="选择工具" aria-pressed={tool==='select'} onClick={()=>setTool('select')}><MousePointer2/></button><button title="移动画布" aria-label="移动画布" aria-pressed={tool==='hand'} onClick={()=>setTool('hand')}><Hand/></button><hr/><button title="添加笔记" aria-label="添加笔记" onClick={()=>add('text')}><Type/></button><button title="上传图片" aria-label="上传图片" onClick={()=>file.current?.click()}><ImagePlus/></button><button title="添加提示词" aria-label="添加提示词" onClick={()=>add('prompt')}><Sparkles/></button><button title="导入项目" aria-label="导入项目" onClick={()=>json.current?.click()}><Upload/></button><div className={s.spacer}/><span>∞</span></nav>
|
||||
<section className={s.canvasArea}>{!doc.cards.length&&<div className={s.startPanel}><span>YOUR NEXT IDEA STARTS HERE</span><h2>给想法,一个无限的空间。</h2><p>双击画布添加节点,或选择一个起点。</p><div><button onClick={()=>add('text')}><Type size={19}/>灵感笔记</button><button onClick={()=>file.current?.click()}><ImagePlus size={19}/>参考图片</button><button onClick={()=>add('prompt')}><Sparkles size={19}/>创作提示词</button></div><button onClick={()=>{commit(example);select('');setPan({x:0,y:0});setZoom(.7)}}>从示例开始 ↗</button><a href="/studio/api" target="_blank" rel="noreferrer">连接你的 coding agent · 接入文档 ↗</a></div>}<div className={s.canvasTitle}><span className={s.eyebrow}>YOUR IDEAS, CONNECTED</span><h1>无限画布<span> / Infinite canvas</span></h1><p>从一个灵感,连接所有可能。</p></div>
|
||||
<div className={s.viewport} style={{backgroundPosition:`${pan.x}px ${pan.y}px`,backgroundSize:`${24*zoom*Math.max(1,2**Math.ceil(Math.log2(1/zoom)))}px ${24*zoom*Math.max(1,2**Math.ceil(Math.log2(1/zoom)))}px`}} ref={viewport} onDoubleClick={e=>{if(e.target===e.currentTarget){setMenu(null);add('text')}}} onContextMenu={e=>{e.preventDefault();const id=(e.target as HTMLElement).closest('[data-node-id]')?.getAttribute('data-node-id');select(id||'');setMenu({x:Math.min(e.clientX,window.innerWidth-250),y:Math.min(e.clientY,window.innerHeight-330)})}} onWheel={e=>{const target=e.target as HTMLElement;if(drag.current)return;if(target.closest('textarea,input,select'))return;if(e.ctrlKey||e.metaKey){scaleAt(zoom*Math.exp(-e.deltaY*.002),e.clientX,e.clientY)}else setPan(p=>({x:p.x-e.deltaX,y:p.y-e.deltaY}))}} onPointerDown={e=>{setMenu(null);if(e.target===e.currentTarget)setEditing('');if(e.target===e.currentTarget||(tool==='hand'&&!(e.target as HTMLElement).closest('input,textarea,button,select'))||e.button===1){e.currentTarget.setPointerCapture(e.pointerId);drag.current={id:'pan',x:e.clientX,y:e.clientY,ox:pan.x,oy:pan.y,before:doc}}}} onPointerMove={e=>{const d=drag.current;if(!d)return;if(d.id==='pan')setPan({x:d.ox+e.clientX-d.x,y:d.oy+e.clientY-d.y});else {if(!d.resize&&Math.hypot(e.clientX-d.x,e.clientY-d.y)<4)return;e.currentTarget.setPointerCapture(e.pointerId);setDoc({...d.before,cards:d.before.cards.map(c=>c.id===d.id?(d.resize?{...c,width:Math.max(260,Math.min(1200,d.ox+(e.clientX-d.x)/zoom)),height:Math.max(300,Math.min(1200,d.oy+(e.clientY-d.y)/zoom))}:{...c,x:d.ox+(e.clientX-d.x)/zoom,y:d.oy+(e.clientY-d.y)/zoom}):c)})}}} onPointerUp={e=>{const d=drag.current;if(d?.id==='pan'&&tool==='select'&&Math.hypot(e.clientX-d.x,e.clientY-d.y)<4){select('');setMenu({x:Math.min(e.clientX,window.innerWidth-250),y:Math.min(e.clientY,window.innerHeight-230)})}if(d&&d.id!=='pan'&&doc!==d.before){setPast(p=>[...p.slice(-29),d.before]);setFuture([])}drag.current=null}} onPointerCancel={()=>{if(drag.current?.id!=='pan'&&drag.current)setDoc(drag.current.before);drag.current=null}}>
|
||||
<div className={s.viewport} style={{backgroundPosition:`${pan.x}px ${pan.y}px`,backgroundSize:`${24*zoom*Math.max(1,2**Math.ceil(Math.log2(1/zoom)))}px ${24*zoom*Math.max(1,2**Math.ceil(Math.log2(1/zoom)))}px`}} ref={viewport} onDoubleClick={e=>{if(e.target===e.currentTarget){setMenu(null);add('text')}}} onContextMenu={e=>{e.preventDefault();const id=(e.target as HTMLElement).closest('[data-node-id]')?.getAttribute('data-node-id');select(id||'');setMenu({x:Math.min(e.clientX,window.innerWidth-250),y:Math.min(e.clientY,window.innerHeight-330)})}} onWheel={e=>{const target=e.target as HTMLElement;if(drag.current)return;if(target.closest('textarea,input,select'))return;if(e.ctrlKey||e.metaKey){scaleAt(zoom*Math.exp(-e.deltaY*.002),e.clientX,e.clientY)}else setPan(p=>({x:p.x-e.deltaX,y:p.y-e.deltaY}))}} onPointerDown={e=>{setMenu(null);setRefMenu('');if(e.target===e.currentTarget){setEditing('');select('')};if(e.target===e.currentTarget||(tool==='hand'&&!(e.target as HTMLElement).closest('input,textarea,button,select'))||e.button===1){e.currentTarget.setPointerCapture(e.pointerId);drag.current={id:'pan',x:e.clientX,y:e.clientY,ox:pan.x,oy:pan.y,before:doc}}}} onPointerMove={e=>{const d=drag.current;if(!d)return;if(d.id==='pan')setPan({x:d.ox+e.clientX-d.x,y:d.oy+e.clientY-d.y});else {if(!d.resize&&Math.hypot(e.clientX-d.x,e.clientY-d.y)<4)return;e.currentTarget.setPointerCapture(e.pointerId);setDoc({...d.before,cards:d.before.cards.map(c=>c.id===d.id?(d.resize?{...c,width:Math.max(260,Math.min(1200,d.ox+(e.clientX-d.x)/zoom)),height:Math.max(300,Math.min(1200,d.oy+(e.clientY-d.y)/zoom))}:{...c,x:d.ox+(e.clientX-d.x)/zoom,y:d.oy+(e.clientY-d.y)/zoom}):c)})}}} onPointerUp={e=>{const d=drag.current;if(d?.id==='pan'&&tool==='select'&&Math.hypot(e.clientX-d.x,e.clientY-d.y)<4){select('');setMenu({x:Math.min(e.clientX,window.innerWidth-250),y:Math.min(e.clientY,window.innerHeight-230)})}if(d&&d.id!=='pan'&&doc!==d.before){setPast(p=>[...p.slice(-29),d.before]);setFuture([])}drag.current=null}} onPointerCancel={()=>{if(drag.current?.id!=='pan'&&drag.current)setDoc(drag.current.before);drag.current=null}}>
|
||||
<div className={s.world} style={{transform:`translate(${pan.x}px,${pan.y}px) scale(${zoom})`}}><svg className={s.wires} aria-hidden="true">{doc.links.map(([a,b])=>{const from=doc.cards.find(c=>c.id===a),to=doc.cards.find(c=>c.id===b);if(!from||!to)return null;return <path key={a+b} d={`M ${from.x+(from.width||320)} ${from.y+(from.height||350)/2} C ${from.x+(from.width||320)+70} ${from.y+(from.height||350)/2},${to.x-45} ${to.y+(to.height||350)/2},${to.x} ${to.y+(to.height||350)/2}`}/>})}</svg>
|
||||
{doc.cards.map(c=><article key={c.id} className={`${s.card} ${selected===c.id?s.selected:''} ${editing===c.id?s.editing:s.preview}`} data-node-id={c.id} style={{left:c.x,top:c.y,width:c.width||320,height:c.height||350}} onPointerDown={e=>{if(editing===c.id||tool==='hand'||e.button!==0||(e.target as HTMLElement).closest('button'))return;e.stopPropagation();setMenu(null);select(c.id);setEditing('');drag.current={id:c.id,x:e.clientX,y:e.clientY,ox:c.x,oy:c.y,before:doc}}} onClick={()=>select(c.id)}><div className={s.cardHead} title="拖动手柄 · 移动工具" aria-label={`移动 ${c.title}`} onPointerDown={e=>{if(tool==='hand'||e.button!==0)return;e.stopPropagation();viewport.current?.setPointerCapture(e.pointerId);select(c.id);drag.current={id:c.id,x:e.clientX,y:e.clientY,ox:c.x,oy:c.y,before:doc}}}><span>{c.kind==='text'?<Type size={15}/>:c.kind==='image'?<ImagePlus size={15}/>:<Sparkles size={15}/>} {c.title}</span><span>⠿</span></div>{c.kind==='image'?<div className={s.image} role="button" tabIndex={0} aria-label={`替换 ${c.title} 图片`} onDoubleClick={e=>{e.stopPropagation();select(c.id);setEditing(c.id);setTool('select');setMenu(null);file.current?.click()}} onKeyDown={e=>{if(e.key==='Enter'||e.key===' '){e.preventDefault();select(c.id);setEditing(c.id);file.current?.click()}}}>{c.image?<img src={c.image} alt={c.title}/>:<><div className={s.landscape}><span>REF.</span><b>光的形状</b><small>LIGHT / SPACE / EMOTION</small></div><span className={s.sample}>双击替换参考图</span></>}</div>:<div className={s.cardBody} onDoubleClick={e=>{e.stopPropagation();if(editing===c.id)return;select(c.id);setEditing(c.id);setTool('select');setMenu(null);const body=e.currentTarget;const name=body.querySelector('input');const field=name&&e.clientY<=name.getBoundingClientRect().bottom?name:body.querySelector('textarea');requestAnimationFrame(()=>field?.focus())}}><span className={s.nodeType}>{c.kind==='prompt'?'PROMPT / 提示词':'NOTE / 灵感笔记'}</span><input readOnly={editing!==c.id} tabIndex={editing===c.id?0:-1} aria-label={`${c.title} 名称`} value={c.title} maxLength={200} onFocus={beginEdit} onChange={e=>change('title',e.target.value,c.id)}/><textarea readOnly={editing!==c.id} tabIndex={editing===c.id?0:-1} aria-label={`${c.title} 内容`} value={c.text} maxLength={10000} placeholder="在工具内直接编辑…" onFocus={beginEdit} onChange={e=>change('text',e.target.value,c.id)}/></div>}{c.kind==='prompt'&&<div className={s.disconnected}>提示词已备好,交给你的 Agent 继续创作。</div>}<footer>{c.kind==='image'?'视觉素材':c.kind==='prompt'?'镜头设计':'创作起点'}<button aria-label={`${editing===c.id?'完成编辑':'编辑'} ${c.title}`} onClick={e=>{e.stopPropagation();select(c.id);setEditing(editing===c.id?'':c.id)}}>{editing===c.id?'完成':'编辑'}</button><button aria-label={`${c.title} 操作`} onClick={e=>{e.stopPropagation();select(c.id);setMenu({x:Math.min(e.clientX,window.innerWidth-250),y:Math.min(e.clientY,window.innerHeight-330)})}}>•••</button></footer><span className={s.inputPort}/><span className={s.outputPort}/><button className={s.resize} aria-label={`调整 ${c.title} 尺寸`} onPointerDown={e=>{e.stopPropagation();viewport.current?.setPointerCapture(e.pointerId);select(c.id);drag.current={id:c.id,x:e.clientX,y:e.clientY,ox:c.width||320,oy:c.height||350,before:doc,resize:true}}}>↘</button></article>)}
|
||||
{doc.cards.map(c=><CanvasNodeView key={c.id} node={c} selected={selected===c.id} editing={editing===c.id&&composerTarget!==c.id} hand={tool==='hand'} onSelect={()=>selectNode(c.id)} onChooseReference={()=>{editNode(c.id);setComposerTarget(c.id);setPickerRequest(++pickerSequence.current)}} onEdit={()=>editNode(c.id)} onDrag={e=>{setMenu(null);if(c.id!==editing)setEditing('');drag.current={id:c.id,x:e.clientX,y:e.clientY,ox:c.x,oy:c.y,before:doc}}} onResize={e=>{viewport.current?.setPointerCapture(e.pointerId);select(c.id);drag.current={id:c.id,x:e.clientX,y:e.clientY,ox:c.width||320,oy:c.height||350,before:doc,resize:true}}} onChange={value=>change('text',value,c.id)} onRename={value=>change('title',value,c.id)} onStyle={style=>patchNode(c.id,{style})} onReference={e=>{select(c.id);setRefMenu(c.id);setMenu({x:Math.min(e.clientX,window.innerWidth-250),y:Math.min(e.clientY,window.innerHeight-230)})}} onMenu={e=>{select(c.id);setRefMenu('');setMenu({x:Math.min(e.clientX,window.innerWidth-250),y:Math.min(e.clientY,window.innerHeight-330)})}} onUpload={()=>{select(c.id);file.current?.click()}} onUpscale={()=>derive(c.id,'image','upscale')} onExpand={()=>patchNode(c.id,{width:Math.min(1200,(c.width||320)*1.5),height:Math.min(1200,(c.height||350)*1.3)})} onBeginEdit={beginEdit}/>)}
|
||||
|
||||
</div></div><div className={s.canvasBottom}><span role="status">● {status}</span><div className={s.controls}><button aria-label="撤销" disabled={!past.length} onClick={undo}><Undo2 size={16}/></button><button aria-label="重做" disabled={!future.length} onClick={redo}><Redo2 size={16}/></button><i/><button aria-label="缩小" onClick={()=>scaleAt(zoom-.1)}><Minus size={16}/></button><span>{Math.round(zoom*100)}%</span><button aria-label="放大" onClick={()=>scaleAt(zoom+.1)}><Plus size={16}/></button><button aria-label="适应画布" onClick={fit}><Maximize size={16}/></button></div></div></section>
|
||||
{menu&&<div className={s.contextMenu} role="dialog" aria-label="画布操作" style={{left:Math.max(8,menu.x),top:Math.max(8,menu.y)}}><div className={s.menuTitle}>{active?active.title:'添加到画布'}<button aria-label="关闭画布操作" onClick={()=>setMenu(null)}>×</button></div>{active?<><button onClick={duplicate}>复制节点</button><button onClick={()=>{commit({...doc,cards:doc.cards.filter(c=>c.id!==selected),links:doc.links.filter(l=>!l.includes(selected))});select('');setMenu(null)}}>删除节点</button><label>连接到<select value={link} onChange={e=>setLink(e.target.value)}><option value="">选择目标节点</option>{doc.cards.filter(c=>c.id!==selected).map(c=><option key={c.id} value={c.id}>{c.title}</option>)}</select></label><button disabled={!link} onClick={()=>{if(!doc.links.some(l=>l[0]===selected&&l[1]===link))commit({...doc,links:[...doc.links,[selected,link]]});setLink('')}}>连接节点</button>{doc.links.filter(l=>l.includes(selected)).map(([a,b])=><button key={a+b} onClick={()=>commit({...doc,links:doc.links.filter(l=>l[0]!==a||l[1]!==b)})}>断开:{doc.cards.find(c=>c.id===(a===selected?b:a))?.title}</button>)}</>:<><button onClick={()=>{add('text');setMenu(null)}}>添加笔记</button><button onClick={()=>{add('image');setMenu(null)}}>添加图片</button><button onClick={()=>{add('prompt');setMenu(null)}}>添加提示词</button></>}</div>}</div>
|
||||
{menu&&<div className={s.contextMenu} role="dialog" aria-label="画布操作" style={{left:Math.max(8,menu.x),top:Math.max(8,menu.y)}}><div className={s.menuTitle}>{refMenu?'引用该节点生成':active?active.title:'添加到画布'}<button aria-label="关闭画布操作" onClick={()=>setMenu(null)}>×</button></div>{refMenu?<>{(['text','image','script'] as const).map(kind=><button key={kind} onClick={()=>derive(refMenu,kind,kind==='script'?'script':'generate')}>{kind==='text'?'文本':kind==='image'?'图片':'脚本'}</button>)}</>:active?<><button onClick={()=>{editNode(active.id);setComposerTarget(active.id)}}>创作要求</button><button onClick={duplicate}>复制节点</button><button onClick={()=>{commit(removeCanvasNode(doc,selected));select('');setEditing('');setMenu(null)}}>删除节点</button><label>连接到<select value={link} onChange={e=>setLink(e.target.value)}><option value="">选择目标节点</option>{doc.cards.filter(c=>c.id!==selected).map(c=><option key={c.id} value={c.id}>{c.title}</option>)}</select></label><button disabled={!link} onClick={()=>{connect(selected,link);setLink('')}}>连接节点</button>{doc.links.filter(l=>l.includes(selected)).map(([a,b])=><button key={a+b} onClick={()=>commit({...doc,links:doc.links.filter(l=>l[0]!==a||l[1]!==b)})}>断开:{doc.cards.find(c=>c.id===(a===selected?b:a))?.title}</button>)}</>:<><button onClick={()=>{add('text');setMenu(null)}}>添加笔记</button><button onClick={()=>{add('image');setMenu(null)}}>添加图片</button><button onClick={()=>{add('script');setMenu(null)}}>添加脚本</button></>}</div>}</div>
|
||||
{active&&editing===active.id&&(active.kind!=='text'||composerTarget===active.id)&&<CanvasComposer pickerRequest={pickerRequest} key={active.id} node={active} nodes={doc.cards} references={referencedNodes(doc,active.id)} onChange={value=>change('instruction',value,active.id)} onRename={value=>change('title',value,active.id)} onBeginEdit={beginEdit} onUpload={()=>file.current?.click()} onAddReference={id=>connect(id,active.id)} onRemoveReference={id=>commit({...doc,links:doc.links.filter(([from,to])=>from!==id||to!==active.id)})} onQueue={()=>void agent.current?.queue(active.id)} onExport={()=>exportTask(active.id)} onClose={()=>setEditing('')} feedback={taskFeedback}/>}
|
||||
<input hidden ref={file} type="file" accept="image/png,image/jpeg,image/webp" onChange={async e=>{const f=e.target.files?.[0];e.target.value='';if(!f)return;if(!['image/png','image/jpeg','image/webp'].includes(f.type)||f.size>2000000){setStatus('请选择 2MB 以内的 PNG、JPEG 或 WebP');return}const reader=new FileReader();reader.onload=()=>{const image=String(reader.result);if(active?.kind==='image')commit({...doc,cards:doc.cards.map(c=>c.id===selected?{...c,image}:c)});else add('image',image)};reader.readAsDataURL(f)}}/>
|
||||
<input hidden ref={json} type="file" accept="application/json,.json" onChange={async e=>{const f=e.target.files?.[0];e.target.value='';if(!f)return;try{if(f.size>15000000)throw Error();const d=JSON.parse(await f.text());if(!valid(d))throw Error();commit(d);select(d.cards[0]?.id||'');setPan({x:0,y:0});setStatus('项目已导入')}catch{setStatus('无法导入:项目格式错误或文件过大')}}}/>
|
||||
<input hidden ref={json} type="file" accept="application/json,.json" onChange={async e=>{const f=e.target.files?.[0];e.target.value='';if(!f)return;try{if(f.size>15000000)throw Error();const d=parseCanvasDocument(await f.text());commit(d);select(d.cards[0]?.id||'');setPan({x:0,y:0});setStatus('项目已导入')}catch{setStatus('无法导入:项目格式错误或文件过大')}}}/>
|
||||
</main>;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
/* Shared chrome uses the canonical package tokens; artwork keeps its own palette. */
|
||||
html,body{background:var(--background);color:var(--foreground);color-scheme:inherit}
|
||||
body{margin:0}
|
||||
.endoleap-brand{display:inline-flex;align-items:center;gap:9px;white-space:nowrap;line-height:1.15;vertical-align:middle;letter-spacing:normal;font-weight:650}
|
||||
.endoleap-brand .endoleap-mark{width:32px;height:32px;object-fit:contain;flex-shrink:0}
|
||||
.endoleap-mark-dark{display:none!important}
|
||||
[data-theme$="-dark"] .endoleap-mark-light{display:none!important}
|
||||
[data-theme$="-dark"] .endoleap-mark-dark{display:block!important}
|
||||
.endoleap-wordmark{display:flex!important;flex-direction:column;font-size:17px!important;color:var(--foreground-strong)!important;letter-spacing:.02em!important;margin:0!important;gap:3px}
|
||||
.endoleap-wordmark small{display:block;font-size:9px;font-weight:500;letter-spacing:.08em;color:var(--muted-foreground)}
|
||||
button.endoleap-theme-toggle{display:inline-flex;align-items:center;justify-content:center;gap:5px;border:1px solid var(--border);background:var(--surface);color:var(--foreground);border-radius:8px;padding:7px 9px;font-size:12px;line-height:1.2;flex-shrink:0;cursor:pointer}
|
||||
button.endoleap-theme-toggle:hover{background:var(--row-hover)}
|
||||
:where(a,button,input,textarea,select):focus-visible{outline:2px solid var(--ring);outline-offset:3px}
|
||||
::selection{background:var(--selection);color:var(--foreground)}
|
||||
@media(max-width:600px){.endoleap-theme-toggle span{display:none}.endoleap-brand{gap:5px}.endoleap-brand .endoleap-mark{width:26px;height:26px}.endoleap-wordmark{font-size:14px!important}}
|
||||
|
||||
button:disabled{cursor:default}
|
||||
input,textarea,select{accent-color:var(--primary)}
|
||||
|
||||
.is-editing.native-layout-page{background:var(--background);color:var(--foreground)}
|
||||
.studio-mark,.page-tools,.workbench-top,.workbench-layers,.workbench-properties,.element-popover,.page-edit-info{color:var(--foreground)}
|
||||
.workbench-top button.primary,.page-tools .page-save,.trial-actions .trial-primary{background:var(--primary);color:var(--primary-foreground);border-color:var(--primary)}
|
||||
.workbench-top button.primary:hover,.page-tools .page-save:hover{background:var(--primary-hover)}
|
||||
.native-element.is-selected,.proof-active .proof-select{outline-color:var(--selection-border)}
|
||||
@@ -0,0 +1,3 @@
|
||||
.root{position:static}.panel{position:absolute;left:0;top:calc(100% + 12px);width:min(390px,calc(100vw - 32px));max-height:75dvh;overflow:auto;padding:20px;border:1px solid var(--border);border-radius:16px;background:var(--surface);color:var(--foreground);box-shadow:0 15px 70px var(--shadow);z-index:12;display:grid;gap:14px;font-size:12px}.panel header,.actions,.conflict{display:flex;align-items:center;justify-content:space-between;gap:8px;flex-wrap:wrap}.panel button{font-size:11px!important;padding:7px 10px!important;background:var(--surface)!important;color:inherit!important;border:1px solid var(--border)!important}.panel button:disabled{opacity:.45}.panel textarea{width:100%;height:150px;resize:vertical;background:var(--surface);color:var(--foreground);border:1px solid var(--input);border-radius:8px;padding:10px;font:10px/1.7 monospace}.panel p{margin:0;color:var(--muted-foreground);line-height:1.7}.panel small{color:var(--muted-foreground);font-size:10px}.panel label{display:grid;gap:8px}.panel select{width:100%;background:var(--surface);color:inherit;padding:8px;border-radius:6px}.panel ul{padding:0;margin:0;list-style:none;display:grid;gap:12px}.panel li{display:grid;gap:5px;border-top:1px solid var(--border);padding-top:10px}.panel li button{justify-self:start}.panel a{color:inherit!important;text-decoration:underline!important}
|
||||
|
||||
:global(header):has(> .root .panel){z-index:12}
|
||||
@@ -0,0 +1,4 @@
|
||||
.composer{position:fixed;z-index:6;left:50%;bottom:92px;transform:translateX(-50%);width:min(900px,calc(100vw - 40px));height:280px;border:1px solid var(--border);border-radius:22px;padding:20px 24px;background:var(--surface);color:var(--foreground);display:flex;flex-direction:column;box-shadow:0 15px 70px var(--shadow)}.composer[data-expanded=true]{height:min(65dvh,650px)}.composer header{display:flex;align-items:center;justify-content:space-between;gap:12px;font-size:12px}.composer header>span,.composer header>div{display:flex;align-items:center;gap:8px}.composer header button,.composer footer button{background:var(--surface);border-color:var(--border);font-size:11px;padding:7px 10px}.composer textarea{display:block;flex:1;min-height:50px;width:100%;resize:none;padding:17px 0;border:0;background:transparent;color:inherit;font:inherit;line-height:1.8;outline:none}.composer footer{display:flex;align-items:center;justify-content:space-between;gap:12px;font-size:10px;color:var(--muted-foreground)}.references{display:flex;gap:10px;padding-top:15px;flex-shrink:0;overflow:auto}.reference{width:70px;height:65px;border:1px solid var(--border);border-radius:8px;position:relative;display:grid;place-items:center;flex-shrink:0;overflow:hidden;background:var(--surface)}.reference img{width:100%;height:100%;object-fit:cover}.reference>span{position:absolute;top:3px;left:4px;background:var(--surface);color:var(--foreground);border-radius:50%;padding:1px 4px;font-size:9px}.reference small{position:absolute;bottom:0;width:100%;text-align:center;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;background:var(--surface);font-size:8px;padding:3px}.reference button{position:absolute;top:1px;right:1px;padding:2px!important;background:var(--surface)!important;border:0!important}.picker{position:absolute;bottom:calc(100% + 10px);left:0;background:var(--surface);border:1px solid var(--border);border-radius:14px;padding:10px;max-height:200px;overflow:auto;display:grid;gap:5px;min-width:220px}.picker button{justify-content:flex-start;background:transparent;border:0}.picker>span{font-size:11px;padding:12px}@media(max-width:600px){.composer{width:calc(100vw - 20px);padding:15px;bottom:80px;height:260px;border-radius:16px}.composer header>span{max-width:95px;overflow:hidden;white-space:nowrap;font-size:10px}.composer header button{padding:5px;font-size:10px}.composer header{gap:4px}.composer header>div{gap:4px}}
|
||||
.composer header input{width:200px;max-width:25vw;min-width:0;border:0;background:transparent;color:inherit;font:inherit;outline:none}
|
||||
|
||||
.composer footer>div{display:flex;gap:8px;flex-shrink:0}@media(max-width:600px){.composer footer>div{gap:4px}.composer footer button{font-size:10px;padding:6px}.composer footer>span{min-width:0}}
|
||||
@@ -0,0 +1,3 @@
|
||||
.node{position:absolute;background:var(--surface);border:1px solid var(--border);border-radius:13px;color:var(--foreground);pointer-events:auto;touch-action:none;cursor:grab;outline:none}.node[data-selected=true]{border-color:var(--selection-border)}.node[data-editing=true]{border:2px solid var(--border);cursor:default}.title{position:absolute;top:-31px;left:0;right:0;display:flex;gap:7px;align-items:center;height:26px;color:var(--muted-foreground);font-size:12px;white-space:nowrap}.title>span{overflow:hidden;text-overflow:ellipsis}.title>button{margin-left:auto;padding:3px!important;background:transparent!important;border:0!important}.content,.image{height:100%;overflow:hidden;border-radius:12px}.content{padding:25px;white-space:pre-wrap;overflow:auto;scrollbar-width:thin}.content p{margin:0;line-height:1.7;pointer-events:none}.image{position:relative;display:grid;place-items:center}.image img{width:100%;height:100%;object-fit:contain;pointer-events:none;user-select:none;-webkit-user-drag:none}.empty{height:100%;width:100%;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:22px;color:var(--muted-foreground)}.empty button{background:transparent!important;border:0!important;color:var(--muted-foreground)!important;font-size:12px!important}.node>textarea{width:100%;height:100%;border:0;border-radius:12px;background:transparent;color:inherit;outline:none;resize:none;padding:25px;line-height:1.7;touch-action:auto}.format{position:absolute;bottom:calc(100% + 48px);left:50%;transform:translateX(-50%);display:flex;align-items:center;gap:4px;background:var(--surface);border:1px solid var(--border);border-radius:12px;padding:6px;white-space:nowrap;z-index:3}.format input{width:24px;height:24px;padding:0;border:0;background:transparent}.format select{max-width:100px;background:transparent;color:var(--foreground);border:0;padding:6px}.format button{padding:5px!important;border:0!important;background:transparent!important}.format button[aria-pressed=true]{background:var(--selection)!important}.reference{position:absolute!important;right:-38px;top:calc(50% - 14px);width:28px;height:28px;border-radius:50%!important;border:1px solid var(--border)!important;padding:2px!important;background:var(--surface)!important;color:var(--foreground)!important}.resize{position:absolute!important;right:3px;bottom:3px;padding:3px!important;border:0!important;background:transparent!important;color:var(--muted-foreground)!important;cursor:nwse-resize!important}.upscale{position:absolute;bottom:10px;left:10px;font-size:10px!important;background:var(--surface)!important}.node:focus-visible{outline:2px solid var(--ring);outline-offset:3px}
|
||||
.title>input{min-width:0;width:100%;border:0!important;background:transparent!important;padding:0!important;color:inherit;font:inherit;outline:none}.title>button{flex-shrink:0}
|
||||
.incoming{right:auto!important;left:-38px}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,65 @@
|
||||
import test from 'node:test';
|
||||
import {readFileSync} from 'node:fs';
|
||||
import assert from 'node:assert/strict';
|
||||
import {parseCanvasDocument,deriveNode,connectNodes,removeCanvasNode,referencedNodes,agentTask} from '../src/lib/canvas-document.ts';
|
||||
const note={id:'brief',kind:'text',title:'创意简报',text:'清晨的城市',x:20,y:50};
|
||||
const doc={version:1,cards:[note],links:[]};
|
||||
test('existing v1 browser drafts remain readable and untrusted fields are discarded',()=>{
|
||||
assert.deepEqual(parseCanvasDocument(JSON.stringify({...doc,token:'ignored',cards:[{...note,html:'ignored'}]})),doc);
|
||||
for(const bad of [{...doc,cards:[note,note]},{...doc,links:[['brief','missing']]},{...doc,cards:[{...note,image:'https://example.com/x.png'}]},{...doc,cards:[{...note,style:{color:'url(javascript:bad)'}}]}])assert.throws(()=>parseCanvasDocument(bad));
|
||||
});
|
||||
test('derived image and script nodes preserve directed references across save and restore',()=>{
|
||||
const picture={...note,id:'picture',kind:'image',title:'参考图',image:'data:image/png;base64,AAAA',operation:'generate'};
|
||||
const next=deriveNode(doc,'brief',picture);
|
||||
const restored=parseCanvasDocument(JSON.stringify(deriveNode(next,'picture',{...note,id:'script',kind:'script',operation:'script'})));
|
||||
assert.equal(referencedNodes(restored,'picture')[0].id,'brief');assert.equal(referencedNodes(restored,'script')[0].id,'picture');
|
||||
assert.throws(()=>connectNodes(restored,'script','brief'),/循环/);
|
||||
assert.throws(()=>parseCanvasDocument({...restored,links:[...restored.links,['script','brief']]}),/循环/);
|
||||
const removed=removeCanvasNode(restored,'picture');assert.deepEqual(removed.links,[]);assert.equal(removed.cards.length,2);
|
||||
});
|
||||
test('agent task captures current referenced content and validates upscale inputs',()=>{
|
||||
const next=deriveNode(doc,'brief',{...note,id:'out',kind:'script',operation:'script'});
|
||||
assert.equal(agentTask(next,'out').references[0].text,'清晨的城市');
|
||||
assert.equal(agentTask(next,'out').result.targetId,'out');
|
||||
assert.throws(()=>agentTask({...next,cards:next.cards.map(n=>n.id==='out'?{...n,operation:'upscale'}:n)},'out'),/引用一张图片/);
|
||||
});
|
||||
|
||||
test('instructions stay distinct from generated content in agent requests',()=>{
|
||||
const result={...note,id:'script',kind:'script',text:'已有脚本正文',instruction:'改写为六个分镜',operation:'script'};
|
||||
const restored=parseCanvasDocument(JSON.stringify(deriveNode(doc,'brief',result)));
|
||||
assert.equal(agentTask(restored,'script').instruction,'改写为六个分镜');
|
||||
assert.equal(restored.cards.find(n=>n.id==='script').text,'已有脚本正文');
|
||||
});
|
||||
|
||||
test('editing an image passes the current image to the external agent',()=>{
|
||||
const image={...note,id:'image',kind:'image',instruction:'把背景改为雪夜',image:'data:image/png;base64,AAAA'};
|
||||
assert.equal(agentTask({version:1,cards:[image],links:[]},'image').sourceImage,image.image);
|
||||
});
|
||||
|
||||
const overlaps=(a,b)=>a.x<b.x+(b.width||320)&&a.x+(a.width||320)>b.x&&a.y<b.y+(b.height||350)&&a.y+(a.height||350)>b.y;
|
||||
test('branching the example keeps the source image accessible after image and script derivation',()=>{
|
||||
const scene={version:1,cards:[
|
||||
{...note,x:70,y:140},
|
||||
{...note,id:'reference',kind:'image',x:460,y:80},
|
||||
{...note,id:'shot',kind:'prompt',x:850,y:160},
|
||||
],links:[['brief','reference'],['reference','shot']]};
|
||||
const image=deriveNode(scene,'reference',{...note,id:'hd',kind:'image',operation:'upscale',x:930,y:80,width:360,height:300});
|
||||
const result=deriveNode(image,'brief',{...note,id:'script',kind:'script',operation:'script',x:540,y:140,width:360,height:300});
|
||||
assert.deepEqual(result.cards.slice(0,3),scene.cards,'Existing content and positions stay unchanged');
|
||||
for(const added of result.cards.slice(3))for(const other of result.cards.filter(n=>n.id!==added.id))assert.equal(overlaps(added,other),false,`${added.id} blocks ${other.id}`);
|
||||
assert.equal(referencedNodes(result,'hd')[0].id,'reference');
|
||||
assert.equal(referencedNodes(result,'script')[0].id,'brief');
|
||||
assert.deepEqual(parseCanvasDocument(JSON.stringify(result)),result);
|
||||
});
|
||||
test('a free requested position is retained and large nodes near the coordinate limit can still branch',()=>{
|
||||
const free=deriveNode(doc,'brief',{...note,id:'free',x:2000,y:50});
|
||||
assert.deepEqual({x:free.cards[1].x,y:free.cards[1].y},{x:2000,y:50});
|
||||
const edge={version:1,cards:[{...note,x:90000,y:99600,width:1200,height:1200}],links:[]};
|
||||
const result=deriveNode(edge,'brief',{...note,id:'edge',x:90500,y:99700,width:360,height:300});
|
||||
assert.equal(overlaps(result.cards[0],result.cards[1]),false);
|
||||
assert.deepEqual(parseCanvasDocument(JSON.stringify(result)),result);
|
||||
});
|
||||
|
||||
test('agent and browser parse the same canvas document contract',()=>{
|
||||
assert.equal(readFileSync(new URL('../src/lib/canvas-document.ts',import.meta.url),'utf8'),readFileSync(new URL('../../waterfall-wiki/src/lib/canvas-document.ts',import.meta.url),'utf8'));
|
||||
});
|
||||
@@ -6,13 +6,13 @@ RUN apt-get update \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
FROM base AS dependencies
|
||||
WORKDIR /repo/apps/waterfall-wiki
|
||||
RUN corepack enable && corepack prepare pnpm@11.25.0 --activate
|
||||
COPY apps/waterfall-wiki/pnpm-lock.yaml ./
|
||||
COPY pnpm-workspace.yaml /tmp/monorepo-policy.yaml
|
||||
RUN node -e "const fs=require('fs');const text=fs.readFileSync('/tmp/monorepo-policy.yaml','utf8');fs.writeFileSync('pnpm-workspace.yaml', text.slice(text.indexOf('allowBuilds:')));"
|
||||
RUN pnpm fetch --frozen-lockfile --fetch-timeout=300000 --network-concurrency=8
|
||||
COPY pnpm-workspace.yaml /repo/pnpm-workspace.yaml
|
||||
COPY packages/brand/ /repo/packages/brand/
|
||||
COPY apps/waterfall-wiki/package.json ./
|
||||
RUN pnpm install --offline --frozen-lockfile
|
||||
RUN pnpm --filter @prompt/waterfall-wiki install --frozen-lockfile --fetch-timeout=300000 --network-concurrency=8
|
||||
|
||||
FROM dependencies AS build
|
||||
# .dockerignore excludes docs and operational notes without extra legacy COPY snapshots.
|
||||
@@ -24,11 +24,12 @@ USER node
|
||||
CMD ["npm", "run", "test:integration"]
|
||||
|
||||
FROM base AS runtime
|
||||
WORKDIR /app/apps/waterfall-wiki
|
||||
ENV NODE_ENV=production HOSTNAME=0.0.0.0 PORT=3000 \
|
||||
FFMPEG_PATH=/usr/bin/ffmpeg FFPROBE_PATH=/usr/bin/ffprobe
|
||||
COPY --from=build --chown=node:node /app/.next/standalone/ ./
|
||||
COPY --from=build --chown=node:node /app/dist/worker.cjs /app/dist/bootstrap.cjs /app/dist/publish-learning-series.cjs /app/dist/auth-cli.cjs ./
|
||||
COPY --from=build --chown=node:node /app/db/ ./db/
|
||||
COPY --from=build --chown=node:node /repo/apps/waterfall-wiki/.next/standalone/ /app/
|
||||
COPY --from=build --chown=node:node /repo/apps/waterfall-wiki/dist/worker.cjs /repo/apps/waterfall-wiki/dist/bootstrap.cjs /repo/apps/waterfall-wiki/dist/publish-learning-series.cjs /repo/apps/waterfall-wiki/dist/auth-cli.cjs ./
|
||||
COPY --from=build --chown=node:node /repo/apps/waterfall-wiki/db/ ./db/
|
||||
COPY --chown=node:node apps/waterfall-wiki/scripts/container-health.mjs ./
|
||||
COPY --chown=node:node apps/waterfall-wiki/scripts/import-character-packs.mjs apps/waterfall-wiki/scripts/verify-character-pack.mjs ./scripts/
|
||||
COPY --chown=node:node apps/waterfall-wiki/seed/characters/ ./seed/characters/
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
BEGIN;
|
||||
CREATE TABLE canvas_documents (
|
||||
id uuid PRIMARY KEY, owner_id uuid NOT NULL REFERENCES account_users(id) ON DELETE CASCADE,
|
||||
name text NOT NULL DEFAULT '未命名画布' CHECK(length(name) BETWEEN 1 AND 200),
|
||||
revision integer NOT NULL DEFAULT 1 CHECK(revision>0), document_key text NOT NULL,
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX canvas_owner_updated ON canvas_documents(owner_id,updated_at DESC);
|
||||
CREATE TABLE canvas_agent_links (
|
||||
digest text PRIMARY KEY, canvas_id uuid NOT NULL REFERENCES canvas_documents(id) ON DELETE CASCADE,
|
||||
expires_at timestamptz NOT NULL, last_seen_at timestamptz
|
||||
);
|
||||
CREATE INDEX canvas_link_expiry ON canvas_agent_links(canvas_id,expires_at);
|
||||
CREATE TABLE canvas_tasks (
|
||||
id uuid PRIMARY KEY, canvas_id uuid NOT NULL REFERENCES canvas_documents(id) ON DELETE CASCADE,
|
||||
target_id text NOT NULL, source_revision integer NOT NULL,
|
||||
status text NOT NULL DEFAULT 'queued' CHECK(status IN ('queued','running','succeeded','failed')),
|
||||
request_key text NOT NULL, result_digest text, error text,
|
||||
created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE UNIQUE INDEX canvas_target_active_task ON canvas_tasks(canvas_id,target_id) WHERE status IN ('queued','running');
|
||||
INSERT INTO schema_migrations(version) VALUES ('016_canvas_workspace');
|
||||
COMMIT;
|
||||
@@ -0,0 +1,5 @@
|
||||
BEGIN;
|
||||
ALTER TABLE account_tokens DROP CONSTRAINT account_tokens_kind_check;
|
||||
ALTER TABLE account_tokens ADD CONSTRAINT account_tokens_kind_check CHECK (kind IN ('verify','session','reset'));
|
||||
INSERT INTO schema_migrations(version) VALUES ('017_password_reset');
|
||||
COMMIT;
|
||||
@@ -1,7 +1,9 @@
|
||||
import { resolve } from "node:path";
|
||||
import type { NextConfig } from "next";
|
||||
import managedMedia from "./src/content/managed-media.json";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
turbopack: { root: resolve(process.cwd(), "../..") },
|
||||
serverExternalPackages: ["playwright-core"],
|
||||
async redirects(){return [
|
||||
{source:'/studio/graphic',destination:'/graphic',permanent:true},
|
||||
@@ -20,7 +22,7 @@ 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: process.cwd(),
|
||||
output: "standalone", outputFileTracingRoot: resolve(process.cwd(), "../.."),
|
||||
outputFileTracingIncludes: {"/*":["./node_modules/playwright-core/**/*"]},
|
||||
poweredByHeader: false,
|
||||
};
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
"node": ">=24 <25"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build && esbuild src/worker.ts --bundle --platform=node --format=cjs --external:pg-native --external:playwright-core --outfile=dist/worker.cjs && esbuild scripts/bootstrap.ts --bundle --platform=node --format=cjs --external:pg-native --external:playwright-core --outfile=dist/bootstrap.cjs && esbuild scripts/publish-learning-series.ts --bundle --platform=node --format=cjs --external:pg-native --external:playwright-core --outfile=dist/publish-learning-series.cjs && esbuild scripts/auth-cli.ts --bundle --platform=node --format=cjs --external:pg-native --outfile=dist/auth-cli.cjs && node scripts/prepare-standalone.mjs",
|
||||
"dev": "pnpm brand:prepare && next dev",
|
||||
"build": "pnpm brand:prepare && next build && esbuild src/worker.ts --bundle --platform=node --format=cjs --external:pg-native --external:playwright-core --outfile=dist/worker.cjs && esbuild scripts/bootstrap.ts --bundle --platform=node --format=cjs --external:pg-native --external:playwright-core --outfile=dist/bootstrap.cjs && esbuild scripts/publish-learning-series.ts --bundle --platform=node --format=cjs --external:pg-native --external:playwright-core --outfile=dist/publish-learning-series.cjs && esbuild scripts/auth-cli.ts --bundle --platform=node --format=cjs --external:pg-native --outfile=dist/auth-cli.cjs && node scripts/prepare-standalone.mjs",
|
||||
"start": "next start",
|
||||
"typecheck": "next typegen && tsc --noEmit",
|
||||
"test": "tsx --test tests/api-documentation.test.ts tests/health.test.ts tests/model.test.ts tests/learning-series.test.ts tests/skill-docs.test.ts tests/wiki.test.ts tests/explore.test.ts tests/preview.test.ts tests/note-markdown.test.tsx tests/api-publication.test.ts tests/discovery.test.ts tests/auth.test.ts tests/account.test.ts tests/guidance.test.ts tests/guidance-publishing.test.ts",
|
||||
@@ -15,7 +15,8 @@
|
||||
"storage:prepare": "tsx scripts/bootstrap.ts",
|
||||
"content:publish-learning": "tsx scripts/publish-learning-series.ts",
|
||||
"worker": "tsx src/worker.ts",
|
||||
"test:integration": "tsx --test tests/integration.test.ts"
|
||||
"test:integration": "tsx --test tests/integration.test.ts",
|
||||
"brand:prepare": "node ../../packages/brand/scripts/prepare-app.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "3.1155.0",
|
||||
@@ -32,7 +33,8 @@
|
||||
"react-markdown": "10.1.0",
|
||||
"remark-gfm": "4.0.1",
|
||||
"sharp": "0.35.5",
|
||||
"zod": "4.6.7"
|
||||
"zod": "4.6.7",
|
||||
"@endoleap/brand": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "4.3.3",
|
||||
|
||||
Generated
+3
@@ -11,6 +11,9 @@ importers:
|
||||
'@aws-sdk/client-s3':
|
||||
specifier: 3.1155.0
|
||||
version: 3.1155.0
|
||||
'@endoleap/brand':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/brand
|
||||
formidable:
|
||||
specifier: 3.5.4
|
||||
version: 3.5.4
|
||||
|
||||
@@ -48,6 +48,7 @@ async function main() {
|
||||
const request = (path:string,init:RequestInit={}) => fetch(base+path,{...init,redirect:'manual'});
|
||||
const buildId = readFileSync('.next/BUILD_ID','utf8').trim();
|
||||
for(const path of ['/','/workspace','/documents','/account'])assert.equal((await request(path)).status,200,'Public shell '+path);
|
||||
for(const path of ['/favicon.ico','/brand/logo-light.svg','/brand/logo-dark.svg','/brand/icon-dark.svg','/apple-touch-icon.png']) assert.equal((await request(path)).status,200,'Public brand asset '+path);
|
||||
const studio=await request('/studio');assert.equal(studio.status,307);assert.equal(studio.headers.get('location'),'/workspace');
|
||||
for(const [from,to] of [['/studio/graphic','/graphic'],['/studio/infinite_canvas','/infinite_canvas']]){const r=await request(from);assert.equal(r.status,308);assert.equal(r.headers.get('location'),to)}
|
||||
assert.equal((await request('/account')).status,200,'Public account page');
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { cp,mkdir,access } from 'node:fs/promises';
|
||||
await mkdir('.next/standalone/.next',{recursive:true});
|
||||
await cp('.next/static','.next/standalone/.next/static',{recursive:true});
|
||||
try{await access('public');await cp('public','.next/standalone/public',{recursive:true});}catch(e){if(e.code!=='ENOENT')throw e;}
|
||||
await mkdir('.next/standalone/apps/waterfall-wiki/.next',{recursive:true});
|
||||
await cp('.next/static','.next/standalone/apps/waterfall-wiki/.next/static',{recursive:true});
|
||||
try{await access('public');await cp('public','.next/standalone/apps/waterfall-wiki/public',{recursive:true});}catch(e){if(e.code!=='ENOENT')throw e;}
|
||||
|
||||
@@ -17,9 +17,9 @@ async function availablePort() {
|
||||
|
||||
const port = process.env.SMOKE_PORT || await availablePort();
|
||||
const release = "smoke-test";
|
||||
const previewFixture='.next/standalone/public/__smoke-preview.png';
|
||||
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/server.js"], {
|
||||
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 },
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
/** The approved mark is copied from @endoleap/brand during the remote build. */
|
||||
export function Brand({ compact = false }: { compact?: boolean }) {
|
||||
return <span className="endoleap-brand"><img src="/brand/logo-light.svg" className="endoleap-mark endoleap-mark-light" alt="" width={32} height={32}/><img src="/brand/logo-dark.svg" className="endoleap-mark endoleap-mark-dark" alt="" width={32} height={32}/><span className="endoleap-wordmark">啡跃{!compact && <small>Endoleap</small>}</span></span>;
|
||||
}
|
||||
@@ -35,7 +35,7 @@ export function ExploreDetail({entry:initial,background}:{entry:ExploreEntry;bac
|
||||
async function archive(){
|
||||
setArchiveBusy(true);setCopyError('');try{const r=await fetch('/api/posts/'+entry.id+'/archive',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({archived:!entry.archived})});const d=await r.json();if(!r.ok)throw Error(d.error||'操作失败');const state=await fetch('/api/notes/'+entry.id).then(r=>r.json());setEntry(state.entry);void router.replace(router.asPath,undefined,{scroll:false})}catch(e){setCopyError(e instanceof Error?e.message:'操作失败')}finally{setArchiveBusy(false)}
|
||||
}
|
||||
return <Shell><Head><title>{entry.title} · PROMPT</title><link rel="canonical" href={exploreUrl(entry.id)}/><link rel="alternate" type="text/plain" href={exploreUrl(entry.id)+'?agent_mode=True'}/></Head>
|
||||
return <Shell><Head><title>{entry.title} · 啡跃 Endoleap</title><link rel="canonical" href={exploreUrl(entry.id)}/><link rel="alternate" type="text/plain" href={exploreUrl(entry.id)+'?agent_mode=True'}/></Head>
|
||||
<div aria-hidden="true" inert><ExploreFeed key={entry.revision} initial={background.map(e=>e.id===entry.id?entry:e)}/></div>
|
||||
<div className="note-overlay" onClick={()=>{if(!editing)close()}}><div className="note-dialog" ref={detailRef} role="dialog" aria-modal="true" aria-labelledby="note-title" onClick={e=>e.stopPropagation()}>
|
||||
<button className="note-close" ref={closeRef} aria-label={editing?'取消编辑':'关闭详情'} onClick={close}><X/></button>
|
||||
|
||||
@@ -31,12 +31,12 @@ export function Feed({initial,section='',tagPath=''}:{initial:FeedData;section?:
|
||||
useEffect(()=>{generation.current++;setPosts(initial.posts);setCursor(initial.nextCursor);setError('');setBusy(false);},[initial,router.query.q]);
|
||||
const current=sections.find(s=>s.path===section);const target=initial.tags.find(t=>t.path===tagPath);const children=initial.tags;
|
||||
async function load(){const gen=generation.current;setBusy(true);setError('');try{const q=new URLSearchParams({section,path:tagPath,cursor:cursor||'',q:String(router.query.q||'')});const res=await fetch('/api/feed?'+q);const d=await res.json();if(!res.ok)throw new Error(d.error);if(gen===generation.current){setPosts(old=>[...old,...d.posts.filter((p:Post)=>!old.some(a=>a.id===p.id))]);setCursor(d.nextCursor);}}catch{if(gen===generation.current)setError('加载没有完成,点击重试。');}finally{if(gen===generation.current)setBusy(false);}}
|
||||
return <Shell><Head><title>{target?.name||current?.name||'灵感首页'} · PROMPT</title><meta name="description" content="自己的图文、视频与声音创作空间"/><link rel="alternate" type="text/markdown" href="/index.md" title="Agent 文档入口"/></Head>
|
||||
return <Shell><Head><title>{target?.name||current?.name||'灵感首页'} · 啡跃 Endoleap</title><meta name="description" content="自己的图文、视频与声音创作空间"/><link rel="alternate" type="text/markdown" href="/index.md" title="Agent 文档入口"/></Head>
|
||||
{current&&<header className="page-header feed-heading"><div><h1>{target?.name||current.name}</h1><p>{current.hint}</p></div></header>}
|
||||
{tagPath&&<div className="breadcrumbs"><Link href={`/${section}`}>全部{current?.name}</Link>{tagPath.split('/').map((_,i)=>{const path=tagPath.split('/').slice(0,i+1).join('/');return <span key={path}> / <Link href={`/${section}/${path}`}>{initial.tags.find(t=>t.path===path)?.name}</Link></span>;})}</div>}
|
||||
<div className="feed-toolbar"><div className="feed-tabs">{!section?<><Link className="selected" href="/">全部</Link>{sections.map(s=><Link key={s.path} href={`/${s.path}`}>{s.name}</Link>)}</>:<><Link className={!tagPath?'selected':''} href={`/${section}`}>全部</Link>{children.map(t=><Link key={t.id} href={`/${section}/${t.path}`}>{t.name}</Link>)}</>}</div><span className="sort-label"><ArrowDown size={13}/>最近收录</span></div>
|
||||
{initial.unavailable?<div className="empty-state"><Layers/><h2>素材库暂时没有连接上</h2><p>请稍后刷新,已保存的内容不会因此丢失。</p></div>:(posts.length||(!section&&skillCatalog.some(s=>(s.title+s.description+s.category).toLowerCase().includes(String(router.query.q||'').toLowerCase()))))?<Masonry posts={posts} includeSkills={!section} q={String(router.query.q||'')}/>:<div className="empty-state"><div className="empty-art"><span/><ImageIcon size={42} strokeWidth={1.2}/><i><AudioLines size={18}/></i><b><Film size={19}/></b></div><span className="eyebrow">A LITTLE SPACE FOR SOMETHING GOOD</span><h2>{router.query.q?'还没有找到这份灵感':'这里还没有内容'}</h2><p>{router.query.q?'换一个词试试看。':'导入图片、视频或声音,开始整理你的素材。'}</p><Link className="primary-button" href={`/library/import${current&&['image','video','audio'].includes(current.kind)?'?kind='+current.kind:''}`}><Upload size={16}/>导入素材<ArrowUpRight size={16}/></Link><small>图文和动态作品通过代码加入,自由决定每一个像素。</small></div>}
|
||||
{cursor&&<div className="load-more"><button disabled={busy} onClick={load}>{busy?'正在翻找…':error||'再看一些'}<ArrowDown size={14}/></button></div>}
|
||||
<footer className="page-footer">PROMPT <span>·</span> PROBABLY RENDERING ONE MORE PIXEL TONIGHT</footer>
|
||||
<footer className="page-footer">啡跃 <span>·</span> Endoleap</footer>
|
||||
</Shell>;
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import {Shell} from './Shell';
|
||||
import {SkillCard} from './SkillCard';
|
||||
import {skillCatalog} from '@/content/skills/catalog';
|
||||
import {guidanceDocuments,humanDocumentUrl} from '@/content/skills/guidance';
|
||||
export function GuidanceIndex(){return <Shell><Head><title>指导文档 · PROMPT</title><link rel="canonical" href="/skills"/><link rel="alternate" type="text/markdown" href="/skills/index.md" title="指导文档"/></Head>
|
||||
export function GuidanceIndex(){return <Shell><Head><title>指导文档 · 啡跃 Endoleap</title><link rel="canonical" href="/skills"/><link rel="alternate" type="text/markdown" href="/skills/index.md" title="指导文档"/></Head>
|
||||
<header className="page-header"><div><h1>指导文档</h1><p>每篇都有独立地址,网页与 Markdown 一一对应。沿着引用,找到需要的方法。</p></div></header>
|
||||
<article className="docs-body"><h2>全部文档</h2><p>这些分组只帮助浏览。任何一篇都可以独立进入,也可以引用其他文档。</p><div className="guidance-directory">{guidanceDocuments.map(doc=><div key={doc.path}><Link href={humanDocumentUrl(doc.path)}>{doc.title}</Link><small>{doc.collection}</small><a href={`${humanDocumentUrl(doc.path)}/index.md`}>Markdown ↗</a></div>)}</div></article>
|
||||
<h2>按创作主题浏览</h2>
|
||||
|
||||
@@ -333,7 +333,7 @@ export default function GuidanceReader({
|
||||
<dt>来源</dt>
|
||||
<dd>
|
||||
{source === "example"
|
||||
? "织页协议样例"
|
||||
? "啡跃协议样例"
|
||||
: canEdit
|
||||
? "你的文档"
|
||||
: "社区作者"}
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import a from "@/styles/account.module.css";
|
||||
import { ThemeToggle } from "./ThemeToggle";
|
||||
import { Brand } from "./Brand";
|
||||
import Head from "next/head";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
@@ -46,9 +49,11 @@ export const useGuidanceAccount = () => useContext(AccountContext);
|
||||
export default function GuidanceShell({
|
||||
children,
|
||||
title = "探索文档",
|
||||
standalone = false,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
title?: string;
|
||||
standalone?: boolean;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const dialog = useRef<HTMLDialogElement>(null);
|
||||
@@ -110,12 +115,7 @@ export default function GuidanceShell({
|
||||
href="/"
|
||||
onClick={() => dialog.current?.close()}
|
||||
>
|
||||
<span className={s.brandMark}>
|
||||
<Waypoints size={22} strokeWidth={2.2} />
|
||||
</span>
|
||||
<span>
|
||||
织页<small>KNOWLEDGE FOR AGENTS</small>
|
||||
</span>
|
||||
<Brand/>
|
||||
</Link>
|
||||
<div className={s.workspaceLabel}>
|
||||
<Globe2 size={16} />
|
||||
@@ -190,11 +190,23 @@ export default function GuidanceShell({
|
||||
</>
|
||||
);
|
||||
}
|
||||
if (standalone) return (
|
||||
<AccountContext.Provider value={{ user, loading, error, refresh }}>
|
||||
<div className={a.page}>
|
||||
<Head><title>{`${title} · 啡跃`}</title></Head>
|
||||
<a className={s.skipLink} href="#account-content">跳到正文</a>
|
||||
<header className={a.header}>
|
||||
<div><Link href="/">返回首页</Link><ThemeToggle /></div>
|
||||
</header>
|
||||
<main id="account-content">{children}</main>
|
||||
</div>
|
||||
</AccountContext.Provider>
|
||||
);
|
||||
return (
|
||||
<AccountContext.Provider value={{ user, loading, error, refresh }}>
|
||||
<div className={s.site}>
|
||||
<Head>
|
||||
<title>{`${title} · 织页`}</title>
|
||||
<title>{`${title} · 啡跃`}</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="为 Agent 编写、发现和连接可独立使用的指导文档。"
|
||||
@@ -230,7 +242,7 @@ export default function GuidanceShell({
|
||||
<ChevronRight size={14} />
|
||||
<strong>{title}</strong>
|
||||
</div>
|
||||
<div className={s.topbarActions}>
|
||||
<div className={s.topbarActions}><ThemeToggle/>
|
||||
<Link className={s.topbarProtocol} href="/protocol">
|
||||
<span className={s.statusDot} />
|
||||
Linked Guidance 0.1
|
||||
@@ -253,7 +265,7 @@ export default function GuidanceShell({
|
||||
{children}
|
||||
</main>
|
||||
<footer className={s.workspaceFooter}>
|
||||
<span>织页 · 让知识按需连接</span>
|
||||
<span>啡跃 · 让知识按需连接</span>
|
||||
<Link href="/protocol">
|
||||
文档协议 <ArrowUpRight size={12} />
|
||||
</Link>
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
type Pose = "idea" | "read" | "make" | "collect" | "dream";
|
||||
|
||||
/** Original theme-aware line drawings, independent of the fixed brand mark. */
|
||||
export function CreatorSketch({ pose }: { pose: Pose }) {
|
||||
return <svg viewBox="0 0 200 220" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<path d="M13 17 185 14l3 176-174 3ZM17 202q42-3 86 0m-85 8 62 1" strokeWidth="1.5" />
|
||||
<path d="M42 188q3-46 31-60l42 1q28 16 42 59" fill="var(--selection)" />
|
||||
<path d="M78 64q-20-3-15-20 5-15 21-11 11-15 29-6 20 7 11 31l-18-10-15 18Z" fill="currentColor" />
|
||||
<path d="M79 63q-11 5-5 17l8 2q1 27 21 29 21-4 21-39l-3-15m-34 52-5 20m24-18 7 18m-17-58-2 13 8 2m-9 13q9 5 17-3m-23-23h1m24-1h1" />
|
||||
{pose === "idea" && <><path d="m117 135 17-38 13-48 11 3-7 53-14 49M68 143l-5 27 37 15" fill="var(--background)" /><path d="m141 70-8-31 26-9 11 29Z" fill="var(--selection)" /><path d="m142 42 14-5m-11 14 13-5m-10 13 12-5M151 22V12m16 14 8-7m-41 12-9-4" /></>}
|
||||
{pose === "read" && <><path d="m49 132 49 16 46-20-4 58-41 15-46-15Z" fill="var(--background)" /><path d="m98 148 1 53m-35-54 23 8m-22 3 20 7m-19 3 16 6m28-16 20-8m-20 19 18-7m-18 17 15-6" /><path d="m50 162 12-5q8 1 5 8l-14 13m89-17-12-4q-9 0-6 7l15 12" fill="var(--background)" /></>}
|
||||
{pose === "make" && <><path d="m99 143 65-7 15 51-68 5Z" fill="var(--background)" /><path d="m101 150-22 3-8 19 32 5m-39-6 16 7 29-2m21-12h13m-28 29H37" /><path d="m151 46 5-14 8 12 15 2-11 9 1 15-13-8-13 5 4-13-10-10Z" fill="var(--selection)" /></>}
|
||||
{pose === "collect" && <><path d="m60 156 15-18 53 7-5 40-62-5Z" fill="var(--background)" /><path d="m76 138 8-11 19 3 7 13m-12 9a12 12 0 1 0 1 24 12 12 0 0 0-1-24m-36 10-11-5-8 8 15 15m64-21 15 9-13 11" /><path d="m143 68 25-7 7 31-25 7Zm7 14 6-6 10 9m-8-14h1" stroke="var(--primary)" /></>}
|
||||
{pose === "dream" && <><path d="m82 136 16 30 26-6 12-36m-18 38 18 11 11-35-7-20q-4-7-8-3l-2 14-6-7m-46 26-9 25 28 14 37-13" fill="var(--background)" /><path d="m142 32 29-10-13 25-4-12-12-3 12 3 17-13m-36 25q-21 4-15 17t25 2" stroke="var(--primary)" /></>}
|
||||
</svg>;
|
||||
}
|
||||
|
||||
export function IdeaSketch({ kind }: { kind: "collect" | "connect" | "read" | "make" }) {
|
||||
return <svg viewBox="0 0 140 110" fill="none" stroke="currentColor" strokeWidth="2.1" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
{kind === "collect" && <><path d="m43 25 49-5 7 64-53 6Z" fill="var(--selection)" /><path d="m54 39 25-2m-24 14 30-3m-28 14 21-2m20-23 18-20 7 7-20 21-9 3ZM34 18l-6-8m19 3-1-10m61 62 8 3m-81 2-10 2" /></>}
|
||||
{kind === "connect" && <><path d="M42 37q27-31 50-1m-48 8q27-17 47-1M45 62q23 28 46 2" /><path d="m18 32 29-3 3 34-31 2Zm72-8 32 4-3 33-30-3ZM52 73l32-1 2 25-32 2Z" fill="var(--selection)" /><path d="m26 43 14-1m-13 9 10-1m62-13 13 2m-13 7 11 1M62 83h13m-12 7h9" /></>}
|
||||
{kind === "read" && <><path d="M20 30q25-8 49 7 24-17 51-10l-3 56q-24-5-48 10-22-13-47-7Z" fill="var(--selection)" /><path d="m69 37 0 56M32 42q13-1 25 5m-23 8 21 5m-21 8 20 4m27-27 24-6m-23 19 21-6m-20 18 18-4m-35-44-2-11m17 10 6-10" /></>}
|
||||
{kind === "make" && <><path d="m24 29 85-3 5 63-86 4ZM31 41h71M45 34h1m-10 0h1m20-1h1m-18 21 21-2 1 26-22 1Zm31 0 27-1m-26 12 20-1m-19 13 24-1" /><path d="m103 14 7-11 4 12 13 2-10 8 2 11-10-6-10 7 2-12-10-7Z" fill="var(--selection)" /></>}
|
||||
</svg>;
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import { ThemeToggle } from "./ThemeToggle";
|
||||
import { Brand } from "./Brand";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import { Grid2X2,Image,Film,AudioLines,BookOpen,Clapperboard,Sparkles,ArrowUpRight,Layers,Tags,Upload,Menu,X,Search } from "lucide-react";
|
||||
@@ -7,9 +9,9 @@ export function Shell({children}:{children:ReactNode}){
|
||||
const router=useRouter();const [open,setOpen]=useState(false);const [query,setQuery]=useState(String(router.query.q||''));
|
||||
useEffect(()=>setQuery(String(router.query.q||'')),[router.query.q]);
|
||||
return <div className="app-shell"><button className="mobile-menu icon-button" aria-label={open?"关闭导航":"展开导航"} aria-expanded={open} onClick={()=>setOpen(!open)}>{open?<X size={22}/>:<Menu size={22}/>}</button>
|
||||
<aside className={`sidebar ${open?'is-open':''}`}><Link href="/" className="brand" aria-label="PROMPT 首页" onClick={()=>setOpen(false)}><img className="brand-mark" src="/brand-mark.svg" width={40} height={40} alt=""/><strong>PROMPT</strong></Link>
|
||||
<aside className={`sidebar ${open?'is-open':''}`}><Link href="/" className="brand" aria-label="啡跃 Endoleap 首页" onClick={()=>setOpen(false)}><Brand/></Link>
|
||||
<nav>{groups.map((g,i)=><div className="nav-group" key={i}>{g.label&&<p>{g.label}</p>}{g.items.map(item=>{const active=item.href==='/'?router.asPath.split('?')[0]==='/':router.asPath.split('?')[0].startsWith(item.href);return <Link key={item.href} href={item.href} title={item.label} className={`nav-item ${active?'active':''}`} onClick={()=>setOpen(false)}><item.icon size={24} strokeWidth={active?2.2:1.8}/><span>{item.label}</span></Link>;})}</div>)}</nav>
|
||||
|
||||
<button className="auth-logout" onClick={async()=>{const response=await fetch('/api/auth/logout',{method:'POST'});if(response.ok)window.location.assign('/login');}}>退出登录</button></aside><main className="main-content"><div className="global-topbar"><form className="global-search" role="search" onSubmit={e=>{e.preventDefault();const path=router.asPath.split('?')[0];const destination=path==='/categories'?path:'/';void router.push({pathname:destination,query:{...(destination==='/categories'&&router.query.category?{category:router.query.category}:{}),...(query.trim()?{q:query.trim()}:{})}});}}><Search size={24}/><input aria-label="搜索笔记全文" placeholder="搜索知识、灵感和素材" value={query} onChange={e=>setQuery(e.target.value)}/>{query&&<button type="button" aria-label="清空搜索" onClick={()=>{setQuery('');void router.push({pathname:router.pathname==='/categories'?'/categories':'/',query:router.query.category?{category:router.query.category}:{}})}}><X size={20}/></button>}<button type="submit" className="search-submit">搜索</button></form></div>{children}</main>{open&&<button className="nav-scrim" aria-label="关闭导航" onClick={()=>setOpen(false)}/>}
|
||||
<button className="auth-logout" onClick={async()=>{const response=await fetch('/api/auth/logout',{method:'POST'});if(response.ok)window.location.assign('/login');}}>退出登录</button></aside><main className="main-content"><div className="global-topbar"><ThemeToggle/><form className="global-search" role="search" onSubmit={e=>{e.preventDefault();const path=router.asPath.split('?')[0];const destination=path==='/categories'?path:'/';void router.push({pathname:destination,query:{...(destination==='/categories'&&router.query.category?{category:router.query.category}:{}),...(query.trim()?{q:query.trim()}:{})}});}}><Search size={24}/><input aria-label="搜索笔记全文" placeholder="搜索知识、灵感和素材" value={query} onChange={e=>setQuery(e.target.value)}/>{query&&<button type="button" aria-label="清空搜索" onClick={()=>{setQuery('');void router.push({pathname:router.pathname==='/categories'?'/categories':'/',query:router.query.category?{category:router.query.category}:{}})}}><X size={20}/></button>}<button type="submit" className="search-submit">搜索</button></form></div>{children}</main>{open&&<button className="nav-scrim" aria-label="关闭导航" onClick={()=>setOpen(false)}/>}
|
||||
</div>;
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ export function SkillDocs({documentPath,documentId='index',skillId='street-peopl
|
||||
setCopied(true);
|
||||
}catch{setCopied(false)}
|
||||
}
|
||||
return <Shell><Head><title>{doc.title} · 指导文档 · PROMPT</title><link rel="canonical" href={humanDocumentUrl(path)}/><link rel="alternate" type="text/markdown" href={markdownDocumentUrl(path)} title={doc.title}/></Head>
|
||||
return <Shell><Head><title>{doc.title} · 指导文档 · 啡跃 Endoleap</title><link rel="canonical" href={humanDocumentUrl(path)}/><link rel="alternate" type="text/markdown" href={markdownDocumentUrl(path)} title={doc.title}/></Head>
|
||||
<div className="docs-top"><Link href="/skills">← 全部指导文档</Link><button onClick={()=>setOpen(v=>!v)} aria-expanded={open}><PanelRight size={16}/>文档与来源 · {doc.resources.length}</button></div>
|
||||
<div className={`docs-layout ${open?'with-sources':''}`}><article className="docs-body">
|
||||
<div className="docs-eyebrow">{doc.collection} / 指导文档</div><h1>{doc.title}</h1>
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
export function ThemeToggle() {
|
||||
const [dark, setDark] = useState(false);
|
||||
const [ready, setReady] = useState(false);
|
||||
useEffect(() => { const sync = () => setDark(document.documentElement.dataset.mode === 'dark'); sync(); setReady(true); window.addEventListener('endoleap-theme', sync); return () => window.removeEventListener('endoleap-theme', sync); }, []);
|
||||
return <button type="button" data-theme-ready={ready} className="endoleap-theme-toggle" aria-label="切换明暗主题" title={dark ? '切换浅色模式' : '切换深色模式'} onClick={() => { const mode = document.documentElement.dataset.mode === 'dark' ? 'light' : 'dark'; const scope = document.documentElement.dataset.theme?.startsWith('canvas-') ? 'canvas' : 'home'; document.documentElement.dataset.mode = mode; document.documentElement.dataset.theme = `${scope}-${mode}`; try { localStorage.setItem('endoleap.theme', mode); } catch {} window.dispatchEvent(new Event('endoleap-theme')); }}><span aria-hidden="true">◐</span></button>;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { normalizeEmail,validPassword, trustedOrigin,accountCookie } from './account-crypto';
|
||||
import { rateLimit,registerAccount,resendVerification,verifyAccount,loginAccount,currentAccount,logoutAccount } from './account';
|
||||
import { requestPasswordReset,resetPassword,rateLimit,registerAccount,resendVerification,verifyAccount,loginAccount,currentAccount,logoutAccount } from './account';
|
||||
export const runtime = 'nodejs';
|
||||
const headers = {'Cache-Control':'private, no-store','Referrer-Policy':'no-referrer'};
|
||||
const json = (body: object,status=200) => NextResponse.json(body,{status,headers});
|
||||
@@ -12,7 +12,7 @@ export async function GET(request: Request,context: Context) {
|
||||
export async function POST(request: Request,context: Context) {
|
||||
if (!trustedOrigin(request)) return json({error:'请求来源不受信任'},403);
|
||||
const {action} = await context.params;
|
||||
if (!['register','resend','verify','login','logout'].includes(action)) return json({error:'不存在'},404);
|
||||
if (!['register','resend','verify','login','logout','forgot','reset'].includes(action)) return json({error:'不存在'},404);
|
||||
try {
|
||||
// Revocation remains available even when login or mail limits are exhausted.
|
||||
if (action === 'logout') { await logoutAccount(request); const r=json({ok:true});r.headers.set('Set-Cookie',accountCookie('',true));return r; }
|
||||
@@ -27,6 +27,19 @@ export async function POST(request: Request,context: Context) {
|
||||
if(typeof body.token!=='string'||! /^[a-f0-9]{64}$/.test(body.token))return json({error:'验证链接无效或已过期'},400);
|
||||
return await verifyAccount(body.token) ? json({ok:true,message:'邮箱已验证,请登录'}) : json({error:'验证链接无效或已过期'},400);
|
||||
}
|
||||
if(action==='reset') {
|
||||
if(typeof body.token!=='string'||!/^[a-f0-9]{64}$/.test(body.token)||!validPassword(body.password))return json({error:'重设链接无效,或密码不符合要求'},400);
|
||||
if(!await rateLimit('reset:'+body.token,5))return json({error:'请求过于频繁,请稍后再试'},429);
|
||||
if(!await resetPassword(body.token,body.password))return json({error:'重设链接无效或已过期,请重新申请'},400);
|
||||
const r=json({ok:true,message:'密码已更新,请使用新密码登录'});r.headers.set('Set-Cookie',accountCookie('',true));return r;
|
||||
}
|
||||
if(action==='forgot') {
|
||||
const email=normalizeEmail(body.email);
|
||||
if(!email)return json({error:'请输入有效邮箱'},400);
|
||||
if(!await rateLimit('forgot:'+email,3))return json({error:'请求过于频繁,请 15 分钟后再试'},429);
|
||||
await requestPasswordReset(email);
|
||||
return json({ok:true,message:'若该邮箱对应已验证账户,重设链接将发送至邮箱,有效期 30 分钟。'});
|
||||
}
|
||||
const email=normalizeEmail(body.email);
|
||||
if(!email||!validPassword(body.password))return json({error:'请输入有效邮箱及 12–256 字节密码'},400);
|
||||
if(!await rateLimit(action+':'+email,action==='login'?10:3))return json({error:'请求过于频繁,请 15 分钟后再试'},429);
|
||||
|
||||
@@ -1,20 +1,44 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import nodemailer from 'nodemailer';
|
||||
import { db, transaction } from './db';
|
||||
import { ACCOUNT_COOKIE, SESSION_TTL, newToken, tokenDigest, hashPassword, checkPassword } from './account-crypto';
|
||||
import { ACCOUNT_COOKIE, SESSION_TTL, newToken, tokenDigest, hashPassword, checkPassword, validPassword } from './account-crypto';
|
||||
export async function rateLimit(key: string, limit: number) {
|
||||
const result = await db().query(`INSERT INTO account_limits(key,attempts,window_start) VALUES ($1,1,now())
|
||||
ON CONFLICT(key) DO UPDATE SET attempts=CASE WHEN account_limits.window_start < now()-interval '15 minutes' THEN 1 ELSE account_limits.attempts+1 END,
|
||||
window_start=CASE WHEN account_limits.window_start < now()-interval '15 minutes' THEN now() ELSE account_limits.window_start END RETURNING attempts`,[tokenDigest(key)]);
|
||||
return result.rows[0].attempts <= limit;
|
||||
}
|
||||
export async function sendVerification(email: string, token: string) {
|
||||
async function sendAccountMail(email: string, token: string, kind: 'verify' | 'reset') {
|
||||
const origin = process.env.ACCOUNT_PUBLIC_ORIGIN;
|
||||
if (!origin || !process.env.SMTP_PASSWORD || !process.env.SMTP_USERNAME) throw Error('SMTP unavailable');
|
||||
const url = new URL('/account',origin); url.searchParams.set('verify',token);
|
||||
const url = new URL('/account',origin); url.searchParams.set(kind,token);
|
||||
const smtp = nodemailer.createTransport({host:process.env.SMTP_HOST || 'smtpdm.aliyun.com',port:Number(process.env.SMTP_PORT || 465),secure:true,
|
||||
auth:{user:process.env.SMTP_USERNAME,pass:process.env.SMTP_PASSWORD},connectionTimeout:10000,socketTimeout:15000});
|
||||
await smtp.sendMail({from:process.env.SMTP_FROM || process.env.SMTP_USERNAME,to:email,subject:'验证你的知识站邮箱',text:`请在 30 分钟内打开链接并点击确认验证邮箱:\n${url.href}\n如果你没有注册,请忽略。`});
|
||||
await smtp.sendMail({from:process.env.SMTP_FROM || process.env.SMTP_USERNAME,to:email,subject:kind==='reset'?'重设你的啡跃账户密码':'验证你的知识站邮箱',text:kind==='reset'?`请在 30 分钟内打开链接,由你设置新的密码:\n${url.href}\n如果你没有请求重设密码,请忽略;你的密码不会改变。`:`请在 30 分钟内打开链接并点击确认验证邮箱:\n${url.href}\n如果你没有注册,请忽略。`});
|
||||
}
|
||||
export async function sendVerification(email: string,token: string) { await sendAccountMail(email,token,'verify'); }
|
||||
export async function requestPasswordReset(email: string) {
|
||||
const result=await db().query('SELECT id FROM account_users WHERE email=$1 AND verified_at IS NOT NULL',[email]);
|
||||
if(!result.rowCount)return;
|
||||
const token=newToken(),digest=tokenDigest(token);
|
||||
await db().query("INSERT INTO account_tokens(digest,user_id,kind,expires_at) VALUES ($1,$2,'reset',now()+interval '30 minutes')",[digest,result.rows[0].id]);
|
||||
try { await sendAccountMail(email,token,'reset'); } catch { await db().query('DELETE FROM account_tokens WHERE digest=$1',[digest]);throw Error('SMTP unavailable'); }
|
||||
}
|
||||
export async function resetPassword(token: string,password: string) {
|
||||
if(!/^[a-f0-9]{64}$/.test(token)||!validPassword(password))return false;
|
||||
const hash=await hashPassword(password),digest=tokenDigest(token);
|
||||
return transaction(async client=>{
|
||||
const lookup=await client.query("SELECT user_id FROM account_tokens WHERE digest=$1 AND kind='reset' AND expires_at>now()",[digest]);
|
||||
if(!lookup.rowCount)return false;
|
||||
const id=lookup.rows[0].user_id;
|
||||
// Serialize all reset links for one account, then recheck after acquiring the lock.
|
||||
await client.query('SELECT id FROM account_users WHERE id=$1 FOR UPDATE',[id]);
|
||||
const consumed=await client.query("DELETE FROM account_tokens WHERE digest=$1 AND kind='reset' AND expires_at>now() RETURNING user_id",[digest]);
|
||||
if(!consumed.rowCount)return false;
|
||||
await client.query('UPDATE account_users SET password_hash=$1 WHERE id=$2',[hash,id]);
|
||||
await client.query('DELETE FROM account_tokens WHERE user_id=$1',[id]);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
export async function registerAccount(email: string,password: string) {
|
||||
const hash = await hashPassword(password);
|
||||
@@ -42,14 +66,16 @@ export async function verifyAccount(token: string) {
|
||||
});
|
||||
}
|
||||
export async function loginAccount(email: string,password: string) {
|
||||
const result = await db().query('SELECT * FROM account_users WHERE email=$1',[email]);
|
||||
return transaction(async client=>{
|
||||
const result = await client.query('SELECT * FROM account_users WHERE email=$1 FOR UPDATE',[email]);
|
||||
const user = result.rows[0];
|
||||
// A fixed dummy hash makes unknown accounts perform the same expensive KDF.
|
||||
const hash = user?.password_hash || 'scrypt:00000000000000000000000000000000:'+'0'.repeat(128);
|
||||
if (!await checkPassword(password,hash) || !user?.verified_at) return null;
|
||||
const token = newToken();
|
||||
await db().query("INSERT INTO account_tokens(digest,user_id,kind,expires_at) VALUES ($1,$2,'session',now()+$3*interval '1 second')",[tokenDigest(token),user.id,SESSION_TTL]);
|
||||
await client.query("INSERT INTO account_tokens(digest,user_id,kind,expires_at) VALUES ($1,$2,'session',now()+$3*interval '1 second')",[tokenDigest(token),user.id,SESSION_TTL]);
|
||||
return token;
|
||||
});
|
||||
}
|
||||
export function requestToken(request: Request) {
|
||||
const value = request.headers.get('cookie')?.split(';').map(s=>s.trim()).find(s=>s.startsWith(ACCOUNT_COOKIE+'='))?.slice(ACCOUNT_COOKIE.length+1);
|
||||
|
||||
@@ -2,6 +2,14 @@ import type {NextApiRequest,NextApiResponse} from 'next';
|
||||
|
||||
// Read documentation before business validation or storage access. Mutations never enter this branch.
|
||||
export const apiDocuments:Record<string,readonly string[]>={
|
||||
"/api/canvases/{...path}": [
|
||||
"在线画布与 coding agent", "GET, POST, PUT, PATCH, DELETE",
|
||||
"参数:POST /api/canvases {name?,document} 创建本人画布;GET 列出本人画布。GET /api/canvases/{id} 返回 document、revision 和 tasks;PUT {revision,document} 更新,冲突返回 409。所有者使用已验证邮箱会话,写入要求同源 Origin。",
|
||||
"连接:所有者 POST /api/canvases/{id}/link 创建仅限此画布、有效一小时的 token(只返回一次,旧 token 同时撤销)。Agent 使用 Authorization: Bearer <token>。DELETE 同一路径并发送 {} 撤销;GET 读取 expiresAt/lastSeenAt。不要将 token 放入 URL、日志或作品中。",
|
||||
"任务:POST /api/canvases/{id}/tasks {revision,targetId} 保存当前引用和指令快照;GET /api/canvases/{id}/tasks/{taskId} 读取 request(instruction、references、可选 sourceImage)。PATCH 先发送 {status:running} 领取任务,执行实际生成后发送 {status:succeeded,result:{text?,image?}},或 {status:failed,error:原因}。image 仅允许嵌入式 PNG/JPEG/WebP data URL(小于 3MB);正文最多 10000 字符。结果只写回该任务的目标节点,不能改写节点标识和引用。",
|
||||
"返回:任务状态 queued/running/succeeded/failed,画布版本为正整数。任务期间画布变动则结果回写返回 409,先读取新画布并重新派发任务;相同成功结果重复提交幂等。请求快照和画布内容保存在 S3;访问凭据仅在数据库保存摘要。",
|
||||
"401 未登录;403 来源或权限不足;404 画布/任务不存在或不可访问;409 版本/任务冲突;413 文件过大;503 服务不可用。"
|
||||
],
|
||||
"/api/posts/{id}/archive": [
|
||||
"归档或恢复笔记", "POST",
|
||||
"路径参数 id 为存储 UUID 或24位 publicId。JSON 参数:{archived:true} 归档,{archived:false} 恢复。重复调用幂等,不删除 post、正文、媒体、标签或页面记录。",
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
export type NodeKind = 'text' | 'image' | 'prompt' | 'script';
|
||||
export type TextStyle = {bold?:boolean;italic?:boolean;color?:string;size?:number};
|
||||
export type CanvasNode = {
|
||||
id:string;kind:NodeKind;title:string;text:string;x:number;y:number;
|
||||
image?:string;width?:number;height?:number;style?:TextStyle;
|
||||
instruction?:string;
|
||||
operation?:'generate'|'upscale'|'script';
|
||||
};
|
||||
export type CanvasDocument = {version:1;cards:CanvasNode[];links:[string,string][]};
|
||||
const kinds:NodeKind[]=['text','image','prompt','script'];
|
||||
const object=(v:unknown):v is Record<string,unknown>=>!!v&&typeof v==='object'&&!Array.isArray(v);
|
||||
const bounded=(v:unknown,min:number,max:number)=>typeof v==='number'&&Number.isFinite(v)&&v>=min&&v<=max;
|
||||
const text=(v:unknown,max:number)=>typeof v==='string'&&v.length<=max;
|
||||
const image=(v:unknown)=>typeof v==='string'&&v.length<3000000&&/^data:image\/(png|jpeg|webp);base64,[A-Za-z0-9+/=]+$/.test(v);
|
||||
|
||||
export function parseCanvasDocument(value:unknown):CanvasDocument {
|
||||
const d=typeof value==='string'?JSON.parse(value):value;
|
||||
if(!object(d)||d.version!==1||!Array.isArray(d.cards)||d.cards.length>80||!Array.isArray(d.links)||d.links.length>200)throw Error('画布格式不正确');
|
||||
const ids=new Set<string>();
|
||||
const cards:CanvasNode[]=d.cards.map((n:unknown)=>{
|
||||
if(!object(n)||!text(n.id,100)||!n.id||ids.has(n.id as string)||!kinds.includes(n.kind as NodeKind)||!text(n.title,200)||!text(n.text,10000)||!bounded(n.x,-100000,100000)||!bounded(n.y,-100000,100000))throw Error('节点格式不正确');
|
||||
if(n.width!==undefined&&!bounded(n.width,180,1200)||n.height!==undefined&&!bounded(n.height,160,1200)||n.image!==undefined&&!image(n.image))throw Error('节点尺寸或图片不正确');
|
||||
if(n.instruction!==undefined&&!text(n.instruction,10000))throw Error('创作要求格式不正确');
|
||||
if(n.operation!==undefined&&!['generate','upscale','script'].includes(n.operation as string))throw Error('生成任务类型不正确');
|
||||
let style:TextStyle|undefined;
|
||||
if(n.style!==undefined){
|
||||
if(!object(n.style)||n.style.bold!==undefined&&typeof n.style.bold!=='boolean'||n.style.italic!==undefined&&typeof n.style.italic!=='boolean'||n.style.color!==undefined&&(typeof n.style.color!=='string'||!/^#[a-f\d]{6}$/i.test(n.style.color))||n.style.size!==undefined&&!bounded(n.style.size,10,72))throw Error('文字样式不正确');
|
||||
style={...(n.style.bold===undefined?{}:{bold:n.style.bold as boolean}),...(n.style.italic===undefined?{}:{italic:n.style.italic as boolean}),...(n.style.color===undefined?{}:{color:n.style.color as string}),...(n.style.size===undefined?{}:{size:n.style.size as number})};
|
||||
}
|
||||
ids.add(n.id as string);
|
||||
return {id:n.id as string,kind:n.kind as NodeKind,title:n.title as string,text:n.text as string,x:n.x as number,y:n.y as number,...(n.width===undefined?{}:{width:n.width as number}),...(n.height===undefined?{}:{height:n.height as number}),...(n.image===undefined?{}:{image:n.image as string}),...(style?{style}:{}),...(n.instruction===undefined?{}:{instruction:n.instruction as string}),...(n.operation===undefined?{}:{operation:n.operation as CanvasNode['operation']})};
|
||||
});
|
||||
const pairs=new Set<string>();
|
||||
const links:[string,string][]=d.links.map((l:unknown)=>{
|
||||
if(!Array.isArray(l)||l.length!==2||l[0]===l[1]||!l.every(id=>typeof id==='string'&&ids.has(id)))throw Error('引用节点不存在');
|
||||
const key=JSON.stringify(l);if(pairs.has(key))throw Error('引用重复');pairs.add(key);return [l[0],l[1]];
|
||||
});
|
||||
const outgoing=new Map<string,string[]>(),indegree=new Map(cards.map(n=>[n.id,0]));
|
||||
for(const [from,to] of links){outgoing.set(from,[...(outgoing.get(from)||[]),to]);indegree.set(to,indegree.get(to)!+1)}
|
||||
const roots=cards.filter(n=>indegree.get(n.id)===0).map(n=>n.id);let visited=0;
|
||||
while(roots.length){const id=roots.pop()!;visited++;for(const to of outgoing.get(id)||[]){const count=indegree.get(to)!-1;indegree.set(to,count);if(!count)roots.push(to)}}
|
||||
if(visited!==cards.length)throw Error('引用不能形成循环');
|
||||
return {version:1,cards,links};
|
||||
}
|
||||
|
||||
export function referencedNodes(doc:CanvasDocument,target:string):CanvasNode[]{
|
||||
return doc.links.filter(([,to])=>to===target).map(([from])=>doc.cards.find(n=>n.id===from)!).filter(Boolean);
|
||||
}
|
||||
export function connectNodes(doc:CanvasDocument,from:string,to:string):CanvasDocument {
|
||||
if(from===to||![from,to].every(id=>doc.cards.some(n=>n.id===id)))throw Error('请选择不同的有效节点');
|
||||
if(doc.links.some(([a,b])=>a===from&&b===to))return doc;
|
||||
if(doc.links.length>=200)throw Error('最多 200 条引用');
|
||||
const seen=new Set<string>();const pending=[to];
|
||||
while(pending.length){const id=pending.pop()!;if(id===from)throw Error('引用不能形成循环');if(seen.has(id))continue;seen.add(id);pending.push(...doc.links.filter(([a])=>a===id).map(([,b])=>b));}
|
||||
return {...doc,links:[...doc.links,[from,to]]};
|
||||
}
|
||||
export function removeCanvasNode(doc:CanvasDocument,id:string):CanvasDocument {
|
||||
return {...doc,cards:doc.cards.filter(n=>n.id!==id),links:doc.links.filter(l=>!l.includes(id))};
|
||||
}
|
||||
export function deriveNode(doc:CanvasDocument,source:string,node:CanvasNode):CanvasDocument {
|
||||
if(doc.cards.length>=80||doc.cards.some(n=>n.id===node.id))throw Error('节点数量或标识不正确');
|
||||
const target=parseCanvasDocument({version:1,cards:[node],links:[]}).cards[0];
|
||||
for(let attempt=0;attempt<=doc.cards.length+1;attempt++){
|
||||
const blockers=doc.cards.filter(n=>target.x<n.x+(n.width||320)+64&&target.x+(target.width||320)+64>n.x&&target.y<n.y+(n.height||350)+64&&target.y+(target.height||350)+64>n.y);
|
||||
if(!blockers.length)return parseCanvasDocument(connectNodes({...doc,cards:[...doc.cards,target]},source,target.id));
|
||||
const nextY=Math.max(...blockers.map(n=>n.y+(n.height||350)+64));
|
||||
target.y=nextY>100000?-100000:nextY;
|
||||
}
|
||||
throw Error('请移动现有节点后再创建引用节点');
|
||||
}
|
||||
export function agentTask(doc:CanvasDocument,id:string){
|
||||
const node=doc.cards.find(n=>n.id===id);if(!node)throw Error('目标节点不存在');
|
||||
const instruction=node.instruction??node.text;
|
||||
if(!instruction.trim()&&node.operation!=='upscale')throw Error('请先填写创作要求');
|
||||
const references=referencedNodes(doc,id);
|
||||
if(node.operation==='upscale'&&!references.some(n=>n.kind==='image'&&n.image))throw Error('请先引用一张图片');
|
||||
return {schema:'studio.canvas-task/1',target:{id:node.id,kind:node.kind,title:node.title},operation:node.operation||'generate',instruction,...(node.image?{sourceImage:node.image}:{}),references:references.map(n=>({id:n.id,kind:n.kind,title:n.title,text:n.text,...(n.image?{image:n.image}:{}),...(n.style?{style:n.style}:{})})),result:{targetId:node.id,allowedFields:node.kind==='image'?['image','text']:['text']}};
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import {randomUUID,createHash} from 'node:crypto';
|
||||
import {PutObjectCommand} from '@aws-sdk/client-s3';
|
||||
import {db,transaction} from './db';
|
||||
import {storage,bucket,getObject,removeObject} from './storage';
|
||||
import {newToken,tokenDigest} from './account-crypto';
|
||||
import {parseCanvasDocument,agentTask,type CanvasDocument} from './canvas-document';
|
||||
export class CanvasError extends Error { constructor(public status:number,message:string){super(message)} }
|
||||
const inputDocument=(input:unknown)=>{try{return parseCanvasDocument(input)}catch{throw new CanvasError(400,'画布或结果参数无效')}};
|
||||
export const uuid=(v:unknown):v is string=>typeof v==='string'&&/^[a-f\d]{8}-[a-f\d]{4}-[a-f\d]{4}-[a-f\d]{4}-[a-f\d]{12}$/i.test(v);
|
||||
export const revision=(v:unknown):v is number=>typeof v==='number'&&Number.isSafeInteger(v)&&v>0&&v<2147483647;
|
||||
export async function readCanvasBody(key:string):Promise<CanvasDocument>{const o=await getObject(key);return parseCanvasDocument(await o.Body!.transformToString())}
|
||||
async function putJson(canvas:string,value:unknown){const key=`canvases/${canvas}/${randomUUID()}.json`,body=JSON.stringify(value);if(Buffer.byteLength(body)>15000000)throw new CanvasError(413,'画布不能超过 15MB');await storage().send(new PutObjectCommand({Bucket:bucket(),Key:key,Body:body,ContentType:'application/json'}));return key}
|
||||
export async function authorizeCanvas(id:string,owner:string|null,bearer:string|null){
|
||||
const record=await db().query('SELECT * FROM canvas_documents WHERE id=$1',[id]);const doc=record.rows[0];
|
||||
if(!doc)throw new CanvasError(404,'画布不存在');
|
||||
if(owner===doc.owner_id)return doc;
|
||||
if(bearer&&/^[a-f\d]{64}$/.test(bearer)){
|
||||
const auth=await db().query('UPDATE canvas_agent_links SET last_seen_at=now() WHERE canvas_id=$1 AND digest=$2 AND expires_at>now() RETURNING canvas_id',[id,tokenDigest(bearer)]);
|
||||
if(auth.rowCount)return doc;
|
||||
}
|
||||
throw new CanvasError(404,'画布不存在或无访问权限');
|
||||
}
|
||||
export async function canvasState(record:{id:string;revision:number;name:string;document_key:string}){
|
||||
const tasks=await db().query('SELECT id,target_id AS "targetId",status,error,source_revision AS "sourceRevision",created_at AS "createdAt" FROM canvas_tasks WHERE canvas_id=$1 ORDER BY created_at DESC LIMIT 80',[record.id]);
|
||||
return {id:record.id,name:record.name,revision:record.revision,document:await readCanvasBody(record.document_key),tasks:tasks.rows};
|
||||
}
|
||||
export async function createCanvas(owner:string,input:unknown,name='未命名画布'){
|
||||
if(typeof name!=='string'||!name.trim()||name.length>200)throw new CanvasError(400,'画布名称无效');
|
||||
const document=inputDocument(input),id=randomUUID(),key=await putJson(id,document);
|
||||
try{const r=await db().query('INSERT INTO canvas_documents(id,owner_id,name,document_key) VALUES($1,$2,$3,$4) RETURNING *',[id,owner,name.trim(),key]);return canvasState(r.rows[0])}catch(e){await removeObject(key).catch(()=>{});throw e}
|
||||
}
|
||||
export async function saveCanvas(id:string,expected:number,input:unknown){
|
||||
if(!revision(expected))throw new CanvasError(400,'版本无效');const document=inputDocument(input),key=await putJson(id,document);
|
||||
try{return await transaction(async client=>{const r=await client.query('UPDATE canvas_documents SET document_key=$3,revision=revision+1,updated_at=now() WHERE id=$1 AND revision=$2 RETURNING *',[id,expected,key]);if(!r.rowCount)throw new CanvasError(409,'在线画布已有更新,请先读取新版本');return r.rows[0]})}catch(e){await removeObject(key).catch(()=>{});throw e}
|
||||
}
|
||||
export async function createCanvasLink(id:string){
|
||||
const token=newToken(),expiresAt=new Date(Date.now()+3600000).toISOString();
|
||||
await transaction(async client=>{await client.query('SELECT id FROM canvas_documents WHERE id=$1 FOR UPDATE',[id]);await client.query('DELETE FROM canvas_agent_links WHERE canvas_id=$1',[id]);await client.query('INSERT INTO canvas_agent_links(digest,canvas_id,expires_at) VALUES($1,$2,$3)',[tokenDigest(token),id,expiresAt])});
|
||||
return {token,expiresAt};
|
||||
}
|
||||
export async function queueCanvasTask(id:string,expected:number,target:string){
|
||||
if(!revision(expected)||typeof target!=='string'||target.length>100)throw new CanvasError(400,'任务参数无效');
|
||||
let key:string|undefined;
|
||||
try{return await transaction(async client=>{
|
||||
const row=(await client.query('SELECT * FROM canvas_documents WHERE id=$1 FOR UPDATE',[id])).rows[0];if(!row)throw new CanvasError(404,'画布不存在');if(row.revision!==expected)throw new CanvasError(409,'画布已有更新,请先同步');
|
||||
const body=await readCanvasBody(row.document_key);let request;try{request=agentTask(body,target)}catch(e){throw new CanvasError(400,e instanceof Error?e.message:'创作要求无效')}key=await putJson(id,request);
|
||||
const result=await client.query('INSERT INTO canvas_tasks(id,canvas_id,target_id,source_revision,request_key) VALUES($1,$2,$3,$4,$5) RETURNING id,status,target_id AS "targetId"',[randomUUID(),id,target,expected,key]);return result.rows[0];
|
||||
})}catch(e){if(key)await removeObject(key).catch(()=>{});if((e as {code?:string}).code==='23505')throw new CanvasError(409,'该节点已有待处理任务');throw e}
|
||||
}
|
||||
export async function readCanvasTask(id:string,task:string){
|
||||
const r=await db().query('SELECT * FROM canvas_tasks WHERE canvas_id=$1 AND id=$2',[id,task]);if(!r.rowCount)throw new CanvasError(404,'任务不存在');const row=r.rows[0],o=await getObject(row.request_key);return {id:row.id,targetId:row.target_id,status:row.status,sourceRevision:row.source_revision,error:row.error,request:JSON.parse(await o.Body!.transformToString())};
|
||||
}
|
||||
export async function updateCanvasTask(id:string,task:string,input:{status?:unknown;result?:unknown;error?:unknown}){
|
||||
let key:string|undefined;
|
||||
try{return await transaction(async client=>{
|
||||
const record=(await client.query('SELECT * FROM canvas_documents WHERE id=$1 FOR UPDATE',[id])).rows[0];
|
||||
const row=(await client.query('SELECT * FROM canvas_tasks WHERE canvas_id=$1 AND id=$2 FOR UPDATE',[id,task])).rows[0];
|
||||
if(!record||!row)throw new CanvasError(404,'任务不存在');
|
||||
if(input.status==='running'){
|
||||
if(!['queued','running'].includes(row.status))throw new CanvasError(409,'任务已经结束');
|
||||
await client.query("UPDATE canvas_tasks SET status='running',updated_at=now() WHERE id=$1",[task]);return {id:task,status:'running',revision:record.revision};
|
||||
}
|
||||
if(input.status==='failed'){
|
||||
if(!['queued','running'].includes(row.status))throw new CanvasError(409,'任务已经结束');
|
||||
if(typeof input.error!=='string'||!input.error.trim()||input.error.length>1000)throw new CanvasError(400,'请提供任务失败原因');
|
||||
await client.query("UPDATE canvas_tasks SET status='failed',error=$2,updated_at=now() WHERE id=$1",[task,input.error]);return {id:task,status:'failed',revision:record.revision};
|
||||
}
|
||||
if(input.status!=='succeeded'||!input.result||typeof input.result!=='object'||Array.isArray(input.result))throw new CanvasError(400,'任务更新格式不正确');
|
||||
const result=input.result as {text?:unknown;image?:unknown};
|
||||
if(result.text===undefined&&result.image===undefined)throw new CanvasError(400,'没有返回结果');
|
||||
const digest=createHash('sha256').update(JSON.stringify({text:result.text,image:result.image})).digest('hex');
|
||||
if(row.status==='succeeded'&&row.result_digest===digest)return {id:task,status:'succeeded',revision:record.revision};
|
||||
if(row.status!=='running')throw new CanvasError(409,'请先领取任务,已结束的任务不能改写');
|
||||
if(record.revision!==row.source_revision)throw new CanvasError(409,'画布在任务创建后已修改,请重新派发任务');
|
||||
const doc=await readCanvasBody(record.document_key),node=doc.cards.find(n=>n.id===row.target_id);if(!node)throw new CanvasError(409,'目标节点已移除');
|
||||
if(result.image!==undefined&&node.kind!=='image')throw new CanvasError(400,'该节点不接收图片结果');
|
||||
const next=inputDocument({...doc,cards:doc.cards.map(n=>n.id===node.id?{...n,...(result.text===undefined?{}:{text:result.text}),...(result.image===undefined?{}:{image:result.image})}:n)});
|
||||
key=await putJson(id,next);await client.query('UPDATE canvas_documents SET document_key=$2,revision=revision+1,updated_at=now() WHERE id=$1',[id,key]);
|
||||
await client.query("UPDATE canvas_tasks SET status='succeeded',result_digest=$2,error=NULL,updated_at=now() WHERE id=$1",[task,digest]);return {id:task,status:'succeeded',revision:record.revision+1};
|
||||
})}catch(e){if(key)await removeObject(key).catch(()=>{});throw e}
|
||||
}
|
||||
@@ -1,8 +1,13 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
import type { AppProps } from "next/app";
|
||||
import "@/styles/globals.css";
|
||||
import "@/styles/skill-docs.css";
|
||||
import "@/styles/learning-series.css";
|
||||
import "@/styles/brand.css";
|
||||
|
||||
export default function App({ Component, pageProps }: AppProps) {
|
||||
const router = useRouter();
|
||||
useEffect(() => { const scope = /^\/(?:graphic|infinite_canvas|layouts|template-layout)(?:\/|$)/.test(router.pathname) ? 'canvas' : 'home'; document.documentElement.dataset.theme = `${scope}-${document.documentElement.dataset.mode === 'dark' ? 'dark' : 'light'}`; }, [router.pathname]);
|
||||
return <Component {...pageProps} />;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
import { Html, Head, Main, NextScript } from 'next/document';
|
||||
const initializeTheme = String.raw`(function(){try{var mode=localStorage.getItem('endoleap.theme')==='dark'?'dark':'light';var canvas=/^\/(?:studio\/)?(?:graphic|infinite_canvas|layouts|template-layout)(?:\/|$)/.test(location.pathname);document.documentElement.dataset.mode=mode;document.documentElement.dataset.theme=(canvas?'canvas':'home')+'-'+mode;}catch(e){}})();`;
|
||||
export default function Document() { return <Html lang="zh-CN" data-theme="home-light" data-mode="light" suppressHydrationWarning><Head><link rel="icon" href="/favicon.ico?v=endoleap-1.2" sizes="any"/><link rel="icon" type="image/svg+xml" href="/brand/icon-dark.svg?v=endoleap-1.2"/><link rel="apple-touch-icon" href="/apple-touch-icon.png"/><script dangerouslySetInnerHTML={{__html:initializeTheme}}/></Head><body><Main/><NextScript/></body></Html>; }
|
||||
@@ -1,22 +1,19 @@
|
||||
import { Brand } from "@/components/Brand";
|
||||
import a from "@/styles/account.module.css";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import { useEffect, useState, type FormEvent } from "react";
|
||||
import {
|
||||
ArrowRight,
|
||||
BookOpen,
|
||||
CheckCircle2,
|
||||
Eye,
|
||||
EyeOff,
|
||||
GitBranch,
|
||||
Link2,
|
||||
LogOut,
|
||||
MailCheck,
|
||||
Waypoints,
|
||||
} from "lucide-react";
|
||||
import GuidanceShell, { useGuidanceAccount } from "@/components/GuidanceShell";
|
||||
import { Notice } from "@/components/GuidanceUI";
|
||||
import s from "@/styles/guidance.module.css";
|
||||
type Mode = "login" | "register" | "resend";
|
||||
type Mode = "login" | "register" | "resend" | "forgot" | "reset" | "verify";
|
||||
function localNext(value: string | string[] | undefined) {
|
||||
if (
|
||||
typeof value !== "string" ||
|
||||
@@ -35,327 +32,104 @@ function localNext(value: string | string[] | undefined) {
|
||||
}
|
||||
}
|
||||
export default function AccountPage() {
|
||||
return (
|
||||
<GuidanceShell title="邮箱账户">
|
||||
<AccountContent />
|
||||
</GuidanceShell>
|
||||
);
|
||||
return <GuidanceShell title="登录与注册" standalone><AccountContent /></GuidanceShell>;
|
||||
}
|
||||
function AccountContent() {
|
||||
const account = useGuidanceAccount(),
|
||||
router = useRouter();
|
||||
const [mode, setMode] = useState<Mode>("login"),
|
||||
[email, setEmail] = useState(""),
|
||||
[password, setPassword] = useState(""),
|
||||
[showPassword, setShowPassword] = useState(false);
|
||||
const [busy, setBusy] = useState(false),
|
||||
[token, setToken] = useState(""),
|
||||
[feedback, setFeedback] = useState<{
|
||||
tone: "success" | "error";
|
||||
text: string;
|
||||
} | null>(null);
|
||||
const account = useGuidanceAccount(), router = useRouter();
|
||||
const [mode, setMode] = useState<Mode>("login");
|
||||
const [email, setEmail] = useState(""), [password, setPassword] = useState(""), [confirm, setConfirm] = useState("");
|
||||
const [showPassword, setShowPassword] = useState(false), [busy, setBusy] = useState(false);
|
||||
const [token, setToken] = useState("");
|
||||
const [feedback, setFeedback] = useState<{tone: "success" | "error"; text: string} | null>(null);
|
||||
useEffect(() => {
|
||||
const url = new URL(window.location.href);
|
||||
const value = url.searchParams.get("verify");
|
||||
if (value) {
|
||||
setToken(value);
|
||||
url.searchParams.delete("verify");
|
||||
const reset = url.searchParams.get("reset"), verify = url.searchParams.get("verify");
|
||||
if (reset || verify) {
|
||||
setToken(reset || verify || ""); setMode(reset ? "reset" : "verify");
|
||||
url.searchParams.delete("reset"); url.searchParams.delete("verify");
|
||||
window.history.replaceState({}, "", url.pathname + url.search + url.hash);
|
||||
}
|
||||
}, []);
|
||||
async function action(name: Mode | "verify" | "logout", body: object = {}) {
|
||||
function switchMode(value: Mode) { setMode(value); setFeedback(null); setPassword(""); setConfirm(""); setShowPassword(false); }
|
||||
async function action(name: Mode | "logout", body: object = {}) {
|
||||
if (busy) return;
|
||||
setBusy(true);
|
||||
setFeedback(null);
|
||||
setBusy(true); setFeedback(null);
|
||||
try {
|
||||
const response = await fetch("/api/account/" + name, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const response = await fetch("/api/account/" + name, {method: "POST", headers: {"Content-Type": "application/json"}, body: JSON.stringify(body)});
|
||||
const data = await response.json();
|
||||
if (!response.ok) throw Error(data.error || "暂时无法完成操作,请重试");
|
||||
if (name === "verify") {
|
||||
setToken("");
|
||||
setMode("login");
|
||||
setFeedback({ tone: "success", text: "邮箱已验证,现在可以登录。" });
|
||||
} else if (name === "login") {
|
||||
setPassword("");
|
||||
setFeedback({ tone: "success", text: "登录成功,欢迎回到织页。" });
|
||||
} else if (name === "logout") {
|
||||
setPassword("");
|
||||
setFeedback({ tone: "success", text: "已退出登录。" });
|
||||
} else
|
||||
setFeedback({
|
||||
tone: "success",
|
||||
text:
|
||||
data.message ||
|
||||
"如果信息符合条件,验证邮件已发送。请检查收件箱和垃圾邮件。",
|
||||
});
|
||||
await account.refresh();
|
||||
if (name === "verify" || name === "reset") { setToken(""); switchMode("login"); }
|
||||
if (name === "login" || name === "logout" || name === "reset") { setPassword(""); setConfirm(""); }
|
||||
setFeedback({tone: "success", text: name === "verify" ? "邮箱已验证,现在可以登录。" : name === "reset" ? "密码已更新,请使用新密码登录。" : name === "login" ? "登录成功,欢迎回到啡跃。" : name === "logout" ? "已退出登录。" : data.message || "若账户符合条件,邮件将发送到你的邮箱。"});
|
||||
if (["verify", "login", "logout", "reset"].includes(name)) await account.refresh();
|
||||
if (name === "login") await router.push(localNext(router.query.next));
|
||||
} catch (e) {
|
||||
setFeedback({
|
||||
tone: "error",
|
||||
text: e instanceof Error ? e.message : "网络异常,请稍后重试",
|
||||
});
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
} catch (error) { setFeedback({tone: "error", text: error instanceof Error ? error.message : "网络异常,请稍后重试"}); }
|
||||
finally { setBusy(false); }
|
||||
}
|
||||
function submit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
void action(mode, { email, password });
|
||||
if (mode === "reset" && password !== confirm) { setFeedback({tone: "error", text: "两次输入的密码不一致。"}); return; }
|
||||
void action(mode, mode === "reset" ? {token, password} : mode === "verify" ? {token} : {email, password});
|
||||
}
|
||||
function switchMode(value: Mode) {
|
||||
setMode(value);
|
||||
setFeedback(null);
|
||||
}
|
||||
return (
|
||||
<div className={s.accountLayout}>
|
||||
<section className={s.accountIntro}>
|
||||
<span className={s.accountSymbol}>
|
||||
<Waypoints size={28} />
|
||||
</span>
|
||||
<span className={s.eyebrow}>YOUR KNOWLEDGE, CONNECTED</span>
|
||||
<h1>
|
||||
让有用的知识,
|
||||
<br />
|
||||
成为下一次行动的起点。
|
||||
</h1>
|
||||
<p>
|
||||
用邮箱建立你的知识空间。把工作方法写成独立文档,让 Agent
|
||||
在需要的时候找到它。
|
||||
</p>
|
||||
<div className={s.accountFeatures}>
|
||||
<div>
|
||||
<BookOpen size={18} />
|
||||
<div>
|
||||
<strong>每篇文档,都能独立使用</strong>
|
||||
<p>从当前任务出发,无需加载整个集合。</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<GitBranch size={18} />
|
||||
<div>
|
||||
<strong>每次更新,都有固定版本</strong>
|
||||
<p>历史引用保持可读,修改始终有迹可循。</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Link2 size={18} />
|
||||
<div>
|
||||
<strong>知识之间,按条件连接</strong>
|
||||
<p>需要更多背景时,再沿有用的链接继续阅读。</p>
|
||||
</div>
|
||||
</div>
|
||||
const user = mode === "reset" || mode === "verify" || mode === "forgot" ? null : account.user;
|
||||
const title = user ? "你的账户" : ({login: "欢迎回来", register: "创建你的账户", resend: "重发验证邮件", forgot: "找回你的账户", reset: "设置新密码", verify: "确认验证邮箱"})[mode];
|
||||
const description = user ? "你的知识与创作,随时继续。" : ({login: "使用邮箱与密码登录,继续你的创作。", register: "用邮箱建立你的知识与创作空间。", resend: "仅适用于尚未验证的账户,输入邮箱和原密码。", forgot: "输入注册邮箱,我们会发送重设密码链接。", reset: "由你设置新密码,原有内容会完整保留。", verify: "确认后,即可使用邮箱和密码登录。"})[mode];
|
||||
const toast = feedback || (account.error ? {tone: "error" as const, text: account.error} : null);
|
||||
return <div className={a.layout}>
|
||||
<section className={a.intro} data-theme="home-dark" aria-label="啡跃创作空间">
|
||||
<Link className={a.brand} href="/" aria-label="啡跃首页"><Brand /></Link>
|
||||
<div className={a.story}>
|
||||
<p className={a.eyebrow}>A SPACE FOR YOUR NEXT IDEA</p>
|
||||
<h1>给想法留白,<br />让灵感有形。</h1>
|
||||
<p className={a.description}>收集一闪而过的灵感,把有用的知识连接起来。下一件作品,从这里开始。</p>
|
||||
<div className={a.art} aria-hidden="true">
|
||||
<div className={a.orbit} />
|
||||
<div className={a.idea}><span>01 / THE IDEA</span><strong>留住清晨<br />的一束光。</strong><i>灵感片段 · 随手记下</i></div>
|
||||
<div className={a.work}><span>02 / IN THE MAKING</span><div className={a.artwork} /><strong>光的形状</strong><i>让一个念头,慢慢成形。</i></div>
|
||||
<span className={a.artCaption}>COLLECT. CONNECT. CREATE.</span>
|
||||
</div>
|
||||
</section>
|
||||
<section className={s.authCard} aria-label="邮箱账户">
|
||||
<h2>
|
||||
{account.user
|
||||
? "你的账户"
|
||||
: mode === "register"
|
||||
? "创建你的账户"
|
||||
: mode === "resend"
|
||||
? "重发验证邮件"
|
||||
: "欢迎回来"}
|
||||
</h2>
|
||||
<p className={s.authDescription}>
|
||||
{account.user
|
||||
? "已准备好发布和管理你的独立文档。"
|
||||
: mode === "register"
|
||||
? "验证邮箱后,就可以发布第一篇文档。"
|
||||
: mode === "resend"
|
||||
? "输入注册时的邮箱和密码,重新获取验证链接。"
|
||||
: "使用邮箱与密码登录,继续你的知识工作。"}
|
||||
</p>
|
||||
{token && (
|
||||
<section className={s.verificationCard}>
|
||||
<h3>
|
||||
<MailCheck size={17} />
|
||||
确认验证邮箱
|
||||
</h3>
|
||||
<p>验证链接有效期为 30 分钟。确认后,再使用邮箱与密码登录。</p>
|
||||
<button
|
||||
type="button"
|
||||
className={s.primaryButton}
|
||||
disabled={busy}
|
||||
onClick={() => void action("verify", { token })}
|
||||
>
|
||||
{busy ? "正在验证…" : "确认验证邮箱"}
|
||||
</button>
|
||||
</section>
|
||||
)}
|
||||
{feedback && <Notice tone={feedback.tone}>{feedback.text}</Notice>}
|
||||
{account.error && (
|
||||
<Notice tone="error">
|
||||
{account.error}{" "}
|
||||
<button
|
||||
type="button"
|
||||
className={s.textLink}
|
||||
onClick={() => void account.refresh()}
|
||||
>
|
||||
重新连接
|
||||
</button>
|
||||
</Notice>
|
||||
)}
|
||||
{account.loading ? (
|
||||
<div className={s.accountLoading} aria-busy="true">
|
||||
正在检查账户状态…
|
||||
</div>
|
||||
<p className={a.signature}>啡跃 Endoleap <span>为下一次创作,留一个起点。</span></p>
|
||||
</section>
|
||||
<section className={a.form} aria-label="邮箱账户">
|
||||
<div className={a.frame}>
|
||||
<h2 key={title}>{title}</h2><p className={a.formDescription}>{description}</p>
|
||||
{account.loading ? <div className={a.loading} aria-busy="true">正在检查账户状态…</div> : user ? <>
|
||||
<div className={a.accountDetails}><CheckCircle2 size={22} /><strong>{user.email}</strong><span>邮箱已验证</span></div>
|
||||
<Link className={s.primaryButton} href={localNext(router.query.next)}>继续使用啡跃 <ArrowRight size={15} /></Link>
|
||||
<button className={a.secondary} type="button" disabled={busy} onClick={() => void action("logout")}><LogOut size={14} />退出登录</button>
|
||||
</> : <>
|
||||
<div className={a.tabSlot}>
|
||||
{(mode === "login" || mode === "register") && <nav className={a.tabs} aria-label="账户操作" data-active={mode}>
|
||||
<button type="button" aria-pressed={mode === "login"} disabled={busy} onClick={() => switchMode("login")}>登录</button>
|
||||
<button type="button" aria-pressed={mode === "register"} disabled={busy} onClick={() => switchMode("register")}>创建账户</button>
|
||||
</nav>}
|
||||
</div>
|
||||
) : account.user ? (
|
||||
<>
|
||||
<div className={s.loggedUser}>
|
||||
<span className={s.avatar}>
|
||||
{account.user.email.slice(0, 1).toUpperCase()}
|
||||
</span>
|
||||
<div>
|
||||
<strong>{account.user.email}</strong>
|
||||
<span className={s.verifiedBadge}>
|
||||
<CheckCircle2 size={13} />
|
||||
邮箱已验证
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className={s.accountInfo}>
|
||||
<p>
|
||||
<strong>你的内容,由你管理。</strong>
|
||||
<br />
|
||||
你可以发布文档、创建新版本,以及撤下自己的内容。公开文档的固定历史版本会继续保留。
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
className={s.primaryButton}
|
||||
href={localNext(router.query.next)}
|
||||
>
|
||||
继续使用织页
|
||||
<ArrowRight size={15} />
|
||||
</Link>
|
||||
<div className={s.authSecondary}>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => void action("logout")}
|
||||
>
|
||||
<LogOut size={12} /> 退出登录
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{mode !== "resend" && (
|
||||
<nav className={s.authTabs} aria-label="账户操作">
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={mode === "login"}
|
||||
disabled={busy}
|
||||
onClick={() => switchMode("login")}
|
||||
>
|
||||
登录
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={mode === "register"}
|
||||
disabled={busy}
|
||||
onClick={() => switchMode("register")}
|
||||
>
|
||||
创建账户
|
||||
</button>
|
||||
</nav>
|
||||
)}
|
||||
<form onSubmit={submit}>
|
||||
<fieldset className={s.formFieldset} disabled={busy}>
|
||||
<div className={s.formField}>
|
||||
<form onSubmit={submit}>
|
||||
<fieldset className={a.fieldset} disabled={busy}>
|
||||
<div className={a.fields} key={mode}>
|
||||
{mode !== "reset" && mode !== "verify" && <div className={a.field}>
|
||||
<label htmlFor="account-email">邮箱地址</label>
|
||||
<input
|
||||
id="account-email"
|
||||
className={s.input}
|
||||
type="email"
|
||||
name="email"
|
||||
autoComplete="email"
|
||||
placeholder="you@example.com"
|
||||
required
|
||||
maxLength={254}
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className={s.formField}>
|
||||
<label htmlFor="account-password">密码</label>
|
||||
<input id="account-email" className={s.input} type="email" name="email" autoComplete="email" placeholder="you@example.com" required maxLength={254} value={email} onChange={event => setEmail(event.target.value)} />
|
||||
</div>}
|
||||
{mode !== "forgot" && mode !== "verify" && <div className={a.field}>
|
||||
<div className={a.labelRow}><label htmlFor="account-password">{mode === "reset" ? "新密码" : "密码"}</label>{mode === "login" && <button type="button" onClick={() => switchMode("forgot")}>忘记密码?</button>}</div>
|
||||
<div className={s.passwordField}>
|
||||
<input
|
||||
id="account-password"
|
||||
className={s.input}
|
||||
type={showPassword ? "text" : "password"}
|
||||
name="password"
|
||||
autoComplete={
|
||||
mode === "register"
|
||||
? "new-password"
|
||||
: "current-password"
|
||||
}
|
||||
placeholder={
|
||||
mode === "register"
|
||||
? "设置至少 12 个字符的密码"
|
||||
: "输入你的密码"
|
||||
}
|
||||
minLength={12}
|
||||
maxLength={256}
|
||||
required
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={showPassword ? "隐藏密码" : "显示密码"}
|
||||
onClick={() => setShowPassword((value) => !value)}
|
||||
>
|
||||
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||
</button>
|
||||
<input id="account-password" className={s.input} type={showPassword ? "text" : "password"} name="password" autoComplete={mode === "register" || mode === "reset" ? "new-password" : "current-password"} placeholder={mode === "register" || mode === "reset" ? "设置至少 12 个字符的密码" : "输入你的密码"} minLength={12} maxLength={256} required value={password} onChange={event => setPassword(event.target.value)} />
|
||||
<button type="button" aria-label={showPassword ? "隐藏密码" : "显示密码"} onClick={() => setShowPassword(value => !value)}>{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}</button>
|
||||
</div>
|
||||
{mode === "register" && (
|
||||
<small className={s.fieldHint}>
|
||||
建议使用包含字母、数字的长密码。
|
||||
</small>
|
||||
)}
|
||||
</div>
|
||||
<button className={s.primaryButton} type="submit">
|
||||
{busy
|
||||
? "正在处理…"
|
||||
: mode === "login"
|
||||
? "登录"
|
||||
: mode === "register"
|
||||
? "创建账户"
|
||||
: "发送验证邮件"}
|
||||
{!busy && <ArrowRight size={15} />}
|
||||
</button>
|
||||
</fieldset>
|
||||
</form>
|
||||
<div className={s.authSecondary}>
|
||||
{mode === "resend" ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => switchMode("login")}
|
||||
>
|
||||
返回登录
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => switchMode("resend")}
|
||||
>
|
||||
没有收到验证邮件?重新发送
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<p className={s.authFootnote}>
|
||||
注册后需要先验证邮箱。公开文档始终可以直接阅读。
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
</div>}
|
||||
{mode === "reset" && <div className={a.field}><label htmlFor="account-confirm">确认新密码</label><input id="account-confirm" className={s.input} type={showPassword ? "text" : "password"} autoComplete="new-password" required minLength={12} maxLength={256} value={confirm} onChange={event => setConfirm(event.target.value)} /></div>}
|
||||
{mode === "verify" && <div className={a.verifyInfo}><MailCheck size={28} /><p>验证链接有效期为 30 分钟。请确认这是你注册的邮箱。</p></div>}
|
||||
</div>
|
||||
<button className={`${s.primaryButton} ${a.submit}`} type="submit">{busy ? "正在处理…" : ({login: "登录", register: "创建账户", resend: "发送验证邮件", forgot: "发送重设链接", reset: "保存新密码", verify: "确认验证邮箱"})[mode]}{!busy && <ArrowRight size={15} />}</button>
|
||||
</fieldset>
|
||||
</form>
|
||||
<div className={a.secondarySlot}>{mode === "login" || mode === "register" ? <button className={a.secondary} type="button" disabled={busy} onClick={() => switchMode("resend")}>没有收到验证邮件?重新发送</button> : <button className={a.secondary} type="button" disabled={busy} onClick={() => switchMode("login")}>返回登录</button>}</div>
|
||||
<p className={a.footnote}>{mode === "forgot" || mode === "reset" ? "链接仅供本人使用。重设成功后,所有旧会话将退出。" : "首次注册需要验证邮箱;已验证的账户可直接登录。"}</p>
|
||||
</>}
|
||||
</div>
|
||||
</section>
|
||||
{toast && <div className={a.toast} data-tone={toast.tone} role={toast.tone === "error" ? "alert" : "status"}><span aria-hidden="true">{toast.tone === "error" ? "!" : "✓"}</span><p>{toast.text}</p><button type="button" aria-label={!feedback && account.error ? "重新连接" : "关闭提示"} onClick={() => {setFeedback(null); if (account.error) void account.refresh();}}>×</button></div>}
|
||||
</div>;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import type {NextApiRequest,NextApiResponse} from 'next';
|
||||
import {serveApiDocumentation} from '@/lib/api-documentation';
|
||||
import {currentAccount} from '@/lib/account';
|
||||
import {trustedOrigin} from '@/lib/account-crypto';
|
||||
import {db} from '@/lib/db';
|
||||
import {CanvasError,uuid,authorizeCanvas,canvasState,createCanvas,saveCanvas,createCanvasLink,queueCanvasTask,readCanvasTask,updateCanvasTask} from '@/lib/canvas-store';
|
||||
export const config={api:{bodyParser:{sizeLimit:'15mb'}}};
|
||||
export default async function handler(req:NextApiRequest,res:NextApiResponse){
|
||||
if(serveApiDocumentation(req,res,'/api/canvases/{...path}'))return;
|
||||
res.setHeader('Cache-Control','private, no-store');res.setHeader('Referrer-Policy','no-referrer');
|
||||
const path=req.query.path||[];if(!Array.isArray(path)||path.length>3)return res.status(404).end();
|
||||
const [id,section,taskId]=path;
|
||||
if(id&&!uuid(id)||taskId&&!uuid(taskId))return res.status(404).end();
|
||||
if(!['GET','POST','PUT','PATCH','DELETE'].includes(req.method||'')){res.setHeader('Allow','GET, POST, PUT, PATCH, DELETE');return res.status(405).end()}
|
||||
const input=req.body;
|
||||
if(req.method!=='GET'&&(!input||typeof input!=='object'||Array.isArray(input)))return res.status(400).json({error:'请发送 JSON 对象'});
|
||||
try{
|
||||
const headers=new Headers(Object.entries(req.headers).filter(([,v])=>typeof v==='string') as [string,string][]);
|
||||
const request=new Request(new URL(req.url||'/',process.env.ACCOUNT_PUBLIC_ORIGIN||'http://'+req.headers.host),{headers});
|
||||
const bearer=req.headers.authorization?.startsWith('Bearer ')?req.headers.authorization.slice(7):null;
|
||||
if(bearer&&req.headers['sec-fetch-site']==='cross-site')throw new CanvasError(403,'请求来源不受信任');
|
||||
if(!bearer&&req.method!=='GET'&&!trustedOrigin(request))throw new CanvasError(403,'请求来源不受信任');
|
||||
const user=bearer?null:await currentAccount(request);
|
||||
if(!id){
|
||||
if(!user)throw new CanvasError(401,'请先登录邮箱账户');
|
||||
if(req.method==='GET'){const rows=await db().query('SELECT id,name,revision,updated_at AS "updatedAt" FROM canvas_documents WHERE owner_id=$1 ORDER BY updated_at DESC LIMIT 100',[user.id]);return res.json({canvases:rows.rows})}
|
||||
if(req.method==='POST')return res.status(201).json(await createCanvas(user.id,input.document,input.name));
|
||||
return res.status(405).end();
|
||||
}
|
||||
const record=await authorizeCanvas(id,user?.id||null,bearer);
|
||||
if(!section){
|
||||
if(req.method==='GET')return res.json(await canvasState(record));
|
||||
if(req.method==='PUT')return res.json(await canvasState(await saveCanvas(id,input.revision,input.document)));
|
||||
return res.status(405).end();
|
||||
}
|
||||
if(section==='link'&&!taskId){
|
||||
if(!user||user.id!==record.owner_id)throw new CanvasError(403,'只有画布所有者可以管理 Agent 连接');
|
||||
if(req.method==='POST')return res.status(201).json({...await createCanvasLink(id),canvasId:id,documentation:`/api/canvases/${id}?agent_mode=True`});
|
||||
if(req.method==='DELETE'){await db().query('DELETE FROM canvas_agent_links WHERE canvas_id=$1',[id]);return res.json({revoked:true})}
|
||||
if(req.method==='GET'){const r=await db().query('SELECT expires_at AS "expiresAt",last_seen_at AS "lastSeenAt" FROM canvas_agent_links WHERE canvas_id=$1 AND expires_at>now() ORDER BY expires_at DESC LIMIT 1',[id]);return res.json({link:r.rows[0]||null})}
|
||||
return res.status(405).end();
|
||||
}
|
||||
if(section==='tasks'){
|
||||
if(!taskId&&req.method==='POST')return res.status(201).json(await queueCanvasTask(id,input.revision,input.targetId));
|
||||
if(taskId&&req.method==='GET')return res.json(await readCanvasTask(id,taskId));
|
||||
if(taskId&&req.method==='PATCH')return res.json(await updateCanvasTask(id,taskId,input));
|
||||
return res.status(405).end();
|
||||
}
|
||||
return res.status(404).end();
|
||||
}catch(e){
|
||||
if(e instanceof CanvasError)return res.status(e.status).json({error:e.message});
|
||||
if(e instanceof SyntaxError||e instanceof Error&&/格式|节点|引用|创作要求|图片|画布不能/.test(e.message))return res.status(400).json({error:'画布或任务参数无效'});
|
||||
return res.status(503).json({error:'在线画布暂时不可用'});
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import {tags} from '@/lib/library';
|
||||
import {systemTags} from '@/lib/system-tags';
|
||||
export default function Categories({categories,category,entries,cursor,q,total,unavailable=false}:{categories:string[];category:string;entries:ExploreEntry[];cursor:string|null;q:string;total?:number;unavailable?:boolean}){
|
||||
const link=(value:string)=>({pathname:'/categories',query:{...(value?{category:value}:{}),...(q?{q}:{})}});
|
||||
return <Shell><Head><title>分类 · PROMPT</title></Head><nav className="discovery-tabs" aria-label="系统标签分类"><Link href={link('')} className={!category?'selected':''}>全部</Link>{categories.map(c=><Link className={c===category?'selected':''} href={link(c)} key={c}>{c}</Link>)}</nav>{category&&!categories.includes(category)&&<div className="ordinary-tag-filter">#{category}<Link href={link('')}>清除筛选 ×</Link></div>}{unavailable&&<p role="status">分类暂时无法读取,请稍后重试。</p>}<ExploreFeed key={JSON.stringify([category,q])} initial={entries} cursor={cursor} q={q} category={category} total={total}/></Shell>;
|
||||
return <Shell><Head><title>分类 · 啡跃 Endoleap</title></Head><nav className="discovery-tabs" aria-label="系统标签分类"><Link href={link('')} className={!category?'selected':''}>全部</Link>{categories.map(c=><Link className={c===category?'selected':''} href={link(c)} key={c}>{c}</Link>)}</nav>{category&&!categories.includes(category)&&<div className="ordinary-tag-filter">#{category}<Link href={link('')}>清除筛选 ×</Link></div>}{unavailable&&<p role="status">分类暂时无法读取,请稍后重试。</p>}<ExploreFeed key={JSON.stringify([category,q])} initial={entries} cursor={cursor} q={q} category={category} total={total}/></Shell>;
|
||||
}
|
||||
export const getServerSideProps:GetServerSideProps=async({query})=>{
|
||||
const category=String(query.category||'').slice(0,200),q=String(query.q||'').slice(0,200);try{const [all,data]=await Promise.all([tags(),exploreFeed(q,category)]);return {props:{categories:all.filter(t=>t.kind==='system').map(t=>t.name),category,q,entries:data.entries,cursor:data.nextCursor,total:data.total}};}catch{return {props:{categories:systemTags.map(t=>t.name),category,q,entries:[],cursor:null,unavailable:true}}}
|
||||
|
||||
@@ -1,10 +1,22 @@
|
||||
import { ThemeToggle } from "../components/ThemeToggle";
|
||||
import { Brand } from "../components/Brand";
|
||||
import Head from 'next/head';
|
||||
import {ArrowUpRight, MousePointer2, Plus, Type, Image, Sparkles, Workflow, Layers, ArrowRight} from 'lucide-react';
|
||||
import {ArrowUpRight, Workflow, Layers, ArrowRight} from 'lucide-react';
|
||||
import {CreatorSketch, IdeaSketch} from "@/components/LandingSketches";
|
||||
import s from '@/styles/landing.module.css';
|
||||
export default function Home(){return <main className={s.page}>
|
||||
<Head><title>织页 Studio · 让灵感,有形。</title><meta name="description" content="连接你的 coding agent,在图形编辑器与无限画布中,把想法组织成作品。"/></Head>
|
||||
<nav className={s.nav}><a className={s.logo} href="/">✳ <b>织页</b><span>STUDIO</span></a><div><a href="#tools">创作工具</a><a href="/documents">知识与教程</a><a href="#workflow">工作方式</a></div><a href="/account?next=/workspace">登录</a><a className={s.dark} href="/account?next=/workspace">进入工作台 <ArrowUpRight size={15}/></a></nav>
|
||||
<section className={s.hero}><p className={s.eyebrow}>A SPACE FOR HUMAN IDEAS & AGENTS</p><h1>想法不该停在对话里。<br/><span>让灵感,有形。</span></h1><p className={s.intro}>从一闪而过的念头,到值得分享的作品。<br/>连接你的 coding agent,在一张画布上,把创作继续下去。</p><div className={s.actions}><a className={s.dark} href="/account?next=/workspace">开始创作 <ArrowRight size={17}/></a><a href="#tools">探索你的创作空间 ↓</a></div><div className={s.product}><div className={s.productBar}><b>✳ STUDIO</b><span>日常的另一种可能 / 创意探索</span><span>本机草稿</span></div><div className={s.demo}><aside>工作空间<hr/>图层与素材<p>◈ 创意简报</p><p>▧ 视觉探索</p><p>✳ 镜头构思</p><small>把线索连接起来</small></aside><div className={s.demoWorld}><svg viewBox="0 0 800 330" preserveAspectRatio="none"><path d="M190 170 C300 170 260 120 360 120 S520 240 620 240"/></svg><article className={s.note}><small>01 / THE IDEA</small><h3>把日常,<br/>拍成一场电影。</h3><p>自然光、细腻颗粒,<br/>让每一帧都有呼吸感。</p></article><article className={s.art}><small>02 / VISUAL STUDY</small><div className={s.orbit}/><h3>光的形状</h3><span>LIGHT / SPACE / EMOTION</span></article><article className={s.prompt}><Sparkles size={17}/><small>03 / PROMPT</small><h3>留住清晨的光。</h3><p>窗边的植物,安静的房间。<br/>35mm 胶片,缓慢推进。</p></article><div className={s.dock}><MousePointer2/><Plus/><Type/><Image/><Sparkles/></div></div></div></div><p className={s.caption}>一个创作场景示意。你的想法、素材与工具,在这里相遇。</p></section>
|
||||
<Head><title>啡跃 Endoleap · 让灵感,有形。</title><meta name="description" content="收藏灵感、连接知识,与熟悉的 Agent 一起,把想法变成作品。探索图形编辑器、无限画布与知识文档。"/></Head>
|
||||
<nav className={s.nav} aria-label="主导航"><a className={s.logo} href="/"><Brand/></a><div><a href="#tools">创作工具</a><a href="/documents">知识与教程</a><a href="#workflow">工作方式</a></div><ThemeToggle/><a href="/account?next=/workspace">登录</a><a className={s.dark} href="/account?next=/workspace">进入工作台 <ArrowUpRight size={15}/></a></nav>
|
||||
<section className={s.hero}><p className={s.eyebrow}>A SPACE FOR HUMAN IDEAS & AGENTS</p><h1>想法不该停在对话里。<br/><span>让灵感,有形。</span></h1><p className={s.intro}>从一闪而过的念头,到值得分享的作品。<br/>连接你的 coding agent,在一张画布上,把创作继续下去。</p><div className={s.actions}><a className={s.dark} href="/account?next=/workspace">开始创作 <ArrowRight size={17}/></a><a href="#tools">探索你的创作空间 ↓</a></div><div className={s.peopleStrip}><span className={s.stripNote}>每一种好奇,都有自己的起点。</span>{([
|
||||
['collect','收藏生活的切片'],['read','遇见新的工作方法'],['idea','记下突然出现的灵感'],['dream','给想法一点自由'],['make','做一件属于你的作品']
|
||||
] as const).map(([pose,label])=><figure key={pose}><CreatorSketch pose={pose}/><figcaption>{label}</figcaption></figure>)}<span className={s.stripSpark} aria-hidden="true">✳</span></div></section>
|
||||
<section className={s.possibilities} id="possibilities" aria-labelledby="possibilities-title"><p className={s.eyebrow}>MAKE ROOM FOR WHAT COMES NEXT</p><h2 id="possibilities-title">给好奇心一点空间,<br/>让下一步<em>自然发生。</em></h2><div className={s.featureGrid}>{([
|
||||
['collect','留住一闪而过','一句念头、一张参考图、一个还没想好的方向。先留下,再慢慢展开。'],
|
||||
['connect','让线索相遇','把笔记、图片和提示词放上画布,用连接看见它们之间的新可能。'],
|
||||
['read','找到有用的方法','阅读可独立使用的知识文档,需要更多背景时,沿链接继续探索。'],
|
||||
['make','把创作继续下去','用熟悉的 coding agent 探索,再亲手调整细节,让想法成为自己的作品。']
|
||||
] as const).map(([kind,title,copy])=><article key={kind}><IdeaSketch kind={kind}/><h3>{title}</h3><p>{copy}</p></article>)}</div></section>
|
||||
<section className={s.tools} id="tools"><p className={s.eyebrow}>TWO TOOLS. ONE CREATIVE FLOW.</p><h2>给不同的想法,<br/>恰好的空间。</h2><div className={s.toolGrid}><a href="/graphic"><Layers/><span>01 / GRAPHIC</span><h3>把灵感排成作品。</h3><p>文字、图形与图片自由编排。精确调整图层和位置,把设计导出为 SVG。</p><div className={s.poster}><span>MAKE<br/>ROOM<br/><i>for ideas.</i></span><b>✳</b></div><strong>打开图形编辑器 <ArrowUpRight size={17}/></strong></a><a href="/infinite_canvas"><Workflow/><span>02 / INFINITE CANVAS</span><h3>让思路,自由生长。</h3><p>把笔记、参考图与提示词连成线索。在无限画布上平移、缩放,整理下一次创作。</p><div className={s.miniCanvas}><b>一个念头</b><i>⟶</i><b>一张参考</b><i>⟶</i><b>新的可能</b></div><strong>打开无限画布 <ArrowUpRight size={17}/></strong></a></div></section>
|
||||
<section className={s.reading} aria-labelledby="reading-title"><div><p className={s.eyebrow}>GOOD IDEAS DESERVE GOOD COMPANY</p><h2 id="reading-title">不必每一次,<br/>都从空白开始。</h2><p>把有用的工作方法,留给下一次的自己。<br/>一篇文档讲清一件事,需要时再沿着链接走远一点。</p><a href="/documents">去知识空间逛逛 <ArrowUpRight size={16}/></a></div><div className={s.readingArt} aria-hidden="true"><span>读到这里,<br/><em>想到了什么?</em></span><CreatorSketch pose="read"/><small>READ. CONNECT. MAKE.</small><svg viewBox="0 0 440 380"><path d="M35 105q-20-55 35-64m-15 0 17-2-8 17M348 267q46 33 32 68m-14-13 14 15 14-13"/></svg></div></section>
|
||||
<section className={s.workflow} id="workflow"><div><p className={s.eyebrow}>YOUR AGENT. YOUR WORKFLOW.</p><h2>用熟悉的 Agent,<br/>继续你的创作。</h2><p>通过项目已有的 API 与文档,让 coding agent 理解模板和创作内容。画布负责呈现,你来决定下一步。</p><a href="/studio/api">查看 Agent 接入文档 <ArrowUpRight size={16}/></a></div><ol><li><b>01</b><div><h3>找到起点</h3><p>从空白设计、素材或一段灵感出发。</p></div></li><li><b>02</b><div><h3>连接工作方式</h3><p>将接入文档交给你的 coding agent。</p></div></li><li><b>03</b><div><h3>亲手打磨,保存作品</h3><p>在编辑器中调整细节,导出并保留备份。</p></div></li></ol></section>
|
||||
<section className={s.end}><span>✳</span><h2>下一件作品,<br/>从这里开始。</h2><a className={s.dark} href="/account?next=/workspace">进入你的工作台 <ArrowRight size={17}/></a></section><footer className={s.footer}><a className={s.logo} href="/">✳ 织页 STUDIO</a><span>给想法留白,给创作空间。</span><a href="/documents">知识与教程 ↗</a></footer></main>}
|
||||
<section className={s.end}><div className={s.endWall} aria-hidden="true">{(['collect','read','connect','make'] as const).map(kind=><div key={kind}><IdeaSketch kind={kind}/><i/><i/><i/></div>)}</div><div className={s.endContent}><p className={s.eyebrow}>YOUR NEXT IDEA STARTS HERE</p><h2>给下一个念头,<br/><em>留一个起点。</em></h2><p>不用想好全部。带着一点好奇,先开始。</p><a className={s.dark} href="/account?next=/workspace">进入你的工作台 <ArrowRight size={17}/></a><a className={s.endSecondary} href="/documents">或先读一篇有用的文档 ↗</a></div></section><footer className={s.footer}><a className={s.logo} href="/"><Brand/></a><span>给想法留白,给创作空间。</span><a href="/documents">知识与教程 ↗</a></footer></main>}
|
||||
|
||||
@@ -12,7 +12,7 @@ export default function Import({allTags}:{allTags:Tag[]}){
|
||||
const [files,setFiles]=useState<File[]>([]);const [title,setTitle]=useState('');const [description,setDescription]=useState('');const [selected,setSelected]=useState<string[]>([]);const [busy,setBusy]=useState(false);const [error,setError]=useState('');
|
||||
function choose(list:FileList|null){if(!list)return;const items=Array.from(list);setFiles(items);if(!title&&items[0])setTitle(items[0].name.replace(/\.[^.]+$/,''));}
|
||||
async function submit(e:FormEvent){e.preventDefault();setError('');setBusy(true);try{const data=new FormData();data.set('kind',kind);data.set('title',title);data.set('description',description);data.set('tags',JSON.stringify(selected));files.forEach(f=>data.append('files',f));const res=await fetch('/api/upload',{method:'POST',body:data});const result=await res.json();if(!res.ok)throw new Error(result.error);await router.push('/explore/'+result.id);}catch(e){setError(e instanceof Error?e.message:'上传失败');}finally{setBusy(false);}}
|
||||
return <Shell><Head><title>素材入库 · PROMPT</title></Head><header className="page-header"><div><span className="eyebrow">MAKE ROOM FOR INSPIRATION</span><h1>导入素材</h1><p>支持多文件导入。同一组素材收录为一个素材包,每份文件都可以再次引用。</p></div></header><form className="editor-panel" onSubmit={submit}>
|
||||
return <Shell><Head><title>素材入库 · 啡跃 Endoleap</title></Head><header className="page-header"><div><span className="eyebrow">MAKE ROOM FOR INSPIRATION</span><h1>导入素材</h1><p>支持多文件导入。同一组素材收录为一个素材包,每份文件都可以再次引用。</p></div></header><form className="editor-panel" onSubmit={submit}>
|
||||
<div className="kind-picker">{[{id:'image',name:'图片',Icon:Image},{id:'video',name:'视频',Icon:Film},{id:'audio',name:'声音',Icon:AudioLines}].map(t=><button type="button" key={t.id} disabled={busy} className={kind===t.id?'selected':''} onClick={()=>{setKind(t.id);setFiles([]);}}><t.Icon size={19}/>{t.name}</button>)}</div>
|
||||
<label className="drop-zone" onDragOver={e=>e.preventDefault()} onDrop={e=>{e.preventDefault();if(!busy)choose(e.dataTransfer.files);}}><input type="file" multiple disabled={busy} accept={kind==='image'?'image/jpeg,image/png,image/webp,image/gif,image/avif':kind+'/*'} onChange={e=>choose(e.target.files)}/><span className="upload-circle"><Upload size={24}/></span><strong>选择文件或拖放到这里</strong><span>最多 16 个文件,单个不超过 250 MB</span></label>
|
||||
{files.length>0&&<ul className="file-list">{files.map((f,i)=><li key={i}><Check size={15}/><span>{f.name}</span><small>{(f.size/1024/1024).toFixed(1)} MB</small><button type="button" aria-label={`移除 ${f.name}`} disabled={busy} onClick={()=>setFiles(a=>a.filter((_,j)=>i!==j))}><X size={14}/></button></li>)}</ul>}
|
||||
|
||||
@@ -13,5 +13,5 @@ export default function Login() {
|
||||
window.location.assign(typeof next === 'string' && next.startsWith('/') && !next.startsWith('//') && !/[\\\r\n]/.test(next) && !next.startsWith('/login') ? next : '/');
|
||||
} catch { setError('暂时无法连接,请稍后重试'); } finally { setBusy(false); }
|
||||
}
|
||||
return <><Head><title>动态码登录 · PROMPT</title><meta name="robots" content="noindex,nofollow"/></Head><main className="auth-page"><form className="auth-card" onSubmit={submit}><img className="auth-logo" src="/brand-mark.svg" width={64} height={64} alt="Prompt"/><h1>输入动态码</h1><p className="auth-description">打开验证器,输入当前的 6 位动态码。</p><label htmlFor="auth-code">动态码</label><input id="auth-code" autoFocus inputMode="numeric" autoComplete="one-time-code" pattern="[0-9]{6}" maxLength={6} required value={code} onChange={e=>setCode(e.target.value.replace(/\D/g,''))} placeholder="000000" aria-describedby="auth-error"/><div id="auth-error" role="alert" className="auth-error">{error}</div><button type="submit" disabled={busy || code.length !== 6}>{busy ? '正在验证…' : '进入知识库'}</button><p className="auth-footnote">尚未绑定验证器?请联系管理员。</p></form></main></>;
|
||||
return <><Head><title>动态码登录 · 啡跃 Endoleap</title><meta name="robots" content="noindex,nofollow"/></Head><main className="auth-page"><form className="auth-card" onSubmit={submit}><img className="auth-logo" src="/brand/icon-dark.svg" width={64} height={64} alt="啡跃 Endoleap"/><h1>输入动态码</h1><p className="auth-description">打开验证器,输入当前的 6 位动态码。</p><label htmlFor="auth-code">动态码</label><input id="auth-code" autoFocus inputMode="numeric" autoComplete="one-time-code" pattern="[0-9]{6}" maxLength={6} required value={code} onChange={e=>setCode(e.target.value.replace(/\D/g,''))} placeholder="000000" aria-describedby="auth-error"/><div id="auth-error" role="alert" className="auth-error">{error}</div><button type="submit" disabled={busy || code.length !== 6}>{busy ? '正在验证…' : '进入知识库'}</button><p className="auth-footnote">尚未绑定验证器?请联系管理员。</p></form></main></>;
|
||||
}
|
||||
|
||||
@@ -16,5 +16,5 @@ export default function Doodle(){
|
||||
const [selected,setSelected]=useState(0);const [color,setColor]=useState('#fffaf0');const [copied,setCopied]=useState(false);
|
||||
const svg=()=>`<svg xmlns="http://www.w3.org/2000/svg" width="256" height="256" viewBox="0 0 128 128"><path d="${strokes[selected].path}" fill="none" stroke="${color}" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round"/></svg>`;
|
||||
function download(){const url=URL.createObjectURL(new Blob([svg()],{type:'image/svg+xml'}));const a=document.createElement('a');a.href=url;a.download=`prompt-doodle-${selected+1}.svg`;a.click();setTimeout(()=>URL.revokeObjectURL(url),1000);}
|
||||
return <Shell><Head><title>线条灵感 · PROMPT</title></Head><header className="page-header"><div><span className="eyebrow">SMALL LINES, LITTLE STORIES</span><h1>线条工具</h1><p>选择图形、调整颜色,下载后用于照片涂鸦。</p></div><span className="pill"><Sparkles size={14}/>照片涂鸦工具箱</span></header><div className="doodle-layout"><section className="doodle-catalog">{strokes.map((s,i)=><button key={s.name} onClick={()=>{setSelected(i);setCopied(false);}} className={`doodle-card ${i===selected?'selected':''}`}><div><svg viewBox="0 0 128 128"><path d={s.path} fill="none" stroke={color} strokeWidth="3.5" strokeLinecap="round" strokeLinejoin="round"/></svg></div><strong>{s.name}</strong><small>{s.hint}</small></button>)}</section><aside className="surface doodle-inspector"><span className="eyebrow">A LITTLE MARK</span><div className="doodle-large"><svg viewBox="0 0 128 128"><path d={strokes[selected].path} fill="none" stroke={color} strokeWidth="3.5" strokeLinecap="round" strokeLinejoin="round"/></svg></div><h2>{strokes[selected].name}</h2><p>{strokes[selected].hint}</p><div className="swatches">{['#fffaf0','#332c28','#ef6954','#ecc75b'].map(c=><button aria-label={`线条颜色 ${c}`} key={c} style={{background:c}} className={c===color?'selected':''} onClick={()=>setColor(c)}/>)}</div><button className="primary-button" onClick={download}><Download size={16}/>下载透明底 SVG</button><button className="secondary-button" onClick={async()=>{await navigator.clipboard.writeText(svg());setCopied(true);}}><Copy size={16}/>{copied?'已复制':'复制线条'}</button><p className="form-note">小图用来挑选造型与记录想法。正式画进照片时,位置、比例与笔触仍需要顺着画面调整。</p></aside></div></Shell>;
|
||||
return <Shell><Head><title>线条灵感 · 啡跃 Endoleap</title></Head><header className="page-header"><div><span className="eyebrow">SMALL LINES, LITTLE STORIES</span><h1>线条工具</h1><p>选择图形、调整颜色,下载后用于照片涂鸦。</p></div><span className="pill"><Sparkles size={14}/>照片涂鸦工具箱</span></header><div className="doodle-layout"><section className="doodle-catalog">{strokes.map((s,i)=><button key={s.name} onClick={()=>{setSelected(i);setCopied(false);}} className={`doodle-card ${i===selected?'selected':''}`}><div><svg viewBox="0 0 128 128"><path d={s.path} fill="none" stroke={color} strokeWidth="3.5" strokeLinecap="round" strokeLinejoin="round"/></svg></div><strong>{s.name}</strong><small>{s.hint}</small></button>)}</section><aside className="surface doodle-inspector"><span className="eyebrow">A LITTLE MARK</span><div className="doodle-large"><svg viewBox="0 0 128 128"><path d={strokes[selected].path} fill="none" stroke={color} strokeWidth="3.5" strokeLinecap="round" strokeLinejoin="round"/></svg></div><h2>{strokes[selected].name}</h2><p>{strokes[selected].hint}</p><div className="swatches">{['#fffaf0','#332c28','#ef6954','#ecc75b'].map(c=><button aria-label={`线条颜色 ${c}`} key={c} style={{background:c}} className={c===color?'selected':''} onClick={()=>setColor(c)}/>)}</div><button className="primary-button" onClick={download}><Download size={16}/>下载透明底 SVG</button><button className="secondary-button" onClick={async()=>{await navigator.clipboard.writeText(svg());setCopied(true);}}><Copy size={16}/>{copied?'已复制':'复制线条'}</button><p className="form-note">小图用来挑选造型与记录想法。正式画进照片时,位置、比例与笔触仍需要顺着画面调整。</p></aside></div></Shell>;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { ThemeToggle } from "../components/ThemeToggle";
|
||||
import { Brand } from "../components/Brand";
|
||||
import Head from 'next/head';
|
||||
import {useEffect,useState} from 'react';
|
||||
import {ArrowUpRight, Layers, Workflow, BookOpen, Link2} from 'lucide-react';
|
||||
import s from '@/styles/workspace.module.css';
|
||||
export default function Workspace(){const [email,setEmail]=useState(''),[state,setState]=useState('loading');useEffect(()=>{fetch('/api/account/session').then(async r=>{if(!r.ok)throw Error();const d=await r.json();if(!d.user){window.location.replace('/account?next=/workspace');return}setEmail(d.user.email);setState('ready')}).catch(()=>setState('error'))},[]);return <main className={s.app}><Head><title>工作台 · 织页 Studio</title></Head><aside><a className={s.brand} href="/">✳ 织页 <small>STUDIO</small></a><span>你的工作空间</span><a aria-current="page" href="/workspace">◈ 创作概览</a><a href="/graphic"><Layers size={16}/>图形设计</a><a href="/infinite_canvas"><Workflow size={16}/>无限画布</a><a href="/documents"><BookOpen size={16}/>知识与教程</a><a href="/studio/api"><Link2 size={16}/>Agent 接入文档</a><a className={s.account} href="/account">{email||'账户设置'} ↗</a></aside><section>{state==='loading'?<p role="status">正在打开你的工作台…</p>:state==='error'?<p role="alert">暂时无法读取账户。<a href="/account?next=/workspace">重新登录</a></p>:<><header><span>WORKSPACE / OVERVIEW</span><a href="/account">账户设置 ↗</a></header><p className={s.kicker}>MAKE SOMETHING THAT MATTERS.</p><h1>今天,让哪个想法成形?</h1><p className={s.sub}>选一个空间,开始你的下一次创作。</p><div className={s.grid}><a href="/graphic"><Layers/><small>GRAPHIC</small><h2>图形设计</h2><p>在文字、图形与图像之间,找到你的表达。</p><div className={s.poster}>Ideas,<br/><i>in shape.</i><b>✳</b></div><strong>打开编辑器 <ArrowUpRight size={18}/></strong></a><a href="/infinite_canvas"><Workflow/><small>INFINITE CANVAS</small><h2>无限画布</h2><p>收集灵感、连接参考,让思路自由生长。</p><div className={s.canvas}><span>灵感</span>⟶<span>参考</span>⟶<span>作品</span></div><strong>打开画布 <ArrowUpRight size={18}/></strong></a></div><div className={s.agent}><Link2/><div><h3>带上你的 coding agent</h3><p>从接入文档开始,使用已有的创作 API。连接由你的 Agent 发起。</p></div><a href="/studio/api">查看文档 ↗</a></div><p className={s.hint}>编辑器草稿保存在当前浏览器。更换设备前,请先导出项目备份。</p></>}</section></main>}
|
||||
export default function Workspace(){const [email,setEmail]=useState(''),[state,setState]=useState('loading');useEffect(()=>{fetch('/api/account/session').then(async r=>{if(!r.ok)throw Error();const d=await r.json();if(!d.user){window.location.replace('/account?next=/workspace');return}setEmail(d.user.email);setState('ready')}).catch(()=>setState('error'))},[]);return <main className={s.app}><Head><title>工作台 · 啡跃 Studio</title></Head><aside><a className={s.brand} href="/"><Brand/></a><span>你的工作空间</span><a aria-current="page" href="/workspace">◈ 创作概览</a><a href="/graphic"><Layers size={16}/>图形设计</a><a href="/infinite_canvas"><Workflow size={16}/>无限画布</a><a href="/documents"><BookOpen size={16}/>知识与教程</a><a href="/studio/api"><Link2 size={16}/>Agent 接入文档</a><a className={s.account} href="/account">{email||'账户设置'} ↗</a></aside><section>{state==='loading'?<p role="status">正在打开你的工作台…</p>:state==='error'?<p role="alert">暂时无法读取账户。<a href="/account?next=/workspace">重新登录</a></p>:<><header><span>WORKSPACE / OVERVIEW</span><ThemeToggle/><a href="/account">账户设置 ↗</a></header><p className={s.kicker}>MAKE SOMETHING THAT MATTERS.</p><h1>今天,让哪个想法成形?</h1><p className={s.sub}>选一个空间,开始你的下一次创作。</p><div className={s.grid}><a href="/graphic"><Layers/><small>GRAPHIC</small><h2>图形设计</h2><p>在文字、图形与图像之间,找到你的表达。</p><div className={s.poster}>Ideas,<br/><i>in shape.</i><b>✳</b></div><strong>打开编辑器 <ArrowUpRight size={18}/></strong></a><a href="/infinite_canvas"><Workflow/><small>INFINITE CANVAS</small><h2>无限画布</h2><p>收集灵感、连接参考,让思路自由生长。</p><div className={s.canvas}><span>灵感</span>⟶<span>参考</span>⟶<span>作品</span></div><strong>打开画布 <ArrowUpRight size={18}/></strong></a></div><div className={s.agent}><Link2/><div><h3>带上你的 coding agent</h3><p>从接入文档开始,使用已有的创作 API。连接由你的 Agent 发起。</p></div><a href="/studio/api">查看文档 ↗</a></div><p className={s.hint}>编辑器草稿保存在当前浏览器。更换设备前,请先导出项目备份。</p></>}</section></main>}
|
||||
|
||||
@@ -2,10 +2,12 @@ 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;
|
||||
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'};
|
||||
// Public knowledge pages are read-only. Account handlers enforce their own origin and session checks.
|
||||
const publicRead = ['GET','HEAD'].includes(request.method) && (path === '/' || ['/workspace','/graphic','/infinite_canvas','/studio','/studio/graphic','/studio/infinite_canvas','/studio/api'].includes(path) || path.startsWith('/studio/_next/') || path === '/protocol' || ['/llms.txt','/index.md','/agent','/agent/index.md','/api/agent-index','/api/guides'].includes(path) || path.startsWith('/guide/') || path.startsWith('/api/guides/'));
|
||||
if (path === '/write' || path === '/documents' || path.startsWith('/documents/') || path === '/api/documents' || path.startsWith('/api/documents/') || publicRead || path === '/account' || path.startsWith('/api/account/')) return NextResponse.next({headers:{...responseHeaders,'Referrer-Policy':'no-referrer'}});
|
||||
if (path === '/api/canvases' || path.startsWith('/api/canvases/') || path === '/write' || path === '/documents' || path.startsWith('/documents/') || path === '/api/documents' || path.startsWith('/api/documents/') || publicRead || path === '/account' || path.startsWith('/api/account/')) return NextResponse.next({headers:{...responseHeaders,'Referrer-Policy':'no-referrer'}});
|
||||
if (path === '/brand-mark.svg' || path === '/login' || path === '/api/auth/login' || path === '/api/auth/logout' || path === '/api/health' || path === '/api/ready' || path.startsWith('/_next/static/')) return NextResponse.next({headers:responseHeaders});
|
||||
let config;
|
||||
try { config = authConfig(); } catch { return NextResponse.json({error:'登录服务配置不可用'}, {status:503,headers:responseHeaders}); }
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
.page { height: 100dvh; overflow: hidden; background: var(--surface); color: var(--foreground); }
|
||||
.page > main { height: 100%; min-height: 0; }
|
||||
.header { position: absolute; z-index: 2; top: 28px; right: 36px; }
|
||||
.header > div { display: flex; align-items: center; gap: 20px; font-size: 13px; color: var(--muted-foreground); }
|
||||
.header a:hover { color: var(--foreground-strong); }
|
||||
.layout { display: grid; grid-template-columns: 46% minmax(0, 1fr); height: 100%; min-height: 0; }
|
||||
.intro { position: relative; isolation: isolate; display: flex; flex-direction: column; min-width: 0; padding: 38px clamp(32px, 5vw, 80px) 30px; overflow: hidden; background: var(--background); color: var(--foreground); }
|
||||
.intro::before { content: ''; position: absolute; z-index: -1; inset: 0; background: radial-gradient(ellipse at 0% 35%, color-mix(in srgb, var(--el-deep-mocha) 85%, transparent), transparent 60%), radial-gradient(ellipse at 100% 92%, color-mix(in srgb, var(--primary) 45%, transparent), transparent 65%); }
|
||||
.brand { align-self: flex-start; }
|
||||
.story { margin: auto 0; padding-top: clamp(16px, 4dvh, 40px); }
|
||||
.eyebrow { margin: 0 0 20px; font-size: 10px; letter-spacing: .18em; color: var(--muted-foreground); line-height: 1.7; }
|
||||
.story h1 { margin: 0 0 22px; font-size: clamp(32px, 3.3vw, 50px); font-weight: 600; line-height: 1.3; letter-spacing: -.035em; color: var(--foreground-strong); }
|
||||
.description { margin: 0; max-width: 340px; font-size: 15px; line-height: 1.9; color: var(--foreground); }
|
||||
.signature { display: flex; flex-wrap: wrap; gap: 8px 16px; margin: 32px 0 0; font-size: 11px; line-height: 1.7; color: var(--muted-foreground); }
|
||||
.signature span { opacity: .8; }
|
||||
.art { position: relative; height: clamp(180px, 28dvh, 280px); margin: 28px -12px 0; pointer-events: none; }
|
||||
.orbit { position: absolute; width: 245px; height: 245px; left: 15%; top: 5%; border: 1px solid color-mix(in srgb, var(--foreground) 18%, transparent); border-radius: 50%; }
|
||||
.orbit::after { content: ''; position: absolute; inset: 25px -35px; border: inherit; border-radius: 50%; transform: rotate(-38deg); }
|
||||
.idea, .work { position: absolute; display: flex; flex-direction: column; border: 1px solid color-mix(in srgb, var(--foreground) 24%, transparent); border-radius: 14px; box-shadow: 0 20px 50px var(--shadow); }
|
||||
.idea { left: 0; top: 42px; width: 48%; min-height: 196px; padding: 22px 18px; transform: rotate(-8deg); background: color-mix(in srgb, var(--surface) 78%, transparent); backdrop-filter: blur(16px); }
|
||||
.idea span, .work > span { font-size: 8px; letter-spacing: .12em; line-height: 1.6; }
|
||||
.idea strong { margin: 24px 0 18px; font-size: clamp(18px, 1.8vw, 26px); font-weight: 500; line-height: 1.5; }
|
||||
.idea i, .work i { font-size: 9px; font-style: normal; line-height: 1.7; opacity: .72; }
|
||||
.work { right: 0; top: 15px; width: 51%; padding: 18px; transform: rotate(8deg); background: var(--el-cream); color: var(--el-espresso); }
|
||||
.artwork { height: 110px; margin: 16px 0 14px; overflow: hidden; border-radius: 100px 100px 8px 8px; background: radial-gradient(ellipse at 75% 20%, var(--el-cream), transparent 60%), linear-gradient(135deg, var(--el-deep-mocha), var(--el-espresso)); }
|
||||
.work strong { margin-bottom: 6px; font-size: 19px; font-weight: 500; }
|
||||
.artCaption { position: absolute; bottom: 0; left: 0; right: 0; text-align: center; font-size: 8px; letter-spacing: .2em; color: var(--muted-foreground); }
|
||||
|
||||
.form { align-self: center; justify-self: center; width: min(400px, calc(100% - 80px)); min-width: 0; padding: 0; }
|
||||
.frame { height: 560px; display: flex; flex-direction: column; justify-content: center; }
|
||||
.frame h2 { margin: 0; font-size: 28px; font-weight: 600; line-height: 1.3; text-align: center; letter-spacing: -.04em; animation: mode-in 180ms ease-out; }
|
||||
.formDescription { height: 42px; flex-shrink: 0; margin: 10px 0 20px; font-size: 14px; line-height: 1.5; text-align: center; color: var(--muted-foreground); }
|
||||
.tabSlot { height: 44px; flex-shrink: 0; margin-bottom: 24px; }
|
||||
.tabs { position: relative; display: grid; grid-template-columns: 1fr 1fr; height: 44px; padding: 4px; border: 1px solid var(--border); border-radius: 9px; background: var(--surface-muted); isolation: isolate; }
|
||||
.tabs::before { content: ''; position: absolute; z-index: -1; top: 4px; bottom: 4px; left: 4px; width: calc(50% - 4px); border-radius: 5px; background: var(--surface); box-shadow: 0 1px 3px var(--shadow); transition: transform 180ms ease; }
|
||||
.tabs[data-active='register']::before { transform: translateX(100%); }
|
||||
.tabs button { border: 0; background: transparent; font-size: 13px; color: var(--muted-foreground); }
|
||||
.tabs button[aria-pressed='true'] { color: var(--foreground-strong); font-weight: 600; }
|
||||
.fieldset { margin: 0; padding: 0; min-width: 0; border: 0; }
|
||||
.fields { height: 168px; display: flex; flex-direction: column; gap: 16px; animation: mode-in 180ms ease-out; }
|
||||
.field { min-width: 0; }
|
||||
.field label { display: block; margin-bottom: 7px; font-size: 13px; line-height: 20px; font-weight: 500; }
|
||||
.labelRow { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; }
|
||||
.labelRow button { font-size: 12px; color: var(--muted-foreground); }
|
||||
.labelRow button:hover { color: var(--foreground-strong); }
|
||||
.page .form input { width: 100%; height: 44px; min-height: 44px; padding: 0 12px; font-size: 14px; background: var(--surface); border: 1px solid var(--input); border-radius: 8px; box-shadow: 0 1px 2px var(--shadow); }
|
||||
.page .form input[name='password'] { padding-right: 44px; }
|
||||
.page .submit { width: 100%; min-height: 44px; margin-top: 8px; font-size: 14px; border-radius: 8px; }
|
||||
.secondarySlot { min-height: 38px; display: grid; place-items: center; margin-top: 14px; }
|
||||
.secondary { display: inline-flex; align-items: center; justify-content: center; gap: 6px; min-height: 32px; padding: 0; font-size: 12px; color: var(--muted-foreground); text-decoration: underline; text-underline-offset: 4px; }
|
||||
.footnote { height: 60px; flex-shrink: 0; margin: 18px 0 0; padding-top: 16px; border-top: 1px solid var(--border); font-size: 12px; line-height: 1.7; text-align: center; color: var(--muted-foreground); }
|
||||
.verifyInfo, .accountDetails { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 12px; height: 168px; text-align: center; color: var(--muted-foreground); }
|
||||
.accountDetails strong { color: var(--foreground-strong); overflow-wrap: anywhere; }
|
||||
.accountDetails span, .verifyInfo p { font-size: 13px; line-height: 1.8; }
|
||||
.loading { height: 320px; display: grid; place-items: center; font-size: 13px; color: var(--muted-foreground); }
|
||||
.toast { position: fixed; z-index: 50; right: 20px; bottom: 20px; width: min(420px, calc(100% - 40px)); display: flex; align-items: flex-start; gap: 10px; padding: 14px 16px; border: 1px solid var(--border); border-radius: 12px; background: var(--surface); color: var(--foreground); box-shadow: 0 8px 32px var(--shadow); animation: mode-in 180ms ease-out; }
|
||||
.toast > span { flex: 0 0 20px; height: 20px; text-align: center; border-radius: 50%; background: var(--success-background); color: var(--success); font-size: 13px; font-weight: 600; }
|
||||
.toast[data-tone='error'] > span { color: var(--danger); background: var(--danger-background); }
|
||||
.toast p { flex: 1; min-width: 0; margin: 0; font-size: 13px; line-height: 1.6; overflow-wrap: anywhere; }
|
||||
.toast button { flex: 0 0 24px; min-height: 24px; color: var(--muted-foreground); }
|
||||
@keyframes mode-in { from { opacity: 0; transform: translateY(5px); } to { opacity: 1; transform: translateY(0); } }
|
||||
@media (min-width: 1500px) { .story { max-width: 480px; width: 100%; margin-left: auto; margin-right: auto; } }
|
||||
@media (max-width: 1000px) { .intro { padding-left: 28px; padding-right: 28px; } .story h1 { font-size: 34px; } .description { font-size: 14px; } .form { width: min(380px, calc(100% - 64px)); } .header { right: 28px; } }
|
||||
@media (max-width: 760px) {
|
||||
.header { top: 20px; right: 20px; }
|
||||
.layout { grid-template-columns: minmax(0, 1fr); }
|
||||
.intro { position: absolute; top: 20px; left: 24px; padding: 0; background: transparent; overflow: visible; }
|
||||
.intro::before, .story, .signature { display: none; }
|
||||
.brand { padding: 5px; border-radius: 8px; background: var(--background); }
|
||||
.form { width: min(400px, calc(100% - 48px)); padding-top: 40px; }
|
||||
.frame h2 { font-size: 25px; }
|
||||
}
|
||||
@media (max-height: 760px) {
|
||||
.art, .signature { display: none; }
|
||||
.intro { padding-top: 24px; padding-bottom: 24px; }
|
||||
.formDescription { height: 34px; font-size: 13px; margin: 8px 0 14px; }
|
||||
.frame { height: 460px; }
|
||||
.frame h2 { font-size: 25px; }
|
||||
.tabSlot { margin-bottom: 18px; }
|
||||
.fields { height: 158px; gap: 12px; }
|
||||
.footnote { display: none; }
|
||||
.secondarySlot { margin-top: 10px; }
|
||||
}
|
||||
@media (max-height: 560px) {
|
||||
.header a { display: none; }
|
||||
.formDescription { display: none; }
|
||||
.frame { height: 340px; }
|
||||
.frame h2 { font-size: 22px; margin-bottom: 14px; }
|
||||
.tabSlot { height: 36px; margin-bottom: 14px; }
|
||||
.tabs { height: 36px; }
|
||||
.story h1 { font-size: 28px; }
|
||||
.eyebrow { display: none; }
|
||||
}
|
||||
@media (max-width: 760px) and (max-height: 760px) { .intro { padding: 0; } }
|
||||
@media (prefers-reduced-motion: reduce) { .frame h2, .fields, .toast { animation: none; } .tabs::before { transition: none; } }
|
||||
@@ -0,0 +1,23 @@
|
||||
/* Shared chrome uses the canonical package tokens; artwork keeps its own palette. */
|
||||
html,body{background:var(--background);color:var(--foreground);color-scheme:inherit}
|
||||
body{margin:0}
|
||||
.endoleap-brand{display:inline-flex;align-items:center;gap:9px;white-space:nowrap;line-height:1.15;vertical-align:middle;letter-spacing:normal;font-weight:650}
|
||||
.endoleap-brand .endoleap-mark{width:32px;height:32px;object-fit:contain;flex-shrink:0}
|
||||
.endoleap-mark-dark{display:none!important}
|
||||
[data-theme$="-dark"] .endoleap-mark-light{display:none!important}
|
||||
[data-theme$="-dark"] .endoleap-mark-dark{display:block!important}
|
||||
.endoleap-wordmark{display:flex!important;flex-direction:column;font-size:17px!important;color:var(--foreground-strong)!important;letter-spacing:.02em!important;margin:0!important;gap:3px}
|
||||
.endoleap-wordmark small{display:block;font-size:9px;font-weight:500;letter-spacing:.08em;color:var(--muted-foreground)}
|
||||
button.endoleap-theme-toggle{display:inline-flex;align-items:center;justify-content:center;gap:5px;border:0;background:transparent;color:var(--muted-foreground);border-radius:8px;padding:8px;width:32px;height:32px;font-size:16px;line-height:1.2;flex-shrink:0;cursor:pointer}
|
||||
button.endoleap-theme-toggle:hover{background:var(--row-hover)}
|
||||
:where(a,button,input,textarea,select):focus-visible{outline:2px solid var(--ring);outline-offset:3px}
|
||||
::selection{background:var(--selection);color:var(--foreground)}
|
||||
@media(max-width:600px){.endoleap-theme-toggle span{display:inline}.endoleap-brand{gap:5px}.endoleap-brand .endoleap-mark{width:26px;height:26px}.endoleap-wordmark{font-size:14px!important}}
|
||||
|
||||
button:disabled{cursor:default}
|
||||
input,textarea,select{accent-color:var(--primary)}
|
||||
|
||||
.primary-button,.auth-submit{background:var(--primary);color:var(--primary-foreground)}
|
||||
.primary-button:hover,.auth-submit:hover{background:var(--primary-hover)}
|
||||
/* The legacy library rail collapses on tablets, while its mobile drawer expands. */
|
||||
@media(min-width:651px) and (max-width:950px){.sidebar .endoleap-wordmark{display:none!important}}
|
||||
@@ -1 +1 @@
|
||||
.page{max-width:850px;margin:0 auto;padding:40px 24px 100px;color:#202326;font-family:system-ui,sans-serif;line-height:1.7}.page nav{margin-bottom:32px}.page a{color:#276750}.page h1{font-size:36px;letter-spacing:-1px}.page label{display:block;margin:18px 0}.page input:not([type=checkbox]),.page textarea,.page select{display:block;width:100%;padding:12px;border:1px solid #d8ded8;border-radius:8px;background:#fff;color:inherit;font:inherit;box-sizing:border-box}.page button{padding:10px 18px;background:#264e40;color:#fff;border:0;border-radius:7px;cursor:pointer}.page article{border-top:1px solid #d8ded8;padding:22px 0}.page pre{white-space:pre-wrap;overflow-wrap:anywhere;background:#f1f4ef;padding:18px}.body{white-space:pre-wrap}.page fieldset{border:1px solid #d8ded8;border-radius:8px;margin:14px 0}
|
||||
.page{max-width:850px;margin:0 auto;padding:40px 24px 100px;color:var(--foreground-strong);font-family:system-ui,sans-serif;line-height:1.7}.page nav{margin-bottom:32px}.page a{color:var(--primary)}.page h1{font-size:36px;letter-spacing:-1px}.page label{display:block;margin:18px 0}.page input:not([type=checkbox]),.page textarea,.page select{display:block;width:100%;padding:12px;border:1px solid var(--input);border-radius:8px;background:var(--surface);color:inherit;font:inherit;box-sizing:border-box}.page button{padding:10px 18px;background:var(--primary);color:var(--primary-foreground);border:0;border-radius:7px;cursor:pointer}.page article{border-top:1px solid var(--border);padding:22px 0}.page pre{white-space:pre-wrap;overflow-wrap:anywhere;background:var(--selection);padding:18px}.body{white-space:pre-wrap}.page fieldset{border:1px solid var(--border);border-radius:8px;margin:14px 0}
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,3 +1,3 @@
|
||||
.skills-masonry{columns:4 230px;column-gap:24px}.skill-pin{display:block;break-inside:avoid;margin:0 0 28px}.skill-pin-cover{background:#f1eee8!important;aspect-ratio:4/5;display:grid;place-items:center;max-height:440px}.skill-pin-cover img{width:100%;height:100%;object-fit:contain!important;padding:24px}.docs-top{display:flex;justify-content:space-between;font-size:13px;color:#777;gap:15px;margin-bottom:28px}.docs-top button{display:flex;align-items:center;gap:8px}.docs-layout{max-width:1200px;margin:auto;display:grid;gap:35px;grid-template-columns:minmax(0,800px);justify-content:center}.docs-layout.with-sources{grid-template-columns:minmax(0,1fr) 340px}.docs-body{min-width:0;padding:18px 12px 70px}.docs-eyebrow{font-size:11px;color:#9b968b;letter-spacing:1.5px}.docs-body h1{font-size:38px;font-weight:700;margin:18px 0 24px;letter-spacing:-1px}.docs-body h2{font-size:23px;margin:40px 0 20px;font-weight:650}.docs-body>p{font-size:15px;color:#555;line-height:2;margin:22px 0}.docs-actions{display:flex;gap:12px;align-items:center;flex-wrap:wrap;font-size:12px}.docs-actions button{display:flex;align-items:center;gap:8px;background:#252525;color:white;padding:12px 16px;border-radius:24px}.docs-actions a{color:#888}.docs-body>.docs-hint{font-size:12px;color:#999}.docs-body pre,.docs-resource-preview pre{white-space:pre-wrap;overflow-wrap:anywhere;background:#f5f5f2;padding:22px;border-radius:15px;font-size:12px;line-height:1.9;color:#666}.docs-inline-links{display:flex;flex-wrap:wrap;gap:8px;margin:20px 0}.docs-inline-links a{display:flex;align-items:center;gap:7px;border:1px solid #e4e4df;border-radius:20px;padding:9px 12px;font-size:12px;color:#64705b}.docs-sources{position:sticky;top:105px;align-self:start;max-height:calc(100vh - 125px);overflow:auto;border:1px solid #e6e6e1;border-radius:18px;background:white;box-shadow:0 8px 35px #00000008;padding:20px}.docs-sources>header{display:flex;justify-content:space-between;gap:10px;align-items:flex-start;border-bottom:1px solid #eee;padding-bottom:18px}.docs-sources header b{font-size:16px}.docs-sources header small{display:block;font-size:11px;color:#aaa;margin-top:7px}.docs-sources h3{font-size:12px;color:#999;margin:21px 0 10px}.docs-sources section>button{display:flex;text-align:left;align-items:flex-start;gap:9px;width:100%;padding:12px 8px;border-radius:10px}.docs-sources section>button.selected,.docs-sources section>button:hover{background:#f2f3ed}.docs-sources section>button>span{font-size:9px;border:1px solid #ddd;padding:3px 4px;border-radius:5px;color:#92987f;flex-shrink:0}.docs-sources section>button>div{font-size:12px;color:#555;min-width:0}.docs-sources section small{display:block;font-size:10px;overflow-wrap:anywhere;color:#aaa;margin-top:5px}.docs-resource-preview{border-top:1px solid #ddd;margin-top:20px}.docs-resource-preview img{width:100%;max-height:350px;object-fit:contain;background:repeating-conic-gradient(#eee 0 25%,white 0 50%) 0/16px 16px}.docs-resource-preview pre{font-size:10px;max-height:330px;overflow:auto;padding:12px}.docs-resource-preview p,.docs-resource-preview code{font-size:11px;line-height:1.8;overflow-wrap:anywhere}.docs-resource-actions{display:flex;flex-wrap:wrap;gap:12px;font-size:12px;color:#60724e;margin:18px 0}.docs-body a:focus-visible,.docs-sources button:focus-visible{outline:2px solid #81956d;outline-offset:3px}
|
||||
@media(max-width:1050px){.docs-layout.with-sources{grid-template-columns:minmax(0,1fr) 285px;gap:18px}}@media(max-width:760px){.docs-layout.with-sources{display:block}.docs-sources{position:fixed;right:12px;left:12px;top:95px;bottom:18px;max-height:none;z-index:45;box-shadow:0 0 0 100vmax #0005}.docs-body h1{font-size:30px}.docs-body{padding:10px 0 40px}.docs-body>p{font-size:14px}.skills-masonry{columns:2;column-gap:15px}.skill-pin-cover img{padding:12px}.snapshot-reader{padding:14px 8px}.snapshot-reader>header{align-items:flex-start;flex-direction:column}.snapshot-reader nav{gap:10px}.snapshot-reader nav a{font-size:11px}}
|
||||
.guidance-directory{border-top:1px solid #e4e4df;margin-top:24px}.guidance-directory>div{display:grid;grid-template-columns:minmax(0,1fr) auto auto;align-items:center;gap:20px;padding:18px 0;border-bottom:1px solid #e4e4df}.guidance-directory>div>a:first-child{font-size:15px;color:#3e4938}.guidance-directory small,.guidance-directory>div>a:last-child{font-size:12px;color:#888}@media(max-width:760px){.guidance-directory>div{grid-template-columns:minmax(0,1fr) auto;gap:8px}.guidance-directory small{grid-column:1;grid-row:2}.guidance-directory>div>a:last-child{grid-column:2;grid-row:1/3}}
|
||||
.skills-masonry{columns:4 230px;column-gap:24px}.skill-pin{display:block;break-inside:avoid;margin:0 0 28px}.skill-pin-cover{background:var(--selection)!important;aspect-ratio:4/5;display:grid;place-items:center;max-height:440px}.skill-pin-cover img{width:100%;height:100%;object-fit:contain!important;padding:24px}.docs-top{display:flex;justify-content:space-between;font-size:13px;color:var(--muted-foreground);gap:15px;margin-bottom:28px}.docs-top button{display:flex;align-items:center;gap:8px}.docs-layout{max-width:1200px;margin:auto;display:grid;gap:35px;grid-template-columns:minmax(0,800px);justify-content:center}.docs-layout.with-sources{grid-template-columns:minmax(0,1fr) 340px}.docs-body{min-width:0;padding:18px 12px 70px}.docs-eyebrow{font-size:11px;color:var(--muted-foreground);letter-spacing:1.5px}.docs-body h1{font-size:38px;font-weight:700;margin:18px 0 24px;letter-spacing:-1px}.docs-body h2{font-size:23px;margin:40px 0 20px;font-weight:650}.docs-body>p{font-size:15px;color:var(--foreground);line-height:2;margin:22px 0}.docs-actions{display:flex;gap:12px;align-items:center;flex-wrap:wrap;font-size:12px}.docs-actions button{display:flex;align-items:center;gap:8px;background:var(--surface);color:var(--foreground);padding:12px 16px;border-radius:24px}.docs-actions a{color:var(--muted-foreground)}.docs-body>.docs-hint{font-size:12px;color:var(--muted-foreground)}.docs-body pre,.docs-resource-preview pre{white-space:pre-wrap;overflow-wrap:anywhere;background:var(--surface);padding:22px;border-radius:15px;font-size:12px;line-height:1.9;color:var(--foreground)}.docs-inline-links{display:flex;flex-wrap:wrap;gap:8px;margin:20px 0}.docs-inline-links a{display:flex;align-items:center;gap:7px;border:1px solid var(--border);border-radius:20px;padding:9px 12px;font-size:12px;color:var(--foreground)}.docs-sources{position:sticky;top:105px;align-self:start;max-height:calc(100vh - 125px);overflow:auto;border:1px solid var(--border);border-radius:18px;background:var(--surface);box-shadow:0 8px 35px var(--shadow);padding:20px}.docs-sources>header{display:flex;justify-content:space-between;gap:10px;align-items:flex-start;border-bottom:1px solid var(--border);padding-bottom:18px}.docs-sources header b{font-size:16px}.docs-sources header small{display:block;font-size:11px;color:var(--muted-foreground);margin-top:7px}.docs-sources h3{font-size:12px;color:var(--muted-foreground);margin:21px 0 10px}.docs-sources section>button{display:flex;text-align:left;align-items:flex-start;gap:9px;width:100%;padding:12px 8px;border-radius:10px}.docs-sources section>button.selected,.docs-sources section>button:hover{background:var(--selection)}.docs-sources section>button>span{font-size:9px;border:1px solid var(--border);padding:3px 4px;border-radius:5px;color:var(--muted-foreground);flex-shrink:0}.docs-sources section>button>div{font-size:12px;color:var(--foreground);min-width:0}.docs-sources section small{display:block;font-size:10px;overflow-wrap:anywhere;color:var(--muted-foreground);margin-top:5px}.docs-resource-preview{border-top:1px solid var(--border);margin-top:20px}.docs-resource-preview img{width:100%;max-height:350px;object-fit:contain;background:repeating-conic-gradient(var(--surface) 0 25%,var(--surface) 0 50%) 0/16px 16px}.docs-resource-preview pre{font-size:10px;max-height:330px;overflow:auto;padding:12px}.docs-resource-preview p,.docs-resource-preview code{font-size:11px;line-height:1.8;overflow-wrap:anywhere}.docs-resource-actions{display:flex;flex-wrap:wrap;gap:12px;font-size:12px;color:var(--foreground);margin:18px 0}.docs-body a:focus-visible,.docs-sources button:focus-visible{outline:2px solid var(--ring);outline-offset:3px}
|
||||
@media(max-width:1050px){.docs-layout.with-sources{grid-template-columns:minmax(0,1fr) 285px;gap:18px}}@media(max-width:760px){.docs-layout.with-sources{display:block}.docs-sources{position:fixed;right:12px;left:12px;top:95px;bottom:18px;max-height:none;z-index:45;box-shadow:0 0 0 100vmax var(--shadow)}.docs-body h1{font-size:30px}.docs-body{padding:10px 0 40px}.docs-body>p{font-size:14px}.skills-masonry{columns:2;column-gap:15px}.skill-pin-cover img{padding:12px}.snapshot-reader{padding:14px 8px}.snapshot-reader>header{align-items:flex-start;flex-direction:column}.snapshot-reader nav{gap:10px}.snapshot-reader nav a{font-size:11px}}
|
||||
.guidance-directory{border-top:1px solid var(--border);margin-top:24px}.guidance-directory>div{display:grid;grid-template-columns:minmax(0,1fr) auto auto;align-items:center;gap:20px;padding:18px 0;border-bottom:1px solid var(--border)}.guidance-directory>div>a:first-child{font-size:15px;color:var(--foreground)}.guidance-directory small,.guidance-directory>div>a:last-child{font-size:12px;color:var(--muted-foreground)}@media(max-width:760px){.guidance-directory>div{grid-template-columns:minmax(0,1fr) auto;gap:8px}.guidance-directory small{grid-column:1;grid-row:2}.guidance-directory>div>a:last-child{grid-column:2;grid-row:1/3}}
|
||||
|
||||
@@ -1 +1 @@
|
||||
.app{display:flex;min-height:100dvh;background:#fafaf8;color:#30352c;font-family:Arial,'PingFang SC',sans-serif}.app *{box-sizing:border-box}.app a{color:inherit;text-decoration:none}.app>aside{width:230px;flex-shrink:0;border-right:1px solid #e0e3da;background:#f3f4ef;padding:30px 20px;display:flex;flex-direction:column;gap:9px}.brand{font-size:22px;font-weight:600;margin-bottom:45px}.brand small{font-size:9px;letter-spacing:1px;color:#87917c}.app aside>span{font-size:10px;color:#99a08f;margin-bottom:10px;padding-left:10px}.app aside>a:not(.brand){display:flex;align-items:center;gap:12px;font-size:12px;padding:12px;border-radius:7px}.app aside>a[aria-current]{background:#e5eadb}.app aside>.account{margin-top:auto;overflow-wrap:anywhere}.app>section{padding:35px 55px;max-width:1250px;width:100%;margin:auto;min-height:100dvh}.app header{display:flex;justify-content:space-between;color:#8d9582;font-size:10px;letter-spacing:1px}.kicker{font-size:10px;letter-spacing:2px;color:#8a9876;margin-top:65px}.app h1{font-size:34px;font-weight:500;letter-spacing:-1px;margin:18px 0}.sub{font-size:13px;color:#929887}.grid{display:grid;grid-template-columns:1fr 1fr;gap:24px;margin-top:35px}.grid>a{background:white;border:1px solid #dfe3d6;border-radius:12px;padding:26px;transition:transform .2s}.grid>a:hover{transform:translateY(-3px)}.grid small{font-size:9px;letter-spacing:1.5px;margin-left:12px;color:#909b82}.grid>a>svg{vertical-align:middle;width:20px;color:#7b8c65}.grid h2{font-size:23px;font-weight:500;margin:24px 0 12px}.grid p{font-size:12px;color:#929a87;line-height:1.8}.poster,.canvas{height:190px;margin:25px 0;background:#ecf0e3;position:relative;overflow:hidden;border-radius:5px}.poster{padding:25px;font-size:40px;line-height:1;font-weight:600;letter-spacing:-2px}.poster i{font-family:Georgia,serif;font-weight:400}.poster b{position:absolute;right:0;top:12px;font-size:155px;color:#a7b88b}.canvas{display:flex;align-items:center;justify-content:center;gap:12px;background:radial-gradient(#c5cfb8 1px,transparent 1px) 0 0/18px 18px,#f3f5ef;color:#91a276}.canvas span{background:white;border:1px solid #d5ddc9;padding:20px 13px;font-size:12px;border-radius:6px;color:#59684a}.grid strong{display:flex;justify-content:space-between;font-size:12px;font-weight:400}.agent{display:flex;align-items:center;gap:18px;border:1px solid #dde3d1;border-radius:10px;padding:23px;margin-top:28px}.agent>svg{color:#83956c}.agent h3{font-size:14px;margin:0 0 8px}.agent p{font-size:11px;color:#8a947d;margin:0;line-height:1.8}.agent a{margin-left:auto;font-size:12px;white-space:nowrap}.hint{font-size:10px;color:#a1a793;margin-top:25px}.app a:focus-visible{outline:2px solid #718358;outline-offset:4px}@media(max-width:850px){.app>aside{width:180px}.app>section{padding:30px 25px}.grid{gap:15px}.grid>a{padding:20px}.app h1{font-size:28px}.canvas{gap:5px}.canvas span{padding:15px 8px}}@media(max-width:600px){.app{display:block}.app>aside{width:100%;flex-direction:row;flex-wrap:wrap;padding:18px;gap:5px;border-right:0;border-bottom:1px solid #ddd}.brand{margin:0;width:100%}.app aside>span,.app aside>.account{display:none}.app aside>a:not(.brand){padding:8px;font-size:10px}.grid{grid-template-columns:1fr}.kicker{margin-top:35px}.agent{flex-wrap:wrap}.agent a{margin-left:0}}
|
||||
.app{display:flex;min-height:100dvh;background:var(--background);color:var(--foreground-strong);font-family:Arial,'PingFang SC',sans-serif}.app *{box-sizing:border-box}.app a{color:inherit;text-decoration:none}.app>aside{width:230px;flex-shrink:0;border-right:1px solid var(--border);background:var(--selection);padding:30px 20px;display:flex;flex-direction:column;gap:9px}.brand{font-size:22px;font-weight:600;margin-bottom:45px}.brand small{font-size:9px;letter-spacing:1px;color:var(--muted-foreground)}.app aside>span{font-size:10px;color:var(--muted-foreground);margin-bottom:10px;padding-left:10px}.app aside>a:not(.brand){display:flex;align-items:center;gap:12px;font-size:12px;padding:12px;border-radius:7px}.app aside>a[aria-current]{background:var(--selection)}.app aside>.account{margin-top:auto;overflow-wrap:anywhere}.app>section{padding:35px 55px;max-width:1250px;width:100%;margin:auto;min-height:100dvh}.app header{display:flex;justify-content:space-between;color:var(--muted-foreground);font-size:10px;letter-spacing:1px}.kicker{font-size:10px;letter-spacing:2px;color:var(--muted-foreground);margin-top:65px}.app h1{font-size:34px;font-weight:500;letter-spacing:-1px;margin:18px 0}.sub{font-size:13px;color:var(--muted-foreground)}.grid{display:grid;grid-template-columns:1fr 1fr;gap:24px;margin-top:35px}.grid>a{background:var(--surface);border:1px solid var(--border);border-radius:12px;padding:26px;transition:transform .2s}.grid>a:hover{transform:translateY(-3px)}.grid small{font-size:9px;letter-spacing:1.5px;margin-left:12px;color:var(--muted-foreground)}.grid>a>svg{vertical-align:middle;width:20px;color:var(--muted-foreground)}.grid h2{font-size:23px;font-weight:500;margin:24px 0 12px}.grid p{font-size:12px;color:var(--muted-foreground);line-height:1.8}.poster,.canvas{height:190px;margin:25px 0;background:#ecf0e3;position:relative;overflow:hidden;border-radius:5px}.poster{padding:25px;font-size:40px;line-height:1;font-weight:600;letter-spacing:-2px}.poster i{font-family:Georgia,serif;font-weight:400}.poster b{position:absolute;right:0;top:12px;font-size:155px;color:#a7b88b}.canvas{display:flex;align-items:center;justify-content:center;gap:12px;background:radial-gradient(var(--selection) 1px,transparent 1px) 0 0/18px 18px,var(--selection);color:var(--muted-foreground)}.canvas span{background:var(--surface);border:1px solid var(--selection-border);padding:20px 13px;font-size:12px;border-radius:6px;color:var(--foreground)}.grid strong{display:flex;justify-content:space-between;font-size:12px;font-weight:400}.agent{display:flex;align-items:center;gap:18px;border:1px solid var(--selection-border);border-radius:10px;padding:23px;margin-top:28px}.agent>svg{color:var(--muted-foreground)}.agent h3{font-size:14px;margin:0 0 8px}.agent p{font-size:11px;color:var(--muted-foreground);margin:0;line-height:1.8}.agent a{margin-left:auto;font-size:12px;white-space:nowrap}.hint{font-size:10px;color:var(--muted-foreground);margin-top:25px}.app a:focus-visible{outline:2px solid var(--ring);outline-offset:4px}@media(max-width:850px){.app>aside{width:180px}.app>section{padding:30px 25px}.grid{gap:15px}.grid>a{padding:20px}.app h1{font-size:28px}.canvas{gap:5px}.canvas span{padding:15px 8px}}@media(max-width:600px){.app{display:block}.app>aside{width:100%;flex-direction:row;flex-wrap:wrap;padding:18px;gap:5px;border-right:0;border-bottom:1px solid var(--border)}.brand{margin:0;width:100%}.app aside>span,.app aside>.account{display:none}.app aside>a:not(.brand){padding:8px;font-size:10px}.grid{grid-template-columns:1fr}.kicker{margin-top:35px}.agent{flex-wrap:wrap}.agent a{margin-left:0}}
|
||||
|
||||
@@ -2,8 +2,8 @@ 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 { loginAccount,verifyAccount,currentAccount,logoutAccount,rateLimit } from '../src/lib/account';
|
||||
export async function verifyEmailAccounts() {
|
||||
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 {
|
||||
@@ -17,6 +17,34 @@ export async function verifyEmailAccounts() {
|
||||
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]);}
|
||||
|
||||
@@ -13,7 +13,7 @@ const request=(method:string,agent_mode:unknown)=>({method,query:{agent_mode}} a
|
||||
|
||||
test('every business handler serves documentation before validating parameters or accessing dependencies',async()=>{
|
||||
const routes:Record<string,string>={
|
||||
'/api/explore-feed':'explore-feed','/api/feed':'feed','/api/notes/{id}':'notes/[id]',
|
||||
'/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',
|
||||
|
||||
@@ -37,7 +37,7 @@ test('proxy exposes creative entry pages while protecting APIs, data and Studio
|
||||
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=['/workspace','/graphic','/infinite_canvas','/studio','/studio/graphic','/studio/infinite_canvas','/studio/api','/studio/_next/static/chunk.js'];
|
||||
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`);
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* Recover once from an idempotent document GET's transient proxy failure.
|
||||
* This never retries hydration, theme assertions, or mutations, and shares one
|
||||
* 60-second budget across both attempts rather than extending the deadline.
|
||||
* @param {(timeout: number) => Promise<{ok(): boolean, status(): number} | null>} navigate
|
||||
* @param {string} url
|
||||
* @param {() => number} now
|
||||
*/
|
||||
export async function navigateBrandDocument(navigate, url, now = Date.now) {
|
||||
const deadline = now() + 60000;
|
||||
for (let attempt = 1; attempt <= 2; attempt++) {
|
||||
const remaining = deadline - now();
|
||||
if (remaining <= 0) throw new Error(`Document navigation budget exhausted: ${url}`);
|
||||
try {
|
||||
const response = await navigate(remaining);
|
||||
if (response?.ok()) return response;
|
||||
const status = response?.status();
|
||||
if (attempt === 1 && [500, 502, 503, 504].includes(status ?? 0)) {
|
||||
console.warn('Brand navigation proxy recovery', JSON.stringify({url, status, attempt}));
|
||||
continue;
|
||||
}
|
||||
throw new Error(`Document navigation failed: ${url}, HTTP ${status ?? 'no response'}`);
|
||||
} catch (error) {
|
||||
if (attempt === 1 && /ECONNRESET|net::ERR_(?:CONNECTION_RESET|CONNECTION_CLOSED|EMPTY_RESPONSE|ABORTED)/.test(String(error))) {
|
||||
console.warn('Brand navigation connection recovery', JSON.stringify({url, error: String(error), attempt}));
|
||||
continue;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
throw new Error(`Document navigation failed: ${url}`);
|
||||
}
|
||||
|
||||
/** Load a document fully before relying on server-rendered text or leaving it.
|
||||
* Only document GETs may recover; application assertions remain outside retry.
|
||||
* @param {import('playwright-core').Page} page
|
||||
* @param {string} url
|
||||
* @param {boolean} reload
|
||||
*/
|
||||
export async function visitBrowserDocument(page, url, reload = false) {
|
||||
return navigateBrandDocument(async timeout => {
|
||||
const started = Date.now();
|
||||
const scripts = new Set(), failures = [];
|
||||
const requested = request => { if (request.resourceType() === 'script') scripts.add(request); };
|
||||
const failed = request => { if (scripts.has(request)) failures.push(request.failure()?.errorText || 'unknown script failure'); };
|
||||
page.on('request', requested);
|
||||
page.on('requestfailed', failed);
|
||||
try {
|
||||
const response = await (reload ? page.reload({waitUntil: 'domcontentloaded', timeout}) : page.goto(url, {waitUntil: 'domcontentloaded', timeout}));
|
||||
if (response?.ok()) {
|
||||
const remaining = timeout - (Date.now() - started);
|
||||
if (remaining <= 0) throw new Error('Document hydration budget exhausted: ' + url);
|
||||
try {
|
||||
// Responsive layouts may hide the toggle. Its mounted ready marker
|
||||
// proves hydration; each caller still checks the controls it needs.
|
||||
// A cancelled script may be a preload duplicate. Hydration is the
|
||||
// authority; recover only when cancellation actually blocks startup.
|
||||
await page.locator('button[data-theme-ready="true"]').first().waitFor({state: 'attached', timeout: failures.length ? Math.min(5000, remaining) : remaining});
|
||||
} catch (error) {
|
||||
if (failures.length) throw new Error('Document bootstrap script request failed: ' + failures.join('; '), {cause: error});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
return response;
|
||||
} finally {
|
||||
page.off('request', requested);
|
||||
page.off('requestfailed', failed);
|
||||
}
|
||||
}, url);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import {test} from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {navigateBrandDocument, visitBrowserDocument} from './brand-navigation.mjs';
|
||||
const response = status => ({ok: () => status === 200, status: () => status});
|
||||
|
||||
test('successful documents are fetched once', async () => {
|
||||
let calls = 0;
|
||||
await navigateBrandDocument(async () => { calls++; return response(200); }, '/canvas');
|
||||
assert.equal(calls, 1);
|
||||
});
|
||||
test('one proxy failure can recover within the original deadline', async () => {
|
||||
const timeouts = []; let elapsed = 0;
|
||||
await navigateBrandDocument(async timeout => {
|
||||
timeouts.push(timeout); elapsed = 15000;
|
||||
return response(timeouts.length === 1 ? 500 : 200);
|
||||
}, '/canvas', () => elapsed);
|
||||
assert.deepEqual(timeouts, [60000, 45000]);
|
||||
});
|
||||
test('connection reset and interrupted bootstrap scripts can recover once', async () => {
|
||||
for(const failure of ['net::ERR_CONNECTION_RESET','Document bootstrap script request failed: net::ERR_ABORTED']){
|
||||
let calls = 0;
|
||||
await navigateBrandDocument(async () => {
|
||||
if (++calls === 1) throw new Error(failure);
|
||||
return response(200);
|
||||
}, '/canvas');
|
||||
assert.equal(calls, 2);
|
||||
}
|
||||
});
|
||||
test('persistent server failure is not hidden', async () => {
|
||||
let calls = 0;
|
||||
await assert.rejects(navigateBrandDocument(async () => { calls++; return response(502); }, '/canvas'), /HTTP 502/);
|
||||
assert.equal(calls, 2);
|
||||
});
|
||||
test('missing routes, authentication failures and ordinary timeouts are not retried', async () => {
|
||||
for (const status of [401, 403, 404]) {
|
||||
let calls = 0;
|
||||
await assert.rejects(navigateBrandDocument(async () => { calls++; return response(status); }, '/canvas'), /HTTP/);
|
||||
assert.equal(calls, 1);
|
||||
}
|
||||
let calls = 0;
|
||||
await assert.rejects(navigateBrandDocument(async () => { calls++; throw new Error('Timeout 60000ms exceeded'); }, '/canvas'), /Timeout/);
|
||||
assert.equal(calls, 1);
|
||||
});
|
||||
test('recovery cannot extend the total navigation budget', async () => {
|
||||
let calls = 0, elapsed = 0;
|
||||
await assert.rejects(navigateBrandDocument(async () => {
|
||||
calls++; elapsed = 60000; return response(503);
|
||||
}, '/canvas', () => elapsed), /budget exhausted/);
|
||||
assert.equal(calls, 1);
|
||||
});
|
||||
|
||||
function browserFixture(failBootstrap = false, failHydration = false, blockedOnce = false, readyVisible = true) {
|
||||
const listeners = new Map(), calls = [];
|
||||
const page = {
|
||||
on(event, fn) { if (!listeners.has(event)) listeners.set(event, new Set()); listeners.get(event).add(fn); },
|
||||
off(event, fn) { listeners.get(event)?.delete(fn); },
|
||||
async goto(url, options) {
|
||||
calls.push(options.waitUntil);
|
||||
const request = {resourceType: () => 'script', failure: () => ({errorText: 'net::ERR_ABORTED'})};
|
||||
for (const fn of listeners.get('request') || []) fn(request);
|
||||
if (failBootstrap && calls.length === 1) for (const fn of listeners.get('requestfailed') || []) fn(request);
|
||||
return response(200);
|
||||
},
|
||||
locator() { return {first: () => ({waitFor: async options => { calls.push('hydrated'); if (!readyVisible && options.state !== 'attached') throw Error('Ready marker is hidden by responsive CSS'); if (failHydration || (blockedOnce && calls.length === 2)) throw Error('Hydration timeout'); }})}; },
|
||||
};
|
||||
return {page, calls, listeners};
|
||||
}
|
||||
test('document visit completes deferred scripts before checking hydration and removes listeners', async () => {
|
||||
const {page, calls, listeners} = browserFixture();
|
||||
await visitBrowserDocument(page, '/account');
|
||||
assert.deepEqual(calls, ['domcontentloaded', 'hydrated']);
|
||||
assert.ok([...listeners.values()].every(set => set.size === 0));
|
||||
});
|
||||
test('interrupted bootstrap retries only document loading before hydration', async () => {
|
||||
const {page, calls} = browserFixture(true, false, true);
|
||||
await visitBrowserDocument(page, '/account');
|
||||
assert.deepEqual(calls, ['domcontentloaded', 'hydrated', 'domcontentloaded', 'hydrated']);
|
||||
});
|
||||
test('hydration failure is surfaced without retrying the application', async () => {
|
||||
const {page, calls} = browserFixture(false, true);
|
||||
await assert.rejects(visitBrowserDocument(page, '/account'), /Hydration timeout/);
|
||||
assert.deepEqual(calls, ['domcontentloaded', 'hydrated']);
|
||||
});
|
||||
|
||||
test('cancelled script does not reject an already hydrated document', async () => {
|
||||
const {page, calls} = browserFixture(true);
|
||||
await visitBrowserDocument(page, '/account');
|
||||
assert.deepEqual(calls, ['domcontentloaded', 'hydrated']);
|
||||
});
|
||||
|
||||
test('hydrated pages are ready even when responsive CSS hides the marker', async () => {
|
||||
const {page, calls} = browserFixture(false, false, false, false);
|
||||
await visitBrowserDocument(page, '/graphic');
|
||||
assert.deepEqual(calls, ['domcontentloaded', 'hydrated']);
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
/** CI-only checks against the disposable stack, never against personal documents. */
|
||||
import assert from 'node:assert/strict';
|
||||
import { chromium, type Request } from 'playwright-core';
|
||||
import { visitBrowserDocument } from './brand-navigation.mjs';
|
||||
export async function verifyBrandUI(base: string) {
|
||||
for (const prefix of process.env.VERIFY_CANVAS_UI === '1' ? ['', '/studio'] : ['']) {
|
||||
const icon = await fetch(`${base}${prefix}/favicon.ico`);
|
||||
assert.equal(icon.status, 200);
|
||||
const bytes = new Uint8Array(await icon.arrayBuffer());
|
||||
assert.deepEqual([...bytes.slice(0, 4)], [0, 0, 1, 0]);
|
||||
assert.equal(bytes[4], 7, 'favicon includes seven resolutions');
|
||||
for (const file of ['logo-light.svg', 'logo-dark.svg', 'icon-dark.svg']) {
|
||||
const image = await fetch(`${base}${prefix}/brand/${file}`);
|
||||
assert.equal(image.status, 200, prefix + '/' + file);
|
||||
assert.match(await image.text(), /<svg/);
|
||||
}
|
||||
}
|
||||
const browser = await chromium.launch({ executablePath: process.env.CHROMIUM_PATH || '/usr/bin/chromium', headless: true, args: ['--no-sandbox', '--disable-dev-shm-usage'] });
|
||||
try {
|
||||
const page = await browser.newPage({ viewport: { width: 1440, height: 960 } });
|
||||
page.setDefaultTimeout(60000);page.setDefaultNavigationTimeout(60000);
|
||||
const failures: string[] = [], scripts: {path:string;status?:number;ms:number;error?:string}[] = [];
|
||||
const scriptStarted = new WeakMap<Request,number>();
|
||||
page.on('request', request => { if(request.resourceType()==='script') scriptStarted.set(request,Date.now()); });
|
||||
page.on('response', response => { if(response.request().resourceType()==='script') { scripts.push({path:new URL(response.url()).pathname,status:response.status(),ms:Date.now()-(scriptStarted.get(response.request())??Date.now())}); if(scripts.length>24)scripts.shift(); } });
|
||||
page.on('pageerror', error => failures.push(error.message));
|
||||
page.on('requestfailed', request => { failures.push(new URL(request.url()).pathname + ': ' + request.failure()?.errorText); if(request.resourceType()==='script') scripts.push({path:new URL(request.url()).pathname,ms:Date.now()-(scriptStarted.get(request)??Date.now()),error:request.failure()?.errorText}); });
|
||||
const visit = async (path: string, theme: string) => {
|
||||
const started = Date.now();
|
||||
try {
|
||||
await visitBrowserDocument(page, base + path);
|
||||
await page.waitForFunction(expected => document.documentElement.dataset.theme === expected, theme);
|
||||
console.log('Brand UI route ready:', path, theme, Date.now() - started);
|
||||
} catch (error) {
|
||||
console.error('Brand UI navigation diagnostics', JSON.stringify({path, url:page.url(), elapsedMs:Date.now()-started, failures:failures.slice(-20), scripts:scripts.slice(-24), controls:await page.locator('[data-theme-ready]').evaluateAll(elements=>elements.map(el=>({ready:el.getAttribute('data-theme-ready'),display:getComputedStyle(el).display}))), body:(await page.locator('body').innerText({timeout:3000}).catch(()=>'unavailable')).slice(0,1200)}));
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
const background = (selector: string) => page.locator(selector).first().evaluate(el => getComputedStyle(el).backgroundColor);
|
||||
await visit('/', 'home-light');
|
||||
assert.equal(await background('body'), 'rgb(255, 246, 239)');
|
||||
assert.ok((await page.title()).includes('啡跃'));
|
||||
assert.ok((await page.locator('link[rel="icon"]').all()).length >= 2);
|
||||
assert.equal(await page.locator('.endoleap-mark-light').first().isVisible(), true);
|
||||
await page.getByRole('button', { name: '切换明暗主题', exact: true }).first().click();
|
||||
await page.waitForFunction(() => document.documentElement.dataset.theme === 'home-dark');
|
||||
assert.equal(await background('body'), 'rgb(28, 24, 22)');
|
||||
assert.equal(await page.locator('.endoleap-mark-dark').first().isVisible(), true);
|
||||
await visit('/account', 'home-dark');
|
||||
if (process.env.VERIFY_CANVAS_UI === '1') {
|
||||
await visit('/graphic', 'canvas-dark');
|
||||
assert.equal(await background('body'), 'rgb(20, 20, 20)');
|
||||
assert.equal(await page.locator('[class*="boardWrap"] > div').first().evaluate(el => getComputedStyle(el).backgroundColor), 'rgb(255, 255, 255)', 'artwork stays white in dark chrome');
|
||||
await page.getByRole('button', { name: '切换明暗主题', exact: true }).first().click();
|
||||
await page.waitForFunction(() => document.documentElement.dataset.theme === 'canvas-light');
|
||||
await visit('/infinite_canvas', 'canvas-light');
|
||||
assert.equal(await background('[class*="viewport"]'), 'rgb(250, 250, 250)');
|
||||
} else {
|
||||
await page.getByRole('button', { name: '切换明暗主题', exact: true }).first().click();
|
||||
}
|
||||
await visit('/', 'home-light');
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
assert.equal(await page.locator('.endoleap-mark-light').first().isVisible(), true);
|
||||
assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1), 'mobile home has no horizontal overflow');
|
||||
for (const width of [390, 320]) {
|
||||
await page.setViewportSize({ width, height: 844 });
|
||||
assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1), 'Editorial home fits narrow screens');
|
||||
const entry = await page.getByRole('navigation', {name: '主导航'}).getByRole('link', {name: /进入工作台/}).boundingBox();
|
||||
assert.ok(entry && entry.x >= 0 && entry.x + entry.width <= width, 'Workspace entry is visible, not clipped');
|
||||
assert.equal(await page.getByRole('link', {name: /去知识空间逛逛/}).getAttribute('href'), '/documents');
|
||||
assert.equal(await page.getByRole('link', {name: '开始创作', exact: true}).getAttribute('href'), '/account?next=/workspace');
|
||||
}
|
||||
console.log('Endoleap logos, both favicon paths, persisted home/canvas themes and artwork color isolation passed.');
|
||||
} finally { await browser.close(); }
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {randomUUID} from 'node:crypto';
|
||||
import {db} from '../src/lib/db';
|
||||
import {newToken,tokenDigest,ACCOUNT_COOKIE} from '../src/lib/account-crypto';
|
||||
import type {CanvasDocument} from '../src/lib/canvas-document';
|
||||
export async function verifyCanvasWorkspace(base:string){
|
||||
const identities=await Promise.all([0,1].map(async()=>{const id=randomUUID(),token=newToken();await db().query("INSERT INTO account_users(id,email,password_hash,verified_at) VALUES($1,$2,'test',now())",[id,id+'@example.com']);await db().query("INSERT INTO account_tokens(digest,user_id,kind,expires_at) VALUES($1,$2,'session',now()+interval '1 hour')",[tokenDigest(token),id]);return {[`cookie`]:ACCOUNT_COOKIE+'='+token}}));
|
||||
const origin=process.env.ACCOUNT_PUBLIC_ORIGIN||base;
|
||||
const call=(path:string,method='GET',body?:unknown,headers:Record<string,string>={})=>fetch(base+'/api/canvases'+path,{method,headers:{'Content-Type':'application/json',origin,...headers},...(body===undefined?{}:{body:JSON.stringify(body)})});
|
||||
const doc={version:1,cards:[{id:'note',kind:'text',title:'简报',text:'自然光与城市清晨',x:0,y:0},{id:'script',kind:'script',title:'脚本',text:'',instruction:'写六个分镜',operation:'script',x:500,y:0}],links:[['note','script']]};
|
||||
assert.equal((await call('','POST',{document:doc})).status,401);
|
||||
const create=await call('','POST',{document:doc},identities[0]);assert.equal(create.status,201,await create.clone().text());const canvas=await create.json();assert.equal(canvas.revision,1);
|
||||
const root='/'+canvas.id;
|
||||
assert.equal((await call(root,'GET',undefined,identities[1])).status,404);
|
||||
assert.equal((await call(root,'PUT',{revision:1,document:doc},{...identities[0],origin:'https://evil.example'})).status,403);
|
||||
assert.equal((await call(root,'PUT',{revision:1,document:doc},{...identities[0],authorization:'Bearer invalid',origin:'https://evil.example'})).status,404,'A bearer request cannot fall back to an owner cookie');
|
||||
assert.equal((await call(root,'PUT',{revision:1,document:{...doc,links:[['note','script'],['script','note']]}},identities[0])).status,400,'Imported cyclic graphs are rejected');
|
||||
const linked=await call(root+'/link','POST',{},identities[0]);assert.equal(linked.status,201);const credentials=await linked.json();const agent={authorization:'Bearer '+credentials.token};
|
||||
assert.equal((await call('','GET',undefined,agent)).status,401);
|
||||
const other=await (await call('','POST',{document:doc},identities[0])).json();assert.equal((await call('/'+other.id,'GET',undefined,agent)).status,404,'A link cannot read another canvas');
|
||||
assert.equal((await call(root,'PUT',{revision:1,document:doc},identities[0])).status,200);
|
||||
assert.equal((await call(root,'PUT',{revision:1,document:doc},identities[0])).status,409);
|
||||
async function queue(rev:number){const r=await call(root+'/tasks','POST',{revision:rev,targetId:'script'},identities[0]);assert.equal(r.status,201,await r.clone().text());return (await r.json()).id as string}
|
||||
const concurrent=await Promise.all([0,1].map(()=>call('/'+other.id,'PUT',{revision:1,document:doc},identities[0])));
|
||||
assert.deepEqual(concurrent.map(r=>r.status).sort(),[200,409],'Concurrent saves cannot lose an update');
|
||||
const first=await queue(2);
|
||||
assert.equal((await call(root+'/tasks','POST',{revision:2,targetId:'script'},identities[0])).status,409,'Only one live task per target');
|
||||
const recipe=await (await call(root+'/tasks/'+first,'GET',undefined,agent)).json();assert.equal(recipe.request.references[0].text,doc.cards[0].text);assert.equal(recipe.request.instruction,'写六个分镜');
|
||||
assert.equal((await call(root+'/tasks/'+first,'GET',undefined,identities[1])).status,404);
|
||||
const result={status:'succeeded',result:{text:'第一镜:城市醒来。'}};
|
||||
assert.equal((await call(root+'/tasks/'+first,'PATCH',result,agent)).status,409,'Result cannot arrive before claim');
|
||||
assert.equal((await call(root+'/tasks/'+first,'PATCH',{status:'running'},agent)).status,200);
|
||||
assert.equal((await call(root,'PUT',{revision:2,document:{...doc,cards:doc.cards.map(n=>({...n,x:n.x+10}))}},identities[0])).status,200);
|
||||
assert.equal((await call(root+'/tasks/'+first,'PATCH',result,agent)).status,409,'Stale result must not overwrite newer user edits');
|
||||
assert.equal((await call(root+'/tasks/'+first,'PATCH',{status:'failed',error:'依据新版本重新派发'},identities[0])).status,200);
|
||||
const second=await queue(3);assert.equal((await call(root+'/tasks/'+second,'PATCH',{status:'running'},agent)).status,200);
|
||||
assert.equal((await call(root+'/tasks/'+second,'PATCH',{status:'succeeded',result:{text:{invalid:true}}},agent)).status,400,'Malformed results do not mutate the canvas');
|
||||
assert.equal((await call(root+'/tasks/'+second,'PATCH',{status:'succeeded',result:{image:'data:image/png;base64,AAAA'}},agent)).status,400,'A script node cannot accept an image result');
|
||||
assert.equal((await call(root+'/tasks/'+second,'PATCH',result,agent)).status,200);
|
||||
assert.equal((await call(root+'/tasks/'+second,'PATCH',result,agent)).status,200,'Identical completion is idempotent');
|
||||
assert.equal((await call(root+'/tasks/'+second,'PATCH',{status:'succeeded',result:{text:'覆盖'}},agent)).status,409);
|
||||
const final=await (await call(root,'GET',undefined,identities[0])).json();assert.equal(final.revision,4);assert.equal(final.document.cards.find((n:{id:string})=>n.id==='script').text,'第一镜:城市醒来。');assert.deepEqual(final.document.links,doc.links);
|
||||
const png='data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+ip1sAAAAASUVORK5CYII=';
|
||||
const imageDocument:CanvasDocument={version:1,cards:[
|
||||
{id:'source',kind:'image',title:'参考图',text:'保留原图',image:png,x:0,y:0},
|
||||
{id:'generated',kind:'image',title:'图生图',text:'',instruction:'保留构图,改为雪夜',operation:'generate',x:500,y:0},
|
||||
{id:'upscaled',kind:'image',title:'图片高清',text:'',operation:'upscale',x:1000,y:0},
|
||||
],links:[['source','generated'],['generated','upscaled']]};
|
||||
const imageCreate=await call('','POST',{document:imageDocument},identities[0]);assert.equal(imageCreate.status,201);
|
||||
const imageCanvas=await imageCreate.json(),imageRoot='/'+imageCanvas.id;
|
||||
const imageLink=await call(imageRoot+'/link','POST',{},identities[0]);assert.equal(imageLink.status,201);
|
||||
const imageAgent={authorization:'Bearer '+(await imageLink.json()).token};
|
||||
for(const [index,targetId] of ['generated','upscaled'].entries()){
|
||||
const queued=await call(imageRoot+'/tasks','POST',{revision:index+1,targetId},identities[0]);assert.equal(queued.status,201,await queued.clone().text());
|
||||
const taskId=(await queued.json()).id,taskPath=imageRoot+'/tasks/'+taskId;
|
||||
const snapshotResponse=await call(taskPath,'GET',undefined,imageAgent);assert.equal(snapshotResponse.status,200);
|
||||
const snapshot=await snapshotResponse.json();assert.equal(snapshot.request.operation,index===0?'generate':'upscale');
|
||||
assert.equal(snapshot.request.references[0].id,index===0?'source':'generated');
|
||||
assert.equal(snapshot.request.references[0].image,png,'Image tasks carry the referenced image bytes');
|
||||
assert.deepEqual(snapshot.request.result.allowedFields,['image','text']);
|
||||
assert.equal((await call(taskPath,'PATCH',{status:'running'},imageAgent)).status,200);
|
||||
const completion={status:'succeeded',result:{image:png,text:index===0?'雪夜图片结果':'高清图片结果'}};
|
||||
const completed=await call(taskPath,'PATCH',completion,imageAgent);assert.equal(completed.status,200,await completed.clone().text());
|
||||
assert.equal((await call(taskPath,'PATCH',completion,imageAgent)).status,200,'Image completion retries are idempotent');
|
||||
const stateResponse=await call(imageRoot,'GET',undefined,identities[0]);assert.equal(stateResponse.status,200);
|
||||
const state=await stateResponse.json();assert.equal(state.revision,index+2);
|
||||
const cards=(state.document as CanvasDocument).cards;
|
||||
assert.equal(cards.find(n=>n.id===targetId)?.image,png);assert.equal(cards.find(n=>n.id===targetId)?.text,completion.result.text);
|
||||
assert.deepEqual(cards.find(n=>n.id==='source'),imageDocument.cards[0],'Generation preserves the reference node');
|
||||
assert.deepEqual(state.document.links,imageDocument.links,'Image results preserve the reference graph');
|
||||
if(index===0)assert.deepEqual(cards.find(n=>n.id==='upscaled'),imageDocument.cards[2],'Generation only updates its target');
|
||||
else assert.equal(cards.find(n=>n.id==='generated')?.text,'雪夜图片结果','Upscaling preserves the previous generated image');
|
||||
}
|
||||
assert.equal((await call(imageRoot+'/link','DELETE',{},identities[0])).status,200);
|
||||
assert.equal((await call(imageRoot,'GET',undefined,imageAgent)).status,404);
|
||||
assert.equal((await call(root+'/link','POST',{},agent)).status,403,'A delegated agent cannot mint links');
|
||||
assert.equal((await call(root+'/link','DELETE',{},identities[0])).status,200);
|
||||
assert.equal((await call(root,'GET',undefined,agent)).status,404,'Revocation takes effect on the next request');
|
||||
const rotated=await (await call(root+'/link','POST',{},identities[0])).json();
|
||||
const expired={authorization:'Bearer '+rotated.token};
|
||||
await db().query("UPDATE canvas_agent_links SET expires_at=now()-interval '1 second' WHERE canvas_id=$1",[canvas.id]);
|
||||
assert.equal((await call(root,'GET',undefined,expired)).status,404,'Expired capabilities are rejected');
|
||||
const rotations=await Promise.all([call(root+'/link','POST',{},identities[0]),call(root+'/link','POST',{},identities[0])]);
|
||||
assert.ok(rotations.every(r=>r.status===201));
|
||||
assert.equal((await db().query('SELECT count(*)::int AS count FROM canvas_agent_links WHERE canvas_id=$1',[canvas.id])).rows[0].count,1,'Concurrent rotation leaves one active capability');
|
||||
const docs=await call(root+'?agent_mode=True');assert.equal(docs.status,200);assert.match(await docs.text(),/Authorization/);
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import { visitBrowserDocument } from "./brand-navigation.mjs";
|
||||
import assert from 'node:assert/strict';
|
||||
import {randomUUID} from 'node:crypto';
|
||||
import {chromium,type Request} from 'playwright-core';
|
||||
import {db} from '../src/lib/db';
|
||||
import {hashPassword,ACCOUNT_COOKIE} from '../src/lib/account-crypto';
|
||||
|
||||
export async function verifyCanvasAgentUI(base:string){
|
||||
assert.equal(process.env.PROMPT_INTEGRATION_TEST,'1');
|
||||
const owner=randomUUID(),email=owner+'@example.com',password='Canvas integration example1!';
|
||||
await db().query("INSERT INTO account_users(id,email,password_hash,verified_at) VALUES($1,$2,$3,now())",[owner,email,await hashPassword(password)]);
|
||||
const browser=await chromium.launch({executablePath:process.env.CHROMIUM_PATH||'/usr/bin/chromium',headless:true,args:['--no-sandbox','--disable-dev-shm-usage']});
|
||||
const context=await browser.newContext({viewport:{width:1440,height:1000}}),errors:string[]=[];
|
||||
const page=await context.newPage(),canvasRequests:string[]=[],pending=new Set<Request>(),network:string[]=[];
|
||||
const started=Date.now(),navigation:{ms:number;event:string;path:string}[]=[];
|
||||
const mark=(event:string)=>{navigation.push({ms:Date.now()-started,event,path:new URL(page.url()).pathname});if(navigation.length>60)navigation.shift()};
|
||||
page.on('framenavigated',frame=>{if(frame===page.mainFrame())mark('framenavigated')});
|
||||
page.on('domcontentloaded',()=>mark('domcontentloaded'));page.on('load',()=>mark('load'));
|
||||
const requestName=(request:{method:()=>string;url:()=>string})=>request.method()+' '+new URL(request.url()).pathname;
|
||||
page.on('request',request=>pending.add(request));
|
||||
page.on('requestfinished',request=>pending.delete(request));
|
||||
page.on('requestfailed',request=>{pending.delete(request);network.push(requestName(request)+': '+request.failure()?.errorText)});
|
||||
page.on('response',response=>{const url=new URL(response.url());if(url.pathname.startsWith('/api/canvases'))canvasRequests.push(`${response.request().method()} ${url.pathname} ${response.status()}`)});
|
||||
try{
|
||||
page.setDefaultTimeout(60000);page.setDefaultNavigationTimeout(60000);page.on('pageerror',e=>errors.push(e.message));
|
||||
mark('open landing');await visitBrowserDocument(page,base+'/');
|
||||
await page.getByRole('heading',{name:/想法不该停在对话里/,level:1}).waitFor();
|
||||
const accountSession=page.waitForResponse(response=>new URL(response.url()).pathname==='/api/account/session'&&response.request().method()==='GET');
|
||||
const [,sessionResponse]=await Promise.all([page.getByRole('link',{name:'开始创作',exact:true}).click(),accountSession]);
|
||||
assert.equal(sessionResponse.status(),200);assert.equal((await sessionResponse.json()).user,null);
|
||||
await page.getByRole('heading',{name:'欢迎回来',exact:true,level:2}).waitFor();
|
||||
await page.locator('button[data-theme-ready="true"]').first().waitFor();
|
||||
mark('login form ready');
|
||||
await page.getByLabel('邮箱地址',{exact:true}).fill(email);
|
||||
await page.getByLabel('密码',{exact:true}).fill(password);
|
||||
mark('before login click');await page.locator('form').getByRole('button',{name:'登录',exact:true}).click();
|
||||
mark('after login click');
|
||||
await page.waitForURL(url=>url.pathname==='/workspace',{waitUntil:'commit'});
|
||||
mark('workspace URL accepted');
|
||||
await page.getByRole('heading',{name:'今天,让哪个想法成形?',exact:true}).waitFor();
|
||||
await page.locator('button[data-theme-ready="true"]').first().waitFor();
|
||||
mark('workspace rendered');
|
||||
const session=(await context.cookies()).find(c=>c.name===ACCOUNT_COOKIE);assert.ok(session?.httpOnly,'The actual login response establishes an HTTP-only account session');
|
||||
const ownerHeaders={cookie:ACCOUNT_COOKIE+'='+session.value};
|
||||
const canvasLink=page.getByRole('link',{name:'无限画布',exact:true});
|
||||
assert.equal(await canvasLink.getAttribute('href'),'/infinite_canvas');
|
||||
await visitBrowserDocument(page,base+'/infinite_canvas');
|
||||
await page.getByRole('link',{name:email,exact:true}).waitFor({state:'attached'});
|
||||
await page.getByRole('button',{name:'从示例开始 ↗',exact:true}).click();
|
||||
const shot=page.locator('[data-node-id="shot"] [data-node-content]');
|
||||
await shot.click();await shot.click();
|
||||
const composer=page.getByRole('region',{name:'节点编辑器',exact:true});
|
||||
await composer.getByRole('textbox',{name:'创作要求',exact:true}).fill('根据参考资料写六个分镜');
|
||||
await composer.getByRole('button',{name:'交给 Agent',exact:true}).click();
|
||||
const panel=page.getByRole('region',{name:'Agent 连接',exact:true});
|
||||
await panel.getByText('任务已创建,等待你的 coding agent 领取',{exact:true}).waitFor();
|
||||
const list=await fetch(base+'/api/canvases',{headers:ownerHeaders}).then(r=>r.json());assert.equal(list.canvases.length,1);
|
||||
const root='/api/canvases/'+list.canvases[0].id;
|
||||
const state=await fetch(base+root,{headers:ownerHeaders}).then(r=>r.json());assert.equal(state.tasks.length,1);assert.equal(state.tasks[0].status,'queued');
|
||||
await panel.getByRole('button',{name:'生成连接凭据',exact:true}).click();
|
||||
const credentials=panel.getByRole('textbox',{name:'Agent 连接凭据',exact:true});await credentials.waitFor();
|
||||
await panel.getByRole('button',{name:'复制给 coding agent',exact:true}).click();
|
||||
await panel.getByRole('status').filter({hasText:/凭据已复制|已选中凭据/}).waitFor();
|
||||
const first=JSON.parse(await credentials.inputValue());assert.equal(first.canvasUrl,base+root);
|
||||
const agentHeaders={'Content-Type':'application/json',authorization:first.authorization};
|
||||
const task=root+'/tasks/'+state.tasks[0].id;
|
||||
const recipe=await fetch(base+task,{headers:agentHeaders}).then(r=>r.json());assert.equal(recipe.request.instruction,'根据参考资料写六个分镜');assert.equal(recipe.request.references[0].id,'reference');
|
||||
const patch=async(body:unknown)=>fetch(base+task,{method:'PATCH',headers:agentHeaders,body:JSON.stringify(body)});
|
||||
assert.equal((await patch({status:'running'})).status,200);
|
||||
await panel.getByText(/Agent 正在处理/).waitFor();
|
||||
// This fixture verifies the external-client protocol and UI delivery, not model quality.
|
||||
let generated='第一镜:晨光穿过窗边的植物。';
|
||||
assert.equal((await patch({status:'succeeded',result:{text:generated}})).status,200);
|
||||
await page.locator('[data-node-id="shot"] [data-node-content]').getByText(generated,{exact:true}).waitFor();
|
||||
const updated=await fetch(base+root,{headers:ownerHeaders}).then(r=>r.json());
|
||||
assert.equal(updated.document.cards.find((n:{id:string})=>n.id==='shot').instruction,'根据参考资料写六个分镜');
|
||||
await panel.getByRole('button',{name:'关闭 Agent 连接',exact:true}).click();
|
||||
await shot.click();await shot.click();
|
||||
await composer.getByRole('textbox',{name:'创作要求',exact:true}).fill('再写一个新镜头');
|
||||
const queued=page.waitForResponse(r=>r.request().method()==='POST'&&new URL(r.url()).pathname===root+'/tasks');
|
||||
await composer.getByRole('button',{name:'交给 Agent',exact:true}).click();
|
||||
const queuedResponse=await queued;assert.equal(queuedResponse.status(),201);const secondTask=await queuedResponse.json();
|
||||
await panel.getByRole('button',{name:'关闭 Agent 连接',exact:true}).click();
|
||||
const brief=page.locator('[data-node-id="brief"] [data-node-content]');await brief.click();await brief.click();
|
||||
const localText=page.getByRole('textbox',{name:'01 / 创意简报 内容',exact:true});await localText.fill('用户尚未同步的本机修改');
|
||||
const nextTask=root+'/tasks/'+secondTask.id;
|
||||
const nextPatch=(body:unknown)=>fetch(base+nextTask,{method:'PATCH',headers:agentHeaders,body:JSON.stringify(body)});
|
||||
assert.equal((await nextPatch({status:'running'})).status,200);
|
||||
generated='第二镜:城市在雨后醒来。';
|
||||
assert.equal((await nextPatch({status:'succeeded',result:{text:generated}})).status,200);
|
||||
await page.getByRole('button',{name:'Agent · 在线画布',exact:true}).click();
|
||||
await panel.getByRole('button',{name:'载入在线版本',exact:true}).waitFor();
|
||||
assert.equal(await localText.inputValue(),'用户尚未同步的本机修改','Polling must not replace unsaved local work');
|
||||
await panel.getByRole('button',{name:'载入在线版本',exact:true}).click();
|
||||
await page.locator('[data-node-id="shot"] [data-node-content]').getByText(generated,{exact:true}).waitFor();
|
||||
assert.equal(await page.locator('[data-node-id="brief"] [data-node-content]').textContent(),state.document.cards.find((n:{id:string})=>n.id==='brief').text);
|
||||
await panel.getByRole('button',{name:'生成连接凭据',exact:true}).click();
|
||||
await page.waitForFunction(old=>{const field=document.querySelector<HTMLTextAreaElement>('textarea[aria-label="Agent 连接凭据"]');return !!field&&JSON.parse(field.value).authorization!==old},first.authorization);
|
||||
const second=JSON.parse(await credentials.inputValue());assert.equal((await fetch(base+root,{headers:agentHeaders})).status,404);
|
||||
await panel.getByRole('button',{name:'撤销连接',exact:true}).click();
|
||||
await panel.getByText('Agent 访问已撤销',{exact:true}).waitFor();
|
||||
assert.equal((await fetch(base+root,{headers:{authorization:second.authorization}})).status,404);
|
||||
await page.setViewportSize({width:390,height:844});
|
||||
const bounds=await panel.boundingBox();assert.ok(bounds&&bounds.x>=0&&bounds.x+bounds.width<=390,'Agent panel stays within the mobile viewport');
|
||||
await visitBrowserDocument(page,page.url(),true);
|
||||
await page.getByRole('link',{name:email,exact:true,includeHidden:true}).waitFor({state:'attached'});
|
||||
await page.getByRole('button',{name:'Agent',exact:true}).click();
|
||||
const reopened=page.getByRole('region',{name:'Agent 连接',exact:true});
|
||||
await reopened.getByRole('combobox',{name:'打开在线画布',exact:true}).selectOption(list.canvases[0].id);
|
||||
await reopened.getByRole('button',{name:'载入在线版本',exact:true}).click();
|
||||
await page.locator('[data-node-id="shot"] [data-node-content]').getByText(generated,{exact:true}).waitFor();
|
||||
assert.deepEqual(errors,[]);
|
||||
console.log('Canvas navigation timeline',JSON.stringify(navigation));
|
||||
console.log('Canvas UI dispatched a real stored task, received an external-client fixture result, rotated/revoked access and restored its online document');
|
||||
}catch(error){
|
||||
const statuses=await page.getByRole('status').allTextContents().catch(()=>[]);
|
||||
const headings=await page.getByRole('heading').allTextContents().catch(()=>[]);
|
||||
const documentState=await page.evaluate(()=>({readyState:document.readyState,navigation:performance.getEntriesByType('navigation').map(entry=>{const timing=entry as PerformanceNavigationTiming;return {type:timing.type,domContentLoadedEventEnd:timing.domContentLoadedEventEnd,loadEventEnd:timing.loadEventEnd}})})).catch(()=>null);
|
||||
console.error('Canvas agent UI diagnostics',JSON.stringify({url:page.url(),browserConnected:browser.isConnected(),headings,statuses,documentState,navigation,pending:[...pending].map(requestName),network:network.slice(-20),canvasRequests:canvasRequests.slice(-20),errors}));
|
||||
throw error;
|
||||
}finally{await context.close();await browser.close()}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { visitBrowserDocument } from "./brand-navigation.mjs";
|
||||
import assert from 'node:assert/strict';
|
||||
import {verifyGuidanceWorkspaceUI} from './guidance-workspace-ui';
|
||||
import {chromium} from 'playwright-core';
|
||||
@@ -35,7 +36,7 @@ export async function verifyGuidancePublishing(base:string){
|
||||
// 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();
|
||||
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));
|
||||
@@ -46,30 +47,33 @@ export async function verifyGuidancePublishing(base:string){
|
||||
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 page.goto(base+'/documents',{waitUntil:'domcontentloaded'});record('directory HTTP '+navigation?.status());
|
||||
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);
|
||||
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 page.reload({waitUntil:'domcontentloaded'});
|
||||
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');
|
||||
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==='');
|
||||
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');
|
||||
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 page.goto(base+'/write',{waitUntil:'domcontentloaded'});assert.equal(await page.locator('h1').textContent(),'发布独立文档');
|
||||
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);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/** 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";
|
||||
@@ -27,9 +28,11 @@ export async function verifyGuidanceWorkspaceUI(base: string, browser: Browser)
|
||||
requestedAuthor: string | null = null,
|
||||
published: Guidance | null = null;
|
||||
const errors: string[] = [];
|
||||
let sessionReplies = 0;
|
||||
try {
|
||||
await context.route("**/api/account/session", (route) =>
|
||||
route.fulfill({
|
||||
await context.route("**/api/account/session", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
json: {
|
||||
user: signedIn
|
||||
? {
|
||||
@@ -39,8 +42,9 @@ export async function verifyGuidanceWorkspaceUI(base: string, browser: Browser)
|
||||
}
|
||||
: null,
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
sessionReplies += 1;
|
||||
});
|
||||
await context.route(
|
||||
(url) => url.pathname === "/api/documents",
|
||||
async (route) => {
|
||||
@@ -86,7 +90,8 @@ export async function verifyGuidanceWorkspaceUI(base: string, browser: Browser)
|
||||
route.fulfill({ json: { document: authored } }),
|
||||
);
|
||||
const page = await context.newPage();
|
||||
page.setDefaultTimeout(10000);
|
||||
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[] = [];
|
||||
@@ -94,11 +99,17 @@ export async function verifyGuidanceWorkspaceUI(base: string, browser: Browser)
|
||||
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 session = page.waitForResponse(response => new URL(response.url()).pathname === "/api/account/session" && response.status() === 200);
|
||||
const previousSessionReplies = sessionReplies;
|
||||
try {
|
||||
const [navigation] = await Promise.all([reload ? page.reload({waitUntil:"domcontentloaded"}) : page.goto(url,{waitUntil:"domcontentloaded"}),session]);
|
||||
const navigation = await visitBrowserDocument(page, url, reload);
|
||||
assert.ok(navigation?.ok(), "Navigation returns a successful document");
|
||||
await page.getByText(signedIn ? "editor@example.com" : "登录你的账户",{exact:true}).first().waitFor({state:"attached"});
|
||||
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}));
|
||||
@@ -111,7 +122,7 @@ export async function verifyGuidanceWorkspaceUI(base: string, browser: Browser)
|
||||
await page
|
||||
.getByRole("heading", { name: authored.title, exact: true })
|
||||
.waitFor();
|
||||
assert.equal(await page.title(), "社区文档 · 织页");
|
||||
assert.equal(await page.title(), "社区文档 · 啡跃");
|
||||
const search = page.getByRole("textbox", { name: "搜索文档" });
|
||||
await search.fill("不存在的文档");
|
||||
await search.press("Enter");
|
||||
@@ -120,6 +131,7 @@ export async function verifyGuidanceWorkspaceUI(base: string, browser: Browser)
|
||||
.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 })
|
||||
@@ -149,6 +161,29 @@ export async function verifyGuidanceWorkspaceUI(base: string, browser: Browser)
|
||||
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
|
||||
@@ -166,6 +201,26 @@ export async function verifyGuidanceWorkspaceUI(base: string, browser: Browser)
|
||||
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;
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
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';
|
||||
@@ -20,18 +23,28 @@ import {verifyFlatNoteMigration} from './flat-note-migration';
|
||||
import {staticEntries} from '../src/lib/explore';
|
||||
import {publishLearningSeries} from '../src/lib/publish-learning-series';
|
||||
|
||||
test('real PostgreSQL + S3 notes, flat tags, resource sharing and video processing', {timeout:300000}, async()=>{
|
||||
// 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/server.js'],{env:{...process.env,PORT:String(port),HOSTNAME:'127.0.0.1'},stdio:'inherit'});
|
||||
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);
|
||||
await verifyEmailAccounts();
|
||||
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'});
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
# Added only by isolated CI integration; no production ports or volumes.
|
||||
services:
|
||||
prompt-studio:
|
||||
image: ${STUDIO_REVIEW_IMAGE:?Build the Studio review image first}
|
||||
init: true
|
||||
environment:
|
||||
HOSTNAME: 0.0.0.0
|
||||
PORT: "3000"
|
||||
STUDIO_WIKI_ORIGIN: http://waterfall-wiki:3000
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
cap_drop: [ALL]
|
||||
healthcheck:
|
||||
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3000/studio/api/health').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))"]
|
||||
interval: 5s
|
||||
timeout: 8s
|
||||
retries: 24
|
||||
tests:
|
||||
environment:
|
||||
VERIFY_CANVAS_UI: "1"
|
||||
@@ -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导出代替视觉验收。
|
||||
@@ -4,13 +4,13 @@
|
||||
|
||||
`ces-control` 必须预装 Docker、Compose,并允许 Runner 访问持久 Docker daemon。CI 只检查环境,不安装系统包、不启停主机服务。当前服务器没有 buildx,先继续使用 daemon 自带层缓存;不要直接粘贴依赖 GitHub cache 服务的 `type=gha` 参数。后续若换 Docker daemon 或清理缓存,会重新冷构建。
|
||||
|
||||
Dockerfile 保持单次 COPY,`.dockerignore` 排除非构建输入;根 Markdown 和 docs 不再使应用构建层失效。只有 main 的 push 事件触发 CI/CD(对应 PR 合并后的主线更新),工作分支 push、PR 创建/更新和手动 dispatch 均不触发;三个应用均有同样的作业级守卫。main 对纯文档改动使用 paths,但 public 下的 Markdown 属于产品资源,必须构建部署。public 下的技能 Markdown 继续纳入构建。旧 builder 每条 COPY 实测约 47 秒,避免拆成多条 COPY 增加快照成本。完整路径保留类型检查、单元测试、smoke 与完整隔离栈集成测试。
|
||||
Dockerfile 保持单次 COPY,`.dockerignore` 排除非构建输入;根 Markdown 和 docs 不再使应用构建层失效。发布由 main 上匹配路径的 push 触发。creative-workspace.yml 另对匹配路径的 PR 执行远程评审检查,包括隔离集成与已部署页面验收。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 后执行;依赖构建镜像缺失也回退完整流水线。
|
||||
main 的 push 与当前已部署 SHA 比较;只有页面、组件、样式、技能文档数据和 public 改动才走 Web 路径,API、共享库、依赖、配置、worker、脚本及数据库变化均回退完整流水线。本地只做差异审查与轻量检查,匹配路径的 PR 在 ces-control 执行远程检查;依赖构建镜像缺失也回退完整流水线。
|
||||
|
||||
Web 路径复用当前 runtime 对应的已验证 integration 镜像作为工具环境,在临时容器构建生产 Web,不运行 docker build。编译缓存按基础镜像隔离持久保存。类型检查、单元测试和 Web smoke 仍同步执行,完整隔离栈测试只在全量路径执行。
|
||||
|
||||
|
||||
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,38 +0,0 @@
|
||||
# PROMPT monorepo
|
||||
|
||||
- `apps/waterfall-wiki`: existing Pages Router application, media library, PostgreSQL, object storage and video processing worker. `/wiki/Namespace:slug` has matching `/api/wiki/Namespace:slug` Markdown; `/api/wiki-index` discovers pages. Existing `/p/slug` has `/api/wiki-post/slug` and alternate metadata. Main/File/Category/Template/Draft/Help are public content types, not permission boundaries. Categories cross namespaces; template pages currently describe reusable structures rather than a transclusion engine.
|
||||
- `apps/studio`: independent Next.js + TypeScript + Tailwind Pages Router. Add ideas in `src/content/ideas.ts`, open `/ideas/slug`, export Markdown or versioned JSON, or print to PDF. Add custom React pages under `src/pages` when a composition needs bespoke rendering.
|
||||
- `apps/python`: install with `pip install -e apps/python`. Run `prompt-export idea.json --output ./output --font /path/to/cjk.ttf [--video]`. Generates 1080×1440 PNGs and an optional silent MP4 (4 seconds per slide), requiring FFmpeg. JSON describes standard slides; arbitrary React animations need a separate browser recording pipeline. Python Docker image includes CJK fonts and FFmpeg.
|
||||
|
||||
## Development
|
||||
|
||||
Node 24, pnpm 11.25.0, Python 3.12+. `pnpm install`; `pnpm dev:waterfall-wiki`; `pnpm dev:studio`. Do not use local preview for acceptance; see root AGENTS.md. Each JS application owns its frozen pnpm lockfile. Production Docker builds install each project independently.
|
||||
|
||||
## Releases
|
||||
|
||||
Three path-filtered Gitea workflows run on ces-control. Changes to one app trigger that app only. Workspace configuration changes intentionally verify both JS applications. Documentation changes do not deploy. Each workflow can be manually dispatched for recovery after a failed release. All app content, including Markdown, triggers its workflow.
|
||||
|
||||
Wiki keeps `/opt/prompt`, port 18131 and existing persistent volumes. Studio uses its own Compose project, `/opt/prompt-studio`, port 18134, and verifies its AI index before recording the release; port 18134 is bound to loopback so access goes through Wiki authentication. Python publishes `prompt-python:<sha>` and the local `latest` alias, used on demand rather than running an empty daemon. No Python network port is exposed. Root Dockerfile is Wiki's build entry; `apps/waterfall-wiki/Dockerfile` mirrors it for discovery.
|
||||
|
||||
CI uses root build context and application-scoped COPY instructions. Wiki runs its existing Docker integration suite before deploying. The former Web-only release path is retired because its source-copy and image assumptions predated the monorepo. No `down -v` is used in production.
|
||||
|
||||
## Explore 页面与轻量图片
|
||||
|
||||
首页 `/` 与分类 `/categories` 统一浏览;内容固定地址 `/explore/<id>`。文档 ID 持久保存在 `apps/waterfall-wiki/src/content/page-ids.json`,改标题或位置时保留 ID;数据库素材使用原有 UUID。旧 `/skills/...`、`/p/...` 和 `/wiki/...` 网页跳转到内容地址,媒体与纯文本 API 保持兼容;Wiki 不再提供截图画布与截图任务。`?agent_mode=True`(大小写不敏感)在相同地址返回 `text/plain`,保留全部正文与媒体链接。
|
||||
|
||||
`/api/preview` 只接受本站静态栅格图或媒体 ID,限制四档尺寸,输出 WebP:列表目标最多 100 KB、详情目标最多 220 KB。原图按需下载。服务端预览使用并发限制、请求合并、磁盘缓存和 ETag;缓存数量有限并定期清理。SVG 图标不经过栅格化。
|
||||
|
||||
Studio 部署到同一 Docker 网络,通过 `http://8.211.154.134:18131/studio` 访问,不依赖额外公网端口。直连端口 `127.0.0.1:18134/studio` 仅服务器本机可用;公网访问统一经过 Wiki 登录保护。
|
||||
|
||||
Docker 在单独的依赖下载层执行 `pnpm fetch`,再离线安装。源码变化复用依赖层,包脚本变化复用已下载的包。CI 不清理 Docker 缓存,不使用 `--no-cache`。
|
||||
|
||||
## 扁平标签与资源引用
|
||||
|
||||
`posts` 保留稳定 UUID、兼容 slug、标题、description、类型、封面资源 ID 与时间;移除代码页绑定 `page_key`。`tags` 为全局唯一名称,不含 `parent_id`,输入 `#标签` 并回车可创建或复用;`post_tags` 和 `asset_tags` 仍是多对多关联。静态 Wiki 页面目前仍使用代码内容,发布步骤将其可见标签同步到标签表。
|
||||
|
||||
所有文件统一进入 `assets`。视频加工结果通过 `source_asset_id` 和 `derivation` 记录来源,并以 `post_assets` 的 `silent_video`、`audio_track`、`poster` 角色供笔记引用;它们可以像其他资源一样复用。旧 `?variant=` 媒体地址继续读取相应普通资源。保留 `media_jobs` 用于异步处理和失败重试,保留 `contribution_events`,不再使用 `asset_variants`、`page_snapshots`、`render_jobs`。已有截图转换成普通图片引用后保留。创作站与 Python 导出功能独立运行。
|
||||
|
||||
部署在停止 Wiki 写入后备份数据库,再运行迁移和启动新版;失败时使用同一备份恢复旧版所需的数据库结构。对象文件和持久卷保留。
|
||||
|
||||
|
||||
内部应用名统一为 waterfall-wiki,网站品牌为 PROMPT。Compose 业务服务为 waterfall-wiki / waterfall-wiki-worker,镜像参数为 WATERFALL_WIKI_IMAGE / WATERFALL_WIKI_TEST_IMAGE;历史 prompt 项目与持久卷保留。系统标签为固定文档入口/参考图,普通标签可通过 # 新建,详见根 README。
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user