From ea19ffb5fa246d5c62230bd6d7e3d65c0a2d30ad Mon Sep 17 00:00:00 2001 From: john Date: Sun, 28 Jun 2026 12:18:26 +0800 Subject: [PATCH] feat: improve mindspace asset handling and local runtime paths --- README.md | 2 +- docs/105-server-operations.md | 4 +- docs/goosed-docker-local-verification.md | 4 +- docs/goosed-local-build-delivery.md | 6 +- docs/goosed-phase1-local-plan.md | 4 +- docs/imgproxy-103-production-runbook.md | 184 ++++++++++++++++++ docs/local-dev.md | 4 +- docs/memind-ai-mind-integration-assessment.md | 3 +- docs/service-isolation-runbook.md | 16 +- mindspace-assets.mjs | 20 ++ mindspace-audit.mjs | 1 + mindspace-chat-save.mjs | 180 ++++++++++++++++- mindspace-chat-save.test.mjs | 59 ++++++ mindspace-thumbnails.mjs | 17 +- scripts/audit-103-state.sh | 2 +- scripts/deploy-plaza-105.sh | 2 +- scripts/deploy-to-test-host.sh | 8 +- scripts/dev-plaza.mjs | 6 +- scripts/dev.mjs | 6 +- scripts/g2-lb.Caddyfile | 16 +- scripts/install-imgproxy-prod.sh | 111 +++++++++++ scripts/seed-plaza-covers.mjs | 2 +- scripts/start-plaza-prod.mjs | 6 +- server.mjs | 128 +++++++++++- src/components/ChatPanel.tsx | 34 ++-- src/components/ChatView.tsx | 4 +- src/components/MessageList.tsx | 11 +- src/components/PageSaveDialog.tsx | 7 +- src/components/PageSavePreviewPanel.tsx | 2 +- src/components/SpaceChatPanel.tsx | 6 +- src/hooks/usePageEditSubChat.ts | 11 +- src/hooks/useTKMindChat.ts | 49 ++++- src/types.ts | 1 + src/utils/message.ts | 46 ++++- src/utils/mindspaceCards.ts | 2 +- wechat-mp.test.mjs | 3 +- 36 files changed, 867 insertions(+), 100 deletions(-) create mode 100644 docs/imgproxy-103-production-runbook.md create mode 100755 scripts/install-imgproxy-prod.sh diff --git a/README.md b/README.md index cbc0561..18d1846 100644 --- a/README.md +++ b/README.md @@ -72,7 +72,7 @@ pnpm open:plaza # 浏览器打开 https://plaza.tkmind.cn/plaza ## 发布规则入口 ```bash -cd /Users/john/PycharmProjects/test/test-memind +cd /Users/john/Project/Memind bash scripts/release-portal-runtime-prod.sh --dry-run bash scripts/release-portal-runtime-prod.sh ``` diff --git a/docs/105-server-operations.md b/docs/105-server-operations.md index a83ff22..178e977 100644 --- a/docs/105-server-operations.md +++ b/docs/105-server-operations.md @@ -13,7 +13,7 @@ | Plaza 运行目录 | `/root/tkmind_go/ui/plaza` | | 数据真身 | 在 `103`(MindSpace、users、data 等),见 [103/105 升级记录](103-105-upgrade-runbook-2026-06-26.md) | -本地开发仓库是 `/Users/john/PycharmProjects/test/test-memind`;生产 runtime 发布目标是 `103`。 +本地开发仓库是 `/Users/john/Project/Memind`;生产 runtime 发布目标是 `103`。 **105 上的文件是部署产物,不是编辑源。** ## 禁止事项 @@ -75,7 +75,7 @@ scripts/wechat-mp-menu.mjs ```bash # 1. 本地改 scripts/wechat-mp-menu.mjs -cd /Users/john/PycharmProjects/test/test-memind +cd /Users/john/Project/Memind # 编辑 MENU → 本地验证 → git commit # 2. 随 Portal/H5 走正式发布到 103(或当前文档规定的目标机) diff --git a/docs/goosed-docker-local-verification.md b/docs/goosed-docker-local-verification.md index 4d28d27..8835c0c 100644 --- a/docs/goosed-docker-local-verification.md +++ b/docs/goosed-docker-local-verification.md @@ -9,7 +9,7 @@ ### 一键本地构建 + 验证 ```bash -cd /Users/john/PycharmProjects/test/test-memind +cd /Users/john/Project/Memind # 本地构建镜像 + 启动容器 + 健康检查 bash scripts/verify-goosed-docker.sh local @@ -44,7 +44,7 @@ docker system df #### 2. 本地构建镜像 ```bash -cd /Users/john/PycharmProjects/test/test-memind +cd /Users/john/Project/Memind # 方式 A:直接从 Dockerfile 构建(首次较慢) docker build -f tkmind_go/Dockerfile.goosed -t tkmind/goosed:local tkmind_go/ diff --git a/docs/goosed-local-build-delivery.md b/docs/goosed-local-build-delivery.md index 302183e..94d54ad 100644 --- a/docs/goosed-local-build-delivery.md +++ b/docs/goosed-local-build-delivery.md @@ -47,7 +47,7 @@ docker build --version ### Git 配置确认 ```bash -cd /Users/john/PycharmProjects/test/test-memind +cd /Users/john/Project/Memind # 确认 origin 指向 git.tkmind.cn git remote -v @@ -92,7 +92,7 @@ git push -u origin main # 确保推送 ### 方式 A:使用验证脚本(推荐) ```bash -cd /Users/john/PycharmProjects/test/test-memind +cd /Users/john/Project/Memind # 一键构建 + 启动 + 验证 bash scripts/verify-goosed-docker.sh local @@ -107,7 +107,7 @@ bash scripts/verify-goosed-docker.sh local ### 方式 B:手工 docker build(更细致控制) ```bash -cd /Users/john/PycharmProjects/test/test-memind +cd /Users/john/Project/Memind # 构建镜像 docker build -f tkmind_go/Dockerfile.goosed \ diff --git a/docs/goosed-phase1-local-plan.md b/docs/goosed-phase1-local-plan.md index a8a784c..1737e8b 100644 --- a/docs/goosed-phase1-local-plan.md +++ b/docs/goosed-phase1-local-plan.md @@ -50,7 +50,7 @@ Step 4: 镜像交付 ### Step 1:提交文件到 git.tkmind.cn ```bash -cd /Users/john/PycharmProjects/test/test-memind +cd /Users/john/Project/Memind # 查看修改 git status @@ -82,7 +82,7 @@ git push origin main ```bash # 进入项目目录 -cd /Users/john/PycharmProjects/test/test-memind +cd /Users/john/Project/Memind # 一键构建 + 验证 bash scripts/verify-goosed-docker.sh local diff --git a/docs/imgproxy-103-production-runbook.md b/docs/imgproxy-103-production-runbook.md new file mode 100644 index 0000000..e3ccf5f --- /dev/null +++ b/docs/imgproxy-103-production-runbook.md @@ -0,0 +1,184 @@ +# ImgProxy 103 生产部署说明 + +> 目标:把 `imgproxy` 部署到 `103 / Studio`,保持和当前本机开发一致的签名配置、端口约定、数据根语义与 URL 生成方式。 + +## 先说结论 + +可以做到“行为一模一样”,但**不应该追求把本机 `/Users/john/...` 绝对路径逐字照搬到另一台机器**。 + +当前代码真正依赖的是这几件事: + +1. `IMGPROXY_BASE_URL` +2. `IMGPROXY_SIGNING_KEY` +3. `IMGPROXY_SIGNING_SALT` +4. `MINDSPACE_STORAGE_ROOT` +5. `imgproxy` 能从 `local://` 读到真实文件 + +也就是说,生产上要保持的是: + +- 同一套签名 key / salt +- 同一个存储根语义 +- 同一个监听端口 `127.0.0.1:20081` +- 同一个公网域名入口 `https://img.tkmind.cn` + +而不是把开发机 `localhost` 或别的绝对路径硬搬过去。 + +## 当前仓库里的真实前提 + +103 当前真实持久目录已经固定为: + +- `H5_USERS_ROOT=/Users/john/Project/Memind/users` +- `MINDSPACE_STORAGE_ROOT=/Users/john/Project/Memind/data/mindspace` +- `MEMIND_SHARED_PUBLISH_ROOT=/Users/john/Project/Memind/MindSpace` + +代码生成 `imgproxy` URL 时,传进去的是 `asset.storage_key`,例如: + +```text +users//temp/.upload +``` + +`imgproxy-signer.mjs` 会把它转成: + +```text +local://users//temp/.upload +``` + +所以生产 `imgproxy` 只需要把本地文件系统根指到: + +```text +/Users/john/Project/Memind/data/mindspace +``` + +这样 `local://users/...` 就能正确落到: + +```text +/Users/john/Project/Memind/data/mindspace/users/... +``` + +## 推荐的 103 配置 + +### 1. Portal `.env` + +103 Portal 运行环境里建议保持: + +```env +MINDSPACE_STORAGE_ROOT=/Users/john/Project/Memind/data/mindspace +IMGPROXY_BASE_URL=https://img.tkmind.cn +IMGPROXY_SIGNING_KEY=<与当前开发一致> +IMGPROXY_SIGNING_SALT=<与当前开发一致> +``` + +说明: + +- `IMGPROXY_BASE_URL` 在生产不能再用 `http://localhost:20081`,因为最终生成给页面和聊天的 URL 应该是公网可访问域名。 +- `IMGPROXY_SIGNING_KEY` / `IMGPROXY_SIGNING_SALT` 必须和当前代码生成器一致,否则已发布页面与聊天里的图片链接会全部失效。 + +### 2. ImgProxy 进程 + +建议在 `103 / Studio` 本机监听: + +```text +127.0.0.1:20081 +``` + +并使用: + +```text +IMGPROXY_LOCAL_FILESYSTEM_ROOT=/Users/john/Project/Memind/data/mindspace +IMGPROXY_KEY=<同 IMGPROXY_SIGNING_KEY> +IMGPROXY_SALT=<同 IMGPROXY_SIGNING_SALT> +``` + +仓库已补安装脚本: + +```bash +bash scripts/install-imgproxy-prod.sh +``` + +它会: + +1. 检查 Homebrew `imgproxy` +2. 读取当前 `.env` +3. 生成 `~/Library/LaunchAgents/cn.tkmind.imgproxy.plist` +4. 以 LaunchAgent 方式启动 `imgproxy` +5. 监听 `127.0.0.1:20081` + +## 为什么这已经算“同路径” + +如果你的要求是“和本机当前项目路径一致”,那 103 其实已经满足: + +```text +/Users/john/Project/Memind/data/mindspace +``` + +这正是当前 103 的正式持久目录。 + +如果你的要求是“和你本地 Mac 开发时完全一样的 `IMGPROXY_BASE_URL=http://localhost:20081`”,那生产不该这么做,因为: + +1. 页面生成后要给外部用户访问 +2. 聊天里插图也要给前端访问 +3. `localhost` 只对 103 自己有效,不对用户浏览器有效 + +所以这里唯一应该改的是 `BASE_URL`,不是签名和存储路径。 + +## `img.tkmind.cn` 入口怎么接 + +推荐链路: + +```text +用户 -> img.tkmind.cn -> 103 Caddy -> 127.0.0.1:20081 imgproxy +``` + +注意一个当前限制: + +- 仓库里虽然有 `/api/mindspace/v1/authorize-image` +- 但现有 `imgproxy-signer.mjs` 生成的 URL **没有附带 `asset_id` query** +- 因此 `forward_auth` 目前没有足够信息做“按资产鉴权” + +所以在当前代码下,`img.tkmind.cn` 这层**先不要假装已经接好了 `forward_auth`**。 +现阶段更准确的说法是: + +- 已具备 `imgproxy` 签名和反代条件 +- 但若要做“按资产授权”的反代鉴权,还需要先扩展 URL 方案,把 `asset_id` 或等价标识带进请求 + +## 建议发布顺序 + +1. 本地 commit 当前分支 +2. 发布 Portal runtime 到 103 +3. 在 103 更新 `.env` 的 `IMGPROXY_BASE_URL=https://img.tkmind.cn` +4. 在 103 执行 `bash scripts/install-imgproxy-prod.sh` +5. 验证 `curl -i http://127.0.0.1:20081/health` +6. 再接 `img.tkmind.cn` 的 Caddy 入口 +7. 用一条真实 MindSpace 图片 URL 验证页面和聊天渲染 + +## 最小验收 + +### 103 本机 + +```bash +curl -i http://127.0.0.1:20081/health +launchctl list | grep cn.tkmind.imgproxy +tail -n 50 ~/Library/Logs/imgproxy.log +``` + +### Portal URL 生成 + +启动后的 Portal 日志里应继续看到: + +```text +[Publication] imgproxy signer initialized +[TKMindProxy] imgproxy signer initialized +``` + +### 业务验收 + +至少验证三条: + +1. MindSpace 公开页图片能显示 +2. 聊天消息里引用的图片能显示 +3. 旧的 `/api/mindspace/v1/assets/.../download` 公开图链不再作为首选发布路径 + +## 风险提示 + +当前 `g2-lb.Caddyfile` 里关于 `img.tkmind.cn` 的注释历史上把入口位置写成了 `105`,这已经不符合现在的真实架构。 +真实运行位应当以 `103 / Studio` 为准。 diff --git a/docs/local-dev.md b/docs/local-dev.md index 9cfd137..91b073d 100644 --- a/docs/local-dev.md +++ b/docs/local-dev.md @@ -2,7 +2,7 @@ > 这是本机本地开发文档,只处理当前工作区里的源码联调,不做任何生产同步。 > `pnpm dev` 不再启动 Plaza;如果需要联动 Plaza,请显式运行 `pnpm dev:all`,或者单独运行 `pnpm dev:plaza` / `pnpm start:plaza`。 -> Plaza 专用脚本的源码默认指向同级仓库 `../test-memindplaza/app/plaza`;如你的目录不同,请用 `PLAZA_APP_DIR` 覆盖。 +> Plaza 专用脚本的源码默认指向同级仓库 `../memind_plaza`;如你的目录不同,请用 `PLAZA_APP_DIR` 覆盖。 > 生产 / 测试 / 预览隔离仍单独看 [生产 / 测试 / 预览隔离规程](./service-isolation-runbook.md)。 `pnpm dev` 启动后,用 **127.0.0.1 + 端口** 访问本仓库自己的服务: @@ -38,7 +38,7 @@ pnpm dev:all | `OPS_PORT` | 3002 | Ops SPA | | `ADMIN_PORT` | 8082 | memind_adm | | `H5_PUBLIC_BASE_URL` | http://127.0.0.1:5173 | 公开链接基址 | -| `PLAZA_APP_DIR` | ../test-memindplaza/app/plaza | Plaza 专用脚本的源码路径(`pnpm dev:plaza` / `pnpm start:plaza` / `pnpm dev:all`) | +| `PLAZA_APP_DIR` | ../memind_plaza | Plaza 专用脚本的源码路径(`pnpm dev:plaza` / `pnpm start:plaza` / `pnpm dev:all`) | Plaza 本地开发说明见 [plaza-local.md](./plaza-local.md)。生产发布、同步与回滚不要在这里处理,统一看 [生产更新发布指南](./release-deploy.md)。 diff --git a/docs/memind-ai-mind-integration-assessment.md b/docs/memind-ai-mind-integration-assessment.md index b5fce3f..14717f1 100644 --- a/docs/memind-ai-mind-integration-assessment.md +++ b/docs/memind-ai-mind-integration-assessment.md @@ -1,7 +1,7 @@ # Memind 打通 AI Mind 项目评估报告 日期:2026-06-24 -范围:`/Users/john/PycharmProjects/test/test-memind` 与 `/Users/john/PycharmProjects/ai_mind` +范围:`/Users/john/Project/Memind` 与 `/Users/john/PycharmProjects/ai_mind` 状态:只读评估结论整理,后续可继续深化方案与实施拆解 ## 1. 结论 @@ -359,4 +359,3 @@ Memind 当前 `/sessions/:id/reply` 链路已有: - 列出 MVP 验收用例 完成这些后,再进入代码实现会更稳。 - diff --git a/docs/service-isolation-runbook.md b/docs/service-isolation-runbook.md index b8ba0b0..90df665 100644 --- a/docs/service-isolation-runbook.md +++ b/docs/service-isolation-runbook.md @@ -11,11 +11,11 @@ | 生产 | `/Users/john/Project/Memind` | `g2.tkmind.cn` 当前在线服务(阿里云解析 → 105 → 本机 Mac) | `8081` | | 生产 Plaza | `/Users/john/Project/Memind` + Plaza | `plaza.tkmind.cn` 当前在线服务 | `3001` | | 生产入口 | `/Users/john/Project/Memind/scripts/g2-lb.Caddyfile` | 105 转发入口 / 反代配置 | `8090` | -| 测试 Portal | `/Users/john/PycharmProjects/test/test-memind` | 开发预览 API / Portal | `18081` | -| 测试 Vite | `/Users/john/PycharmProjects/test/test-memind` | 开发预览前端 | `15173` | -| 测试 Admin | `/Users/john/PycharmProjects/test/test-memind` | 开发预览后台 | `18082` | -| 测试 Plaza | `/Users/john/PycharmProjects/test/test-memind` | 开发预览 Plaza | `13001` | -| 测试 Ops | `/Users/john/PycharmProjects/test/test-memind` | 开发预览 Ops | `13002` | +| 测试 Portal | `/Users/john/Project/Memind` | 开发预览 API / Portal | `18081` | +| 测试 Vite | `/Users/john/Project/Memind` | 开发预览前端 | `15173` | +| 测试 Admin | `/Users/john/Project/Memind` | 开发预览后台 | `18082` | +| 测试 Plaza | `/Users/john/Project/Memind` | 开发预览 Plaza | `13001` | +| 测试 Ops | `/Users/john/Project/Memind` | 开发预览 Ops | `13002` | 硬规则: @@ -64,7 +64,7 @@ g2.tkmind.cn 生产请求 | 项目目录 | 记忆含义 | |----------|----------| | `/Users/john/Project/Memind` | 生产项目的开发记忆 | -| `/Users/john/PycharmProjects/test/test-memind` | 测试项目的开发记忆 | +| `/Users/john/Project/Memind` | 测试项目的开发记忆 | | 测试 Goose 项目目录(按本机实际路径填写) | 测试 Goose 的开发记忆 | 硬规则: @@ -110,7 +110,7 @@ scripts/install-prod-services.sh 优先在测试目录进行: ```bash -cd /Users/john/PycharmProjects/test/test-memind +cd /Users/john/Project/Memind ``` 启动前先确认生产还在: @@ -146,7 +146,7 @@ pnpm dev 如果只改前端样式,优先只启动 Vite: ```bash -cd /Users/john/PycharmProjects/test/test-memind +cd /Users/john/Project/Memind VITE_PORT=15173 \ H5_PUBLIC_BASE_URL=http://127.0.0.1:15173 \ VITE_MINDSPACE_BASE=http://127.0.0.1:15173 \ diff --git a/mindspace-assets.mjs b/mindspace-assets.mjs index 55dd42c..f69312c 100644 --- a/mindspace-assets.mjs +++ b/mindspace-assets.mjs @@ -822,6 +822,25 @@ export function createAssetService(pool, options = {}) { }; }; + const readPublicAsset = async (assetId) => { + const [rows] = await pool.query( + `SELECT a.*, c.category_code, v.storage_key, v.scan_status + FROM h5_assets a + JOIN h5_space_categories c ON c.id = a.category_id AND c.user_id = a.user_id + JOIN h5_asset_versions v ON v.id = a.current_version_id + WHERE a.id = ? AND a.status <> 'deleted' + LIMIT 1`, + [assetId], + ); + const asset = rows[0]; + if (!asset) throw Object.assign(new Error('资产不存在'), { code: 'asset_not_found' }); + assertAssetDownloadable(asset); + return { + asset: assetResponse(asset), + path: await resolveReadableStoragePath(asset.storage_key), + }; + }; + const createChatAsset = async ( userId, { categoryCode, buffer, filename, displayName, sourceType = 'chat' }, @@ -1101,6 +1120,7 @@ export function createAssetService(pool, options = {}) { listAssets, deleteAsset, readAsset, + readPublicAsset, renderAssetPreview, extractAssetText, renderAssetThumbnail, diff --git a/mindspace-audit.mjs b/mindspace-audit.mjs index 6a037bf..cacff10 100644 --- a/mindspace-audit.mjs +++ b/mindspace-audit.mjs @@ -10,6 +10,7 @@ export function createMindSpaceAuditWriter(pool) { now = Date.now(), }) => { if (!pool) return; + if (!userId) return; await pool.query( `INSERT INTO h5_mindspace_audit_logs (user_id, action, object_type, object_id, ip, result, risk_level, created_at) diff --git a/mindspace-chat-save.mjs b/mindspace-chat-save.mjs index d4c8b71..396bf06 100644 --- a/mindspace-chat-save.mjs +++ b/mindspace-chat-save.mjs @@ -1,8 +1,186 @@ import fs from 'node:fs/promises'; import path from 'node:path'; -import { PUBLISH_ROOT_DIR, PUBLISH_KEY_UUID } from './user-publish.mjs'; +import { PUBLISH_ROOT_DIR, PUBLISH_KEY_UUID, PUBLIC_ZONE_DIR, resolvePublishDir } from './user-publish.mjs'; import { workspaceThumbnailRelativePath } from './mindspace-workspace-thumbnails.mjs'; +const PRIVATE_ASSET_URL_PATTERN = + /(?:https?:\/\/[^/]+)?\/api\/mindspace\/v1\/assets\/([a-z0-9-]+)\/download(?:\?[^"'<>\\\s)]*)?/gi; + +const PUBLIC_TEMP_IMAGE_DIR = '.tmp-images'; + +function extensionForMime(mimeType, filename = '') { + const ext = path.extname(String(filename ?? '')).replace(/^\./, '').toLowerCase(); + if (ext && /^[a-z0-9]{1,8}$/.test(ext)) return ext === 'jpeg' ? 'jpg' : ext; + if (mimeType === 'image/jpeg') return 'jpg'; + if (mimeType === 'image/png') return 'png'; + if (mimeType === 'image/webp') return 'webp'; + if (mimeType === 'image/gif') return 'gif'; + return 'bin'; +} + +function publicTempImageFilename(assetId, mimeType, fallbackFilename = '') { + return `${assetId}.${extensionForMime(mimeType, fallbackFilename)}`; +} + +function absoluteStoragePath(storageRoot, storageKey) { + const resolvedRoot = path.resolve(storageRoot); + const resolved = path.resolve(resolvedRoot, storageKey); + if (resolved !== resolvedRoot && !resolved.startsWith(`${resolvedRoot}${path.sep}`)) { + throw Object.assign(new Error('路径越界'), { code: 'invalid_storage_path' }); + } + return resolved; +} + +function workspaceTempImageRelativeUrl(htmlRelativePath, filename) { + const htmlDir = path.posix.dirname(String(htmlRelativePath ?? '').replace(/^\/+/, '')); + const tmpDir = path.posix.join(PUBLIC_ZONE_DIR, PUBLIC_TEMP_IMAGE_DIR); + const relDir = + htmlDir === '.' || !htmlDir + ? tmpDir + : path.posix.relative(htmlDir, tmpDir).replace(/\\/g, '/'); + return relDir ? `${relDir}/${filename}` : filename; +} + +export async function materializePrivateAssetsInWorkspaceHtml({ + pool, + storageRoot, + h5Root, + userId, + html, + htmlRelativePath, + writeBack = false, +}) { + const matches = [...String(html).matchAll(PRIVATE_ASSET_URL_PATTERN)]; + if (matches.length === 0) { + return { html, count: 0, changed: false }; + } + + const assetIds = [...new Set(matches.map((match) => match[1]).filter(Boolean))]; + const [assets] = await pool.query( + `SELECT a.id, a.mime_type, a.original_filename, v.storage_key + FROM h5_assets a + JOIN h5_asset_versions v ON v.id = a.current_version_id + WHERE a.user_id = ? AND a.id IN (?) AND a.mime_type LIKE 'image/%' + AND a.status <> 'deleted'`, + [userId, assetIds], + ); + + const publishDir = resolvePublishDir(h5Root, { id: userId }); + const tmpDir = path.join(publishDir, PUBLIC_ZONE_DIR, PUBLIC_TEMP_IMAGE_DIR); + const urlMap = new Map(); + + for (const asset of assets) { + const filename = publicTempImageFilename(asset.id, asset.mime_type, asset.original_filename); + const target = path.join(tmpDir, filename); + try { + const sourcePath = absoluteStoragePath(storageRoot, asset.storage_key); + await fs.mkdir(tmpDir, { recursive: true }); + await fs.copyFile(sourcePath, target); + urlMap.set(asset.id, workspaceTempImageRelativeUrl(htmlRelativePath, filename)); + } catch { + // skip unreadable assets + } + } + + if (urlMap.size === 0) { + return { html, count: 0, changed: false }; + } + + let count = 0; + const result = String(html).replace(PRIVATE_ASSET_URL_PATTERN, (value, assetId) => { + const nextUrl = urlMap.get(assetId); + if (!nextUrl) return value; + count += 1; + return nextUrl; + }); + + if (count === 0 || result === html) { + return { html, count: 0, changed: false }; + } + + if (writeBack) { + const htmlPath = path.join(publishDir, htmlRelativePath); + await fs.writeFile(htmlPath, result, 'utf8'); + } + + return { html: result, count, changed: true }; +} + +export async function repairMissingHtmlAssetReferences({ pool, userId, sourceContent, html }) { + const collectIds = (text) => [ + ...new Set([...String(text).matchAll(PRIVATE_ASSET_URL_PATTERN)].map((match) => match[1])), + ]; + const htmlIds = collectIds(html); + const messageIds = collectIds(sourceContent); + if (htmlIds.length === 0 || messageIds.length === 0) { + return { html, changed: false }; + } + + const [rows] = await pool.query( + `SELECT id FROM h5_assets + WHERE user_id = ? AND id IN (?) AND mime_type LIKE 'image/%' AND status <> 'deleted'`, + [userId, [...new Set([...htmlIds, ...messageIds])]], + ); + const existing = new Set(rows.map((row) => row.id)); + const invalidHtmlIds = htmlIds.filter((id) => !existing.has(id)); + const validMessageIds = messageIds.filter((id) => existing.has(id)); + if (invalidHtmlIds.length === 0 || validMessageIds.length === 0) { + return { html, changed: false }; + } + + const idMap = new Map(); + for (let index = 0; index < invalidHtmlIds.length; index += 1) { + const replacement = validMessageIds[index]; + if (!replacement) break; + idMap.set(invalidHtmlIds[index], replacement); + } + if (idMap.size === 0) { + return { html, changed: false }; + } + + let changed = false; + const nextHtml = String(html).replace(PRIVATE_ASSET_URL_PATTERN, (value, assetId) => { + const replacement = idMap.get(assetId); + if (!replacement) return value; + changed = true; + return value.replace(assetId, replacement); + }); + return { html: nextHtml, changed }; +} + +export function createAssetDataUriResolver(pool, storageRoot, userId) { + const cache = new Map(); + return async (assetId) => { + const key = String(assetId ?? '').trim(); + if (!key) return null; + if (cache.has(key)) return cache.get(key); + const [rows] = await pool.query( + `SELECT a.mime_type, v.storage_key + FROM h5_assets a + JOIN h5_asset_versions v ON v.id = a.current_version_id + WHERE a.user_id = ? AND a.id = ? AND a.mime_type LIKE 'image/%' + AND a.status <> 'deleted' + LIMIT 1`, + [userId, key], + ); + const asset = rows[0]; + if (!asset) { + cache.set(key, null); + return null; + } + try { + const buffer = await fs.readFile(absoluteStoragePath(storageRoot, asset.storage_key)); + const mimeType = String(asset.mime_type || 'image/jpeg'); + const dataUri = `data:${mimeType};base64,${buffer.toString('base64')}`; + cache.set(key, dataUri); + return dataUri; + } catch { + cache.set(key, null); + return null; + } + }; +} + const URL_PATTERN = /https?:\/\/[^\s<>"')\]]+\/(?:MindSpace|temp)\/([0-9a-f-]{36}|[a-z0-9._-]+)\/([^\s<>"')\]]+\.html)/gi; diff --git a/mindspace-chat-save.test.mjs b/mindspace-chat-save.test.mjs index 3fcf887..38c89bc 100644 --- a/mindspace-chat-save.test.mjs +++ b/mindspace-chat-save.test.mjs @@ -13,6 +13,8 @@ import { extractStaticPageLinks, findPublishHtml, injectHtmlBaseHref, + materializePrivateAssetsInWorkspaceHtml, + repairMissingHtmlAssetReferences, resolveClosestHtmlRelativePath, resolveChatSaveAnalysis, sanitizeMessageContentForSave, @@ -185,3 +187,60 @@ test('resolveChatSaveAnalysis falls back to local html filename hints', async () assert.equal(result.analysis.contentMode, 'static_html'); assert.equal(result.resolvedHtml?.relativePath, 'public/welcome-v2.html'); }); + +test('materializePrivateAssetsInWorkspaceHtml copies images to public/.tmp-images', async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'mindspace-chat-save-materialize-')); + const storageRoot = path.join(root, 'storage'); + const assetId = '11111111-1111-4111-8111-111111111111'; + const userId = USER_ID; + const storageKey = `users/${userId}/assets/${assetId}/versions/v1`; + const sourcePath = path.join(storageRoot, storageKey); + await fs.mkdir(path.dirname(sourcePath), { recursive: true }); + await fs.writeFile(sourcePath, Buffer.from([0xff, 0xd8, 0xff, 0xd9])); + + const publishDir = path.join(root, 'MindSpace', userId, 'public'); + await fs.mkdir(publishDir, { recursive: true }); + const htmlRelativePath = 'public/look.html'; + const htmlPath = path.join(root, 'MindSpace', userId, htmlRelativePath); + const html = ``; + await fs.writeFile(htmlPath, html, 'utf8'); + + const pool = { + query: async () => [ + [{ id: assetId, mime_type: 'image/jpeg', original_filename: 'hero.jpg', storage_key: storageKey }], + ], + }; + + const result = await materializePrivateAssetsInWorkspaceHtml({ + pool, + storageRoot, + h5Root: root, + userId, + html, + htmlRelativePath, + writeBack: true, + }); + + assert.equal(result.changed, true); + assert.match(result.html, /\.tmp-images\/11111111-1111-4111-8111-111111111111\.jpg/); + await fs.stat(path.join(publishDir, '.tmp-images', `${assetId}.jpg`)); +}); + +test('repairMissingHtmlAssetReferences swaps invalid html asset ids from chat message', async () => { + const validId = '22222222-2222-4222-8222-222222222222'; + const invalidId = '33333333-3333-4333-8333-333333333333'; + const html = ``; + const sourceContent = `[图片1]: /api/mindspace/v1/assets/${validId}/download?inline=1`; + const pool = { + query: async (_sql, params) => [[{ id: validId }]], + }; + const result = await repairMissingHtmlAssetReferences({ + pool, + userId: USER_ID, + sourceContent, + html, + }); + assert.equal(result.changed, true); + assert.match(result.html, new RegExp(validId)); + assert.doesNotMatch(result.html, new RegExp(invalidId)); +}); diff --git a/mindspace-thumbnails.mjs b/mindspace-thumbnails.mjs index c0b83cd..4d90e00 100644 --- a/mindspace-thumbnails.mjs +++ b/mindspace-thumbnails.mjs @@ -132,7 +132,13 @@ function resolveLocalImagePath({ storageRoot, contentStorageKey, contentBaseDir, return target; } -export async function resolveCoverDataUri({ storageRoot, contentStorageKey, contentBaseDir, imageUrl }) { +export async function resolveCoverDataUri({ + storageRoot, + contentStorageKey, + contentBaseDir, + imageUrl, + resolveAssetDataUri, +}) { const raw = String(imageUrl ?? '').trim(); if (!raw) return null; if (raw.startsWith('data:')) return raw.length <= MAX_COVER_BYTES * 2 ? raw : null; @@ -143,6 +149,14 @@ export async function resolveCoverDataUri({ storageRoot, contentStorageKey, cont return null; } } + const assetMatch = raw.match(/\/mindspace\/v1\/assets\/([a-z0-9-]+)(?:\/download)?/i); + if (assetMatch && resolveAssetDataUri) { + try { + return await resolveAssetDataUri(assetMatch[1]); + } catch { + return null; + } + } const localPath = resolveLocalImagePath({ storageRoot, contentStorageKey, contentBaseDir, imageUrl: raw }); if (!localPath) return null; try { @@ -458,6 +472,7 @@ export async function generateHtmlThumbnail(storageRoot, storageKey, html, meta contentStorageKey: meta.contentStorageKey, contentBaseDir: meta.contentBaseDir, imageUrl: signals.image, + resolveAssetDataUri: meta.resolveAssetDataUri, }); const svg = buildFeedThumbnailSvg(signals, { coverDataUri }); await writeThumbnail(storageRoot, storageKey, svg); diff --git a/scripts/audit-103-state.sh b/scripts/audit-103-state.sh index 28a92b3..3bfaeab 100755 --- a/scripts/audit-103-state.sh +++ b/scripts/audit-103-state.sh @@ -36,6 +36,6 @@ say "本地与 103 HEAD 对比" remote_memind="$(ssh -o BatchMode=yes -o ConnectTimeout=15 "${HOST}" "cd /Users/john/Project/Memind && git rev-parse --short HEAD")" remote_adm="$(ssh -o BatchMode=yes -o ConnectTimeout=15 "${HOST}" "cd /Users/john/Project/memind_adm && git rev-parse --short HEAD")" local_memind="$(git -C "$(cd "$(dirname "$0")/.." && pwd)" rev-parse --short HEAD)" -local_adm="$(git -C /Users/john/PycharmProjects/test/test-memindadm rev-parse --short HEAD)" +local_adm="$(git -C /Users/john/Project/memind_adm rev-parse --short HEAD)" printf 'local_memind=%s remote_memind=%s\n' "${local_memind}" "${remote_memind}" printf 'local_memindadm=%s remote_memindadm=%s\n' "${local_adm}" "${remote_adm}" diff --git a/scripts/deploy-plaza-105.sh b/scripts/deploy-plaza-105.sh index 4409932..dc57f70 100755 --- a/scripts/deploy-plaza-105.sh +++ b/scripts/deploy-plaza-105.sh @@ -22,7 +22,7 @@ DEPLOY_DIR="${ROOT}/deploy/plaza-105" DEPLOY_ENV="${DEPLOY_DIR}/plaza-105.env" EXCLUDE="${ROOT}/scripts/.rsync-exclude-plaza-web" -PLAZA_APP_DIR="${PLAZA_APP_DIR:-${ROOT}/../tkmind_go/ui/plaza}" +PLAZA_APP_DIR="${PLAZA_APP_DIR:-${ROOT}/../memind_plaza}" PLAZA_DEPLOY_HOST="${PLAZA_DEPLOY_HOST:-root@120.26.184.105}" PLAZA_REMOTE_DIR="${PLAZA_REMOTE_DIR:-/root/plaza/web}" PLAZA_PROD_URL="${PLAZA_PROD_URL:-https://plaza.tkmind.cn}" diff --git a/scripts/deploy-to-test-host.sh b/scripts/deploy-to-test-host.sh index a86b741..b307d73 100755 --- a/scripts/deploy-to-test-host.sh +++ b/scripts/deploy-to-test-host.sh @@ -13,11 +13,11 @@ EXCLUDE_FILE="${ROOT_DIR}/scripts/.rsync-exclude-test-host" MEMIND_SRC="${MEMIND_SRC:-${ROOT_DIR}}" ADM_SRC="${ADM_SRC:-${ROOT_DIR}/../memind_adm}" -PLAZA_SRC="${PLAZA_SRC:-${ROOT_DIR}/../tkmind_go/ui/plaza}" +PLAZA_SRC="${PLAZA_SRC:-${ROOT_DIR}/../memind_plaza}" -MEMIND_DST="${MEMIND_DST:-/Users/john/PycharmProjects/test/test-memind}" -ADM_DST="${ADM_DST:-/Users/john/PycharmProjects/test/test-memindadm}" -PLAZA_DST="${PLAZA_DST:-/Users/john/PycharmProjects/test/test-memindplaza}" +MEMIND_DST="${MEMIND_DST:-/Users/john/Project/Memind}" +ADM_DST="${ADM_DST:-/Users/john/Project/memind_adm}" +PLAZA_DST="${PLAZA_DST:-/Users/john/Project/memind_plaza}" RSYNC_OPTS=( -az diff --git a/scripts/dev-plaza.mjs b/scripts/dev-plaza.mjs index 9ded8f2..14e3f71 100644 --- a/scripts/dev-plaza.mjs +++ b/scripts/dev-plaza.mjs @@ -12,9 +12,7 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..'); -const defaultPlazaDir = fs.existsSync(path.join(root, '../test-memindplaza/app/plaza/package.json')) - ? path.join(root, '../test-memindplaza/app/plaza') - : path.join(root, '../tkmind_go/ui/plaza'); +const defaultPlazaDir = path.join(root, '../memind_plaza'); const plazaDir = process.env.PLAZA_APP_DIR ?? defaultPlazaDir; const portalPort = Number(process.env.H5_PORT ?? 8081); const plazaPort = Number(process.env.PLAZA_PORT ?? 3001); @@ -119,7 +117,7 @@ process.on('SIGINT', () => shutdown(0)); process.on('SIGTERM', () => shutdown(0)); if (!fs.existsSync(path.join(plazaDir, 'package.json'))) { - console.error(`未找到 Plaza Next.js:${plazaDir}。请设置 PLAZA_APP_DIR 指向本地 Plaza 源码,例如 ../test-memindplaza/app/plaza`); + console.error(`未找到 Plaza Next.js:${plazaDir}。请设置 PLAZA_APP_DIR 指向本地 Plaza 源码,例如 ../memind_plaza`); process.exit(1); } diff --git a/scripts/dev.mjs b/scripts/dev.mjs index 0015e74..7f2c603 100755 --- a/scripts/dev.mjs +++ b/scripts/dev.mjs @@ -7,9 +7,7 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..'); -const defaultPlazaDir = fs.existsSync(path.join(root, '../test-memindplaza/app/plaza/package.json')) - ? path.join(root, '../test-memindplaza/app/plaza') - : path.join(root, '../tkmind_go/ui/plaza'); +const defaultPlazaDir = path.join(root, '../memind_plaza'); const plazaDir = process.env.PLAZA_APP_DIR ?? defaultPlazaDir; const opsDir = path.join(root, 'ops'); const portalPort = Number(process.env.H5_PORT ?? 8081); @@ -125,7 +123,7 @@ const opsEnv = { function ensurePlazaApp() { if (!fs.existsSync(path.join(plazaDir, 'package.json'))) { - throw new Error(`未找到 Plaza Next.js:${plazaDir}。请设置 PLAZA_APP_DIR 指向本地 Plaza 源码,例如 ../test-memindplaza/app/plaza`); + throw new Error(`未找到 Plaza Next.js:${plazaDir}。请设置 PLAZA_APP_DIR 指向本地 Plaza 源码,例如 ../memind_plaza`); } } diff --git a/scripts/g2-lb.Caddyfile b/scripts/g2-lb.Caddyfile index 4667281..6657ca3 100644 --- a/scripts/g2-lb.Caddyfile +++ b/scripts/g2-lb.Caddyfile @@ -44,22 +44,22 @@ gadm.tkmind.cn { } } -# img.tkmind.cn:图片缓存与授权 (forward_auth → imgproxy) -# 在生产环境(105)上: -# 1. TLS 证书指向实际的 .acme.sh 目录 -# 2. forward_auth 指向 103 的授权端点 -# 3. reverse_proxy 指向 103 的 imgproxy 进程 +# img.tkmind.cn:图片缩放入口(当前真实运行位应在 103 / Studio) +# 注意: +# 1. 现有 signer 只生成签名路径,不会附带 asset_id query +# 2. 因此下面的 forward_auth 示例目前还不能直接用于“按资产鉴权” +# 3. 先确保 103 本机已有 imgproxy 监听 127.0.0.1:20081,再接入公网域名 img.tkmind.cn { - # 本地开发时如果没有证书,可注释掉 tls 行 + # 生产证书路径按 103 当前 .acme.sh 实际位置填写 # tls /Users/john/.acme.sh/img.tkmind.cn_ecc/fullchain.cer /Users/john/.acme.sh/img.tkmind.cn_ecc/img.tkmind.cn.key - # forward_auth 检查图片访问权限 + # 如果后续 signer URL 带上 asset_id,可启用这层鉴权 # forward_auth 127.0.0.1:8081 /api/mindspace/v1/authorize-image { # uri /api/mindspace/v1/authorize-image?asset_id={query.asset_id} # copy_headers X-Request-Id # } - # reverse_proxy 到 imgproxy(生产在 103) + # reverse_proxy 到 103 本机 imgproxy # reverse_proxy 127.0.0.1:20081 { # flush_interval -1 # } diff --git a/scripts/install-imgproxy-prod.sh b/scripts/install-imgproxy-prod.sh new file mode 100755 index 0000000..0ff6870 --- /dev/null +++ b/scripts/install-imgproxy-prod.sh @@ -0,0 +1,111 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="${ROOT:-/Users/john/Project/Memind}" +ENV_FILE="${ENV_FILE:-${ROOT}/.env}" +LABEL="${LABEL:-cn.tkmind.imgproxy}" +PLIST="${HOME}/Library/LaunchAgents/${LABEL}.plist" +LOG_DIR="${HOME}/Library/Logs" +LOG_FILE="${LOG_DIR}/imgproxy.log" +IMGPROXY_BIN="${IMGPROXY_BIN:-/opt/homebrew/bin/imgproxy}" +IMGPROXY_BIND="${IMGPROXY_BIND:-127.0.0.1:20081}" +UID_NUM="$(id -u)" +GUI="gui/${UID_NUM}" + +if [[ ! -f "${ENV_FILE}" ]]; then + echo "未找到环境文件: ${ENV_FILE}" >&2 + exit 1 +fi + +if [[ ! -x "${IMGPROXY_BIN}" ]]; then + echo "未找到 imgproxy: ${IMGPROXY_BIN}" >&2 + echo "请先在 103 执行: brew install imgproxy" >&2 + exit 1 +fi + +mkdir -p "${HOME}/Library/LaunchAgents" "${LOG_DIR}" + +set -a +source "${ENV_FILE}" +set +a + +MINDSPACE_STORAGE_ROOT="${MINDSPACE_STORAGE_ROOT:-${ROOT}/data/mindspace}" +IMGPROXY_SIGNING_KEY="${IMGPROXY_SIGNING_KEY:-}" +IMGPROXY_SIGNING_SALT="${IMGPROXY_SIGNING_SALT:-}" + +if [[ -z "${IMGPROXY_SIGNING_KEY}" || -z "${IMGPROXY_SIGNING_SALT}" ]]; then + echo "缺少 IMGPROXY_SIGNING_KEY / IMGPROXY_SIGNING_SALT,无法安装 imgproxy" >&2 + exit 1 +fi + +cat >"${PLIST}" < + + + + Label + ${LABEL} + ProgramArguments + + /bin/bash + -lc + + export IMGPROXY_BIND='${IMGPROXY_BIND}'; + export IMGPROXY_LOCAL_FILESYSTEM_ROOT='${MINDSPACE_STORAGE_ROOT}'; + export IMGPROXY_KEY='${IMGPROXY_SIGNING_KEY}'; + export IMGPROXY_SALT='${IMGPROXY_SIGNING_SALT}'; + export IMGPROXY_USE_ETAG='true'; + export IMGPROXY_ENABLE_WEBP_DETECTION='true'; + exec '${IMGPROXY_BIN}' + + + WorkingDirectory + ${ROOT} + EnvironmentVariables + + PATH + /opt/homebrew/bin:/opt/homebrew/opt/node@24/bin:/usr/local/bin:/usr/bin:/bin + + RunAtLoad + + KeepAlive + + ThrottleInterval + 10 + StandardOutPath + ${LOG_FILE} + StandardErrorPath + ${LOG_FILE} + + +EOF + +if launchctl print "${GUI}/${LABEL}" >/dev/null 2>&1; then + launchctl bootout "${GUI}/${LABEL}" 2>/dev/null || true + sleep 1 +fi + +launchctl bootstrap "${GUI}" "${PLIST}" +launchctl enable "${GUI}/${LABEL}" +launchctl kickstart -k "${GUI}/${LABEL}" + +echo "等待 imgproxy 就绪..." +for _ in $(seq 1 20); do + if curl -sf "http://${IMGPROXY_BIND}/health" >/dev/null 2>&1; then + break + fi + sleep 1 +done + +echo "" +echo "=== imgproxy 状态 ===" +launchctl list | grep "${LABEL}" || true +curl -i "http://${IMGPROXY_BIND}/health" || true +echo "" +echo "LaunchAgent: ${PLIST}" +echo "Log file: ${LOG_FILE}" +echo "Storage: ${MINDSPACE_STORAGE_ROOT}" +echo "Bind: ${IMGPROXY_BIND}" +echo "" +echo "如果这是 103 生产环境,记得把 Portal .env 中的 IMGPROXY_BASE_URL 设为:" +echo " IMGPROXY_BASE_URL=https://img.tkmind.cn" diff --git a/scripts/seed-plaza-covers.mjs b/scripts/seed-plaza-covers.mjs index 09591e8..b0d468b 100644 --- a/scripts/seed-plaza-covers.mjs +++ b/scripts/seed-plaza-covers.mjs @@ -192,7 +192,7 @@ const coverBase = process.env.PLAZA_COVER_BASE ?? '/plaza-covers'; const plazaPublicDir = process.env.PLAZA_APP_DIR ? path.join(process.env.PLAZA_APP_DIR, 'public/plaza-covers') - : path.join(root, '../tkmind_go/ui/plaza/public/plaza-covers'); + : path.join(root, '../memind_plaza/public/plaza-covers'); fs.mkdirSync(coversDir, { recursive: true }); diff --git a/scripts/start-plaza-prod.mjs b/scripts/start-plaza-prod.mjs index 16b805c..5dc586b 100644 --- a/scripts/start-plaza-prod.mjs +++ b/scripts/start-plaza-prod.mjs @@ -12,9 +12,7 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..'); -const defaultPlazaDir = fs.existsSync(path.join(root, '../test-memindplaza/app/plaza/package.json')) - ? path.join(root, '../test-memindplaza/app/plaza') - : path.join(root, '../tkmind_go/ui/plaza'); +const defaultPlazaDir = path.join(root, '../memind_plaza'); const plazaDir = process.env.PLAZA_APP_DIR ?? defaultPlazaDir; const portalPort = Number(process.env.H5_PORT ?? 8081); const plazaPort = Number(process.env.PLAZA_PORT ?? 3001); @@ -102,7 +100,7 @@ process.on('SIGINT', () => shutdown(0)); process.on('SIGTERM', () => shutdown(0)); if (!fs.existsSync(path.join(plazaDir, 'package.json'))) { - console.error(`未找到 Plaza Next.js:${plazaDir}。请设置 PLAZA_APP_DIR 指向本地 Plaza 源码,例如 ../test-memindplaza/app/plaza`); + console.error(`未找到 Plaza Next.js:${plazaDir}。请设置 PLAZA_APP_DIR 指向本地 Plaza 源码,例如 ../memind_plaza`); process.exit(1); } diff --git a/server.mjs b/server.mjs index e468f4e..5c1a97b 100644 --- a/server.mjs +++ b/server.mjs @@ -71,6 +71,9 @@ import { buildWorkspaceThumbnailUrl, injectHtmlBaseHref, resolveClosestHtmlRelativePath, + createAssetDataUriResolver, + materializePrivateAssetsInWorkspaceHtml, + repairMissingHtmlAssetReferences, resolveChatSaveAnalysis, resolveStaticHtmlContent, } from './mindspace-chat-save.mjs'; @@ -1559,6 +1562,9 @@ api.use(async (req, res, next) => { if (req.path.startsWith('/internal/agent/')) return next(); if (req.path === '/agent/mindspace_page_patch') return next(); if (req.path === '/config/blocked-words') return next(); + if (req.method === 'GET' && /^\/mindspace\/v1\/assets\/[^/]+\/download$/.test(req.path)) { + return next(); + } const plazaPublic = isPlazaPublicRead(req.path, req.method); @@ -2189,17 +2195,65 @@ api.get('/mindspace/v1/authorize-image', async (req, res) => { api.get('/mindspace/v1/assets/:assetId/download', async (req, res) => { if (!mindSpaceAssets || !ensureMindSpaceEnabled(res, req)) return; try { - const { asset, path: assetPath } = await mindSpaceAssets.readAsset( - req.currentUser.id, - req.params.assetId, - ); + const assetId = req.params.assetId; + const currentUserId = req.currentUser?.id ?? null; + let allowedByPublication = false; + let publicationAccessMode = null; + if (!currentUserId) { + const referrer = String(req.get('referer') ?? req.get('referrer') ?? ''); + const isPublicPageReferrer = (() => { + if (!referrer) return false; + try { + return new URL(referrer, resolveRequestOrigin(req) || 'http://localhost').pathname.includes('/public/'); + } catch { + return referrer.includes('/public/'); + } + })(); + const [refs] = await authPool.query( + `SELECT pr.access_mode, pr.expires_at + FROM h5_publication_asset_refs refs + JOIN h5_publish_records pr ON refs.publication_id = pr.id + WHERE refs.asset_id = ? AND pr.status = 'online' + ORDER BY CASE + WHEN pr.access_mode = 'public' THEN 0 + WHEN pr.access_mode = 'time_limited' THEN 1 + ELSE 2 + END, + pr.expires_at DESC + LIMIT 1`, + [assetId], + ); + const publication = refs[0] ?? null; + if (publication?.access_mode === 'public') { + allowedByPublication = true; + publicationAccessMode = 'public'; + } else if ( + publication?.access_mode === 'time_limited' && + publication.expires_at && + Number(publication.expires_at) > Date.now() + ) { + allowedByPublication = true; + publicationAccessMode = 'time_limited'; + } + if (!allowedByPublication && isPublicPageReferrer) { + allowedByPublication = true; + publicationAccessMode = 'public-page-referrer'; + } + if (!allowedByPublication) { + return res.status(401).json({ message: '未授权,请重新登录' }); + } + } + const { asset, path: assetPath } = currentUserId + ? await mindSpaceAssets.readAsset(currentUserId, assetId) + : await mindSpaceAssets.readPublicAsset(assetId); await mindSpaceAudit?.write({ - userId: req.currentUser.id, + userId: req.currentUser?.id ?? null, action: 'asset.download', objectType: 'asset', - objectId: req.params.assetId, + objectId: assetId, ip: req.ip, riskLevel: asset.riskLevel, + detail: allowedByPublication ? { accessMode: publicationAccessMode } : undefined, }); res.type(asset.mimeType); const inline = @@ -2207,7 +2261,7 @@ api.get('/mindspace/v1/assets/:assetId/download', async (req, res) => { req.query.disposition === 'inline' || req.get('sec-fetch-dest') === 'iframe'; if (inline && asset.mimeType.startsWith('image/') && wantsInlineImageViewer(req)) { - const downloadUrl = `/api/mindspace/v1/assets/${encodeURIComponent(req.params.assetId)}/download?inline=1`; + const downloadUrl = `/api/mindspace/v1/assets/${encodeURIComponent(assetId)}/download?inline=1`; const html = renderImageAssetViewerHtml({ asset, downloadUrl }); res.set('Content-Type', 'text/html; charset=utf-8'); res.set('Cache-Control', 'private, no-store'); @@ -2224,7 +2278,7 @@ api.get('/mindspace/v1/assets/:assetId/download', async (req, res) => { return res.sendFile(assetPath); } catch (error) { await mindSpaceAudit?.write({ - userId: req.currentUser.id, + userId: req.currentUser?.id ?? null, action: 'asset.download', objectType: 'asset', objectId: req.params.assetId, @@ -2384,13 +2438,53 @@ async function resolveChatSaveBundle(user, h5Root, input = {}) { const previewSummary = String(input.previewSummary ?? input.preview_summary ?? '').trim(); const source = await resolveOwnedAssistantMessage(user.id, sessionId, messageId); - const { analysis, resolvedHtml } = await resolveChatSaveAnalysis({ + let { analysis, resolvedHtml } = await resolveChatSaveAnalysis({ content: source.content, userId: user.id, username: user.username, h5Root, selectedLinkIndex, }); + + if (resolvedHtml?.content && resolvedHtml.relativePath && authPool) { + const storageRoot = + process.env.MINDSPACE_STORAGE_ROOT ?? path.join(__dirname, 'data', 'mindspace'); + let htmlContent = resolvedHtml.content; + + const repaired = await repairMissingHtmlAssetReferences({ + pool: authPool, + userId: user.id, + sourceContent: source.content, + html: htmlContent, + }).catch(() => ({ html: htmlContent, changed: false })); + if (repaired.changed) { + htmlContent = repaired.html; + } + + const materialized = await materializePrivateAssetsInWorkspaceHtml({ + pool: authPool, + storageRoot, + h5Root, + userId: user.id, + html: htmlContent, + htmlRelativePath: resolvedHtml.relativePath, + writeBack: false, + }).catch(() => ({ html: htmlContent, count: 0, changed: false })); + if (materialized.changed) { + htmlContent = materialized.html; + } + + if (htmlContent !== resolvedHtml.content) { + const publishDir = resolvePublishDir(h5Root, { id: user.id }); + await fsPromises.writeFile( + path.join(publishDir, resolvedHtml.relativePath), + htmlContent, + 'utf8', + ); + resolvedHtml = { ...resolvedHtml, content: htmlContent }; + } + } + return { source, analysis, @@ -2464,10 +2558,18 @@ api.get('/mindspace/v1/pages/chat-save-thumbnail', async (req, res) => { bundle.resolvedHtml.content, { title, subtitle, force: true }, ).catch(() => {}); + const storageRoot = + process.env.MINDSPACE_STORAGE_ROOT ?? path.join(__dirname, 'data', 'mindspace'); + const resolveAssetDataUri = createAssetDataUriResolver( + authPool, + storageRoot, + req.currentUser.id, + ); const svg = await generateHtmlThumbnail(publishDir, thumbRel, bundle.resolvedHtml.content, { title, subtitle, contentBaseDir: path.dirname(bundle.resolvedHtml.absolute), + resolveAssetDataUri, force: true, }); res.set('Content-Type', 'image/svg+xml; charset=utf-8'); @@ -2482,6 +2584,8 @@ api.get('/mindspace/v1/pages/chat-save-thumbnail', async (req, res) => { api.post('/mindspace/v1/pages/analyze-chat-save', async (req, res) => { if (!mindSpacePages) return res.status(503).json({ message: 'MindSpace 未启用' }); try { + const sessionId = req.body?.session_id; + const messageId = req.body?.message_id; const bundle = await resolveChatSaveBundle(req.currentUser, __dirname, req.body); const { source, @@ -2687,7 +2791,11 @@ api.post('/mindspace/v1/pages/save-from-chat', async (req, res) => { : null; let page; if (replacePageId) { - page = await mindSpacePages.updatePage(req.currentUser.id, replacePageId, pageInput); + const existingPage = await mindSpacePages.getPage(req.currentUser.id, replacePageId); + page = await mindSpacePages.updatePage(req.currentUser.id, replacePageId, { + ...pageInput, + expectedVersion: existingPage.versionNo, + }); } else { page = await mindSpacePages.createFromChat(req.currentUser.id, pageInput, { sessionId: req.body.session_id, diff --git a/src/components/ChatPanel.tsx b/src/components/ChatPanel.tsx index c707182..4f32a80 100644 --- a/src/components/ChatPanel.tsx +++ b/src/components/ChatPanel.tsx @@ -48,7 +48,8 @@ const CHAT_PLACEHOLDER_PROMPTS = [ type PendingChatImage = { id: string; url: string; - local: boolean; + previewUrl: string; + uploading: boolean; }; function FileIcon() { @@ -87,7 +88,7 @@ export function ChatPanel({ session: Session | null; capabilities?: CapabilityMap; grantedSkills?: string[]; - onSubmit: (text: string, imageUrls?: string[]) => void | Promise; + onSubmit: (text: string, imageUrls?: string[], previewImageUrls?: string[]) => void | Promise; onUploadImage?: (file: File) => Promise; onStop: () => void | Promise; onApproveTool: (allow: boolean) => void | Promise; @@ -124,7 +125,7 @@ export function ChatPanel({ useEffect(() => { return () => { pendingImagesRef.current.forEach((item) => { - if (item.local) URL.revokeObjectURL(item.url); + if (item.previewUrl.startsWith('blob:')) URL.revokeObjectURL(item.previewUrl); }); }; }, []); @@ -183,17 +184,18 @@ export function ChatPanel({ const imageAttachmentDisabled = !onUploadImage || busy || uploadingImage || offlineBlocked || !!pendingTool; const revokePendingImage = (item: PendingChatImage) => { - if (item.local) URL.revokeObjectURL(item.url); + if (item.previewUrl.startsWith('blob:')) URL.revokeObjectURL(item.previewUrl); }; const handleSubmit = async () => { const trimmed = input.trim(); if ((!trimmed && pendingImages.length === 0) || voiceDisabled) return; - if (uploadingImage || pendingImages.some((item) => item.local)) { + if (uploadingImage || pendingImages.some((item) => item.uploading)) { setImageError('图片仍在上传中,请稍候再发送'); return; } const imagesToSend = pendingImages.map((item) => item.url); + const previewImagesToSend = pendingImages.map((item) => item.previewUrl); const sentImages = [...pendingImages]; suppressVoiceUpdateRef.current = true; @@ -205,7 +207,7 @@ export function ChatPanel({ setVoiceNotice(null); setImageError(null); try { - await onSubmit(trimmed, imagesToSend); + await onSubmit(trimmed, imagesToSend, previewImagesToSend); sentImages.forEach(revokePendingImage); setPendingImages([]); } catch (err) { @@ -245,8 +247,9 @@ export function ChatPanel({ const placeholders = toUpload.map((file, index) => ({ id: `${Date.now()}-${index}-${file.name}`, - url: URL.createObjectURL(file), - local: true, + previewUrl: URL.createObjectURL(file), + url: '', + uploading: true, })); setPendingImages((prev) => [...prev, ...placeholders]); @@ -258,8 +261,7 @@ export function ChatPanel({ const next = prev.map((item) => { const index = placeholderIds.indexOf(item.id); if (index < 0 || !uploaded[index]) return item; - revokePendingImage(item); - return { ...item, url: uploaded[index], local: false }; + return { ...item, url: uploaded[index], uploading: false }; }); const seen = new Set(); return next.filter((item) => { @@ -310,8 +312,9 @@ export function ChatPanel({ const placeholders = toUpload.map((file, index) => ({ id: `${Date.now()}-${index}-${file.name || 'pasted-image'}`, - url: URL.createObjectURL(file), - local: true, + previewUrl: URL.createObjectURL(file), + url: '', + uploading: true, })); setPendingImages((prev) => [...prev, ...placeholders]); @@ -323,8 +326,7 @@ export function ChatPanel({ const next = prev.map((item) => { const index = placeholderIds.indexOf(item.id); if (index < 0 || !uploaded[index]) return item; - revokePendingImage(item); - return { ...item, url: uploaded[index], local: false }; + return { ...item, url: uploaded[index], uploading: false }; }); const seen = new Set(); return next.filter((item) => { @@ -419,8 +421,8 @@ export function ChatPanel({
{pendingImages.map((item, index) => (
- - {`上传图片 + + {`上传图片