feat: improve mindspace asset handling and local runtime paths
This commit is contained in:
@@ -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
|
||||
```
|
||||
|
||||
@@ -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(或当前文档规定的目标机)
|
||||
|
||||
@@ -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/
|
||||
|
||||
@@ -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 \
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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 验收用例
|
||||
|
||||
完成这些后,再进入代码实现会更稳。
|
||||
|
||||
|
||||
@@ -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 \
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
# }
|
||||
|
||||
Executable
+111
@@ -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"
|
||||
@@ -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 });
|
||||
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -32,6 +32,7 @@ export type Message = {
|
||||
agentVisible: boolean;
|
||||
displayText?: string;
|
||||
imageUrls?: string[];
|
||||
previewImageUrls?: string[];
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
+41
-5
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user