diff --git a/.env.example b/.env.example index e0d54e8..996df4a 100644 --- a/.env.example +++ b/.env.example @@ -118,22 +118,17 @@ VITE_TKMIND_WORKING_DIR=/Users/john/PycharmProjects/tkmind # 历史列表每次展示/加载条数(默认 25,与 desktop 一致) # VITE_SESSION_PAGE_SIZE=25 -# Plaza 发现广场(Sprint 1) +# Plaza 发现广场(本机为主,105 已停用) # PLAZA_AUTO_APPROVE=true -# 本地 Plaza(无端口,与线上一致): -# sudo pnpm setup:plaza-dns # hosts + /etc/resolver(覆盖 Tailscale fake-ip) -# pnpm dev:plaza-dns # 本地 DNS :5533(dev:plaza 会自动启动) -# pnpm dev:plaza # 仅 Plaza + Portal -# sudo pnpm dev:plaza-proxy # :443 -> :3001 -# 若浏览器仍走 Cloudflare:关闭「安全 DNS / Secure DNS」后重开浏览器 +# 文档:docs/plaza-local.md +# 首次:sudo pnpm setup:plaza-dns # 仅纯本地 HTTPS 时需要;走 Tunnel 请用 setup:plaza-dns:remove +# 日常:pnpm dev:plaza | 浏览器:pnpm open:plaza | 诊断:pnpm check:plaza +# 公网 plaza.tkmind.cn 经 Cloudflare Tunnel → 本机 :3001(需 dev:plaza 常驻) # PLAZA_LOCAL_HOST=plaza.tkmind.cn -# PLAZA_PUBLIC_PORT=80 -# PLAZA_PUBLIC_BASE=http://plaza.tkmind.cn -# VITE_PLAZA_BASE=http://plaza.tkmind.cn -# 105 部署(Plaza 与 H5 分离): -# pnpm deploy:105 # Memind H5 → 105(Plaza API + MindSpace + 登录) -# pnpm deploy:plaza-105 # Plaza Next.js → 105 /root/plaza/web(独立前端) -# 配置:deploy/plaza-105/plaza-105.env.example → plaza-105.env +# PLAZA_PUBLIC_BASE=https://plaza.tkmind.cn +# VITE_PLAZA_BASE=https://plaza.tkmind.cn +# CLOUDFLARE_TUNNEL_CONFIG=/Users/john/Project/ollama/cloudflare/config.yml +# CLOUDFLARE_TUNNEL_NAME=ollama-tkmind # MindSpace(默认启用;子功能需按需开启) # MINDSPACE_ENABLED=false diff --git a/README.md b/README.md new file mode 100644 index 0000000..a3071f7 --- /dev/null +++ b/README.md @@ -0,0 +1,49 @@ +# Memind (TKMind H5) + +Memind 主应用与 Plaza 发现广场。**当前以本机 Mac 为主服务**,105 服务器已停用。 + +## 快速开始 + +```bash +pnpm install +cp .env.example .env # 按需填写 +pnpm dev # MindSpace + Plaza + Ops +``` + +仅 Plaza: + +```bash +pnpm start:plaza # 本机正式模式(Tunnel 回源) +pnpm dev:plaza # 开发热更新 +pnpm open:plaza # 浏览器打开 https://plaza.tkmind.cn/plaza +``` + +## Plaza 本机为主 + +完整说明见 **[docs/plaza-local.md](docs/plaza-local.md)**,摘要如下: + +| 步骤 | 命令 | +|------|------| +| 首次 DNS | `sudo pnpm setup:plaza-dns` | +| 首次 TLS(可选) | `sudo pnpm setup:plaza-local` | +| 公网 Tunnel | `pnpm setup:plaza-tunnel` | +| 日常正式 | `pnpm start:plaza` | +| 开发调试 | `pnpm dev:plaza` | +| 诊断 | `pnpm check:plaza` | + +公网 `https://plaza.tkmind.cn` 经 Cloudflare Tunnel 回源本机 `:3001`,需保持 `pnpm start:plaza` 运行。 + +## 常用脚本 + +| 命令 | 说明 | +|------|------| +| `pnpm dev` | 全栈开发 | +| `pnpm start:plaza` | Plaza 正式模式 + Portal | +| `pnpm dev:plaza` | Plaza 开发模式 + Portal | +| `pnpm open:plaza` | 浏览器强制本地解析 | +| `pnpm check:plaza` | 本地 Plaza 诊断 | +| `pnpm test` | 单元测试 | + +## 文档 + +- [Plaza 本机部署与 Tunnel](docs/plaza-local.md) diff --git a/docs/plaza-local.md b/docs/plaza-local.md new file mode 100644 index 0000000..57e81e8 --- /dev/null +++ b/docs/plaza-local.md @@ -0,0 +1,111 @@ +# Plaza 本机为主 + +105 服务器已停用。Plaza 与 Memind H5 均在本机 Mac 运行;公网 `plaza.tkmind.cn` 经 **Cloudflare Tunnel** 回源到本机。 + +## 架构 + +``` +浏览器 / 微信 + ↓ +Cloudflare(plaza.tkmind.cn) + ↓ Cloudflare Tunnel +本机 Mac + ├─ :3001 Plaza Next.js(/plaza、/_next) + │ └─ rewrites → :8081(/api、/auth、/u) + └─ :8081 Memind Portal(server.mjs) +``` + +Next.js 通过 `PLAZA_API_PROXY` 把 API 请求转发到 Portal,无需单独 nginx。 + +## 首次配置(一次性) + +```bash +# 1. 本地 DNS(覆盖 Tailscale / Clash fake-ip) +sudo pnpm setup:plaza-dns + +# 2. 本地 HTTPS 证书(可选,供本机 https://plaza.tkmind.cn) +sudo pnpm setup:plaza-local + +# 3. 公网隧道(plaza.tkmind.cn → 本机 :3001) +pnpm setup:plaza-tunnel +``` + +## 日常启动 + +```bash +# 本机正式模式(next build + start,供 Tunnel 回源,推荐公网) +pnpm start:plaza + +# 开发模式(热更新,仅本地调试) +pnpm dev:plaza + +# 另开终端:本机 HTTPS 入口(可选,纯本地 HTTPS 时用) +sudo pnpm dev:plaza-proxy + +# 或全栈(MindSpace + Plaza + Ops) +pnpm dev +``` + +## 访问方式 + +| 场景 | 地址 | +|------|------| +| 本机直连 | http://127.0.0.1:3001/plaza | +| 本机域名(需 proxy) | https://plaza.tkmind.cn/plaza | +| 浏览器强制本地 | `pnpm open:plaza` | +| 公网(需 dev:plaza + 隧道) | https://plaza.tkmind.cn/plaza | + +### 浏览器提示「连接不是私密连接」? + +本机曾用 `setup:plaza-dns` 把域名指到 `127.0.0.1`,HTTPS 走自签证书。若已配置 Cloudflare Tunnel,应 **去掉本地劫持**,走公网证书(仍回源本机): + +```bash +sudo pnpm setup:plaza-dns:remove +``` + +然后刷新 https://plaza.tkmind.cn/ 即可。 + +### 浏览器打不开? + +Chrome / Cursor 内置浏览器默认走 **Secure DNS**,会绕过 `/etc/hosts` 连到 Cloudflare(105 停服时显示 502)。 + +解决: + +1. `pnpm open:plaza`(推荐) +2. 关闭浏览器「安全 DNS / Secure DNS」 +3. `pnpm check:plaza` 查看诊断 + +## 公网 Tunnel + +隧道配置写在 `ollama-tkmind` 的 cloudflared config(默认 `~/Project/ollama/cloudflare/config.yml`)。 + +```bash +pnpm setup:plaza-tunnel # 写入 ingress + DNS 路由 + 重启 cloudflared +``` + +**前提:** `pnpm start:plaza`(或 `pnpm dev:plaza`)已在跑(:3001 可访问)。Mac 休眠或服务停掉时,外网会 502/503。 + +自定义: + +```bash +CLOUDFLARE_TUNNEL_CONFIG=/path/to/config.yml \ +CLOUDFLARE_TUNNEL_NAME=ollama-tkmind \ +PLAZA_TUNNEL_PORT=3001 \ +pnpm setup:plaza-tunnel +``` + +## 环境变量 + +见 `.env.example` 中 Plaza 相关注释。常用: + +| 变量 | 默认 | 说明 | +|------|------|------| +| `PLAZA_PORT` | 3001 | Plaza Next.js 端口 | +| `H5_PORT` | 8081 | Portal / API 端口 | +| `PLAZA_LOCAL_HOST` | plaza.tkmind.cn | 本地域名 | +| `PLAZA_PUBLIC_BASE` | https://plaza.tkmind.cn | 公开 URL 前缀 | + +## 已弃用 + +- `pnpm deploy:105`、`pnpm deploy:plaza-105` — 105 不再作为主服务 +- 105 上 nginx / goose-h5 / goose-plaza-web — 已停服,公网不再回源 105 diff --git a/mindspace-visual-editor.test.mjs b/mindspace-visual-editor.test.mjs new file mode 100644 index 0000000..67b3877 --- /dev/null +++ b/mindspace-visual-editor.test.mjs @@ -0,0 +1,28 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + MINDSPACE_PAGE_CONTENT_MESSAGE, + buildEditablePreviewDocument, +} from './src/utils/mindspaceVisualEditor.ts'; + +test('buildEditablePreviewDocument injects visual editor into body', () => { + const html = '

