feat: improve mindspace asset handling and local runtime paths

This commit is contained in:
john
2026-06-28 12:18:26 +08:00
parent 4a9bc710f1
commit ea19ffb5fa
36 changed files with 867 additions and 100 deletions
+1 -1
View File
@@ -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
```
+2 -2
View File
@@ -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(或当前文档规定的目标机)
+2 -2
View File
@@ -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/
+3 -3
View File
@@ -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 \
+2 -2
View File
@@ -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
+184
View File
@@ -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://<storage_key>` 读到真实文件
也就是说,生产上要保持的是:
- 同一套签名 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/<userId>/temp/<file>.upload
```
`imgproxy-signer.mjs` 会把它转成:
```text
local://users/<userId>/temp/<file>.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` 为准。
+2 -2
View File
@@ -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)。
@@ -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 验收用例
完成这些后,再进入代码实现会更稳。
+8 -8
View File
@@ -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 \
+20
View File
@@ -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,
+1
View File
@@ -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)
+179 -1
View File
@@ -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;
+59
View File
@@ -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 = `<!doctype html><img src="/api/mindspace/v1/assets/${assetId}/download?inline=1">`;
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 = `<img src="/api/mindspace/v1/assets/${invalidId}/download?inline=1">`;
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));
});
+16 -1
View File
@@ -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);
+1 -1
View File
@@ -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}"
+1 -1
View File
@@ -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}"
+4 -4
View File
@@ -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
+2 -4
View File
@@ -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);
}
+2 -4
View File
@@ -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`);
}
}
+8 -8
View File
@@ -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
# }
+111
View File
@@ -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}" <<EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>${LABEL}</string>
<key>ProgramArguments</key>
<array>
<string>/bin/bash</string>
<string>-lc</string>
<string>
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}'
</string>
</array>
<key>WorkingDirectory</key>
<string>${ROOT}</string>
<key>EnvironmentVariables</key>
<dict>
<key>PATH</key>
<string>/opt/homebrew/bin:/opt/homebrew/opt/node@24/bin:/usr/local/bin:/usr/bin:/bin</string>
</dict>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>ThrottleInterval</key>
<integer>10</integer>
<key>StandardOutPath</key>
<string>${LOG_FILE}</string>
<key>StandardErrorPath</key>
<string>${LOG_FILE}</string>
</dict>
</plist>
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"
+1 -1
View File
@@ -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 });
+2 -4
View File
@@ -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);
}
+118 -10
View File
@@ -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,
+18 -16
View File
@@ -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<void>;
onSubmit: (text: string, imageUrls?: string[], previewImageUrls?: string[]) => void | Promise<void>;
onUploadImage?: (file: File) => Promise<string>;
onStop: () => void | Promise<void>;
onApproveTool: (allow: boolean) => void | Promise<void>;
@@ -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<string>();
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<string>();
return next.filter((item) => {
@@ -419,8 +421,8 @@ export function ChatPanel({
<div className="chat-image-attachment-grid">
{pendingImages.map((item, index) => (
<div key={item.id} className="chat-image-attachment-item">
<a href={item.url} target="_blank" rel="noreferrer">
<img src={item.url} alt={`上传图片 ${index + 1}`} className="chat-image-attachment-thumb" />
<a href={item.url || item.previewUrl} target="_blank" rel="noreferrer">
<img src={item.previewUrl} alt={`上传图片 ${index + 1}`} className="chat-image-attachment-thumb" />
</a>
<button
type="button"
+3 -1
View File
@@ -297,7 +297,9 @@ export function ChatView({
session={session}
capabilities={capabilities}
grantedSkills={grantedSkills}
onSubmit={(text, imageUrls) => void submit(text, undefined, imageUrls)}
onSubmit={(text, imageUrls, previewImageUrls) =>
void submit(text, undefined, imageUrls, previewImageUrls)
}
onUploadImage={uploadChatImage}
onStop={stop}
onApproveTool={approveTool}
+6 -5
View File
@@ -1,7 +1,7 @@
import { useState } from 'react';
import { useUserAvatar } from '../hooks/useUserAvatar';
import type { Message } from '../types';
import { getDisplayText, getImageUrls, getThinking } from '../utils/message';
import { getDisplayText, getImageUrls, getRenderableImageUrls, getThinking } from '../utils/message';
import { getMessageSaveActions } from '../utils/messageSave';
import { renderMarkdown } from '../utils/markdown';
import { filterText } from '../utils/wordFilter';
@@ -189,6 +189,7 @@ function MessageRow({
const thinking = getThinking(message);
const isUser = message.role === 'user';
const imageUrls = getImageUrls(message);
const renderableImageUrls = getRenderableImageUrls(message);
const copyText = [thinking ? `【思考】\n${thinking}` : '', text].filter(Boolean).join('\n\n');
if (!text && !thinking && message.role === 'assistant') {
@@ -221,16 +222,16 @@ function MessageRow({
)}
{imageUrls.length > 0 && (
<div className="msg-image-gallery">
{imageUrls.map((imageUrl, index) => (
{renderableImageUrls.map((image, index) => (
<a
key={`${imageUrl}-${index}`}
href={imageUrl}
key={`${image.href}-${index}`}
href={image.href}
target="_blank"
rel="noreferrer"
className="msg-image-link"
>
<img
src={imageUrl}
src={image.src}
alt={`图片 ${index + 1}`}
className="msg-image-thumb"
loading="lazy"
+6 -1
View File
@@ -182,7 +182,12 @@ export function PageSaveDialog({
setSaveNotice(`已保存到${CATEGORY_LABELS[result.categoryCode]}`);
onSaved({ kind: 'asset', categoryCode: result.categoryCode });
} catch (err) {
setSaveError(err instanceof Error ? err.message : '保存失败');
const message = err instanceof Error ? err.message : '保存失败';
if (message.includes('用户不存在') || message.includes('未授权') || message.includes('登录已过期')) {
setSaveError('当前账号未登录或已失效,请重新登录后再保存');
} else {
setSaveError(message);
}
} finally {
setSaving(false);
}
+1 -1
View File
@@ -112,7 +112,7 @@ export function PageSavePreviewPanel({
title="页面预览"
src={pagePreviewUrl}
className="page-save-mini-page-frame"
sandbox="allow-scripts"
sandbox="allow-scripts allow-same-origin"
scrolling="yes"
onError={() => setPreviewFailed(true)}
/>
+3 -3
View File
@@ -123,10 +123,10 @@ export function SpaceChatPanel({
chatState={chatState}
pendingTool={pendingTool}
session={session}
onSubmit={(text, imageUrls) =>
onSubmit={(text, imageUrls, previewImageUrls) =>
chatBridge
? void submit(text, context, imageUrls)
: void submit(text, { mindspaceContext: context }, imageUrls)
? void submit(text, context, imageUrls, previewImageUrls)
: void submit(text, { mindspaceContext: context }, imageUrls, previewImageUrls)
}
onUploadImage={uploadChatImage}
onStop={stop}
+10 -1
View File
@@ -276,9 +276,17 @@ export function usePageEditSubChat({
);
const submit = useCallback(
async (text: string, context: MindSpaceChatContext, imageUrls?: string[]) => {
async (
text: string,
context: MindSpaceChatContext,
imageUrls?: string[],
previewImageUrls?: string[],
) => {
const currentSession = sessionRef.current;
const normalizedImageUrls = (imageUrls ?? []).filter((value) => typeof value === 'string' && value.trim());
const normalizedPreviewImageUrls = (previewImageUrls ?? []).filter(
(value) => typeof value === 'string' && value.trim(),
);
if (!currentSession || (!text.trim() && normalizedImageUrls.length === 0)) return;
if (chatState === 'streaming' || chatState === 'loading' || chatState === 'connecting') return;
@@ -290,6 +298,7 @@ export function usePageEditSubChat({
agentText: `${agentPrefix}${trimmed}`,
displayText: trimmed,
imageUrls: normalizedImageUrls,
previewImageUrls: normalizedPreviewImageUrls,
});
const requestId = crypto.randomUUID();
activeRequestId.current = requestId;
+45 -4
View File
@@ -56,7 +56,6 @@ import {
normalizeConversationMessages,
getDisplayText,
getToolConfirmation,
getVisibleText,
isCreditsExhaustedNotification,
isRelayServerErrorMessage,
pushMessage,
@@ -69,9 +68,15 @@ import {
} from '../utils/sessions';
const INSUFFICIENT_BALANCE_NOTICE = '余额不足,请充值后继续使用';
const REPLY_RECOVERY_SYNC_DELAYS_MS = [1500, 5000, 12000];
export { INSUFFICIENT_BALANCE_NOTICE };
function isAmbiguousReplySubmitError(err: unknown) {
if (!(err instanceof ApiError)) return true;
return err.status === 0 || err.status === 409 || err.status >= 500;
}
export function useTKMindChat(
user?: PortalUser | null,
onUserUpdate?: (user: PortalUser) => void,
@@ -666,6 +671,7 @@ export function useTKMindChat(
const previousSessionId = readStoredSessionId(userRef.current?.id);
let staleSession = false;
let restorableSessionId: string | null = null;
if (previousSessionId) {
try {
@@ -676,8 +682,10 @@ export function useTKMindChat(
} catch {
// Best-effort cleanup: a failed delete should not block the next fresh chat.
}
clearStoredSessionId(userRef.current?.id);
} else {
restorableSessionId = previousSessionId;
}
clearStoredSessionId(userRef.current?.id);
} catch (err) {
if (err instanceof ApiError && (err.status === 403 || err.status === 404 || err.status === 400)) {
clearStoredSessionId(userRef.current?.id);
@@ -693,7 +701,12 @@ export function useTKMindChat(
}
if (cancelled) return;
setChatState('idle');
if (restorableSessionId) {
await connectSessionRef.current(restorableSessionId, { showLoading: false });
if (cancelled) return;
} else {
setChatState('idle');
}
void refreshSessions();
} catch (err) {
if (!cancelled) {
@@ -756,8 +769,12 @@ export function useTKMindChat(
text: string,
options?: { mindspaceContext?: MindSpaceChatContext },
imageUrls?: string[],
previewImageUrls?: string[],
) => {
const normalizedImageUrls = (imageUrls ?? []).filter((value) => typeof value === 'string' && value.trim());
const normalizedPreviewImageUrls = (previewImageUrls ?? []).filter(
(value) => typeof value === 'string' && value.trim(),
);
if (!text.trim() && normalizedImageUrls.length === 0) return;
if (chatState === 'streaming' || chatState === 'loading' || chatState === 'connecting') return;
@@ -772,6 +789,7 @@ export function useTKMindChat(
agentText: `${agentPrefix}${trimmed}`,
displayText: trimmed,
imageUrls: normalizedImageUrls,
previewImageUrls: normalizedPreviewImageUrls,
});
const requestId = crypto.randomUUID();
activeRequestId.current = requestId;
@@ -811,6 +829,19 @@ export function useTKMindChat(
try {
await sendReply(activeSessionId, requestId, userMessage);
} catch (err) {
if (isAmbiguousReplySubmitError(err)) {
subscribeToSession(activeSessionId);
setChatState('streaming');
setError(null);
for (const delay of REPLY_RECOVERY_SYNC_DELAYS_MS) {
window.setTimeout(() => {
if (sessionRef.current?.id === activeSessionId) {
void syncSessionMessages(activeSessionId);
}
}, delay);
}
return;
}
if (session) setSessions((prev) => touchSession(prev, activeSessionId!, -1));
if (err instanceof ApiError && err.status === 402) {
notifyInsufficientBalance();
@@ -821,7 +852,17 @@ export function useTKMindChat(
activeRequestId.current = null;
}
},
[notifyInsufficientBalance, session, chatState, grantedSkills, subscribeToSession, ensureProvider, loadProjectMemory, refreshSessions],
[
notifyInsufficientBalance,
session,
chatState,
grantedSkills,
subscribeToSession,
ensureProvider,
loadProjectMemory,
refreshSessions,
syncSessionMessages,
],
);
const stop = useCallback(async () => {
+1
View File
@@ -32,6 +32,7 @@ export type Message = {
agentVisible: boolean;
displayText?: string;
imageUrls?: string[];
previewImageUrls?: string[];
};
};
+41 -5
View File
@@ -4,10 +4,31 @@ import { stripUserAddressPrefix } from './userAddress';
const IMAGE_URL_LINE_RE = /^\[图片\d+]: (.+)$/;
function normalizeImageUrl(url: string): string {
const value = String(url ?? '').trim();
if (!value) return '';
if (!/\/api\/mindspace\/v1\/assets\/[^/]+\/download\b/.test(value)) return value;
try {
const parsed = value.startsWith('http://') || value.startsWith('https://')
? new URL(value)
: new URL(value, window.location.origin);
if (parsed.searchParams.get('inline') === '1' && !parsed.searchParams.has('viewer')) {
parsed.searchParams.set('viewer', '0');
}
return parsed.origin === window.location.origin
? `${parsed.pathname}${parsed.search}${parsed.hash}`
: parsed.toString();
} catch {
const separator = value.includes('?') ? '&' : '?';
return /(?:\?|&)viewer=/.test(value) ? value : `${value}${separator}viewer=0`;
}
}
function formatImageUrlsForAgentText(urls: string[]): string {
return urls
.filter((url) => typeof url === 'string' && url.trim())
.map((url, index) => `[图片${index + 1}]: ${url.trim()}`)
.map((url, index) => `[图片${index + 1}]: ${normalizeImageUrl(url)}`)
.join('\n');
}
@@ -85,10 +106,13 @@ export function normalizeUserMessageForApi(message: Message): Message {
export function buildUserMessage(
text: string,
options?: { agentText?: string; displayText?: string; imageUrls?: string[] },
options?: { agentText?: string; displayText?: string; imageUrls?: string[]; previewImageUrls?: string[] },
): Message {
const displayText = options?.displayText ?? text;
const imageUrls = (options?.imageUrls ?? []).filter((value) => typeof value === 'string' && value.trim());
const previewImageUrls = (options?.previewImageUrls ?? []).filter(
(value) => typeof value === 'string' && value.trim(),
);
const baseAgentText = options?.agentText ?? text;
const imageText = formatImageUrlsForAgentText(imageUrls);
const agentText = [baseAgentText.trim(), imageText].filter(Boolean).join('\n\n');
@@ -103,6 +127,7 @@ export function buildUserMessage(
agentVisible: true,
displayText,
...(imageUrls.length ? { imageUrls } : {}),
...(previewImageUrls.length ? { previewImageUrls } : {}),
},
};
}
@@ -169,16 +194,27 @@ export function getImageUrls(message: Message): string[] {
const fromMetadata = message.metadata.imageUrls?.filter(
(url) => typeof url === 'string' && url.trim(),
);
if (fromMetadata?.length) return fromMetadata;
if (fromMetadata?.length) return fromMetadata.map(normalizeImageUrl);
const fromContent = readLegacyImageUrls(message.content);
if (fromContent.length) return fromContent;
if (fromContent.length) return fromContent.map(normalizeImageUrl);
const text = message.content
.filter((item): item is Extract<MessageContent, { type: 'text' }> => item.type === 'text')
.map((item) => item.text)
.join('\n');
return parseImageUrlsFromText(text);
return parseImageUrlsFromText(text).map(normalizeImageUrl);
}
export function getRenderableImageUrls(message: Message): Array<{ src: string; href: string }> {
const imageUrls = getImageUrls(message);
const previewImageUrls = message.metadata.previewImageUrls?.filter(
(url) => typeof url === 'string' && url.trim(),
) ?? [];
return imageUrls.map((href, index) => ({
href,
src: previewImageUrls[index] || href,
}));
}
export function getSystemNotificationText(message: Message): string | null {
+1 -1
View File
@@ -58,7 +58,7 @@ export function isImageAsset(asset: MindSpaceAsset) {
export function buildAssetImageUrl(asset: Pick<MindSpaceAsset, 'id' | 'updatedAt' | 'publicUrl'>) {
if (asset.publicUrl) return asset.publicUrl;
return `/api/mindspace/v1/assets/${asset.id}/download?inline=1&v=${asset.updatedAt}`;
return `/api/mindspace/v1/assets/${asset.id}/download?inline=1&viewer=0&v=${asset.updatedAt}`;
}
export function buildAbsoluteAssetImageUrl(
+2 -1
View File
@@ -1,6 +1,7 @@
import assert from 'node:assert/strict';
import crypto from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import test from 'node:test';
import {
buildWechatTextReply,
@@ -1853,7 +1854,7 @@ test('wechat mp service persists image and routes image url into agent prompt',
await result.task;
} finally {
crypto.randomUUID = originalRandomUuid;
fs.rmSync(`/Users/john/PycharmProjects/test/test-memind/MindSpace/${testUserId}`, {
fs.rmSync(path.join(process.cwd(), 'MindSpace', testUserId), {
recursive: true,
force: true,
});