diff --git a/.env.example b/.env.example index 9c6f52b..435f18d 100644 --- a/.env.example +++ b/.env.example @@ -167,10 +167,11 @@ VITE_TKMIND_WORKING_DIR=/Users/john/PycharmProjects/tkmind # 历史列表每次展示/加载条数(默认 25,与 desktop 一致) # VITE_SESSION_PAGE_SIZE=25 -# Plaza 发现广场(本机为主,105 已停用) -# PLAZA_AUTO_APPROVE=true +# Plaza 发现广场(独立域名 plaza.tkmind.cn;Portal 前端链接见 VITE_PLAZA_BASE) +# PLAZA_PUBLIC_BASE=https://plaza.tkmind.cn +# 本地 dev 脚本会自动注入 VITE_PLAZA_BASE=http://127.0.0.1:3001;勿把该值留进生产构建用的 .env # VITE_PLAZA_BASE=http://127.0.0.1:3001 -# 文档:docs/plaza-local.md | 本地:http://127.0.0.1:3001/plaza +# 文档:docs/plaza-local.md | 本地:http://127.0.0.1:3001/plaza | 生产:https://plaza.tkmind.cn/plaza # MindSpace(默认启用;子功能需按需开启) # MINDSPACE_ENABLED=false diff --git a/AGENTS.md b/AGENTS.md index c319690..1a4a92c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,6 +24,7 @@ bash scripts/check-release-ready.sh | 主题 | 文档 | |------|------| | MindSpace 公开页 `edit_file` 落盘 + 聊天 Finish 不丢消息 | [docs/regression-guards/mindspace-publish-and-chat-finish.md](docs/regression-guards/mindspace-publish-and-chat-finish.md) | +| MindSpace remote 页面 sync + storage 缺失缩略图 fallback | [docs/regression-guards/mindspace-remote-page-sync-and-thumbnail.md](docs/regression-guards/mindspace-remote-page-sync-and-thumbnail.md) | 索引:[docs/regression-guards/README.md](docs/regression-guards/README.md) @@ -32,6 +33,7 @@ bash scripts/check-release-ready.sh ```bash npm run verify:mindspace-publish-guards npm run verify:mindspace-publish-guards:full +npm run verify:mindspace-page-sync-guards ``` 发版脚本(`scripts/release-portal-runtime-prod.sh`)在未 `--skip-tests` 时也会执行相关 verify。 @@ -43,6 +45,8 @@ npm run verify:mindspace-publish-guards:full - `conversation-display.mjs` + `src/utils/message.ts` - 用户消息不得展示 routing/skill 内部前缀 - `src/hooks/useTKMindChat.ts` - `syncSessionMessages` merge + 重试 - `server.mjs` - session snapshot 需 `hint_mc` 且 `hint_ua` 才走缓存 +- `mindspace-pages.mjs` - storage 缺失时 HTML 页回退读 workspace `relative_path` +- `mindspace-page-sync-service.mjs` + `server.mjs` - remote 模式也必须 sync public HTML 代码内搜索 `REGRESSION GUARD` 可定位所有内联说明。 @@ -52,6 +56,11 @@ npm run verify:mindspace-publish-guards:full - `chat-finish-sync.test.mjs` - `conversation-display.test.mjs` +### MindSpace page sync + thumbnail + +- `mindspace-page-sync-service.test.mjs` +- `mindspace-pages.test.mjs` + ## Cursor 专用规则(其它工具请读上文文档) Cursor 额外加载:`.cursor/rules/mindspace-publish-chat-finish-guards.mdc` diff --git a/docs/plaza-local.md b/docs/plaza-local.md index 57e81e8..fd2002e 100644 --- a/docs/plaza-local.md +++ b/docs/plaza-local.md @@ -104,6 +104,30 @@ pnpm setup:plaza-tunnel | `H5_PORT` | 8081 | Portal / API 端口 | | `PLAZA_LOCAL_HOST` | plaza.tkmind.cn | 本地域名 | | `PLAZA_PUBLIC_BASE` | https://plaza.tkmind.cn | 公开 URL 前缀 | +| `VITE_PLAZA_BASE` | (见下) | Portal 前端「逛 Plaza」链接;**编译期**写入 dist | + +### Portal「逛 Plaza」链接(MindSpace / 发布成功页) + +Plaza 是独立域名 `plaza.tkmind.cn`,与 Portal `m.tkmind.cn` 不同。前端通过 `src/utils/publicUrl.ts` → `public-site-bases.mjs` 解析: + +| 环境 | 期望链接 | +|------|----------| +| 本机 `127.0.0.1` / `localhost` | `http://127.0.0.1:3001/plaza` | +| 生产 `m.tkmind.cn` | `https://plaza.tkmind.cn/plaza` | + +**为何生产曾出现 127?** `VITE_*` 在 `vite build` 时写入 bundle。若本机 `.env` 含 `VITE_PLAZA_BASE=http://127.0.0.1:3001`,未覆盖就直接打生产包,会把 127 带进 `m.tkmind.cn`。 + +**彻底修复(三层):** + +1. **运行时守卫**:在 `m.tkmind.cn` 等公网 host 上,即使 bundle 内是 loopback,也强制用 `https://plaza.tkmind.cn`(见 `public-site-bases.mjs`)。 +2. **构建默认值**:`scripts/build-portal-runtime.mjs` 在未显式设置时注入 `VITE_PLAZA_BASE=https://plaza.tkmind.cn`。 +3. **勿在生产构建用本地 .env**:发 Portal runtime 前确认 `npm run build` / `build-portal-runtime.mjs` 未读取带 127 的 `VITE_PLAZA_BASE`。 + +验证: + +```bash +node --test public-site-bases.test.mjs +``` ## 已弃用 diff --git a/docs/regression-guards/README.md b/docs/regression-guards/README.md index 2be27e2..5f156eb 100644 --- a/docs/regression-guards/README.md +++ b/docs/regression-guards/README.md @@ -7,6 +7,7 @@ | 文档 | 保护内容 | |------|----------| | [mindspace-publish-and-chat-finish.md](./mindspace-publish-and-chat-finish.md) | ① `edit_file` 覆盖 `public/*.html` ② Finish 后聊天不清空、不暴露 agent 内部前缀 | +| [mindspace-remote-page-sync-and-thumbnail.md](./mindspace-remote-page-sync-and-thumbnail.md) | ① remote 模式 public HTML 入库 sync ② storage 缺失时缩略图/读页回退 workspace HTML | ## 自动化 diff --git a/docs/regression-guards/mindspace-remote-page-sync-and-thumbnail.md b/docs/regression-guards/mindspace-remote-page-sync-and-thumbnail.md new file mode 100644 index 0000000..2320b81 --- /dev/null +++ b/docs/regression-guards/mindspace-remote-page-sync-and-thumbnail.md @@ -0,0 +1,100 @@ +# MindSpace Remote 页面同步与缩略图回归守卫 + +日期:2026-07-05 + +## 事故症状 + +1. **缩略图 500**:`GET /api/mindspace/v1/pages/:pageId/thumbnail` 返回 500;日志/storage 为 `ENOENT`,但 `MindSpace//public/*.html` 仍存在。 +2. **页面列表缺新生成页**:103 生产 `MINDSPACE_SERVER_ADAPTER=remote` 时,Agent 新生成的 `public/*.html` 不出现在「最近页面 / 全部页面」。 +3. **误判**:容易把缺页当成「MindSpace 路径解析错误」或「改回 local adapter 就能修缩略图」——两者根因不同。 + +## 根因 + +### 缩略图 500 + +- `h5_page_records` / `h5_asset_versions.storage_key` 指向 `data/mindspace/users/.../*.md`。 +- 磁盘上该 storage 文件可能已丢失或未写入(历史 sync、清理、quota、迁移)。 +- 页面 snapshot 仍保留 `relative_path: public/index.html`,工作区 HTML 是真实内容来源。 +- `loadPageThumbnailContext` / `getPage` 若**只读 storage**,会在 remote 8082 上直接抛错 → Portal 返回 500。 + +### Remote 下缺页 + +- Portal `GET /mindspace/v1/pages` 会调用 `syncUserGeneratedPages()`,将 `public/*.html` 入库。 +- 曾存在 `if (adapterKind === 'remote') return;`,导致 **8082 独立部署后 sync 永不执行**。 +- 测试环境多为 `local`,sync 正常 → 生产/测试行为不一致。 + +## 必须保留的行为 + +### 1. Workspace HTML fallback(读内容) + +在 `mindspace-pages.mjs` 中,读取页面 content / 缩略图 HTML 时: + +1. 优先读 `storage_key` 对应文件; +2. 若 `ENOENT` 且 `page_type === 'html'` 且 snapshot 有 `relative_path` → 回退读 `resolvePublishDir(h5Root, userId)` 下对应 HTML; +3. `getPage` 与 `renderThumbnail` **共用** `readPageContentWithWorkspaceFallback`,禁止只修一处。 + +### 2. Remote 也执行 page sync + +1. 抽出 `mindspace-page-sync-service.mjs` → `pageSyncService.syncUserGeneratedPages(userId)`; +2. 写入 `mindspace-server-adapter-contract.mjs`:`pageSyncService` binding; +3. **Local**:8082 / Portal 内直接跑 `syncGeneratedPagesFromPublicAssets`; +4. **Remote**:Portal 通过 RPC 调 8082 的 `pageSyncService.syncUserGeneratedPages`; +5. **`server.mjs` 禁止**在 remote 模式 early-return 跳过 sync。 + +触发点(保持不变): + +- `GET /mindspace/v1/pages` 列表前 +- 聊天 Finish 后(`onAfterFinish`) + +### 3. 103 拓扑约束 + +- Portal:`8081`,`MINDSPACE_SERVER_ADAPTER=remote` +- MindSpace Service:`8082`,跑 **local adapter**(含 pageSyncService + storage + h5Root) +- 用户 HTML:`/Users/john/Project/Memind/MindSpace//public/` +- Storage:`data/mindspace/users//...` + +**禁止**用「改回 Portal local adapter」作为生产长期方案;只可用于本地对比验证。 + +## 排查入口 + +```bash +# 1. 页面 DB 记录 vs storage vs workspace +mysql ... # h5_page_records + h5_page_versions.source_snapshot_json + h5_asset_versions.storage_key + +# 2. storage 是否存在 +ls -la data/mindspace/users//pages//versions/ + +# 3. workspace HTML 是否存在 +ls -la MindSpace//public/ + +# 4. 8082 健康与 adapter +curl -s http://127.0.0.1:8082/health + +# 5. 缩略图 RPC(需登录态;或直接在 8082 侧调 pageService.renderThumbnail) +curl -I 'https://m.tkmind.cn/api/mindspace/v1/pages//thumbnail' +``` + +## 改相关文件前必跑 + +```bash +npm run verify:mindspace-page-sync-guards +node --test mindspace-page-sync-service.test.mjs mindspace-pages.test.mjs +``` + +## Checklist(发 103 前) + +- [ ] `mindspace-pages.mjs` 含 workspace fallback +- [ ] `pageSyncService` 在 contract + local runtime + remote proxy 中齐全 +- [ ] `server.mjs` 无 remote sync skip +- [ ] **8082 与 Portal 均发布**(8082 承载 renderThumbnail + sync) +- [ ] 用真实 pageId 验证 thumbnail 200 + 列表含新页 + +## 相关单测 + +- `mindspace-page-sync-service.test.mjs` +- `mindspace-pages.test.mjs`(storage 缺失时 thumbnail / getPage fallback) +- `mindspace-page-sync.test.mjs`(public dir → DB) + +## 代码内标记 + +搜索 `REGRESSION GUARD: mindspace-page-sync-thumbnail` 定位内联说明。 diff --git a/mindspace-local-runtime-services.mjs b/mindspace-local-runtime-services.mjs index ef9fa03..6e97efc 100644 --- a/mindspace-local-runtime-services.mjs +++ b/mindspace-local-runtime-services.mjs @@ -6,6 +6,7 @@ import { createAgentJobService } from './mindspace-agent-jobs.mjs'; import { createAssetAgentService } from './mindspace-asset-agent.mjs'; import { createPageLiveEditService } from './mindspace-page-live-edit.mjs'; import { createPageService } from './mindspace-pages.mjs'; +import { createPageSyncService } from './mindspace-page-sync-service.mjs'; import { createPublicationService } from './mindspace-publications.mjs'; import { createMindSpaceLocalRuntime } from './mindspace-runtime-config.mjs'; @@ -19,6 +20,7 @@ export function createMindSpaceLocalRuntimeServices({ conversationPackageBackfill, conversationPackagePublicHtmlHydrator, logger = console, + syncWorkspaceAssets = null, } = {}) { let conversationPackageRegistry = null; const runtime = createMindSpaceLocalRuntime({ @@ -51,6 +53,14 @@ export function createMindSpaceLocalRuntimeServices({ storageRoot: runtime.storageRoot, conversationPackageRegistry, }); + const pageSyncService = createPageSyncService({ + pool, + pageService, + assetService, + h5Root, + syncWorkspaceAssets, + logger, + }); const publicationService = createPublicationService(pool, { h5Root, storageRoot: runtime.storageRoot, @@ -80,6 +90,7 @@ export function createMindSpaceLocalRuntimeServices({ conversationPackageRegistry, assetService, pageService, + pageSyncService, publicationService, cleanupService, agentJobService, diff --git a/mindspace-local-runtime-services.test.mjs b/mindspace-local-runtime-services.test.mjs index 7043309..b807dfb 100644 --- a/mindspace-local-runtime-services.test.mjs +++ b/mindspace-local-runtime-services.test.mjs @@ -30,6 +30,7 @@ test('createMindSpaceLocalRuntimeServices assembles local runtime-backed service assert.ok(services.conversationPackageRegistry); assert.ok(services.assetService); assert.ok(services.pageService); + assert.ok(services.pageSyncService); assert.ok(services.publicationService); assert.ok(services.cleanupService); assert.ok(services.agentJobService); diff --git a/mindspace-local-server-adapter.mjs b/mindspace-local-server-adapter.mjs index da8a237..34cd2e8 100644 --- a/mindspace-local-server-adapter.mjs +++ b/mindspace-local-server-adapter.mjs @@ -13,8 +13,10 @@ export function createMindSpaceLocalServerAdapter({ registerPublicHtmlArtifactsForConversation, logger = console, setIntervalFn = setInterval, + syncWorkspaceAssetsEnabled = true, } = {}) { - const services = createMindSpaceLocalRuntimeServices({ + let services; + services = createMindSpaceLocalRuntimeServices({ pool, h5Root, env, @@ -36,6 +38,10 @@ export function createMindSpaceLocalServerAdapter({ registerPublicHtmlArtifactsForConversation, }), logger, + syncWorkspaceAssets: syncWorkspaceAssetsEnabled + ? (targetUserId, options) => + services?.assetService?.syncWorkspaceAssets?.(targetUserId, options) + : null, }); return { diff --git a/mindspace-page-sync-service.mjs b/mindspace-page-sync-service.mjs new file mode 100644 index 0000000..3f3b6b3 --- /dev/null +++ b/mindspace-page-sync-service.mjs @@ -0,0 +1,44 @@ +import { syncGeneratedPagesFromPublicAssets } from './mindspace-page-sync.mjs'; +import { resolvePublishDir } from './user-publish.mjs'; + +export function createPageSyncService({ + pool, + pageService, + assetService, + h5Root, + syncWorkspaceAssets = null, + logger = console, +} = {}) { + const syncInFlight = new Map(); + + async function syncUserGeneratedPages(userId) { + if (!pool || !pageService || !userId) { + return { created: 0, skipped: 0, updated: 0 }; + } + + let inFlight = syncInFlight.get(userId); + if (!inFlight) { + inFlight = syncGeneratedPagesFromPublicAssets({ + pool, + pageService, + assetService, + userId, + publishDir: h5Root ? resolvePublishDir(h5Root, { id: userId }) : null, + syncWorkspaceAssets, + }) + .catch((error) => { + logger.warn?.('[MindSpace] page sync failed:', error?.message ?? error); + return { created: 0, skipped: 0, updated: 0 }; + }) + .finally(() => { + if (syncInFlight.get(userId) === inFlight) { + syncInFlight.delete(userId); + } + }); + syncInFlight.set(userId, inFlight); + } + return inFlight; + } + + return { syncUserGeneratedPages }; +} diff --git a/mindspace-page-sync-service.test.mjs b/mindspace-page-sync-service.test.mjs new file mode 100644 index 0000000..96c7c75 --- /dev/null +++ b/mindspace-page-sync-service.test.mjs @@ -0,0 +1,88 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; +import { createPageSyncService } from './mindspace-page-sync-service.mjs'; +import { PUBLISH_ROOT_DIR } from './user-publish.mjs'; + +test('syncUserGeneratedPages registers html files from publish public dir', async () => { + const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'mindspace-page-sync-service-')); + const h5Root = tmp; + const userId = 'user-1'; + const publishDir = path.join(h5Root, PUBLISH_ROOT_DIR, userId); + const html = + 'Sync Demo

