diff --git a/.gitignore b/.gitignore index 30becd5e1..b18b56081 100644 --- a/.gitignore +++ b/.gitignore @@ -87,7 +87,7 @@ checkpoints/ # 第三方参考源码(不提交到git) reference-sources/ -# Local self-hosted Pascal Editor checkout. +# Legacy external Pascal Editor checkout (the managed source is under services/). .pascal-editor/ # Drizzle migrations must be versioned (override the global *.sql ignore above) diff --git a/app/globals.css b/app/globals.css index d8ab6e0ec..9291d28d4 100644 --- a/app/globals.css +++ b/app/globals.css @@ -3,6 +3,7 @@ /* include utility classes in streamdown */ @source '../node_modules/streamdown/dist/index.js'; +@source not '../services/pascal-editor'; /* custom variant for setting dark mode programmatically */ @custom-variant dark (&:is(.dark, .dark *)); diff --git a/biome.jsonc b/biome.jsonc index 90f778466..125e08bfa 100644 --- a/biome.jsonc +++ b/biome.jsonc @@ -13,6 +13,7 @@ "!lib/db/migrations/meta", "!public/vendor", "!public/three", + "!services/pascal-editor", "!vercel.json" ] }, diff --git a/docs/pascal-editor-integration.md b/docs/pascal-editor-integration.md index d4b2ef826..69843a509 100644 --- a/docs/pascal-editor-integration.md +++ b/docs/pascal-editor-integration.md @@ -2,10 +2,11 @@ ## Current Shape -The app now exposes a first-phase Pascal Editor workspace at `/pascal-editor`. -It is intentionally iframe-based so Pascal can be self-hosted as a standalone -Next app without forcing its unpublished `@pascal-app/nodes` workspace package -into the main application dependency graph. +The app exposes a Pascal Editor workspace at `/pascal-editor`. Its customized +source is versioned with the main repository under `services/pascal-editor`, +while it remains an independently built Next app embedded through an iframe. +This keeps Pascal's Bun workspace and `@pascal-app/nodes` dependency graph out +of the host application's pnpm dependency graph. The wrapper route is protected by the host application's existing login system. Unauthenticated users are redirected to `/login?callbackUrl=/pascal-editor`. @@ -26,11 +27,10 @@ loading a broken iframe. ## Why Iframe First `@pascal-app/core`, `@pascal-app/viewer`, and `@pascal-app/editor` are published -on npm, but the official standalone app still depends on `@pascal-app/nodes` -from its monorepo workspace. That package registers the built-in node -definitions used by walls, doors, windows, levels, roofs, stairs, and other -editable building elements. Until it is published or vendored, a pure npm-based -native React integration would be incomplete. +on npm, but the standalone app also depends on its monorepo workspace packages. +Keeping the complete Pascal source tree below `services/pascal-editor` preserves +the built-in walls, doors, windows, levels, roofs, stairs, MCP tools, and our AI +Agent customization without coupling the host build to those packages. ## Deployment Options @@ -42,9 +42,9 @@ Run Pascal Editor as a local standalone service: pnpm pascal:dev ``` -The helper clones `pascalorg/editor` into the ignored `.pascal-editor/` -directory on first run, installs its dependencies, and starts `apps/editor` on -`http://localhost:3002`. +The helper uses `services/pascal-editor`, installs its dependencies when needed, +and starts `apps/editor` on `http://localhost:3002`. Set `PASCAL_EDITOR_DIR` only +when deliberately testing a different Pascal checkout. Then open: @@ -121,13 +121,20 @@ For repeatable deployment and upgrades on additional servers, use variables, health check, real-model smoke test, and rollback behavior are documented in `docs/pascal-production-deployment.md`. -## Next Step +## Source Maintenance -After the iframe POC is stable, the native integration path is: +`services/pascal-editor` was imported with Git subtree so its upstream history +can still be refreshed deliberately. Apply and test upstream updates in a +dedicated branch before merging them with our localization, host authentication, +scene storage, and AI Agent changes: -1. Pin a Pascal upstream commit. -2. Vendor or workspace-link `packages/nodes` as `@pascal-app/nodes`. -3. Add Pascal packages to `transpilePackages`. -4. Upgrade `three` to the Pascal-compatible `^0.184` line and retest existing - 3D/CAD surfaces. -5. Wire `Editor` `onLoad` / `onSave` to our project storage APIs. +```bash +git subtree pull \ + --prefix=services/pascal-editor \ + https://github.com/pascalorg/editor.git main \ + --squash +``` + +Production servers do not run this command. They only pull the reviewed main +repository and let `pnpm pascal:deploy` sync the vendored source into the +isolated runtime directory. diff --git a/docs/pascal-production-deployment.md b/docs/pascal-production-deployment.md index 80e25f298..a4fc5d8aa 100644 --- a/docs/pascal-production-deployment.md +++ b/docs/pascal-production-deployment.md @@ -1,32 +1,30 @@ # Pascal Editor 可复用生产部署 -本项目把 Pascal Editor 作为独立 Bun/Next.js 服务运行,并通过主站 Nginx -同域挂载。部署脚本会复用主站登录态、系统设置中显式标记的“多模态模型”和主站 -Git 仓库里的 `pascal-editor-ai-agent` 独立分支。 +Pascal Editor 的定制源码位于主仓库 `services/pascal-editor`,以独立 +Bun/Next.js 服务运行,并通过主站 Nginx 同域挂载。新服务器只需拉取 +主仓库,无需再克隆或维护 Pascal 独立分支。 ## 默认结构 | 项目 | 默认值 | | --- | --- | -| 主站目录 | 当前仓库目录 | +| 主站源码 | 当前仓库目录 | +| Pascal 内置源码 | `services/pascal-editor` | +| Pascal 运行副本 | `/opt/pascal-editor` | +| Pascal 用户数据 | `/var/lib/pascal-editor` | | 主站 PM2 服务 | `ai-chatbot` | -| 主站内部地址 | `http://127.0.0.1:3125` | -| Pascal 源码 | `/opt/pascal-editor` | -| Pascal 数据 | `/var/lib/pascal-editor` | | Pascal PM2 服务 | `pascal-editor` | | Pascal 监听 | `127.0.0.1:3002` | | 同域路径 | `/pascal-service` | -| Pascal Git 分支 | `pascal-editor-ai-agent` | -Pascal 端口不对公网开放。Nginx 会先通过主站 -`/api/internal/pascal/access` 校验登录 Cookie,再把可信用户 ID 注入 Pascal。 -场景的创建、列表、读取、保存、删除、事件流和 AI Agent 都检查同一个 -`ownerId`。 +`/opt/pascal-editor` 是部署脚本生成的运行副本,不是第二个源码仓库, +不应在该目录直接修改代码。Pascal 端口不对公网开放。Nginx 先通过 +`/api/internal/pascal/access` 校验主站登录 Cookie,再注入可信用户 ID。 +场景数据按 `ownerId` 隔离。 ## 首次部署 -在目标服务器先部署好主站并确认主站 Git remote 能读取 -`pascal-editor-ai-agent` 分支,然后执行: +目标服务器部署好主站后执行: ```bash cd /var/www/ai-chatbot @@ -41,39 +39,43 @@ sudo env \ 脚本会按顺序: 1. 生成或复用 `PASCAL_PROXY_SECRET`,写入主站 `.env.local`。 -2. 构建并重启主站。 -3. 安装固定版本 Bun(默认 `1.3.0`)。 -4. 克隆或快进 Pascal 独立分支。 +2. 安装主站开发依赖,构建并重启主站。 +3. 安装固定版本 Bun(默认 `1.3.0`)和 `rsync`。 +4. 将 `services/pascal-editor` 同步为独立运行副本。 5. 写入仅 root 可读的 Pascal 生产环境文件。 6. 安装依赖、构建 Pascal,并注册 PM2 服务。 -7. 渲染 Nginx 片段、执行 `nginx -t`,成功后热重载。 +7. 渲染 Nginx 片段,校验成功后热重载。 8. 检查主站、Pascal、Agent 状态和主站模型网关。 -主站环境文件和 Nginx 配置会备份到 `/var/backups/pascal-deploy`。Nginx -校验失败时,脚本会立即恢复执行前的配置。 +主站环境文件、Nginx 配置以及旧 Pascal Git 元数据会备份到 +`/var/backups/pascal-deploy`。Nginx 校验失败时会恢复原配置。 ## 日常更新 -主站和 Pascal 分支有新提交后,重复执行同一个命令即可。脚本只允许 Git -快进更新;如果 `/opt/pascal-editor` 有本地修改,会停止部署,不会覆盖现场 -文件。 +以后只需更新主仓库,并重复执行部署命令: + +```bash +git pull --ff-only origin main +sudo env PASCAL_PUBLIC_ORIGIN=https://example.com pnpm pascal:deploy +``` + +`rsync --delete` 会更新运行副本,但保留 `/opt/pascal-editor/.env.production`。 +用户数据始终位于独立的 `PASCAL_DATA_DIR`,不会被代码同步覆盖。 只做无模型费用的健康检查: ```bash -sudo env \ - PASCAL_PUBLIC_ORIGIN=https://example.com \ - pnpm pascal:check +sudo env PASCAL_PUBLIC_ORIGIN=https://example.com pnpm pascal:check ``` -执行一次真实自然语言建模、验证节点已落库并自动删除临时场景: +执行一次真实自然语言建模,验证节点落库后删除临时场景: ```bash sudo env PASCAL_SMOKE_CONFIRM=1 pnpm pascal:smoke ``` -真实冒烟会调用当前系统设置中显式标记的“多模态模型”,因此默认不会在一键部署时 -执行。需要每次部署自动执行时设置 `RUN_AGENT_SMOKE_TEST=true`。 +真实冒烟会调用系统设置中显式标记的“多模态模型”,默认不在一键部署 +时执行。需要每次部署自动执行时设置 `RUN_AGENT_SMOKE_TEST=true`。 ## 常用覆盖项 @@ -83,16 +85,15 @@ sudo env PASCAL_SMOKE_CONFIRM=1 pnpm pascal:smoke | `MAIN_ENV_FILE` | 主站环境文件 | | `MAIN_PM2_NAME` | 主站 PM2 服务名 | | `MAIN_INTERNAL_URL` | Pascal 调用主站网关的地址 | -| `MAIN_NGINX_UPSTREAM` | Nginx 主站 upstream,例如 `127.0.0.1:3125` | +| `MAIN_NGINX_UPSTREAM` | Nginx 主站 upstream | | `BUILD_MAIN_APP=false` | 跳过主站安装、构建和重启 | -| `PASCAL_INSTALL_DIR` | Pascal 源码目录 | -| `PASCAL_DATA_DIR` | SQLite 数据目录 | -| `PASCAL_GIT_REPO` | Pascal 分支所在 Git 仓库;默认使用主站 origin | -| `PASCAL_GIT_REF` | Pascal 分支或标签 | +| `PASCAL_SOURCE_DIR` | Pascal 内置源码目录 | +| `PASCAL_INSTALL_DIR` | Pascal 运行副本目录 | +| `PASCAL_DATA_DIR` | SQLite 用户数据目录 | | `PASCAL_PORT` | Pascal 本地监听端口 | | `PASCAL_BASE_PATH` | 同域挂载路径 | | `PASCAL_PUBLIC_ORIGIN` | 公网主站 Origin,不带末尾斜杠 | -| `NGINX_SITE_CONFIG` | 需要加入 Pascal include 的 HTTPS server 配置 | +| `NGINX_SITE_CONFIG` | 需加入 Pascal include 的 HTTPS server 配置 | | `INSTALL_NGINX=false` | 仅部署服务,不修改 Nginx | | `PASCAL_BUN_VERSION` | 固定 Bun 版本 | | `PASCAL_BACKUP_DIR` | 环境与 Nginx 配置备份目录 | @@ -103,9 +104,10 @@ sudo env PASCAL_SMOKE_CONFIRM=1 pnpm pascal:smoke pm2 status pm2 logs pascal-editor --lines 100 curl http://127.0.0.1:3002/pascal-service/api/health +cat /opt/pascal-editor/.pascal-source-version nginx -t ``` -升级时不要把主站源码复制到 Pascal 目录,也不要把本地测试数据库上传到 -服务器。主站始终更新 `main`,Pascal 始终更新独立分支,用户场景只保存在 +不要将本地测试数据库放进 `services/pascal-editor`,也不要直接修改 +`/opt/pascal-editor`。代码只更新主仓 `main`,用户场景只保存在 `PASCAL_DATA_DIR`。 diff --git a/scripts/deploy-pascal-production.sh b/scripts/deploy-pascal-production.sh index 134607537..b727d13f7 100755 --- a/scripts/deploy-pascal-production.sh +++ b/scripts/deploy-pascal-production.sh @@ -12,9 +12,8 @@ MAIN_NGINX_UPSTREAM="${MAIN_NGINX_UPSTREAM:-127.0.0.1:3125}" BUILD_MAIN_APP="${BUILD_MAIN_APP:-true}" PASCAL_INSTALL_DIR="${PASCAL_INSTALL_DIR:-/opt/pascal-editor}" +PASCAL_SOURCE_DIR="${PASCAL_SOURCE_DIR:-$MAIN_APP_DIR/services/pascal-editor}" PASCAL_DATA_DIR="${PASCAL_DATA_DIR:-/var/lib/pascal-editor}" -PASCAL_GIT_REF="${PASCAL_GIT_REF:-pascal-editor-ai-agent}" -PASCAL_GIT_REPO="${PASCAL_GIT_REPO:-}" PASCAL_PM2_NAME="${PASCAL_PM2_NAME:-pascal-editor}" PASCAL_PORT="${PASCAL_PORT:-3002}" PASCAL_NGINX_UPSTREAM="${PASCAL_NGINX_UPSTREAM:-127.0.0.1:${PASCAL_PORT}}" @@ -127,6 +126,17 @@ install_bun() { [ -x "$PASCAL_BUN_BIN" ] || die "Bun installation did not create $PASCAL_BUN_BIN" } +install_rsync() { + if command -v rsync >/dev/null 2>&1; then + return + fi + + [ "$(id -u)" -eq 0 ] || die "Installing rsync requires root; install rsync manually" + require_command apt-get + apt-get update -qq + DEBIAN_FRONTEND=noninteractive apt-get install -y -qq rsync +} + configure_main_app() { local secret="$1" local next_env_backup="" @@ -176,29 +186,40 @@ configure_main_app() { ) } -resolve_pascal_repo() { - if [ -n "$PASCAL_GIT_REPO" ]; then - printf '%s' "$PASCAL_GIT_REPO" - return - fi - git -C "$MAIN_APP_DIR" remote get-url origin -} +sync_pascal_source() { + [ -f "$PASCAL_SOURCE_DIR/package.json" ] || + die "Vendored Pascal source not found: $PASCAL_SOURCE_DIR" + [ -f "$PASCAL_SOURCE_DIR/apps/editor/package.json" ] || + die "Pascal editor package not found below $PASCAL_SOURCE_DIR" + [ "$PASCAL_INSTALL_DIR" != "/" ] || die "PASCAL_INSTALL_DIR cannot be /" + [ "$PASCAL_INSTALL_DIR" != "$MAIN_APP_DIR" ] || + die "PASCAL_INSTALL_DIR cannot overwrite MAIN_APP_DIR" + [ "$PASCAL_INSTALL_DIR" != "$PASCAL_SOURCE_DIR" ] || + die "PASCAL_INSTALL_DIR must be separate from PASCAL_SOURCE_DIR" + + install_rsync + mkdir -p "$PASCAL_INSTALL_DIR" "$PASCAL_BACKUP_DIR" -checkout_pascal() { - local repo="$1" if [ -d "$PASCAL_INSTALL_DIR/.git" ]; then [ -z "$(git -C "$PASCAL_INSTALL_DIR" status --porcelain)" ] || - die "$PASCAL_INSTALL_DIR has local Git changes; refusing to overwrite them" - git -C "$PASCAL_INSTALL_DIR" fetch origin "$PASCAL_GIT_REF" - git -C "$PASCAL_INSTALL_DIR" checkout "$PASCAL_GIT_REF" - git -C "$PASCAL_INSTALL_DIR" pull --ff-only origin "$PASCAL_GIT_REF" - return + die "$PASCAL_INSTALL_DIR has legacy local Git changes; refusing to overwrite them" + local legacy_git_backup="$PASCAL_BACKUP_DIR/legacy-pascal-git-$(date +%Y%m%d%H%M%S)" + log "Archiving legacy Pascal Git metadata to $legacy_git_backup" + mv "$PASCAL_INSTALL_DIR/.git" "$legacy_git_backup" fi - [ ! -e "$PASCAL_INSTALL_DIR" ] || - die "$PASCAL_INSTALL_DIR exists but is not a Git checkout" - mkdir -p "$(dirname "$PASCAL_INSTALL_DIR")" - git clone --single-branch --branch "$PASCAL_GIT_REF" "$repo" "$PASCAL_INSTALL_DIR" + log "Syncing vendored Pascal source from $PASCAL_SOURCE_DIR" + rsync -a --delete \ + --exclude '.env.production' \ + --exclude '.git/' \ + --exclude '.next/' \ + --exclude '.turbo/' \ + --exclude 'node_modules/' \ + "$PASCAL_SOURCE_DIR/" "$PASCAL_INSTALL_DIR/" + + if git -C "$MAIN_APP_DIR" rev-parse --verify HEAD >/dev/null 2>&1; then + git -C "$MAIN_APP_DIR" rev-parse HEAD >"$PASCAL_INSTALL_DIR/.pascal-source-version" + fi } write_pascal_env() { @@ -226,7 +247,7 @@ build_pascal() { mkdir -p "$PASCAL_DATA_DIR" chmod 700 "$PASCAL_DATA_DIR" export PATH="$(dirname "$PASCAL_BUN_BIN"):$PATH" - log "Installing and building Pascal from $PASCAL_GIT_REF" + log "Installing and building vendored Pascal service" ( cd "$PASCAL_INSTALL_DIR" "$PASCAL_BUN_BIN" install --frozen-lockfile @@ -369,7 +390,7 @@ main() { [ "$(id -u)" -eq 0 ] || die "Production deployment must run as root" configure_main_app "$secret" install_bun - checkout_pascal "$(resolve_pascal_repo)" + sync_pascal_source write_pascal_env "$secret" build_pascal start_pascal diff --git a/scripts/pascal-editor-dev.mjs b/scripts/pascal-editor-dev.mjs index 6a5d51857..c2bdade6a 100644 --- a/scripts/pascal-editor-dev.mjs +++ b/scripts/pascal-editor-dev.mjs @@ -1,22 +1,13 @@ #!/usr/bin/env node import { spawn, spawnSync } from "node:child_process"; -import { - copyFileSync, - existsSync, - mkdirSync, - readFileSync, - writeFileSync, -} from "node:fs"; +import { existsSync, writeFileSync } from "node:fs"; import path from "node:path"; const cwd = process.cwd(); const pascalDir = path.resolve( cwd, - process.env.PASCAL_EDITOR_DIR || ".pascal-editor" + process.env.PASCAL_EDITOR_DIR || "services/pascal-editor" ); -const repoUrl = - process.env.PASCAL_EDITOR_REPO || "https://github.com/pascalorg/editor.git"; -const repoRef = process.env.PASCAL_EDITOR_REF || "main"; const port = process.env.PASCAL_EDITOR_PORT || "3002"; function run(command, args, options = {}) { @@ -36,72 +27,6 @@ function commandExists(command) { return result.status === 0; } -function patchNestedNextRoot() { - const configPath = path.join(pascalDir, "apps/editor/next.config.ts"); - if (!existsSync(configPath)) { - return; - } - - let config = readFileSync(configPath, "utf8"); - if (!config.includes("import path from 'node:path'")) { - config = config.replace( - "import type { NextConfig } from 'next'\n", - "import path from 'node:path'\nimport type { NextConfig } from 'next'\n" - ); - } - - if (!config.includes("root: path.resolve(process.cwd(), '../..')")) { - config = config.replace( - " turbopack: {\n", - " turbopack: {\n root: path.resolve(process.cwd(), '../..'),\n" - ); - } - - writeFileSync(configPath, config, "utf8"); -} - -function ensureSparseCheckout() { - run( - "git", - [ - "-C", - pascalDir, - "sparse-checkout", - "set", - "apps/editor", - "packages", - "tooling", - "styles", - ], - { cwd } - ); -} - -function patchCssImports() { - const source = path.join(pascalDir, "styles/elevation.css"); - const targetDir = path.join(pascalDir, "apps/editor/styles"); - const target = path.join(targetDir, "elevation.css"); - const globalsPath = path.join(pascalDir, "apps/editor/app/globals.css"); - - if (existsSync(source)) { - mkdirSync(targetDir, { recursive: true }); - copyFileSync(source, target); - } - - if (!existsSync(globalsPath)) { - return; - } - - const globals = readFileSync(globalsPath, "utf8"); - const patched = globals.replace( - '../../../styles/elevation.css', - '../styles/elevation.css' - ); - if (patched !== globals) { - writeFileSync(globalsPath, patched, "utf8"); - } -} - function runPackageScript(packageManager, packageDir, scriptName) { if (packageManager === "bun") { run("bun", ["--cwd", packageDir, "run", scriptName], { cwd: pascalDir }); @@ -127,36 +52,18 @@ function ensureBuiltWorkspacePackages(packageManager) { } } -if (!existsSync(pascalDir)) { - mkdirSync(path.dirname(pascalDir), { recursive: true }); - run("git", [ - "clone", - "--depth", - "1", - "--filter=blob:none", - "--sparse", - "--branch", - repoRef, - repoUrl, - pascalDir, - ]); -} else if (!existsSync(path.join(pascalDir, ".git"))) { +if (!existsSync(path.join(pascalDir, "apps/editor/package.json"))) { console.error( - `${pascalDir} already exists but is not a git checkout. Move it away or set PASCAL_EDITOR_DIR.` + `Vendored Pascal Editor source was not found at ${pascalDir}. Set PASCAL_EDITOR_DIR to override it.` ); process.exit(1); } -ensureSparseCheckout(); -patchCssImports(); - const envLocalPath = path.join(pascalDir, ".env.local"); if (!existsSync(envLocalPath)) { writeFileSync(envLocalPath, `PORT=${port}\n`, "utf8"); } -patchNestedNextRoot(); - const hasBun = commandExists("bun"); const hasNpm = commandExists("npm"); const hasNodeModules = existsSync(path.join(pascalDir, "node_modules")); diff --git a/services/pascal-editor/HOST_INTEGRATION.md b/services/pascal-editor/HOST_INTEGRATION.md new file mode 100644 index 000000000..ede263d8b --- /dev/null +++ b/services/pascal-editor/HOST_INTEGRATION.md @@ -0,0 +1,19 @@ +# Host Application Integration + +This directory is the source of truth for the Pascal Editor service shipped +with the host application. It originated from `pascalorg/editor` and includes +the host authentication, user-scoped scene storage, Chinese localization, and +AI Agent integration maintained by this project. + +## Boundaries + +- The host application uses pnpm and does not install this workspace as part of + its own dependency graph. +- Pascal uses its own Bun lockfile and is built as an independent Next service. +- Production deploys copy this directory to `/opt/pascal-editor`. +- Runtime secrets remain in `/opt/pascal-editor/.env.production`. +- User scene data remains outside both source trees in `/var/lib/pascal-editor`. + +Run `pnpm pascal:dev` from the host repository for local development. Run +`pnpm pascal:deploy` as root on a production server after pulling the host main +branch. Never commit runtime environment files or SQLite scene databases here. diff --git a/tsconfig.json b/tsconfig.json index e11ae50bc..93154cf65 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -31,5 +31,5 @@ "next.config.js", ".next/dev/types/**/*.ts" ], - "exclude": ["node_modules"] + "exclude": ["node_modules", "services/pascal-editor"] }