Hi

'; + const result = buildEditablePreviewDocument(html); + + assert.match(result, /mindspace-visual-editor-style/); + assert.match(result, /mindspace-visual-editor-script/); + assert.match(result, /data-mindspace-editing/); + assert.match(result, /

Hi<\/p>/); + assert.match(result, new RegExp(MINDSPACE_PAGE_CONTENT_MESSAGE.replace(':', '\\:'))); +}); + +test('buildEditablePreviewDocument wraps fragment html', () => { + const result = buildEditablePreviewDocument('

'); + assert.match(result, /
块<\/section>/); + assert.match(result, /<\/body>/); +}); + +test('buildEditablePreviewDocument provides blank fallback', () => { + const result = buildEditablePreviewDocument(''); + assert.match(result, /空白页面/); +}); diff --git a/package.json b/package.json index c5748fa..fe045d9 100644 --- a/package.json +++ b/package.json @@ -6,12 +6,15 @@ "scripts": { "dev": "node scripts/dev.mjs", "dev:plaza": "node scripts/dev-plaza.mjs", + "start:plaza": "node scripts/start-plaza-prod.mjs", "dev:plaza-proxy": "node scripts/plaza-local-proxy.mjs", "setup:plaza-dns": "node scripts/setup-plaza-local-dns.mjs", + "setup:plaza-dns:remove": "node scripts/setup-plaza-local-dns.mjs --remove", "dev:plaza-dns": "node scripts/plaza-local-dns-server.mjs", "open:plaza": "node scripts/open-plaza-local.mjs", "check:plaza": "node scripts/check-plaza-local.mjs", "setup:plaza-local": "node scripts/setup-plaza-local.mjs", + "setup:plaza-tunnel": "bash scripts/install-plaza-tunnel.sh", "setup:plaza-tls": "node scripts/plaza-local-tls.mjs", "dev:vite": "vite", "dev:server": "node server.mjs", @@ -22,7 +25,7 @@ "seed:plaza-covers": "node scripts/seed-plaza-covers.mjs", "enrich:plaza": "node scripts/enrich-plaza-posts.mjs", "build": "vite build", - "test": "node --test auth.test.mjs asr-proxy.test.mjs billing.test.mjs billing-recharge.test.mjs wechat-pay.test.mjs wechat-oauth.test.mjs capabilities.test.mjs policies.test.mjs chat-skills.test.mjs user-publish.test.mjs user-memory-profile.test.mjs skills-registry.test.mjs mindspace.test.mjs mindspace-scan.test.mjs mindspace-assets.test.mjs mindspace-pages.test.mjs mindspace-publications.test.mjs mindspace-chat-save.test.mjs mindspace-chat-context.test.mjs mindspace-content-scan.test.mjs mindspace-html-localize.test.mjs mindspace-cleanup.test.mjs mindspace-thumbnails.test.mjs mindspace-workspace-thumbnails.test.mjs mindspace-workspace-sync.test.mjs mindspace-asset-preview.test.mjs mindspace-agent-jobs.test.mjs mindspace-agent-runner.test.mjs message-stream.test.mjs plaza-posts.test.mjs plaza-interactions.test.mjs plaza-algorithm.test.mjs plaza-seo.test.mjs plaza-ops.test.mjs user-auth.test.mjs llm-providers.test.mjs", + "test": "node --test auth.test.mjs asr-proxy.test.mjs billing.test.mjs billing-recharge.test.mjs wechat-pay.test.mjs wechat-oauth.test.mjs capabilities.test.mjs policies.test.mjs chat-skills.test.mjs user-publish.test.mjs user-memory-profile.test.mjs skills-registry.test.mjs mindspace.test.mjs mindspace-scan.test.mjs mindspace-assets.test.mjs mindspace-pages.test.mjs mindspace-publications.test.mjs mindspace-chat-save.test.mjs mindspace-chat-context.test.mjs mindspace-content-scan.test.mjs mindspace-html-localize.test.mjs mindspace-visual-editor.test.mjs mindspace-cleanup.test.mjs mindspace-thumbnails.test.mjs mindspace-workspace-thumbnails.test.mjs mindspace-workspace-sync.test.mjs mindspace-asset-preview.test.mjs mindspace-agent-jobs.test.mjs mindspace-agent-runner.test.mjs message-stream.test.mjs plaza-posts.test.mjs plaza-interactions.test.mjs plaza-algorithm.test.mjs plaza-seo.test.mjs plaza-ops.test.mjs user-auth.test.mjs llm-providers.test.mjs", "test:mindspace-e2e": "node scripts/mindspace-e2e.mjs", "test:mindspace-pages-e2e": "node scripts/mindspace-pages-e2e.mjs", "test:mindspace-publications-e2e": "node scripts/mindspace-publications-e2e.mjs", diff --git a/plaza-embed.mjs b/plaza-embed.mjs index aea7988..d46cdec 100644 --- a/plaza-embed.mjs +++ b/plaza-embed.mjs @@ -27,7 +27,19 @@ const EMBED_BOOTSTRAP = ``; +const EMBED_LAYOUT_OVERRIDES = + ''; + +/** Remove inline CSP meta tags that block plaza-embed-bootstrap (HTTP CSP header applies instead). */ +export function stripPublicationHtmlCspMeta(html) { + return String(html ?? '').replace( + /]*>/gi, + '', + ); +} + +/** Prepare stored publication HTML for Plaza iframe embed. */ +export function preparePublicationHtmlForEmbed(html) { + let source = stripPublicationHtmlCspMeta(html); + if (!source.includes('id="plaza-embed-overrides"') && /]*>/i.test(source)) { + source = source.replace(/]*)>/i, `${EMBED_LAYOUT_OVERRIDES}`); + } + return injectPlazaEmbedBootstrap(source); +} + export function injectPlazaEmbedBootstrap(html) { const source = String(html ?? ''); if (source.includes('id="plaza-embed-bootstrap"')) return source; diff --git a/plaza-embed.test.mjs b/plaza-embed.test.mjs index acd2eaa..c4507c1 100644 --- a/plaza-embed.test.mjs +++ b/plaza-embed.test.mjs @@ -4,7 +4,9 @@ import { appendPlazaEmbedQuery, injectPlazaEmbedBootstrap, isPlazaEmbedRequest, + preparePublicationHtmlForEmbed, publishedPageCspForEmbed, + stripPublicationHtmlCspMeta, } from './plaza-embed.mjs'; test('isPlazaEmbedRequest detects embed=plaza', () => { @@ -34,3 +36,20 @@ test('injectPlazaEmbedBootstrap adds script before closing body', () => { test('publishedPageCspForEmbed allows inline script', () => { assert.match(publishedPageCspForEmbed(true), /script-src 'unsafe-inline'/); }); + +test('stripPublicationHtmlCspMeta removes inline CSP meta tags', () => { + const html = + ''; + const out = stripPublicationHtmlCspMeta(html); + assert.doesNotMatch(out, /Content-Security-Policy/); +}); + +test('preparePublicationHtmlForEmbed strips CSP and injects bootstrap', () => { + const html = ` + +
Hi
`; + const out = preparePublicationHtmlForEmbed(html); + assert.doesNotMatch(out, /http-equiv="Content-Security-Policy"/); + assert.match(out, /plaza-embed-bootstrap/); + assert.match(out, /plaza-embed-overrides/); +}); diff --git a/scripts/install-plaza-tunnel.sh b/scripts/install-plaza-tunnel.sh new file mode 100755 index 0000000..bddca9b --- /dev/null +++ b/scripts/install-plaza-tunnel.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +# 将 plaza.tkmind.cn 经 Cloudflare Tunnel 指到本机 Plaza(:3001) +# +# 前置: +# 1. cloudflared 已安装且 ollama-tkmind 隧道在跑 +# 2. pnpm dev:plaza 或 next start 监听 :3001 +# +# 用法:pnpm setup:plaza-tunnel +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +CF_CONFIG="${CLOUDFLARE_TUNNEL_CONFIG:-/Users/john/Project/ollama/cloudflare/config.yml}" +TUNNEL_NAME="${CLOUDFLARE_TUNNEL_NAME:-ollama-tkmind}" +PLAZA_HOST="${PLAZA_LOCAL_HOST:-plaza.tkmind.cn}" +PLAZA_PORT="${PLAZA_TUNNEL_PORT:-${PLAZA_PORT:-3001}}" +GUI="gui/$(id -u)" + +chmod +x "${ROOT}/scripts/patch-plaza-tunnel-config.mjs" + +echo "==> 更新 cloudflared ingress(${PLAZA_HOST} → 127.0.0.1:${PLAZA_PORT})" +CLOUDFLARE_TUNNEL_CONFIG="${CF_CONFIG}" PLAZA_TUNNEL_PORT="${PLAZA_PORT}" \ + node "${ROOT}/scripts/patch-plaza-tunnel-config.mjs" + +echo "==> 注册 DNS 路由(${PLAZA_HOST} → 隧道 ${TUNNEL_NAME})" +if cloudflared tunnel route dns list 2>/dev/null | grep -q "${PLAZA_HOST}"; then + echo "DNS 路由已存在,跳过" +else + cloudflared tunnel route dns "${TUNNEL_NAME}" "${PLAZA_HOST}" || { + echo "⚠ DNS 路由失败(可能已在 Cloudflare 控制台手动配置)。继续重启 cloudflared…" >&2 + } +fi + +echo "==> 重启 cloudflared" +launchctl kickstart -k "${GUI}/com.cloudflare.cloudflared" 2>/dev/null || { + echo "⚠ 未找到 LaunchAgent com.cloudflare.cloudflared,请手动:cloudflared tunnel --config ${CF_CONFIG} run" >&2 +} + +sleep 2 +echo "" +echo "=== Plaza 隧道状态 ===" +curl -s -o /dev/null -w "本机 Plaza :${PLAZA_PORT}/plaza → %{http_code}\n" \ + "http://127.0.0.1:${PLAZA_PORT}/plaza" 2>/dev/null || echo "本机 Plaza 未运行(请先 pnpm dev:plaza)" +curl -s -o /dev/null -w "外网 ${PLAZA_HOST}/plaza → %{http_code}\n" \ + "https://${PLAZA_HOST}/plaza" 2>/dev/null || true +echo "" +echo "日志: ~/Library/Logs/com.cloudflare.cloudflared.log(或 launchctl 配置的路径)" +echo "配置: ${CF_CONFIG}" diff --git a/scripts/patch-plaza-tunnel-config.mjs b/scripts/patch-plaza-tunnel-config.mjs new file mode 100755 index 0000000..01c5e74 --- /dev/null +++ b/scripts/patch-plaza-tunnel-config.mjs @@ -0,0 +1,60 @@ +#!/usr/bin/env node +/** + * Add plaza.tkmind.cn ingress to cloudflared config (before catch-all 404). + * + * Usage: + * node scripts/patch-plaza-tunnel-config.mjs + * CLOUDFLARE_TUNNEL_CONFIG=/path/to/config.yml PLAZA_TUNNEL_PORT=3001 node ... + */ +import fs from 'node:fs'; + +const configPath = + process.env.CLOUDFLARE_TUNNEL_CONFIG ?? + '/Users/john/Project/ollama/cloudflare/config.yml'; +const plazaPort = Number(process.env.PLAZA_TUNNEL_PORT ?? process.env.PLAZA_PORT ?? 3001); +const plazaHost = process.env.PLAZA_LOCAL_HOST ?? 'plaza.tkmind.cn'; + +if (!fs.existsSync(configPath)) { + console.error(`错误: 未找到 cloudflared 配置:${configPath}`); + console.error('请设置 CLOUDFLARE_TUNNEL_CONFIG'); + process.exit(1); +} + +let content = fs.readFileSync(configPath, 'utf8'); + +if (content.includes(`hostname: ${plazaHost}`)) { + const current = content.match( + new RegExp(`hostname: ${plazaHost.replace('.', '\\.')}[\\s\\S]*?service: (http://[^\\n]+)`), + ); + const target = `http://127.0.0.1:${plazaPort}`; + if (current?.[1] === target) { + console.log(`cloudflared 已配置 ${plazaHost} → ${target}`); + process.exit(0); + } + content = content.replace( + new RegExp( + `(hostname: ${plazaHost.replace('.', '\\.')}[\\s\\S]*?service: )http://127\\.0\\.0\\.1:\\d+`, + ), + `$1${target}`, + ); + fs.writeFileSync(configPath, content); + console.log(`已更新 ${plazaHost} → ${target}`); + process.exit(0); +} + +const block = ` - hostname: ${plazaHost} + service: http://127.0.0.1:${plazaPort} + originRequest: + connectTimeout: 30s + keepAliveTimeout: 90s + disableChunkedEncoding: false +`; + +if (!content.includes('- service: http_status:404')) { + console.error('错误: 配置中未找到 catch-all http_status:404 规则'); + process.exit(1); +} + +content = content.replace(/\n - service: http_status:404/, `\n${block}\n - service: http_status:404`); +fs.writeFileSync(configPath, content); +console.log(`已添加 ${plazaHost} → http://127.0.0.1:${plazaPort}`); diff --git a/scripts/plaza-rich-content.mjs b/scripts/plaza-rich-content.mjs index 3393651..897096e 100644 --- a/scripts/plaza-rich-content.mjs +++ b/scripts/plaza-rich-content.mjs @@ -183,7 +183,7 @@ export function buildJohnPage(opts) { text-decoration: none; box-shadow: 0 8px 28px ${accent}44; } - .section { max-width: 1100px; margin: 0 auto; padding: 72px 24px; content-visibility: auto; contain-intrinsic-size: auto 360px; } + .section { max-width: 1100px; margin: 0 auto; padding: 72px 24px; } .section h2 { font-size: 26px; font-weight: 700; margin-bottom: 8px; } .section .hint { color: rgba(255,255,255,0.38); font-size: 15px; margin-bottom: 36px; } .grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 20px; } diff --git a/scripts/start-plaza-prod.mjs b/scripts/start-plaza-prod.mjs new file mode 100644 index 0000000..eacf11e --- /dev/null +++ b/scripts/start-plaza-prod.mjs @@ -0,0 +1,169 @@ +#!/usr/bin/env node +/** + * Plaza 本机正式模式:Portal API + Next.js build/start(供 Cloudflare Tunnel 回源)。 + * + * Usage: + * pnpm start:plaza + * pnpm start:plaza -- --skip-build # 跳过 next build(已有 .next 产物时) + */ +import { spawn, execSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..'); +const plazaDir = process.env.PLAZA_APP_DIR ?? path.join(root, '../tkmind_go/ui/plaza'); +const portalPort = Number(process.env.H5_PORT ?? 8081); +const plazaPort = Number(process.env.PLAZA_PORT ?? 3001); +const plazaHost = process.env.PLAZA_LOCAL_HOST ?? 'plaza.tkmind.cn'; +const plazaPublicBase = ( + process.env.PLAZA_PUBLIC_BASE ?? 'https://plaza.tkmind.cn' +).replace(/\/$/, ''); +const portalUrl = `http://127.0.0.1:${portalPort}`; +const skipBuild = process.argv.includes('--skip-build'); + +function loadEnvFile(filePath) { + if (!fs.existsSync(filePath)) return; + for (const line of fs.readFileSync(filePath, 'utf8').split('\n')) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + const eq = trimmed.indexOf('='); + if (eq < 0) continue; + const key = trimmed.slice(0, eq).trim(); + const value = trimmed.slice(eq + 1).trim(); + if (!process.env[key]) process.env[key] = value; + } +} + +loadEnvFile(path.join(root, '../../.env.local')); +loadEnvFile(path.join(root, '.env')); + +function freePort(port) { + try { + execSync(`lsof -ti TCP:${port} -sTCP:LISTEN | xargs kill -9`, { stdio: 'ignore' }); + } catch { + // already free + } +} + +async function portOpen(port) { + try { + const res = await fetch(`http://127.0.0.1:${port}/auth/status`); + return res.ok; + } catch { + return false; + } +} + +async function waitFor(check, label, retries = 120) { + for (let i = 0; i < retries; i += 1) { + try { + if (await check()) return; + } catch { + // still starting + } + await new Promise((resolve) => setTimeout(resolve, 500)); + } + throw new Error(`${label} 启动超时`); +} + +let portal; +let plaza; +let stopping = false; + +function spawnChild(command, args, label, cwd = root, extraEnv = {}) { + const child = spawn(command, args, { + cwd, + env: { ...process.env, NODE_ENV: 'production', ...extraEnv }, + stdio: 'inherit', + }); + child.on('exit', (code, signal) => { + if (signal || stopping) return; + if (code && code !== 0) { + console.error(`[${label}] exited with code ${code}`); + shutdown(code ?? 1); + } + }); + return child; +} + +function shutdown(code = 0) { + if (stopping) return; + stopping = true; + portal?.kill('SIGTERM'); + plaza?.kill('SIGTERM'); + setTimeout(() => process.exit(code), 300); +} + +process.on('SIGINT', () => shutdown(0)); +process.on('SIGTERM', () => shutdown(0)); + +if (!fs.existsSync(path.join(plazaDir, 'package.json'))) { + console.error(`未找到 Plaza Next.js:${plazaDir}`); + process.exit(1); +} + +const mindSpacePublicBase = ( + process.env.H5_PUBLIC_BASE_URL ?? + process.env.VITE_MINDSPACE_BASE ?? + 'https://g2.tkmind.cn' +).replace(/\/$/, ''); + +const plazaEnv = { + PLAZA_API_PROXY: portalUrl, + PLAZA_API_BASE: portalUrl, + PLAZA_PUBLIC_BASE: plazaPublicBase, + NEXT_PUBLIC_API_BASE: plazaPublicBase, + NEXT_PUBLIC_SITE_BASE: plazaPublicBase, + NEXT_PUBLIC_MINDSPACE_BASE: mindSpacePublicBase, + NEXT_PUBLIC_PLAZA_BASE: '/plaza', +}; + +console.log('==> Plaza 本机正式模式(next build + start)'); +console.log(` 公开地址 ${plazaPublicBase}/plaza`); +console.log(` 内部端口 ${plazaPort}`); + +freePort(plazaPort); + +try { + if (!(await portOpen(portalPort))) { + console.log(`==> 启动 Portal @ ${portalUrl}`); + portal = spawnChild('node', ['server.mjs'], 'portal'); + await waitFor(() => portOpen(portalPort), 'Portal'); + } else { + console.log(`==> Portal 已在运行: ${portalUrl}`); + } + + if (!skipBuild) { + console.log('==> 构建 Plaza Next.js(生产)…'); + execSync('npm run build', { cwd: plazaDir, stdio: 'inherit', env: { ...process.env, ...plazaEnv } }); + } else { + console.log('==> 跳过 next build(--skip-build)'); + } + + console.log(`==> 启动 Plaza Next.js 正式服务 (0.0.0.0:${plazaPort})`); + plaza = spawnChild( + 'npm', + ['run', 'start', '--', '-H', '0.0.0.0', '-p', String(plazaPort)], + 'plaza', + plazaDir, + plazaEnv, + ); + + await waitFor(async () => { + const res = await fetch(`http://127.0.0.1:${plazaPort}/plaza`, { + headers: { Host: plazaHost }, + }); + return res.ok || res.status === 307 || res.status === 308; + }, 'Plaza Next.js'); + + console.log(''); + console.log('Plaza 正式模式已就绪:'); + console.log(` 本机 http://127.0.0.1:${plazaPort}/plaza`); + console.log(` 公网 ${plazaPublicBase}/plaza (需 Cloudflare Tunnel 回源 :${plazaPort})`); + console.log(''); + console.log('隧道未配置或外网 502 时:pnpm setup:plaza-tunnel'); +} catch (err) { + console.error(err instanceof Error ? err.message : err); + shutdown(1); +} diff --git a/server.mjs b/server.mjs index e6c8c75..1fbb9cd 100644 --- a/server.mjs +++ b/server.mjs @@ -46,7 +46,7 @@ import { startPlazaTasks, writebackPublications } from './plaza-tasks.mjs'; import { createPlazaSeoService } from './plaza-seo.mjs'; import { createPlazaOpsService, hasOpsRole } from './plaza-ops.mjs'; import { - injectPlazaEmbedBootstrap, + preparePublicationHtmlForEmbed, isPlazaEmbedRequest, publishedPageCspForEmbed, } from './plaza-embed.mjs'; @@ -3213,7 +3213,7 @@ function publishedPageCsp(html, { embed = false } = {}) { function sendPublishedPage(res, result, { embed = false } = {}) { let html = result.html; if (embed) { - html = injectPlazaEmbedBootstrap(html); + html = preparePublicationHtmlForEmbed(html); } res.set('Content-Type', 'text/html; charset=utf-8'); res.set('Content-Security-Policy', publishedPageCsp(html, { embed })); diff --git a/src/components/MindSpacePageDetail.tsx b/src/components/MindSpacePageDetail.tsx index dd093bb..64cd0a6 100644 --- a/src/components/MindSpacePageDetail.tsx +++ b/src/components/MindSpacePageDetail.tsx @@ -31,7 +31,6 @@ import { } from './MindSpacePageFullscreenPreview'; import { MindSpacePagePreviewPanel } from './MindSpacePagePreviewPanel'; import { MindSpaceModal } from './MindSpaceModal'; -import { mergeMindSpacePagePatch, type MindSpacePagePatch } from '../utils/mindspacePagePatch'; type AccessMode = MindSpacePublishCheck['accessMode']; @@ -97,8 +96,6 @@ export function MindSpacePageDetail({ onContextUpdate, autoOpenPlaza = false, refreshTrigger = 0, - incomingPagePatch = null, - onPagePatchHandled, overlayChat = null, onFullscreenPreviewChange, }: { @@ -113,8 +110,6 @@ export function MindSpacePageDetail({ }) => void; autoOpenPlaza?: boolean; refreshTrigger?: number; - incomingPagePatch?: { patch: MindSpacePagePatch; sourceMessageId: string } | null; - onPagePatchHandled?: (sourceMessageId: string) => void; overlayChat?: MindSpacePageFullscreenPreviewChat | null; onFullscreenPreviewChange?: (open: boolean) => void; }) { @@ -134,6 +129,8 @@ export function MindSpacePageDetail({ const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [previewKey, setPreviewKey] = useState(0); + const [previewContent, setPreviewContent] = useState(''); + const [previewRefreshPending, setPreviewRefreshPending] = useState(false); const [fullscreenPreviewOpen, setFullscreenPreviewOpen] = useState(false); const [changeHighlight, setChangeHighlight] = useState( null, @@ -162,8 +159,6 @@ export function MindSpacePageDetail({ const [plazaPost, setPlazaPost] = useState(null); const [pushToPlaza, setPushToPlaza] = useState(false); const [fixNotice, setFixNotice] = useState(null); - const [patchNotice, setPatchNotice] = useState(null); - const [patchBusy, setPatchBusy] = useState(false); const applyPageRecord = useCallback( (next: MindSpacePage, options?: { recordHistory?: boolean }) => { @@ -192,6 +187,9 @@ export function MindSpacePageDetail({ summary: next.summary, content: next.content ?? '', }); + setPreviewContent(next.content ?? ''); + setPreviewRefreshPending(false); + setPreviewKey((value) => value + 1); setTemplateId(next.templateId); setUrlSlug(next.publication?.urlSlug ?? slugFromTitle(next.title, next.id)); setAccessMode(next.publication?.accessMode ?? 'public'); @@ -419,6 +417,34 @@ export function MindSpacePageDetail({ }); }, []); + const markPreviewRefreshPending = useCallback(() => { + setPreviewRefreshPending(true); + }, []); + + const handleManualPreviewRefresh = useCallback((nextContent?: string) => { + const html = nextContent ?? content; + setPreviewContent(html); + setPreviewRefreshPending(false); + setPreviewKey((value) => value + 1); + }, [content]); + + const syncPreviewAfterHistory = useCallback( + (snapshot: PageDraftSnapshot) => { + if (previewRefreshPending) return; + setPreviewContent(snapshot.content); + setPreviewKey((value) => value + 1); + }, + [previewRefreshPending], + ); + + const handlePreviewContentChange = useCallback( + (html: string) => { + setPreviewContent(html); + replaceDraft({ ...draft, content: html }, { recordHistory: true }); + }, + [draft, replaceDraft], + ); + const refreshFromServer = useCallback(async () => { const currentPage = page; if (!currentPage) return; @@ -438,52 +464,12 @@ export function MindSpacePageDetail({ } applyPageRecord(next, { recordHistory: true }); if (draftChanged) { - setPreviewKey((value) => value + 1); + markPreviewRefreshPending(); } } catch (err) { setError(err instanceof Error ? err.message : '页面刷新失败'); } - }, [applyPageRecord, content, page, pageId, summary, title, triggerChangeHighlight]); - - const applyIncomingPatch = useCallback( - async (patch: MindSpacePagePatch) => { - if (!page) return; - const merged = mergeMindSpacePagePatch({ title, summary, content }, patch); - triggerChangeHighlight({ title, summary, content }); - setPatchBusy(true); - setPatchNotice(null); - setError(null); - try { - const updated = await updateMindSpacePage(page.id, { - expectedVersion: page.versionNo, - title: merged.title, - summary: merged.summary, - content: merged.content, - templateId, - changeNote: 'Agent 对话修改', - }); - applyPageRecord(updated, { recordHistory: true }); - setPreviewKey((value) => value + 1); - setPatchNotice('Agent 修改已同步到预览'); - } catch (err) { - applyPageRecord( - { - ...page, - title: merged.title, - summary: merged.summary, - content: merged.content, - }, - { recordHistory: true }, - ); - setPreviewKey((value) => value + 1); - setPatchNotice('预览已更新(保存到服务器失败,请稍后手动保存)'); - setError(err instanceof Error ? err.message : '保存 Agent 修改失败'); - } finally { - setPatchBusy(false); - } - }, - [applyPageRecord, content, page, summary, templateId, title, triggerChangeHighlight], - ); + }, [applyPageRecord, content, markPreviewRefreshPending, page, pageId, summary, title, triggerChangeHighlight]); const applyOneClickFix = async () => { if (!page) return; @@ -519,24 +505,6 @@ export function MindSpacePageDetail({ void refreshFromServer(); }, [refreshTrigger, refreshFromServer]); - useEffect(() => { - if (!incomingPagePatch || !page || patchBusy) return; - let cancelled = false; - void applyIncomingPatch(incomingPagePatch.patch).finally(() => { - if (!cancelled) onPagePatchHandled?.(incomingPagePatch.sourceMessageId); - }); - return () => { - cancelled = true; - }; - }, [ - incomingPagePatch?.sourceMessageId, - incomingPagePatch?.patch, - page?.id, - patchBusy, - applyIncomingPatch, - onPagePatchHandled, - ]); - useEffect(() => { if (autoOpenPlaza && page?.publication && !plazaOpen) { void openPlazaPanel(); @@ -556,16 +524,24 @@ export function MindSpacePageDetail({ return; } if (event.key.toLowerCase() === 'z' && event.shiftKey) { - if (redo()) event.preventDefault(); + const next = redo(); + if (next) { + syncPreviewAfterHistory(next); + event.preventDefault(); + } return; } if (event.key.toLowerCase() === 'z') { - if (undo()) event.preventDefault(); + const previous = undo(); + if (previous) { + syncPreviewAfterHistory(previous); + event.preventDefault(); + } } }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); - }, [redo, undo]); + }, [redo, syncPreviewAfterHistory, undo]); useEffect(() => { if (!page || !onContextUpdate) return; @@ -581,17 +557,23 @@ export function MindSpacePageDetail({ const closeFullscreenPreview = () => { setFullscreenPreviewOpen(false); onFullscreenPreviewChange?.(false); + setPreviewKey((value) => value + 1); + setPreviewRefreshPending(false); }; const handlePreviewUndo = () => { - if (!undo()) return false; - setPreviewKey((value) => value + 1); + const previous = undo(); + if (!previous) return false; + if (previewRefreshPending) return true; + handleManualPreviewRefresh(previous.content); return true; }; const handlePreviewRedo = () => { - if (!redo()) return false; - setPreviewKey((value) => value + 1); + const next = redo(); + if (!next) return false; + if (previewRefreshPending) return true; + handleManualPreviewRefresh(next.content); return true; }; @@ -649,7 +631,6 @@ export function MindSpacePageDetail({ {error && !deletePreviewOpen &&
{error}
} - {patchNotice ?
{patchNotice}
: null}
- {page.contentFormat === 'html' ? '页面预览' : '安全预览'} + {page.contentFormat === 'html' ? '页面预览编辑' : '安全预览'}
- {patchBusy ? 同步 Agent 修改… : null} - - -
@@ -1099,11 +1090,9 @@ export function MindSpacePageDetail({ pageId={page.id} title={title} summary={summary} - content={content} + content={previewContent} reloadKey={previewKey} - onContentChange={(html) => - replaceDraft({ ...draft, content: html }, { recordHistory: true }) - } + onContentChange={handlePreviewContentChange} /> ) : ( )} {page.contentFormat === 'html' && ( @@ -1123,7 +1113,13 @@ export function MindSpacePageDetail({ className="mindspace-page-content-input" value={content} rows={22} - onChange={(event) => updateDraft({ ...draft, content: event.target.value })} + onChange={(event) => { + const nextContent = event.target.value; + updateDraft({ ...draft, content: nextContent }); + if (!previewRefreshPending) { + setPreviewContent(nextContent); + } + }} /> )} @@ -1164,26 +1160,19 @@ export function MindSpacePageDetail({ pageTitle={title || page.title} title={title} summary={summary} - content={content} + content={previewContent} templateId={templateId} reloadKey={previewKey} - patchBusy={patchBusy} changeHighlight={changeHighlight} canUndo={canUndo} canRedo={canRedo} onUndo={handlePreviewUndo} onRedo={handlePreviewRedo} onClose={closeFullscreenPreview} - onRefresh={() => setPreviewKey((value) => value + 1)} - onAgentIdleSync={() => void refreshFromServer()} - chat={ - overlayChat - ? { - ...overlayChat, - onPagePatch: (patch) => void applyIncomingPatch(patch), - } - : null - } + onRefresh={() => handleManualPreviewRefresh()} + refreshPending={previewRefreshPending} + onContentChange={handlePreviewContentChange} + chat={overlayChat} /> ) : null} diff --git a/src/components/MindSpacePageDraftPreviewFrame.tsx b/src/components/MindSpacePageDraftPreviewFrame.tsx index c613c5a..37dbccd 100644 --- a/src/components/MindSpacePageDraftPreviewFrame.tsx +++ b/src/components/MindSpacePageDraftPreviewFrame.tsx @@ -1,5 +1,6 @@ import { useEffect, useRef, useState } from 'react'; import { fetchMindSpacePageDraftPreview } from '../api/client'; +import { bindIframeDocumentHeightSync } from '../utils/iframeContentHeight'; import { applyPageChangeHighlight } from '../utils/mindspacePageChangeHighlight'; export type MindSpacePreviewChangeHighlight = { @@ -15,8 +16,9 @@ export function MindSpacePageDraftPreviewFrame({ content, templateId, reloadKey = 0, + liveUpdate = true, minHeight = 680, - maxHeight = 4800, + maxHeight = 12000, className = 'mindspace-page-preview-frame', changeHighlight = null, }: { @@ -26,6 +28,7 @@ export function MindSpacePageDraftPreviewFrame({ content: string; templateId: string; reloadKey?: number; + liveUpdate?: boolean; minHeight?: number; maxHeight?: number; className?: string; @@ -62,7 +65,9 @@ export function MindSpacePageDraftPreviewFrame({ }, hasRenderedRef.current ? 450 : 120); return () => window.clearTimeout(timer); - }, [pageId, title, summary, content, templateId, reloadKey]); + }, liveUpdate + ? [pageId, title, summary, content, templateId, reloadKey] + : [pageId, reloadKey, templateId]); useEffect(() => { setHeight(minHeight); @@ -72,27 +77,16 @@ export function MindSpacePageDraftPreviewFrame({ const iframe = iframeRef.current; if (!iframe || !srcdoc) return; - const syncHeight = () => { - try { - const doc = iframe.contentDocument; - if (!doc) return; - const body = doc.body; - const root = doc.documentElement; - const next = Math.ceil( - Math.max( - body?.scrollHeight ?? 0, - body?.offsetHeight ?? 0, - root?.scrollHeight ?? 0, - root?.offsetHeight ?? 0, - minHeight, - ), - ); - setHeight(Math.min(next, maxHeight)); + const applyHeight = (nextHeight: number) => { + setHeight(nextHeight); - if ( - changeHighlight && - appliedHighlightRevisionRef.current !== changeHighlight.revision - ) { + if ( + changeHighlight && + appliedHighlightRevisionRef.current !== changeHighlight.revision + ) { + try { + const doc = iframe.contentDocument; + if (!doc) return; appliedHighlightRevisionRef.current = changeHighlight.revision; applyPageChangeHighlight(doc, { previousTitle: changeHighlight.previousTitle, @@ -100,14 +94,17 @@ export function MindSpacePageDraftPreviewFrame({ previousContent: changeHighlight.previousContent, nextContent: content, }); + } catch { + // ignore highlight errors in sandboxed preview } - } catch { - setHeight(minHeight); } }; - iframe.addEventListener('load', syncHeight); - return () => iframe.removeEventListener('load', syncHeight); + return bindIframeDocumentHeightSync(iframe, { + minHeight, + maxHeight, + onHeight: applyHeight, + }); }, [changeHighlight, content, maxHeight, minHeight, srcdoc, title]); if (error) { diff --git a/src/components/MindSpacePageEditablePreviewFrame.tsx b/src/components/MindSpacePageEditablePreviewFrame.tsx new file mode 100644 index 0000000..9051ee3 --- /dev/null +++ b/src/components/MindSpacePageEditablePreviewFrame.tsx @@ -0,0 +1,113 @@ +import { useEffect, useRef, useState } from 'react'; +import { bindIframeDocumentHeightSync, measureDocumentHeight } from '../utils/iframeContentHeight'; +import { + buildEditablePreviewDocument, + MINDSPACE_PAGE_CONTENT_MESSAGE, +} from '../utils/mindspaceVisualEditor'; + +export function MindSpacePageEditablePreviewFrame({ + title, + content, + reloadKey, + minHeight = 680, + maxHeight = 12000, + className = 'mindspace-page-preview-frame', + showHint = true, + onContentChange, +}: { + title: string; + content: string; + reloadKey: number; + minHeight?: number; + maxHeight?: number; + className?: string; + showHint?: boolean; + onContentChange: (html: string) => void; +}) { + const [previewFailed, setPreviewFailed] = useState(false); + const [srcdoc, setSrcdoc] = useState(() => buildEditablePreviewDocument(content)); + const [height, setHeight] = useState(minHeight); + const iframeContentRef = useRef(content); + const iframeRef = useRef(null); + const syncHeightRef = useRef<(() => void) | null>(null); + + useEffect(() => { + iframeContentRef.current = content; + setSrcdoc(buildEditablePreviewDocument(content)); + setPreviewFailed(false); + setHeight(minHeight); + }, [reloadKey]); + + useEffect(() => { + if (content === iframeContentRef.current) return; + iframeContentRef.current = content; + setSrcdoc(buildEditablePreviewDocument(content)); + setPreviewFailed(false); + setHeight(minHeight); + }, [content, minHeight]); + + useEffect(() => { + const onMessage = (event: MessageEvent) => { + if (event.source !== iframeRef.current?.contentWindow) return; + if (event.data?.type !== MINDSPACE_PAGE_CONTENT_MESSAGE) return; + const html = String(event.data.html ?? ''); + if (!html.trim()) return; + iframeContentRef.current = html; + onContentChange(html); + syncHeightRef.current?.(); + }; + + window.addEventListener('message', onMessage); + return () => window.removeEventListener('message', onMessage); + }, [onContentChange]); + + useEffect(() => { + const iframe = iframeRef.current; + if (!iframe || !srcdoc) return; + + const applyHeight = (nextHeight: number) => setHeight(nextHeight); + const unbind = bindIframeDocumentHeightSync(iframe, { + minHeight, + maxHeight, + onHeight: applyHeight, + }); + syncHeightRef.current = () => { + try { + const doc = iframe.contentDocument; + if (!doc) return; + applyHeight(Math.min(measureDocumentHeight(doc, minHeight), maxHeight)); + } catch { + applyHeight(minHeight); + } + }; + + return () => { + syncHeightRef.current = null; + unbind(); + }; + }, [maxHeight, minHeight, srcdoc]); + + if (previewFailed) { + return
页面预览加载失败
; + } + + return ( + <> + {showHint ? ( +

+ 文字直接改 · 图片/音视频点击换 · 背景点空白区 · 链接双击改地址 +

+ ) : null} +