Hi

'; + await fs.mkdir(path.join(publishDir, 'public'), { recursive: true }); + await fs.writeFile(path.join(publishDir, 'public/index.html'), html); + + const calls = []; + const pageService = { + createFromChat: async (_userId, input, source) => { + calls.push({ input, source }); + return { id: 'page-1' }; + }, + updatePage: async () => assert.fail('should create, not update'), + }; + const pool = { + query: async (sql) => { + if (String(sql).includes('FROM h5_page_records')) return [[]]; + return [[]]; + }, + }; + + const pageSyncService = createPageSyncService({ + pool, + pageService, + assetService: null, + h5Root, + }); + const result = await pageSyncService.syncUserGeneratedPages(userId); + + assert.equal(result.created, 1); + assert.equal(calls.length, 1); + assert.equal(calls[0].input.title, 'Sync Demo'); + assert.equal(calls[0].source.snapshot.relative_path, 'public/index.html'); +}); + +test('syncUserGeneratedPages deduplicates concurrent sync for the same user', async () => { + const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'mindspace-page-sync-dedupe-')); + const h5Root = tmp; + const userId = 'user-1'; + const publishDir = path.join(h5Root, PUBLISH_ROOT_DIR, userId); + await fs.mkdir(path.join(publishDir, 'public'), { recursive: true }); + await fs.writeFile( + path.join(publishDir, 'public/index.html'), + 'DedupeHi', + ); + + let active = 0; + let maxActive = 0; + const pageService = { + createFromChat: async () => { + active += 1; + maxActive = Math.max(maxActive, active); + await new Promise((resolve) => setTimeout(resolve, 20)); + active -= 1; + return { id: 'page-1' }; + }, + }; + const pool = { + query: async (sql) => { + if (String(sql).includes('FROM h5_page_records')) return [[]]; + return [[]]; + }, + }; + const pageSyncService = createPageSyncService({ + pool, + pageService, + h5Root, + }); + + await Promise.all([ + pageSyncService.syncUserGeneratedPages(userId), + pageSyncService.syncUserGeneratedPages(userId), + ]); + + assert.equal(maxActive, 1); +}); diff --git a/mindspace-pages.mjs b/mindspace-pages.mjs index 4d26ebd..3c2fc53 100644 --- a/mindspace-pages.mjs +++ b/mindspace-pages.mjs @@ -309,6 +309,45 @@ export function createPageService(pool, options = {}) { throw lastError ?? Object.assign(new Error('存储文件不存在'), { code: 'storage_not_found' }); }; + // REGRESSION GUARD: mindspace-page-sync-thumbnail — storage ENOENT 时回退读 workspace HTML + const readPageContentWithWorkspaceFallback = async ({ + userId, + storageKey, + sourceSnapshotJson, + pageType, + }) => { + let snapshot = {}; + try { + snapshot = parseJsonColumn(sourceSnapshotJson); + } catch { + snapshot = {}; + } + const workspaceHtmlRelativePath = + pageType === 'html' ? snapshot.relative_path ?? null : null; + const workspacePublishDir = workspaceHtmlRelativePath + ? await resolveWorkspacePublishDir(userId) + : null; + + try { + const storagePath = await resolveReadableStoragePath(storageKey); + return await fs.readFile(storagePath, 'utf8'); + } catch (error) { + const storageMissing = error?.code === 'ENOENT' || error?.code === 'storage_not_found'; + if (!storageMissing || !workspacePublishDir || !workspaceHtmlRelativePath) { + throw error; + } + const workspaceHtmlPath = path.join(workspacePublishDir, workspaceHtmlRelativePath); + try { + return await fs.readFile(workspaceHtmlPath, 'utf8'); + } catch (workspaceError) { + if (workspaceError?.code === 'ENOENT') { + throw error; + } + throw workspaceError; + } + } + }; + const writeVersionContent = async (userId, assetId, versionId, content) => { const storageKey = path.posix.join( 'users', @@ -668,7 +707,7 @@ export function createPageService(pool, options = {}) { async function getPage(userId, pageId) { const [rows] = await pool.query( - `SELECT p.*, c.category_code, pv.version_no, av.storage_key + `SELECT p.*, c.category_code, pv.version_no, av.storage_key, pv.source_snapshot_json FROM h5_page_records p JOIN h5_space_categories c ON c.id = p.category_id AND c.user_id = p.user_id JOIN h5_page_versions pv ON pv.id = p.current_version_id @@ -679,8 +718,12 @@ export function createPageService(pool, options = {}) { ); const row = rows[0]; if (!row) throw pageError('页面不存在', 'page_not_found'); - const storagePath = await resolveReadableStoragePath(row.storage_key); - const content = await fs.readFile(storagePath, 'utf8'); + const content = await readPageContentWithWorkspaceFallback({ + userId, + storageKey: row.storage_key, + sourceSnapshotJson: row.source_snapshot_json, + pageType: row.page_type, + }); return pageResponse({ ...row, content }); } @@ -1265,8 +1308,12 @@ export function createPageService(pool, options = {}) { if (row.page_type !== 'html') { throw pageError('该页面不支持缩略图', 'thumbnail_not_supported'); } - const storagePath = await resolveReadableStoragePath(row.storage_key); - const content = await fs.readFile(storagePath, 'utf8'); + const content = await readPageContentWithWorkspaceFallback({ + userId, + storageKey: row.storage_key, + sourceSnapshotJson: row.source_snapshot_json, + pageType: row.page_type, + }); let snapshot = {}; try { snapshot = parseJsonColumn(row.source_snapshot_json); diff --git a/mindspace-pages.test.mjs b/mindspace-pages.test.mjs index 1d64f20..603316d 100644 --- a/mindspace-pages.test.mjs +++ b/mindspace-pages.test.mjs @@ -1,6 +1,10 @@ import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; import test from 'node:test'; -import { pageInternals, normalizeWorkspaceRelativePath } from './mindspace-pages.mjs'; +import { createPageService, pageInternals, normalizeWorkspaceRelativePath } from './mindspace-pages.mjs'; +import { PUBLISH_ROOT_DIR } from './user-publish.mjs'; test('parseJsonColumn accepts mysql json objects and strings', () => { const objectValue = { relative_path: 'public/demo.html', auto_synced: true }; @@ -208,3 +212,86 @@ test('registerPageArtifactForConversation skips missing registry or session safe null, ); }); + +test('renderThumbnail falls back to workspace html when storage asset is missing', async () => { + const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'mindspace-thumb-')); + const storageRoot = path.join(tmp, 'storage'); + const h5Root = tmp; + const userId = 'user-1'; + const pageId = 'page-1'; + const publishDir = path.join(h5Root, PUBLISH_ROOT_DIR, userId); + const html = + 'Demo

