Compare commits
155 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| 8649af1b37 | |||
| bcdcd0906b | |||
| cbf70936ab | |||
| 1a3a335aae | |||
| 900fd89af9 | |||
| 9b81cad9f9 | |||
| 0d8aacb9a2 | |||
| 8c5d838944 | |||
| f490543853 | |||
| b94c1e43f1 | |||
| 9a603cf87e | |||
| dbc3ad8c69 | |||
| 1a7b8f5235 | |||
| d1020850a7 | |||
| 254ac628be | |||
| be23fe7030 | |||
| 6c498255e9 | |||
| 75b7a025a0 | |||
| 1925d15272 | |||
| 24beb66484 | |||
| 03b8e46731 | |||
| b1f1a454e7 | |||
| 1100b79062 | |||
| d779245e09 | |||
| 968e7ff353 | |||
| 89f72512ae | |||
| 68155fc0f7 | |||
| 554c0d120f | |||
| 28b9fb1464 | |||
| db4b00bad0 | |||
| 21983619f3 | |||
| 4de92fd059 | |||
| 29e4ac849b | |||
| 5410da8b6d | |||
| af1b2c34e8 | |||
| 6e594f3fbf | |||
| 5c89d27528 | |||
| 0e47bae606 | |||
| 2bf572d353 | |||
| 9256dd27e1 | |||
| e920f90c7c | |||
| 02267a73b9 | |||
| fac685a81c | |||
| 7372783062 | |||
| 39fad306c3 | |||
| a1984e615a | |||
| 0445b370fa | |||
| da45505485 | |||
| d5807aa298 | |||
| b157504ca6 | |||
| 36d15dc7bf | |||
| e141de05fe | |||
| b131e1b11d | |||
| c27fa4fe22 | |||
| 2df69ec1c6 | |||
| ee69749405 | |||
| 134bc2c9cc | |||
| 8d487fa457 | |||
| 4b24745eb9 | |||
| 0360a5165b | |||
| dbc575ced7 | |||
| 6a7d924436 | |||
| 04df551f6a | |||
| 174a3ef266 | |||
| d12f9a8dc3 | |||
| b550c151bd | |||
| eac2dd4cde | |||
| df831a15ba | |||
| de30ee4ec8 | |||
| efffcdb1fe |
@@ -0,0 +1,27 @@
|
||||
name: Endoleap brand checks
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'packages/brand/**'
|
||||
- 'pnpm-workspace.yaml'
|
||||
- 'package.json'
|
||||
- 'AGENTS.md'
|
||||
- '.gitea/workflows/brand.yml'
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'packages/brand/**'
|
||||
- 'pnpm-workspace.yaml'
|
||||
- '.gitea/workflows/brand.yml'
|
||||
concurrency:
|
||||
group: brand-${{ gitea.event.pull_request.number || gitea.ref }}
|
||||
cancel-in-progress: true
|
||||
jobs:
|
||||
verify:
|
||||
runs-on: ces-control
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Verify brand assets, Tailwind compilation and typed exports in Docker
|
||||
run: docker build -f packages/brand/Dockerfile -t "endoleap-brand-check:${{ gitea.sha }}" .
|
||||
@@ -0,0 +1,74 @@
|
||||
name: creative workspace review checks
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'apps/studio/**'
|
||||
- 'apps/waterfall-wiki/**'
|
||||
- '.gitea/workflows/**'
|
||||
- 'scripts/*studio*'
|
||||
- 'compose.creative-review.yml'
|
||||
- 'scripts/integration.sh'
|
||||
- 'scripts/cleanup-integration.sh'
|
||||
- 'scripts/test-integration-cleanup.py'
|
||||
- 'scripts/cleanup-review-images.sh'
|
||||
- 'scripts/test-review-image-cleanup.py'
|
||||
concurrency:
|
||||
group: creative-workspace-${{ gitea.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
jobs:
|
||||
verify-deployed-canvas:
|
||||
runs-on: ces-control
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Verify canvas acceptance script against the designated deployed server
|
||||
run: |
|
||||
node --check apps/studio/scripts/infinite-canvas-smoke.cjs
|
||||
wiki_image=$(docker inspect --format '{{.Config.Image}}' prompt-waterfall-wiki-1)
|
||||
[[ "$wiki_image" =~ ^waterfall-wiki:[0-9a-f]{40}$ ]]
|
||||
test_image="waterfall-wiki-tests:${wiki_image#waterfall-wiki:}"
|
||||
docker image inspect "$test_image" >/dev/null
|
||||
docker run --rm \
|
||||
--mount "type=bind,src=$PWD,dst=/delivery,readonly" \
|
||||
-e NODE_PATH=/repo/apps/waterfall-wiki/node_modules \
|
||||
-e CHROMIUM_PATH=/usr/bin/chromium \
|
||||
--entrypoint node "$test_image" /delivery/apps/studio/scripts/infinite-canvas-smoke.cjs
|
||||
verify:
|
||||
runs-on: ces-control
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Check patch formatting
|
||||
run: |
|
||||
git diff --check HEAD^ HEAD
|
||||
bash -n scripts/integration.sh scripts/cleanup-integration.sh scripts/verify-studio-production.sh scripts/cleanup-review-images.sh
|
||||
node --check scripts/verify-studio-visual.cjs
|
||||
python3 scripts/test-integration-cleanup.py
|
||||
python3 scripts/test-review-image-cleanup.py
|
||||
node --test apps/waterfall-wiki/tests/brand-navigation.test.mjs
|
||||
- name: Verify Studio types, tests, build and smoke
|
||||
run: docker build -f apps/studio/Dockerfile -t "creative-studio-review:${{ gitea.sha }}" .
|
||||
- name: Build Wiki integration and runtime images
|
||||
run: |
|
||||
docker build --target integration -t "creative-wiki-tests:${{ gitea.sha }}" .
|
||||
docker build --target runtime -t "creative-wiki-review:${{ gitea.sha }}" .
|
||||
- name: Verify isolated PostgreSQL, S3 and browser integration
|
||||
env:
|
||||
STUDIO_REVIEW_IMAGE: creative-studio-review:${{ gitea.sha }}
|
||||
WATERFALL_WIKI_IMAGE: creative-wiki-review:${{ gitea.sha }}
|
||||
WATERFALL_WIKI_TEST_IMAGE: creative-wiki-tests:${{ gitea.sha }}
|
||||
GITEA_RUN_ID: ${{ gitea.run_id }}
|
||||
run: bash scripts/integration.sh
|
||||
- name: Clean this run disposable integration resources even after cancellation
|
||||
if: always()
|
||||
env:
|
||||
GITEA_RUN_ID: ${{ gitea.run_id }}
|
||||
run: bash scripts/cleanup-integration.sh
|
||||
- name: Remove this run disposable review image tags
|
||||
if: always()
|
||||
env:
|
||||
REVIEW_SHA: ${{ gitea.sha }}
|
||||
run: bash scripts/cleanup-review-images.sh
|
||||
@@ -6,15 +6,10 @@ on:
|
||||
- 'apps/python/**'
|
||||
- 'scripts/deploy-python.sh'
|
||||
- '.gitea/workflows/python.yml'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'apps/python/**'
|
||||
- 'scripts/deploy-python.sh'
|
||||
- '.gitea/workflows/python.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
verify-and-deploy:
|
||||
if: gitea.ref == 'refs/heads/main' && gitea.event_name == 'push'
|
||||
runs-on: ces-control
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
@@ -24,5 +19,5 @@ jobs:
|
||||
- name: Build and verify python
|
||||
run: docker build -f apps/python/Dockerfile -t "prompt-python:${{ gitea.sha }}" .
|
||||
- name: Release python
|
||||
if: gitea.ref == 'refs/heads/main' && (gitea.event_name == 'push' || gitea.event_name == 'workflow_dispatch')
|
||||
if: gitea.ref == 'refs/heads/main' && gitea.event_name == 'push'
|
||||
run: bash scripts/deploy-python.sh "${{ gitea.sha }}"
|
||||
|
||||
@@ -1,54 +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'
|
||||
- 'pnpm-workspace.yaml'
|
||||
pull_request:
|
||||
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'
|
||||
- 'pnpm-workspace.yaml'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
verify-and-deploy:
|
||||
runs-on: ces-control
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- 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' || gitea.event_name == 'workflow_dispatch')
|
||||
run: bash scripts/deploy-studio.sh "${{ gitea.sha }}"
|
||||
- name: Capture deployed baseline diagnostics (does not validate PR code)
|
||||
if: gitea.event_name == 'pull_request'
|
||||
run: bash scripts/verify-studio-production.sh --diagnose
|
||||
- name: Verify the deployed Studio API and native pages
|
||||
if: gitea.ref == 'refs/heads/main' && (gitea.event_name == 'push' || gitea.event_name == 'workflow_dispatch')
|
||||
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,51 +1,71 @@
|
||||
name: waterfall-wiki CI and deployment
|
||||
name: creative workspace CI and deployment
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'apps/waterfall-wiki/**'
|
||||
- 'apps/studio/**'
|
||||
- 'scripts/*studio*'
|
||||
- 'content/studio-examples/**'
|
||||
- 'content/studio-tools/layouts-api.md'
|
||||
- '.gitea/workflows/waterfall-wiki.yml'
|
||||
- 'Dockerfile'
|
||||
- 'compose.yml'
|
||||
- 'scripts/deploy.sh'
|
||||
- 'scripts/wiki-image-lifecycle.py'
|
||||
- 'scripts/infra.sh'
|
||||
- 'scripts/auth-admin.sh'
|
||||
- 'scripts/integration.sh'
|
||||
- 'scripts/cleanup-integration.sh'
|
||||
- 'scripts/test-integration-cleanup.py'
|
||||
- 'pnpm-workspace.yaml'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'apps/waterfall-wiki/**'
|
||||
- '.gitea/workflows/waterfall-wiki.yml'
|
||||
- 'Dockerfile'
|
||||
- 'compose.yml'
|
||||
- 'scripts/deploy.sh'
|
||||
- 'scripts/infra.sh'
|
||||
- 'scripts/auth-admin.sh'
|
||||
- 'scripts/integration.sh'
|
||||
- 'pnpm-workspace.yaml'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
verify-and-deploy:
|
||||
if: gitea.ref == 'refs/heads/main' && gitea.event_name == 'push'
|
||||
runs-on: ces-control
|
||||
timeout-minutes: 45
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Validate configuration
|
||||
run: docker compose --env-file apps/waterfall-wiki/.env.example config --quiet
|
||||
- name: Build and verify waterfall-wiki
|
||||
run: docker build --target integration -t "waterfall-wiki-tests:${{ gitea.sha }}" .
|
||||
- name: Verify retained character originals
|
||||
run: docker run --rm --entrypoint node "waterfall-wiki-tests:${{ gitea.sha }}" --test tests/character-pack.test.mjs
|
||||
- name: Build runtime
|
||||
run: docker build --target runtime -t "waterfall-wiki:${{ gitea.sha }}" .
|
||||
- name: Integration stack
|
||||
- name: Check shared content and documentation contracts
|
||||
run: |
|
||||
python3 scripts/test-integration-cleanup.py
|
||||
bash -n scripts/integration.sh scripts/cleanup-integration.sh
|
||||
node scripts/check-studio-contracts.mjs
|
||||
node --check scripts/export-studio-series.cjs
|
||||
node --check scripts/save-studio-series.mjs
|
||||
bash -n scripts/publish-studio-guide.sh scripts/deliver-studio-series.sh
|
||||
- name: Build and verify Studio
|
||||
run: docker build -f apps/studio/Dockerfile -t "prompt-studio:${{ gitea.sha }}" .
|
||||
- name: Build, verify, deploy and retain wiki images
|
||||
env:
|
||||
WATERFALL_WIKI_IMAGE: waterfall-wiki:${{ gitea.sha }}
|
||||
WATERFALL_WIKI_TEST_IMAGE: waterfall-wiki-tests:${{ gitea.sha }}
|
||||
run: bash scripts/integration.sh
|
||||
- name: Deploy waterfall-wiki
|
||||
if: gitea.ref == 'refs/heads/main' && (gitea.event_name == 'push' || gitea.event_name == 'workflow_dispatch')
|
||||
run: bash scripts/deploy.sh "${{ gitea.sha }}"
|
||||
RELEASE_SHA: ${{ gitea.sha }}
|
||||
run: |
|
||||
if [ "$(id -u)" -eq 0 ]; then
|
||||
python3 scripts/wiki-image-lifecycle.py ci --sha "$RELEASE_SHA"
|
||||
else
|
||||
sudo -n python3 scripts/wiki-image-lifecycle.py ci --sha "$RELEASE_SHA"
|
||||
fi
|
||||
- name: Release Studio after Wiki routes are deployed
|
||||
run: bash scripts/deploy-studio.sh "${{ gitea.sha }}"
|
||||
- name: Verify the deployed creative workspace
|
||||
run: bash scripts/verify-studio-production.sh
|
||||
- name: Save server acceptance evidence
|
||||
id: acceptance_evidence
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: studio-production-acceptance
|
||||
path: artifacts/studio-acceptance/
|
||||
if-no-files-found: ignore
|
||||
retention-days: 7
|
||||
- name: Remove temporary acceptance output after upload
|
||||
if: always() && steps.acceptance_evidence.outcome == 'success'
|
||||
run: rm -rf artifacts/studio-acceptance
|
||||
- name: Clean this run disposable integration resources even after cancellation
|
||||
if: always()
|
||||
env:
|
||||
GITEA_RUN_ID: ${{ gitea.run_id }}
|
||||
run: bash scripts/cleanup-integration.sh
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
name: Wiki image retention checks
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'scripts/wiki-image-lifecycle.py'
|
||||
- 'tests/test_wiki_image_lifecycle.py'
|
||||
- '.gitea/workflows/wiki-image-retention-checks.yml'
|
||||
jobs:
|
||||
retention-policy:
|
||||
if: gitea.ref == 'refs/heads/main' && gitea.event_name == 'push'
|
||||
runs-on: ces-control
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Check retention without building or accessing the Docker daemon
|
||||
run: |
|
||||
bash -n scripts/deploy.sh
|
||||
PYTHONDONTWRITEBYTECODE=1 python3 -m unittest discover -s tests -p 'test_wiki_image_lifecycle.py' -v
|
||||
@@ -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 执行,不在本机启动预览或构建。
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
FROM node:24-bookworm-slim AS base
|
||||
LABEL io.entroia.project="waterfall-wiki"
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
WORKDIR /app
|
||||
RUN apt-get update \
|
||||
@@ -6,13 +7,14 @@ RUN apt-get update \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
FROM base AS dependencies
|
||||
WORKDIR /repo/apps/waterfall-wiki
|
||||
LABEL io.entroia.wiki.build="true"
|
||||
RUN corepack enable && corepack prepare pnpm@11.25.0 --activate
|
||||
COPY apps/waterfall-wiki/pnpm-lock.yaml ./
|
||||
COPY pnpm-workspace.yaml /tmp/monorepo-policy.yaml
|
||||
RUN node -e "const fs=require('fs');const text=fs.readFileSync('/tmp/monorepo-policy.yaml','utf8');fs.writeFileSync('pnpm-workspace.yaml', text.slice(text.indexOf('allowBuilds:')));"
|
||||
RUN pnpm fetch --frozen-lockfile --fetch-timeout=300000 --network-concurrency=8
|
||||
COPY pnpm-workspace.yaml /repo/pnpm-workspace.yaml
|
||||
COPY packages/brand/ /repo/packages/brand/
|
||||
COPY apps/waterfall-wiki/package.json ./
|
||||
RUN pnpm install --offline --frozen-lockfile
|
||||
RUN pnpm --filter @prompt/waterfall-wiki install --frozen-lockfile --fetch-timeout=300000 --network-concurrency=8
|
||||
|
||||
FROM dependencies AS build
|
||||
# .dockerignore excludes docs and operational notes without extra legacy COPY snapshots.
|
||||
@@ -24,14 +26,16 @@ 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"]
|
||||
|
||||
@@ -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 应用由 `waterfall-wiki.yml` 统一发布,两个应用的路径都能触发它:先检查并构建 Studio,再完成 Wiki 的类型检查、单元测试、生产构建、冒烟及隔离 PostgreSQL/S3 集成测试并部署 Wiki,随后部署 Studio,最后在指定服务器验收。顺序在同一个 ces-control 作业内保证,避免独立作业互相等待。PR 检查也运行隔离集成测试,但不部署。Python 检查仍由 `python.yml` 承担;文档与作者数据本身不触发应用部署。
|
||||
|
||||
| 服务 | 入口/端口 |
|
||||
| --- | --- |
|
||||
| `waterfall-wiki` | 公网 `18131`,也是 Studio 的统一入口 |
|
||||
| PostgreSQL | `127.0.0.1:18132` |
|
||||
| 对象存储 | `127.0.0.1:18133` |
|
||||
| Studio | `127.0.0.1:18134`;公网通过 `/studio` 代理 |
|
||||
| Studio | `127.0.0.1:18134`;编辑器公网入口为 `/graphic`、`/infinite_canvas`,资源和 API 保留 `/studio` 代理 |
|
||||
| Python | 无网络端口,按需运行 |
|
||||
|
||||
生产 Compose 项目仍为 `prompt`,部署根目录仍为 `/opt/prompt`。既有 `prompt_postgres_data`、`prompt_objects`、`prompt_auth_data` 卷与数据库身份保留;应用更名不会重新创建一套空存储。Compose 应用服务改为 `waterfall-wiki` 和 `waterfall-wiki-worker`,镜像参数为 `WATERFALL_WIKI_IMAGE` / `WATERFALL_WIKI_TEST_IMAGE`。
|
||||
|
||||
@@ -1,21 +1,23 @@
|
||||
FROM node:24-bookworm-slim AS build
|
||||
WORKDIR /app
|
||||
WORKDIR /repo/apps/studio
|
||||
RUN corepack enable && corepack prepare pnpm@11.25.0 --activate
|
||||
COPY apps/studio/pnpm-lock.yaml ./
|
||||
COPY pnpm-workspace.yaml /tmp/monorepo-policy.yaml
|
||||
RUN node -e "const fs=require('fs');const text=fs.readFileSync('/tmp/monorepo-policy.yaml','utf8');fs.writeFileSync('pnpm-workspace.yaml', text.slice(text.indexOf('allowBuilds:')));"
|
||||
RUN pnpm fetch --frozen-lockfile --fetch-timeout=300000 --network-concurrency=8
|
||||
COPY pnpm-workspace.yaml /repo/pnpm-workspace.yaml
|
||||
COPY packages/brand/ /repo/packages/brand/
|
||||
COPY apps/studio/package.json ./
|
||||
RUN pnpm install --offline --frozen-lockfile
|
||||
RUN pnpm --filter @prompt/studio install --frozen-lockfile --fetch-timeout=300000 --network-concurrency=8
|
||||
COPY apps/studio/ .
|
||||
# Compare the two public parameter validators inside the isolated CI build.
|
||||
COPY apps/waterfall-wiki/src/lib/layout-parameters.ts /waterfall-wiki/src/lib/layout-parameters.ts
|
||||
COPY apps/waterfall-wiki/src/lib/layout-parameters.ts /repo/apps/waterfall-wiki/src/lib/layout-parameters.ts
|
||||
COPY apps/waterfall-wiki/src/lib/series.ts /repo/apps/waterfall-wiki/src/lib/series.ts
|
||||
COPY apps/waterfall-wiki/src/lib/canvas-document.ts /repo/apps/waterfall-wiki/src/lib/canvas-document.ts
|
||||
RUN pnpm typecheck && pnpm test && pnpm build && pnpm test:smoke
|
||||
FROM node:24-bookworm-slim
|
||||
WORKDIR /app
|
||||
WORKDIR /app/apps/studio
|
||||
ENV NODE_ENV=production HOSTNAME=0.0.0.0 PORT=3000
|
||||
COPY --from=build --chown=node:node /app/.next/standalone/ ./
|
||||
COPY --from=build --chown=node:node /app/.next/static/ ./.next/static/
|
||||
COPY --from=build --chown=node:node /repo/apps/studio/.next/standalone/ /app/
|
||||
COPY --from=build --chown=node:node /repo/apps/studio/.next/static/ ./.next/static/
|
||||
COPY --from=build --chown=node:node /repo/apps/studio/public/ ./public/
|
||||
USER node
|
||||
EXPOSE 3000
|
||||
CMD ["node", "server.js"]
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
{
|
||||
"schemaVersion": 2,
|
||||
"name": "把秋天装进一页 · 银杏观察手记",
|
||||
"document": {
|
||||
"version": 1,
|
||||
"brief": {
|
||||
"topic": "银杏与秋日观察手记",
|
||||
"audience": "喜欢日常摄影与视觉手帐的小红书读者",
|
||||
"intent": "以封面、观察方法和行动结尾组成三页可发布图文",
|
||||
"designIntent": "奶油纸底、森林墨色、银杏金点缀;宋体大标题与克制的无衬线正文;所有页面保留相同页边距和节奏",
|
||||
"imageStrategy": "生成一幅原创银杏静物插画作为系列母图;每页复用素材,保留裁切配置,不把正文烘焙进图片"
|
||||
},
|
||||
"style": {
|
||||
"version": "autumn-journal/1",
|
||||
"palette": {
|
||||
"paper": "#F3EFE5",
|
||||
"ink": "#253C32",
|
||||
"muted": "#718074",
|
||||
"accent": "#C3A050"
|
||||
},
|
||||
"typography": {
|
||||
"title": {
|
||||
"fontFamily": "serif",
|
||||
"fontSize": 8.4,
|
||||
"fontWeight": 600,
|
||||
"lineHeight": 1.15,
|
||||
"letterSpacing": 0
|
||||
},
|
||||
"body": {
|
||||
"fontFamily": "sans",
|
||||
"fontSize": 3.1,
|
||||
"fontWeight": 400,
|
||||
"lineHeight": 1.65,
|
||||
"letterSpacing": 0
|
||||
},
|
||||
"caption": {
|
||||
"fontFamily": "sans",
|
||||
"fontSize": 1.7,
|
||||
"fontWeight": 500,
|
||||
"lineHeight": 1.2,
|
||||
"letterSpacing": 0
|
||||
}
|
||||
},
|
||||
"spacing": {
|
||||
"margin": 10,
|
||||
"gap": 4
|
||||
}
|
||||
},
|
||||
"sources": [],
|
||||
"pages": [
|
||||
{
|
||||
"key": "cover",
|
||||
"role": "cover",
|
||||
"layoutId": "standard-01",
|
||||
"intent": "用银杏静物与两行大标题建立主题",
|
||||
"bindings": {
|
||||
"T01": "title",
|
||||
"T02": "body",
|
||||
"T03": "caption"
|
||||
},
|
||||
"parameters": {
|
||||
"texts": {
|
||||
"T01": {
|
||||
"value": "把秋天\n装进一页"
|
||||
},
|
||||
"T02": {
|
||||
"value": "不必走很远。\n一片银杏,就是今天的风景。"
|
||||
},
|
||||
"T03": {
|
||||
"value": "SEASONAL NOTES / 01"
|
||||
}
|
||||
},
|
||||
"images": {
|
||||
"I01": {
|
||||
"fit": "cover",
|
||||
"cropX": 50,
|
||||
"cropY": 50,
|
||||
"zoom": 1
|
||||
}
|
||||
},
|
||||
"decorations": {}
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "observe",
|
||||
"role": "body",
|
||||
"layoutId": "standard-01",
|
||||
"intent": "用三个观察动作提供可实践的方法",
|
||||
"bindings": {
|
||||
"T01": "title",
|
||||
"T02": "body",
|
||||
"T03": "caption"
|
||||
},
|
||||
"parameters": {
|
||||
"texts": {
|
||||
"T01": {
|
||||
"value": "先看见\n再记录"
|
||||
},
|
||||
"T02": {
|
||||
"value": "看叶脉,看光落下的方向。\n留住一片金黄,也留出一半空白。\n把感受写成短句,画面就有了呼吸。"
|
||||
},
|
||||
"T03": {
|
||||
"value": "FIELD NOTES / 02"
|
||||
}
|
||||
},
|
||||
"images": {
|
||||
"I01": {
|
||||
"fit": "cover",
|
||||
"cropX": 65,
|
||||
"cropY": 40,
|
||||
"zoom": 1.25
|
||||
}
|
||||
},
|
||||
"decorations": {}
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "ending",
|
||||
"role": "ending",
|
||||
"layoutId": "standard-01",
|
||||
"intent": "以轻量行动和提问收尾",
|
||||
"bindings": {
|
||||
"T01": "title",
|
||||
"T02": "body",
|
||||
"T03": "caption"
|
||||
},
|
||||
"parameters": {
|
||||
"texts": {
|
||||
"T01": {
|
||||
"value": "留一点白\n给下一次",
|
||||
"align": "middle"
|
||||
},
|
||||
"T02": {
|
||||
"value": "今天,捡起一片叶子。\n用三个词记录它的颜色、纹理与心情。\n你的秋天,会从哪一页开始?",
|
||||
"align": "middle"
|
||||
},
|
||||
"T03": {
|
||||
"value": "YOUR TURN / 03"
|
||||
}
|
||||
},
|
||||
"images": {
|
||||
"I01": {
|
||||
"fit": "cover",
|
||||
"cropX": 65,
|
||||
"cropY": 50,
|
||||
"zoom": 1
|
||||
}
|
||||
},
|
||||
"decorations": {},
|
||||
"layers": {
|
||||
"I01": {
|
||||
"x": 40,
|
||||
"y": 55,
|
||||
"width": 40,
|
||||
"height": 40,
|
||||
"radius": 20
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { resolve } from "node:path";
|
||||
import type { NextConfig } from "next";
|
||||
const config: NextConfig = { basePath: "/studio", output: "standalone", outputFileTracingRoot: process.cwd(), poweredByHeader: false };
|
||||
const config: NextConfig = { turbopack: { root: resolve(process.cwd(), "../..") }, basePath: "/studio", output: "standalone", outputFileTracingRoot: resolve(process.cwd(), "../.."), poweredByHeader: false };
|
||||
export default config;
|
||||
|
||||
@@ -2,18 +2,20 @@
|
||||
"name": "@prompt/studio",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev -p 3001",
|
||||
"build": "next build",
|
||||
"dev": "pnpm brand:prepare && next dev -p 3001",
|
||||
"build": "pnpm brand:prepare && next build",
|
||||
"start": "next start",
|
||||
"typecheck": "next typegen && tsc --noEmit",
|
||||
"test": "node --test tests/*.test.mjs",
|
||||
"test:smoke": "node scripts/smoke.mjs"
|
||||
"test:smoke": "node scripts/smoke.mjs",
|
||||
"brand:prepare": "node ../../packages/brand/scripts/prepare-app.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"next": "16.4.2",
|
||||
"react": "19.3.1",
|
||||
"react-dom": "19.3.1",
|
||||
"lucide-react": "1.61.0"
|
||||
"lucide-react": "1.61.0",
|
||||
"@endoleap/brand": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "4.3.3",
|
||||
|
||||
@@ -8,6 +8,9 @@ importers:
|
||||
|
||||
.:
|
||||
dependencies:
|
||||
'@endoleap/brand':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/brand
|
||||
lucide-react:
|
||||
specifier: 1.61.0
|
||||
version: 1.61.0(@types/react@19.3.0)(react@19.3.1)
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
const {chromium}=require('playwright-core');
|
||||
const assert=require('node:assert/strict');
|
||||
(async()=>{const browser=await chromium.launch({channel:'chrome',headless:true});try{const page=await browser.newPage({viewport:{width:1440,height:1000}});const errors=[];page.on('pageerror',e=>errors.push(e.message));
|
||||
await page.route('**/api/account/session',r=>r.fulfill({json:{user:null}}));
|
||||
await page.goto((process.env.STUDIO_TEST_ORIGIN||'http://8.211.154.134:18131')+'/graphic');
|
||||
await page.getByRole('button',{name:'切换图层面板',exact:true}).click();
|
||||
assert.equal(await page.getByRole('button',{name:'图层',exact:true}).isVisible(),false);
|
||||
await page.getByRole('button',{name:'切换图层面板',exact:true}).click();
|
||||
await page.getByRole('button',{name:'切换属性面板',exact:true}).click();
|
||||
assert.equal(await page.getByRole('button',{name:'备份设计 JSON',exact:true}).isVisible(),false);
|
||||
await page.getByRole('button',{name:'切换属性面板',exact:true}).click();
|
||||
await page.getByRole('button',{name:'添加文字',exact:true}).click();
|
||||
await page.getByRole('textbox',{name:'文字内容',exact:true}).fill('设计从这里开始');
|
||||
await page.getByRole('button',{name:'添加矩形',exact:true}).click();
|
||||
await page.getByRole('button',{name:'撤销',exact:true}).click();
|
||||
await page.getByRole('button',{name:'重做',exact:true}).click();
|
||||
await page.reload();await page.getByRole('button',{name:'文字',exact:true}).first().waitFor();
|
||||
assert.ok(await page.evaluate(()=>localStorage.getItem('studio.graphic.v1').includes('设计从这里开始')));
|
||||
const file=page.waitForEvent('download');await page.getByRole('button',{name:'导出 SVG',exact:true}).click();assert.ok((await file).suggestedFilename().endsWith('.svg'));
|
||||
await page.getByRole('button',{name:'邮箱登录',exact:true}).click();await page.getByRole('dialog').waitFor();await page.keyboard.press('Escape');await page.getByRole('dialog').waitFor({state:'hidden'});
|
||||
await page.screenshot({path:process.env.STUDIO_QA_SCREENSHOT||'graphic-qa.png',fullPage:true});
|
||||
await page.setViewportSize({width:390,height:844});assert.equal(await page.evaluate(()=>document.documentElement.scrollWidth>innerWidth),false);
|
||||
assert.deepEqual(errors,[]);console.log('Graphic editing, history, restore, SVG download, account dialog and mobile overflow checks passed.');
|
||||
}finally{await browser.close()}})().catch(e=>{console.error(e);process.exitCode=1});
|
||||
@@ -0,0 +1,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,17 +1,29 @@
|
||||
/** CI-only isolated runtime checks, never a local preview workflow. */
|
||||
import {readFileSync} from 'node:fs';
|
||||
import {spawn} from 'node:child_process';import {once} from 'node:events';import {createServer} from 'node:net';import {createServer as createHttpServer} from 'node:http';import assert from 'node:assert/strict';import {setTimeout} from 'node:timers/promises';
|
||||
const designId='00000000-0000-4000-8000-000000000001',missingId='00000000-0000-4000-8000-000000000002';
|
||||
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(designId)){res.writeHead(404);return res.end('{}')}res.end(JSON.stringify({design:{id:designId,name:'已保存的实际页面',layoutId:'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'}}}))});
|
||||
const standardId='00000000-0000-4000-8000-000000000003';
|
||||
const seriesId='00000000-0000-4000-8000-000000000004';
|
||||
const doc=JSON.parse(readFileSync(new URL('../fixtures/autumn-journal.json',import.meta.url))).document;
|
||||
const assetId='00000000-0000-4000-8000-000000000005';doc.sources=[{assetId,author:'CI fixture',license:'test only',url:'generated',prompt:'test image',tool:'fixture'}];for(const p of doc.pages)p.parameters.images.I01.assetId=assetId;
|
||||
let reads=0,mediaReads=0;
|
||||
const wiki=createHttpServer((req,res)=>{assert.equal(req.headers.cookie,'session=smoke-test');if(/^\/api\/media\/[0-9a-f-]{36}$/.test(req.url)){mediaReads++;res.setHeader('Content-Type','image/png');return res.end(Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+j3ioAAAAASUVORK5CYII=','base64'))}reads++;res.setHeader('Content-Type','application/json');if(req.url.endsWith(seriesId))return res.end(JSON.stringify({design:{id:seriesId,name:'完整三页作品',layoutId:'standard-01',revision:1,url:'/studio/template-layout?id='+seriesId,parameters:doc.pages[0].parameters,document:doc}}));const standard=req.url.endsWith(standardId);if(!standard&&!req.url.endsWith(designId)){res.writeHead(404);return res.end('{}')}res.end(JSON.stringify({design:{id:standard?standardId:designId,name:'已保存的实际页面',layoutId:standard?'standard-01':'ref-01',revision:3,url:'/studio/designs/'+designId,parameters:{added:[{id:'user-smoke',kind:'text',role:'Added text',box:[10,10,20,10],direction:'horizontal',text:'新增元素已恢复',reading:'custom'}],layers:{T01:{x:12,rotation:25,opacity:.7},'user-smoke':{z:12}},texts:{T01:{value:'确定的页面文字',color:'#123456'}},images:{I01:{color:'#abcdef'}},decorations:{D01:{hidden:true}},paper:'#eeeeee'}}}))});
|
||||
wiki.listen(0,'127.0.0.1');await once(wiki,'listening');
|
||||
const socket=createServer();socket.listen(0,'127.0.0.1');await once(socket,'listening');const port=socket.address().port;await new Promise(r=>socket.close(r));const child=spawn(process.execPath,['.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 library=await fetch(base+'/studio').then(r=>r.text());assert.ok(library.includes('standard-01'));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/.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/studio/server.js'],{env:{...process.env,PORT:String(port),HOSTNAME:'127.0.0.1',STUDIO_WIKI_ORIGIN:'http://127.0.0.1:'+wiki.address().port},stdio:'inherit'}),base='http://127.0.0.1:'+port;
|
||||
try{let ready=false;for(let i=0;i<60;i++){try{if((await fetch(base+'/studio/api/health')).ok){ready=true;break}}catch{}await setTimeout(250)}assert.ok(ready);const entry=await fetch(base+'/studio',{redirect:'manual'});assert.equal(entry.status,307);assert.equal(entry.headers.get('location'),'/studio/graphic');const library=await fetch(base+'/studio/graphic').then(r=>r.text());assert.ok(library.includes('GRAPHIC'));assert.ok(!library.includes('ref-27')); const catalog=await fetch(base+'/studio/api/layouts').then(r=>r.json());assert.equal(catalog.layouts.length,28);
|
||||
for(const l of catalog.layouts){const page=await fetch(base+l.url);assert.equal(page.status,200,l.id);const html=await page.text();assert.ok(html.includes(l.name));assert.ok(html.includes('data-layout-id="'+l.id+'"'));assert.ok(!/<svg|<img|data:image|trial-controls|page-tools|workbench-top|contenteditable|编辑页面/.test(html),'Default page must use native elements, not source imagery');const schema=await fetch(base+l.api).then(r=>r.json());assert.equal(schema.id,l.id);for(const s of schema.elements)assert.ok(html.includes('data-element="'+s.id+'"'));if(l.family==='standard'){assert.equal((await fetch(base+l.api+'?format=svg')).status,400);continue;}const original=await fetch(base+l.api+'?format=svg',{headers:{cookie:'session=smoke-test'}}).then(r=>r.text());assert.ok(original.includes('data-layout-id="'+l.id+'"'));const text=schema.elements.find(e=>e.kind==='text');const result=await fetch(base+l.api,{method:'POST',headers:{'Content-Type':'application/json',cookie:'session=smoke-test'},body:JSON.stringify({texts:{[text.id]:{value:'API render <test>'}}})});assert.equal(result.status,200);assert.ok((await result.text()).includes('<'));}
|
||||
assert.equal(reads,0,'Default pages never load a saved design');assert.ok(mediaReads>0,'SVG exports load isolated media fixtures');
|
||||
const headers={cookie:'session=smoke-test'};
|
||||
const saved=await fetch(base+'/studio/layouts/ref-01?id='+designId,{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="'+designId+'"'));assert.ok(savedHtml.includes('#123456'));assert.ok(!savedHtml.includes('data-element="D01"'));assert.ok(!savedHtml.includes('data:image'));
|
||||
for(const id of [designId,standardId]){
|
||||
const saved=await fetch(base+'/studio/template-layout?id='+id,{headers});assert.equal(saved.status,200);const savedHtml=await saved.text();assert.ok(savedHtml.includes('确定的页面文字'));assert.ok(savedHtml.includes('新增元素已恢复'));assert.ok(savedHtml.includes('data-element="user-smoke"'));assert.ok(savedHtml.includes('rotate(25deg)'));assert.ok(savedHtml.includes('opacity:0.7'));assert.ok(savedHtml.includes('data-design-id="'+id+'"'));assert.ok(savedHtml.includes('#123456'));assert.ok(!savedHtml.includes('data-element="D01"'));assert.ok(!savedHtml.includes('data:image'));assert.ok(!/page-tools|workbench-top|contenteditable|编辑页面/.test(savedHtml));
|
||||
}
|
||||
const complete=await fetch(base+'/studio/template-layout?id='+seriesId,{headers});assert.equal(complete.status,200);const completeHtml=await complete.text(),artwork=completeHtml.match(/<main[\s\S]*?<\/main>/)[0];assert.equal((artwork.match(/data-page-key=/g)||[]).length,3);assert.ok(artwork.includes('data-style-version="autumn-journal/1"'));assert.ok(artwork.includes('把秋天'));assert.ok(artwork.includes('先看见'));assert.ok(artwork.includes('留一点白'));assert.ok(!/button|contenteditable|workbench-top/.test(artwork));
|
||||
const second=await fetch(base+'/studio/template-layout?id='+seriesId+'&page=2&export=1',{headers});assert.equal(second.status,200);const secondHtml=(await second.text()).match(/<main[\s\S]*?<\/main>/)[0];assert.equal((secondHtml.match(/data-page-key=/g)||[]).length,1);assert.ok(secondHtml.includes('data-page="2"'));assert.ok(secondHtml.includes('height:1440px'));assert.ok(secondHtml.includes('width=2160'));assert.ok(secondHtml.includes('profile=export'));assert.ok(secondHtml.includes('Noto Serif CJK SC'));
|
||||
for(const suffix of ['0','4','bad'])assert.equal((await fetch(base+'/studio/template-layout?id='+seriesId+'&page='+suffix,{headers})).status,404);
|
||||
for(const path of ['/studio/template-layout','/studio/template-layout?id=invalid','/studio/template-layout?id='+missingId,'/studio/template-layout?id='+designId+'&id='+standardId])assert.equal((await fetch(base+path,{headers})).status,404);
|
||||
assert.equal((await fetch(base+'/studio/layouts/ref-02?id='+designId,{headers})).status,404);
|
||||
assert.equal((await fetch(base+'/studio/layouts/ref-01?id='+missingId,{headers})).status,404);
|
||||
const beforeInvalid=reads;assert.equal((await fetch(base+'/studio/layouts/ref-01?id=invalid',{headers})).status,404);assert.equal(reads,beforeInvalid);
|
||||
const legacy=await fetch(base+'/studio/designs/'+designId,{headers,redirect:'manual'});assert.equal(legacy.status,307);assert.equal(legacy.headers.get('location'),'/studio/layouts/ref-01?id='+designId);
|
||||
const legacy=await fetch(base+'/studio/designs/'+designId,{headers,redirect:'manual'});assert.equal(legacy.status,307);assert.equal(legacy.headers.get('location'),'/studio/template-layout?id='+designId);
|
||||
const old=await fetch(base+'/studio/layouts/ref-01?id='+designId,{headers,redirect:'manual'});assert.equal(old.status,307);assert.equal(old.headers.get('location'),'/studio/template-layout?id='+designId);
|
||||
assert.equal((await fetch(base+'/studio/layouts/ref-28')).status,404);assert.equal((await fetch(base+'/studio/api/layouts/ref-01',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({texts:{I01:{value:'bad'}}})})).status,400);console.log('All 27 native pages, server-loaded saved IDs, legacy redirects and render APIs passed');}finally{child.kill('SIGTERM');await once(child,'exit');await new Promise(r=>wiki.close(r))}
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
/** The approved mark is copied from @endoleap/brand during the remote build. */
|
||||
export function Brand({ compact = false }: { compact?: boolean }) {
|
||||
return <span className="endoleap-brand"><img src="/studio/brand/logo-light.svg" className="endoleap-mark endoleap-mark-light" alt="" width={32} height={32}/><img src="/studio/brand/logo-dark.svg" className="endoleap-mark endoleap-mark-dark" alt="" width={32} height={32}/><span className="endoleap-wordmark">啡跃{!compact && <small>Endoleap</small>}</span></span>;
|
||||
}
|
||||
@@ -10,5 +10,5 @@ export function LayoutSelection(){
|
||||
async function download(format:'svg'|'json'){
|
||||
try{setStatus('正在生成…');const r=await fetch('/studio/api/export/layout-selection?format='+format,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({layoutId:layout.id,...current})});if(!r.ok)throw Error();const url=URL.createObjectURL(await r.blob()),a=document.createElement('a');a.href=url;a.download=layout.id+'.'+format;a.click();setTimeout(()=>URL.revokeObjectURL(url),500);setStatus('已导出当前模板。')}catch{setStatus('导出失败,请重试。')}
|
||||
}
|
||||
return <main className="template-trial"><Head><title>三种排版 · Studio 工具</title></Head><header className="trial-nav"><a href={tutorialPost}>← PROMPT 使用教程</a><span>STUDIO / LAYOUT SELECTION</span></header><section className="trial-intro"><div><small>REF-01 / REF-07 / REF-26</small><h1>三个构图,各有位置。</h1><p>切换真实原图对应的模板,分别试文字与配色。图位与装饰按教程构件重建为干净矢量;当前只放占位内容。</p></div></section><div className="trial-workspace"><aside className="trial-controls"><h2>选择与编辑</h2><label>排版<select value={active} onChange={e=>{setActive(Number(e.target.value));setStatus('')}}>{selections.map((l,i)=><option key={l.id} value={i}>{l.id} · {l.name}</option>)}</select></label>{layout.slots.filter(s=>s.kind==='text').map(s=><label key={s.id}>{s.id} · {s.role}<textarea rows={current.fields[s.id]?.includes('\n')?3:1} maxLength={120} value={current.fields[s.id]||''} onChange={e=>update({fields:{...current.fields,[s.id]:e.target.value}})}/></label>)}<div className="trial-colors"><label>主图色<input type="color" value={current.color} onChange={e=>update({color:e.target.value})}/></label><label>副图色<input type="color" value={current.accent} onChange={e=>update({accent:e.target.value})}/></label></div><label className="trial-switch"><input type="checkbox" checked={guides} onChange={e=>setGuides(e.target.checked)}/>显示构件边界</label><div className="trial-actions"><button className="trial-primary" onClick={()=>void download('svg')}>下载当前 SVG</button><button onClick={()=>void download('json')}>下载参数 JSON</button></div><p className="trial-help">每张模板单独保留本页修改,刷新恢复初始值。下载 SVG 为无边界线的正式图;原始低分辨率图层在 JSON 中保留作对照。</p><p role="status">{status}</p></aside><div><div className="selection-preview"><SourceCover layout={layout} values={current} guides={guides}/></div><div className="selection-thumbs">{selections.map((l,i)=><button key={l.id} aria-pressed={active===i} onClick={()=>setActive(i)}><SourceCover layout={l} values={values[i]}/><span>{l.id}</span></button>)}</div><details className="trial-reference"><summary>原图与构件坐标</summary><div className="trial-reference-body"><img src={referencePreview(layout.referenceUrl)} alt={layout.name+'原图'}/><div><a href={layout.tutorialUrl}>阅读这张原图的教程 ↗</a><table><thead><tr><th>ID</th><th>构件</th><th>x,y,w,h</th></tr></thead><tbody>{layout.slots.map(s=><tr key={s.id}><td>{s.id}</td><td>{s.role}</td><td>{s.box.join(', ')}</td></tr>)}</tbody></table></div></div></details></div></div></main>;
|
||||
return <main className="template-trial"><Head><title>三种排版 · 啡跃 Endoleap 工具</title></Head><header className="trial-nav"><a href={tutorialPost}>← 啡跃使用教程</a><span>STUDIO / LAYOUT SELECTION</span></header><section className="trial-intro"><div><small>REF-01 / REF-07 / REF-26</small><h1>三个构图,各有位置。</h1><p>切换真实原图对应的模板,分别试文字与配色。图位与装饰按教程构件重建为干净矢量;当前只放占位内容。</p></div></section><div className="trial-workspace"><aside className="trial-controls"><h2>选择与编辑</h2><label>排版<select value={active} onChange={e=>{setActive(Number(e.target.value));setStatus('')}}>{selections.map((l,i)=><option key={l.id} value={i}>{l.id} · {l.name}</option>)}</select></label>{layout.slots.filter(s=>s.kind==='text').map(s=><label key={s.id}>{s.id} · {s.role}<textarea rows={current.fields[s.id]?.includes('\n')?3:1} maxLength={120} value={current.fields[s.id]||''} onChange={e=>update({fields:{...current.fields,[s.id]:e.target.value}})}/></label>)}<div className="trial-colors"><label>主图色<input type="color" value={current.color} onChange={e=>update({color:e.target.value})}/></label><label>副图色<input type="color" value={current.accent} onChange={e=>update({accent:e.target.value})}/></label></div><label className="trial-switch"><input type="checkbox" checked={guides} onChange={e=>setGuides(e.target.checked)}/>显示构件边界</label><div className="trial-actions"><button className="trial-primary" onClick={()=>void download('svg')}>下载当前 SVG</button><button onClick={()=>void download('json')}>下载参数 JSON</button></div><p className="trial-help">每张模板单独保留本页修改,刷新恢复初始值。下载 SVG 为无边界线的正式图;原始低分辨率图层在 JSON 中保留作对照。</p><p role="status">{status}</p></aside><div><div className="selection-preview"><SourceCover layout={layout} values={current} guides={guides}/></div><div className="selection-thumbs">{selections.map((l,i)=><button key={l.id} aria-pressed={active===i} onClick={()=>setActive(i)}><SourceCover layout={l} values={values[i]}/><span>{l.id}</span></button>)}</div><details className="trial-reference"><summary>原图与构件坐标</summary><div className="trial-reference-body"><img src={referencePreview(layout.referenceUrl)} alt={layout.name+'原图'}/><div><a href={layout.tutorialUrl}>阅读这张原图的教程 ↗</a><table><thead><tr><th>ID</th><th>构件</th><th>x,y,w,h</th></tr></thead><tbody>{layout.slots.map(s=><tr key={s.id}><td>{s.id}</td><td>{s.role}</td><td>{s.box.join(', ')}</td></tr>)}</tbody></table></div></div></details></div></div></main>;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { ThemeToggle } from "./ThemeToggle";
|
||||
import { Brand } from "./Brand";
|
||||
import {CurvedCopy} from './NativeCurvedCopy';
|
||||
import {useEffect,useRef,useState,type CSSProperties,type KeyboardEvent,type PointerEvent} from 'react';
|
||||
import {ArrowLeft,MousePointer2,Type,Square,Circle,ImagePlus,Layers,SlidersHorizontal,Undo2,Redo2,Copy,Trash2,Eye,EyeOff,Lock,Unlock,ChevronUp,ChevronDown,Minus,Plus,Scan,Save} from 'lucide-react';
|
||||
@@ -55,7 +57,7 @@ export function LayoutWorkbench({layout,design:initialDesign}:LayoutPageProps){
|
||||
const selectedColor=slot?(slot.kind==='text'?params.texts[slot.id]?.color||textColor(layout.id,slot):slot.kind==='image'?params.images[slot.id]?.color||String(imageShape(layout,slot).backgroundColor):params.decorations[slot.id]?.color||'#83908a'):'';
|
||||
return <main className={'native-layout-page'+(editing?' is-editing':'')}>
|
||||
<Head><title>{name} · Prompt</title><meta name="description" content={layout.name+' · 可直接编辑的排版页面'}/></Head>
|
||||
{editing&&<header className="workbench-top"><a href="/explore/8eb8c76500580f7a4b56ba3c" aria-label="返回目录"><ArrowLeft size={18}/></a><span className="studio-mark">P<span>STUDIO</span></span><input aria-label="页面名称" value={name} maxLength={120} onChange={e=>{setName(e.target.value);setDirty(true)}}/><span className="save-indicator">{busy?'正在处理…':dirty?'有未保存的修改':design?'已保存':'尚未保存'}</span><button title="撤销 ⌘Z" disabled={!history.length||busy} onClick={undo}><Undo2 size={17}/></button><button title="重做 ⇧⌘Z" disabled={!future.length||busy} onClick={redo}><Redo2 size={17}/></button><button onClick={()=>{setEditing(false);setTextEditing(null);setSelected(null)}}>查看页面</button><button className="primary" disabled={busy||!name.trim()} onClick={()=>void save()}><Save size={15}/>保存</button></header>}
|
||||
{editing&&<header className="workbench-top"><a href="/explore/8eb8c76500580f7a4b56ba3c" aria-label="返回目录"><ArrowLeft size={18}/></a><a href="/workspace" className="studio-mark"><Brand compact/></a><ThemeToggle/><input aria-label="页面名称" value={name} maxLength={120} onChange={e=>{setName(e.target.value);setDirty(true)}}/><span className="save-indicator">{busy?'正在处理…':dirty?'有未保存的修改':design?'已保存':'尚未保存'}</span><button title="撤销 ⌘Z" disabled={!history.length||busy} onClick={undo}><Undo2 size={17}/></button><button title="重做 ⇧⌘Z" disabled={!future.length||busy} onClick={redo}><Redo2 size={17}/></button><button onClick={()=>{setEditing(false);setTextEditing(null);setSelected(null)}}>查看页面</button><button className="primary" disabled={busy||!name.trim()} onClick={()=>void save()}><Save size={15}/>保存</button></header>}
|
||||
{editing&&<aside className={'workbench-layers '+(panel==='layers'?'mobile-open':'')} inert={busy}><div className="panel-heading"><Layers size={16}/><strong>图层与素材</strong><span>{elements.length}</span></div><div className="insert-tools"><button onClick={()=>add('text')}><Type/>文字</button><button onClick={()=>add('decoration','rectangle')}><Square/>矩形</button><button onClick={()=>add('decoration','ellipse')}><Circle/>椭圆</button><button onClick={()=>add('image')}><ImagePlus/>图片</button></div><div className="layers-caption">{layout.id.toUpperCase()} <span>从上到下排列</span></div><div className="layer-list">{[...elements].sort((a,b)=>(layer(b).z??(b.kind==='text'?3:b.kind==='image'?1:2))-(layer(a).z??(a.kind==='text'?3:a.kind==='image'?1:2))).map(s=><div key={s.id} className={'layer-row '+(selected===s.id?'active':'')+(layer(s).hidden?' muted':'')}><button className="layer-select" onClick={()=>{setSelected(s.id);setTextEditing(null)}}>{s.kind==='text'?<Type size={14}/>:s.kind==='image'?<ImagePlus size={14}/>:<Square size={14}/>}<span>{s.role}</span></button><button title="显示或隐藏图层" aria-label={s.role+'显示或隐藏'} onClick={()=>updateLayer(s,{hidden:!layer(s).hidden})}>{layer(s).hidden?<EyeOff size={13}/>:<Eye size={13}/>}</button><button title="锁定或解锁图层" aria-label={s.role+'锁定或解锁'} onClick={()=>updateLayer(s,{locked:!layer(s).locked})}>{layer(s).locked?<Lock size={13}/>:<Unlock size={13}/>}</button></div>)}</div><p className="workbench-tip">拖动移动 · 双击文字改写<br/>⌘/Ctrl D 复制 · 方向键微调<br/>Shift + 方向键移动 10 单位</p></aside>}
|
||||
<div ref={viewport} className={editing?'workbench-viewport':'page-viewport'} onClick={e=>{if(e.target===e.currentTarget){setSelected(null);setTextEditing(null)}}}>
|
||||
{editing&&<div className="canvas-caption">{layout.name}<span>{layout.width} × {layout.height}</span></div>}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import Head from 'next/head';
|
||||
import type {CSSProperties} from 'react';
|
||||
import type {AddedSlot,Layout,Parameters} from '../lib/layout-parameters';
|
||||
import {CurvedCopy} from './NativeCurvedCopy';
|
||||
import {decorationKind,defaultText,emptyParameters,imageShape,paperColor,slotPosition,textColor,textSize,type Design} from '../lib/native-layout';
|
||||
|
||||
/** A saved result contains artwork only: no editing state, controls or event handlers. */
|
||||
export function NativeResult({layout,design}:{layout:Layout;design:Design|null}){
|
||||
return <main className="native-layout-page"><Head><title>{design?.name||layout.name} · Prompt</title></Head><NativeArtwork layout={layout} design={design}/></main>;
|
||||
}
|
||||
export function NativeArtwork({layout,design,quality=false}:{layout:Layout;design:Design|null;quality?:boolean}){
|
||||
const params:Parameters=design?.parameters||emptyParameters();
|
||||
const elements=[...layout.elements,...(params.added||[])];
|
||||
return <article className={'native-layout '+layout.id} aria-label={design?.name||layout.name} data-layout-id={layout.id} data-design-id={design?.id} style={{aspectRatio:`${layout.width}/${layout.height}`,height:quality?Math.round(1080*layout.height/layout.width):undefined,backgroundColor:params.paper||paperColor(layout.id)}}>
|
||||
{['image','decoration','text'].flatMap(kind=>elements.filter(s=>s.kind===kind).map(s=>{
|
||||
const t=params.texts[s.id],im=params.images[s.id],d=params.decorations[s.id],ls=params.layers?.[s.id]||{};
|
||||
if(ls.hidden||(kind==='decoration'&&d?.hidden))return null;
|
||||
const text=t?.value??defaultText(layout,s);
|
||||
const position:CSSProperties={...slotPosition(layout,{...s,box:[ls.x??s.box[0],ls.y??s.box[1],ls.width??s.box[2],ls.height??s.box[3]]}),transform:`rotate(${ls.rotation||0}deg)`,opacity:ls.opacity??1,borderRadius:ls.radius===undefined?undefined:`${ls.radius/layout.width*100}cqw`,zIndex:ls.z??(kind==='text'?3:kind==='image'?1:2)};
|
||||
const textStyle:CSSProperties={fontSize:`${(t?.fontSize??textSize(layout,s,text)*(t?.scale||1))/layout.width*100}cqw`,color:t?.color||textColor(layout.id,s),fontFamily:t?.fontFamily==='serif'?'Georgia,"Noto Serif CJK SC","Songti SC",serif':t?.fontFamily==='mono'?'"Noto Sans Mono CJK SC",monospace':t?.fontFamily==='sans'?'Arial,"Noto Sans CJK SC","PingFang SC","Microsoft YaHei",sans-serif':undefined,fontWeight:t?.fontWeight,lineHeight:t?.lineHeight,letterSpacing:t?.letterSpacing===undefined?undefined:`${t.letterSpacing/layout.width*100}cqw`,textAlign:t?.align==='middle'?'center':t?.align==='end'?'right':'left'};
|
||||
return <div key={s.id} className={'native-element native-'+kind} data-element={s.id} aria-label={s.role} style={position}>
|
||||
{kind==='text'?(s.direction==='curved'?<CurvedCopy layout={layout} slot={s} value={text} spacing={t?.letterSpacing} style={textStyle}/>:<div className={'native-copy direction-'+s.direction} style={textStyle}>{text}</div>):kind==='image'?<div className="native-image-fill" style={{...imageShape(layout,s),...(im?.color?{backgroundColor:im.color}:{}),...(ls.radius!==undefined?{borderRadius:`${ls.radius/layout.width*100}cqw`}:{})}}>
|
||||
{im?.assetId&&<img src={'/api/preview?'+new URLSearchParams({source:'/api/media/'+im.assetId,width:quality?'2160':'960',...(quality?{profile:'export'}:{})})} srcSet={quality?undefined:[240,480,960,1280].map(width=>'/api/preview?'+new URLSearchParams({source:'/api/media/'+im.assetId!,width:String(width)})+' '+width+'w').join(',')} sizes={`(max-width: 860px) ${Math.min(100,(ls.width??s.box[2])/layout.width*100)}vw, ${Math.min(860,(ls.width??s.box[2])/layout.width*860)}px`} alt={s.role} style={{objectFit:im.fit||'cover',objectPosition:`${im.cropX??50}% ${im.cropY??50}%`,transform:`scale(${im.zoom??1})`}}/>}
|
||||
</div>:<div className={'native-decoration decoration-'+((s as AddedSlot).shape?'solid':decorationKind(s))} style={{borderRadius:ls.radius!==undefined?`${ls.radius/layout.width*100}cqw`:(s as AddedSlot).shape==='ellipse'?'50%':undefined,color:d?.color||'#83908a',backgroundColor:(s as AddedSlot).shape?d?.color||'#83908a':decorationKind(s)==='band'?d?.color||'#dce1d8':undefined}}>{decorationKind(s)==='lines'&&Array.from({length:s.box[3]>4?3:1},(_,i)=><i key={i}/>)}{decorationKind(s)==='vertical-lines'&&<><i/><i/><i/></>}</div>}
|
||||
</div>;
|
||||
}))}
|
||||
</article>;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import Head from 'next/head';
|
||||
import fixed from '../content/templates/layouts.json';
|
||||
import standard from '../content/standard-layout.json';
|
||||
import {pageParameters} from '../lib/series';
|
||||
import type {Design} from '../lib/native-layout';
|
||||
import {NativeArtwork} from './NativeResult';
|
||||
export function NativeSeries({design,page,quality}:{design:Design;page:number|null;quality:boolean}){
|
||||
const doc=design.document!;
|
||||
return <main className={'native-layout-page native-series'+(quality?' native-export':'')} data-style-version={doc.style.version} data-revision={design.revision}>
|
||||
<Head><title>{design.name} · {doc.pages.length}页</title><meta name="description" content={doc.brief.intent}/></Head>
|
||||
{doc.pages.map((p,index)=>{if(page!==null&&page!==index+1)return null;const layout=[standard,...fixed].find(l=>l.id===p.layoutId)!;return <div className="series-page" key={p.key} data-page={index+1} data-page-key={p.key} data-page-role={p.role}><NativeArtwork layout={layout} quality={quality} design={{...design,name:design.name+' · '+(index+1),layoutId:p.layoutId,parameters:pageParameters(doc,p,layout)}}/>{!quality&&<p className="series-page-number">{String(index+1).padStart(2,'0')} / {doc.pages.length}</p>}</div>})}
|
||||
</main>;
|
||||
}
|
||||
@@ -12,8 +12,8 @@ export function TemplateTrial({idea}:{idea:Idea}){
|
||||
const download=(text:string,type:string,name:string)=>{const url=URL.createObjectURL(new Blob([text],{type})),a=document.createElement('a');a.href=url;a.download=name;a.click();setTimeout(()=>URL.revokeObjectURL(url),500)};
|
||||
async function exportSvg(){await document.fonts.ready;const svg=document.querySelector(`[data-proof="${selected.key}"] svg`);if(!svg)return;download(new XMLSerializer().serializeToString(svg),'image/svg+xml',`ref-17-${selected.key}.svg`);setNotice('已下载当前封面 SVG,可继续编辑文字与图形。')}
|
||||
const reset=()=>{setSlides(idea.slides);setMasthead(template.masthead);setGuides(false);setNotice('已恢复初始试版。')};
|
||||
return <main className="template-trial"><Head><title>{idea.title} · Studio</title><link rel="alternate" type="text/markdown" href={'/studio/api/export/'+idea.slug+'?format=markdown'}/></Head>
|
||||
<header className="trial-nav"><a href={tutorialPost}>← PROMPT 使用教程</a><span>STUDIO / TEMPLATE PROOF</span><a href={template.tutorialUrl} target="_blank" rel="noreferrer">排版教程 ↗</a></header>
|
||||
return <main className="template-trial"><Head><title>{idea.title} · 啡跃 Endoleap</title><link rel="alternate" type="text/markdown" href={'/studio/api/export/'+idea.slug+'?format=markdown'}/></Head>
|
||||
<header className="trial-nav"><a href={tutorialPost}>← 啡跃使用教程</a><span>STUDIO / TEMPLATE PROOF</span><a href={template.tutorialUrl} target="_blank" rel="noreferrer">排版教程 ↗</a></header>
|
||||
<section className="trial-intro"><div><small>REF-17 · 同一母版 / 四张试版</small><h1>先试封面,再填内容。</h1><p>英文刊头、圆形叠标、左下主图与右侧窄栏保持同一套坐标。三组合集先用主题占位,暂不放入具体 Skills。</p></div><div className="trial-source"><b>01 母版</b><span>02 笔记复习</span><span>03 教学资料</span><span>04 英语学习</span></div></section>
|
||||
<div className="trial-workspace"><aside className="trial-controls"><h2>试一试</h2><label>当前封面<select value={active} onChange={e=>{setActive(Number(e.target.value));setNotice('')}}>{slides.map((s,i)=><option value={i} key={s.key}>{s.label}</option>)}</select></label>
|
||||
<fieldset><legend>共享英文刊头</legend><label>第一行<input value={masthead[0]} maxLength={8} onChange={e=>setMasthead([e.target.value.toUpperCase().replace(/[^A-Z ]/g,''),masthead[1]])}/></label><label>第二行<input value={masthead[1]} maxLength={8} onChange={e=>setMasthead([masthead[0],e.target.value.replace(/[^A-Za-z ]/g,'')])}/></label></fieldset>
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
export function ThemeToggle() {
|
||||
const [dark, setDark] = useState(false);
|
||||
const [ready, setReady] = useState(false);
|
||||
useEffect(() => { const sync = () => setDark(document.documentElement.dataset.mode === 'dark'); sync(); setReady(true); window.addEventListener('endoleap-theme', sync); return () => window.removeEventListener('endoleap-theme', sync); }, []);
|
||||
return <button type="button" data-theme-ready={ready} className="endoleap-theme-toggle" aria-label="切换明暗主题" title={dark ? '切换浅色模式' : '切换深色模式'} onClick={() => { const mode = document.documentElement.dataset.mode === 'dark' ? 'light' : 'dark'; const scope = document.documentElement.dataset.theme?.startsWith('canvas-') ? 'canvas' : 'home'; document.documentElement.dataset.mode = mode; document.documentElement.dataset.theme = `${scope}-${mode}`; try { localStorage.setItem('endoleap.theme', mode); } catch {} window.dispatchEvent(new Event('endoleap-theme')); }}>◐<span>{dark ? '浅色' : '深色'}</span></button>;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import {forwardRef,useEffect,useImperativeHandle,useRef,useState} from 'react';
|
||||
import {parseCanvasDocument,type CanvasDocument} from '../../lib/canvas-document';
|
||||
import s from '../../styles/canvas-agent.module.css';
|
||||
type Task={id:string;targetId:string;status:'queued'|'running'|'succeeded'|'failed';error?:string};
|
||||
type State={id:string;name:string;revision:number;document:CanvasDocument;tasks:Task[]};
|
||||
type LinkState={expiresAt:string;lastSeenAt:string|null};
|
||||
export type CanvasAgentHandle={queue:(target:string)=>Promise<void>};
|
||||
type Props={document:CanvasDocument;email:string;onLoad:(document:CanvasDocument)=>void;onFeedback:(text:string)=>void};
|
||||
async function api(path:string,method='GET',body?:unknown){const r=await fetch('/api/canvases'+path,{method,headers:body?{'Content-Type':'application/json'}:undefined,...(body?{body:JSON.stringify(body)}:{})});const data=await r.json();if(!r.ok)throw Error(data.error||'在线画布暂时不可用');return data;}
|
||||
const names={queued:'等待 Agent',running:'Agent 正在处理',succeeded:'已完成',failed:'已结束'};
|
||||
export default forwardRef<CanvasAgentHandle,Props>(function CanvasAgentPanel(p,ref){
|
||||
const [open,setOpen]=useState(false),[busy,setBusy]=useState(false),[message,setMessage]=useState(''),[remote,setRemote]=useState<State|null>(null),[pending,setPending]=useState<State|null>(null),[connection,setConnection]=useState<LinkState|null>(null),[credential,setCredential]=useState(''),[items,setItems]=useState<{id:string;name:string}[]>([]);
|
||||
const live=useRef(p.document),synced=useRef(''),current=useRef<State|null>(null),locked=useRef(false),account=useRef(p.email),generation=useRef(0),credentialField=useRef<HTMLTextAreaElement>(null);
|
||||
live.current=p.document;account.current=p.email;
|
||||
function update(state:State){current.current=state;setRemote(state);}
|
||||
function apply(state:State){state={...state,document:parseCanvasDocument(state.document)};synced.current=JSON.stringify(state.document);update(state);setPending(null);p.onLoad(state.document);}
|
||||
async function run(action:()=>Promise<void>){if(locked.current)return;locked.current=true;setBusy(true);try{await action()}catch(e){const text=e instanceof Error?e.message:'操作失败';setMessage(text);p.onFeedback(text)}finally{locked.current=false;setBusy(false)}}
|
||||
useEffect(()=>{generation.current++;current.current=null;synced.current='';setRemote(null);setPending(null);setConnection(null);setCredential('');setItems([]);if(!p.email)return;const epoch=generation.current;void api('').then(data=>{if(epoch===generation.current)setItems(data.canvases)}).catch(()=>{});},[p.email]);
|
||||
useEffect(()=>{if(!remote||!p.email)return;const id=remote.id,epoch=generation.current;let cancelled=false;const poll=async()=>{try{const state:State=await api('/'+id);if(cancelled||epoch!==generation.current||current.current?.id!==id||locked.current)return;if(state.revision!==current.current.revision){if(JSON.stringify(live.current)===synced.current){apply(state);setMessage('已同步 Agent 的画布更新')}else{setPending(state);setMessage('在线画布有更新,请选择要保留的版本')}}else update(state);const link=await api('/'+id+'/link');if(!cancelled&&epoch===generation.current)setConnection(link.link)}catch(e){if(!cancelled)setMessage(e instanceof Error?e.message:'同步失败')}};void poll();const timer=setInterval(()=>void poll(),5000);return()=>{cancelled=true;clearInterval(timer)};},[remote?.id,p.email]);
|
||||
async function save():Promise<State>{if(!account.current)throw Error('请先登录邮箱账户');if(pending)throw Error('请先处理在线版本冲突');const doc=live.current,serialized=JSON.stringify(doc),old=current.current;const state:State=old?serialized===synced.current?old:await api('/'+old.id,'PUT',{revision:old.revision,document:doc}):await api('','POST',{document:doc,name:'灵感画布 '+new Date().toLocaleDateString()});if(account.current!==p.email)throw Error('账户已切换,请重新连接');synced.current=serialized;update(state);setMessage('已保存在线画布');return state;}
|
||||
useImperativeHandle(ref,()=>({queue:async target=>{setOpen(true);await run(async()=>{const state=await save();if(JSON.stringify(live.current)!==synced.current)throw Error('画布刚有修改,请重新派发任务');const task:Task=await api('/'+state.id+'/tasks','POST',{revision:state.revision,targetId:target});if(account.current!==p.email)return;update({...state,tasks:[task,...state.tasks]});setMessage('任务已创建,等待你的 coding agent 领取');p.onFeedback('任务已创建,等待 Agent')})}}));
|
||||
async function connect(){const state=await save(),link=await api('/'+state.id+'/link','POST',{});if(account.current!==p.email)return;setConnection({expiresAt:link.expiresAt,lastSeenAt:null});setCredential(JSON.stringify({canvasUrl:location.origin+'/api/canvases/'+state.id,authorization:'Bearer '+link.token,documentation:location.origin+link.documentation,expiresAt:link.expiresAt},null,2));setMessage('连接凭据已生成,有效期 1 小时');}
|
||||
async function copyCredential(){
|
||||
if(navigator.clipboard?.writeText){try{await navigator.clipboard.writeText(credential);setMessage('凭据已复制');return}catch{}}
|
||||
const field=credentialField.current;if(!field)return;field.focus();field.select();
|
||||
let copied=false;try{copied=document.execCommand('copy')}catch{}
|
||||
setMessage(copied?'凭据已复制':'已选中凭据,请按 Ctrl+C 或 ⌘C 复制');
|
||||
}
|
||||
const dirty=!!remote&&JSON.stringify(p.document)!==synced.current;
|
||||
return <div className={s.root}><button onClick={()=>setOpen(v=>!v)} aria-expanded={open}>Agent{remote?' · 在线画布':''}</button>{open&&<section className={s.panel} aria-label="Agent 连接"><header><strong>你的 coding agent</strong><button aria-label="关闭 Agent 连接" onClick={()=>setOpen(false)}>×</button></header>{!p.email?<a href="/account">登录后连接在线画布 ↗</a>:<>
|
||||
<div className={s.actions}><button disabled={busy||!!pending} onClick={()=>void run(async()=>{await save()})}>{remote?(dirty?'保存修改':'已同步'):'保存为在线画布'}</button><button disabled={busy||!!pending} onClick={()=>void run(connect)}>生成连接凭据</button>{connection&&<button disabled={busy} onClick={()=>void run(async()=>{await api('/'+current.current!.id+'/link','DELETE',{});setConnection(null);setCredential('');setMessage('Agent 访问已撤销')})}>撤销连接</button>}</div>
|
||||
{!remote&&<label>打开在线画布<select aria-label="打开在线画布" defaultValue="" disabled={busy} onChange={e=>{const id=e.target.value;if(id)void run(async()=>{const state:State=await api('/'+id);parseCanvasDocument(state.document);setPending(state);setMessage('加载在线画布将替换当前草稿,请先导出需要保留的内容')})}}><option value="">选择画布</option>{items.map(item=><option value={item.id} key={item.id}>{item.name} · {item.id.slice(0,8)}</option>)}</select></label>}
|
||||
{connection&&<small>{connection.lastSeenAt?'Agent 最近访问:'+new Date(connection.lastSeenAt).toLocaleTimeString():'等待 Agent 首次访问'} · 到期 {new Date(connection.expiresAt).toLocaleTimeString()}</small>}
|
||||
{credential&&<><textarea ref={credentialField} aria-label="Agent 连接凭据" readOnly value={credential}/><button onClick={()=>void copyCredential()}>复制给 coding agent</button></>}
|
||||
{pending&&<div className={s.conflict}><button disabled={busy} onClick={()=>{apply(pending);setMessage('已载入在线版本')}}>载入在线版本</button><button disabled={busy} onClick={()=>{setPending(null);current.current=null;setRemote(null);synced.current='';setConnection(null);setCredential('');setMessage('已保留本机草稿,可另存为新画布')}}>保留草稿并断开</button></div>}
|
||||
{!!remote?.tasks.length&&<ul aria-label="画布任务">{remote.tasks.slice(0,8).map(task=><li key={task.id}><span>{p.document.cards.find(n=>n.id===task.targetId)?.title||'已移除节点'} · {names[task.status]}</span>{task.error&&<small>{task.error}</small>}{['queued','running'].includes(task.status)&&<button disabled={busy} onClick={()=>void run(async()=>{await api('/'+remote.id+'/tasks/'+task.id,'PATCH',{status:'failed',error:'用户结束任务'});setMessage('任务已结束,可修改后重新派发')})}>结束任务</button>}</li>)}</ul>}
|
||||
</>}<p role="status">{message}</p><a href="/api/canvases?agent_mode=True" target="_blank" rel="noreferrer">接入文档 ↗</a></section>}</div>
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
import {useEffect,useState} from 'react';
|
||||
import {ImagePlus,FileText,Plus,X,Maximize,Download,ArrowUp} from 'lucide-react';
|
||||
import type {CanvasNode} from '../../lib/canvas-document';
|
||||
import s from '../../styles/canvas-composer.module.css';
|
||||
type Props={pickerRequest:number;feedback:string;node:CanvasNode;nodes:CanvasNode[];references:CanvasNode[];onChange:(text:string)=>void;onRename:(title:string)=>void;onBeginEdit:()=>void;onUpload:()=>void;onAddReference:(id:string)=>void;onRemoveReference:(id:string)=>void;onQueue:()=>void;onExport:()=>void;onClose:()=>void};
|
||||
export default function CanvasComposer(p:Props){const [expanded,expand]=useState(false),[choosing,choose]=useState(false);useEffect(()=>{if(p.pickerRequest)choose(true)},[p.pickerRequest]);return <section className={s.composer} data-expanded={expanded} aria-label="节点编辑器"><header><span>{p.node.kind==='image'?<ImagePlus size={16}/>:<FileText size={16}/>} <input aria-label="节点名称" maxLength={200} value={p.node.title} onFocus={p.onBeginEdit} onChange={e=>p.onRename(e.target.value)}/></span><div>{p.node.kind==='image'&&<button onClick={p.onUpload}><Plus size={15}/>上传图片</button>}<button aria-expanded={choosing} onClick={()=>choose(v=>!v)}><Plus size={15}/>画布参考</button><button aria-label="展开编辑器" aria-pressed={expanded} onClick={()=>expand(v=>!v)}><Maximize size={16}/></button><button aria-label="关闭节点编辑器" onClick={p.onClose}><X size={17}/></button></div></header>
|
||||
{choosing&&<div className={s.picker} role="group" aria-label="选择引用节点">{p.nodes.filter(n=>n.id!==p.node.id&&!p.references.some(r=>r.id===n.id)).map(n=><button key={n.id} onClick={()=>{p.onAddReference(n.id);choose(false)}}>{n.kind==='image'?<ImagePlus size={15}/>:<FileText size={15}/>} {n.title}</button>)}{p.nodes.length<2&&<span>先在画布上添加一个参考节点</span>}</div>}
|
||||
{!!p.references.length&&<div className={s.references}>{p.references.map((n,i)=><div className={s.reference} key={n.id}>{n.image?<img src={n.image} alt={n.title}/>:<FileText size={24}/>}<span>{i+1}</span><small>{n.title}</small><button aria-label={`移除引用 ${n.title}`} onClick={()=>p.onRemoveReference(n.id)}><X size={12}/></button></div>)}</div>}
|
||||
<textarea aria-label="创作要求" maxLength={10000} value={p.node.instruction??p.node.text} onFocus={p.onBeginEdit} onChange={e=>p.onChange(e.target.value)} placeholder={p.node.kind==='script'?'描述剧情片段、故事或分镜要求…':'描述画面,或写下对参考图的修改要求…'}/><footer><span role="status">{p.feedback||<>{p.node.operation==='upscale'?'图片高清':p.node.kind==='script'?'分镜脚本':'创作任务'} · {p.references.length} 个引用</>}</span><div><button onClick={p.onExport}><Download size={15}/>导出任务</button><button onClick={p.onQueue}><ArrowUp size={15}/>交给 Agent</button></div></footer></section>}
|
||||
@@ -0,0 +1,16 @@
|
||||
import {useRef,type PointerEvent} from 'react';
|
||||
import {Type,ImagePlus,FileText,Plus,Bold,Italic,Maximize,MoreHorizontal} from 'lucide-react';
|
||||
import type {CanvasNode,TextStyle} from '../../lib/canvas-document';
|
||||
import s from '../../styles/canvas-node.module.css';
|
||||
type Props={node:CanvasNode;selected:boolean;editing:boolean;hand:boolean;onSelect:()=>void;onChooseReference:()=>void;onEdit:()=>void;onDrag:(e:PointerEvent<HTMLElement>)=>void;onResize:(e:PointerEvent<HTMLElement>)=>void;onChange:(text:string)=>void;onRename:(title:string)=>void;onStyle:(style:TextStyle)=>void;onReference:(e:PointerEvent<HTMLButtonElement>|React.MouseEvent<HTMLButtonElement>)=>void;onMenu:(e:React.MouseEvent<HTMLButtonElement>)=>void;onUpload:()=>void;onUpscale:()=>void;onExpand:()=>void;onBeginEdit:()=>void};
|
||||
export default function CanvasNodeView(p:Props){
|
||||
const {node:n}=p;const gesture=useRef<{selected:boolean;x:number;y:number;moved:boolean}|null>(null);
|
||||
const icon=n.kind==='image'?<ImagePlus size={15}/>:n.kind==='script'?<FileText size={15}/>:<Type size={15}/>;
|
||||
const style={fontWeight:n.style?.bold?700:400,fontStyle:n.style?.italic?'italic':'normal',fontSize:n.style?.size||15,color:n.style?.color};
|
||||
return <article data-node-id={n.id} data-editing={p.editing} data-selected={p.selected} className={s.node} style={{left:n.x,top:n.y,width:n.width||320,height:n.height||350}} onPointerDown={e=>{gesture.current=null;if(p.hand||(p.editing&&n.kind==='text')||e.button!==0||(e.target as HTMLElement).closest('button,input,select'))return;gesture.current={selected:p.selected,x:e.clientX,y:e.clientY,moved:false};e.stopPropagation();p.onSelect();p.onDrag(e)}} onPointerMove={e=>{const g=gesture.current;if(g&&Math.hypot(e.clientX-g.x,e.clientY-g.y)>=4)g.moved=true}} onClick={e=>{if((e.target as HTMLElement).closest('button,input,select'))return;const g=gesture.current;if(!p.hand&&!p.editing&&g?.selected&&!g.moved)p.onEdit();gesture.current=null}} onKeyDown={e=>{if(e.target!==e.currentTarget)return;if(e.key==='Enter'){e.preventDefault();p.onEdit()}}} tabIndex={0} aria-label={n.title}>
|
||||
<header className={s.title}>{icon}<input aria-label="节点名称" value={n.title} maxLength={200} onFocus={p.onBeginEdit} onChange={e=>p.onRename(e.target.value)} onPointerDown={e=>e.stopPropagation()}/>{p.selected&&<button aria-label={`${n.title} 操作`} onClick={e=>{e.stopPropagation();p.onMenu(e)}}><MoreHorizontal size={17}/></button>}</header>
|
||||
{p.editing&&n.kind==='text'&&<div className={s.format} onPointerDown={e=>e.stopPropagation()}><input aria-label="文字颜色" type="color" value={n.style?.color||'#dddddd'} onChange={e=>p.onStyle({...n.style,color:e.target.value})}/><select aria-label="文字样式" value={n.style?.size||15} onChange={e=>p.onStyle({...n.style,size:Number(e.target.value)})}><option value={15}>正文</option><option value={24}>小标题</option><option value={32}>标题</option></select><button aria-label="加粗" aria-pressed={!!n.style?.bold} onClick={()=>p.onStyle({...n.style,bold:!n.style?.bold})}><Bold size={17}/></button><button aria-label="斜体" aria-pressed={!!n.style?.italic} onClick={()=>p.onStyle({...n.style,italic:!n.style?.italic})}><Italic size={17}/></button><button aria-label="展开文本节点" onClick={p.onExpand}><Maximize size={17}/></button></div>}
|
||||
{n.kind==='image'?<div data-node-content className={s.image}>{n.image?<img src={n.image} alt={n.title} draggable={false}/>:<div className={s.empty}><ImagePlus size={48} strokeWidth={1}/><button onClick={e=>{e.stopPropagation();p.onUpload()}}>上传图片</button></div>}{p.selected&&n.image&&<button className={s.upscale} onClick={e=>{e.stopPropagation();p.onUpscale()}}>图片高清 ↗</button>}</div>:p.editing&&n.kind==='text'?<textarea data-node-content autoFocus aria-label={`${n.title} 内容`} style={style} value={n.text} maxLength={10000} placeholder="输入内容…" onFocus={p.onBeginEdit} onChange={e=>p.onChange(e.target.value)}/>:<div data-node-content className={s.content} style={style}>{n.text?<p>{n.text}</p>:<div className={s.empty}>{icon}<button onClick={e=>{e.stopPropagation();p.onEdit()}}>{n.kind==='text'?'自己编写内容':n.kind==='script'?'编写脚本':'填写创作要求'}</button></div>}</div>}
|
||||
{p.selected&&<><button className={`${s.reference} ${s.incoming}`} aria-label={`为 ${n.title} 添加参考`} onClick={e=>{e.stopPropagation();p.onChooseReference()}}><Plus size={18}/></button><button className={s.reference} aria-label={`引用 ${n.title} 生成`} onClick={e=>{e.stopPropagation();p.onReference(e)}}><Plus size={18}/></button><button className={s.resize} aria-label={`调整 ${n.title} 尺寸`} onPointerDown={e=>{e.stopPropagation();gesture.current=null;p.onResize(e)}} onClick={e=>e.stopPropagation()}>↘</button></>}
|
||||
</article>;
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
export type NodeKind = 'text' | 'image' | 'prompt' | 'script';
|
||||
export type TextStyle = {bold?:boolean;italic?:boolean;color?:string;size?:number};
|
||||
export type CanvasNode = {
|
||||
id:string;kind:NodeKind;title:string;text:string;x:number;y:number;
|
||||
image?:string;width?:number;height?:number;style?:TextStyle;
|
||||
instruction?:string;
|
||||
operation?:'generate'|'upscale'|'script';
|
||||
};
|
||||
export type CanvasDocument = {version:1;cards:CanvasNode[];links:[string,string][]};
|
||||
const kinds:NodeKind[]=['text','image','prompt','script'];
|
||||
const object=(v:unknown):v is Record<string,unknown>=>!!v&&typeof v==='object'&&!Array.isArray(v);
|
||||
const bounded=(v:unknown,min:number,max:number)=>typeof v==='number'&&Number.isFinite(v)&&v>=min&&v<=max;
|
||||
const text=(v:unknown,max:number)=>typeof v==='string'&&v.length<=max;
|
||||
const image=(v:unknown)=>typeof v==='string'&&v.length<3000000&&/^data:image\/(png|jpeg|webp);base64,[A-Za-z0-9+/=]+$/.test(v);
|
||||
|
||||
export function parseCanvasDocument(value:unknown):CanvasDocument {
|
||||
const d=typeof value==='string'?JSON.parse(value):value;
|
||||
if(!object(d)||d.version!==1||!Array.isArray(d.cards)||d.cards.length>80||!Array.isArray(d.links)||d.links.length>200)throw Error('画布格式不正确');
|
||||
const ids=new Set<string>();
|
||||
const cards:CanvasNode[]=d.cards.map((n:unknown)=>{
|
||||
if(!object(n)||!text(n.id,100)||!n.id||ids.has(n.id as string)||!kinds.includes(n.kind as NodeKind)||!text(n.title,200)||!text(n.text,10000)||!bounded(n.x,-100000,100000)||!bounded(n.y,-100000,100000))throw Error('节点格式不正确');
|
||||
if(n.width!==undefined&&!bounded(n.width,180,1200)||n.height!==undefined&&!bounded(n.height,160,1200)||n.image!==undefined&&!image(n.image))throw Error('节点尺寸或图片不正确');
|
||||
if(n.instruction!==undefined&&!text(n.instruction,10000))throw Error('创作要求格式不正确');
|
||||
if(n.operation!==undefined&&!['generate','upscale','script'].includes(n.operation as string))throw Error('生成任务类型不正确');
|
||||
let style:TextStyle|undefined;
|
||||
if(n.style!==undefined){
|
||||
if(!object(n.style)||n.style.bold!==undefined&&typeof n.style.bold!=='boolean'||n.style.italic!==undefined&&typeof n.style.italic!=='boolean'||n.style.color!==undefined&&(typeof n.style.color!=='string'||!/^#[a-f\d]{6}$/i.test(n.style.color))||n.style.size!==undefined&&!bounded(n.style.size,10,72))throw Error('文字样式不正确');
|
||||
style={...(n.style.bold===undefined?{}:{bold:n.style.bold as boolean}),...(n.style.italic===undefined?{}:{italic:n.style.italic as boolean}),...(n.style.color===undefined?{}:{color:n.style.color as string}),...(n.style.size===undefined?{}:{size:n.style.size as number})};
|
||||
}
|
||||
ids.add(n.id as string);
|
||||
return {id:n.id as string,kind:n.kind as NodeKind,title:n.title as string,text:n.text as string,x:n.x as number,y:n.y as number,...(n.width===undefined?{}:{width:n.width as number}),...(n.height===undefined?{}:{height:n.height as number}),...(n.image===undefined?{}:{image:n.image as string}),...(style?{style}:{}),...(n.instruction===undefined?{}:{instruction:n.instruction as string}),...(n.operation===undefined?{}:{operation:n.operation as CanvasNode['operation']})};
|
||||
});
|
||||
const pairs=new Set<string>();
|
||||
const links:[string,string][]=d.links.map((l:unknown)=>{
|
||||
if(!Array.isArray(l)||l.length!==2||l[0]===l[1]||!l.every(id=>typeof id==='string'&&ids.has(id)))throw Error('引用节点不存在');
|
||||
const key=JSON.stringify(l);if(pairs.has(key))throw Error('引用重复');pairs.add(key);return [l[0],l[1]];
|
||||
});
|
||||
const outgoing=new Map<string,string[]>(),indegree=new Map(cards.map(n=>[n.id,0]));
|
||||
for(const [from,to] of links){outgoing.set(from,[...(outgoing.get(from)||[]),to]);indegree.set(to,indegree.get(to)!+1)}
|
||||
const roots=cards.filter(n=>indegree.get(n.id)===0).map(n=>n.id);let visited=0;
|
||||
while(roots.length){const id=roots.pop()!;visited++;for(const to of outgoing.get(id)||[]){const count=indegree.get(to)!-1;indegree.set(to,count);if(!count)roots.push(to)}}
|
||||
if(visited!==cards.length)throw Error('引用不能形成循环');
|
||||
return {version:1,cards,links};
|
||||
}
|
||||
|
||||
export function referencedNodes(doc:CanvasDocument,target:string):CanvasNode[]{
|
||||
return doc.links.filter(([,to])=>to===target).map(([from])=>doc.cards.find(n=>n.id===from)!).filter(Boolean);
|
||||
}
|
||||
export function connectNodes(doc:CanvasDocument,from:string,to:string):CanvasDocument {
|
||||
if(from===to||![from,to].every(id=>doc.cards.some(n=>n.id===id)))throw Error('请选择不同的有效节点');
|
||||
if(doc.links.some(([a,b])=>a===from&&b===to))return doc;
|
||||
if(doc.links.length>=200)throw Error('最多 200 条引用');
|
||||
const seen=new Set<string>();const pending=[to];
|
||||
while(pending.length){const id=pending.pop()!;if(id===from)throw Error('引用不能形成循环');if(seen.has(id))continue;seen.add(id);pending.push(...doc.links.filter(([a])=>a===id).map(([,b])=>b));}
|
||||
return {...doc,links:[...doc.links,[from,to]]};
|
||||
}
|
||||
export function removeCanvasNode(doc:CanvasDocument,id:string):CanvasDocument {
|
||||
return {...doc,cards:doc.cards.filter(n=>n.id!==id),links:doc.links.filter(l=>!l.includes(id))};
|
||||
}
|
||||
export function deriveNode(doc:CanvasDocument,source:string,node:CanvasNode):CanvasDocument {
|
||||
if(doc.cards.length>=80||doc.cards.some(n=>n.id===node.id))throw Error('节点数量或标识不正确');
|
||||
const target=parseCanvasDocument({version:1,cards:[node],links:[]}).cards[0];
|
||||
for(let attempt=0;attempt<=doc.cards.length+1;attempt++){
|
||||
const blockers=doc.cards.filter(n=>target.x<n.x+(n.width||320)+64&&target.x+(target.width||320)+64>n.x&&target.y<n.y+(n.height||350)+64&&target.y+(target.height||350)+64>n.y);
|
||||
if(!blockers.length)return parseCanvasDocument(connectNodes({...doc,cards:[...doc.cards,target]},source,target.id));
|
||||
const nextY=Math.max(...blockers.map(n=>n.y+(n.height||350)+64));
|
||||
target.y=nextY>100000?-100000:nextY;
|
||||
}
|
||||
throw Error('请移动现有节点后再创建引用节点');
|
||||
}
|
||||
export function agentTask(doc:CanvasDocument,id:string){
|
||||
const node=doc.cards.find(n=>n.id===id);if(!node)throw Error('目标节点不存在');
|
||||
const instruction=node.instruction??node.text;
|
||||
if(!instruction.trim()&&node.operation!=='upscale')throw Error('请先填写创作要求');
|
||||
const references=referencedNodes(doc,id);
|
||||
if(node.operation==='upscale'&&!references.some(n=>n.kind==='image'&&n.image))throw Error('请先引用一张图片');
|
||||
return {schema:'studio.canvas-task/1',target:{id:node.id,kind:node.kind,title:node.title},operation:node.operation||'generate',instruction,...(node.image?{sourceImage:node.image}:{}),references:references.map(n=>({id:n.id,kind:n.kind,title:n.title,text:n.text,...(n.image?{image:n.image}:{}),...(n.style?{style:n.style}:{})})),result:{targetId:node.id,allowedFields:node.kind==='image'?['image','text']:['text']}};
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import type {IncomingMessage} from 'node:http';
|
||||
import {series} from './series';
|
||||
import {parameters,uuid} from './layout-parameters';
|
||||
import fixedLayouts from '../content/templates/layouts.json';
|
||||
import standardLayout from '../content/standard-layout.json';
|
||||
@@ -19,5 +20,5 @@ export async function readDesign(id:unknown,req:IncomingMessage):Promise<Design|
|
||||
const layout=layouts.find(l=>l.id===design.layoutId);
|
||||
if(!layout||design.id!==id)throw new Error('Invalid saved layout');
|
||||
if((design as Design&{archived?:boolean}).archived)return null;
|
||||
return {...design,parameters:parameters(layout,design.parameters)};
|
||||
return {...design,...(design.document?{document:series(design.document,layouts)}:{}),parameters:parameters(layout,design.parameters)};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
export type GraphicItem = {
|
||||
id:string;kind:'text'|'rect'|'ellipse'|'image';name:string;text:string;
|
||||
x:number;y:number;w:number;h:number;fill:string;size:number;hidden:boolean;locked:boolean;src?:string;
|
||||
};
|
||||
export type GraphicDocument = {name:string;background:string;items:GraphicItem[]};
|
||||
const color=(value:unknown)=>typeof value==='string'&&/^#[a-f0-9]{6}$/i.test(value);
|
||||
/** Accept only bounded editable documents; never execute or load external assets. */
|
||||
export function parseGraphicDocument(raw:string):GraphicDocument {
|
||||
if(raw.length>5*1024*1024)throw Error('设计文件不得超过 5MB');
|
||||
const d=JSON.parse(raw);
|
||||
if(!d||typeof d.name!=='string'||d.name.length>100||!color(d.background)||!Array.isArray(d.items)||d.items.length>500)throw Error('设计文件格式不正确');
|
||||
const ids=new Set<string>();
|
||||
for(const i of d.items){
|
||||
if(!i||typeof i.id!=='string'||i.id.length>100||ids.has(i.id)||!['text','rect','ellipse','image'].includes(i.kind)||typeof i.name!=='string'||i.name.length>200||typeof i.text!=='string'||i.text.length>4000||!color(i.fill)||typeof i.hidden!=='boolean'||typeof i.locked!=='boolean'||![i.x,i.y,i.w,i.h,i.size].every(Number.isFinite)||Math.abs(i.x)>4000||Math.abs(i.y)>4000||i.w<1||i.h<1||i.w>4000||i.h>4000||i.size<8||i.size>240)throw Error('设计图层格式不正确');
|
||||
if(i.kind==='image'&&(typeof i.src!=='string'||!/^data:image\/(png|jpeg|webp);base64,[A-Za-z0-9+/=]+$/.test(i.src)))throw Error('图片必须为嵌入式 PNG、JPEG 或 WebP');
|
||||
ids.add(i.id);
|
||||
}
|
||||
return {name:d.name,background:d.background,items:d.items.map((i:GraphicItem)=>({id:i.id,kind:i.kind,name:i.name,text:i.text,x:i.x,y:i.y,w:i.w,h:i.h,fill:i.fill,size:i.size,hidden:i.hidden,locked:i.locked,...(i.kind==='image'?{src:i.src}:{})}))};
|
||||
}
|
||||
@@ -4,6 +4,22 @@ import type {Parameters} from './layout-parameters';
|
||||
export async function embedLayoutImages(req:NextApiRequest,params:Parameters){
|
||||
const ids=[...new Set(Object.values(params.images).flatMap(v=>v.assetId?[v.assetId]:[]))],sources:Record<string,string>={};let total=0;
|
||||
const headers:Record<string,string>={};if(req.headers.authorization)headers.authorization=req.headers.authorization;if(req.headers.cookie)headers.cookie=req.headers.cookie;
|
||||
for(const id of ids){const url='http://waterfall-wiki:3000/api/preview?'+new URLSearchParams({source:'/api/media/'+id,width:'960'}),r=await fetch(url,{headers,signal:AbortSignal.timeout(15000),redirect:'error'});if(!r.ok||r.headers.get('content-type')?.split(';')[0]!=='image/webp')throw Error('图片预览不可访问,请检查图片 ID 与登录状态');const chunks:Buffer[]=[];const reader=r.body!.getReader();try{while(true){const {done,value}=await reader.read();if(done)break;total+=value.length;if(total>16*1024*1024)throw Error('图片预览总计超限');chunks.push(Buffer.from(value))}}finally{await reader.cancel()}sources[id]='data:image/webp;base64,'+Buffer.concat(chunks).toString('base64');}
|
||||
for(const id of ids){const url=(process.env.STUDIO_WIKI_ORIGIN||'http://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;
|
||||
}
|
||||
|
||||
/** Export a self-contained SVG without storing image bytes in template JSON. */
|
||||
export async function embedSvgMedia(req:NextApiRequest,svg:string){
|
||||
const paths=[...new Set(svg.match(/\/api\/media\/[0-9a-f-]{36}/g)||[])];
|
||||
const headers:Record<string,string>={};if(req.headers.authorization)headers.authorization=req.headers.authorization;if(req.headers.cookie)headers.cookie=req.headers.cookie;
|
||||
let total=0;
|
||||
for(const path of paths){
|
||||
const response=await fetch((process.env.STUDIO_WIKI_ORIGIN||'http://waterfall-wiki:3000')+path,{headers,redirect:'error',signal:AbortSignal.timeout(15000)});
|
||||
const mime=response.headers.get('content-type')?.split(';')[0];
|
||||
if(!response.ok||!mime||!['image/png','image/jpeg','image/webp'].includes(mime))throw Error('模板图片不可访问');
|
||||
const chunks:Uint8Array[]=[];const reader=response.body!.getReader();
|
||||
try{while(true){const {done,value}=await reader.read();if(done)break;total+=value.length;if(total>16*1024*1024)throw Error('模板图片总计超限');chunks.push(value);}}finally{await reader.cancel();}
|
||||
svg=svg.replaceAll(path,'data:'+mime+';base64,'+Buffer.concat(chunks).toString('base64'));
|
||||
}
|
||||
return svg;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import type {CSSProperties} from 'react';
|
||||
import type {Layout,Parameters,Slot} from './layout-parameters';
|
||||
|
||||
export type Design={id:string;name:string;layoutId:string;parameters:Parameters;revision:number;url:string};
|
||||
export type Design={document?:import('./series').Series;id:string;name:string;layoutId:string;parameters:Parameters;revision:number;url:string};
|
||||
// getRandomValues is available on the deployment's HTTP origin; randomUUID is not.
|
||||
export const newElementId=()=> 'user-'+Array.from(globalThis.crypto.getRandomValues(new Uint32Array(4)),n=>n.toString(16).padStart(8,'0')).join('');
|
||||
export const emptyParameters=():Parameters=>({texts:{},images:{},decorations:{}});
|
||||
export const designUrl=(layoutId:string,id:string)=>`/studio/layouts/${layoutId}?id=${id}`;
|
||||
export const designUrl=(_layoutId:string,id:string)=>`/studio/template-layout?id=${id}`;
|
||||
export function nativeLayout(layout:Layout):Layout {
|
||||
return {id:layout.id,name:layout.name,width:layout.width,height:layout.height,elements:layout.elements.map(({id,kind,role,box,direction,text,reading})=>({id,kind,role,box,direction,text,reading}))};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
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 +1,8 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
import type { AppProps } from "next/app";
|
||||
import "../styles/globals.css";
|
||||
export default function App({Component,pageProps}:AppProps){return <Component {...pageProps}/>;}
|
||||
import "../styles/brand.css";
|
||||
export default function App({Component,pageProps}:AppProps){const router = useRouter();
|
||||
useEffect(() => { const scope = /^\/(?:graphic|infinite_canvas|layouts|template-layout)(?:\/|$)/.test(router.pathname) ? 'canvas' : 'home'; document.documentElement.dataset.theme = `${scope}-${document.documentElement.dataset.mode === 'dark' ? 'dark' : 'light'}`; }, [router.pathname]);
|
||||
return <Component {...pageProps}/>;}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
import { Html, Head, Main, NextScript } from 'next/document';
|
||||
const initializeTheme = String.raw`(function(){try{var mode=localStorage.getItem('endoleap.theme')==='dark'?'dark':'light';var canvas=/^\/(?:studio\/)?(?:graphic|infinite_canvas|layouts|template-layout)(?:\/|$)/.test(location.pathname);document.documentElement.dataset.mode=mode;document.documentElement.dataset.theme=(canvas?'canvas':'home')+'-'+mode;}catch(e){}})();`;
|
||||
export default function Document() { return <Html lang="zh-CN" data-theme="home-light" data-mode="light" suppressHydrationWarning><Head><link rel="icon" href="/studio/favicon.ico?v=endoleap-1.2" sizes="any"/><link rel="icon" type="image/svg+xml" href="/studio/brand/icon-dark.svg?v=endoleap-1.2"/><link rel="apple-touch-icon" href="/studio/apple-touch-icon.png"/><script dangerouslySetInnerHTML={{__html:initializeTheme}}/></Head><body><Main/><NextScript/></body></Html>; }
|
||||
@@ -1,3 +1,4 @@
|
||||
import {embedSvgMedia} from '../../../lib/layout-images';
|
||||
import {renderToStaticMarkup} from 'react-dom/server';
|
||||
import {serveApiDocumentation} from '../../../lib/api-documentation';
|
||||
import type {NextApiRequest,NextApiResponse} from 'next';
|
||||
@@ -5,7 +6,7 @@ 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){
|
||||
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 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- [按 ID 保存效果](/api/studio/designs?agent_mode=True)\n\n'+ideas.map(i=>`- [${i.title}](/studio/ideas/${i.slug}) · [正文](/studio/api/export/${i.slug}?format=markdown)`).join('\n'));}
|
||||
export default function handler(_:NextApiRequest,res:NextApiResponse){res.setHeader('Content-Type','text/markdown; charset=utf-8');res.send('# 创作站\n\n- [在线画布、引用与 Agent 任务](/api/canvases?agent_mode=True)\n- [27 套排版目录与 API](/studio/api/layouts?agent_mode=True)\n- [多页作品与统一视觉](/api/studio/series?agent_mode=True)\n- [按 ID 保存效果](/api/studio/designs?agent_mode=True)\n\n'+ideas.map(i=>`- [${i.title}](/studio/ideas/${i.slug}) · [正文](/studio/api/export/${i.slug}?format=markdown)`).join('\n'));}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import {embedSvgMedia} from '../../../lib/layout-images';
|
||||
import type {NextApiRequest,NextApiResponse} from 'next';
|
||||
import {embedLayoutImages} from '../../../lib/layout-images';
|
||||
import {createElement} from 'react';
|
||||
@@ -15,7 +16,7 @@ const workspaceSchema={
|
||||
};
|
||||
export default async function handler(req:NextApiRequest,res:NextApiResponse){
|
||||
res.setHeader('Cache-Control','no-store');
|
||||
if(String(req.query.agent_mode).toLowerCase()==='true'&&['GET','HEAD'].includes(req.method||'')){res.setHeader('Content-Type','text/plain; charset=utf-8');if(req.method==='HEAD')return res.status(200).end();return res.send(`# 扩展编辑参数\n${JSON.stringify(workspaceSchema,null,2)}\n\n# 排版参数与渲染 API\n\nGET /studio/api/layouts/{id} 返回模板尺寸、T/I/D 构件 ID、坐标、原始文字可辨识状态与默认参数。id 为 standard-01(标准图文)或 ref-01 .. ref-27(固定构图)。standard-01 仅返回原生模板参数,不提供兼容 SVG。\n页面 /studio/layouts/{id} 直接提供原生 HTML/CSS 默认排版,不加载原图。带 ?id={designId} 在服务端加载已保存页面;ID 与排版不匹配或不存在返回404。右键或编辑页面可调整元素。\nGET 同地址 ?format=svg 是兼容的原图图层导出,与原生页面使用的 HTML/CSS 不同。POST JSON 参数返回 SVG,不自动保存。?format=json 返回规范化参数。\n参数整体形状:{texts:{T01:{value:"标题",color:"#26362f",scale:1,align:"start"}},images:{I01:{color:"#788880",assetId:"已上传图片UUID",fit:"cover"}},decorations:{D01:{color:"#53645b",hidden:false}},paper:"#f4f2eb"}。全部可省略;构件 ID 必须属于当前模板。文字 value 可为空(移除),最多500字/8行;scale 0.25–2,align start/middle/end;fit cover/contain。图片先通过 /api/upload assetOnly=true 上传。\n未替换构件保留原始低分辨率图层;替换文字为 SVG 文字,未辨识小字不会编造。图位使用原始形状蒙版。paper 修改原底色层,不覆盖原图已有设计色块。\n文字、图像、装饰分组一一对应源图,不接受任意 URL、SVG 或未知构件。最大请求64KB。\n保存:POST /api/studio/designs {layoutId,name,parameters},返回 design.id/url/revision。再次打开 /studio/layouts/{layoutId}?id={id}(原生页面);/studio/designs/{id} 自动跳转;PATCH /api/studio/designs/{id} {name,parameters,revision} 更新。\n错误400参数不合法,404模板不存在,405方法不支持。\n目录:/studio/api/layouts?agent_mode=True\n`)}
|
||||
if(String(req.query.agent_mode).toLowerCase()==='true'&&['GET','HEAD'].includes(req.method||'')){res.setHeader('Content-Type','text/plain; charset=utf-8');if(req.method==='HEAD')return res.status(200).end();return res.send(`# 扩展编辑参数\n${JSON.stringify(workspaceSchema,null,2)}\n\n# 排版参数与渲染 API\n\nGET /studio/api/layouts/{id} 返回模板尺寸、T/I/D 构件 ID、坐标、原始文字可辨识状态与默认参数。id 为 standard-01(标准图文)或 ref-01 .. ref-27(固定构图)。standard-01 仅返回原生模板参数,不提供兼容 SVG。\n页面 /studio/layouts/{id} 直接提供原生 HTML/CSS 默认排版,不加载原图。保存结果直接打开 /studio/template-layout?id={designId},服务端根据 ID 读取模板、图片和文字,无需选版或编辑器;缺失或不存在的 ID 返回404。\nGET 同地址 ?format=svg 是兼容的原图图层导出,与原生页面使用的 HTML/CSS 不同。POST JSON 参数返回 SVG,不自动保存。?format=json 返回规范化参数。\n参数整体形状:{texts:{T01:{value:"标题",color:"#26362f",scale:1,align:"start"}},images:{I01:{color:"#788880",assetId:"已上传图片UUID",fit:"cover"}},decorations:{D01:{color:"#53645b",hidden:false}},paper:"#f4f2eb"}。全部可省略;构件 ID 必须属于当前模板。文字 value 可为空(移除),最多500字/8行;scale 0.25–2,align start/middle/end;fit cover/contain。图片先通过 /api/upload assetOnly=true 上传。\n未替换构件保留原始低分辨率图层;替换文字为 SVG 文字,未辨识小字不会编造。图位使用原始形状蒙版。paper 修改原底色层,不覆盖原图已有设计色块。\n文字、图像、装饰分组一一对应源图,不接受任意 URL、SVG 或未知构件。最大请求64KB。\n保存:POST /api/studio/designs {layoutId,name,parameters},返回 design.id/url/revision。再次打开返回的 /studio/template-layout?id={id}(纯结果页面);/studio/designs/{id} 自动跳转;PATCH /api/studio/designs/{id} {name,parameters,revision} 更新。\n错误400参数不合法,404模板不存在,405方法不支持。\n目录:/studio/api/layouts?agent_mode=True\n`)}
|
||||
if(req.query.id==='standard-01'){
|
||||
if(!['GET','HEAD','POST'].includes(req.method||'')){res.setHeader('Allow','GET, HEAD, POST');return res.status(405).end()}
|
||||
try{const p=parameters(standardLayout,req.method==='POST'?req.body:{});
|
||||
@@ -25,6 +26,6 @@ export default async function handler(req:NextApiRequest,res:NextApiResponse){
|
||||
}catch(e){return res.status(400).json({error:e instanceof Error?e.message:'参数不合法'})}
|
||||
}
|
||||
if(!['GET','HEAD','POST'].includes(req.method||'')){res.setHeader('Allow','GET, HEAD, POST');return res.status(405).end()}const layout=layouts.find(l=>l.id===req.query.id);if(!layout)return res.status(404).json({error:'排版不存在'});
|
||||
try{if(req.query.format!==undefined&&!['svg','json'].includes(String(req.query.format)))throw new LayoutParameterError('format 为 svg 或 json');const params=parameters(layout,req.method==='POST'?req.body:{});if(req.method==='HEAD')return res.status(200).end();if(req.query.format==='json')return res.json({layoutId:layout.id,parameters:params});if(req.method==='POST'||req.query.format==='svg'){res.setHeader('Content-Type','image/svg+xml; charset=utf-8');return res.send(renderToStaticMarkup(createElement(LayoutCanvas,{layout,params,imageSources:await embedLayoutImages(req,params)})))}return res.json({schemaVersion:1,family:'fixed',id:layout.id,name:layout.name,width:layout.width,height:layout.height,sourceSha256:layout.sourceSha256,tutorialUrl:layout.tutorialUrl,elements:layout.elements.map(({id,kind,role,box,direction,text,reading})=>({id,kind,role,box,direction,text,reading})),parameters:params,workspaceSchema,url:'/studio/layouts/'+layout.id})}catch(e){return res.status(400).json({error:e instanceof Error?e.message:'参数不合法'})}
|
||||
try{if(req.query.format!==undefined&&!['svg','json'].includes(String(req.query.format)))throw new LayoutParameterError('format 为 svg 或 json');const params=parameters(layout,req.method==='POST'?req.body:{});if(req.method==='HEAD')return res.status(200).end();if(req.query.format==='json')return res.json({layoutId:layout.id,parameters:params});if(req.method==='POST'||req.query.format==='svg'){res.setHeader('Content-Type','image/svg+xml; charset=utf-8');return res.send(await embedSvgMedia(req,renderToStaticMarkup(createElement(LayoutCanvas,{layout,params,imageSources:await embedLayoutImages(req,params)}))))}return res.json({schemaVersion:1,family:'fixed',id:layout.id,name:layout.name,width:layout.width,height:layout.height,sourceSha256:layout.sourceSha256,tutorialUrl:layout.tutorialUrl,elements:layout.elements.map(({id,kind,role,box,direction,text,reading})=>({id,kind,role,box,direction,text,reading})),parameters:params,workspaceSchema,url:'/studio/layouts/'+layout.id})}catch(e){return res.status(400).json({error:e instanceof Error?e.message:'参数不合法'})}
|
||||
}
|
||||
export const config={api:{bodyParser:{sizeLimit:'64kb'}}};
|
||||
|
||||
@@ -5,6 +5,6 @@ export default function handler(req:NextApiRequest,res:NextApiResponse){
|
||||
if(!['GET','HEAD'].includes(req.method||'')){res.setHeader('Allow','GET, HEAD');return res.status(405).end()}
|
||||
const entries=[standard,...fixed].map(l=>({id:l.id,name:l.name,family:l.id==='standard-01'?'standard':'fixed',url:'/studio/layouts/'+l.id,api:'/studio/api/layouts/'+l.id}));
|
||||
res.setHeader('Cache-Control','no-store');if(req.method==='HEAD')return res.status(200).end();
|
||||
if(String(req.query.agent_mode).toLowerCase()==='true'){res.setHeader('Content-Type','text/plain; charset=utf-8');return res.send('# Studio 可视化排版 API 库\n\n'+entries.map(e=>`${e.family} / ${e.id} · ${e.name}\n页面:${e.url}\n参数:${e.api}?agent_mode=True`).join('\n\n')+'\n\nCRUD:/api/studio/designs?agent_mode=True\n统一文档:/explore/8eb8c76500580f7a4b56ba3c')}
|
||||
return res.json({version:1,layouts:entries,saveApi:'/api/studio/designs',catalogUrl:'/studio',documentationUrl:'/explore/8eb8c76500580f7a4b56ba3c'});
|
||||
if(String(req.query.agent_mode).toLowerCase()==='true'){res.setHeader('Content-Type','text/plain; charset=utf-8');return res.send('# Studio 可视化排版 API 库\n\n'+entries.map(e=>`${e.family} / ${e.id} · ${e.name}\n页面:${e.url}\n参数:${e.api}?agent_mode=True`).join('\n\n')+'\n\n多页作品:/api/studio/series?agent_mode=True\n单页 CRUD:/api/studio/designs?agent_mode=True\n统一文档:/explore/8eb8c76500580f7a4b56ba3c')}
|
||||
return res.json({version:1,seriesDocumentVersion:1,seriesApi:'/api/studio/series',layouts:entries,saveApi:'/api/studio/designs',catalogUrl:'/studio',documentationUrl:'/explore/8eb8c76500580f7a4b56ba3c'});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { ThemeToggle } from "../../components/ThemeToggle";
|
||||
import { Brand } from "../../components/Brand";
|
||||
import Head from 'next/head';
|
||||
import {useEffect,useRef,useState,type PointerEvent} from 'react';
|
||||
import {MousePointer2,Type,Square,Circle,Plus,Minus,Undo2,Redo2,Download,Layers,ImagePlus,Trash2,Copy,Eye,EyeOff,Lock,Unlock,Scan,ChevronRight,ArrowUpRight,PenTool,X,Hand,Link2,BookOpen,PanelLeft,PanelRight} from 'lucide-react';
|
||||
import {newElementId} from '../../lib/native-layout';
|
||||
import styles from '../../styles/graphic.module.css';
|
||||
import {parseGraphicDocument,type GraphicItem as Item,type GraphicDocument as Doc} from '../../lib/graphic-document';
|
||||
const blank:Doc={name:'未命名设计',background:'#ffffff',items:[]};
|
||||
const storage='studio.graphic.v1';
|
||||
export default function Graphic(){
|
||||
const [doc,setDoc]=useState<Doc>(blank),[selected,setSelected]=useState(''),[past,setPast]=useState<Doc[]>([]),[future,setFuture]=useState<Doc[]>([]),[zoom,setZoom]=useState(.65),[status,setStatus]=useState('本机草稿'),[tab,setTab]=useState('图层'),[user,setUser]=useState<{email:string}|null>(null),[auth,setAuth]=useState(false),[mode,setMode]=useState('login'),[message,setMessage]=useState(''),[busy,setBusy]=useState(false);
|
||||
const [tool,setTool]=useState('select'),[offset,setOffset]=useState({x:0,y:0}),[showLayers,setShowLayers]=useState(true),[showProps,setShowProps]=useState(true);
|
||||
const dialog=useRef<HTMLDivElement>(null);
|
||||
useEffect(()=>{if(!auth)return;const old=document.activeElement as HTMLElement;const box=dialog.current;box?.querySelector<HTMLElement>('button,input')?.focus();function key(e:KeyboardEvent){if(e.key==='Escape'){setAuth(false);return}if(e.key!=='Tab'||!box)return;const nodes=Array.from(box.querySelectorAll<HTMLElement>('button:not(:disabled),input,a[href]'));const first=nodes[0],last=nodes[nodes.length-1];if(e.shiftKey&&document.activeElement===first){e.preventDefault();last?.focus()}else if(!e.shiftKey&&document.activeElement===last){e.preventDefault();first?.focus()}}document.addEventListener('keydown',key);return()=>{document.removeEventListener('keydown',key);old?.focus()}},[auth]);
|
||||
const restore=useRef<HTMLInputElement>(null);
|
||||
const board=useRef<HTMLDivElement>(null),upload=useRef<HTMLInputElement>(null),viewport=useRef<HTMLDivElement>(null),ready=useRef(false),dirty=useRef(false);
|
||||
const item=doc.items.find(i=>i.id===selected);
|
||||
useEffect(()=>{try{const raw=localStorage.getItem(storage);if(raw){setDoc(parseGraphicDocument(raw));}}catch{setStatus('无法读取草稿,请导出备份')}ready.current=true;void refresh();},[]);
|
||||
useEffect(()=>{if(!ready.current)return;try{localStorage.setItem(storage,JSON.stringify(doc));setStatus('已保存到此浏览器');dirty.current=false;}catch{setStatus('本机空间不足,请导出备份');dirty.current=true;}},[doc]);
|
||||
useEffect(()=>{const warn=(e:BeforeUnloadEvent)=>{if(dirty.current){e.preventDefault();e.returnValue=''}};window.addEventListener('beforeunload',warn);return()=>window.removeEventListener('beforeunload',warn)},[]);
|
||||
async function refresh(){try{const r=await fetch('/api/account/session');if(r.ok)setUser((await r.json()).user);}catch{/* Guest editing remains available. */}}
|
||||
function change(next:Doc){setPast(p=>[...p.slice(-49),doc]);setFuture([]);setDoc(next);dirty.current=true;}
|
||||
function patch(p:Partial<Item>){if(item&&!item.locked)change({...doc,items:doc.items.map(i=>i.id===selected?{...i,...p}:i)})}
|
||||
function add(kind:Item['kind'],src?:string){if(doc.items.length>=500){setStatus('单页最多 500 个图层');return;}const id=newElementId();const next:Item={id,kind,name:kind==='text'?'文字':kind==='image'?'图片':kind==='rect'?'矩形':'椭圆',text:'让灵感,有形。',x:90+doc.items.length*12%260,y:100+doc.items.length*12%400,w:kind==='text'?480:260,h:kind==='text'?90:220,fill:kind==='text'?'#282832':'#b8b3ff',size:52,hidden:false,locked:false,src};change({...doc,items:[...doc.items,next]});setSelected(id);setTab('图层');}
|
||||
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 remove(){if(item&&!item.locked){change({...doc,items:doc.items.filter(i=>i.id!==selected)});setSelected('')}}
|
||||
function duplicate(){if(item&&doc.items.length<500){const id=newElementId();change({...doc,items:[...doc.items,{...item,id,x:item.x+24,y:item.y+24,name:item.name+' 副本',locked:false}]});setSelected(id)}}
|
||||
function fit(){setOffset({x:0,y:0});if(viewport.current)setZoom(Math.max(.15,Math.min(1,(viewport.current.clientWidth-100)/800,(viewport.current.clientHeight-100)/1000)))}
|
||||
useEffect(()=>{fit();},[]);
|
||||
useEffect(()=>{function key(e:KeyboardEvent){if((e.target as HTMLElement).closest('input,textarea,select,button'))return;if((e.metaKey||e.ctrlKey)&&e.key.toLowerCase()==='z'){e.preventDefault();e.shiftKey?redo():undo()}else if((e.metaKey||e.ctrlKey)&&e.key.toLowerCase()==='d'){e.preventDefault();duplicate()}else if(e.key==='Delete'||e.key==='Backspace'){e.preventDefault();remove()}else if(e.key==='Escape')setSelected('');else if(!e.ctrlKey&&!e.metaKey&&e.key.toLowerCase()==='v')setTool('select');else if(!e.ctrlKey&&!e.metaKey&&e.key.toLowerCase()==='h')setTool('hand');else if(item&&!item.locked&&e.key.startsWith('Arrow')){e.preventDefault();const step=e.shiftKey?10:1;patch({x:item.x+(e.key==='ArrowRight'?step:e.key==='ArrowLeft'?-step:0),y:item.y+(e.key==='ArrowDown'?step:e.key==='ArrowUp'?-step:0)})}}window.addEventListener('keydown',key);return()=>window.removeEventListener('keydown',key)});
|
||||
function drag(e:PointerEvent<HTMLDivElement>,i:Item){if(tool==='hand'){panBoard(e);return}e.stopPropagation();setSelected(i.id);if(i.locked||e.button!==0)return;e.preventDefault();e.currentTarget.setPointerCapture(e.pointerId);const x=e.clientX,y=e.clientY,start=doc;let next=start;const target=e.currentTarget;const move=(p:globalThis.PointerEvent)=>{next={...start,items:start.items.map(n=>n.id===i.id?{...n,x:Math.max(-4000,Math.min(4000,i.x+(p.clientX-x)/zoom)),y:Math.max(-4000,Math.min(4000,i.y+(p.clientY-y)/zoom))}:n)};setDoc(next)};const end=()=>{target.removeEventListener('pointermove',move);target.removeEventListener('pointerup',end);target.removeEventListener('pointercancel',end);if(next!==start){setPast(p=>[...p.slice(-49),start]);setFuture([])}};target.addEventListener('pointermove',move);target.addEventListener('pointerup',end);target.addEventListener('pointercancel',end);}
|
||||
function resize(e:PointerEvent<HTMLElement>){e.stopPropagation();if(!item||item.locked)return;e.preventDefault();const start=doc,x=e.clientX,y=e.clientY,target=e.currentTarget;target.setPointerCapture(e.pointerId);let next=start;const move=(p:globalThis.PointerEvent)=>{next={...start,items:start.items.map(i=>i.id===item.id?{...i,w:Math.max(1,Math.min(4000,item.w+(p.clientX-x)/zoom)),h:Math.max(1,Math.min(4000,item.h+(p.clientY-y)/zoom))}:i)};setDoc(next)};const end=()=>{target.removeEventListener('pointermove',move);target.removeEventListener('pointerup',end);target.removeEventListener('pointercancel',end);if(next!==start){setPast(p=>[...p.slice(-49),start]);setFuture([])}};target.addEventListener('pointermove',move);target.addEventListener('pointerup',end);target.addEventListener('pointercancel',end)}
|
||||
function panBoard(e:PointerEvent<HTMLElement>){if(tool!=='hand')return;e.preventDefault();const x=e.clientX,y=e.clientY,start=offset,target=e.currentTarget;target.setPointerCapture(e.pointerId);const move=(p:globalThis.PointerEvent)=>setOffset({x:start.x+p.clientX-x,y:start.y+p.clientY-y});const end=()=>{target.removeEventListener('pointermove',move);target.removeEventListener('pointerup',end);target.removeEventListener('pointercancel',end)};target.addEventListener('pointermove',move);target.addEventListener('pointerup',end);target.addEventListener('pointercancel',end)}
|
||||
function download(content:string,type:string,extension:string){const url=URL.createObjectURL(new Blob([content],{type}));const a=document.createElement('a');a.href=url;a.download=doc.name+'.'+extension;a.click();setTimeout(()=>URL.revokeObjectURL(url),1000)}
|
||||
function exportSVG(){const esc=(s:string)=>s.replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]!));const svg=doc.items.filter(i=>!i.hidden).map(i=>i.kind==='text'?`<text x="${i.x}" y="${i.y+i.size}" font-family="sans-serif" font-size="${i.size}" fill="${esc(i.fill)}">${i.text.split('\n').map((line,n)=>`<tspan x="${i.x}" dy="${n?i.size*1.3:0}">${esc(line)}</tspan>`).join('')}</text>`:i.kind==='image'?`<image x="${i.x}" y="${i.y}" width="${i.w}" height="${i.h}" href="${esc(i.src||'')}" preserveAspectRatio="xMidYMid slice"/>`:i.kind==='ellipse'?`<ellipse cx="${i.x+i.w/2}" cy="${i.y+i.h/2}" rx="${i.w/2}" ry="${i.h/2}" fill="${esc(i.fill)}"/>`:`<rect x="${i.x}" y="${i.y}" width="${i.w}" height="${i.h}" fill="${esc(i.fill)}"/>`).join('');download(`<svg xmlns="http://www.w3.org/2000/svg" width="800" height="1000" viewBox="0 0 800 1000"><rect width="800" height="1000" fill="${esc(doc.background)}"/>${svg}</svg>`,'image/svg+xml','svg');}
|
||||
async function account(e:React.FormEvent<HTMLFormElement>){e.preventDefault();setBusy(true);setMessage('');const data=new FormData(e.currentTarget);try{const r=await fetch('/api/account/'+mode,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({email:data.get('email'),password:data.get('password')})});const d=await r.json();setMessage(d.error||d.message||'登录成功');if(r.ok){await refresh();if(mode==='login')setAuth(false);}}catch{setMessage('账户服务暂时无法连接')}finally{setBusy(false)}}
|
||||
return <div className={styles.app} data-layers={showLayers} data-properties={showProps}><Head><title>{doc.name + " · 啡跃 Endoleap Graphic"}</title><meta name="description" content="在 Studio 中自由编排文字、图形与图片。"/></Head>
|
||||
<header className={styles.top}><a href="/workspace" className={styles.brand}><Brand compact/><span className={styles.badge}>GRAPHIC</span></a><button aria-label="切换图层面板" aria-pressed={showLayers} onClick={()=>setShowLayers(v=>!v)}><PanelLeft size={17}/></button><button aria-label="切换属性面板" aria-pressed={showProps} onClick={()=>setShowProps(v=>!v)}><PanelRight size={17}/></button><ChevronRight size={14}/><input aria-label="设计名称" value={doc.name} maxLength={100} onChange={e=>change({...doc,name:e.target.value})}/><span className={styles.saved} role="status">{status}</span><button title="撤销" aria-label="撤销" disabled={!past.length} onClick={undo}><Undo2 size={17}/></button><button title="重做" aria-label="重做" disabled={!future.length} onClick={redo}><Redo2 size={17}/></button><ThemeToggle/><button onClick={()=>setAuth(true)} className={styles.account}>{user?user.email:'邮箱登录'}</button><button className={styles.primary} onClick={exportSVG}><Download size={15}/>导出 SVG</button></header>
|
||||
<div className={styles.body}><aside className={styles.left}><div className={styles.tabs}>{['图层','素材','Agent'].map(t=><button key={t} aria-pressed={tab===t} onClick={()=>setTab(t)}>{t}</button>)}</div><div className={styles.pageTitle}><span>页面 01</span><span>800 × 1000</span></div>{tab==='Agent'?<div className={styles.agentPanel}><Link2 size={24}/><h3>与你的 Agent 一起设计</h3><p>将接入文档交给 coding agent,使用已有的模板与创作 API。</p><a href="/studio/api" target="_blank" rel="noreferrer">查看接入文档 ↗</a><a href="/documents" target="_blank" rel="noreferrer"><BookOpen size={15}/>知识与教程 ↗</a><small>在 Agent 中打开文档,查看可用的创作接口与使用方式。</small></div>:tab==='图层'?<><div className={styles.layers}>{[...doc.items].reverse().map(i=><div className={styles.layer} data-selected={i.id===selected} key={i.id}><button onClick={()=>setSelected(i.id)}>{i.kind==='text'?<Type size={14}/>:<Square size={14}/>}<span>{i.name}</span></button><button aria-label={i.hidden?'显示图层':'隐藏图层'} onClick={()=>change({...doc,items:doc.items.map(n=>n.id===i.id?{...n,hidden:!n.hidden}:n)})}>{i.hidden?<EyeOff size={13}/>:<Eye size={13}/>}</button><button aria-label={i.locked?'解锁图层':'锁定图层'} onClick={()=>change({...doc,items:doc.items.map(n=>n.id===i.id?{...n,locked:!n.locked}:n)})}>{i.locked?<Lock size={13}/>:<Unlock size={13}/>}</button></div>)}{!doc.items.length&&<div className={styles.emptyLayers}><Layers size={23}/><p>好设计,从第一笔开始</p><small>从下方工具栏添加文字与图形</small></div>}</div><button className={styles.addLayer} onClick={()=>add('text')}><Plus size={15}/>添加图层</button></>:<div className={styles.assets}><h3>基础元素</h3><button onClick={()=>add('rect')}><Square/>矩形</button><button onClick={()=>add('ellipse')}><Circle/>椭圆</button><button onClick={()=>add('text')}><Type/>文字</button><button onClick={()=>upload.current?.click()}><ImagePlus/>上传图片</button><p>你的画布,由你定义。支持 PNG、JPEG、WebP。</p></div>}<div className={styles.leftFooter}><a href="/infinite_canvas">Infinite Canvas <ArrowUpRight size={14}/></a><small>⌘ Z 撤销 · 方向键微调 · Delete 删除</small></div></aside>
|
||||
<main ref={viewport} className={styles.viewport} onPointerDown={e=>{setSelected('');panBoard(e)}}><div className={styles.canvasLabel}>画板 01 <span>800 × 1000</span></div><div className={styles.boardWrap} style={{width:800*zoom,height:1000*zoom,transform:`translate(${offset.x}px,${offset.y}px)`}}><div ref={board} className={styles.board} style={{background:doc.background,transform:`scale(${zoom})`}}>{doc.items.filter(i=>!i.hidden).map(i=><div key={i.id} tabIndex={0} role="button" aria-label={i.name} aria-pressed={selected===i.id} onFocus={()=>setSelected(i.id)} onPointerDown={e=>drag(e,i)} className={styles.element} data-selected={selected===i.id} style={{left:i.x,top:i.y,width:i.w,height:i.h,borderRadius:i.kind==='ellipse'?'50%':0,background:i.kind==='rect'||i.kind==='ellipse'?i.fill:undefined,color:i.fill,fontSize:i.size,whiteSpace:'pre-wrap'}}>{i.kind==='text'?i.text:i.kind==='image'?<img src={i.src} alt={i.name} draggable={false}/>:null}{selected===i.id&&<><i className={styles.corner} onPointerDown={resize}/><span className={styles.measure}>{Math.round(i.w)} × {Math.round(i.h)}</span></>}</div>)}{!doc.items.length&&<div className={styles.blankHint}><PenTool size={34} strokeWidth={1}/><h1>留白,是创作的起点。</h1><p>添加文字、图形,或放入你的第一张图片。</p><button onPointerDown={e=>e.stopPropagation()} onClick={()=>add('text')}>开始设计 <ArrowUpRight size={16}/></button></div>}</div></div><div className={styles.tools} onPointerDown={e=>e.stopPropagation()}><button title="选择" aria-label="选择工具" aria-pressed={tool==='select'} onClick={()=>{setTool('select');setSelected('')}}><MousePointer2 size={20}/></button><button title="抓手工具" aria-label="抓手工具" aria-pressed={tool==='hand'} onClick={()=>setTool('hand')}><Hand size={19}/></button><button title="文字" aria-label="添加文字" onClick={()=>add('text')}><Type size={20}/></button><button title="矩形" aria-label="添加矩形" onClick={()=>add('rect')}><Square size={19}/></button><button title="椭圆" aria-label="添加椭圆" onClick={()=>add('ellipse')}><Circle size={19}/></button><button title="图片" aria-label="上传图片" onClick={()=>upload.current?.click()}><ImagePlus size={19}/></button><span/><button aria-label="缩小" onClick={()=>setZoom(z=>Math.max(.15,z-.1))}><Minus size={16}/></button><output>{Math.round(zoom*100)}%</output><button aria-label="放大" onClick={()=>setZoom(z=>Math.min(2,z+.1))}><Plus size={16}/></button><button aria-label="适应画布" onClick={fit}><Scan size={18}/></button></div></main>
|
||||
<aside className={styles.right}><div className={styles.inspectorTitle}>设计属性<span>Design</span></div>{item?<div className={styles.props}><label>图层名称<input maxLength={200} value={item.name} disabled={item.locked} onChange={e=>patch({name:e.target.value})}/></label><h3>布局</h3><div className={styles.fields}>{(['x','y','w','h'] as const).map(k=><label key={k}>{({x:'X',y:'Y',w:'宽',h:'高'})[k]}<input type="number" value={Math.round(item[k])} disabled={item.locked} onChange={e=>{const value=Number(e.target.value);if(Number.isFinite(value))patch({[k]:k==='w'||k==='h'?Math.max(1,Math.min(4000,value)):Math.max(-4000,Math.min(4000,value))})}}/></label>)}</div>{item.kind==='text'&&<><h3>文字</h3><textarea aria-label="文字内容" value={item.text} maxLength={4000} disabled={item.locked} onChange={e=>patch({text:e.target.value})}/><label>字号<input type="number" min={8} max={240} value={item.size} disabled={item.locked} onChange={e=>patch({size:Math.max(8,Math.min(240,Number(e.target.value)||8))})}/></label></>}<h3>填色</h3><label className={styles.color}><input type="color" value={item.fill} disabled={item.locked} onChange={e=>patch({fill:e.target.value})}/>{item.fill.toUpperCase()}</label><div className={styles.actions}><button onClick={duplicate}><Copy size={15}/>复制</button><button disabled={item.locked} onClick={remove}><Trash2 size={15}/>删除</button></div><button disabled={item.locked} onClick={()=>{const n=doc.items.findIndex(i=>i.id===item.id);if(n>0){const items=[...doc.items];[items[n-1],items[n]]=[items[n],items[n-1]];change({...doc,items})}}}>下移一层</button></div>:<div className={styles.props}><h3>画板</h3><p>自由画布 · 竖版</p><div className={styles.dimensions}>W <b>800</b> H <b>1000</b></div><h3>背景颜色</h3><label className={styles.color}><input type="color" value={doc.background} onChange={e=>change({...doc,background:e.target.value})}/>{doc.background.toUpperCase()}</label><div className={styles.note}><MousePointer2 size={20}/><p>选中一个图层,<br/>调整属于它的每个细节。</p></div></div>}<div className={styles.rightFooter}><button onClick={()=>download(JSON.stringify(doc,null,2),'application/json','json')}><Download size={15}/>备份设计 JSON</button><button onClick={()=>restore.current?.click()}>导入设计 JSON</button><p>草稿仅保存在此浏览器。<br/>邮箱登录共用全站账户。</p></div></aside></div>
|
||||
<input ref={restore} hidden type="file" accept="application/json,.json" onChange={async e=>{const f=e.target.files?.[0];e.target.value='';if(!f)return;try{if(f.size>5*1024*1024)throw Error('文件不得超过 5MB');const restored=parseGraphicDocument(await f.text());change(restored);setSelected('');}catch(error){setStatus(error instanceof Error?error.message:'导入失败')}}}/>
|
||||
<input ref={upload} hidden type="file" accept="image/png,image/jpeg,image/webp" onChange={e=>{const f=e.target.files?.[0];if(!f)return;if(f.size>3*1024*1024||!['image/png','image/jpeg','image/webp'].includes(f.type)){setStatus('请使用 3MB 以内的 PNG / JPEG / WebP');return}const reader=new FileReader();reader.onload=()=>add('image',String(reader.result));reader.readAsDataURL(f);e.target.value=''}}/>
|
||||
{auth&&<div className={styles.overlay}><div ref={dialog} className={styles.modal} role="dialog" aria-modal="true" aria-label="邮箱账户"><button className={styles.close} aria-label="关闭" onClick={()=>setAuth(false)}><X size={20}/></button><span className={styles.modalIcon}><Brand compact/></span><h2>{user?'你的 Studio 账户':'把灵感带进 Studio'}</h2>{user?<><p>{user.email}</p><button onClick={async()=>{try{const r=await fetch('/api/account/logout',{method:'POST',headers:{'Content-Type':'application/json'},body:'{}'});if(!r.ok)throw Error();setUser(null);setAuth(false)}catch{setMessage('退出失败,请重试')}}}>退出登录</button></>:<><p>使用知识站的同一个邮箱账户</p><div className={styles.tabs}>{[['login','登录'],['register','注册'],['resend','重发邮件']].map(([v,t])=><button key={v} aria-pressed={mode===v} onClick={()=>setMode(v)}>{t}</button>)}</div><form onSubmit={account}><label>邮箱<input autoFocus required type="email" name="email" autoComplete="email" maxLength={254}/></label><label>密码<input required type="password" name="password" minLength={12} maxLength={256} autoComplete={mode==='register'?'new-password':'current-password'}/></label><small>密码至少 12 字符;新账户需通过邮件验证。</small><button className={styles.primary} disabled={busy}>{busy?'处理中…':mode==='login'?'登录 Studio':mode==='register'?'创建账户':'重发验证邮件'}</button></form></>}<p role="status">{message}</p></div></div>}
|
||||
</div>
|
||||
}
|
||||
@@ -5,6 +5,6 @@ import {LayoutSelection} from '../../components/LayoutSelection';
|
||||
import {tutorialPost} from '../../content/tool-links';
|
||||
import {TemplateTrial} from '../../components/TemplateTrial';
|
||||
import {ideas,type Idea} from '../../content/ideas';
|
||||
export default function IdeaPage({idea}:{idea:Idea}){if(idea.tool==='layout-selection')return <LayoutSelection/>;if(idea.template)return <TemplateTrial idea={idea}/>;return <main className="editor"><Head><title>{idea.title} · 创作站</title><link rel="alternate" type="text/markdown" href={'/studio/api/export/'+idea.slug+'?format=markdown'}/></Head><header><a href={tutorialPost}>← PROMPT 使用教程</a><a href={'/studio/api/export/'+idea.slug}>导出内容包 ↓</a><a href={'/studio/api/export/'+idea.slug+'?format=markdown'}>Markdown ↓</a><button onClick={()=>window.print()}>打印 / PDF</button></header><h1>{idea.title}</h1><p>{idea.summary}</p><div className="slides">{idea.slides.map((s,i)=><article className="slide" key={i} style={{background:s.color}}><small>PROMPT STUDIO / {String(i+1).padStart(2,'0')}</small><h2>{s.heading}</h2><p>{s.body}</p><footer>记录灵感 · 自由表达</footer></article>)}</div></main>}
|
||||
export default function IdeaPage({idea}:{idea:Idea}){if(idea.tool==='layout-selection')return <LayoutSelection/>;if(idea.template)return <TemplateTrial idea={idea}/>;return <main className="editor"><Head><title>{idea.title} · 创作站</title><link rel="alternate" type="text/markdown" href={'/studio/api/export/'+idea.slug+'?format=markdown'}/></Head><header><a href={tutorialPost}>← 啡跃使用教程</a><a href={'/studio/api/export/'+idea.slug}>导出内容包 ↓</a><a href={'/studio/api/export/'+idea.slug+'?format=markdown'}>Markdown ↓</a><button onClick={()=>window.print()}>打印 / PDF</button></header><h1>{idea.title}</h1><p>{idea.summary}</p><div className="slides">{idea.slides.map((s,i)=><article className="slide" key={i} style={{background:s.color}}><small>ENDOLEAP / {String(i+1).padStart(2,'0')}</small><h2>{s.heading}</h2><p>{s.body}</p><footer>记录灵感 · 自由表达</footer></article>)}</div></main>}
|
||||
export const getStaticPaths:GetStaticPaths=async()=>({paths:ideas.map(i=>({params:{slug:i.slug}})),fallback:false});
|
||||
export const getStaticProps:GetStaticProps=async({params})=>{const idea=ideas.find(i=>i.slug===params?.slug);return idea?{props:{idea}}:{notFound:true}};
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
import Head from 'next/head';
|
||||
import fixed from '../content/templates/layouts.json';
|
||||
import standard from '../content/standard-layout.json';
|
||||
import {nativeLayout} from '../lib/native-layout';
|
||||
import {NativePreview as Preview} from '../components/NativePreview';
|
||||
export default function StudioEntry(){return <main className="studio-library"><Head><title>Studio · 可视化排版 API 库</title></Head><nav className="library-nav"><a href="/">PROMPT / STUDIO</a><a href="/explore/8eb8c76500580f7a4b56ba3c">使用文档与工具箱</a><a href="/api/studio/designs?agent_mode=True">API 契约</a></nav><h1>用参数,编排画面。</h1><p className="library-intro">给 coding agent 文字、图片与创作意图。选择标准图文或固定构图,通过 HTTP 保存渲染描述,得到一个可持续更新的原生页面。</p><div className="library-heading"><h2>标准排版</h2><a href="/studio/api/layouts/standard-01">参数</a></div><div className="library-standard"><a href="/studio/layouts/standard-01"><Preview layout={standard}/></a><div><h2>一页,一件事。</h2><p>沿用 Studio 标题、正文与页脚的图文结构,增加可配置图片位。文字、颜色、图片和几何坐标共用一套参数,也可添加原生元素。</p><code>layoutId: standard-01</code><p><a href="/studio/layouts/standard-01">打开原生页面</a></p></div></div><div className="library-heading"><h2>固定排版 · 27 套</h2><a href="/explore/8eb8c76500580f7a4b56ba3c">使用文档</a></div><div className="library-grid">{fixed.map(l=><article className="library-item" key={l.id}><a href={'/studio/layouts/'+l.id}><Preview layout={nativeLayout(l)}/><h3>{l.name}</h3><p>{l.id.toUpperCase()}</p></a></article>)}</div></main>}
|
||||
import type {GetServerSideProps} from 'next';
|
||||
export default function StudioEntry(){return null}
|
||||
export const getServerSideProps:GetServerSideProps=async()=>({redirect:{destination:'/graphic',permanent:false}});
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { ThemeToggle } from "../../components/ThemeToggle";
|
||||
import { Brand } from "../../components/Brand";
|
||||
import Head from 'next/head';
|
||||
import {useEffect,useRef,useState} from 'react';
|
||||
import {ArrowLeft,Plus,MousePointer2,Hand,ImagePlus,Type,Workflow,Undo2,Redo2,Minus,Maximize,Download,Upload,Trash2,Link2,Sparkles,ChevronRight,Sun,Moon} from 'lucide-react';
|
||||
import s from '../../styles/infinite-canvas.module.css';
|
||||
import CanvasNodeView from '../../components/canvas/CanvasNodeView';
|
||||
import CanvasComposer from '../../components/canvas/CanvasComposer';
|
||||
import CanvasAgentPanel,{type CanvasAgentHandle} from '../../components/canvas/CanvasAgentPanel';
|
||||
import {parseCanvasDocument,deriveNode,connectNodes,removeCanvasNode,referencedNodes,agentTask,type CanvasNode as Card,type CanvasDocument as Doc} from '../../lib/canvas-document';
|
||||
const example:Doc={version:1,cards:[{id:'brief',kind:'text',title:'01 / 创意简报',text:'把日常,拍成一场电影。\n\n一段关于城市清晨的视觉散文。低饱和色彩、自然光与细腻颗粒,让每一帧都有呼吸感。',x:70,y:140},{id:'reference',kind:'image',title:'02 / 视觉参考',text:'晨光 · 建筑 · 胶片质感\n上传你的参考图片',x:460,y:80},{id:'shot',kind:'prompt',title:'03 / 镜头构思',text:'清晨,阳光穿过窗边的植物,落在安静的房间里。缓慢推进镜头,35mm 胶片,温暖柔和的光线。',x:850,y:160}],links:[['brief','reference'],['reference','shot']]};
|
||||
const initial:Doc={version:1,cards:[],links:[]};
|
||||
const key='studio.infinite-canvas.v1';
|
||||
export default function InfiniteCanvas(){
|
||||
const [doc,setDoc]=useState<Doc>(initial),[selected,select]=useState('shot'),[zoom,setZoom]=useState(.8),[pan,setPan]=useState({x:0,y:0}),[tool,setTool]=useState('select'),[past,setPast]=useState<Doc[]>([]),[future,setFuture]=useState<Doc[]>([]),[status,setStatus]=useState('示例项目 · 尚未保存'),[ready,setReady]=useState(false),[email,setEmail]=useState(''),[link,setLink]=useState(''),[menu,setMenu]=useState<{x:number;y:number}|null>(null),[editing,setEditing]=useState(''),[refMenu,setRefMenu]=useState(''),[taskFeedback,setTaskFeedback]=useState(''),[composerTarget,setComposerTarget]=useState(''),[pickerRequest,setPickerRequest]=useState(0);
|
||||
const file=useRef<HTMLInputElement>(null),json=useRef<HTMLInputElement>(null),viewport=useRef<HTMLDivElement>(null),drag=useRef<{id:string;x:number;y:number;ox:number;oy:number;before:Doc;resize?:boolean}|null>(null);
|
||||
const pickerSequence=useRef(0),agent=useRef<CanvasAgentHandle>(null);
|
||||
const active=doc.cards.find(c=>c.id===selected);
|
||||
useEffect(()=>{try{const raw=localStorage.getItem(key);if(raw){const d=parseCanvasDocument(raw);setDoc(d);setStatus('已恢复本机草稿')}}catch{setStatus('草稿读取失败,可导入 JSON 恢复')}try{const v=JSON.parse(localStorage.getItem(key+'.view')||'null');if(v&&Number.isFinite(v.zoom)&&v.zoom>=.2&&v.zoom<=2&&Number.isFinite(v.x)&&Number.isFinite(v.y)){setZoom(v.zoom);setPan({x:v.x,y:v.y})}}catch{}setReady(true);fetch('/api/account/session').then(r=>r.ok?r.json():null).then(d=>setEmail(d?.user?.email||'')).catch(()=>{});},[]);
|
||||
useEffect(()=>{if(!ready)return;try{localStorage.setItem(key,JSON.stringify(doc));setStatus('已保存到此浏览器')}catch{setStatus('本机空间不足,请导出项目备份')}},[doc,ready]);
|
||||
useEffect(()=>{if(ready)try{localStorage.setItem(key+'.view',JSON.stringify({...pan,zoom}))}catch{}},[pan,zoom,ready]);
|
||||
useEffect(()=>{const el=viewport.current;if(!el)return;const block=(e:WheelEvent)=>{if(!(e.target as HTMLElement).closest('textarea,input,select'))e.preventDefault()};el.addEventListener('wheel',block,{passive:false});return()=>el.removeEventListener('wheel',block)},[]);
|
||||
useEffect(()=>{const keys=(e:KeyboardEvent)=>{if(e.key==='Escape'){setMenu(null);setRefMenu('');setEditing('');return}if((e.target as HTMLElement).closest('input,textarea,select,[contenteditable]'))return;if((e.ctrlKey||e.metaKey)&&e.key.toLowerCase()==='z'){e.preventDefault();if(e.shiftKey)redo();else undo()}if((e.ctrlKey||e.metaKey)&&e.key.toLowerCase()==='d'){e.preventDefault();duplicate()}};window.addEventListener('keydown',keys);return()=>window.removeEventListener('keydown',keys)});
|
||||
|
||||
function scaleAt(next:number,cx?:number,cy?:number){const el=viewport.current;if(!el)return;const r=el.getBoundingClientRect(),x=cx===undefined?r.width/2:cx-r.left,y=cy===undefined?r.height/2:cy-r.top;const z=Math.max(.2,Math.min(2,next));setPan({x:x-(x-pan.x)*z/zoom,y:y-(y-pan.y)*z/zoom});setZoom(z)}
|
||||
function duplicate(){if(!active||doc.cards.length>=80)return;const id=Array.from(crypto.getRandomValues(new Uint8Array(16)),b=>b.toString(16).padStart(2,'0')).join('');commit({...doc,cards:[...doc.cards,{...active,id,title:active.title.slice(0,197)+' 副本',x:active.x+40,y:active.y+40}]});select(id)}
|
||||
function commit(next:Doc){setPast(p=>[...p.slice(-29),doc]);setFuture([]);setDoc(next)}
|
||||
function add(kind:Card['kind'],image?:string){if(doc.cards.length>=80){setStatus('最多 80 个节点');return}const id=Array.from(crypto.getRandomValues(new Uint8Array(16)),b=>b.toString(16).padStart(2,'0')).join('');const rect=viewport.current?.getBoundingClientRect();commit({...doc,cards:[...doc.cards,{id,kind,title:kind==='text'?'灵感笔记':kind==='image'?'图片节点':kind==='script'?'脚本生成器':'生成提示词',text:'',x:((menu?.x??(rect?.width||700)/2)-pan.x)/zoom-160,y:((menu?.y??(rect?.height||600)/2)-pan.y)/zoom-100,image}]});select(id)}
|
||||
function change(field:'title'|'text'|'instruction',value:string,id=selected){setDoc(d=>({...d,cards:d.cards.map(c=>c.id===id?{...c,[field]:value}:c)}))}
|
||||
function beginEdit(){setPast(p=>[...p.slice(-29),doc]);setFuture([])}
|
||||
function undo(){if(!past.length)return;setFuture(f=>[doc,...f]);setDoc(past[past.length-1]);setPast(p=>p.slice(0,-1))}
|
||||
function redo(){if(!future.length)return;setPast(p=>[...p,doc]);setDoc(future[0]);setFuture(f=>f.slice(1))}
|
||||
function fit(){const el=viewport.current;if(!el||!doc.cards.length)return;const minX=Math.min(...doc.cards.map(c=>c.x)),minY=Math.min(...doc.cards.map(c=>c.y));const w=Math.max(...doc.cards.map(c=>c.x+(c.width||320)))-minX,h=Math.max(...doc.cards.map(c=>c.y+(c.height||350)))-minY;const z=Math.max(.2,Math.min(1,(el.clientWidth-80)/w,(el.clientHeight-100)/h));setZoom(z);setPan({x:(el.clientWidth-w*z)/2-minX*z,y:(el.clientHeight-h*z)/2-minY*z})}
|
||||
function selectNode(id:string){if(id!==selected)setEditing('');select(id)}
|
||||
function editNode(id:string){setPickerRequest(0);setComposerTarget('');select(id);setEditing(id);setMenu(null);setRefMenu('');setTaskFeedback('');setTool('select')}
|
||||
function patchNode(id:string,patch:Partial<Card>){commit({...doc,cards:doc.cards.map(n=>n.id===id?{...n,...patch}:n)})}
|
||||
function derive(source:string,kind:Card['kind'],operation:Card['operation']='generate'){
|
||||
const parent=doc.cards.find(n=>n.id===source);if(!parent)return;
|
||||
const id=Array.from(crypto.getRandomValues(new Uint8Array(16)),b=>b.toString(16).padStart(2,'0')).join('');
|
||||
const title=operation==='upscale'?'图片高清':kind==='script'?'脚本生成器':kind==='image'?'图片节点':'文本节点';
|
||||
try{commit(deriveNode(doc,source,{id,kind,operation,title,text:'',x:parent.x+(parent.width||320)+150,y:parent.y,width:360,height:300}));editNode(id);setComposerTarget(id)}catch(e){setStatus(e instanceof Error?e.message:'无法创建节点')}
|
||||
}
|
||||
function connect(from:string,to:string){try{const next=connectNodes(doc,from,to);if(next!==doc)commit(next);setTaskFeedback('')}catch(e){setTaskFeedback(e instanceof Error?e.message:'无法引用节点')}}
|
||||
function exportTask(id:string){try{const task=agentTask(doc,id),url=URL.createObjectURL(new Blob([JSON.stringify(task,null,2)],{type:'application/json'}));const a=document.createElement('a');a.href=url;a.download=`canvas-task-${id}.json`;a.click();setTimeout(()=>URL.revokeObjectURL(url),1000);setTaskFeedback('Agent 任务已导出')}catch(e){setTaskFeedback(e instanceof Error?e.message:'无法导出任务')}}
|
||||
function exportDoc(){const url=URL.createObjectURL(new Blob([JSON.stringify(doc,null,2)],{type:'application/json'}));const a=document.createElement('a');a.href=url;a.download='studio-canvas.json';a.click();setTimeout(()=>URL.revokeObjectURL(url),1000)}
|
||||
return <main className={s.app}><Head><title>无限画布 · 啡跃 Endoleap</title><meta name="description" content="在无限画布上组织灵感、参考素材与镜头提示词"/></Head>
|
||||
<header className={s.header}><a href="/workspace" className={s.back} aria-label="返回 Studio"><ArrowLeft size={18}/></a><a href="/workspace" className={s.brand}><Brand compact/></a><i/><span className={s.crumb}>工作空间 <ChevronRight size={13}/></span><strong>未命名的灵感宇宙</strong><span className={s.badge}>本机草稿</span><div className={s.spacer}/><CanvasAgentPanel ref={agent} document={doc} email={email} onLoad={next=>{setDoc(next);setPast([]);setFuture([]);setEditing('');select('')}} onFeedback={setTaskFeedback}/><ThemeToggle/><a href="/account">{email||'登录账户'}</a><button className={s.export} onClick={exportDoc}><Download size={15}/>导出项目</button></header>
|
||||
<div className={s.workspace}><nav className={s.rail} aria-label="画布工具"><button title="选择工具" aria-label="选择工具" aria-pressed={tool==='select'} onClick={()=>setTool('select')}><MousePointer2/></button><button title="移动画布" aria-label="移动画布" aria-pressed={tool==='hand'} onClick={()=>setTool('hand')}><Hand/></button><hr/><button title="添加笔记" aria-label="添加笔记" onClick={()=>add('text')}><Type/></button><button title="上传图片" aria-label="上传图片" onClick={()=>file.current?.click()}><ImagePlus/></button><button title="添加提示词" aria-label="添加提示词" onClick={()=>add('prompt')}><Sparkles/></button><button title="导入项目" aria-label="导入项目" onClick={()=>json.current?.click()}><Upload/></button><div className={s.spacer}/><span>∞</span></nav>
|
||||
<section className={s.canvasArea}>{!doc.cards.length&&<div className={s.startPanel}><span>YOUR NEXT IDEA STARTS HERE</span><h2>给想法,一个无限的空间。</h2><p>双击画布添加节点,或选择一个起点。</p><div><button onClick={()=>add('text')}><Type size={19}/>灵感笔记</button><button onClick={()=>file.current?.click()}><ImagePlus size={19}/>参考图片</button><button onClick={()=>add('prompt')}><Sparkles size={19}/>创作提示词</button></div><button onClick={()=>{commit(example);select('');setPan({x:0,y:0});setZoom(.7)}}>从示例开始 ↗</button><a href="/studio/api" target="_blank" rel="noreferrer">连接你的 coding agent · 接入文档 ↗</a></div>}<div className={s.canvasTitle}><span className={s.eyebrow}>YOUR IDEAS, CONNECTED</span><h1>无限画布<span> / Infinite canvas</span></h1><p>从一个灵感,连接所有可能。</p></div>
|
||||
<div className={s.viewport} style={{backgroundPosition:`${pan.x}px ${pan.y}px`,backgroundSize:`${24*zoom*Math.max(1,2**Math.ceil(Math.log2(1/zoom)))}px ${24*zoom*Math.max(1,2**Math.ceil(Math.log2(1/zoom)))}px`}} ref={viewport} onDoubleClick={e=>{if(e.target===e.currentTarget){setMenu(null);add('text')}}} onContextMenu={e=>{e.preventDefault();const id=(e.target as HTMLElement).closest('[data-node-id]')?.getAttribute('data-node-id');select(id||'');setMenu({x:Math.min(e.clientX,window.innerWidth-250),y:Math.min(e.clientY,window.innerHeight-330)})}} onWheel={e=>{const target=e.target as HTMLElement;if(drag.current)return;if(target.closest('textarea,input,select'))return;if(e.ctrlKey||e.metaKey){scaleAt(zoom*Math.exp(-e.deltaY*.002),e.clientX,e.clientY)}else setPan(p=>({x:p.x-e.deltaX,y:p.y-e.deltaY}))}} onPointerDown={e=>{setMenu(null);setRefMenu('');if(e.target===e.currentTarget){setEditing('');select('')};if(e.target===e.currentTarget||(tool==='hand'&&!(e.target as HTMLElement).closest('input,textarea,button,select'))||e.button===1){e.currentTarget.setPointerCapture(e.pointerId);drag.current={id:'pan',x:e.clientX,y:e.clientY,ox:pan.x,oy:pan.y,before:doc}}}} onPointerMove={e=>{const d=drag.current;if(!d)return;if(d.id==='pan')setPan({x:d.ox+e.clientX-d.x,y:d.oy+e.clientY-d.y});else {if(!d.resize&&Math.hypot(e.clientX-d.x,e.clientY-d.y)<4)return;e.currentTarget.setPointerCapture(e.pointerId);setDoc({...d.before,cards:d.before.cards.map(c=>c.id===d.id?(d.resize?{...c,width:Math.max(260,Math.min(1200,d.ox+(e.clientX-d.x)/zoom)),height:Math.max(300,Math.min(1200,d.oy+(e.clientY-d.y)/zoom))}:{...c,x:d.ox+(e.clientX-d.x)/zoom,y:d.oy+(e.clientY-d.y)/zoom}):c)})}}} onPointerUp={e=>{const d=drag.current;if(d?.id==='pan'&&tool==='select'&&Math.hypot(e.clientX-d.x,e.clientY-d.y)<4){select('');setMenu({x:Math.min(e.clientX,window.innerWidth-250),y:Math.min(e.clientY,window.innerHeight-230)})}if(d&&d.id!=='pan'&&doc!==d.before){setPast(p=>[...p.slice(-29),d.before]);setFuture([])}drag.current=null}} onPointerCancel={()=>{if(drag.current?.id!=='pan'&&drag.current)setDoc(drag.current.before);drag.current=null}}>
|
||||
<div className={s.world} style={{transform:`translate(${pan.x}px,${pan.y}px) scale(${zoom})`}}><svg className={s.wires} aria-hidden="true">{doc.links.map(([a,b])=>{const from=doc.cards.find(c=>c.id===a),to=doc.cards.find(c=>c.id===b);if(!from||!to)return null;return <path key={a+b} d={`M ${from.x+(from.width||320)} ${from.y+(from.height||350)/2} C ${from.x+(from.width||320)+70} ${from.y+(from.height||350)/2},${to.x-45} ${to.y+(to.height||350)/2},${to.x} ${to.y+(to.height||350)/2}`}/>})}</svg>
|
||||
{doc.cards.map(c=><CanvasNodeView key={c.id} node={c} selected={selected===c.id} editing={editing===c.id&&composerTarget!==c.id} hand={tool==='hand'} onSelect={()=>selectNode(c.id)} onChooseReference={()=>{editNode(c.id);setComposerTarget(c.id);setPickerRequest(++pickerSequence.current)}} onEdit={()=>editNode(c.id)} onDrag={e=>{setMenu(null);if(c.id!==editing)setEditing('');drag.current={id:c.id,x:e.clientX,y:e.clientY,ox:c.x,oy:c.y,before:doc}}} onResize={e=>{viewport.current?.setPointerCapture(e.pointerId);select(c.id);drag.current={id:c.id,x:e.clientX,y:e.clientY,ox:c.width||320,oy:c.height||350,before:doc,resize:true}}} onChange={value=>change('text',value,c.id)} onRename={value=>change('title',value,c.id)} onStyle={style=>patchNode(c.id,{style})} onReference={e=>{select(c.id);setRefMenu(c.id);setMenu({x:Math.min(e.clientX,window.innerWidth-250),y:Math.min(e.clientY,window.innerHeight-230)})}} onMenu={e=>{select(c.id);setRefMenu('');setMenu({x:Math.min(e.clientX,window.innerWidth-250),y:Math.min(e.clientY,window.innerHeight-330)})}} onUpload={()=>{select(c.id);file.current?.click()}} onUpscale={()=>derive(c.id,'image','upscale')} onExpand={()=>patchNode(c.id,{width:Math.min(1200,(c.width||320)*1.5),height:Math.min(1200,(c.height||350)*1.3)})} onBeginEdit={beginEdit}/>)}
|
||||
|
||||
</div></div><div className={s.canvasBottom}><span role="status">● {status}</span><div className={s.controls}><button aria-label="撤销" disabled={!past.length} onClick={undo}><Undo2 size={16}/></button><button aria-label="重做" disabled={!future.length} onClick={redo}><Redo2 size={16}/></button><i/><button aria-label="缩小" onClick={()=>scaleAt(zoom-.1)}><Minus size={16}/></button><span>{Math.round(zoom*100)}%</span><button aria-label="放大" onClick={()=>scaleAt(zoom+.1)}><Plus size={16}/></button><button aria-label="适应画布" onClick={fit}><Maximize size={16}/></button></div></div></section>
|
||||
{menu&&<div className={s.contextMenu} role="dialog" aria-label="画布操作" style={{left:Math.max(8,menu.x),top:Math.max(8,menu.y)}}><div className={s.menuTitle}>{refMenu?'引用该节点生成':active?active.title:'添加到画布'}<button aria-label="关闭画布操作" onClick={()=>setMenu(null)}>×</button></div>{refMenu?<>{(['text','image','script'] as const).map(kind=><button key={kind} onClick={()=>derive(refMenu,kind,kind==='script'?'script':'generate')}>{kind==='text'?'文本':kind==='image'?'图片':'脚本'}</button>)}</>:active?<><button onClick={()=>{editNode(active.id);setComposerTarget(active.id)}}>创作要求</button><button onClick={duplicate}>复制节点</button><button onClick={()=>{commit(removeCanvasNode(doc,selected));select('');setEditing('');setMenu(null)}}>删除节点</button><label>连接到<select value={link} onChange={e=>setLink(e.target.value)}><option value="">选择目标节点</option>{doc.cards.filter(c=>c.id!==selected).map(c=><option key={c.id} value={c.id}>{c.title}</option>)}</select></label><button disabled={!link} onClick={()=>{connect(selected,link);setLink('')}}>连接节点</button>{doc.links.filter(l=>l.includes(selected)).map(([a,b])=><button key={a+b} onClick={()=>commit({...doc,links:doc.links.filter(l=>l[0]!==a||l[1]!==b)})}>断开:{doc.cards.find(c=>c.id===(a===selected?b:a))?.title}</button>)}</>:<><button onClick={()=>{add('text');setMenu(null)}}>添加笔记</button><button onClick={()=>{add('image');setMenu(null)}}>添加图片</button><button onClick={()=>{add('script');setMenu(null)}}>添加脚本</button></>}</div>}</div>
|
||||
{active&&editing===active.id&&(active.kind!=='text'||composerTarget===active.id)&&<CanvasComposer pickerRequest={pickerRequest} key={active.id} node={active} nodes={doc.cards} references={referencedNodes(doc,active.id)} onChange={value=>change('instruction',value,active.id)} onRename={value=>change('title',value,active.id)} onBeginEdit={beginEdit} onUpload={()=>file.current?.click()} onAddReference={id=>connect(id,active.id)} onRemoveReference={id=>commit({...doc,links:doc.links.filter(([from,to])=>from!==id||to!==active.id)})} onQueue={()=>void agent.current?.queue(active.id)} onExport={()=>exportTask(active.id)} onClose={()=>setEditing('')} feedback={taskFeedback}/>}
|
||||
<input hidden ref={file} type="file" accept="image/png,image/jpeg,image/webp" onChange={async e=>{const f=e.target.files?.[0];e.target.value='';if(!f)return;if(!['image/png','image/jpeg','image/webp'].includes(f.type)||f.size>2000000){setStatus('请选择 2MB 以内的 PNG、JPEG 或 WebP');return}const reader=new FileReader();reader.onload=()=>{const image=String(reader.result);if(active?.kind==='image')commit({...doc,cards:doc.cards.map(c=>c.id===selected?{...c,image}:c)});else add('image',image)};reader.readAsDataURL(f)}}/>
|
||||
<input hidden ref={json} type="file" accept="application/json,.json" onChange={async e=>{const f=e.target.files?.[0];e.target.value='';if(!f)return;try{if(f.size>15000000)throw Error();const d=parseCanvasDocument(await f.text());commit(d);select(d.cards[0]?.id||'');setPan({x:0,y:0});setStatus('项目已导入')}catch{setStatus('无法导入:项目格式错误或文件过大')}}}/>
|
||||
</main>;
|
||||
}
|
||||
@@ -1,19 +1,20 @@
|
||||
import type {GetServerSideProps} from 'next';
|
||||
import {LayoutWorkbench,type LayoutPageProps} from '../../components/LayoutWorkbench';
|
||||
import {NativeResult} from '../../components/NativeResult';
|
||||
import type {Layout} from '../../lib/layout-parameters';
|
||||
import fixedLayouts from '../../content/templates/layouts.json';
|
||||
import standardLayout from '../../content/standard-layout.json';
|
||||
const layouts=[...fixedLayouts,standardLayout];
|
||||
import {nativeLayout} from '../../lib/native-layout';
|
||||
import {nativeLayout,designUrl} from '../../lib/native-layout';
|
||||
import {readDesign} from '../../lib/design-server';
|
||||
export default function LayoutPage(props:LayoutPageProps){return <LayoutWorkbench key={props.design?.id||props.layout.id} {...props}/>}
|
||||
export const getServerSideProps:GetServerSideProps<LayoutPageProps>=async({params,query,req,res})=>{
|
||||
export default function LayoutPage({layout}:{layout:Layout}){return <NativeResult layout={layout} design={null}/>}
|
||||
export const getServerSideProps:GetServerSideProps<{layout:Layout}>=async({params,query,req,res})=>{
|
||||
res.setHeader('Cache-Control','private, no-store');
|
||||
const layout=layouts.find(l=>l.id===params?.layoutId);
|
||||
if(!layout)return {notFound:true};
|
||||
if(query.id!==undefined){
|
||||
const design=await readDesign(query.id,req);
|
||||
if(!design||design.layoutId!==layout.id)return {notFound:true};
|
||||
return {props:{layout:nativeLayout(layout),design}};
|
||||
return {redirect:{destination:designUrl(design.layoutId,design.id).slice('/studio'.length),permanent:false}};
|
||||
}
|
||||
return {props:{layout:nativeLayout(layout),design:null}};
|
||||
return {props:{layout:nativeLayout(layout)}};
|
||||
};
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import type {GetServerSideProps} from 'next';
|
||||
import {NativeSeries} from '../components/NativeSeries';
|
||||
import {NativeResult} from '../components/NativeResult';
|
||||
import fixedLayouts from '../content/templates/layouts.json';
|
||||
import standardLayout from '../content/standard-layout.json';
|
||||
import {readDesign} from '../lib/design-server';
|
||||
import {nativeLayout,type Design} from '../lib/native-layout';
|
||||
import type {Layout} from '../lib/layout-parameters';
|
||||
|
||||
type Props={layout:Layout;design:Design;page:number|null;quality:boolean};
|
||||
export default function TemplateLayout(props:Props){return props.design.document?<NativeSeries design={props.design} page={props.page} quality={props.quality}/>:<NativeResult {...props}/>}
|
||||
export const getServerSideProps:GetServerSideProps<Props>=async({query,req,res})=>{
|
||||
res.setHeader('Cache-Control','private, no-store');
|
||||
const design=await readDesign(query.id,req);
|
||||
if(!design)return {notFound:true};
|
||||
const layout=[standardLayout,...fixedLayouts].find(l=>l.id===design.layoutId);
|
||||
if(!layout)return {notFound:true};
|
||||
const page=query.page===undefined?null:Number(query.page);
|
||||
if(page!==null&&(!Number.isInteger(page)||page<1||page>(design.document?.pages.length||1)))return {notFound:true};
|
||||
return {props:{layout:nativeLayout(layout),design,page,quality:query.export==='1'}};
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
/* Shared chrome uses the canonical package tokens; artwork keeps its own palette. */
|
||||
html,body{background:var(--background);color:var(--foreground);color-scheme:inherit}
|
||||
body{margin:0}
|
||||
.endoleap-brand{display:inline-flex;align-items:center;gap:9px;white-space:nowrap;line-height:1.15;vertical-align:middle;letter-spacing:normal;font-weight:650}
|
||||
.endoleap-brand .endoleap-mark{width:32px;height:32px;object-fit:contain;flex-shrink:0}
|
||||
.endoleap-mark-dark{display:none!important}
|
||||
[data-theme$="-dark"] .endoleap-mark-light{display:none!important}
|
||||
[data-theme$="-dark"] .endoleap-mark-dark{display:block!important}
|
||||
.endoleap-wordmark{display:flex!important;flex-direction:column;font-size:17px!important;color:var(--foreground-strong)!important;letter-spacing:.02em!important;margin:0!important;gap:3px}
|
||||
.endoleap-wordmark small{display:block;font-size:9px;font-weight:500;letter-spacing:.08em;color:var(--muted-foreground)}
|
||||
button.endoleap-theme-toggle{display:inline-flex;align-items:center;justify-content:center;gap:5px;border:1px solid var(--border);background:var(--surface);color:var(--foreground);border-radius:8px;padding:7px 9px;font-size:12px;line-height:1.2;flex-shrink:0;cursor:pointer}
|
||||
button.endoleap-theme-toggle:hover{background:var(--row-hover)}
|
||||
:where(a,button,input,textarea,select):focus-visible{outline:2px solid var(--ring);outline-offset:3px}
|
||||
::selection{background:var(--selection);color:var(--foreground)}
|
||||
@media(max-width:600px){.endoleap-theme-toggle span{display:none}.endoleap-brand{gap:5px}.endoleap-brand .endoleap-mark{width:26px;height:26px}.endoleap-wordmark{font-size:14px!important}}
|
||||
|
||||
button:disabled{cursor:default}
|
||||
input,textarea,select{accent-color:var(--primary)}
|
||||
|
||||
.is-editing.native-layout-page{background:var(--background);color:var(--foreground)}
|
||||
.studio-mark,.page-tools,.workbench-top,.workbench-layers,.workbench-properties,.element-popover,.page-edit-info{color:var(--foreground)}
|
||||
.workbench-top button.primary,.page-tools .page-save,.trial-actions .trial-primary{background:var(--primary);color:var(--primary-foreground);border-color:var(--primary)}
|
||||
.workbench-top button.primary:hover,.page-tools .page-save:hover{background:var(--primary-hover)}
|
||||
.native-element.is-selected,.proof-active .proof-select{outline-color:var(--selection-border)}
|
||||
@@ -0,0 +1,3 @@
|
||||
.root{position:static}.panel{position:absolute;left:0;top:calc(100% + 12px);width:min(390px,calc(100vw - 32px));max-height:75dvh;overflow:auto;padding:20px;border:1px solid var(--border);border-radius:16px;background:var(--surface);color:var(--foreground);box-shadow:0 15px 70px var(--shadow);z-index:12;display:grid;gap:14px;font-size:12px}.panel header,.actions,.conflict{display:flex;align-items:center;justify-content:space-between;gap:8px;flex-wrap:wrap}.panel button{font-size:11px!important;padding:7px 10px!important;background:var(--surface)!important;color:inherit!important;border:1px solid var(--border)!important}.panel button:disabled{opacity:.45}.panel textarea{width:100%;height:150px;resize:vertical;background:var(--surface);color:var(--foreground);border:1px solid var(--input);border-radius:8px;padding:10px;font:10px/1.7 monospace}.panel p{margin:0;color:var(--muted-foreground);line-height:1.7}.panel small{color:var(--muted-foreground);font-size:10px}.panel label{display:grid;gap:8px}.panel select{width:100%;background:var(--surface);color:inherit;padding:8px;border-radius:6px}.panel ul{padding:0;margin:0;list-style:none;display:grid;gap:12px}.panel li{display:grid;gap:5px;border-top:1px solid var(--border);padding-top:10px}.panel li button{justify-self:start}.panel a{color:inherit!important;text-decoration:underline!important}
|
||||
|
||||
:global(header):has(> .root .panel){z-index:12}
|
||||
@@ -0,0 +1,4 @@
|
||||
.composer{position:fixed;z-index:6;left:50%;bottom:92px;transform:translateX(-50%);width:min(900px,calc(100vw - 40px));height:280px;border:1px solid var(--border);border-radius:22px;padding:20px 24px;background:var(--surface);color:var(--foreground);display:flex;flex-direction:column;box-shadow:0 15px 70px var(--shadow)}.composer[data-expanded=true]{height:min(65dvh,650px)}.composer header{display:flex;align-items:center;justify-content:space-between;gap:12px;font-size:12px}.composer header>span,.composer header>div{display:flex;align-items:center;gap:8px}.composer header button,.composer footer button{background:var(--surface);border-color:var(--border);font-size:11px;padding:7px 10px}.composer textarea{display:block;flex:1;min-height:50px;width:100%;resize:none;padding:17px 0;border:0;background:transparent;color:inherit;font:inherit;line-height:1.8;outline:none}.composer footer{display:flex;align-items:center;justify-content:space-between;gap:12px;font-size:10px;color:var(--muted-foreground)}.references{display:flex;gap:10px;padding-top:15px;flex-shrink:0;overflow:auto}.reference{width:70px;height:65px;border:1px solid var(--border);border-radius:8px;position:relative;display:grid;place-items:center;flex-shrink:0;overflow:hidden;background:var(--surface)}.reference img{width:100%;height:100%;object-fit:cover}.reference>span{position:absolute;top:3px;left:4px;background:var(--surface);color:var(--foreground);border-radius:50%;padding:1px 4px;font-size:9px}.reference small{position:absolute;bottom:0;width:100%;text-align:center;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;background:var(--surface);font-size:8px;padding:3px}.reference button{position:absolute;top:1px;right:1px;padding:2px!important;background:var(--surface)!important;border:0!important}.picker{position:absolute;bottom:calc(100% + 10px);left:0;background:var(--surface);border:1px solid var(--border);border-radius:14px;padding:10px;max-height:200px;overflow:auto;display:grid;gap:5px;min-width:220px}.picker button{justify-content:flex-start;background:transparent;border:0}.picker>span{font-size:11px;padding:12px}@media(max-width:600px){.composer{width:calc(100vw - 20px);padding:15px;bottom:80px;height:260px;border-radius:16px}.composer header>span{max-width:95px;overflow:hidden;white-space:nowrap;font-size:10px}.composer header button{padding:5px;font-size:10px}.composer header{gap:4px}.composer header>div{gap:4px}}
|
||||
.composer header input{width:200px;max-width:25vw;min-width:0;border:0;background:transparent;color:inherit;font:inherit;outline:none}
|
||||
|
||||
.composer footer>div{display:flex;gap:8px;flex-shrink:0}@media(max-width:600px){.composer footer>div{gap:4px}.composer footer button{font-size:10px;padding:6px}.composer footer>span{min-width:0}}
|
||||
@@ -0,0 +1,3 @@
|
||||
.node{position:absolute;background:var(--surface);border:1px solid var(--border);border-radius:13px;color:var(--foreground);pointer-events:auto;touch-action:none;cursor:grab;outline:none}.node[data-selected=true]{border-color:var(--selection-border)}.node[data-editing=true]{border:2px solid var(--border);cursor:default}.title{position:absolute;top:-31px;left:0;right:0;display:flex;gap:7px;align-items:center;height:26px;color:var(--muted-foreground);font-size:12px;white-space:nowrap}.title>span{overflow:hidden;text-overflow:ellipsis}.title>button{margin-left:auto;padding:3px!important;background:transparent!important;border:0!important}.content,.image{height:100%;overflow:hidden;border-radius:12px}.content{padding:25px;white-space:pre-wrap;overflow:auto;scrollbar-width:thin}.content p{margin:0;line-height:1.7;pointer-events:none}.image{position:relative;display:grid;place-items:center}.image img{width:100%;height:100%;object-fit:contain;pointer-events:none;user-select:none;-webkit-user-drag:none}.empty{height:100%;width:100%;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:22px;color:var(--muted-foreground)}.empty button{background:transparent!important;border:0!important;color:var(--muted-foreground)!important;font-size:12px!important}.node>textarea{width:100%;height:100%;border:0;border-radius:12px;background:transparent;color:inherit;outline:none;resize:none;padding:25px;line-height:1.7;touch-action:auto}.format{position:absolute;bottom:calc(100% + 48px);left:50%;transform:translateX(-50%);display:flex;align-items:center;gap:4px;background:var(--surface);border:1px solid var(--border);border-radius:12px;padding:6px;white-space:nowrap;z-index:3}.format input{width:24px;height:24px;padding:0;border:0;background:transparent}.format select{max-width:100px;background:transparent;color:var(--foreground);border:0;padding:6px}.format button{padding:5px!important;border:0!important;background:transparent!important}.format button[aria-pressed=true]{background:var(--selection)!important}.reference{position:absolute!important;right:-38px;top:calc(50% - 14px);width:28px;height:28px;border-radius:50%!important;border:1px solid var(--border)!important;padding:2px!important;background:var(--surface)!important;color:var(--foreground)!important}.resize{position:absolute!important;right:3px;bottom:3px;padding:3px!important;border:0!important;background:transparent!important;color:var(--muted-foreground)!important;cursor:nwse-resize!important}.upscale{position:absolute;bottom:10px;left:10px;font-size:10px!important;background:var(--surface)!important}.node:focus-visible{outline:2px solid var(--ring);outline-offset:3px}
|
||||
.title>input{min-width:0;width:100%;border:0!important;background:transparent!important;padding:0!important;color:inherit;font:inherit;outline:none}.title>button{flex-shrink:0}
|
||||
.incoming{right:auto!important;left:-38px}
|
||||
@@ -0,0 +1,65 @@
|
||||
import test from 'node:test';
|
||||
import {readFileSync} from 'node:fs';
|
||||
import assert from 'node:assert/strict';
|
||||
import {parseCanvasDocument,deriveNode,connectNodes,removeCanvasNode,referencedNodes,agentTask} from '../src/lib/canvas-document.ts';
|
||||
const note={id:'brief',kind:'text',title:'创意简报',text:'清晨的城市',x:20,y:50};
|
||||
const doc={version:1,cards:[note],links:[]};
|
||||
test('existing v1 browser drafts remain readable and untrusted fields are discarded',()=>{
|
||||
assert.deepEqual(parseCanvasDocument(JSON.stringify({...doc,token:'ignored',cards:[{...note,html:'ignored'}]})),doc);
|
||||
for(const bad of [{...doc,cards:[note,note]},{...doc,links:[['brief','missing']]},{...doc,cards:[{...note,image:'https://example.com/x.png'}]},{...doc,cards:[{...note,style:{color:'url(javascript:bad)'}}]}])assert.throws(()=>parseCanvasDocument(bad));
|
||||
});
|
||||
test('derived image and script nodes preserve directed references across save and restore',()=>{
|
||||
const picture={...note,id:'picture',kind:'image',title:'参考图',image:'data:image/png;base64,AAAA',operation:'generate'};
|
||||
const next=deriveNode(doc,'brief',picture);
|
||||
const restored=parseCanvasDocument(JSON.stringify(deriveNode(next,'picture',{...note,id:'script',kind:'script',operation:'script'})));
|
||||
assert.equal(referencedNodes(restored,'picture')[0].id,'brief');assert.equal(referencedNodes(restored,'script')[0].id,'picture');
|
||||
assert.throws(()=>connectNodes(restored,'script','brief'),/循环/);
|
||||
assert.throws(()=>parseCanvasDocument({...restored,links:[...restored.links,['script','brief']]}),/循环/);
|
||||
const removed=removeCanvasNode(restored,'picture');assert.deepEqual(removed.links,[]);assert.equal(removed.cards.length,2);
|
||||
});
|
||||
test('agent task captures current referenced content and validates upscale inputs',()=>{
|
||||
const next=deriveNode(doc,'brief',{...note,id:'out',kind:'script',operation:'script'});
|
||||
assert.equal(agentTask(next,'out').references[0].text,'清晨的城市');
|
||||
assert.equal(agentTask(next,'out').result.targetId,'out');
|
||||
assert.throws(()=>agentTask({...next,cards:next.cards.map(n=>n.id==='out'?{...n,operation:'upscale'}:n)},'out'),/引用一张图片/);
|
||||
});
|
||||
|
||||
test('instructions stay distinct from generated content in agent requests',()=>{
|
||||
const result={...note,id:'script',kind:'script',text:'已有脚本正文',instruction:'改写为六个分镜',operation:'script'};
|
||||
const restored=parseCanvasDocument(JSON.stringify(deriveNode(doc,'brief',result)));
|
||||
assert.equal(agentTask(restored,'script').instruction,'改写为六个分镜');
|
||||
assert.equal(restored.cards.find(n=>n.id==='script').text,'已有脚本正文');
|
||||
});
|
||||
|
||||
test('editing an image passes the current image to the external agent',()=>{
|
||||
const image={...note,id:'image',kind:'image',instruction:'把背景改为雪夜',image:'data:image/png;base64,AAAA'};
|
||||
assert.equal(agentTask({version:1,cards:[image],links:[]},'image').sourceImage,image.image);
|
||||
});
|
||||
|
||||
const overlaps=(a,b)=>a.x<b.x+(b.width||320)&&a.x+(a.width||320)>b.x&&a.y<b.y+(b.height||350)&&a.y+(a.height||350)>b.y;
|
||||
test('branching the example keeps the source image accessible after image and script derivation',()=>{
|
||||
const scene={version:1,cards:[
|
||||
{...note,x:70,y:140},
|
||||
{...note,id:'reference',kind:'image',x:460,y:80},
|
||||
{...note,id:'shot',kind:'prompt',x:850,y:160},
|
||||
],links:[['brief','reference'],['reference','shot']]};
|
||||
const image=deriveNode(scene,'reference',{...note,id:'hd',kind:'image',operation:'upscale',x:930,y:80,width:360,height:300});
|
||||
const result=deriveNode(image,'brief',{...note,id:'script',kind:'script',operation:'script',x:540,y:140,width:360,height:300});
|
||||
assert.deepEqual(result.cards.slice(0,3),scene.cards,'Existing content and positions stay unchanged');
|
||||
for(const added of result.cards.slice(3))for(const other of result.cards.filter(n=>n.id!==added.id))assert.equal(overlaps(added,other),false,`${added.id} blocks ${other.id}`);
|
||||
assert.equal(referencedNodes(result,'hd')[0].id,'reference');
|
||||
assert.equal(referencedNodes(result,'script')[0].id,'brief');
|
||||
assert.deepEqual(parseCanvasDocument(JSON.stringify(result)),result);
|
||||
});
|
||||
test('a free requested position is retained and large nodes near the coordinate limit can still branch',()=>{
|
||||
const free=deriveNode(doc,'brief',{...note,id:'free',x:2000,y:50});
|
||||
assert.deepEqual({x:free.cards[1].x,y:free.cards[1].y},{x:2000,y:50});
|
||||
const edge={version:1,cards:[{...note,x:90000,y:99600,width:1200,height:1200}],links:[]};
|
||||
const result=deriveNode(edge,'brief',{...note,id:'edge',x:90500,y:99700,width:360,height:300});
|
||||
assert.equal(overlaps(result.cards[0],result.cards[1]),false);
|
||||
assert.deepEqual(parseCanvasDocument(JSON.stringify(result)),result);
|
||||
});
|
||||
|
||||
test('agent and browser parse the same canvas document contract',()=>{
|
||||
assert.equal(readFileSync(new URL('../src/lib/canvas-document.ts',import.meta.url),'utf8'),readFileSync(new URL('../../waterfall-wiki/src/lib/canvas-document.ts',import.meta.url),'utf8'));
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {parseGraphicDocument} from '../src/lib/graphic-document.ts';
|
||||
const item={id:'one',kind:'text',name:'标题',text:'<script>文本</script>',x:20,y:20,w:300,h:100,fill:'#123456',size:32,hidden:false,locked:false};
|
||||
const doc={name:'作品',background:'#ffffff',items:[item]};
|
||||
test('graphic backup restores editable text and ignores unrelated properties',()=>{assert.deepEqual(parseGraphicDocument(JSON.stringify({...doc,extra:'ignored'})),doc)});
|
||||
test('graphic backup rejects corrupted layers, duplicate IDs and unbounded geometry',()=>{for(const items of [[{...item,text:null}],[item,item],[{...item,x:Infinity}],[{...item,w:-1}],[{...item,fill:'url(https://example.com)'}]])assert.throws(()=>parseGraphicDocument(JSON.stringify({...doc,items})));});
|
||||
test('graphic backup disallows active SVG and remote image loads',()=>{for(const src of ['https://example.com/image.png','javascript:alert(1)','data:image/svg+xml;base64,PHN2Zz4='])assert.throws(()=>parseGraphicDocument(JSON.stringify({...doc,items:[{...item,kind:'image',src}]})));});
|
||||
@@ -0,0 +1,27 @@
|
||||
import {test} from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {embedSvgMedia} from '../src/lib/layout-images.ts';
|
||||
const id='a05dc841-240a-5a3a-b01b-d589fc21cd36';
|
||||
test('SVG export fetches each internal media once with the caller authentication',async()=>{
|
||||
const original=globalThis.fetch;let calls=0;
|
||||
try{
|
||||
globalThis.fetch=async(url,options)=>{calls++;assert.equal(url,'http://waterfall-wiki:3000/api/media/'+id);assert.equal(options.headers.cookie,'session=fixture');assert.equal(options.redirect,'error');return new Response(Buffer.from('fixture'),{headers:{'content-type':'image/png'}});};
|
||||
const svg=await embedSvgMedia({headers:{cookie:'session=fixture'}},`<svg><image href="/api/media/${id}"/><image href="/api/media/${id}"/></svg>`);
|
||||
assert.equal(calls,1);assert.equal((svg.match(/data:image\/png;base64,Zml4dHVyZQ==/g)||[]).length,2);assert.ok(!svg.includes('/api/media/'));
|
||||
}finally{globalThis.fetch=original;}
|
||||
});
|
||||
test('SVG export rejects unavailable or non-image objects',async()=>{
|
||||
const original=globalThis.fetch;
|
||||
try{for(const response of [new Response('missing',{status:404}),new Response('<svg/>',{headers:{'content-type':'image/svg+xml'}})]){
|
||||
globalThis.fetch=async()=>response;await assert.rejects(()=>embedSvgMedia({headers:{}},`<image href="/api/media/${id}"/>`),/模板图片不可访问/);
|
||||
}}finally{globalThis.fetch=original;}
|
||||
});
|
||||
test('SVG exports without managed images do not access storage',async()=>{
|
||||
assert.equal(await embedSvgMedia({headers:{}},'<svg><text>Title</text></svg>'),'<svg><text>Title</text></svg>');
|
||||
});
|
||||
|
||||
test('SVG export uses the configured isolated Wiki origin',async()=>{
|
||||
const original=globalThis.fetch,previous=process.env.STUDIO_WIKI_ORIGIN;
|
||||
try{process.env.STUDIO_WIKI_ORIGIN='http://127.0.0.1:19000';globalThis.fetch=async url=>{assert.equal(url,'http://127.0.0.1:19000/api/media/'+id);return new Response('fixture',{headers:{'content-type':'image/png'}})};assert.ok((await embedSvgMedia({headers:{}},`<image href="/api/media/${id}"/>`)).includes('data:image/png;base64,'));}
|
||||
finally{globalThis.fetch=original;if(previous===undefined)delete process.env.STUDIO_WIKI_ORIGIN;else process.env.STUDIO_WIKI_ORIGIN=previous;}
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import {test} from 'node:test';import assert from 'node:assert/strict';import {readFileSync} from 'node:fs';
|
||||
import {parameters} from '../src/lib/layout-parameters.ts';
|
||||
const layouts=JSON.parse(readFileSync(new URL('../src/content/templates/layouts.json',import.meta.url)));
|
||||
test('27 layouts preserve unique typed source slots and complete shapes',()=>{assert.equal(layouts.length,27);for(const [i,l] of layouts.entries()){assert.equal(l.id,'ref-'+String(i+1).padStart(2,'0'));assert.equal(new Set(l.elements.map(s=>s.id)).size,l.elements.length);for(const s of l.elements){assert.match(s.id,/^[TID]\d{2}$/);assert.equal(s.kind,({T:'text',I:'image',D:'decoration'})[s.id[0]]);assert.ok(s.box[0]>=0&&s.box[1]>=0&&s.box[0]+s.box[2]<=l.width&&s.box[1]+s.box[3]<=l.height);assert.match(s.raster,/^data:image\/png;base64,/);if(s.kind==='image')assert.match(s.shape,/^data:image\/png;base64,/)}assert.deepEqual(parameters(l,{}),{texts:{},images:{},decorations:{}})}});
|
||||
test('27 layouts preserve unique typed source slots and complete shapes',()=>{assert.equal(layouts.length,27);for(const [i,l] of layouts.entries()){assert.equal(l.id,'ref-'+String(i+1).padStart(2,'0'));assert.equal(new Set(l.elements.map(s=>s.id)).size,l.elements.length);for(const s of l.elements){assert.match(s.id,/^[TID]\d{2}$/);assert.equal(s.kind,({T:'text',I:'image',D:'decoration'})[s.id[0]]);assert.ok(s.box[0]>=0&&s.box[1]>=0&&s.box[0]+s.box[2]<=l.width&&s.box[1]+s.box[3]<=l.height);assert.match(s.raster,/^\/api\/media\/[a-f0-9-]{36}$/);if(s.kind==='image')assert.match(s.shape,/^\/api\/media\/[a-f0-9-]{36}$/)}assert.deepEqual(parameters(l,{}),{texts:{},images:{},decorations:{}})}});
|
||||
test('parameters reject mismatched slots, unsafe URLs and excessive text',()=>{const l=layouts[0];for(const input of [{texts:{I01:{value:'bad'}}},{images:{I01:{url:'https://evil.test'}}},{texts:{T01:{value:'x',scale:3}}},{texts:{T01:{value:'x'.repeat(501)}}},{paper:'url(x)'},{decorations:{D01:{hidden:'true'}}},null,[]])assert.throws(()=>parameters(l,input));const p=parameters(l,{texts:{T01:{value:'<script>not markup</script>',scale:1.2,align:'middle'}},images:{I01:{color:'#abcdef',fit:'contain'}},decorations:{D01:{hidden:true}}});assert.equal(p.texts.T01.value,'<script>not markup</script>');assert.equal(p.decorations.D01.hidden,true)});
|
||||
test('workspace parameters round-trip identically through Studio and Wiki validation',async()=>{const {parameters:wiki}=await import('../../waterfall-wiki/src/lib/layout-parameters.ts');for(const l of layouts){const added={id:'user-example',kind:'text',role:'Added text',box:[3,4,20,12],direction:'horizontal',text:'Hello',reading:'custom'};const input={added:[added],layers:{T01:{x:2,y:4,width:15,height:10,rotation:25,opacity:.7,radius:2,z:10,hidden:false,locked:true},'user-example':{x:8,z:20}},texts:{T01:{value:'Updated',fontFamily:'serif',fontSize:4,fontWeight:700,lineHeight:1.6,letterSpacing:.2},'user-example':{value:'Hello'}},images:{I01:{fit:'cover',cropX:15,cropY:85,zoom:2}}};assert.deepEqual(parameters(l,input),wiki(l,input));assert.deepEqual(parameters(l,parameters(l,input)),parameters(l,input))}});
|
||||
test('workspace validation rejects unbounded, mismatched and executable properties',()=>{const l=layouts[0];for(const input of [{layers:{unknown:{x:2}}},{layers:{T01:{rotation:Infinity}}},{layers:{T01:{width:0}}},{layers:{T01:{opacity:2}}},{layers:{T01:{locked:'yes'}}},{texts:{T01:{value:'x',fontFamily:'url(evil)'}}},{images:{I01:{cropX:101}}},{images:{I01:{zoom:0}}},{added:[{id:'T01'}]},{added:Array(101).fill({})},{layers:{T01:{onClick:'evil'}}}])assert.throws(()=>parameters(l,input))});
|
||||
|
||||
@@ -12,7 +12,8 @@ test('geometric templates retain their distinct silhouettes without bitmap masks
|
||||
assert.equal(imageShape(layouts[4],layouts[4].elements.find(s=>s.id==='I03')).borderRadius,'50%');
|
||||
assert.ok(imageShape(layouts[21],layouts[21].elements.find(s=>s.id==='I02')).clipPath.startsWith('polygon('));
|
||||
assert.notEqual(imageShape(layouts[21],layouts[21].elements.find(s=>s.id==='I02')).clipPath,imageShape(layouts[26],layouts[26].elements.find(s=>s.id==='I02')).clipPath);
|
||||
assert.equal(designUrl('ref-01','abc'),'/studio/layouts/ref-01?id=abc');
|
||||
assert.equal(designUrl('ref-01','abc'),'/studio/template-layout?id=abc');
|
||||
assert.equal(designUrl('standard-01','abc'),'/studio/template-layout?id=abc');
|
||||
});
|
||||
|
||||
test('element IDs work on HTTP origins without secure-context randomUUID',()=>{const saved=Object.getOwnPropertyDescriptor(globalThis,'crypto');let calls=0;Object.defineProperty(globalThis,'crypto',{configurable:true,value:{getRandomValues(array){for(let i=0;i<array.length;i++)array[i]=++calls;return array}}});try{const first=newElementId(),second=newElementId();assert.match(first,/^user-[a-f0-9]{32}$/);assert.notEqual(first,second)}finally{Object.defineProperty(globalThis,'crypto',saved)}});
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import {test} from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {readFileSync} from 'node:fs';
|
||||
import {registerHooks} from 'node:module';
|
||||
registerHooks({resolve(specifier,context,next){try{return next(specifier,context)}catch(e){if(e.code==='ERR_MODULE_NOT_FOUND'&&specifier.startsWith('.'))return next(specifier+'.ts',context);throw e}}});
|
||||
const {series,pageParameters}=await import('../src/lib/series.ts');
|
||||
const layout=JSON.parse(readFileSync(new URL('../src/content/standard-layout.json',import.meta.url)));
|
||||
// Fixture copied into the CI image; no build is performed by these tests.
|
||||
const input=JSON.parse(readFileSync(new URL('../fixtures/autumn-journal.json',import.meta.url)));
|
||||
test('a complete three-page document preserves copy and resolves shared visual tokens',()=>{const d=series(input.document,[layout]);assert.equal(d.pages.length,3);for(const page of d.pages){const p=pageParameters(d,page,layout);assert.equal(p.paper,d.style.palette.paper);assert.equal(p.texts.T01.fontFamily,'serif');assert.equal(p.texts.T01.value,page.parameters.texts.T01.value);assert.equal(p.layers.T01.x,10)}const change=structuredClone(d);change.style.palette.ink='#112233';change.style.spacing.margin=8;for(const page of change.pages){const p=pageParameters(change,page,layout);assert.equal(p.texts.T02.color,'#112233');assert.equal(p.layers.T01.x,8)}change.pages[1].parameters.texts.T01.color='#ABCDEF';assert.equal(pageParameters(change,change.pages[1],layout).texts.T01.color,'#ABCDEF')});
|
||||
test('invalid page order, stale template placeholders, untracked images and unsafe fields fail',()=>{for(const mutate of [d=>d.pages.reverse(),d=>d.pages[1].key=d.pages[0].key,d=>delete d.pages[0].parameters.texts.T02,d=>d.pages[0].parameters.images.I01.assetId='f76134e7-6e4f-4261-b90c-3c523dc16307',d=>d.style.palette.ink='url(evil)',d=>d.style.spacing.gap=10,d=>d.pages[0].bindings.I01='title',d=>d.pages[0].parameters.images.I01.url='https://evil.test']){const d=structuredClone(input.document);mutate(d);assert.throws(()=>series(d,[layout]))}});
|
||||
test('persistence and renderer use byte-identical series contracts',()=>assert.equal(readFileSync(new URL('../src/lib/series.ts',import.meta.url),'utf8'),readFileSync(new URL('../../waterfall-wiki/src/lib/series.ts',import.meta.url),'utf8')));
|
||||
@@ -9,3 +9,14 @@ PG_PORT=18132
|
||||
S3_PORT=18133
|
||||
PROMPT_IMAGE=prompt:local
|
||||
RELEASE_SHA=development
|
||||
|
||||
# Use HTTPS and secure cookies in production; HTTP acceptance requires explicit false.
|
||||
AUTH_COOKIE_SECURE=true
|
||||
ACCOUNT_PUBLIC_ORIGIN=http://8.211.154.134:18131
|
||||
# Enable only behind an ingress that overwrites x-forwarded-for.
|
||||
ACCOUNT_TRUST_PROXY=false
|
||||
SMTP_HOST=smtpdm.aliyun.com
|
||||
SMTP_PORT=465
|
||||
SMTP_USERNAME=noreply@notice.entroia.com
|
||||
SMTP_FROM=noreply@notice.entroia.com
|
||||
SMTP_PASSWORD=
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
# Email accounts
|
||||
|
||||
`/account` offers registration, password login, email verification/resend, and logout.
|
||||
`/api/account/session` returns the current account; other actions use same-origin POST.
|
||||
Passwords require at least 12 characters and at most 256 UTF-8 bytes. Only verified
|
||||
accounts can log in. Verification links expire after 30 minutes and are consumed once.
|
||||
Session tokens expire after seven days; logout deletes the current token immediately.
|
||||
Only token SHA-256 digests are stored. Passwords use individually salted scrypt.
|
||||
|
||||
## Runtime configuration
|
||||
|
||||
The existing production deployment reads `/etc/prompt.env` on ces-control. Set its
|
||||
ownership to root and mode to 0600. The operator should update SMTP_PASSWORD there
|
||||
through a secure remote session, without putting it in command arguments, repository,
|
||||
CI output, release.env, or chat. Existing compose deployment reads this file without
|
||||
copying credentials into the release directory. Do not run `docker compose config`
|
||||
without `--quiet` against the production environment because interpolation shows secrets.
|
||||
Docker administrators can inspect container environment; access to Docker is privileged.
|
||||
|
||||
Set ACCOUNT_PUBLIC_ORIGIN to the exact browser origin and SMTP_HOST=smtpdm.aliyun.com,
|
||||
SMTP_PORT=465, SMTP_USERNAME and SMTP_FROM=noreply@notice.entroia.com. TLS certificate
|
||||
validation stays enabled. Missing SMTP configuration returns a generic service failure;
|
||||
resend with the registration password can recover an account whose initial delivery failed.
|
||||
|
||||
Keep AUTH_CONFIG_FILE configured for administrator access. Missing administrator
|
||||
configuration fails closed. AUTH_DISABLED_FOR_TESTS is false by default and is enabled
|
||||
only by the disposable legacy integration stack; never enable it in production.
|
||||
Email account cookies never satisfy administrator authorization. With the current HTTP
|
||||
acceptance origin use AUTH_COOKIE_SECURE=false; use true after enabling HTTPS.
|
||||
|
||||
Limits persist in PostgreSQL: 10 logins and 3 registration/resend attempts per email per
|
||||
15 minutes, plus 300 requests per 15 minutes globally. ACCOUNT_TRUST_PROXY defaults
|
||||
false. Enable it only when a trusted ingress overwrites x-forwarded-for; this changes
|
||||
the global cap to 60 per trusted client address. Do not enable it on the direct public port.
|
||||
Limits count failures and successes. Registration/resend always give the same success
|
||||
response for existing/unknown emails, and login uses a dummy KDF for unknown accounts.
|
||||
The rate limit itself can reveal recent activity for a known email; it does not reveal
|
||||
whether an account exists. Synchronous SMTP latency can still expose a timing side channel;
|
||||
a durable asynchronous mail queue would be needed to fully decouple delivery timing.
|
||||
Logout remains available when login/mail limits are exhausted. No password recovery or
|
||||
account deletion is included.
|
||||
|
||||
## Verification
|
||||
|
||||
ces-control runs crypto/origin tests, PostgreSQL verification-token replay races,
|
||||
expired/revoked sessions, concurrent limit consumption and unverified login rejection.
|
||||
Production-auth exercises verify that account cookies cannot authorize legacy writes.
|
||||
Actual SMTP delivery and visual acceptance at http://8.211.154.134:18131/account require
|
||||
operator runtime configuration and the normal manually approved deployment.
|
||||
@@ -6,13 +6,13 @@ RUN apt-get update \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
FROM base AS dependencies
|
||||
WORKDIR /repo/apps/waterfall-wiki
|
||||
RUN corepack enable && corepack prepare pnpm@11.25.0 --activate
|
||||
COPY apps/waterfall-wiki/pnpm-lock.yaml ./
|
||||
COPY pnpm-workspace.yaml /tmp/monorepo-policy.yaml
|
||||
RUN node -e "const fs=require('fs');const text=fs.readFileSync('/tmp/monorepo-policy.yaml','utf8');fs.writeFileSync('pnpm-workspace.yaml', text.slice(text.indexOf('allowBuilds:')));"
|
||||
RUN pnpm fetch --frozen-lockfile --fetch-timeout=300000 --network-concurrency=8
|
||||
COPY pnpm-workspace.yaml /repo/pnpm-workspace.yaml
|
||||
COPY packages/brand/ /repo/packages/brand/
|
||||
COPY apps/waterfall-wiki/package.json ./
|
||||
RUN pnpm install --offline --frozen-lockfile
|
||||
RUN pnpm --filter @prompt/waterfall-wiki install --frozen-lockfile --fetch-timeout=300000 --network-concurrency=8
|
||||
|
||||
FROM dependencies AS build
|
||||
# .dockerignore excludes docs and operational notes without extra legacy COPY snapshots.
|
||||
@@ -24,14 +24,16 @@ 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,5 @@
|
||||
BEGIN;
|
||||
-- The cover continues to use the existing design/post identity and archival lifecycle.
|
||||
ALTER TABLE studio_designs ADD COLUMN IF NOT EXISTS document jsonb CHECK(document IS NULL OR jsonb_typeof(document)='object');
|
||||
INSERT INTO schema_migrations(version) VALUES('013_studio_series') ON CONFLICT DO NOTHING;
|
||||
COMMIT;
|
||||
@@ -0,0 +1,22 @@
|
||||
BEGIN;
|
||||
CREATE TABLE account_users (
|
||||
id uuid PRIMARY KEY,
|
||||
email text NOT NULL UNIQUE,
|
||||
password_hash text NOT NULL,
|
||||
verified_at timestamptz,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE TABLE account_tokens (
|
||||
digest text PRIMARY KEY,
|
||||
user_id uuid NOT NULL REFERENCES account_users(id) ON DELETE CASCADE,
|
||||
kind text NOT NULL CHECK (kind IN ('verify','session')),
|
||||
expires_at timestamptz NOT NULL
|
||||
);
|
||||
CREATE INDEX account_tokens_expiry ON account_tokens(expires_at);
|
||||
CREATE TABLE account_limits (
|
||||
key text PRIMARY KEY,
|
||||
attempts integer NOT NULL,
|
||||
window_start timestamptz NOT NULL
|
||||
);
|
||||
INSERT INTO schema_migrations(version) VALUES ('014_email_accounts');
|
||||
COMMIT;
|
||||
@@ -0,0 +1,7 @@
|
||||
BEGIN;
|
||||
CREATE TABLE guidance_documents(id text PRIMARY KEY CHECK(id ~ '^user-[a-z0-9-]{1,74}$'), owner_id uuid NOT NULL REFERENCES account_users(id), latest integer NOT NULL DEFAULT 0, deleted_at timestamptz, CHECK(id LIKE 'user-' || owner_id::text || '-%'));
|
||||
CREATE TABLE guidance_revisions(document_id text NOT NULL REFERENCES guidance_documents(id), revision integer NOT NULL CHECK(revision>0), content jsonb NOT NULL, created_at timestamptz NOT NULL DEFAULT now(), PRIMARY KEY(document_id,revision));
|
||||
CREATE FUNCTION guidance_immutable() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'Published revisions are immutable'; END $$;
|
||||
CREATE TRIGGER guidance_immutable BEFORE UPDATE OR DELETE ON guidance_revisions FOR EACH ROW EXECUTE FUNCTION guidance_immutable();
|
||||
INSERT INTO schema_migrations(version) VALUES('015_guidance_documents');
|
||||
COMMIT;
|
||||
@@ -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,163 @@
|
||||
{
|
||||
"schemaVersion": 2,
|
||||
"name": "把秋天装进一页 · 银杏观察手记",
|
||||
"document": {
|
||||
"version": 1,
|
||||
"brief": {
|
||||
"topic": "银杏与秋日观察手记",
|
||||
"audience": "喜欢日常摄影与视觉手帐的小红书读者",
|
||||
"intent": "以封面、观察方法和行动结尾组成三页可发布图文",
|
||||
"designIntent": "奶油纸底、森林墨色、银杏金点缀;宋体大标题与克制的无衬线正文;所有页面保留相同页边距和节奏",
|
||||
"imageStrategy": "生成一幅原创银杏静物插画作为系列母图;每页复用素材,保留裁切配置,不把正文烘焙进图片"
|
||||
},
|
||||
"style": {
|
||||
"version": "autumn-journal/1",
|
||||
"palette": {
|
||||
"paper": "#F3EFE5",
|
||||
"ink": "#253C32",
|
||||
"muted": "#718074",
|
||||
"accent": "#C3A050"
|
||||
},
|
||||
"typography": {
|
||||
"title": {
|
||||
"fontFamily": "serif",
|
||||
"fontSize": 8.4,
|
||||
"fontWeight": 600,
|
||||
"lineHeight": 1.15,
|
||||
"letterSpacing": 0
|
||||
},
|
||||
"body": {
|
||||
"fontFamily": "sans",
|
||||
"fontSize": 3.1,
|
||||
"fontWeight": 400,
|
||||
"lineHeight": 1.65,
|
||||
"letterSpacing": 0
|
||||
},
|
||||
"caption": {
|
||||
"fontFamily": "sans",
|
||||
"fontSize": 1.7,
|
||||
"fontWeight": 500,
|
||||
"lineHeight": 1.2,
|
||||
"letterSpacing": 0
|
||||
}
|
||||
},
|
||||
"spacing": {
|
||||
"margin": 10,
|
||||
"gap": 4
|
||||
}
|
||||
},
|
||||
"sources": [],
|
||||
"pages": [
|
||||
{
|
||||
"key": "cover",
|
||||
"role": "cover",
|
||||
"layoutId": "standard-01",
|
||||
"intent": "用银杏静物与两行大标题建立主题",
|
||||
"bindings": {
|
||||
"T01": "title",
|
||||
"T02": "body",
|
||||
"T03": "caption"
|
||||
},
|
||||
"parameters": {
|
||||
"texts": {
|
||||
"T01": {
|
||||
"value": "把秋天\n装进一页"
|
||||
},
|
||||
"T02": {
|
||||
"value": "不必走很远。\n一片银杏,就是今天的风景。"
|
||||
},
|
||||
"T03": {
|
||||
"value": "SEASONAL NOTES / 01"
|
||||
}
|
||||
},
|
||||
"images": {
|
||||
"I01": {
|
||||
"fit": "cover",
|
||||
"cropX": 50,
|
||||
"cropY": 50,
|
||||
"zoom": 1
|
||||
}
|
||||
},
|
||||
"decorations": {}
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "observe",
|
||||
"role": "body",
|
||||
"layoutId": "standard-01",
|
||||
"intent": "用三个观察动作提供可实践的方法",
|
||||
"bindings": {
|
||||
"T01": "title",
|
||||
"T02": "body",
|
||||
"T03": "caption"
|
||||
},
|
||||
"parameters": {
|
||||
"texts": {
|
||||
"T01": {
|
||||
"value": "先看见\n再记录"
|
||||
},
|
||||
"T02": {
|
||||
"value": "看叶脉,看光落下的方向。\n留住一片金黄,也留出一半空白。\n把感受写成短句,画面就有了呼吸。"
|
||||
},
|
||||
"T03": {
|
||||
"value": "FIELD NOTES / 02"
|
||||
}
|
||||
},
|
||||
"images": {
|
||||
"I01": {
|
||||
"fit": "cover",
|
||||
"cropX": 65,
|
||||
"cropY": 40,
|
||||
"zoom": 1.25
|
||||
}
|
||||
},
|
||||
"decorations": {}
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "ending",
|
||||
"role": "ending",
|
||||
"layoutId": "standard-01",
|
||||
"intent": "以轻量行动和提问收尾",
|
||||
"bindings": {
|
||||
"T01": "title",
|
||||
"T02": "body",
|
||||
"T03": "caption"
|
||||
},
|
||||
"parameters": {
|
||||
"texts": {
|
||||
"T01": {
|
||||
"value": "留一点白\n给下一次",
|
||||
"align": "middle"
|
||||
},
|
||||
"T02": {
|
||||
"value": "今天,捡起一片叶子。\n用三个词记录它的颜色、纹理与心情。\n你的秋天,会从哪一页开始?",
|
||||
"align": "middle"
|
||||
},
|
||||
"T03": {
|
||||
"value": "YOUR TURN / 03"
|
||||
}
|
||||
},
|
||||
"images": {
|
||||
"I01": {
|
||||
"fit": "cover",
|
||||
"cropX": 65,
|
||||
"cropY": 50,
|
||||
"zoom": 1
|
||||
}
|
||||
},
|
||||
"decorations": {},
|
||||
"layers": {
|
||||
"I01": {
|
||||
"x": 40,
|
||||
"y": 55,
|
||||
"width": 40,
|
||||
"height": 40,
|
||||
"radius": 20
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,28 @@
|
||||
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 [
|
||||
...Object.entries(managedMedia).filter(([path])=>path.startsWith("apps/waterfall-wiki/public/")).map(([path,asset])=>({source:"/"+path.slice("apps/waterfall-wiki/public/".length),destination:"/api/media/"+asset.id})),
|
||||
{source:'/graphic',destination:'http://prompt-studio:3000/studio/graphic'},
|
||||
{source:'/infinite_canvas',destination:'http://prompt-studio:3000/studio/infinite_canvas'},
|
||||
{source:"/studio/:path*",destination:"http://prompt-studio:3000/studio/:path*"},
|
||||
{source:"/index.md",destination:"/api/guidance"},
|
||||
{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+"},
|
||||
{source:"/agent",destination:"/api/agent-index"},
|
||||
{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",
|
||||
"test": "tsx --test tests/api-documentation.test.ts tests/health.test.ts tests/model.test.ts tests/learning-series.test.ts tests/skill-docs.test.ts tests/wiki.test.ts tests/explore.test.ts tests/preview.test.ts tests/note-markdown.test.tsx tests/api-publication.test.ts tests/discovery.test.ts tests/auth.test.ts tests/account.test.ts tests/guidance.test.ts tests/guidance-publishing.test.ts",
|
||||
"test:smoke": "node scripts/smoke.mjs",
|
||||
"storage:prepare": "tsx scripts/bootstrap.ts",
|
||||
"content:publish-learning": "tsx scripts/publish-learning-series.ts",
|
||||
"worker": "tsx src/worker.ts",
|
||||
"test:integration": "tsx --test tests/integration.test.ts"
|
||||
"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",
|
||||
@@ -23,6 +24,7 @@
|
||||
"lucide-react": "1.61.0",
|
||||
"mdast-util-from-markdown": "2.1.0",
|
||||
"next": "16.4.2",
|
||||
"nodemailer": "10.2.1",
|
||||
"pg": "8.23.1",
|
||||
"playwright-core": "1.56.1",
|
||||
"qrcode": "1.5.4",
|
||||
@@ -31,12 +33,14 @@
|
||||
"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",
|
||||
"@types/formidable": "3.5.1",
|
||||
"@types/node": "24.19.2",
|
||||
"@types/nodemailer": "7.0.4",
|
||||
"@types/pg": "8.23.1",
|
||||
"@types/qrcode": "1.5.6",
|
||||
"@types/react": "19.3.0",
|
||||
|
||||
@@ -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
|
||||
@@ -23,6 +26,9 @@ importers:
|
||||
next:
|
||||
specifier: 16.4.2
|
||||
version: 16.4.2(@types/node@24.19.2)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)
|
||||
nodemailer:
|
||||
specifier: 10.2.1
|
||||
version: 10.2.1
|
||||
pg:
|
||||
specifier: 8.23.1
|
||||
version: 8.23.1
|
||||
@@ -60,6 +66,9 @@ importers:
|
||||
'@types/node':
|
||||
specifier: 24.19.2
|
||||
version: 24.19.2
|
||||
'@types/nodemailer':
|
||||
specifier: 7.0.4
|
||||
version: 7.0.4
|
||||
'@types/pg':
|
||||
specifier: 8.23.1
|
||||
version: 8.23.1
|
||||
@@ -99,42 +108,82 @@ packages:
|
||||
resolution: {integrity: sha512-l3uMeIlzWVgj/RTJTh5sKYKtQz1la3cvWLUPn1cwFH4UZpcqTH66SgJHCeB2FOE9bEAjOdbmCyPKt9aw867Fng==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/client-sesv2@3.1158.0':
|
||||
resolution: {integrity: sha512-CEhnnEhIUeyfVSEB/Q8Gm2YOu/vF6ONrQ09e9t3imkvTTBVbpMNDhVyFBlBfHxNDD63kgJ1p39DPicx5Er+Sxw==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/core@3.978.5':
|
||||
resolution: {integrity: sha512-ruI0AWqcrfL20XDS6SwZIJFPOBZ6N0ADa9nLzWlBhxk1aoUjbUX3JkTR9vEmk7o35XpAHi68ahZ4T6RE2U9XyA==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/core@3.978.6':
|
||||
resolution: {integrity: sha512-ykF/MiYoM15dgZwF/WLgjJXVbAK2wU6vjPigeEnOHwyUJ0rdlraO5uekecKNVPkvOnz+oju/5zljViQs1nI1FQ==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/credential-provider-env@3.972.76':
|
||||
resolution: {integrity: sha512-YxmL1Ar7bV9ZkJvtv+lj+MF6le76NnZnbmgySyZuxpZaCv0/1lZ3KxKy6RIj1h9ui2hWnVjZxbmTMeO0y/NrQA==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/credential-provider-env@3.972.77':
|
||||
resolution: {integrity: sha512-CN6dQCDo32a8n2gvm2HBeqGV5BNI9noG96nTj5bGO2HWtt0Rucu/A8pcnJIqkzILOHSuilnCk0Do8/7h6SOXhQ==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/credential-provider-http@3.972.78':
|
||||
resolution: {integrity: sha512-3VHknDRngoebsaqyT1iR75b5cBT2zfEtODK6yYmYltljkK1HEAQLs2FEd1IpWqAeOYdMGaG9wikngWLTufY7RA==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/credential-provider-http@3.972.79':
|
||||
resolution: {integrity: sha512-Netsu0nnmvP2HiN1ZFQBeUu+JehQIuubqz+j2kHDgeSE4HDzniCpIZ7RzoWABq+LTcbCi9W03hywifUagoVIWQ==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/credential-provider-ini@3.973.21':
|
||||
resolution: {integrity: sha512-aLx9hbCX6mGAyv4ZZd8UagJ4RASHVJw6BFDSIyuKmLkqNL6H47EjBPK6qaJ4B5TDV6mc+vMa0SJFYyo4mkvVQg==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/credential-provider-ini@3.973.22':
|
||||
resolution: {integrity: sha512-x7/5h58cqOoZR0RWUZC7v/cJOADQR42iSIQkvU+00HVHR4jh8ZpqEgRPGf2zei59iH5qP40lL3bkq49PuWl71g==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/credential-provider-login@3.972.83':
|
||||
resolution: {integrity: sha512-9bJU6FjRacF4FK/QHf8Dyxhur7B1grRj8C1Yh4IkpoFW2exQflPdCv0VrbemLW9ZctLNle+AE7TETs9KNvuLYg==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/credential-provider-login@3.972.84':
|
||||
resolution: {integrity: sha512-G9Ty1uIV0laEvEp2KiMbEhsuBAjgaD3KYrwVjAM5VUxpcIuPBqQI5riA7/qzrnSgYiAKQbubbgkwcqCICKCIxg==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/credential-provider-node@3.972.88':
|
||||
resolution: {integrity: sha512-AG0KID5qhQPzrFkh5b0Cws1Amytvzo0L0Xj6hMgJXEwD6ZXAqninDhSNAWJiN5zU4YVVZnuidnqwPwYJ04IamA==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/credential-provider-node@3.972.89':
|
||||
resolution: {integrity: sha512-O8xj7/sanrnXfFHu/LUujVBiiwn5mknYenU7xt1w6+yvhR1QoDZWXLFh1gLITaFVPtb1jWgEirC+kFNw+//tyw==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/credential-provider-process@3.972.76':
|
||||
resolution: {integrity: sha512-pdhKFTXv1zPYhh92MTlp0jVbLWvUEV9v+rziuEkoskGGQ2iJskxHTO38nA8UAbT/N4IM3n8OZJ9rIY5IBO15/A==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/credential-provider-process@3.972.77':
|
||||
resolution: {integrity: sha512-sWA8Jm+ASzkTxoqLizHWRbt8Gj3yZCmZ0hYdvg0uIxuLzWseYcfrDFzaSnkRWlEWSJV38rAWFimdrN0J4NR7Pg==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/credential-provider-sso@3.973.20':
|
||||
resolution: {integrity: sha512-DlxrAB3MnjNLx/da7b89W+3DyJQYso/q7Ns15MsST2IV2nkAYQOYTUCaffNSjgrpMP99Dm2mV2jxzcbyF94ZfA==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/credential-provider-sso@3.973.21':
|
||||
resolution: {integrity: sha512-KV2LM8fbYUhDzGEBnPwb+ISNfNlhmnpLS+y6A6VsJpg1EI9aUJ0+dRTxh2GrGdneFH0T4TiuboHxz8i6+M38JA==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/credential-provider-web-identity@3.972.82':
|
||||
resolution: {integrity: sha512-LUo46IqPTJ4xKBsrLuoaxiv/D2GTBCcFDMD1y3jKIqPR/p7Rw14RJFZ0iRWftqZtyLPQX9JMGnSi3mBu4y/pNA==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/credential-provider-web-identity@3.972.83':
|
||||
resolution: {integrity: sha512-iekYilmo2Re51fC7gMkEfZ7jFDXwehSCmXjDESaYQsdgYhsLQFG7rTYr3GBvRIyj+Fkda2NxbZX7hAZDaFD0JQ==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/middleware-sdk-s3@3.972.81':
|
||||
resolution: {integrity: sha512-jfanv1pV0sJFZw6/DPiNjvK7B478god0zOum5yIWo+/+1Gy+CSXWpGBWx+3nBJyDvjcu/2kUMr4ADUp9BHH34Q==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
@@ -143,6 +192,10 @@ packages:
|
||||
resolution: {integrity: sha512-nKb8qu4QsnpZTq/6JIXc72mzuzfh7BK4sUQkpQznPec2z4W7Ob2xYkcM4i5e1yGTBnbJVeFv/0sf3jcz85hclA==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/nested-clients@3.997.51':
|
||||
resolution: {integrity: sha512-DJK4iGIHWU8Jh8ODMvqFYcX9yGBMXV4tSZAvqlHF5EDd8hbmF3eFO9KIWOnA7wcQjPzEkyMMW1/FgZ9RU8oimQ==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/signature-v4-multi-region@3.996.49':
|
||||
resolution: {integrity: sha512-nvuAZNRrRRVCiARRKsYlQ0VnXiEQiq7vXPH+UA+LSo+kN3Djl9eo/V2r0udkw85PnsNshmv2Em1clld5M3j67w==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
@@ -151,6 +204,10 @@ packages:
|
||||
resolution: {integrity: sha512-iIcQnYLhWuq9qOLQbIWFrxF3PF0svVeZvHnUS4DUnO4zUFmFHIyGyjqrntXG+e/5PUgfavV0pYfDIKHxTzN2LA==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/token-providers@3.1157.0':
|
||||
resolution: {integrity: sha512-AeAzcwWRP2uANO2TjFpKjRdvOCzgfIu6YQl9Ceibd6E0gnq5dfOzujFnq2aeqgbLg4wRtXbUZE4gekCCPrz/OQ==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/types@3.975.0':
|
||||
resolution: {integrity: sha512-WQlhs6juByzWOzj5fwxOx0mPJaEqMcG/sDpl3Id61B3XhKE63xY+0QWcnG+sWR/sCfprmreyUo1lM7IKZmHMzw==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
@@ -566,6 +623,10 @@ packages:
|
||||
resolution: {integrity: sha512-oRw0rB9QSUiCZfEs23G5F4Af+b2rQt1lItIICWu5IejFNd+uPM8SWqiKLMpQ6bX1Q7slsLbjuoE2niErFtkVPQ==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
'@smithy/core@3.37.0':
|
||||
resolution: {integrity: sha512-97imTcCyiPd8k0e9+ts/Sp0W65Dn9Ti357J7M09Ov4tI8Q1JDJVFSoAJtjEVKqfHEr3Pdo2jfolqKCaaiszH0g==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
'@smithy/credential-provider-imds@4.5.2':
|
||||
resolution: {integrity: sha512-A9uSdn72ozbRUSit0eib0TW7nXuNPlaeM0zcGkJ+nE6tFcSDbnmtwoxbTCFBukVQcszDAyvsd7+rTduPTXpygg==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
@@ -578,6 +639,10 @@ packages:
|
||||
resolution: {integrity: sha512-ThMkboGeONWXAelq9FvGsuJC4rOi+qyC4/zhUF58xYpxUg5sQKx2VXZYJmtNjr4dSuBJ1HeJXETQILCz3wOHvw==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
'@smithy/node-http-handler@4.13.0':
|
||||
resolution: {integrity: sha512-/GGcPoNJ2nzGytdAanpbx7mpp9Coa8LP+/Rpmbktr8YeLuIXs9B0SEz+X3AeonHdyzOIFJdKbN7jOZ+Tj+pMGw==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
'@smithy/signature-v4@5.7.4':
|
||||
resolution: {integrity: sha512-tHy0K0VtqNd5Y7Y41h0a0Lhh0L1GzC08dTWg0F7vRJWFtTENg7IZikf3wQkanYIRdb7ngoIPMTmqgUi401fEeQ==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
@@ -705,6 +770,9 @@ packages:
|
||||
'@types/node@24.19.2':
|
||||
resolution: {integrity: sha512-93d49wCWJkCyN87MxLpNN6KXPCwFw7bVUg79mGUV1OS40JGWwVCr8n61DXCSqz7CV6/VfPkb7xec7uP2M+QHWQ==}
|
||||
|
||||
'@types/nodemailer@7.0.4':
|
||||
resolution: {integrity: sha512-ee8fxWqOchH+Hv6MDDNNy028kwvVnLplrStm4Zf/3uHWw5zzo8FoYYeffpJtGs2wWysEumMH0ZIdMGMY1eMAow==}
|
||||
|
||||
'@types/pg@8.23.1':
|
||||
resolution: {integrity: sha512-fKVHpikPdg4GKks3JuLEhvwSyvwzF23hnabPy6DD8ljVbC7+6J5dQzdv4arV6jqq57djnMgs1HKBxX4P8aBI3A==}
|
||||
|
||||
@@ -1159,6 +1227,10 @@ packages:
|
||||
sass:
|
||||
optional: true
|
||||
|
||||
nodemailer@10.2.1:
|
||||
resolution: {integrity: sha512-ZrDNsPFoDMwefNs7sJwrYiEJ/CG4ClJWDZv05iNJsEBWa8GRDry1QJHTJxsuTAY9eDDKjX4k+q3SxslXJOao7w==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
once@1.4.0:
|
||||
resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
|
||||
|
||||
@@ -1464,6 +1536,18 @@ snapshots:
|
||||
'@smithy/types': 4.19.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/client-sesv2@3.1158.0':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.978.6
|
||||
'@aws-sdk/credential-provider-node': 3.972.89
|
||||
'@aws-sdk/signature-v4-multi-region': 3.996.49
|
||||
'@aws-sdk/types': 3.975.0
|
||||
'@smithy/core': 3.37.0
|
||||
'@smithy/fetch-http-handler': 5.8.0
|
||||
'@smithy/node-http-handler': 4.13.0
|
||||
'@smithy/types': 4.19.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/core@3.978.5':
|
||||
dependencies:
|
||||
'@aws-sdk/types': 3.975.0
|
||||
@@ -1475,6 +1559,17 @@ snapshots:
|
||||
bowser: 2.14.1
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/core@3.978.6':
|
||||
dependencies:
|
||||
'@aws-sdk/types': 3.975.0
|
||||
'@aws-sdk/xml-builder': 3.972.41
|
||||
'@aws/lambda-invoke-store': 0.3.0
|
||||
'@smithy/core': 3.37.0
|
||||
'@smithy/signature-v4': 5.7.4
|
||||
'@smithy/types': 4.19.0
|
||||
bowser: 2.14.1
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/credential-provider-env@3.972.76':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.978.5
|
||||
@@ -1483,6 +1578,14 @@ snapshots:
|
||||
'@smithy/types': 4.19.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/credential-provider-env@3.972.77':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.978.6
|
||||
'@aws-sdk/types': 3.975.0
|
||||
'@smithy/core': 3.37.0
|
||||
'@smithy/types': 4.19.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/credential-provider-http@3.972.78':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.978.5
|
||||
@@ -1493,6 +1596,16 @@ snapshots:
|
||||
'@smithy/types': 4.19.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/credential-provider-http@3.972.79':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.978.6
|
||||
'@aws-sdk/types': 3.975.0
|
||||
'@smithy/core': 3.37.0
|
||||
'@smithy/fetch-http-handler': 5.8.0
|
||||
'@smithy/node-http-handler': 4.13.0
|
||||
'@smithy/types': 4.19.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/credential-provider-ini@3.973.21':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.978.5
|
||||
@@ -1509,6 +1622,22 @@ snapshots:
|
||||
'@smithy/types': 4.19.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/credential-provider-ini@3.973.22':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.978.6
|
||||
'@aws-sdk/credential-provider-env': 3.972.77
|
||||
'@aws-sdk/credential-provider-http': 3.972.79
|
||||
'@aws-sdk/credential-provider-login': 3.972.84
|
||||
'@aws-sdk/credential-provider-process': 3.972.77
|
||||
'@aws-sdk/credential-provider-sso': 3.973.21
|
||||
'@aws-sdk/credential-provider-web-identity': 3.972.83
|
||||
'@aws-sdk/nested-clients': 3.997.51
|
||||
'@aws-sdk/types': 3.975.0
|
||||
'@smithy/core': 3.37.0
|
||||
'@smithy/credential-provider-imds': 4.5.2
|
||||
'@smithy/types': 4.19.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/credential-provider-login@3.972.83':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.978.5
|
||||
@@ -1518,6 +1647,15 @@ snapshots:
|
||||
'@smithy/types': 4.19.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/credential-provider-login@3.972.84':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.978.6
|
||||
'@aws-sdk/nested-clients': 3.997.51
|
||||
'@aws-sdk/types': 3.975.0
|
||||
'@smithy/core': 3.37.0
|
||||
'@smithy/types': 4.19.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/credential-provider-node@3.972.88':
|
||||
dependencies:
|
||||
'@aws-sdk/credential-provider-env': 3.972.76
|
||||
@@ -1532,6 +1670,20 @@ snapshots:
|
||||
'@smithy/types': 4.19.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/credential-provider-node@3.972.89':
|
||||
dependencies:
|
||||
'@aws-sdk/credential-provider-env': 3.972.77
|
||||
'@aws-sdk/credential-provider-http': 3.972.79
|
||||
'@aws-sdk/credential-provider-ini': 3.973.22
|
||||
'@aws-sdk/credential-provider-process': 3.972.77
|
||||
'@aws-sdk/credential-provider-sso': 3.973.21
|
||||
'@aws-sdk/credential-provider-web-identity': 3.972.83
|
||||
'@aws-sdk/types': 3.975.0
|
||||
'@smithy/core': 3.37.0
|
||||
'@smithy/credential-provider-imds': 4.5.2
|
||||
'@smithy/types': 4.19.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/credential-provider-process@3.972.76':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.978.5
|
||||
@@ -1540,6 +1692,14 @@ snapshots:
|
||||
'@smithy/types': 4.19.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/credential-provider-process@3.972.77':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.978.6
|
||||
'@aws-sdk/types': 3.975.0
|
||||
'@smithy/core': 3.37.0
|
||||
'@smithy/types': 4.19.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/credential-provider-sso@3.973.20':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.978.5
|
||||
@@ -1550,6 +1710,16 @@ snapshots:
|
||||
'@smithy/types': 4.19.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/credential-provider-sso@3.973.21':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.978.6
|
||||
'@aws-sdk/nested-clients': 3.997.51
|
||||
'@aws-sdk/token-providers': 3.1157.0
|
||||
'@aws-sdk/types': 3.975.0
|
||||
'@smithy/core': 3.37.0
|
||||
'@smithy/types': 4.19.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/credential-provider-web-identity@3.972.82':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.978.5
|
||||
@@ -1559,6 +1729,15 @@ snapshots:
|
||||
'@smithy/types': 4.19.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/credential-provider-web-identity@3.972.83':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.978.6
|
||||
'@aws-sdk/nested-clients': 3.997.51
|
||||
'@aws-sdk/types': 3.975.0
|
||||
'@smithy/core': 3.37.0
|
||||
'@smithy/types': 4.19.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/middleware-sdk-s3@3.972.81':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.978.5
|
||||
@@ -1579,6 +1758,17 @@ snapshots:
|
||||
'@smithy/types': 4.19.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/nested-clients@3.997.51':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.978.6
|
||||
'@aws-sdk/signature-v4-multi-region': 3.996.49
|
||||
'@aws-sdk/types': 3.975.0
|
||||
'@smithy/core': 3.37.0
|
||||
'@smithy/fetch-http-handler': 5.8.0
|
||||
'@smithy/node-http-handler': 4.13.0
|
||||
'@smithy/types': 4.19.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/signature-v4-multi-region@3.996.49':
|
||||
dependencies:
|
||||
'@aws-sdk/types': 3.975.0
|
||||
@@ -1595,6 +1785,15 @@ snapshots:
|
||||
'@smithy/types': 4.19.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/token-providers@3.1157.0':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.978.6
|
||||
'@aws-sdk/nested-clients': 3.997.51
|
||||
'@aws-sdk/types': 3.975.0
|
||||
'@smithy/core': 3.37.0
|
||||
'@smithy/types': 4.19.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/types@3.975.0':
|
||||
dependencies:
|
||||
'@smithy/types': 4.19.0
|
||||
@@ -1852,6 +2051,11 @@ snapshots:
|
||||
'@smithy/types': 4.19.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@smithy/core@3.37.0':
|
||||
dependencies:
|
||||
'@smithy/types': 4.19.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@smithy/credential-provider-imds@4.5.2':
|
||||
dependencies:
|
||||
'@smithy/core': 3.36.2
|
||||
@@ -1870,6 +2074,12 @@ snapshots:
|
||||
'@smithy/types': 4.19.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@smithy/node-http-handler@4.13.0':
|
||||
dependencies:
|
||||
'@smithy/core': 3.37.0
|
||||
'@smithy/types': 4.19.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@smithy/signature-v4@5.7.4':
|
||||
dependencies:
|
||||
'@smithy/core': 3.36.2
|
||||
@@ -1981,6 +2191,11 @@ snapshots:
|
||||
dependencies:
|
||||
undici-types: 7.24.6
|
||||
|
||||
'@types/nodemailer@7.0.4':
|
||||
dependencies:
|
||||
'@aws-sdk/client-sesv2': 3.1158.0
|
||||
'@types/node': 24.19.2
|
||||
|
||||
'@types/pg@8.23.1':
|
||||
dependencies:
|
||||
'@types/node': 24.19.2
|
||||
@@ -2629,6 +2844,8 @@ snapshots:
|
||||
- '@types/node'
|
||||
- babel-plugin-macros
|
||||
|
||||
nodemailer@10.2.1: {}
|
||||
|
||||
once@1.4.0:
|
||||
dependencies:
|
||||
wrappy: 1.0.2
|
||||
|
||||
|
Before Width: | Height: | Size: 204 KiB |
@@ -1,9 +1,11 @@
|
||||
# PROMPT
|
||||
# 织页 · Linked Guidance
|
||||
|
||||
> 创作素材与可相互引用的指导文档。
|
||||
> 独立使用、按需连接的 Agent 指导文档。名称与 XML 协议仍处于实验阶段。
|
||||
|
||||
- [网站 Markdown 入口](/index.md)
|
||||
- [全部指导文档](/skills/index.md)
|
||||
- [Agent 阅读入口](/agent/index.md)
|
||||
- [真实用户文档摘要(JSON)](/api/documents)
|
||||
- [协议样例摘要(JSON)](/api/guides)
|
||||
- [协议说明](/protocol)
|
||||
- [人类入口](/)
|
||||
|
||||
每篇指导文档的网页地址加 /index.md,即为一一对应的 Markdown。沿文档链接继续阅读,可直接进入任意一篇,不必经过某个技能入口。
|
||||
从单篇文档开始;只有当前任务需要时才沿条件链接读取其他文档。固定版本引用,限制读取深度与篇数。集合不是强制安装包,链接不授予执行权限。
|
||||
|
||||
|
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,7 +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();
|
||||
for (const path of ['/','/library/import','/skills/shadow-puppet/egret-example.png','/studio',`/_next/data/${buildId}/index.json`]) {
|
||||
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/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);
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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,8 +17,12 @@ async function availablePort() {
|
||||
|
||||
const port = process.env.SMOKE_PORT || await availablePort();
|
||||
const release = "smoke-test";
|
||||
const child = spawn(process.execPath, [".next/standalone/server.js"], {
|
||||
env: { ...process.env, PORT: port, HOSTNAME: "127.0.0.1", RELEASE_SHA: release },
|
||||
const previewFixture='.next/standalone/apps/waterfall-wiki/public/__smoke-preview.png';
|
||||
await writeFile(previewFixture,await sharp({create:{width:48,height:48,channels:3,background:'#eee'}}).png().toBuffer());
|
||||
const child = spawn(process.execPath, [".next/standalone/apps/waterfall-wiki/server.js"], {
|
||||
// This loopback-only child checks retained resources without production credentials.
|
||||
// Production keeps failing closed; the bypass requires both explicit test settings.
|
||||
env: { ...process.env, AUTH_CONFIG_FILE: "", AUTH_DISABLED_FOR_TESTS: "true", PORT: port, HOSTNAME: "127.0.0.1", RELEASE_SHA: release },
|
||||
stdio: "inherit",
|
||||
});
|
||||
try {
|
||||
@@ -40,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");
|
||||
|
||||
|
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 |