Compare commits
114 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 896c8bb9e5 | |||
| 1df5790d23 | |||
| fe26f03cf4 | |||
| 19e179dd20 | |||
| 7f88ebba71 | |||
| 0a77d03220 | |||
| 6654a1456b | |||
| 80d23f0e3d | |||
| 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 | |||
| 429e78c722 | |||
| f4f0d67306 | |||
| 4faef8b766 | |||
| fb6f29db76 | |||
| 2bcd5992eb | |||
| d8c06d6cc6 | |||
| 15542e977a | |||
| 5ab0ca78b2 | |||
| 8ab67def5f | |||
| 8944e59ead | |||
| cad84f8a67 | |||
| 33405b7d8b | |||
| 67dea99cd1 | |||
| de851085dd | |||
| af4e9b13ed | |||
| b1e6083996 | |||
| cfc6686904 | |||
| 7f7fc28e57 | |||
| bd8ccbb4a1 | |||
| 40a6076961 | |||
| d072aeeaa0 | |||
| 53a4103308 | |||
| ea7bbebb3e | |||
| 94193d485b | |||
| 265cd7444b | |||
| f1c794654e | |||
| fd9b8640b7 | |||
| 4791729b65 | |||
| d362e1a975 | |||
| 924724c5e4 | |||
| eed5d43e21 | |||
| 5a0554b3b1 | |||
| 82bef38536 | |||
| d050e06870 | |||
| 045c3d8188 | |||
| f3e9f5a3fb | |||
| 922c7bd0d0 | |||
| 3f8bd11a17 | |||
| e2bc4ae6bd | |||
| ef66f687bd | |||
| f5422b1541 | |||
| 19bba57561 | |||
| 906c6985cb |
@@ -22,3 +22,18 @@ coverage
|
||||
|
||||
# Local authoring assets are uploaded via APIs, never application build inputs.
|
||||
/content
|
||||
|
||||
# Apply local metadata, secrets and generated-output exclusions at every depth.
|
||||
**/.git
|
||||
**/.worktrees
|
||||
**/.pnpm-store
|
||||
**/.turbo
|
||||
**/.nx
|
||||
**/coverage
|
||||
**/artifacts
|
||||
**/.pytest_cache
|
||||
**/*.egg-info
|
||||
**/*.tsbuildinfo
|
||||
**/.env
|
||||
**/.env.*
|
||||
!**/.env.example
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
name: Unified application CI and deployment
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'apps/waterfall-wiki/**'
|
||||
- 'packages/brand/**'
|
||||
- 'scripts/**'
|
||||
- 'Dockerfile'
|
||||
- '.dockerignore'
|
||||
- 'compose.yml'
|
||||
- 'pnpm-workspace.yaml'
|
||||
- '.gitea/workflows/app.yml'
|
||||
push:
|
||||
branches: ["**"]
|
||||
paths:
|
||||
- 'apps/waterfall-wiki/**'
|
||||
- 'packages/brand/**'
|
||||
- 'scripts/**'
|
||||
- 'Dockerfile'
|
||||
- '.dockerignore'
|
||||
- 'compose.yml'
|
||||
- 'pnpm-workspace.yaml'
|
||||
- '.gitea/workflows/app.yml'
|
||||
concurrency:
|
||||
group: unified-app-${{ gitea.event.pull_request.number || gitea.ref }}
|
||||
cancel-in-progress: false
|
||||
jobs:
|
||||
static-checks:
|
||||
runs-on: ces-control
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 2
|
||||
- name: Check patch, syntax and source contracts without building
|
||||
run: |
|
||||
git diff --check HEAD^ HEAD
|
||||
for script in scripts/*.sh scripts/preview/*.sh; do bash -n "$script"; done
|
||||
node scripts/check-studio-contracts.mjs
|
||||
node --check scripts/preview/fingerprint.mjs
|
||||
node --check scripts/verify-studio-visual.cjs
|
||||
node --check apps/waterfall-wiki/scripts/infinite-canvas-smoke.cjs
|
||||
node --check apps/waterfall-wiki/scripts/cleanup-ci-bucket.mjs
|
||||
python3 -c 'import ast,pathlib; ast.parse(pathlib.Path("scripts/wiki-image-lifecycle.py").read_text())'
|
||||
verify-and-release:
|
||||
needs: static-checks
|
||||
if: gitea.event_name == 'push' && gitea.ref == 'refs/heads/main'
|
||||
runs-on: ces-control
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Build and verify the unified application once
|
||||
run: |
|
||||
docker build --target integration -t "waterfall-wiki-tests:${{ gitea.sha }}" .
|
||||
docker build --target runtime -t "waterfall-wiki:${{ gitea.sha }}" .
|
||||
- name: Verify candidate using persistent test infrastructure
|
||||
env:
|
||||
WATERFALL_WIKI_IMAGE: waterfall-wiki:${{ gitea.sha }}
|
||||
WATERFALL_WIKI_TEST_IMAGE: waterfall-wiki-tests:${{ gitea.sha }}
|
||||
GITEA_RUN_ID: ${{ gitea.run_id }}
|
||||
run: bash scripts/integration.sh
|
||||
- name: Deploy unified Web and worker; verify production
|
||||
if: gitea.event_name == 'push' && gitea.ref == 'refs/heads/main'
|
||||
run: bash scripts/deploy.sh "${{ gitea.sha }}"
|
||||
- name: Save production acceptance evidence
|
||||
id: evidence
|
||||
if: always() && gitea.event_name == 'push'
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: unified-production-acceptance
|
||||
path: artifacts/studio-acceptance/
|
||||
if-no-files-found: ignore
|
||||
retention-days: 7
|
||||
- name: Remove uploaded local evidence
|
||||
if: always() && steps.evidence.outcome == 'success'
|
||||
run: rm -rf artifacts/studio-acceptance
|
||||
@@ -0,0 +1,32 @@
|
||||
name: Remote development preview source sync
|
||||
on:
|
||||
push:
|
||||
branches: ['**']
|
||||
paths:
|
||||
- 'apps/waterfall-wiki/**'
|
||||
- 'packages/brand/**'
|
||||
- 'pnpm-workspace.yaml'
|
||||
- 'scripts/preview/**'
|
||||
- '.gitea/workflows/preview.yml'
|
||||
concurrency:
|
||||
group: development-preview
|
||||
cancel-in-progress: false
|
||||
jobs:
|
||||
sync-source:
|
||||
# One preview slot follows one branch. Setup changes use an explicit refresh.
|
||||
if: gitea.ref == format('refs/heads/{0}', vars.PREVIEW_BRANCH || 'codex/unified-web')
|
||||
runs-on: ces-control
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
- name: Initialize once, otherwise synchronize files only
|
||||
env:
|
||||
SOURCE_SHA: ${{ gitea.sha }}
|
||||
SOURCE_BRANCH: ${{ gitea.ref_name }}
|
||||
run: |
|
||||
if [ ! -f /opt/prompt-preview/ready ]; then
|
||||
bash scripts/preview/init.sh "$PWD" waterfall-wiki-tests:6654a1456bdcef77d83ca84373dc36188e15bd12
|
||||
fi
|
||||
bash scripts/preview/sync.sh "$PWD" "$SOURCE_SHA" "$SOURCE_BRANCH"
|
||||
@@ -1,14 +1,35 @@
|
||||
name: python CI and deployment
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- '.dockerignore'
|
||||
- 'apps/python/**'
|
||||
- 'scripts/deploy-python.sh'
|
||||
- '.gitea/workflows/python.yml'
|
||||
push:
|
||||
branches: ["**"]
|
||||
paths:
|
||||
- '.dockerignore'
|
||||
- 'apps/python/**'
|
||||
- 'scripts/deploy-python.sh'
|
||||
- '.gitea/workflows/python.yml'
|
||||
|
||||
jobs:
|
||||
static-checks:
|
||||
runs-on: ces-control
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 2
|
||||
- name: Check Python and deployment syntax without executing tests
|
||||
run: |
|
||||
git diff --check HEAD^ HEAD
|
||||
bash -n scripts/deploy-python.sh
|
||||
python3 -c 'import ast,pathlib; [ast.parse(p.read_text(),filename=str(p)) for p in pathlib.Path("apps/python").rglob("*.py")]'
|
||||
verify-and-deploy:
|
||||
needs: static-checks
|
||||
if: gitea.ref == 'refs/heads/main' && gitea.event_name == 'push'
|
||||
runs-on: ces-control
|
||||
timeout-minutes: 45
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
name: studio CI and deployment
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'apps/studio/**'
|
||||
- 'scripts/deploy-studio.sh'
|
||||
- 'scripts/verify-studio-production.sh'
|
||||
- 'scripts/verify-studio-library.mjs'
|
||||
- 'scripts/verify-studio-visual.cjs'
|
||||
- '.gitea/workflows/studio.yml'
|
||||
- 'scripts/*studio*'
|
||||
- 'content/studio-examples/**'
|
||||
- 'content/studio-tools/layouts-api.md'
|
||||
- 'pnpm-workspace.yaml'
|
||||
|
||||
jobs:
|
||||
verify-and-deploy:
|
||||
if: gitea.ref == 'refs/heads/main' && gitea.event_name == 'push'
|
||||
runs-on: ces-control
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Check shared content and documentation contracts
|
||||
run: |
|
||||
node scripts/check-studio-contracts.mjs
|
||||
node --check scripts/export-studio-series.cjs
|
||||
node --check scripts/save-studio-series.mjs
|
||||
bash -n scripts/publish-studio-guide.sh scripts/deliver-studio-series.sh
|
||||
- name: Build and verify studio
|
||||
run: docker build -f apps/studio/Dockerfile -t "prompt-studio:${{ gitea.sha }}" .
|
||||
- name: Release studio
|
||||
if: gitea.ref == 'refs/heads/main' && gitea.event_name == 'push'
|
||||
run: bash scripts/deploy-studio.sh "${{ gitea.sha }}"
|
||||
- name: Verify the deployed Studio API and native pages
|
||||
if: gitea.ref == 'refs/heads/main' && gitea.event_name == 'push'
|
||||
run: bash scripts/verify-studio-production.sh
|
||||
- name: Save server acceptance evidence
|
||||
id: acceptance_evidence
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: studio-production-acceptance
|
||||
path: artifacts/studio-acceptance/
|
||||
if-no-files-found: ignore
|
||||
retention-days: 7
|
||||
- name: Remove temporary acceptance output after upload
|
||||
if: always() && steps.acceptance_evidence.outcome == 'success'
|
||||
run: rm -rf artifacts/studio-acceptance
|
||||
@@ -1,34 +0,0 @@
|
||||
name: waterfall-wiki CI and deployment
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'apps/waterfall-wiki/**'
|
||||
- '.gitea/workflows/waterfall-wiki.yml'
|
||||
- 'Dockerfile'
|
||||
- 'compose.yml'
|
||||
- 'scripts/deploy.sh'
|
||||
- 'scripts/wiki-image-lifecycle.py'
|
||||
- 'scripts/infra.sh'
|
||||
- 'scripts/auth-admin.sh'
|
||||
- 'scripts/integration.sh'
|
||||
- 'pnpm-workspace.yaml'
|
||||
|
||||
jobs:
|
||||
verify-and-deploy:
|
||||
if: gitea.ref == 'refs/heads/main' && gitea.event_name == 'push'
|
||||
runs-on: ces-control
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Build, verify, deploy and retain wiki images
|
||||
env:
|
||||
RELEASE_SHA: ${{ gitea.sha }}
|
||||
run: |
|
||||
if [ "$(id -u)" -eq 0 ]; then
|
||||
python3 scripts/wiki-image-lifecycle.py ci --sha "$RELEASE_SHA"
|
||||
else
|
||||
sudo -n python3 scripts/wiki-image-lifecycle.py ci --sha "$RELEASE_SHA"
|
||||
fi
|
||||
@@ -10,3 +10,21 @@ dist/
|
||||
__pycache__/
|
||||
*.egg-info/
|
||||
.venv/
|
||||
|
||||
# Content images belong in S3; metadata lives in managed-media.json.
|
||||
*.png
|
||||
*.jpg
|
||||
*.jpeg
|
||||
*.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
|
||||
|
||||
@@ -14,3 +14,10 @@ This block is written and re-added by `next dev` — verify at `node_modules/nex
|
||||
- 页面改动必须先 push,等待 ces-control 的 CI/CD 成功部署,再直接访问服务器检查效果。
|
||||
- 不启动本地网页预览,不使用 localhost / 127.0.0.1 页面进行视觉验收,也不把本地预览链接交付给用户。
|
||||
- 类型检查、单元测试等不提供网页预览的检查可以本地执行;完整运行检查由 Docker CI 完成。
|
||||
|
||||
## 啡跃 Endoleap 品牌与主题
|
||||
|
||||
- 共享品牌包位于 [`packages/brand`](packages/brand/README.md)。修改品牌色、主题或标志前,阅读包内的 [品牌边界](packages/brand/docs/overview.md)、[配色与交互规范](packages/brand/docs/colors.md)、[Tailwind / shadcn 接入](packages/brand/docs/integration.md) 和 [Logo 规则](packages/brand/docs/logo.md)。不在仓库根 docs 重复维护一套品牌规范。
|
||||
- 四套正式主题为 home-light、home-dark、canvas-light、canvas-dark;不创建 drafts,不恢复 proposed 命名。画布 Light / Dark 背景保持中性近白 / 近黑,用户素材与作品不整体染色。
|
||||
- 基础色只在 tokens/palette.json 定义,主题引用色板键;Logo 轮廓只在 tokens/geometry.json 定义。保持 CSS、类型声明、资产与清单一致,不通过图片模型重画已固定的图形。
|
||||
- 本地仅运行不生成产物的轻量读取检查。品牌导出、Tailwind/TypeScript 编译和 SVG 栅格化检查必须通过已确认的 ces-control Docker CI 执行,不在本机启动预览或构建。
|
||||
|
||||
@@ -7,33 +7,40 @@ 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
|
||||
# Download cache depends on lockfile and workspace policy, not package scripts.
|
||||
RUN pnpm fetch --lockfile-dir=/repo/apps/waterfall-wiki --fetch-timeout=300000 --network-concurrency=8
|
||||
COPY packages/brand/package.json /repo/packages/brand/package.json
|
||||
COPY apps/waterfall-wiki/package.json ./
|
||||
RUN pnpm install --offline --frozen-lockfile
|
||||
RUN pnpm --filter @prompt/waterfall-wiki install --offline --frozen-lockfile
|
||||
|
||||
FROM dependencies AS build
|
||||
# .dockerignore excludes docs and operational notes without extra legacy COPY snapshots.
|
||||
# Brand source changes invalidate compilation, not dependency installation.
|
||||
COPY packages/brand/ /repo/packages/brand/
|
||||
COPY apps/waterfall-wiki/ .
|
||||
RUN pnpm typecheck && pnpm test && pnpm build && pnpm test:smoke
|
||||
|
||||
FROM build AS integration
|
||||
ENV VERIFY_CANVAS_UI=1
|
||||
USER node
|
||||
CMD ["npm", "run", "test:integration"]
|
||||
|
||||
FROM base AS runtime
|
||||
WORKDIR /app/apps/waterfall-wiki
|
||||
ENV NODE_ENV=production HOSTNAME=0.0.0.0 PORT=3000 \
|
||||
FFMPEG_PATH=/usr/bin/ffmpeg FFPROBE_PATH=/usr/bin/ffprobe
|
||||
COPY --from=build --chown=node:node /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/
|
||||
COPY --chown=node:node apps/waterfall-wiki/src/content/managed-media.json ./src/content/managed-media.json
|
||||
USER node
|
||||
EXPOSE 3000
|
||||
CMD ["node", "server.js"]
|
||||
|
||||
@@ -55,7 +55,7 @@ Studio 负责交互式排版与无状态渲染,知识库负责教程、来源
|
||||
| 路径 | 职责 |
|
||||
| --- | --- |
|
||||
| `apps/waterfall-wiki` | Next.js Pages Router 知识库、搜索、标签、媒体 API、认证与后台视频加工 |
|
||||
| `apps/studio` | 独立 Next.js 创作工具与封面渲染 API |
|
||||
| `apps/waterfall-wiki/src/studio` | 合并后的创作工具、排版渲染与编辑器模块 |
|
||||
| `apps/python` | 按需运行的 PNG/视频导出 CLI,没有常驻网络服务 |
|
||||
| `content` | 本地作者资料、教程及排版源图,不打入 Wiki 运行镜像 |
|
||||
| `scripts` | CI 部署、运维、API 发布与作者工具 |
|
||||
@@ -71,8 +71,8 @@ Studio 负责交互式排版与无状态渲染,知识库负责教程、来源
|
||||
pnpm install
|
||||
pnpm --dir apps/waterfall-wiki typecheck
|
||||
pnpm --dir apps/waterfall-wiki test
|
||||
pnpm --dir apps/studio typecheck
|
||||
bash -n scripts/deploy.sh scripts/integration.sh scripts/deploy-studio.sh
|
||||
node scripts/check-studio-contracts.mjs
|
||||
for script in scripts/*.sh; do bash -n "$script"; done
|
||||
```
|
||||
|
||||
完整集成测试只针对显式隔离的数据库、对象存储和生产容器运行,覆盖存储、视频加工、编辑冲突、真实搜索与引用、标签规则和登录保护。不要把生产存储配置拿来运行集成测试。
|
||||
@@ -81,14 +81,14 @@ bash -n scripts/deploy.sh scripts/integration.sh scripts/deploy-studio.sh
|
||||
|
||||
## 部署与持久化
|
||||
|
||||
Gitea 工作流按应用路径触发:`waterfall-wiki.yml`、`studio.yml` 和 `python.yml`。waterfall-wiki 完成类型检查、单元测试、生产构建、冒烟和真实 PostgreSQL/S3 集成测试后部署;Studio 独立构建和部署。文档与作者数据本身不触发应用部署。
|
||||
Web 应用由 `.gitea/workflows/app.yml` 统一验证与发布。Wiki 和 Studio 已合并成一个 Next.js 应用,一次编译、一个 Web 运行镜像,Web 与后台 worker 使用同一镜像。PostgreSQL/S3 基础服务长期运行,不在日常发布中重建。工作分支 push/PR 仅做轻量静态检查;只有 main push 才构建、运行测试并更新生产。完整步骤见 [统一部署流程](docs/unified-deployment.md)。
|
||||
|
||||
| 服务 | 入口/端口 |
|
||||
| --- | --- |
|
||||
| `waterfall-wiki` | 公网 `18131`,也是 Studio 的统一入口 |
|
||||
| PostgreSQL | `127.0.0.1:18132` |
|
||||
| 对象存储 | `127.0.0.1:18133` |
|
||||
| Studio | `127.0.0.1:18134`;公网通过 `/studio` 代理 |
|
||||
| 编辑器 | 与知识站共享 `18131`;原生路由 `/graphic`、`/infinite_canvas`,历史 `/studio` 页面与 API 保留兼容 |
|
||||
| Python | 无网络端口,按需运行 |
|
||||
|
||||
生产 Compose 项目仍为 `prompt`,部署根目录仍为 `/opt/prompt`。既有 `prompt_postgres_data`、`prompt_objects`、`prompt_auth_data` 卷与数据库身份保留;应用更名不会重新创建一套空存储。Compose 应用服务改为 `waterfall-wiki` 和 `waterfall-wiki-worker`,镜像参数为 `WATERFALL_WIKI_IMAGE` / `WATERFALL_WIKI_TEST_IMAGE`。
|
||||
|
||||
@@ -1,8 +1,22 @@
|
||||
FROM python:3.12-slim
|
||||
FROM python:3.12-slim AS base
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends ffmpeg fonts-noto-cjk && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
FROM base AS dependencies
|
||||
ENV PATH="/opt/venv/bin:$PATH"
|
||||
WORKDIR /app
|
||||
RUN python -m venv /opt/venv
|
||||
COPY apps/python/pyproject.toml ./
|
||||
# Read the pinned requirements from the package metadata before copying source.
|
||||
RUN python -c 'import subprocess, tomllib; p = tomllib.load(open("pyproject.toml", "rb")); subprocess.check_call(["python", "-m", "pip", "install", "--no-cache-dir", *p["build-system"]["requires"], *p["project"]["dependencies"]])'
|
||||
|
||||
FROM dependencies AS build
|
||||
COPY apps/python/ .
|
||||
RUN pip install --no-cache-dir . && python -m unittest discover -s tests -v
|
||||
RUN pip install --no-cache-dir --no-deps --no-build-isolation . && python -m unittest discover -s tests -v
|
||||
|
||||
FROM base AS runtime
|
||||
WORKDIR /app
|
||||
ENV PATH="/opt/venv/bin:$PATH"
|
||||
COPY --from=build /opt/venv /opt/venv
|
||||
RUN useradd --create-home exporter
|
||||
USER exporter
|
||||
ENTRYPOINT ["prompt-export"]
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
FROM node:24-bookworm-slim AS build
|
||||
WORKDIR /app
|
||||
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 apps/studio/package.json ./
|
||||
RUN pnpm install --offline --frozen-lockfile
|
||||
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
|
||||
RUN pnpm typecheck && pnpm test && pnpm build && pnpm test:smoke
|
||||
FROM node:24-bookworm-slim
|
||||
WORKDIR /app
|
||||
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/
|
||||
USER node
|
||||
EXPOSE 3000
|
||||
CMD ["node", "server.js"]
|
||||
@@ -1,7 +0,0 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/types/routes.d.ts";
|
||||
import "./.next/types/root-params.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/pages/api-reference/config/typescript for more information.
|
||||
@@ -1,3 +0,0 @@
|
||||
import type { NextConfig } from "next";
|
||||
const config: NextConfig = { basePath: "/studio", output: "standalone", outputFileTracingRoot: process.cwd(), poweredByHeader: false };
|
||||
export default config;
|
||||
@@ -1,27 +0,0 @@
|
||||
{
|
||||
"name": "@prompt/studio",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev -p 3001",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"typecheck": "next typegen && tsc --noEmit",
|
||||
"test": "node --test tests/*.test.mjs",
|
||||
"test:smoke": "node scripts/smoke.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"next": "16.4.2",
|
||||
"react": "19.3.1",
|
||||
"react-dom": "19.3.1",
|
||||
"lucide-react": "1.61.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "4.3.3",
|
||||
"@types/node": "24.19.2",
|
||||
"@types/react": "19.3.0",
|
||||
"@types/react-dom": "19.3.0",
|
||||
"tailwindcss": "4.3.3",
|
||||
"typescript": "5.9.3"
|
||||
},
|
||||
"packageManager": "pnpm@11.25.0"
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
export default {
|
||||
plugins: { "@tailwindcss/postcss": {} },
|
||||
};
|
||||
@@ -1,4 +0,0 @@
|
||||
// Run against an already started Studio. BASE_URL is the origin, including port.
|
||||
const {chromium}=require('playwright-core');
|
||||
const assert=require('node:assert/strict');
|
||||
(async()=>{const browser=await chromium.launch({channel:'chrome',headless:true});try{const page=await browser.newPage({viewport:{width:1440,height:1000}});const errors=[];page.on('pageerror',e=>errors.push(e.message));await page.goto((process.env.BASE_URL||'http://localhost:3018')+'/studio/infinite_canvas');await page.getByRole('heading',{name:'无限画布 / Infinite canvas'}).waitFor();assert.equal(await page.locator('article').count(),3);await page.getByRole('button',{name:'添加笔记',exact:true}).click();assert.equal(await page.locator('article').count(),4);await page.getByLabel('节点名称').fill('Smoke note');await page.getByLabel('笔记内容').fill('A saved idea');await page.reload();await page.getByText('Smoke note',{exact:true}).first().waitFor();assert.equal(await page.locator('article').count(),4);await page.getByText('Smoke note',{exact:true}).first().click();await page.getByRole('button',{name:'删除节点',exact:true}).click();assert.equal(await page.locator('article').count(),3);await page.getByRole('button',{name:'撤销',exact:true}).click();assert.equal(await page.locator('article').count(),4);await page.getByRole('button',{name:'适应画布'}).click();const card=page.locator('article').last();const before=await card.getAttribute('style');const box=await card.locator('div').first().boundingBox();await page.mouse.move(box.x+70,box.y+20);await page.mouse.down();await page.mouse.move(box.x+120,box.y+45,{steps:5});await page.mouse.up();assert.notEqual(await card.getAttribute('style'),before);const download=page.waitForEvent('download');await page.getByRole('button',{name:'导出项目'}).click();assert.equal((await download).suggestedFilename(),'studio-canvas.json');if(process.env.SCREENSHOT_DIR)await page.screenshot({path:process.env.SCREENSHOT_DIR+'/canvas-desktop.png'});await page.setViewportSize({width:390,height:844});assert.ok(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth));if(process.env.SCREENSHOT_DIR)await page.screenshot({path:process.env.SCREENSHOT_DIR+'/canvas-mobile.png',fullPage:true});assert.deepEqual(errors,[]);console.log('Canvas browser smoke passed: create, edit, restore, delete, undo, fit, export, mobile.')}finally{await browser.close()}})().catch(e=>{console.error(e);process.exitCode=1});
|
||||
@@ -1,14 +0,0 @@
|
||||
import {referencePreview} from '../lib/layout-images-client';
|
||||
import {useState} from 'react';
|
||||
import Head from 'next/head';
|
||||
import {SourceCover,selections,type CoverValues} from './SourceCover';
|
||||
import {tutorialPost} from '../content/tool-links';
|
||||
export function LayoutSelection(){
|
||||
const [active,setActive]=useState(0),[values,setValues]=useState<CoverValues[]>(selections.map(l=>({fields:Object.fromEntries(Object.entries(l.fields).filter(([,v])=>v!==undefined)) as Record<string,string>,color:'#83918a',accent:'#c3cec5'}))),[guides,setGuides]=useState(false),[status,setStatus]=useState('');
|
||||
const layout=selections[active],current=values[active];
|
||||
function update(patch:Partial<CoverValues>){setValues(old=>old.map((v,i)=>i===active?{...v,...patch}:v));}
|
||||
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>;
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
import type {NextApiRequest} from 'next';
|
||||
import type {Parameters} from './layout-parameters';
|
||||
/** Only fetch bounded previews from the authenticated Wiki service, never caller URLs. */
|
||||
export async function embedLayoutImages(req:NextApiRequest,params:Parameters){
|
||||
const ids=[...new Set(Object.values(params.images).flatMap(v=>v.assetId?[v.assetId]:[]))],sources:Record<string,string>={};let total=0;
|
||||
const headers:Record<string,string>={};if(req.headers.authorization)headers.authorization=req.headers.authorization;if(req.headers.cookie)headers.cookie=req.headers.cookie;
|
||||
for(const id of ids){const url='http://waterfall-wiki:3000/api/preview?'+new URLSearchParams({source:'/api/media/'+id,width:'960'}),r=await fetch(url,{headers,signal:AbortSignal.timeout(15000),redirect:'error'});if(!r.ok||r.headers.get('content-type')?.split(';')[0]!=='image/webp')throw Error('图片预览不可访问,请检查图片 ID 与登录状态');const chunks:Buffer[]=[];const reader=r.body!.getReader();try{while(true){const {done,value}=await reader.read();if(done)break;total+=value.length;if(total>16*1024*1024)throw Error('图片预览总计超限');chunks.push(Buffer.from(value))}}finally{await reader.cancel()}sources[id]='data:image/webp;base64,'+Buffer.concat(chunks).toString('base64');}
|
||||
return sources;
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
export type Slot={id:string;kind:string;role:string;box:number[];direction:string;text:string|null;reading:string};
|
||||
export type Layout={id:string;name:string;width:number;height:number;elements:Slot[]};
|
||||
export type TextValue={value:string;color?:string;scale?:number;align?:'start'|'middle'|'end';fontFamily?:'sans'|'serif'|'mono';fontSize?:number;fontWeight?:number;lineHeight?:number;letterSpacing?:number};
|
||||
export type ImageValue={assetId?:string;color?:string;fit?:'cover'|'contain';cropX?:number;cropY?:number;zoom?:number};
|
||||
export type DecorationValue={color?:string;hidden?:boolean};
|
||||
export type LayerValue={x?:number;y?:number;width?:number;height?:number;rotation?:number;opacity?:number;radius?:number;z?:number;hidden?:boolean;locked?:boolean};
|
||||
export type AddedSlot=Slot & {shape?:'rectangle'|'ellipse'};
|
||||
export type Parameters={texts:Record<string,TextValue>;images:Record<string,ImageValue>;decorations:Record<string,DecorationValue>;paper?:string;layers?:Record<string,LayerValue>;added?:AddedSlot[]};
|
||||
export class LayoutParameterError extends Error{}
|
||||
export const uuid=(v:unknown):v is string=>typeof v==='string'&&/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(v);
|
||||
function fail(message:string):never{throw new LayoutParameterError(message)}
|
||||
function object(v:unknown):Record<string,unknown>{if(!v||typeof v!=='object'||Array.isArray(v))fail('参数必须为 JSON 对象');return v as Record<string,unknown>}
|
||||
function keys(v:Record<string,unknown>,allowed:string[]){if(Object.keys(v).some(k=>!allowed.includes(k)))fail('未知参数或构件 ID')}
|
||||
function number(v:unknown,min:number,max:number){if(typeof v!=='number'||!Number.isFinite(v)||v<min||v>max)fail(`数值范围 ${min}–${max}`);return v}
|
||||
function color(v:unknown){if(typeof v!=='string'||!/^#[a-f0-9]{6}$/i.test(v))fail('颜色使用 #RRGGBB');return v}
|
||||
export function parameters(layout:Layout,input:unknown):Parameters{
|
||||
const raw=object(input);keys(raw,['texts','images','decorations','paper','layers','added']);const result:Parameters={texts:{},images:{},decorations:{}};
|
||||
const added:AddedSlot[]=[];
|
||||
if(raw.added!==undefined){if(!Array.isArray(raw.added)||raw.added.length>100)fail('最多新增100个元素');for(const entry of raw.added){const v=object(entry);keys(v,['id','kind','role','box','direction','text','reading','shape']);if(typeof v.id!=='string'||!/^user-[a-zA-Z0-9-]{1,60}$/.test(v.id)||added.some(s=>s.id===v.id))fail('新增元素 ID 无效或重复');if(!['text','image','decoration'].includes(String(v.kind)))fail('新增元素类型无效');if(typeof v.role!=='string'||v.role.length>80)fail('图层名称最多80字');if(!Array.isArray(v.box)||v.box.length!==4)fail('box 为 x,y,width,height');const box=v.box.map((n,i)=>number(n,i<2?-10000:.1,10000));if(v.direction!=='horizontal'||v.reading!=='custom'||(v.text!==null&&(typeof v.text!=='string'||v.text.length>500)))fail('新增元素内容无效');if(v.shape!==undefined&&!['rectangle','ellipse'].includes(String(v.shape)))fail('无效形状');added.push({id:v.id,kind:String(v.kind),role:v.role,box,direction:'horizontal',text:v.text as string|null,reading:'custom',...(v.shape?{shape:v.shape as AddedSlot['shape']}:{})})}result.added=added}
|
||||
const elements=[...layout.elements,...added];
|
||||
if(raw.layers!==undefined){const entries=object(raw.layers);keys(entries,elements.map(e=>e.id));result.layers={};for(const [id,entry] of Object.entries(entries)){const v=object(entry);keys(v,['x','y','width','height','rotation','opacity','radius','z','hidden','locked']);const layer:Record<string,number|boolean>={};for(const [key,value] of Object.entries(v)){if(key==='hidden'||key==='locked'){if(typeof value!=='boolean')fail('图层开关必须为布尔值');layer[key]=value}else{const range:Record<string,number[]>={x:[-10000,10000],y:[-10000,10000],width:[.1,10000],height:[.1,10000],rotation:[-360,360],opacity:[0,1],radius:[0,5000],z:[-1000,1000]};layer[key]=number(value,range[key][0],range[key][1])}}result.layers[id]=layer}}
|
||||
if(raw.paper!==undefined)result.paper=color(raw.paper);
|
||||
for(const [group,kind] of [['texts','text'],['images','image'],['decorations','decoration']] as const){
|
||||
const entries=object(raw[group]??{});keys(entries,elements.filter(e=>e.kind===kind).map(e=>e.id));
|
||||
for(const [id,value] of Object.entries(entries)){const v=object(value);
|
||||
if(group==='texts'){keys(v,['value','color','scale','align','fontFamily','fontSize','fontWeight','lineHeight','letterSpacing']);if(typeof v.value!=='string'||Array.from(v.value).length>500||v.value.split('\n').length>8)fail('文字最多 500 字 / 8 行');const t:TextValue={value:v.value};if(v.color!==undefined)t.color=color(v.color);if(v.scale!==undefined){if(typeof v.scale!=='number'||!Number.isFinite(v.scale)||v.scale<.25||v.scale>2)fail('scale 范围 0.25–2');t.scale=v.scale}if(v.align!==undefined){if(!['start','middle','end'].includes(String(v.align)))fail('无效 align');t.align=v.align as TextValue['align']}if(v.fontFamily!==undefined){if(!['sans','serif','mono'].includes(String(v.fontFamily)))fail('无效字体');t.fontFamily=v.fontFamily as TextValue['fontFamily']}for(const [key,min,max] of [['fontSize',.1,1000],['fontWeight',100,900],['lineHeight',.5,4],['letterSpacing',-20,100]] as const)if(v[key]!==undefined)t[key]=number(v[key],min,max);result.texts[id]=t;
|
||||
}else if(group==='images'){keys(v,['assetId','color','fit','cropX','cropY','zoom']);const image:ImageValue={};if(v.assetId!==undefined){if(!uuid(v.assetId))fail('assetId 必须为已上传图片 UUID');image.assetId=v.assetId}if(v.color!==undefined)image.color=color(v.color);if(v.fit!==undefined){if(v.fit!=='cover'&&v.fit!=='contain')fail('fit 使用 cover 或 contain');image.fit=v.fit}for(const [key,min,max] of [['cropX',0,100],['cropY',0,100],['zoom',1,5]] as const)if(v[key]!==undefined)image[key]=number(v[key],min,max);result.images[id]=image;
|
||||
}else{keys(v,['color','hidden']);const d:DecorationValue={};if(v.color!==undefined)d.color=color(v.color);if(v.hidden!==undefined){if(typeof v.hidden!=='boolean')fail('hidden 必须为布尔值');d.hidden=v.hidden}result.decorations[id]=d}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
import {parameters,LayoutParameterError,type Layout,type Parameters,type TextValue,uuid} from './layout-parameters';
|
||||
export type Typography=Required<Pick<TextValue,'fontFamily'|'fontSize'|'fontWeight'|'lineHeight'|'letterSpacing'>>;
|
||||
export type Series={version:1;brief:{topic:string;audience:string;intent:string;designIntent:string;imageStrategy:string};style:{version:string;palette:{paper:string;ink:string;muted:string;accent:string};typography:Record<'title'|'body'|'caption',Typography>;spacing:{margin:number;gap:number}};sources:{assetId:string;author:string;license:string;url:string;prompt:string;tool:string}[];pages:{key:string;role:'cover'|'body'|'ending';layoutId:string;intent:string;bindings:Record<string,'title'|'body'|'caption'>;parameters:Parameters}[]};
|
||||
function fail(message:string):never{throw new LayoutParameterError(message)}
|
||||
const obj=(v:unknown)=>{if(!v||typeof v!=='object'||Array.isArray(v))return fail('作品字段必须是对象');return v as Record<string,unknown>};
|
||||
function exact(v:unknown,allowed:string[]){const o=obj(v);if(Object.keys(o).some(k=>!allowed.includes(k)))fail('作品包含未知字段');return o}
|
||||
function str(v:unknown,max=1000){if(typeof v!=='string'||!v.trim()||v.length>max)fail('作品文字缺失或超长');return v as string}
|
||||
function num(v:unknown,min:number,max:number){if(typeof v!=='number'||!Number.isFinite(v)||v<min||v>max)fail('作品数值超出范围');return v as number}
|
||||
/** Closed, bounded content document. The same validator runs in persistence and SSR. */
|
||||
export function series(input:unknown,layouts:Layout[]):Series{
|
||||
const raw=exact(input,['version','brief','style','sources','pages']);if(raw.version!==1)fail('不支持的作品版本');
|
||||
const brief=exact(raw.brief,['topic','audience','intent','designIntent','imageStrategy']);for(const k of ['topic','audience','intent','designIntent','imageStrategy'])str(brief[k],2000);
|
||||
const style=exact(raw.style,['version','palette','typography','spacing']);str(style.version,80);
|
||||
const palette=exact(style.palette,['paper','ink','muted','accent']);for(const k of ['paper','ink','muted','accent'])if(!/^#[a-f\d]{6}$/i.test(String(palette[k])))fail('作品颜色使用 #RRGGBB');
|
||||
const typography=exact(style.typography,['title','body','caption']);
|
||||
for(const role of ['title','body','caption']){const t=exact(typography[role],['fontFamily','fontSize','fontWeight','lineHeight','letterSpacing']);if(!['sans','serif','mono'].includes(String(t.fontFamily)))fail('无效作品字体');num(t.fontSize,.1,40);num(t.fontWeight,100,900);num(t.lineHeight,.5,4);num(t.letterSpacing,-5,10)}
|
||||
const spacing=exact(style.spacing,['margin','gap']);num(spacing.margin,4,16);num(spacing.gap,1,10);if(Number(spacing.margin)*2+Number(spacing.gap)*4>48)fail('页边距与间距组合过大');
|
||||
if(!Array.isArray(raw.sources)||raw.sources.length>100)fail('素材来源最多100条');
|
||||
const sources=raw.sources.map(v=>{const s=exact(v,['assetId','author','license','url','prompt','tool']);if(!uuid(str(s.assetId,36)))fail('来源需素材 UUID');for(const k of ['author','license','url','prompt','tool'])str(s[k],4000);if(s.url!=='generated'&&!/^https?:\/\//.test(String(s.url)))fail('来源使用 http(s) 地址或 generated');return s as Series['sources'][number]});
|
||||
if(!Array.isArray(raw.pages)||raw.pages.length<3||raw.pages.length>18)fail('作品需要3–18页');
|
||||
const seen=new Set<string>();
|
||||
const pages=raw.pages.map((entry,index)=>{const p=exact(entry,['key','role','layoutId','intent','bindings','parameters']);const key=str(p.key,60);if(!/^[a-z0-9-]+$/.test(key)||seen.has(key))fail('页面 key 必须唯一');seen.add(key);
|
||||
const role=index===0?'cover':index===(raw.pages as unknown[]).length-1?'ending':'body';if(p.role!==role)fail('页序须为封面、正文、结尾');str(p.intent,1000);const layout=layouts.find(l=>l.id===p.layoutId);if(!layout)fail('作品模板不存在');const resolvedLayout=layout!;const params=parameters(resolvedLayout,p.parameters);const bindings=obj(p.bindings);const slots=[...resolvedLayout.elements,...(params.added||[])];for(const [id,token] of Object.entries(bindings)){if(!slots.some(s=>s.id===id&&s.kind==='text')||!['title','body','caption'].includes(String(token)))fail('字体绑定无效');if(!params.texts[id])fail('绑定文字必须提供文案')}
|
||||
// Every visible text is authored; template placeholder copy must never leak into a finished series.
|
||||
for(const s of slots)if(s.kind==='text'&&!params.layers?.[s.id]?.hidden&&!params.texts[s.id])fail('请填写或隐藏所有文字元素');
|
||||
for(const image of Object.values(params.images))if(image.assetId&&!sources.some(s=>s.assetId===image.assetId))fail('每张图片需记录作者、许可和生成过程');
|
||||
return {...p,parameters:params,bindings} as Series['pages'][number]});
|
||||
return {version:1,brief:brief as Series['brief'],style:style as Series['style'],sources,pages};
|
||||
}
|
||||
/** Style units use a 120-wide reference canvas; explicit element values always win. */
|
||||
export function pageParameters(doc:Series,page:Series['pages'][number],layout:Layout):Parameters{
|
||||
const p=page.parameters,s=doc.style,k=layout.width/120;
|
||||
const result:Parameters={...p,paper:p.paper||s.palette.paper,texts:{...p.texts},images:{...p.images},decorations:{...p.decorations},layers:{...p.layers}};
|
||||
for(const [id,token] of Object.entries(page.bindings)){const t=s.typography[token];result.texts[id]={...t,fontSize:t.fontSize*k,letterSpacing:t.letterSpacing*k,color:token==='caption'?s.palette.muted:s.palette.ink,...p.texts[id]}}
|
||||
for(const slot of [...layout.elements,...(p.added||[])]){if(slot.kind==='image')result.images[slot.id]={color:s.palette.accent,...p.images[slot.id]};if(slot.kind==='decoration')result.decorations[slot.id]={color:s.palette.accent,...p.decorations[slot.id]}}
|
||||
if(layout.id==='standard-01'){
|
||||
const m=s.spacing.margin,g=s.spacing.gap,w=120-2*m;
|
||||
const boxes:Record<string,number[]>={T03:[m,m,w,4],T01:[m,m+4+g,w,28],I01:[m,m+32+2*g,w,50],T02:[m,m+82+3*g,w,27],D01:[m,160-m,w,.4]};
|
||||
for(const [id,[x,y,width,height]] of Object.entries(boxes))result.layers![id]={x,y,width,height,...p.layers?.[id]};
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
import type { AppProps } from "next/app";
|
||||
import "../styles/globals.css";
|
||||
export default function App({Component,pageProps}:AppProps){return <Component {...pageProps}/>;}
|
||||
@@ -1,3 +0,0 @@
|
||||
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'));}
|
||||
@@ -1,10 +0,0 @@
|
||||
import type {GetStaticPaths,GetStaticProps} from 'next';
|
||||
import Head from 'next/head';
|
||||
import Link from 'next/link';
|
||||
import {LayoutSelection} from '../../components/LayoutSelection';
|
||||
import {tutorialPost} from '../../content/tool-links';
|
||||
import {TemplateTrial} from '../../components/TemplateTrial';
|
||||
import {ideas,type Idea} from '../../content/ideas';
|
||||
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 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,3 +0,0 @@
|
||||
import type {GetServerSideProps} from 'next';
|
||||
export default function StudioEntry(){return null}
|
||||
export const getServerSideProps:GetServerSideProps=async()=>({redirect:{destination:'/graphic',permanent:false}});
|
||||
@@ -1,35 +0,0 @@
|
||||
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} 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};
|
||||
type Doc={version:1;cards:Card[];links:[string,string][]};
|
||||
const initial: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 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.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('');
|
||||
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}|null>(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 恢复')}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]);
|
||||
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:((rect?.width||700)/2-pan.x)/zoom-160,y:(160-pan.y)/zoom,image}]});select(id)}
|
||||
function change(field:'title'|'text',value:string){commit({...doc,cards:doc.cards.map(c=>c.id===selected?{...c,[field]:value}:c)})}
|
||||
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+320))-minX,h=Math.max(...doc.cards.map(c=>c.y+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 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}><Head><title>无限画布 · Studio</title><meta name="description" content="在无限画布上组织灵感、参考素材与镜头提示词"/></Head>
|
||||
<header className={s.header}><a href="/studio" className={s.back} aria-label="返回 Studio"><ArrowLeft size={18}/></a><a href="/studio" 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}/><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}><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} ref={viewport} onWheel={e=>{if(e.ctrlKey||e.metaKey){setZoom(z=>Math.max(.2,Math.min(2,z-e.deltaY*.002)))}else setPan(p=>({x:p.x-e.deltaX,y:p.y-e.deltaY}))}} onPointerDown={e=>{if(e.target===e.currentTarget||tool==='hand'){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 setDoc({...d.before,cards:d.before.cards.map(c=>c.id===d.id?{...c,x:d.ox+(e.clientX-d.x)/zoom,y:d.oy+(e.clientY-d.y)/zoom}:c)})}} onPointerUp={()=>{const d=drag.current;if(d&&d.id!=='pan'){setPast(p=>[...p.slice(-29),d.before]);setFuture([])}drag.current=null}} onPointerCancel={()=>{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+320} ${from.y+150} C ${from.x+365} ${from.y+150},${to.x-45} ${to.y+150},${to.x} ${to.y+150}`}/>})}</svg>
|
||||
{doc.cards.map(c=><article key={c.id} className={`${s.card} ${selected===c.id?s.selected:''}`} style={{left:c.x,top:c.y}} onClick={()=>select(c.id)}><div className={s.cardHead} onPointerDown={e=>{if(tool==='hand')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} onClick={()=>{select(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}><span className={s.nodeType}>{c.kind==='prompt'?'PROMPT / 提示词':'NOTE / 灵感笔记'}</span><p>{c.text||'选择节点,在右侧写下你的想法。'}</p></div>}{c.kind==='prompt'&&<div className={s.disconnected}>○ 模型服务未接入 · 仅编辑提示词</div>}<footer>{c.kind==='image'?'视觉素材':c.kind==='prompt'?'镜头设计':'创作起点'}<span className={s.port}/></footer></article>)}
|
||||
</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={()=>setZoom(z=>Math.max(.2,z-.1))}><Minus size={16}/></button><span>{Math.round(zoom*100)}%</span><button aria-label="放大" onClick={()=>setZoom(z=>Math.min(2,z+.1))}><Plus size={16}/></button><button aria-label="适应画布" onClick={fit}><Maximize size={16}/></button></div></div></section>
|
||||
<aside className={s.inspector}><div className={s.panelHeading}><Workflow size={17}/><strong>节点属性</strong><span>{doc.cards.length} 个节点</span></div>{active?<><span className={s.eyebrow}>INSPECTOR</span><h2>{active.kind==='prompt'?'把想法描述清楚':active.kind==='image'?'让参考彼此连接':'记录灵感的起点'}</h2><label>节点名称<input maxLength={200} value={active.title} onChange={e=>change('title',e.target.value)}/></label><label>{active.kind==='prompt'?'画面描述':'笔记内容'}<textarea maxLength={10000} value={active.text} onChange={e=>change('text',e.target.value)} placeholder="写下你的创作想法…"/></label>{active.kind==='image'&&<button className={s.wide} onClick={()=>file.current?.click()}><ImagePlus size={16}/>选择参考图片</button>}{active.kind==='prompt'&&<div className={s.notice}><Sparkles size={18}/><strong>为下一次创作做好准备</strong><p>提示词可保存在项目中。当前没有连接模型服务,暂不支持 AI 图片或视频生成。</p></div>}<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 className={s.wide} disabled={!link} onClick={()=>{if(!doc.links.some(l=>l[0]===selected&&l[1]===link))commit({...doc,links:[...doc.links,[selected,link]]});setLink('')}}><Link2 size={16}/>连接节点</button>{doc.links.filter(l=>l.includes(selected)).map(([a,b])=><button className={s.connection} 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} <span>断开 ×</span></button>)}<button className={s.delete} onClick={()=>{commit({...doc,cards:doc.cards.filter(c=>c.id!==selected),links:doc.links.filter(l=>!l.includes(selected))});select('')}}><Trash2 size={15}/>删除节点</button></>:<><h2>给灵感留一点空间</h2><p>选择画布中的节点来编辑,或从左侧添加新内容。</p><button className={s.wide} onClick={()=>add('text')}><Plus size={16}/>添加笔记</button></>}<div className={s.help}>拖动节点标题移动节点<br/>滚轮平移 · Ctrl / ⌘ + 滚轮缩放<br/>草稿仅存于当前浏览器,请及时导出。</div></aside></div>
|
||||
<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('无法导入:项目格式错误或文件过大')}}}/>
|
||||
</main>;
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "react-jsx",
|
||||
"incremental": true,
|
||||
"baseUrl": ".",
|
||||
"paths": { "@/*": ["./src/*"] }
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
@@ -6,32 +6,39 @@ 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
|
||||
# Download cache depends on lockfile and workspace policy, not package scripts.
|
||||
RUN pnpm fetch --lockfile-dir=/repo/apps/waterfall-wiki --fetch-timeout=300000 --network-concurrency=8
|
||||
COPY packages/brand/package.json /repo/packages/brand/package.json
|
||||
COPY apps/waterfall-wiki/package.json ./
|
||||
RUN pnpm install --offline --frozen-lockfile
|
||||
RUN pnpm --filter @prompt/waterfall-wiki install --offline --frozen-lockfile
|
||||
|
||||
FROM dependencies AS build
|
||||
# .dockerignore excludes docs and operational notes without extra legacy COPY snapshots.
|
||||
# Brand source changes invalidate compilation, not dependency installation.
|
||||
COPY packages/brand/ /repo/packages/brand/
|
||||
COPY apps/waterfall-wiki/ .
|
||||
RUN pnpm typecheck && pnpm test && pnpm build && pnpm test:smoke
|
||||
|
||||
FROM build AS integration
|
||||
ENV VERIFY_CANVAS_UI=1
|
||||
USER node
|
||||
CMD ["npm", "run", "test:integration"]
|
||||
|
||||
FROM base AS runtime
|
||||
WORKDIR /app/apps/waterfall-wiki
|
||||
ENV NODE_ENV=production HOSTNAME=0.0.0.0 PORT=3000 \
|
||||
FFMPEG_PATH=/usr/bin/ffmpeg FFPROBE_PATH=/usr/bin/ffprobe
|
||||
COPY --from=build --chown=node:node /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/
|
||||
COPY --chown=node:node apps/waterfall-wiki/src/content/managed-media.json ./src/content/managed-media.json
|
||||
USER node
|
||||
EXPOSE 3000
|
||||
CMD ["node", "server.js"]
|
||||
|
||||
@@ -0,0 +1,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,9 +1,21 @@
|
||||
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},
|
||||
{source:'/studio/infinite_canvas',destination:'/infinite_canvas',permanent:true},
|
||||
{source:'/studio',destination:'/workspace',permanent:false},
|
||||
]},
|
||||
async rewrites(){return [
|
||||
{source:"/studio/:path*",destination:"http://prompt-studio:3000/studio/:path*"},
|
||||
...Object.entries(managedMedia).filter(([path])=>path.startsWith("apps/waterfall-wiki/public/")).map(([path,asset])=>({source:"/"+path.slice("apps/waterfall-wiki/public/".length),destination:"/api/media/"+asset.id})),
|
||||
{source:"/studio/api/:path*",destination:"/api/studio-ui/:path*"},
|
||||
{source:"/studio/brand/:path*",destination:"/brand/:path*"},
|
||||
{source:"/studio/favicon.ico",destination:"/favicon.ico"},
|
||||
{source:"/studio/apple-touch-icon.png",destination:"/apple-touch-icon.png"},
|
||||
{source:"/index.md",destination:"/api/agent-index"},
|
||||
{source:"/skills/index.md",destination:"/api/guidance/skills"},
|
||||
{source:"/skills/:path+/index.md",destination:"/api/guidance/skills/:path+"},
|
||||
@@ -11,7 +23,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,16 +6,17 @@
|
||||
"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",
|
||||
"test:smoke": "node scripts/smoke.mjs",
|
||||
"test": "tsx --test tests/api-documentation.test.ts tests/health.test.ts tests/model.test.ts tests/learning-series.test.ts tests/skill-docs.test.ts tests/wiki.test.ts tests/explore.test.ts tests/preview.test.ts tests/note-markdown.test.tsx tests/api-publication.test.ts tests/discovery.test.ts tests/auth.test.ts tests/account.test.ts tests/guidance.test.ts tests/guidance-publishing.test.ts && node --test tests/studio/*.test.mjs",
|
||||
"test:smoke": "node scripts/smoke.mjs && node scripts/studio-smoke.mjs",
|
||||
"storage:prepare": "tsx scripts/bootstrap.ts",
|
||||
"content:publish-learning": "tsx scripts/publish-learning-series.ts",
|
||||
"worker": "tsx src/worker.ts",
|
||||
"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",
|
||||
|
||||
@@ -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
|
||||
|
||||
|
Before Width: | Height: | Size: 204 KiB |
|
Before Width: | Height: | Size: 694 KiB |
|
Before Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 3.7 MiB |
|
Before Width: | Height: | Size: 1.8 MiB |
@@ -47,13 +47,16 @@ async function main() {
|
||||
if (command === 'verify' || command === 'exercise') {
|
||||
const request = (path:string,init:RequestInit={}) => fetch(base+path,{...init,redirect:'manual'});
|
||||
const buildId = readFileSync('.next/BUILD_ID','utf8').trim();
|
||||
assert.equal((await request('/')).status,200,'Public knowledge homepage');
|
||||
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');
|
||||
const homeData = await request(`/_next/data/${buildId}/index.json`);
|
||||
assert.ok([200,404].includes(homeData.status),'Public homepage data is readable or absent for static pages');
|
||||
assert.ok(!isLoginRedirect(homeData.headers.get('location') || homeData.headers.get('x-nextjs-redirect'),base));
|
||||
assert.equal((await request('/api/posts',{method:'POST',headers:{Cookie:'wiki_account='+ 'a'.repeat(64)},body:'{}'})).status,401,'Account cookies never grant admin rights');
|
||||
for (const path of ['/library/import','/skills/shadow-puppet/egret-example.png','/studio',`/_next/data/${buildId}/library/import.json`]) {
|
||||
for (const path of ['/library/import','/skills/shadow-puppet/egret-example.png','/studio/layouts/ref-01',`/_next/data/${buildId}/library/import.json`]) {
|
||||
const response = await request(path); assert.equal(response.status,307,path); const destination = response.headers.get('location') || response.headers.get('x-nextjs-redirect'); assert.ok(isLoginRedirect(destination,base),path+' must redirect to login: '+destination);
|
||||
}
|
||||
for (const path of ['/api/feed','/api/upload','/studio/api/export/test','/api/media/missing']) assert.equal((await request(path)).status,401,path);
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
// Only invoked inside the dedicated test network with this run's bucket name.
|
||||
import {S3Client,ListObjectsV2Command,DeleteObjectsCommand,DeleteBucketCommand} from '@aws-sdk/client-s3';
|
||||
const bucket=process.env.S3_BUCKET;
|
||||
if (!/^ci-[1-9][0-9]*$/.test(bucket || '')) throw Error('Refusing non-CI bucket');
|
||||
const client=new S3Client({endpoint:'http://objects:9000',region:'us-east-1',forcePathStyle:true,credentials:{accessKeyId:process.env.S3_ACCESS_KEY,secretAccessKey:process.env.S3_SECRET_KEY}});
|
||||
try {
|
||||
// Re-list the first page after each deletion so no pagination token can skip keys.
|
||||
for (;;) {
|
||||
const result=await client.send(new ListObjectsV2Command({Bucket:bucket,MaxKeys:1000}));
|
||||
const keys=(result.Contents || []).map(({Key})=>({Key}));
|
||||
if (!keys.length) break;
|
||||
const deleted=await client.send(new DeleteObjectsCommand({Bucket:bucket,Delete:{Objects:keys,Quiet:true}}));
|
||||
if (deleted.Errors?.length) throw Error('CI object cleanup failed');
|
||||
}
|
||||
await client.send(new DeleteBucketCommand({Bucket:bucket}));
|
||||
} catch (error) {
|
||||
if (error.name !== 'NoSuchBucket') throw error;
|
||||
}
|
||||
@@ -2,7 +2,13 @@ const {chromium}=require('playwright-core');
|
||||
const assert=require('node:assert/strict');
|
||||
(async()=>{const browser=await chromium.launch({channel:'chrome',headless:true});try{const page=await browser.newPage({viewport:{width:1440,height:1000}});const errors=[];page.on('pageerror',e=>errors.push(e.message));
|
||||
await page.route('**/api/account/session',r=>r.fulfill({json:{user:null}}));
|
||||
await page.goto((process.env.STUDIO_TEST_ORIGIN||'http://127.0.0.1:3011')+'/studio/graphic');
|
||||
await page.goto((process.env.STUDIO_TEST_ORIGIN||'http://8.211.154.134:18131')+'/graphic');
|
||||
await page.getByRole('button',{name:'切换图层面板',exact:true}).click();
|
||||
assert.equal(await page.getByRole('button',{name:'图层',exact:true}).isVisible(),false);
|
||||
await page.getByRole('button',{name:'切换图层面板',exact:true}).click();
|
||||
await page.getByRole('button',{name:'切换属性面板',exact:true}).click();
|
||||
assert.equal(await page.getByRole('button',{name:'备份设计 JSON',exact:true}).isVisible(),false);
|
||||
await page.getByRole('button',{name:'切换属性面板',exact:true}).click();
|
||||
await page.getByRole('button',{name:'添加文字',exact:true}).click();
|
||||
await page.getByRole('textbox',{name:'文字内容',exact:true}).fill('设计从这里开始');
|
||||
await page.getByRole('button',{name:'添加矩形',exact:true}).click();
|
||||
@@ -1,17 +1,19 @@
|
||||
import {readFile,readdir} from 'node:fs/promises';
|
||||
import {readFile} from 'node:fs/promises';
|
||||
import {fileURLToPath} from 'node:url';
|
||||
import {join,dirname} from 'node:path';
|
||||
import {createHash} from 'node:crypto';
|
||||
import {requiredPackImages} from './verify-character-pack.mjs';
|
||||
const root=join(dirname(fileURLToPath(import.meta.url)),'..','seed','characters');
|
||||
const base=process.env.PROMPT_IMPORT_BASE_URL||'http://127.0.0.1:3000';
|
||||
const media=JSON.parse(await readFile(join(root,'..','..','src','content','managed-media.json'),'utf8'));
|
||||
const packFiles=pack=>Object.entries(media).filter(([name])=>name.startsWith(`apps/waterfall-wiki/seed/characters/${pack.directory}/`)).sort(([a],[b])=>a.localeCompare(b));
|
||||
const packs=JSON.parse(await readFile(join(root,'packs.json'),'utf8'));
|
||||
async function verify(pack){
|
||||
let cursor='',post;const seen=new Set();
|
||||
do{const response=await fetch(base+'/api/feed?'+new URLSearchParams({section:'images',...(cursor?{cursor}:{})}));if(!response.ok)throw Error('Cannot verify character feed: '+response.status);const page=await response.json();post=page.posts.find(p=>p.slug===pack.slug);cursor=page.nextCursor||'';if(cursor&&seen.has(cursor))throw Error('Character feed cursor did not advance');seen.add(cursor);}while(!post&&cursor);
|
||||
if(!post)throw Error('Character pack missing: '+pack.slug);
|
||||
const files=(await readdir(join(root,pack.directory))).filter(n=>n.endsWith('.png')).sort();if(files.length!==pack.count)throw Error('Seed file count mismatch: '+pack.slug);
|
||||
const hashes=await Promise.all(files.map(async file=>createHash('sha256').update(await readFile(join(root,pack.directory,file))).digest('hex')));
|
||||
const files=packFiles(pack);if(files.length!==pack.count)throw Error('Seed file count mismatch: '+pack.slug);
|
||||
const hashes=files.map(([,asset])=>asset.sha256);
|
||||
const originals=requiredPackImages(hashes,post.assets.filter(a=>a.kind==='image'));
|
||||
for(const image of originals){const media=await fetch(`${base}/api/media/${image.id}`,{method:'HEAD'});if(!media.ok||media.headers.get('content-type')!=='image/png')throw Error('Original PNG unavailable');}
|
||||
console.log(`Verified: /p/${pack.slug} (${originals.length} original PNGs preserved; editorial covers allowed)`);
|
||||
@@ -20,10 +22,14 @@ for(const pack of packs){
|
||||
const existing=await fetch(`${base}/p/${pack.slug}`);
|
||||
if(existing.ok){console.log(`Already imported: /p/${pack.slug}`);await verify(pack);continue;}
|
||||
if(existing.status!==404)throw new Error(`Cannot check pack ${pack.slug}: ${existing.status}`);
|
||||
const files=(await readdir(join(root,pack.directory))).filter(n=>n.endsWith('.png')).sort();
|
||||
const files=packFiles(pack);
|
||||
if(files.length!==pack.count)throw new Error(`Expected ${pack.count} PNGs for ${pack.slug}, got ${files.length}`);
|
||||
const data=new FormData();data.set('title',pack.title);data.set('slug',pack.slug);data.set('description',pack.description);
|
||||
for(const file of files)data.append('files',new Blob([await readFile(join(root,pack.directory,file))],{type:'image/png'}),file);
|
||||
for(const [name,asset] of files){
|
||||
const response=await fetch(`${base}/api/media/${asset.id}`);if(!response.ok)throw Error('Cannot read migrated original: '+name);
|
||||
const bytes=Buffer.from(await response.arrayBuffer());if(bytes.length!==asset.bytes||createHash('sha256').update(bytes).digest('hex')!==asset.sha256)throw Error('Original checksum mismatch: '+name);
|
||||
data.append('files',new Blob([bytes],{type:'image/png'}),name.split('/').pop());
|
||||
}
|
||||
const response=await fetch(`${base}/api/skills/street-people`,{method:'POST',body:data});
|
||||
if(response.status!==201&&response.status!==409)throw new Error(`Import ${pack.slug}: ${response.status} ${await response.text()}`);
|
||||
if(response.status===409&&!(await fetch(`${base}/p/${pack.slug}`)).ok)throw new Error(`Conflicting import ${pack.slug}`);
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
// Isolated browser context against the designated deployed server only.
|
||||
const {chromium}=require('playwright-core');
|
||||
const assert=require('node:assert/strict');
|
||||
const fs=require('node:fs/promises');
|
||||
(async()=>{
|
||||
const base=process.env.BASE_URL||'http://8.211.154.134:18131';
|
||||
assert.equal(base,'http://8.211.154.134:18131');
|
||||
const browser=await chromium.launch(process.env.CHROMIUM_PATH?{executablePath:process.env.CHROMIUM_PATH,headless:true,args:['--no-sandbox','--disable-dev-shm-usage']}:{channel:'chrome',headless:true});
|
||||
let page;
|
||||
try{
|
||||
const context=await browser.newContext({viewport:{width:1600,height:1100},storageState:process.env.STORAGE_STATE||undefined});
|
||||
page=await context.newPage();const errors=[];let choosers=0;
|
||||
page.on('pageerror',e=>errors.push(e.message));page.on('filechooser',()=>choosers++);
|
||||
const snapshot=()=>page.evaluate(()=>JSON.parse(localStorage.getItem('studio.infinite-canvas.v1')));
|
||||
const node=id=>page.locator(`[data-node-id="${id}"]`);
|
||||
const content=id=>node(id).locator('[data-node-content]');
|
||||
const waitField=(id,key,value)=>page.waitForFunction(({id,key,value})=>JSON.parse(localStorage.getItem('studio.infinite-canvas.v1')).cards.find(n=>n.id===id)?.[key]===value,{id,key,value});
|
||||
const composer=page.getByRole('region',{name:'节点编辑器'});
|
||||
const capture=async name=>{if(process.env.SCREENSHOT_DIR){await fs.mkdir(process.env.SCREENSHOT_DIR,{recursive:true});await page.screenshot({path:process.env.SCREENSHOT_DIR+'/'+name+'.png'})}};
|
||||
async function unobstructed(id){const box=await node(id).boundingBox();assert.ok(box);for(const card of (await snapshot()).cards.filter(n=>n.id!==id)){const other=await node(card.id).boundingBox();assert.ok(other);assert.equal(box.x<other.x+other.width&&box.x+box.width>other.x&&box.y<other.y+other.height&&box.y+box.height>other.y,false,`${id} overlaps ${card.id}`)}}
|
||||
const close=(a,b)=>assert.ok(Math.abs(a-b)<2,`${a} ≈ ${b}`);
|
||||
async function drag(handle,dx,dy){const b=await handle.boundingBox();assert.ok(b);await page.mouse.move(b.x+b.width/2,b.y+b.height/2);await page.mouse.down();await page.mouse.move(b.x+b.width/2+dx,b.y+b.height/2+dy,{steps:12});await page.mouse.up()}
|
||||
const canvasResponse=await page.goto(base+'/infinite_canvas',{waitUntil:'commit',timeout:60000});
|
||||
assert.ok(canvasResponse?.ok(),'Canvas document returns HTTP success');
|
||||
await page.locator('button[data-theme-ready="true"]').waitFor();
|
||||
await page.getByRole('button',{name:'从示例开始 ↗',exact:true}).click();
|
||||
await page.getByRole('button',{name:'适应画布',exact:true}).click();
|
||||
await content('brief').click({position:{x:20,y:20}});
|
||||
assert.equal(await node('brief').getAttribute('data-selected'),'true');assert.equal(await node('brief').getAttribute('data-editing'),'false');
|
||||
await page.waitForTimeout(700);
|
||||
await content('brief').click({position:{x:20,y:20}});
|
||||
await node('brief').getByRole('textbox',{name:'01 / 创意简报 内容'}).fill('选择后再次点击进入编辑');
|
||||
await waitField('brief','text','选择后再次点击进入编辑');
|
||||
await node('brief').getByRole('button',{name:'加粗',exact:true}).click();
|
||||
assert.equal((await snapshot()).cards.find(n=>n.id==='brief').style.bold,true);
|
||||
await node('brief').getByRole('button',{name:'斜体',exact:true}).click();
|
||||
await node('brief').getByLabel('文字颜色',{exact:true}).fill('#78905e');
|
||||
await node('brief').getByRole('combobox',{name:'文字样式',exact:true}).selectOption('24');
|
||||
await page.waitForFunction(()=>{const n=JSON.parse(localStorage.getItem('studio.infinite-canvas.v1')).cards.find(n=>n.id==='brief');return n.style.bold&&n.style.italic&&n.style.color==='#78905e'&&n.style.size===24});
|
||||
assert.deepEqual(await node('brief').getByRole('textbox',{name:'01 / 创意简报 内容'}).evaluate(el=>{const s=getComputedStyle(el);return {size:s.fontSize,weight:s.fontWeight,style:s.fontStyle,color:s.color}}),{size:'24px',weight:'700',style:'italic',color:'rgb(120, 144, 94)'});
|
||||
const compact=(await snapshot()).cards.find(n=>n.id==='brief');
|
||||
await node('brief').getByRole('button',{name:'展开文本节点',exact:true}).click();
|
||||
const expanded=(await snapshot()).cards.find(n=>n.id==='brief');assert.ok(expanded.width>(compact.width||320)&&expanded.height>(compact.height||350));
|
||||
await capture('canvas-text-format-desktop');
|
||||
await page.getByRole('button',{name:'撤销',exact:true}).click();
|
||||
assert.equal((await snapshot()).cards.find(n=>n.id==='brief').width,compact.width);assert.equal((await snapshot()).cards.find(n=>n.id==='brief').height,compact.height);
|
||||
await page.keyboard.press('Escape');
|
||||
const before=(await snapshot()).cards.find(n=>n.id==='brief');
|
||||
const z=await page.locator('[class*=world]').evaluate(el=>new DOMMatrix(getComputedStyle(el).transform).a);
|
||||
await drag(content('brief'),45,25);
|
||||
const after=(await snapshot()).cards.find(n=>n.id==='brief');close(after.x-before.x,45/z);close(after.y-before.y,25/z);
|
||||
assert.equal(await node('brief').getAttribute('data-editing'),'false');
|
||||
|
||||
await content('reference').click();assert.equal(await composer.count(),0);
|
||||
await page.waitForTimeout(700);await content('reference').click();await composer.waitFor();assert.equal(choosers,0,'Entering image editing must not force a file picker');
|
||||
const panelBefore=await composer.boundingBox(),imageBefore=await node('reference').boundingBox();
|
||||
await capture('canvas-composer-desktop');
|
||||
await page.getByRole('button',{name:'缩小',exact:true}).click();
|
||||
const panelAfter=await composer.boundingBox(),imageAfter=await node('reference').boundingBox();
|
||||
close(panelBefore.width,panelAfter.width);close(panelBefore.height,panelAfter.height);assert.ok(imageAfter.width<imageBefore.width);
|
||||
const chooser=page.waitForEvent('filechooser');await composer.getByRole('button',{name:'上传图片',exact:true}).click();
|
||||
await (await chooser).setFiles({name:'reference.png',mimeType:'image/png',buffer:Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+ip1sAAAAASUVORK5CYII=','base64')});
|
||||
await page.waitForFunction(()=>!!JSON.parse(localStorage.getItem('studio.infinite-canvas.v1')).cards.find(n=>n.id==='reference').image);
|
||||
await node('reference').getByRole('button',{name:'图片高清 ↗',exact:true}).click();
|
||||
const upscale=(await snapshot()).cards.find(n=>n.operation==='upscale');assert.ok(upscale);
|
||||
await unobstructed(upscale.id);
|
||||
const download=page.waitForEvent('download');await composer.getByRole('button',{name:'导出任务',exact:true}).click();
|
||||
const task=JSON.parse(await fs.readFile(await (await download).path(),'utf8'));
|
||||
assert.equal(task.target.id,upscale.id);assert.equal(task.references[0].id,'reference');assert.ok(task.references[0].image);
|
||||
|
||||
await page.keyboard.press('Escape');await page.getByRole('button',{name:'适应画布',exact:true}).click();
|
||||
await content('brief').click({position:{x:20,y:20}});
|
||||
await node('brief').getByRole('button',{name:'引用 01 / 创意简报 生成',exact:true}).click();
|
||||
await page.getByRole('dialog',{name:'画布操作'}).getByRole('button',{name:'脚本',exact:true}).click();
|
||||
const script=(await snapshot()).cards.find(n=>n.kind==='script');assert.ok(script);
|
||||
await unobstructed(script.id);
|
||||
await composer.getByRole('textbox',{name:'创作要求',exact:true}).fill('基于简报写六个分镜');
|
||||
await waitField(script.id,'instruction','基于简报写六个分镜');assert.equal((await snapshot()).cards.find(n=>n.id===script.id).text,'');
|
||||
await composer.getByRole('button',{name:'画布参考',exact:true}).click();
|
||||
await composer.getByRole('group',{name:'选择引用节点'}).getByRole('button',{name:'02 / 视觉参考',exact:true}).click();
|
||||
assert.ok((await snapshot()).links.some(([a,b])=>a==='reference'&&b===script.id));
|
||||
await composer.getByRole('button',{name:'移除引用 01 / 创意简报',exact:true}).click();
|
||||
assert.ok(!(await snapshot()).links.some(([a,b])=>a==='brief'&&b===script.id));
|
||||
await page.keyboard.press('Escape');
|
||||
await page.getByRole('button',{name:'适应画布',exact:true}).click();
|
||||
await content('reference').click();await node('reference').getByRole('button',{name:'02 / 视觉参考 操作',exact:true}).click();
|
||||
await page.getByRole('button',{name:'删除节点',exact:true}).click();
|
||||
assert.ok((await snapshot()).cards.some(n=>n.id===script.id));assert.ok((await snapshot()).links.every(l=>!l.includes('reference')));
|
||||
await page.getByRole('button',{name:'撤销',exact:true}).click();assert.ok((await snapshot()).links.some(([a,b])=>a==='reference'&&b===script.id));
|
||||
await page.keyboard.press('Escape');
|
||||
await content('brief').click({position:{x:20,y:20}});
|
||||
const priorWidth=(await snapshot()).cards.find(n=>n.id==='brief').width;
|
||||
await drag(node('brief').getByRole('button',{name:'调整 01 / 创意简报 尺寸',exact:true}),50,30);
|
||||
assert.ok((await snapshot()).cards.find(n=>n.id==='brief').width>(priorWidth||320));
|
||||
await page.getByRole('button',{name:'撤销',exact:true}).click();assert.equal((await snapshot()).cards.find(n=>n.id==='brief').width,priorWidth);
|
||||
await page.getByRole('button',{name:'重做',exact:true}).click();
|
||||
await page.getByRole('button',{name:'移动画布',exact:true}).click();
|
||||
await page.mouse.move(15,300);await page.mouse.down();await page.mouse.move(75,340,{steps:8});await page.mouse.up();
|
||||
assert.equal(await page.evaluate(()=>{const path=[...document.querySelectorAll('svg path')].find(p=>p.getAttribute('d')?.startsWith('M '));const d=path.getAttribute('d').match(/-?\d+(?:\.\d+)?/g).map(Number),point=new DOMPoint(d[0],d[1]).matrixTransform(path.getScreenCTM()),card=document.querySelector('[data-node-id="brief"]').getBoundingClientRect();return Math.abs(point.x-card.right)<3&&Math.abs(point.y-(card.top+card.height/2))<3}),true);
|
||||
const worldAt=()=>page.locator('[class*=world]').evaluate(el=>{const m=new DOMMatrix(getComputedStyle(el).transform);return {x:(1450-m.e)/m.a,y:(150-m.f)/m.d,z:m.a}});
|
||||
const anchorBefore=await worldAt();await page.mouse.move(1450,150);await page.keyboard.down('Control');await page.mouse.wheel(0,-80);await page.keyboard.up('Control');
|
||||
await page.waitForFunction(z=>new DOMMatrix(getComputedStyle(document.querySelector('[class*=world]')).transform).a!==z,anchorBefore.z);
|
||||
const anchorAfter=await worldAt();close(anchorBefore.x,anchorAfter.x);close(anchorBefore.y,anchorAfter.y);
|
||||
const saved=await snapshot();await page.reload({waitUntil:'commit',timeout:60000});await page.locator('button[data-theme-ready="true"]').waitFor();await page.waitForFunction(()=>!!localStorage.getItem('studio.infinite-canvas.v1'));assert.deepEqual(await snapshot(),saved);
|
||||
const themeToggle=page.getByRole('button',{name:'切换明暗主题',exact:true});
|
||||
await page.waitForFunction(()=>document.querySelector('[data-theme-ready]')?.dataset.themeReady==='true');
|
||||
const initialMode=await page.evaluate(()=>document.documentElement.dataset.mode);
|
||||
assert.ok(['light','dark'].includes(initialMode));
|
||||
for(const mode of [initialMode==='light'?'dark':'light',initialMode]){
|
||||
await themeToggle.click();
|
||||
const matchesTheme=mode=>{
|
||||
const root=document.documentElement,canvas=document.querySelector('main');
|
||||
return root.dataset.mode===mode&&root.dataset.theme===`canvas-${mode}`&&localStorage.getItem('endoleap.theme')===mode&&getComputedStyle(canvas).backgroundColor===(mode==='dark'?'rgb(20, 20, 20)':'rgb(250, 250, 250)');
|
||||
};
|
||||
await page.waitForFunction(matchesTheme,mode);
|
||||
await page.reload({waitUntil:'commit'});
|
||||
await page.waitForFunction(()=>document.querySelector('[data-theme-ready]')?.dataset.themeReady==='true');
|
||||
await page.waitForFunction(matchesTheme,mode);
|
||||
assert.deepEqual(await snapshot(),saved,'Theme changes must preserve canvas content');
|
||||
await capture('canvas-theme-'+mode);
|
||||
}
|
||||
if(process.env.SCREENSHOT_DIR){await fs.mkdir(process.env.SCREENSHOT_DIR,{recursive:true});await page.screenshot({path:process.env.SCREENSHOT_DIR+'/canvas-nodes-desktop.png'});}
|
||||
await page.setViewportSize({width:390,height:844});assert.ok(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth));
|
||||
const viewport=await page.locator('[class*=viewport]').boundingBox();assert.equal(viewport.width,390);assert.equal(viewport.height,844);
|
||||
if(process.env.SCREENSHOT_DIR)await page.screenshot({path:process.env.SCREENSHOT_DIR+'/canvas-nodes-mobile.png'});
|
||||
assert.deepEqual(errors,[]);console.log('Passed: selected-node re-click editing, drag, formatting, fixed composer, upload, references, task export, deletion/undo, reload, both persistent root themes and mobile bounds');
|
||||
}catch(error){if(process.env.SCREENSHOT_DIR&&page){await fs.mkdir(process.env.SCREENSHOT_DIR,{recursive:true});await page.screenshot({path:process.env.SCREENSHOT_DIR+'/canvas-failure.png'}).catch(()=>{})}throw error}finally{await browser.close()}
|
||||
})().catch(e=>{console.error(e);process.exitCode=1});
|
||||
@@ -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;}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import {writeFile,unlink} from 'node:fs/promises';
|
||||
import sharp from 'sharp';
|
||||
import { spawn } from "node:child_process";
|
||||
import assert from "node:assert/strict";
|
||||
import { setTimeout } from "node:timers/promises";
|
||||
@@ -15,7 +17,9 @@ async function availablePort() {
|
||||
|
||||
const port = process.env.SMOKE_PORT || await availablePort();
|
||||
const release = "smoke-test";
|
||||
const child = spawn(process.execPath, [".next/standalone/server.js"], {
|
||||
const previewFixture='.next/standalone/apps/waterfall-wiki/public/__smoke-preview.png';
|
||||
await writeFile(previewFixture,await sharp({create:{width:48,height:48,channels:3,background:'#eee'}}).png().toBuffer());
|
||||
const child = spawn(process.execPath, [".next/standalone/apps/waterfall-wiki/server.js"], {
|
||||
// This loopback-only child checks retained resources without production credentials.
|
||||
// Production keeps failing closed; the bypass requires both explicit test settings.
|
||||
env: { ...process.env, AUTH_CONFIG_FILE: "", AUTH_DISABLED_FOR_TESTS: "true", PORT: port, HOSTNAME: "127.0.0.1", RELEASE_SHA: release },
|
||||
@@ -42,10 +46,17 @@ try {
|
||||
const id=indexText.match(/\/explore\/([a-f0-9]{24})/)?.[1];assert.ok(id);
|
||||
const agent=await fetch(`http://127.0.0.1:${port}/explore/${id}?agent_mode=True`);
|
||||
assert.equal(agent.status,200);assert.match(agent.headers.get('content-type'),/text\/plain/);const agentText=await agent.text();assert.match(agentText,/^# /);assert.doesNotMatch(agentText,/^\s*(?:<!doctype html|<html)/i);
|
||||
const preview=await fetch(`http://127.0.0.1:${port}/api/preview?source=${encodeURIComponent('/skills/shadow-puppet/egret-example.png')}&width=480`);
|
||||
const preview=await fetch(`http://127.0.0.1:${port}/api/preview?source=${encodeURIComponent('/__smoke-preview.png')}&width=480`);
|
||||
assert.equal(preview.status,200);assert.match(preview.headers.get('content-type'),/image\/webp/);assert.ok((await preview.arrayBuffer()).byteLength<=100000);
|
||||
console.log("Production smoke test passed");
|
||||
const home=await fetch(`http://127.0.0.1:${port}/`).then(r=>r.text());
|
||||
for(const href of ['/graphic','/infinite_canvas','/account?next=/workspace'])assert.ok(home.includes(`href="${href}"`),href);
|
||||
for(const [legacy,canonical] of [['/studio/graphic','/graphic'],['/studio/infinite_canvas','/infinite_canvas']]){
|
||||
const redirect=await fetch(`http://127.0.0.1:${port}${legacy}`,{redirect:'manual'});
|
||||
assert.equal(redirect.status,308);assert.equal(redirect.headers.get('location'),canonical);
|
||||
}
|
||||
console.log("Production smoke test passed, including landing entry points and canonical redirects");
|
||||
} finally {
|
||||
await unlink(previewFixture);
|
||||
if (child.exitCode === null && child.signalCode === null) {
|
||||
const exited = once(child, "exit");
|
||||
child.kill("SIGTERM");
|
||||
|
||||
@@ -6,13 +6,13 @@ const standardId='00000000-0000-4000-8000-000000000003';
|
||||
const seriesId='00000000-0000-4000-8000-000000000004';
|
||||
const doc=JSON.parse(readFileSync(new URL('../fixtures/autumn-journal.json',import.meta.url))).document;
|
||||
const assetId='00000000-0000-4000-8000-000000000005';doc.sources=[{assetId,author:'CI fixture',license:'test only',url:'generated',prompt:'test image',tool:'fixture'}];for(const p of doc.pages)p.parameters.images.I01.assetId=assetId;
|
||||
let reads=0;
|
||||
const wiki=createHttpServer((req,res)=>{reads++;assert.equal(req.headers.cookie,'session=smoke-test');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'}}}))});
|
||||
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;
|
||||
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').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'},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');
|
||||
const socket=createServer();socket.listen(0,'127.0.0.1');await once(socket,'listening');const port=socket.address().port;await new Promise(r=>socket.close(r));const child=spawn(process.execPath,[process.env.STUDIO_STANDALONE_SERVER || '.next/standalone/apps/waterfall-wiki/server.js'],{env:{...process.env,PORT:String(port),HOSTNAME:'127.0.0.1',AUTH_DISABLED_FOR_TESTS:'true',AUTH_CONFIG_FILE:'',STUDIO_WIKI_ORIGIN:'http://127.0.0.1:'+wiki.address().port},stdio:'inherit'}),base='http://127.0.0.1:'+port;
|
||||
try{let ready=false;for(let i=0;i<60;i++){try{if((await fetch(base+'/studio/api/health')).ok){ready=true;break}}catch{}await setTimeout(250)}assert.ok(ready);const entry=await fetch(base+'/studio',{redirect:'manual'});assert.equal(entry.status,307);assert.equal(entry.headers.get('location'),'/workspace');const library=await fetch(base+'/studio/graphic').then(r=>r.text());assert.ok(library.includes('GRAPHIC'));assert.ok(!library.includes('ref-27')); const catalog=await fetch(base+'/studio/api/layouts').then(r=>r.json());assert.equal(catalog.layouts.length,28);
|
||||
for(const l of catalog.layouts){const page=await fetch(base+l.url);assert.equal(page.status,200,l.id);const html=await page.text();assert.ok(html.includes(l.name));assert.ok(html.includes('data-layout-id="'+l.id+'"'));assert.ok(!/<svg|<img|data:image|trial-controls|page-tools|workbench-top|contenteditable|编辑页面/.test(html),'Default page must use native elements, not source imagery');const schema=await fetch(base+l.api).then(r=>r.json());assert.equal(schema.id,l.id);for(const s of schema.elements)assert.ok(html.includes('data-element="'+s.id+'"'));if(l.family==='standard'){assert.equal((await fetch(base+l.api+'?format=svg')).status,400);continue;}const original=await fetch(base+l.api+'?format=svg',{headers:{cookie:'session=smoke-test'}}).then(r=>r.text());assert.ok(original.includes('data-layout-id="'+l.id+'"'));const text=schema.elements.find(e=>e.kind==='text');const result=await fetch(base+l.api,{method:'POST',headers:{'Content-Type':'application/json',cookie:'session=smoke-test'},body:JSON.stringify({texts:{[text.id]:{value:'API render <test>'}}})});assert.equal(result.status,200);assert.ok((await result.text()).includes('<'));}
|
||||
assert.equal(reads,0,'Default pages never load a saved design');assert.ok(mediaReads>0,'SVG exports load isolated media fixtures');
|
||||
const headers={cookie:'session=smoke-test'};
|
||||
for(const id of [designId,standardId]){
|
||||
const saved=await fetch(base+'/studio/template-layout?id='+id,{headers});assert.equal(saved.status,200);const savedHtml=await saved.text();assert.ok(savedHtml.includes('确定的页面文字'));assert.ok(savedHtml.includes('新增元素已恢复'));assert.ok(savedHtml.includes('data-element="user-smoke"'));assert.ok(savedHtml.includes('rotate(25deg)'));assert.ok(savedHtml.includes('opacity:0.7'));assert.ok(savedHtml.includes('data-design-id="'+id+'"'));assert.ok(savedHtml.includes('#123456'));assert.ok(!savedHtml.includes('data-element="D01"'));assert.ok(!savedHtml.includes('data:image'));assert.ok(!/page-tools|workbench-top|contenteditable|编辑页面/.test(savedHtml));
|
||||
|
Before Width: | Height: | Size: 1.8 MiB |
|
Before Width: | Height: | Size: 1.7 MiB |
|
Before Width: | Height: | Size: 1.6 MiB |
|
Before Width: | Height: | Size: 1.8 MiB |
|
Before Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 769 KiB |
|
Before Width: | Height: | Size: 813 KiB |
|
Before Width: | Height: | Size: 1.5 MiB |
|
Before Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 700 KiB |
|
Before Width: | Height: | Size: 1.5 MiB |
|
Before Width: | Height: | Size: 986 KiB |
|
Before Width: | Height: | Size: 854 KiB |
|
Before Width: | Height: | Size: 884 KiB |
|
Before Width: | Height: | Size: 1.0 MiB |
|
Before Width: | Height: | Size: 782 KiB |
|
Before Width: | Height: | Size: 1020 KiB |
|
Before Width: | Height: | Size: 866 KiB |
|
Before Width: | Height: | Size: 1.6 MiB |
@@ -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,7 +1,8 @@
|
||||
import { randomBytes, scrypt as derive, timingSafeEqual, createHash } from 'node:crypto';
|
||||
import { promisify } from 'node:util';
|
||||
const scrypt = promisify(derive);
|
||||
export const ACCOUNT_COOKIE = 'wiki_account';
|
||||
export const ACCOUNT_COOKIE = process.env.ACCOUNT_COOKIE_NAME || 'wiki_account';
|
||||
if (!/^[A-Za-z0-9_-]{1,64}$/.test(ACCOUNT_COOKIE)) throw Error('Invalid ACCOUNT_COOKIE_NAME');
|
||||
export const SESSION_TTL = 7 * 86400;
|
||||
export function normalizeEmail(value: unknown) {
|
||||
if (typeof value !== 'string') return null;
|
||||
|
||||
@@ -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、正文、媒体、标签或页面记录。",
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { createHash, createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
export const SESSION_COOKIE = 'prompt_session';
|
||||
export const SESSION_COOKIE = process.env.AUTH_COOKIE_NAME || 'prompt_session';
|
||||
if (!/^[A-Za-z0-9_-]{1,64}$/.test(SESSION_COOKIE)) throw Error('Invalid AUTH_COOKIE_NAME');
|
||||
export const SESSION_SECONDS = 7 * 24 * 60 * 60;
|
||||
export type AuthConfig = { secret: string; jwtKey: string };
|
||||
export function authConfig(): AuthConfig | null {
|
||||
|
||||
@@ -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,4 +1,6 @@
|
||||
import sharp from 'sharp';
|
||||
import managedMedia from '../content/managed-media.json';
|
||||
import {getObject} from './storage';
|
||||
import {createHash} from 'node:crypto';
|
||||
import {mkdir,readFile,writeFile,rename,stat,readdir,unlink,realpath} from 'node:fs/promises';
|
||||
import {resolve,sep,join} from 'node:path';
|
||||
@@ -11,4 +13,7 @@ async function limited<T>(work:()=>Promise<T>){if(active>=3){if(waiting.length>=
|
||||
const pending=new Map<string,Promise<Buffer>>();const cache=join(tmpdir(),'prompt-previews-v1');let writes=0;
|
||||
export async function compressPreview(input:Buffer,width:number){const max=width<=480?100_000:220_000;let size=width,quality=72;let output:Buffer;do{output=await sharp(input,{limitInputPixels:40_000_000,animated:false}).rotate().resize({width:size,withoutEnlargement:true}).webp({quality,effort:3}).toBuffer();if(output.length<=max)return output;size=Math.max(240,Math.floor(size*.8));quality=Math.max(30,quality-10);}while(size>240||quality>30);return sharp(input,{limitInputPixels:40_000_000}).rotate().resize({width:240,withoutEnlargement:true}).webp({quality:30}).toBuffer();}
|
||||
export async function cachedPreview(identity:string,width:number,load:()=>Promise<Buffer>,quality=false){const key=createHash('sha256').update(identity+':'+width+(quality?':export-v1':':webp-v1')).digest('hex');const file=join(cache,key+'.webp');try{return {bytes:await readFile(file),etag:'"'+key+'"'};}catch{/* cache miss */}let task=pending.get(key);if(!task){task=limited(async()=>{await mkdir(cache,{recursive:true});const input=await load();const bytes=quality?await sharp(input,{limitInputPixels:40_000_000,animated:false}).rotate().resize({width,withoutEnlargement:true}).webp({quality:95,effort:4}).toBuffer():await compressPreview(input,width);const temp=file+'.'+process.pid+'.tmp';await writeFile(temp,bytes);await rename(temp,file);if(++writes%32===0){const files=await readdir(cache);if(files.length>1024){const dates=await Promise.all(files.map(async name=>({name,time:(await stat(join(cache,name)).catch(()=>({mtimeMs:0}))).mtimeMs})));await Promise.all(dates.sort((a,b)=>a.time-b.time).slice(0,files.length-1024).map(f=>unlink(join(cache,f.name)).catch(()=>{})));}}return bytes;});pending.set(key,task);}try{return {bytes:await task,etag:'"'+key+'"'};}finally{pending.delete(key);}}
|
||||
export async function loadStaticPreview(source:string){const root=await realpath(join(process.cwd(),'public'));const file=await realpath(staticPreviewPath(source,root));if(!file.startsWith(root+sep))throw Error('Invalid source');const info=await stat(file);if(info.size>64*1024*1024)throw Error('Image too large');return {identity:file+':'+info.mtimeMs,load:()=>readFile(file)};}
|
||||
export async function loadStaticPreview(source:string){
|
||||
const asset=(managedMedia as Record<string,{key:string;sha256:string;bytes:number}>)["apps/waterfall-wiki/public"+source];
|
||||
if(asset){return {identity:asset.key,load:async()=>{const object=await getObject(asset.key);if(asset.bytes>64*1024*1024)throw Error("Image too large");const bytes=Buffer.from(await object.Body!.transformToByteArray());if(bytes.length!==asset.bytes||createHash("sha256").update(bytes).digest("hex")!==asset.sha256)throw Error("Stored image checksum mismatch");return bytes;}};}
|
||||
const root=await realpath(join(process.cwd(),'public'));const file=await realpath(staticPreviewPath(source,root));if(!file.startsWith(root+sep))throw Error('Invalid source');const info=await stat(file);if(info.size>64*1024*1024)throw Error('Image too large');return {identity:file+':'+info.mtimeMs,load:()=>readFile(file)};}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import managedMedia from "../content/managed-media.json";
|
||||
import {guidanceDocuments} from "../content/skills/guidance";
|
||||
import {wikiPages} from "../content/wiki";
|
||||
import { chapters } from "../content/learning-skills/data";
|
||||
import { transaction } from "./db";
|
||||
import { putFile } from "./storage";
|
||||
import { getObject } from "./storage";
|
||||
|
||||
function stableId(name: string) {
|
||||
const bytes=createHash("sha256").update(`prompt:learning-series:${name}`).digest().subarray(0,16);
|
||||
@@ -13,12 +13,13 @@ function stableId(name: string) {
|
||||
}
|
||||
// Explicit editorial publication, never part of the generic storage bootstrap.
|
||||
// Re-running updates these three authored posts without duplicating posts or media.
|
||||
export async function publishLearningSeries() {
|
||||
const path="public/learning-skills/cover.png";
|
||||
const cover=await readFile(path);const digest=createHash("sha256").update(cover).digest("hex");
|
||||
const storageKey=`editorial/learning-skills/${digest}.png`;
|
||||
const coverId=stableId("cover");
|
||||
await putFile(storageKey,path,"image/png");
|
||||
export async function publishLearningSeries(asset=managedMedia["apps/waterfall-wiki/public/learning-skills/cover.png"]) {
|
||||
const object=await getObject(asset.key);
|
||||
const cover=Buffer.from(await object.Body!.transformToByteArray());
|
||||
const digest=createHash("sha256").update(cover).digest("hex");
|
||||
if(digest!==asset.sha256||cover.length!==asset.bytes)throw Error("Learning cover checksum mismatch");
|
||||
const storageKey=asset.key;
|
||||
const coverId=asset.id;
|
||||
return transaction(async client=>{
|
||||
await client.query("SELECT pg_advisory_xact_lock(18131002)");
|
||||
await client.query(`INSERT INTO assets(id,kind,filename,storage_key,mime_type,byte_size,sha256,width,height)
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
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";
|
||||
import "@/studio/styles/globals.css";
|
||||
|
||||
export default function App({ Component, pageProps }: AppProps) {
|
||||
return <Component {...pageProps} />;
|
||||
const router = useRouter();
|
||||
useEffect(() => { const scope = /^\/(?:studio\/)?(?:graphic|infinite_canvas|layouts|template-layout)(?:\/|$)/.test(router.pathname) ? 'canvas' : 'home'; document.documentElement.dataset.theme = `${scope}-${document.documentElement.dataset.mode === 'dark' ? 'dark' : 'light'}`; }, [router.pathname]);
|
||||
const studio = /^\/(?:studio(?:\/|$)|graphic(?:\/|$)|infinite_canvas(?:\/|$))/.test(router.pathname);
|
||||
return studio ? <div className="studio-app"><Component {...pageProps} /></div> : <Component {...pageProps} />;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,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" ||
|
||||
@@ -24,337 +21,115 @@ function localNext(value: string | string[] | undefined) {
|
||||
value.startsWith("//") ||
|
||||
/[\\\u0000-\u001f]/.test(value)
|
||||
)
|
||||
return "/write";
|
||||
return "/workspace";
|
||||
try {
|
||||
const url = new URL(value, "https://local.invalid");
|
||||
return url.origin === "https://local.invalid"
|
||||
? url.pathname + url.search + url.hash
|
||||
: "/write";
|
||||
: "/workspace";
|
||||
} catch {
|
||||
return "/write";
|
||||
return "/workspace";
|
||||
}
|
||||
}
|
||||
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();
|
||||
} catch (e) {
|
||||
setFeedback({
|
||||
tone: "error",
|
||||
text: e instanceof Error ? e.message : "网络异常,请稍后重试",
|
||||
});
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
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 (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:'在线画布暂时不可用'});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (process.env.PREVIEW_MODE !== 'true') return res.status(404).end();
|
||||
if (req.method !== 'GET' && req.method !== 'HEAD') return res.status(405).end();
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
try {
|
||||
const status = JSON.parse(await readFile('/preview-status/status.json', 'utf8'));
|
||||
return req.method === 'HEAD' ? res.status(200).end() : res.status(200).json({ ...status, mode: 'development' });
|
||||
} catch {
|
||||
return res.status(503).json({ error: 'Preview source has not synchronized yet' });
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,12 @@
|
||||
import {embedSvgMedia} from '../../../../studio/lib/layout-images';
|
||||
import {renderToStaticMarkup} from 'react-dom/server';
|
||||
import {serveApiDocumentation} from '../../../lib/api-documentation';
|
||||
import {serveApiDocumentation} from '../../../../studio/lib/api-documentation';
|
||||
import type {NextApiRequest,NextApiResponse} from 'next';
|
||||
import {ideas,ideaMarkdown} from '../../../content/ideas';
|
||||
import {selections} from '../../../components/SourceCover';
|
||||
import {renderCover} from '../../../lib/render-cover';
|
||||
import {tutorialPost} from '../../../content/tool-links';
|
||||
export default function handler(req:NextApiRequest,res:NextApiResponse){
|
||||
import {ideas,ideaMarkdown} from '../../../../studio/content/ideas';
|
||||
import {selections} from '../../../../studio/components/SourceCover';
|
||||
import {renderCover} from '../../../../studio/lib/render-cover';
|
||||
import {tutorialPost} from '../../../../studio/content/tool-links';
|
||||
export default async function handler(req:NextApiRequest,res:NextApiResponse){
|
||||
if(serveApiDocumentation(req,res,'/studio/api/export/{slug}'))return;
|
||||
if(!['GET','POST'].includes(req.method||'')){res.setHeader('Allow','GET, POST');return res.status(405).end();}
|
||||
const slug=String(req.query.slug||''),idea=ideas.find(i=>i.slug===slug);if(!idea)return res.status(404).end();
|
||||
@@ -17,7 +18,7 @@ export default function handler(req:NextApiRequest,res:NextApiResponse){
|
||||
const result=renderCover(slug,input);res.setHeader('Cache-Control','no-store');
|
||||
res.setHeader('Content-Disposition',`attachment; filename="${slug}.${req.query.format==='json'?'json':'svg'}"`);
|
||||
if(req.query.format==='json')return res.json(result.data);
|
||||
res.setHeader('Content-Type','image/svg+xml; charset=utf-8');return res.send(renderToStaticMarkup(result.node));
|
||||
res.setHeader('Content-Type','image/svg+xml; charset=utf-8');return res.send(await embedSvgMedia(req,renderToStaticMarkup(result.node)));
|
||||
}catch(error){return res.status(400).json({error:error instanceof Error?error.message:'无效参数'});}
|
||||
}
|
||||
if(req.query.format==='markdown'){res.setHeader('Content-Type','text/markdown; charset=utf-8');return res.send(ideaMarkdown(idea)+'\n\n使用教程:'+tutorialPost+(idea.tool?'\n\n```json\n'+JSON.stringify(selections,null,2)+'\n```':''));}
|
||||
@@ -1,3 +1,3 @@
|
||||
import {serveApiDocumentation} from '../../lib/api-documentation';
|
||||
import {serveApiDocumentation} from '../../../studio/lib/api-documentation';
|
||||
import type {NextApiRequest,NextApiResponse} from "next";
|
||||
export default function handler(req:NextApiRequest,res:NextApiResponse){if(serveApiDocumentation(req,res,'/studio/api/health'))return;res.setHeader("Cache-Control","no-store");if(!["GET","HEAD"].includes(req.method||""))return res.status(405).end();return req.method==="HEAD"?res.status(200).end():res.status(200).json({status:"ok",service:"prompt-studio"});}
|
||||
@@ -0,0 +1,3 @@
|
||||
import type {NextApiRequest,NextApiResponse} from 'next';
|
||||
import {ideas} from '../../../studio/content/ideas';
|
||||
export default function handler(_:NextApiRequest,res:NextApiResponse){res.setHeader('Content-Type','text/markdown; charset=utf-8');res.send('# 创作站\n\n- [在线画布、引用与 Agent 任务](/api/canvases?agent_mode=True)\n- [27 套排版目录与 API](/studio/api/layouts?agent_mode=True)\n- [多页作品与统一视觉](/api/studio/series?agent_mode=True)\n- [按 ID 保存效果](/api/studio/designs?agent_mode=True)\n\n'+ideas.map(i=>`- [${i.title}](/studio/ideas/${i.slug}) · [正文](/studio/api/export/${i.slug}?format=markdown)`).join('\n'));}
|
||||