Hi

'; + await fs.mkdir(path.join(publishDir, 'public'), { recursive: true }); + await fs.writeFile(path.join(publishDir, 'public/index.html'), html); + + const pool = { + query: async (sql) => { + if (String(sql).includes('FROM h5_page_records')) { + return [[{ + title: 'Demo', + summary: '', + page_type: 'html', + version_no: 1, + storage_key: `users/${userId}/pages/missing/versions/v1.md`, + source_snapshot_json: { relative_path: 'public/index.html' }, + }]]; + } + if (String(sql).includes('FROM h5_users')) { + return [[{ username: 'demo' }]]; + } + return [[]]; + }, + }; + + const pageService = createPageService(pool, { storageRoot, h5Root }); + const svg = await pageService.renderThumbnail(userId, pageId); + assert.match(svg, / { + const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'mindspace-get-page-')); + const storageRoot = path.join(tmp, 'storage'); + const h5Root = tmp; + const userId = 'user-1'; + const pageId = 'page-1'; + const publishDir = path.join(h5Root, PUBLISH_ROOT_DIR, userId); + const html = + 'Page Demo

Body

'; + await fs.mkdir(path.join(publishDir, 'public'), { recursive: true }); + await fs.writeFile(path.join(publishDir, 'public/index.html'), html); + + const pool = { + query: async (sql) => { + if (String(sql).includes('FROM h5_page_records')) { + return [[{ + id: pageId, + user_id: userId, + title: 'Page Demo', + summary: '', + page_type: 'html', + template_id: 'static-html', + current_version_id: 'version-1', + status: 'draft', + visibility: 'private', + category_code: 'draft', + version_no: 1, + storage_key: `users/${userId}/pages/missing/versions/v1.md`, + source_snapshot_json: { relative_path: 'public/index.html' }, + created_at: 1, + updated_at: 1, + }]]; + } + if (String(sql).includes('FROM h5_users')) { + return [[{ username: 'demo' }]]; + } + return [[]]; + }, + }; + + const pageService = createPageService(pool, { storageRoot, h5Root }); + const page = await pageService.getPage(userId, pageId); + assert.equal(page.title, 'Page Demo'); + assert.match(page.content, /

Body<\/h1>/); +}); diff --git a/mindspace-server-adapter-contract.mjs b/mindspace-server-adapter-contract.mjs index da8e2bd..d3783ff 100644 --- a/mindspace-server-adapter-contract.mjs +++ b/mindspace-server-adapter-contract.mjs @@ -44,6 +44,7 @@ export const MINDSPACE_SERVER_ADAPTER_BINDINGS = Object.freeze({ 'updatePage', 'uploadThumbnail', ]), + pageSyncService: Object.freeze(['syncUserGeneratedPages']), pageLiveEditService: Object.freeze(['applyAgentPatch', 'bindSession', 'getRevisionSnapshot']), assetAgentService: Object.freeze(['applyAgentDelete']), publicationService: Object.freeze([ diff --git a/package.json b/package.json index bcf1295..1008ee8 100644 --- a/package.json +++ b/package.json @@ -51,12 +51,13 @@ "smoke:memory-v2-qdrant": "node scripts/smoke-memory-v2-qdrant.mjs", "smoke:memory-v2-external": "node scripts/smoke-memory-v2-external.mjs", "mock:memory-v2-services": "node scripts/mock-memory-v2-services.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 wechat-mp.test.mjs schedule-intent.test.mjs schedule-reminder-worker.test.mjs capabilities.test.mjs policies.test.mjs chat-skills.test.mjs chat-intent-router.test.mjs chat-finish-sync.test.mjs conversation-display.test.mjs user-publish.test.mjs user-memory-profile.test.mjs skills-registry.test.mjs agent-run-gateway.test.mjs agent-run-routes.test.mjs admin-routes.test.mjs direct-chat-service.test.mjs tool-gateway.test.mjs mindspace.test.mjs mindspace-scan.test.mjs mindspace-assets.test.mjs mindspace-local-runtime-services.test.mjs mindspace-local-server-adapter.test.mjs mindspace-public-asset-token.test.mjs mindspace-remote-server-adapter.test.mjs mindspace-server-adapter.test.mjs mindspace-pages.test.mjs mindspace-html-download-links.test.mjs mindspace-page-purge.test.mjs mindspace-public-delivery.test.mjs mindspace-public-page-context.test.mjs mindspace-public-route.test.mjs mindspace-publications.test.mjs mindspace-public-links.test.mjs mindspace-chat-save.test.mjs mindspace-chat-docx-package.test.mjs mindspace-public-finish-sync.test.mjs mindspace-chat-context.test.mjs mindspace-canonical-url.test.mjs mindspace-conversation-package.test.mjs mindspace-conversation-package-backfill.test.mjs mindspace-conversation-package-public-html.test.mjs mindspace-conversation-package-verify.test.mjs mindspace-conversation-package-registry.test.mjs mindspace-conversation-package-routes.test.mjs mindspace-conversation-package-store.test.mjs mindspace-conversation-schema.test.mjs mindspace-runtime-config.test.mjs mindspace-service.test.mjs mindspace-storage-adapter.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 mindspace-sandbox-mcp.test.mjs message-stream.test.mjs mindspace-service/mindspace-rpc-server.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 admin-guard.test.mjs user-feedback.test.mjs memory-v2.test.mjs memory-v2-admin-config.test.mjs memory-v2-adapter-scaffold.test.mjs memory-v2-backend-contract.test.mjs memory-v2-health.test.mjs memory-v2-runtime.test.mjs memory-v2-plugin-backends.test.mjs memory-v2-pgvector.test.mjs memory-v2-pgvector-schema.test.mjs memory-v2-pgvector-backfill.test.mjs memory-v2-pgvector-smoke.test.mjs memory-v2-qdrant.test.mjs memory-v2-weaviate.test.mjs memory-v2-mem0.test.mjs memory-v2-letta.test.mjs memory-v2-external-adapters.test.mjs scripts/embed-memory-v2-local-hash.test.mjs scripts/check-memory-v2-app-canary.test.mjs scripts/check-memory-v2-config-gaps.test.mjs scripts/check-memory-v2-contracts.test.mjs scripts/check-memory-v2-health.test.mjs scripts/check-memory-v2-session-flow.test.mjs scripts/check-memory-v2-stack.test.mjs scripts/setup-memory-v2-pgvector-schema.test.mjs scripts/backfill-memory-v2-pgvector.test.mjs scripts/scaffold-memory-v2-backend.test.mjs scripts/smoke-memory-v2-pgvector.test.mjs scripts/smoke-memory-v2-qdrant.test.mjs scripts/smoke-memory-v2-external.test.mjs scripts/mock-memory-v2-services.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 wechat-mp.test.mjs schedule-intent.test.mjs schedule-reminder-worker.test.mjs capabilities.test.mjs policies.test.mjs chat-skills.test.mjs chat-intent-router.test.mjs chat-finish-sync.test.mjs conversation-display.test.mjs user-publish.test.mjs user-memory-profile.test.mjs skills-registry.test.mjs agent-run-gateway.test.mjs agent-run-routes.test.mjs admin-routes.test.mjs direct-chat-service.test.mjs tool-gateway.test.mjs mindspace.test.mjs mindspace-scan.test.mjs mindspace-assets.test.mjs mindspace-local-runtime-services.test.mjs mindspace-local-server-adapter.test.mjs mindspace-public-asset-token.test.mjs mindspace-remote-server-adapter.test.mjs mindspace-server-adapter.test.mjs mindspace-pages.test.mjs mindspace-page-sync-service.test.mjs public-site-bases.test.mjs mindspace-html-download-links.test.mjs mindspace-page-purge.test.mjs mindspace-public-delivery.test.mjs mindspace-public-page-context.test.mjs mindspace-public-route.test.mjs mindspace-publications.test.mjs mindspace-public-links.test.mjs mindspace-chat-save.test.mjs mindspace-chat-docx-package.test.mjs mindspace-public-finish-sync.test.mjs mindspace-chat-context.test.mjs mindspace-canonical-url.test.mjs mindspace-conversation-package.test.mjs mindspace-conversation-package-backfill.test.mjs mindspace-conversation-package-public-html.test.mjs mindspace-conversation-package-verify.test.mjs mindspace-conversation-package-registry.test.mjs mindspace-conversation-package-routes.test.mjs mindspace-conversation-package-store.test.mjs mindspace-conversation-schema.test.mjs mindspace-runtime-config.test.mjs mindspace-service.test.mjs mindspace-storage-adapter.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 mindspace-sandbox-mcp.test.mjs message-stream.test.mjs mindspace-service/mindspace-rpc-server.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 admin-guard.test.mjs user-feedback.test.mjs memory-v2.test.mjs memory-v2-admin-config.test.mjs memory-v2-adapter-scaffold.test.mjs memory-v2-backend-contract.test.mjs memory-v2-health.test.mjs memory-v2-runtime.test.mjs memory-v2-plugin-backends.test.mjs memory-v2-pgvector.test.mjs memory-v2-pgvector-schema.test.mjs memory-v2-pgvector-backfill.test.mjs memory-v2-pgvector-smoke.test.mjs memory-v2-qdrant.test.mjs memory-v2-weaviate.test.mjs memory-v2-mem0.test.mjs memory-v2-letta.test.mjs memory-v2-external-adapters.test.mjs scripts/embed-memory-v2-local-hash.test.mjs scripts/check-memory-v2-app-canary.test.mjs scripts/check-memory-v2-config-gaps.test.mjs scripts/check-memory-v2-contracts.test.mjs scripts/check-memory-v2-health.test.mjs scripts/check-memory-v2-session-flow.test.mjs scripts/check-memory-v2-stack.test.mjs scripts/setup-memory-v2-pgvector-schema.test.mjs scripts/backfill-memory-v2-pgvector.test.mjs scripts/scaffold-memory-v2-backend.test.mjs scripts/smoke-memory-v2-pgvector.test.mjs scripts/smoke-memory-v2-qdrant.test.mjs scripts/smoke-memory-v2-external.test.mjs scripts/mock-memory-v2-services.test.mjs", "test:mindspace-service": "node --test mindspace-service/mindspace-rpc-server.test.mjs", "verify:chat-finish-sync": "node scripts/verify-chat-finish-sync.mjs", "verify:public-finish-sync-runtime": "node scripts/verify-public-finish-sync-runtime.mjs", "verify:mindspace-publish-guards": "node scripts/verify-mindspace-publish-guards.mjs", "verify:mindspace-publish-guards:full": "node scripts/verify-mindspace-publish-guards.mjs --with-runtime", + "verify:mindspace-page-sync-guards": "node scripts/verify-mindspace-page-sync-guards.mjs", "verify:wechat-channel-isolation": "node scripts/check-wechat-channel-isolation.mjs", "test:mindspace-e2e": "node scripts/mindspace-e2e.mjs", "test:mindspace-pages-e2e": "node scripts/mindspace-pages-e2e.mjs", diff --git a/public-site-bases.test.mjs b/public-site-bases.test.mjs new file mode 100644 index 0000000..ccea7a9 --- /dev/null +++ b/public-site-bases.test.mjs @@ -0,0 +1,64 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + DEFAULT_PLAZA_PUBLIC_BASE, + DEFAULT_LOCAL_PLAZA_PUBLIC_BASE, + resolvePlazaHomePath, + resolvePlazaPostPath, + resolvePlazaPublicBase, +} from './src/utils/public-site-bases.mjs'; + +test('resolvePlazaPublicBase uses local default on localhost without configured base', () => { + assert.equal( + resolvePlazaPublicBase({ hostname: '127.0.0.1', dev: false }), + DEFAULT_LOCAL_PLAZA_PUBLIC_BASE, + ); +}); + +test('resolvePlazaPublicBase uses plaza.tkmind.cn on production host without configured base', () => { + assert.equal( + resolvePlazaPublicBase({ hostname: 'm.tkmind.cn', dev: false }), + DEFAULT_PLAZA_PUBLIC_BASE, + ); +}); + +test('resolvePlazaPublicBase ignores loopback configured base on production host', () => { + assert.equal( + resolvePlazaPublicBase({ + configuredBase: 'http://127.0.0.1:3001', + hostname: 'm.tkmind.cn', + dev: false, + }), + DEFAULT_PLAZA_PUBLIC_BASE, + ); +}); + +test('resolvePlazaPublicBase keeps loopback configured base on local host', () => { + assert.equal( + resolvePlazaPublicBase({ + configuredBase: 'http://127.0.0.1:3001', + hostname: '127.0.0.1', + dev: false, + }), + 'http://127.0.0.1:3001', + ); +}); + +test('resolvePlazaPublicBase respects explicit production configured base', () => { + assert.equal( + resolvePlazaPublicBase({ + configuredBase: 'https://plaza.tkmind.cn', + hostname: 'm.tkmind.cn', + dev: false, + }), + 'https://plaza.tkmind.cn', + ); +}); + +test('resolvePlazaHomePath and resolvePlazaPostPath append plaza routes', () => { + assert.equal(resolvePlazaHomePath('https://plaza.tkmind.cn'), 'https://plaza.tkmind.cn/plaza'); + assert.equal( + resolvePlazaPostPath('https://plaza.tkmind.cn', 'post-1'), + 'https://plaza.tkmind.cn/plaza/p/post-1', + ); +}); diff --git a/scripts/build-portal-runtime.mjs b/scripts/build-portal-runtime.mjs index 571cb1c..9ec2680 100755 --- a/scripts/build-portal-runtime.mjs +++ b/scripts/build-portal-runtime.mjs @@ -81,7 +81,18 @@ async function writeFile(targetPath, content) { async function buildFrontend() { if (skipBuild) return; console.log('==> 构建 Portal 前端 dist'); - await run('npm', ['run', 'build']); + const buildEnv = { + ...process.env, + VITE_PLAZA_BASE: + process.env.VITE_PLAZA_BASE ?? + process.env.PLAZA_PUBLIC_BASE ?? + 'https://plaza.tkmind.cn', + VITE_MINDSPACE_BASE: + process.env.VITE_MINDSPACE_BASE ?? + process.env.H5_PUBLIC_BASE_URL ?? + 'https://m.tkmind.cn', + }; + await run('npm', ['run', 'build'], { env: buildEnv }); } async function bundleServer() { @@ -397,6 +408,11 @@ async function writeMetadata() { ' TKMIND_API_TARGETS / TKMIND_API_TARGET / TKMIND_API_TARGET_1', ' H5_USERS_ROOT / MINDSPACE_STORAGE_ROOT / MEMIND_SHARED_PUBLISH_ROOT', '', + 'Frontend compile-time public URLs (baked into dist at build):', + ' VITE_PLAZA_BASE defaults to https://plaza.tkmind.cn in build-portal-runtime.mjs', + ' VITE_MINDSPACE_BASE defaults to H5_PUBLIC_BASE_URL or https://m.tkmind.cn', + ' Do not build production dist with VITE_PLAZA_BASE=http://127.0.0.1:* in .env', + '', 'Deployment and operations transport:', ' H5 public domain: m.tkmind.cn', ' Current public path: m.tkmind.cn -> 105 nginx -> 103 Portal :8081', diff --git a/scripts/verify-mindspace-page-sync-guards.mjs b/scripts/verify-mindspace-page-sync-guards.mjs new file mode 100644 index 0000000..36f65f1 --- /dev/null +++ b/scripts/verify-mindspace-page-sync-guards.mjs @@ -0,0 +1,68 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +const root = path.resolve(new URL('..', import.meta.url).pathname); + +function read(relativePath) { + return fs.readFileSync(path.join(root, relativePath), 'utf8'); +} + +function assertIncludes(label, source, snippet) { + if (!source.includes(snippet)) { + throw new Error(`${label} is missing required guard snippet:\n${snippet}`); + } +} + +const serverSource = read('server.mjs'); +const pagesSource = read('mindspace-pages.mjs'); +const contractSource = read('mindspace-server-adapter-contract.mjs'); + +assertIncludes( + 'server.mjs syncUserGeneratedPages', + serverSource, + 'await mindSpacePageSync.syncUserGeneratedPages(userId);', +); +if (serverSource.includes("mindSpaceServerRuntime.adapterKind === 'remote'")) { + throw new Error( + 'server.mjs must not skip page sync in remote mode; use pageSyncService RPC instead', + ); +} + +assertIncludes( + 'mindspace-server-adapter-contract.mjs', + contractSource, + "pageSyncService: Object.freeze(['syncUserGeneratedPages'])", +); + +assertIncludes( + 'mindspace-pages.mjs', + pagesSource, + 'readPageContentWithWorkspaceFallback', +); +assertIncludes( + 'mindspace-pages.mjs workspace fallback', + pagesSource, + "snapshot.relative_path ?? null", +); + +const publicSiteBasesSource = read('src/utils/public-site-bases.mjs'); +const publicUrlSource = read('src/utils/publicUrl.ts'); +const buildRuntimeSource = read('scripts/build-portal-runtime.mjs'); + +assertIncludes( + 'public-site-bases.mjs loopback guard', + publicSiteBasesSource, + 'isLoopbackPublicBase', +); +assertIncludes( + 'publicUrl.ts uses public-site-bases', + publicUrlSource, + "from './public-site-bases.mjs'", +); +assertIncludes( + 'build-portal-runtime.mjs production plaza default', + buildRuntimeSource, + 'https://plaza.tkmind.cn', +); + +console.log('mindspace page sync + thumbnail regression guards ok'); diff --git a/server.mjs b/server.mjs index 8ab61d4..2f38dbd 100644 --- a/server.mjs +++ b/server.mjs @@ -122,7 +122,6 @@ import { normalizePublicHtmlRelativePath, syncPublicHtmlAfterFinish, } from './mindspace-public-finish-sync.mjs'; -import { syncGeneratedPagesFromPublicAssets } from './mindspace-page-sync.mjs'; import { extractCoverSignals, generateHtmlThumbnail } from './mindspace-thumbnails.mjs'; import { extractSharePreviewMeta, @@ -299,6 +298,7 @@ let mindSpaceAudit = null; let mindSpaceServiceFacade = null; let mindSpaceConversationPackageRegistry = null; let mindSpacePages = null; +let mindSpacePageSync = null; let mindSpacePageLiveEdit = null; let mindSpaceAssetAgent = null; let mindSpacePageEditSession = null; @@ -363,6 +363,7 @@ async function bootstrapUserAuth() { resolveUserIdForAgentSession, resolveSessionSnapshot: (sessionId) => sessionSnapshotService?.get?.(sessionId), registerPublicHtmlArtifactsForConversation, + syncWorkspaceAssetsEnabled: WORKSPACE_MAINTENANCE_ENABLED, remote: mindSpaceServerRuntime.remote, logger: console, }), @@ -372,6 +373,7 @@ async function bootstrapUserAuth() { mindSpaceConversationPackageRegistry = mindSpaceRuntimeAdapter.conversationPackageRegistry; mindSpaceAssets = mindSpaceRuntimeAdapter.assetService; mindSpacePages = mindSpaceRuntimeAdapter.pageService; + mindSpacePageSync = mindSpaceRuntimeAdapter.pageSyncService; mindSpacePageLiveEdit = mindSpaceRuntimeAdapter.pageLiveEditService; mindSpaceAssetAgent = mindSpaceRuntimeAdapter.assetAgentService; mindSpacePublications = mindSpaceRuntimeAdapter.publicationService; @@ -2942,36 +2944,10 @@ async function resolveExistingSavedPage(userId, { sessionId, messageId, relative const SAVE_TARGET_CATEGORIES = new Set(['draft', 'oa', 'public']); -const pageSyncInFlight = new Map(); - +// REGRESSION GUARD: mindspace-page-sync-thumbnail — remote 也经 pageSyncService RPC 同步 public HTML async function syncUserGeneratedPages(userId) { - if (!mindSpacePages || !authPool || !userId) return; - if (mindSpaceServerRuntime.adapterKind === 'remote') return; - - let inFlight = pageSyncInFlight.get(userId); - if (!inFlight) { - inFlight = syncGeneratedPagesFromPublicAssets({ - pool: authPool, - pageService: mindSpacePages, - assetService: mindSpaceAssets, - userId, - publishDir: resolveMindSpaceUserPublishDir(__dirname, { id: userId }), - syncWorkspaceAssets: - mindSpaceAssets && WORKSPACE_MAINTENANCE_ENABLED - ? (targetUserId, options) => mindSpaceAssets.syncWorkspaceAssets(targetUserId, options) - : null, - }) - .catch((error) => { - console.warn('[MindSpace] page sync failed:', error?.message ?? error); - }) - .finally(() => { - if (pageSyncInFlight.get(userId) === inFlight) { - pageSyncInFlight.delete(userId); - } - }); - pageSyncInFlight.set(userId, inFlight); - } - await inFlight; + if (!mindSpacePageSync || !userId) return; + await mindSpacePageSync.syncUserGeneratedPages(userId); } async function resolveChatSaveBundle(user, h5Root, input = {}) { diff --git a/src/utils/public-site-bases.mjs b/src/utils/public-site-bases.mjs new file mode 100644 index 0000000..08af4aa --- /dev/null +++ b/src/utils/public-site-bases.mjs @@ -0,0 +1,43 @@ +export const DEFAULT_PLAZA_PUBLIC_BASE = 'https://plaza.tkmind.cn'; +export const DEFAULT_LOCAL_PLAZA_PUBLIC_BASE = 'http://127.0.0.1:3001'; + +export function isLocalSiteHost(hostname) { + const host = String(hostname ?? '').trim().toLowerCase(); + return !host || host === 'localhost' || host === '127.0.0.1' || host.endsWith('.localhost'); +} + +export function isLoopbackPublicBase(base) { + const value = String(base ?? '').trim(); + if (!value) return false; + return ( + /^https?:\/\/127\.0\.0\.1(?::\d+)?(?:\/|$)/i.test(value) || + /^https?:\/\/localhost(?::\d+)?(?:\/|$)/i.test(value) + ); +} + +export function resolvePlazaPublicBase({ + configuredBase = '', + dev = false, + hostname = '', +} = {}) { + const configured = String(configuredBase ?? '').trim().replace(/\/$/, ''); + const localHost = isLocalSiteHost(hostname); + if (configured) { + if (!localHost && !dev && isLoopbackPublicBase(configured)) { + return DEFAULT_PLAZA_PUBLIC_BASE; + } + return configured; + } + if (dev || localHost) { + return DEFAULT_LOCAL_PLAZA_PUBLIC_BASE; + } + return DEFAULT_PLAZA_PUBLIC_BASE; +} + +export function resolvePlazaHomePath(base) { + return `${String(base ?? '').replace(/\/$/, '')}/plaza`; +} + +export function resolvePlazaPostPath(base, postId) { + return `${String(base ?? '').replace(/\/$/, '')}/plaza/p/${encodeURIComponent(postId)}`; +} diff --git a/src/utils/publicUrl.ts b/src/utils/publicUrl.ts index 2629501..f13ed22 100644 --- a/src/utils/publicUrl.ts +++ b/src/utils/publicUrl.ts @@ -1,3 +1,9 @@ +import { + resolvePlazaHomePath, + resolvePlazaPostPath, + resolvePlazaPublicBase, +} from './public-site-bases.mjs'; + export function resolvePublicPageUrl(publicUrl: string): string { const value = String(publicUrl ?? '').trim(); if (!value) return value; @@ -7,26 +13,20 @@ export function resolvePublicPageUrl(publicUrl: string): string { return `${window.location.origin}/${value.replace(/^\/+/, '')}`; } +function resolvedPlazaBase(): string { + return resolvePlazaPublicBase({ + configuredBase: import.meta.env.VITE_PLAZA_BASE, + dev: import.meta.env.DEV, + hostname: typeof window !== 'undefined' ? window.location.hostname : '', + }); +} + export function resolvePlazaPostUrl(postId: string): string { - const configured = String(import.meta.env.VITE_PLAZA_BASE ?? '').trim(); - if (configured) { - return `${configured.replace(/\/$/, '')}/plaza/p/${encodeURIComponent(postId)}`; - } - if (import.meta.env.DEV) { - return `http://127.0.0.1:3001/plaza/p/${encodeURIComponent(postId)}`; - } - return `${window.location.origin}/plaza/p/${encodeURIComponent(postId)}`; + return resolvePlazaPostPath(resolvedPlazaBase(), postId); } export function resolvePlazaHomeUrl(): string { - const configured = String(import.meta.env.VITE_PLAZA_BASE ?? '').trim(); - if (configured) { - return `${configured.replace(/\/$/, '')}/plaza`; - } - if (import.meta.env.DEV) { - return 'http://127.0.0.1:3001/plaza'; - } - return `${window.location.origin}/plaza`; + return resolvePlazaHomePath(resolvedPlazaBase()); } export function resolveMindSpaceHomeUrl(): string {