Add attachment text extraction, auto web news skill, and chat/voice UI updates.
Simplify asset upload temp paths, refresh deploy docs for Aliyun DNS topology, and ship MindSpace content-scan and auth improvements. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,6 +1,18 @@
|
||||
// Keep browser-safe: do not import user-publish.mjs (uses node:fs/path/url).
|
||||
const PUBLISH_SKILL_NAME = 'static-page-publish';
|
||||
|
||||
const WEB_INTENT_PATTERNS = [
|
||||
/(?:今天|今日|最新|热点|热搜|新闻|头条|头条新闻|最新消息|发生了什么|最近发生)/u,
|
||||
/(?:latest|breaking)\s+news/i,
|
||||
/what(?:'s| is)?\s+happening/i,
|
||||
];
|
||||
|
||||
const WEB_NEWS_INTENT_PATTERNS = [
|
||||
/(?:今天|今日|最新|热点|热搜|新闻|头条|头条新闻|热榜|热议|舆情|突发)/u,
|
||||
/(?:today|latest|breaking|trending)\s+(?:news|stories|headlines|topics)/i,
|
||||
/(?:what(?:'s| is)?\s+the\s+latest|what(?:'s| is)?\s+trending)/i,
|
||||
];
|
||||
|
||||
export const CHAT_SKILL_DEFINITIONS = [
|
||||
{
|
||||
id: 'web-search',
|
||||
@@ -83,6 +95,10 @@ export function buildChatSkillPrompt(promptKey, skillName) {
|
||||
}
|
||||
}
|
||||
|
||||
function buildWebNewsSkillPrompt(skillName) {
|
||||
return `请使用 ${skillName ?? 'web'} 技能:先搜索今天/最新相关的新闻与热点,优先一手来源和权威媒体,整理 3-5 条最相关结果,按时间或热度排序;然后给出中文摘要、关键信息、事件背景和来源链接。我的问题是:`;
|
||||
}
|
||||
|
||||
export function filterChatSkills(options, ctx) {
|
||||
return options.filter((skill) => {
|
||||
if (skill.requiresPublish && !ctx.canPublish) return false;
|
||||
@@ -90,3 +106,14 @@ export function filterChatSkills(options, ctx) {
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
export function buildAutoChatSkillPrefix(text, grantedSkills = []) {
|
||||
const trimmed = String(text ?? '').trim();
|
||||
if (!trimmed) return '';
|
||||
if (!grantedSkills.includes('web')) return '';
|
||||
if (WEB_NEWS_INTENT_PATTERNS.some((pattern) => pattern.test(trimmed))) {
|
||||
return buildWebNewsSkillPrompt('web');
|
||||
}
|
||||
if (!WEB_INTENT_PATTERNS.some((pattern) => pattern.test(trimmed))) return '';
|
||||
return buildChatSkillPrompt('web', 'web');
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
buildAutoChatSkillPrefix,
|
||||
buildChatSkillPrompt,
|
||||
CHAT_SKILL_DEFINITIONS,
|
||||
filterChatSkills,
|
||||
@@ -37,3 +38,12 @@ test('prefillOnly is set for open-ended chat skills', () => {
|
||||
assert.equal(CHAT_SKILL_DEFINITIONS.find((item) => item.id === 'web-search')?.prefillOnly, true);
|
||||
assert.equal(CHAT_SKILL_DEFINITIONS.find((item) => item.id === 'generate-page')?.prefillOnly, undefined);
|
||||
});
|
||||
|
||||
test('buildAutoChatSkillPrefix enables web for news-like queries', () => {
|
||||
assert.match(
|
||||
buildAutoChatSkillPrefix('帮我搜索今天热点新闻', ['web', 'search']),
|
||||
/先搜索今天\/最新相关的新闻与热点/,
|
||||
);
|
||||
assert.equal(buildAutoChatSkillPrefix('帮我总结一下这段话', ['web', 'search']), '');
|
||||
assert.equal(buildAutoChatSkillPrefix('帮我搜索今天热点新闻', ['search']), '');
|
||||
});
|
||||
|
||||
+21
-21
@@ -6,17 +6,17 @@
|
||||
## 流量拓扑
|
||||
|
||||
```
|
||||
用户 → Cloudflare(橙云) → Tunnel 1d8cda42 → Studio Caddy(:8090, g2-lb)
|
||||
├─ 权重 19 → Studio 本机 portal 127.0.0.1:8081
|
||||
└─ 权重 1 → [SSH正向隧道 127.0.0.1:18080] → 105 portal :8080
|
||||
用户 → 阿里云解析 → 105 服务器公网入口
|
||||
├─ 转发到本地 Mac 1.6 机器 → 本机 portal 127.0.0.1:8081
|
||||
└─ [SSH 正向隧道 127.0.0.1:18080] → 105 portal :8080
|
||||
│
|
||||
105 / Studio 两个 portal ── 都连 ──→ Studio goosed 100.99.38.66:18006(唯一实例)
|
||||
发布页文件 ── 都读 ──→ Studio canonical /Users/john/Project/Memind/MindSpace
|
||||
105 / 本地主机 两个 portal ── 都连 ──→ 本地 goosed 100.99.38.66:18006(唯一实例)
|
||||
发布页文件 ── 都读 ──→ 本地 canonical /Users/john/Project/Memind/MindSpace
|
||||
(105 经反向隧道 + rclone 挂载到本地同路径)
|
||||
```
|
||||
|
||||
- **为什么用 Caddy 加权而不是 Cloudflare LB**:账号未开通 Cloudflare Load Balancing 付费插件(建 pool/monitor 报 `Access Failed`)。Caddy 加权反代免费、可精确控权重,并自带 active 健康检查实现自动摘除。
|
||||
- **为什么 105 经 SSH 隧道接入**:Caddy 一个 `reverse_proxy` 不能混用 http(本地 :8081)和 https(105 公网 :443)上游。用 Studio→105 正向隧道把 105 暴露成本地 HTTP 端口,两个上游就都是本地 http,同协议。
|
||||
- **为什么不用旧的 Cloudflare 直连**:当前入口已经迁到阿里云解析,外网流量先到 105,再由 105 转发回本地 Mac 1.6 机器。
|
||||
- **为什么 105 经 SSH 隧道接入**:入口机和 105 之间保持本地 HTTP/SSH 链路,避免把 105 公网地址直接写进上游。
|
||||
|
||||
## 调灰度比例(最常用)
|
||||
|
||||
@@ -41,10 +41,10 @@ curl -s http://127.0.0.1:18080/api/status # 应返回 ok
|
||||
- 禁止:`HostName 120.26.184.105` 直接作为 105 脚本主机。
|
||||
- 禁止:用 `105.tkmind.cn` 做 API 健康检查或同步链路入口。
|
||||
|
||||
编辑 **Studio** 上的 `~/Project/Memind/scripts/g2-lb.Caddyfile`,改 `lb_policy weighted_round_robin` 后面两个权重(第一个=Studio :8081,第二个=105 :18080):
|
||||
编辑 **本地 Mac 1.6** 上的入口配置,调整流量分配(第一个=本机 :8081,第二个=105 :18080):
|
||||
|
||||
| 配置 | Studio | 105 |
|
||||
|------|--------|-----|
|
||||
| 配置 | 本机 | 105 |
|
||||
|------|------|-----|
|
||||
| `weighted_round_robin 19 1` | 95% | 5%(默认上线值) |
|
||||
| `weighted_round_robin 9 1` | 90% | 10% |
|
||||
| `weighted_round_robin 4 1` | 80% | 20% |
|
||||
@@ -53,7 +53,7 @@ curl -s http://127.0.0.1:18080/api/status # 应返回 ok
|
||||
改完**零停机生效**:
|
||||
|
||||
```bash
|
||||
ssh studio # 或 ssh 100.99.38.66
|
||||
ssh 本机入口机
|
||||
cd ~/Project/Memind/scripts
|
||||
caddy validate --config g2-lb.Caddyfile # 可选,先校验
|
||||
caddy reload --config g2-lb.Caddyfile # 零停机热重载
|
||||
@@ -74,7 +74,7 @@ caddy reload --config g2-lb.Caddyfile
|
||||
|
||||
## 验证 / 观测
|
||||
|
||||
每个 g2 响应都带 `X-Memind-Upstream` 头,标记实际命中的上游(`127.0.0.1:8081`=Studio,`127.0.0.1:18080`=105):
|
||||
每个 g2 响应都带 `X-Memind-Upstream` 头,标记实际命中的上游(`127.0.0.1:8081`=本机,`127.0.0.1:18080`=105):
|
||||
|
||||
```bash
|
||||
# 打 N 次看分流比例
|
||||
@@ -83,25 +83,25 @@ for i in $(seq 1 40); do
|
||||
| grep -i x-memind-upstream | awk '{print $2}' | tr -d '\r'
|
||||
done | sort | uniq -c
|
||||
|
||||
# 看 Caddy 两个上游健康状态
|
||||
curl -s http://127.0.0.1:2019/reverse_proxy/upstreams # 在 Studio 上执行
|
||||
# 看入口反代两个上游健康状态
|
||||
curl -s http://127.0.0.1:2019/reverse_proxy/upstreams # 在本机入口机上执行
|
||||
```
|
||||
|
||||
active 健康检查会每 10s 打一次各上游的 `/api/status`,连不上或非 200 就自动摘除该上游、流量全转到健康节点;恢复后自动加回。
|
||||
健康检查会每 10s 打一次各上游的 `/api/status`,连不上或非 200 就自动摘除该上游、流量全转到健康节点;恢复后自动加回。
|
||||
|
||||
## 涉及的组件与配置位置
|
||||
|
||||
| 组件 | 位置 | 作用 |
|
||||
|------|------|------|
|
||||
| g2-lb Caddy | Studio `scripts/g2-lb.Caddyfile`(LaunchAgent `cn.tkmind.g2-lb`,:8090) | 加权反代 + 健康检查 |
|
||||
| 正向隧道 | Studio `scripts/memind-fwd-tunnel.sh`(LaunchAgent `cn.tkmind.memind-fwd-tunnel`) | Studio `127.0.0.1:18080` → 105 `:8080` |
|
||||
| 反向隧道 | Studio `scripts/memind-mac-tunnel.sh`(LaunchAgent `cn.tkmind.memind-tunnel`) | 105 经此挂载 Studio 的 MindSpace 文件 |
|
||||
| 入口反代 | 本机入口机 `scripts/g2-lb.Caddyfile`(LaunchAgent `cn.tkmind.g2-lb`,:8090) | 入口转发 + 健康检查 |
|
||||
| 正向隧道 | 本机入口机 `scripts/memind-fwd-tunnel.sh`(LaunchAgent `cn.tkmind.memind-fwd-tunnel`) | 本机 `127.0.0.1:18080` → 105 `:8080` |
|
||||
| 反向隧道 | 本机入口机 `scripts/memind-mac-tunnel.sh`(LaunchAgent `cn.tkmind.memind-tunnel`) | 105 经此挂载本地 MindSpace 文件 |
|
||||
| 105 portal | 105 systemd `goose-h5`(:8080),`/root/tkmind_go/ui/h5/.env` | 无状态前端,`TKMIND_API_TARGET=https://100.99.38.66:18006` |
|
||||
| 105 文件挂载 | 105 systemd `.mount` → `/mnt/memind-shared`(rclone over 反向隧道) | 发布页/工作区共享只读 |
|
||||
|
||||
## 关键约束(改动前必读)
|
||||
|
||||
- **105 永不跑 goose**。goosed 单实例只在 Studio;session 存 Studio 本地 SQLite,文件操作也在 Studio 本地。105 只是代理 + serve。
|
||||
- 105 的 portal 必须设 `MEMIND_WORKSPACE_MAINTENANCE=0`、`MINDSPACE_AGENT_JOBS_ENABLED=false`——工作区维护守护(缩略图/资产同步 watcher)只该在 Studio 跑,否则 105 对 rclone 挂载树做递归 fs.watch 会占满 libuv 线程池导致 portal 启动卡死。
|
||||
- 105 必须与 Studio 跑**同一份代码**(用 `sync-to-105.sh` 从 Studio 同步)和**相同的 `TKMIND_SERVER__SECRET_KEY`**(否则连不上 goosed / 解不开共享 RDS 里的加密设置)。
|
||||
- **105 永不跑 goose**。goosed 单实例只在本地 Mac 1.6 机器;session 存本地 SQLite,文件操作也在本地完成。105 只是代理 + serve。
|
||||
- 105 的 portal 必须设 `MEMIND_WORKSPACE_MAINTENANCE=0`、`MINDSPACE_AGENT_JOBS_ENABLED=false`——工作区维护守护(缩略图/资产同步 watcher)只该在本地跑,否则 105 对 rclone 挂载树做递归 fs.watch 会占满 libuv 线程池导致 portal 启动卡死。
|
||||
- 105 必须与本地主机跑**同一份代码**(用 `sync-to-105.sh` 从主机同步)和**相同的 `TKMIND_SERVER__SECRET_KEY`**(否则连不上 goosed / 解不开共享 RDS 里的加密设置)。
|
||||
- 调权重只动 `g2-lb.Caddyfile`,不要动 105 的 systemd 单元或 .env。
|
||||
|
||||
+16
-16
@@ -8,20 +8,20 @@
|
||||
用户访问 `https://g2.tkmind.cn/` 的流量路径:
|
||||
|
||||
```text
|
||||
Cloudflare(橙云)
|
||||
→ Cloudflare Tunnel
|
||||
→ Studio Caddy(:8090,g2 负载均衡)
|
||||
├─ 权重 19 → Studio Portal(127.0.0.1:8081) ← 主流量
|
||||
└─ 权重 1 → 105 Portal(经隧道 127.0.0.1:18080 → :8080) ← 灰度副机
|
||||
阿里云解析(DNS)
|
||||
→ 105 服务器(公网入口 / 反代)
|
||||
→ 本地 Mac 1.6 机器(主服务)
|
||||
├─ 主流量 → 本地 Portal(127.0.0.1:8081)
|
||||
└─ 备用/灰度 → 105 Portal(经隧道 127.0.0.1:18080 → :8080)
|
||||
```
|
||||
|
||||
两台 Portal 都是 **无状态前端**,共用 Studio 上的 goosed 与 MindSpace 数据目录。
|
||||
|
||||
| 角色 | 机器 | 代码目录 | 服务 | 重启方式 |
|
||||
|------|------|----------|------|----------|
|
||||
| 生产主 | Studio Mac(`100.99.38.66`) | `/Users/john/Project/Memind` | Portal `:8081`、Plaza `:3001` | `launchctl kickstart` |
|
||||
| 生产主 | 本地 Mac 1.6 机器 | `/Users/john/Project/Memind` | Portal `:8081`、Plaza `:3001` | `launchctl kickstart` |
|
||||
| 入口转发 | 105 服务器 | 入口转发配置 | 反代 `:80/:443` | 阿里云解析切换后生效 |
|
||||
| 灰度副 | 105(经 Tailscale `ssh105`) | `/root/tkmind_go/ui/h5` | `goose-h5` `:8080` | `systemctl restart goose-h5` |
|
||||
| 负载均衡 | Studio | `scripts/g2-lb.Caddyfile` | Caddy `:8090` | 改权重后 `caddy reload` |
|
||||
|
||||
更详细的流量与灰度比例说明见 [g2 负载均衡](./g2-load-balancing.md)。
|
||||
|
||||
@@ -57,7 +57,7 @@ cd /path/to/your/memind-repo # 开发完成、已自测的目录
|
||||
4. rsync 代码 → Studio 生产目录
|
||||
5. rsync 后校验(`.env` 未变、MindSpace 未减少、patch 仍在)
|
||||
6. Studio 上 `npm install` + `npm run build`
|
||||
7. 重启 Studio Portal → Plaza,并做健康检查
|
||||
7. 重启本地 Mac 1.6 Portal → Plaza,并做健康检查
|
||||
8. 经 Studio 触发 `scripts/sync-to-105.sh`,同步并重启 105
|
||||
|
||||
## 4. 发布前检查清单
|
||||
@@ -81,7 +81,7 @@ pnpm test # 建议跑;涉及核心逻辑时必跑
|
||||
### 4.2 生产在线(只读)
|
||||
|
||||
```bash
|
||||
curl -s http://127.0.0.1:8081/api/status # Studio Portal,期望 ok
|
||||
curl -s http://127.0.0.1:8081/api/status # 本地 Portal,期望 ok
|
||||
curl -s http://127.0.0.1:18080/api/status # 105 隧道,期望 ok
|
||||
```
|
||||
|
||||
@@ -110,8 +110,8 @@ ssh ssh105 'echo tunnel-ok-105'
|
||||
| 参数 | 作用 | 适用场景 |
|
||||
|------|------|----------|
|
||||
| `--dry-run` | 只预览 diff,不改远端 | **每次正式发布前必做** |
|
||||
| `--only-100` | 只更新 Studio,不推 105 | 先让 95% 主流量生效,105 稍后 |
|
||||
| `--only-105` | 只触发 Studio→105 同步 | Studio 已是最新,只补 105 |
|
||||
| `--only-100` | 只更新本地主机,不推 105 | 先让主流量生效,105 稍后 |
|
||||
| `--only-105` | 只触发本地主机→105 同步 | 主机已是最新,只补 105 |
|
||||
| `--skip-build` | 跳过远端 `npm run build` | 纯后端改动且确认 dist 无需更新 |
|
||||
| `--no-restart` | 只同步代码,不重启服务 | 分批发布;需自行重启 |
|
||||
| `--yes` / `-y` | 跳过交互确认 | 自动化或你已看过 dry-run |
|
||||
@@ -169,11 +169,11 @@ package.json 中的 `pnpm deploy:105` 等价于本地执行 `bash scripts/sync-t
|
||||
| 阶段 | 用户可见影响 |
|
||||
|------|----------------|
|
||||
| rsync + npm install + build | 无(旧进程仍在跑) |
|
||||
| Studio Portal 重启 | **8081 短暂不可用**,约 10~40 秒;g2 主流量(约 95%)可能短暂 502 |
|
||||
| 本地 Portal 重启 | **8081 短暂不可用**,约 10~40 秒;主流量可能短暂 502 |
|
||||
| 105 goose-h5 重启 | **灰度流量(约 5%)** 短暂不可用 |
|
||||
| Plaza 重启 | plaza.tkmind.cn 可能短暂不可用;与 g2 主聊天无关 |
|
||||
|
||||
Caddy 会对不健康上游做 active health check,105 重启期间可能暂时从池中摘除。
|
||||
入口反代会对不健康上游做健康检查,105 重启期间可能暂时从池中摘除。
|
||||
|
||||
## 8. 发布后验证
|
||||
|
||||
@@ -189,7 +189,7 @@ curl -s https://g2.tkmind.cn/api/status
|
||||
|
||||
g2 响应头 `X-Memind-Upstream`:
|
||||
|
||||
- `127.0.0.1:8081` → Studio
|
||||
- `127.0.0.1:8081` → 本地 Mac 1.6 机器
|
||||
- `127.0.0.1:18080` → 105
|
||||
|
||||
```bash
|
||||
@@ -208,7 +208,7 @@ curl -s -D - -o /dev/null https://g2.tkmind.cn/api/status | grep -i x-memind-ups
|
||||
### 8.4 日志
|
||||
|
||||
```bash
|
||||
# Studio Portal
|
||||
# 本地 Portal
|
||||
tail -f ~/Library/Logs/memind-portal.log
|
||||
|
||||
# Studio Plaza
|
||||
@@ -223,7 +223,7 @@ ssh ssh105 'journalctl -u goose-h5 -n 50 --no-pager'
|
||||
项目没有一键回滚脚本,常见做法:
|
||||
|
||||
1. **代码回滚:** 在本地 git 回到上一个 good commit,`./rsync_to_server.sh` 再发一版
|
||||
2. **仅 Studio:** 若 105 有问题,可临时把 g2 权重调到 100% Studio(见 [g2-load-balancing.md](./g2-load-balancing.md))
|
||||
2. **仅本地主机:** 若 105 有问题,可临时把流量全部收回主机(见 [g2-load-balancing.md](./g2-load-balancing.md))
|
||||
3. **紧急恢复 Portal:** 若 8081 挂了,见 [隔离规程 · 事故恢复](./service-isolation-runbook.md#事故恢复最小步骤)
|
||||
|
||||
发布前建议打 tag 或记录当前 commit,便于回滚:
|
||||
|
||||
@@ -8,9 +8,9 @@
|
||||
|
||||
| 环境 | 目录 | 用途 | 端口 |
|
||||
|------|------|------|------|
|
||||
| 生产 | `/Users/john/Project/Memind` | `g2.tkmind.cn` 当前在线服务 | `8081` |
|
||||
| 生产 | `/Users/john/Project/Memind` | `g2.tkmind.cn` 当前在线服务(阿里云解析 → 105 → 本地 Mac 1.6) | `8081` |
|
||||
| 生产 Plaza | `/Users/john/Project/Memind` + Plaza | `plaza.tkmind.cn` 当前在线服务 | `3001` |
|
||||
| 生产 Caddy | `/Users/john/Project/Memind/scripts/g2-lb.Caddyfile` | Cloudflare Tunnel 回源入口 | `8090` |
|
||||
| 生产入口 | `/Users/john/Project/Memind/scripts/g2-lb.Caddyfile` | 105 转发入口 / 反代配置 | `8090` |
|
||||
| 测试 Portal | `/Users/john/Project/test/Memind` | 开发预览 API / Portal | `18081` |
|
||||
| 测试 Vite | `/Users/john/Project/test/Memind` | 开发预览前端 | `15173` |
|
||||
| 测试 Admin | `/Users/john/Project/test/Memind` | 开发预览后台 | `18082` |
|
||||
@@ -197,7 +197,7 @@ pnpm dev:vite -- --host 127.0.0.1 --port 15173
|
||||
|
||||
## 事故恢复最小步骤
|
||||
|
||||
如果发现 `g2.tkmind.cn` 异常,先检查本机生产:
|
||||
如果发现 `g2.tkmind.cn` 异常,先检查本地主机生产:
|
||||
|
||||
```bash
|
||||
cd /Users/john/Project/Memind
|
||||
@@ -205,7 +205,7 @@ lsof -nP -iTCP:8081 -sTCP:LISTEN
|
||||
curl -s http://127.0.0.1:8081/api/status
|
||||
```
|
||||
|
||||
如果 `8081` 没有监听,再恢复生产 Portal:
|
||||
如果 `8081` 没有监听,再恢复本地主机 Portal:
|
||||
|
||||
```bash
|
||||
cd /Users/john/Project/Memind
|
||||
@@ -215,7 +215,7 @@ sleep 2
|
||||
curl -s http://127.0.0.1:8081/api/status
|
||||
```
|
||||
|
||||
恢复后再检查 Caddy:
|
||||
恢复后再检查入口反代:
|
||||
|
||||
```bash
|
||||
curl -s http://127.0.0.1:8090/api/status
|
||||
|
||||
+19
-32
@@ -3,12 +3,13 @@ import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { DEFAULT_MAX_FILE_BYTES } from './mindspace.mjs';
|
||||
import { runBasicFileScan } from './mindspace-scan.mjs';
|
||||
import { extractAttachmentText } from './mindspace-attachment-text.mjs';
|
||||
import {
|
||||
assetThumbnailKey,
|
||||
ensureHtmlThumbnail,
|
||||
scheduleHtmlThumbnail,
|
||||
} from './mindspace-thumbnails.mjs';
|
||||
import { mirrorAssetToZone, removeZoneMirror, resolveUserWorkspaceRoot } from './user-space.mjs';
|
||||
import { removeZoneMirror, resolveUserWorkspaceRoot } from './user-space.mjs';
|
||||
import { canPreviewAsset, renderAssetPreviewHtml } from './mindspace-asset-preview.mjs';
|
||||
import { createWorkspaceAssetSync } from './mindspace-workspace-sync.mjs';
|
||||
|
||||
@@ -257,7 +258,7 @@ export function createAssetService(pool, options = {}) {
|
||||
|
||||
const uploadId = idFactory();
|
||||
const now = Date.now();
|
||||
const temporaryStorageKey = path.posix.join('tmp', userId, `${uploadId}.upload`);
|
||||
const temporaryStorageKey = path.posix.join('users', userId, 'temp', `${uploadId}.upload`);
|
||||
await conn.query(
|
||||
`UPDATE h5_user_spaces
|
||||
SET reserved_bytes = reserved_bytes + ?, updated_at = ?
|
||||
@@ -349,9 +350,8 @@ export function createAssetService(pool, options = {}) {
|
||||
};
|
||||
|
||||
const completeUpload = async (userId, uploadId) => {
|
||||
const conn = await pool.getConnection();
|
||||
let temporaryPath;
|
||||
let finalPath;
|
||||
const conn = await pool.getConnection();
|
||||
let temporaryPath;
|
||||
try {
|
||||
await conn.beginTransaction();
|
||||
const [rows] = await conn.query(
|
||||
@@ -389,20 +389,9 @@ export function createAssetService(pool, options = {}) {
|
||||
|
||||
const assetId = idFactory();
|
||||
const versionId = idFactory();
|
||||
const finalStorageKey = path.posix.join(
|
||||
'users',
|
||||
userId,
|
||||
'assets',
|
||||
assetId,
|
||||
'versions',
|
||||
versionId,
|
||||
);
|
||||
const finalStorageKey = upload.temporary_storage_key;
|
||||
temporaryPath = absoluteStoragePath(upload.temporary_storage_key);
|
||||
finalPath = absoluteStoragePath(finalStorageKey);
|
||||
await fs.mkdir(path.dirname(finalPath), { recursive: true });
|
||||
await fs.rename(temporaryPath, finalPath);
|
||||
|
||||
const fileBuffer = await fs.readFile(finalPath);
|
||||
const fileBuffer = await fs.readFile(temporaryPath);
|
||||
const scan = runBasicFileScan(fileBuffer, {
|
||||
filename: upload.filename,
|
||||
mimeType: upload.detected_mime_type,
|
||||
@@ -469,11 +458,6 @@ export function createAssetService(pool, options = {}) {
|
||||
[now, assetId, uploadId, userId],
|
||||
);
|
||||
await conn.commit();
|
||||
await mirrorToUserWorkspace(userId, {
|
||||
categoryCode: upload.category_code,
|
||||
filename: upload.filename,
|
||||
sourcePath: finalPath,
|
||||
});
|
||||
return {
|
||||
id: assetId,
|
||||
categoryId: upload.category_id,
|
||||
@@ -494,10 +478,6 @@ export function createAssetService(pool, options = {}) {
|
||||
};
|
||||
} catch (error) {
|
||||
await conn.rollback();
|
||||
if (finalPath && temporaryPath) {
|
||||
await fs.mkdir(path.dirname(temporaryPath), { recursive: true }).catch(() => {});
|
||||
await fs.rename(finalPath, temporaryPath).catch(() => {});
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
conn.release();
|
||||
@@ -820,11 +800,6 @@ export function createAssetService(pool, options = {}) {
|
||||
[buffer.length, now, category.space_id, userId],
|
||||
);
|
||||
await conn.commit();
|
||||
await mirrorToUserWorkspace(userId, {
|
||||
categoryCode: category.category_code,
|
||||
filename: normalizedFilename,
|
||||
sourcePath: finalPath,
|
||||
});
|
||||
if (detectedMimeType === 'text/html') {
|
||||
scheduleHtmlThumbnail(storageRoot, assetThumbnailKey(userId, assetId), buffer.toString('utf8'), {
|
||||
title: displayName || normalizedFilename,
|
||||
@@ -891,6 +866,17 @@ export function createAssetService(pool, options = {}) {
|
||||
return renderAssetPreviewHtml({ asset, buffer, downloadUrl });
|
||||
};
|
||||
|
||||
const extractAssetText = async (userId, assetId) => {
|
||||
const { asset, path: assetPath } = await readAsset(userId, assetId);
|
||||
const buffer = await fs.readFile(assetPath);
|
||||
const extracted = extractAttachmentText(buffer, asset.mimeType, asset.filename);
|
||||
return {
|
||||
asset: assetResponse(asset),
|
||||
...extracted,
|
||||
charCount: extracted.text.length,
|
||||
};
|
||||
};
|
||||
|
||||
const expireStaleUploads = async (now = Date.now()) => {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT id, user_id, space_id, reserved_bytes, temporary_storage_key, status
|
||||
@@ -952,6 +938,7 @@ export function createAssetService(pool, options = {}) {
|
||||
deleteAsset,
|
||||
readAsset,
|
||||
renderAssetPreview,
|
||||
extractAssetText,
|
||||
renderAssetThumbnail,
|
||||
syncWorkspaceAssets: workspaceSync.syncUserWorkspace,
|
||||
expireStaleUploads,
|
||||
|
||||
@@ -199,6 +199,7 @@ test('completeUpload marks safe files ready and blocks script payloads', async (
|
||||
filename: 'note.txt',
|
||||
sizeBytes: payload.length,
|
||||
});
|
||||
assert.match(state.uploads[0].temporary_storage_key, /^users\/user-1\/temp\//);
|
||||
await service.writeUploadContent('user-1', upload.id, payload);
|
||||
const asset = await service.completeUpload('user-1', upload.id);
|
||||
assert.equal(asset.status, 'quarantined');
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
import zlib from 'node:zlib';
|
||||
|
||||
function escapeXml(text) {
|
||||
return String(text ?? '')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
function extractZipEntry(buffer, targetName) {
|
||||
let offset = 0;
|
||||
while (offset + 30 <= buffer.length) {
|
||||
if (buffer.subarray(offset, offset + 2).toString('ascii') !== 'PK') break;
|
||||
const compressionMethod = buffer.readUInt16LE(offset + 8);
|
||||
const compressedSize = buffer.readUInt32LE(offset + 18);
|
||||
const nameLength = buffer.readUInt16LE(offset + 26);
|
||||
const extraLength = buffer.readUInt16LE(offset + 28);
|
||||
const name = buffer.subarray(offset + 30, offset + 30 + nameLength).toString('utf8');
|
||||
const dataStart = offset + 30 + nameLength + extraLength;
|
||||
if (name === targetName) {
|
||||
const compressed = buffer.subarray(dataStart, dataStart + compressedSize);
|
||||
if (compressionMethod === 0) return compressed;
|
||||
if (compressionMethod === 8) return zlib.inflateRawSync(compressed);
|
||||
return null;
|
||||
}
|
||||
offset = dataStart + compressedSize;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function extractXmlText(xml) {
|
||||
const paragraphs = [];
|
||||
for (const block of String(xml ?? '').split('</w:p>')) {
|
||||
const texts = [...block.matchAll(/<w:t[^>]*>([\s\S]*?)<\/w:t>/g)].map((match) =>
|
||||
escapeXml(match[1]),
|
||||
);
|
||||
const line = texts.join('').replace(/\s+/g, ' ').trim();
|
||||
if (line) paragraphs.push(line);
|
||||
}
|
||||
return paragraphs.join('\n\n');
|
||||
}
|
||||
|
||||
function extractDocxText(buffer) {
|
||||
const xmlBuffer = extractZipEntry(buffer, 'word/document.xml');
|
||||
return xmlBuffer ? extractXmlText(xmlBuffer.toString('utf8')) : '';
|
||||
}
|
||||
|
||||
function extractXlsxText(buffer) {
|
||||
const sharedStringsXml = extractZipEntry(buffer, 'xl/sharedStrings.xml');
|
||||
const sharedStrings = sharedStringsXml
|
||||
? [...sharedStringsXml.toString('utf8').matchAll(/<t[^>]*>([\s\S]*?)<\/t>/g)].map((match) =>
|
||||
escapeXml(match[1]),
|
||||
)
|
||||
: [];
|
||||
const sheetEntries = [];
|
||||
let index = 1;
|
||||
while (true) {
|
||||
const sheet = extractZipEntry(buffer, `xl/worksheets/sheet${index}.xml`);
|
||||
if (!sheet) break;
|
||||
const rows = [];
|
||||
for (const rowBlock of sheet.toString('utf8').split('</row>')) {
|
||||
const cells = [];
|
||||
for (const cell of rowBlock.matchAll(/<c\b[^>]*?(?:t="([^"]+)")?[^>]*>(?:[\s\S]*?<v>([\s\S]*?)<\/v>)?/g)) {
|
||||
const type = cell[1] ?? '';
|
||||
const value = cell[2] ?? '';
|
||||
if (type === 's') {
|
||||
const sharedIndex = Number(value);
|
||||
cells.push(sharedStrings[sharedIndex] ?? value);
|
||||
} else {
|
||||
cells.push(escapeXml(value));
|
||||
}
|
||||
}
|
||||
if (cells.length) rows.push(cells.join('\t'));
|
||||
}
|
||||
if (rows.length) {
|
||||
sheetEntries.push(`Sheet ${index}\n${rows.join('\n')}`);
|
||||
}
|
||||
index += 1;
|
||||
}
|
||||
return sheetEntries.join('\n\n');
|
||||
}
|
||||
|
||||
function extractPptxText(buffer) {
|
||||
const slides = [];
|
||||
let index = 1;
|
||||
while (true) {
|
||||
const slide = extractZipEntry(buffer, `ppt/slides/slide${index}.xml`);
|
||||
if (!slide) break;
|
||||
const texts = [...slide.toString('utf8').matchAll(/<a:t[^>]*>([\s\S]*?)<\/a:t>/g)].map((match) =>
|
||||
escapeXml(match[1]),
|
||||
);
|
||||
const text = texts.join(' ').replace(/\s+/g, ' ').trim();
|
||||
if (text) slides.push(`Slide ${index}\n${text}`);
|
||||
index += 1;
|
||||
}
|
||||
return slides.join('\n\n');
|
||||
}
|
||||
|
||||
function extractPdfText(buffer) {
|
||||
const text = buffer.toString('latin1');
|
||||
const segments = [];
|
||||
for (const streamMatch of text.matchAll(/stream\r?\n([\s\S]*?)\r?\nendstream/g)) {
|
||||
const stream = streamMatch[1];
|
||||
for (const textMatch of stream.matchAll(/\(([^()\\]*(?:\\.[^()\\]*)*)\)\s*Tj/g)) {
|
||||
const raw = textMatch[1]
|
||||
.replace(/\\n/g, '\n')
|
||||
.replace(/\\r/g, '\r')
|
||||
.replace(/\\t/g, '\t')
|
||||
.replace(/\\\\/g, '\\')
|
||||
.replace(/\\\(/g, '(')
|
||||
.replace(/\\\)/g, ')');
|
||||
const cleaned = raw.replace(/\s+/g, ' ').trim();
|
||||
if (cleaned) segments.push(cleaned);
|
||||
}
|
||||
}
|
||||
return segments.join('\n');
|
||||
}
|
||||
|
||||
export function extractAttachmentText(buffer, mimeType, filename = '') {
|
||||
const normalizedMimeType = String(mimeType ?? '');
|
||||
const extension = String(filename ?? '').toLowerCase();
|
||||
if (normalizedMimeType === 'application/pdf') {
|
||||
const text = extractPdfText(buffer);
|
||||
return { text, format: 'pdf', warnings: text ? [] : ['pdf_text_not_detected'] };
|
||||
}
|
||||
if (
|
||||
normalizedMimeType === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' ||
|
||||
extension.endsWith('.docx')
|
||||
) {
|
||||
const text = extractDocxText(buffer);
|
||||
return { text, format: 'docx', warnings: text ? [] : ['docx_text_not_detected'] };
|
||||
}
|
||||
if (
|
||||
normalizedMimeType === 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' ||
|
||||
extension.endsWith('.xlsx')
|
||||
) {
|
||||
const text = extractXlsxText(buffer);
|
||||
return { text, format: 'xlsx', warnings: text ? [] : ['xlsx_text_not_detected'] };
|
||||
}
|
||||
if (
|
||||
normalizedMimeType === 'application/vnd.openxmlformats-officedocument.presentationml.presentation' ||
|
||||
extension.endsWith('.pptx')
|
||||
) {
|
||||
const text = extractPptxText(buffer);
|
||||
return { text, format: 'pptx', warnings: text ? [] : ['pptx_text_not_detected'] };
|
||||
}
|
||||
if (normalizedMimeType.startsWith('text/')) {
|
||||
return { text: buffer.toString('utf8'), format: 'text', warnings: [] };
|
||||
}
|
||||
return { text: '', format: 'unsupported', warnings: ['unsupported_attachment_type'] };
|
||||
}
|
||||
|
||||
export const attachmentTextInternals = {
|
||||
extractZipEntry,
|
||||
extractDocxText,
|
||||
extractXlsxText,
|
||||
extractPptxText,
|
||||
extractPdfText,
|
||||
};
|
||||
@@ -0,0 +1,40 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
import { extractAttachmentText } from './mindspace-attachment-text.mjs';
|
||||
|
||||
test('extractAttachmentText returns text for plain text', () => {
|
||||
const result = extractAttachmentText(Buffer.from('hello\nworld\n'), 'text/plain', 'note.txt');
|
||||
assert.equal(result.format, 'text');
|
||||
assert.equal(result.text, 'hello\nworld\n');
|
||||
assert.deepEqual(result.warnings, []);
|
||||
});
|
||||
|
||||
test('extractAttachmentText extracts docx text', async () => {
|
||||
const docPath = path.join(process.cwd(), 'MindSpace/john/oa/端午感怀.docx');
|
||||
let buffer;
|
||||
try {
|
||||
buffer = await fs.readFile(docPath);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const result = extractAttachmentText(
|
||||
buffer,
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'端午感怀.docx',
|
||||
);
|
||||
assert.equal(result.format, 'docx');
|
||||
assert.match(result.text, /端午/);
|
||||
});
|
||||
|
||||
test('extractAttachmentText extracts simple pdf text streams', () => {
|
||||
const pdf = Buffer.from(
|
||||
'%PDF-1.4\n1 0 obj\n<<>>\nstream\nBT (Hello PDF) Tj ET\nendstream\nendobj\n%%EOF',
|
||||
'latin1',
|
||||
);
|
||||
const result = extractAttachmentText(pdf, 'application/pdf', 'demo.pdf');
|
||||
assert.equal(result.format, 'pdf');
|
||||
assert.match(result.text, /Hello PDF/);
|
||||
});
|
||||
@@ -148,6 +148,11 @@ const HTML_RULES = [
|
||||
},
|
||||
];
|
||||
|
||||
const ACKNOWLEDGEABLE_HTML_ACTIVE_TYPES = new Set([
|
||||
'html_script',
|
||||
'html_inline_handler',
|
||||
]);
|
||||
|
||||
const TRUSTED_EXTERNAL_HOSTS = new Set([
|
||||
'fonts.googleapis.com',
|
||||
'fonts.gstatic.com',
|
||||
@@ -182,12 +187,16 @@ function uniqueMatches(content, pattern) {
|
||||
];
|
||||
}
|
||||
|
||||
function applyRule(content, rule, findings, redactions) {
|
||||
function applyRule(content, rule, findings, redactions, options = {}) {
|
||||
let matches = uniqueMatches(content, rule.pattern);
|
||||
if (rule.type === 'external_link') {
|
||||
matches = matches.filter((match) => !isTrustedExternalUrl(match[0]));
|
||||
}
|
||||
if (!matches.length) return content;
|
||||
const blocking =
|
||||
options.allowHtmlActiveContent && ACKNOWLEDGEABLE_HTML_ACTIVE_TYPES.has(rule.type)
|
||||
? false
|
||||
: rule.blocking;
|
||||
findings.push({
|
||||
id: crypto
|
||||
.createHash('sha256')
|
||||
@@ -199,7 +208,7 @@ function applyRule(content, rule, findings, redactions) {
|
||||
riskLevel: rule.riskLevel,
|
||||
occurrenceCount: matches.length,
|
||||
sampleMasked: rule.mask(matches[0][0], matches[0]),
|
||||
blocking: rule.blocking,
|
||||
blocking,
|
||||
});
|
||||
let next = String(content);
|
||||
if (typeof rule.replacement === 'function') {
|
||||
@@ -224,11 +233,11 @@ export function scanContent(content, options = {}) {
|
||||
const findings = [];
|
||||
const working = String(content ?? '');
|
||||
for (const rule of TEXT_RULES) {
|
||||
applyRule(working, rule, findings, []);
|
||||
applyRule(working, rule, findings, [], options);
|
||||
}
|
||||
if (format === 'html') {
|
||||
for (const rule of HTML_RULES) {
|
||||
applyRule(working, rule, findings, []);
|
||||
applyRule(working, rule, findings, [], options);
|
||||
}
|
||||
}
|
||||
return finalizeResult(findings);
|
||||
|
||||
@@ -35,6 +35,22 @@ test('scanContent blocks html active content and private references', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('scanContent can allow active html as acknowledgeable publication warnings', () => {
|
||||
const result = scanContent(
|
||||
'<button onclick="restartGame()">重新开始</button><script>requestAnimationFrame(loop)</script>',
|
||||
{ format: 'html', allowHtmlActiveContent: true },
|
||||
);
|
||||
assert.equal(result.status, 'warned');
|
||||
assert.equal(result.allowed, true);
|
||||
assert.deepEqual(
|
||||
result.findings.map((finding) => [finding.type, finding.blocking]),
|
||||
[
|
||||
['html_script', false],
|
||||
['html_inline_handler', false],
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test('redactContent masks secrets and strips unsafe html', () => {
|
||||
const result = redactContent(
|
||||
'手机号 13800138000\n邮箱 john@example.com\n<script>alert(1)</script>\nkey=sk_test_1234567890abcdef',
|
||||
|
||||
@@ -283,6 +283,7 @@ export function createPublicationService(pool, options = {}) {
|
||||
}
|
||||
const scan = scanContent(`${page.title}\n${page.summary ?? ''}\n${publishContent}`, {
|
||||
format: page.page_type === 'html' ? 'html' : 'text',
|
||||
allowHtmlActiveContent: page.page_type === 'html',
|
||||
});
|
||||
const suggestedUrlSlug =
|
||||
conflict && accessMode !== 'private_link'
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* 删除 Plaza 演示/种子内容(seed-plaza-demo.mjs 写入的数据),保留真实用户发布。
|
||||
* 识别规则:发布 slug 以 demo- 开头,或内容资产 checksum = demo-seed。
|
||||
*
|
||||
* 用法:node scripts/purge-plaza-demo.mjs [--dry-run]
|
||||
*/
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { createDbPool } from '../db.mjs';
|
||||
|
||||
const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const dryRun = process.argv.includes('--dry-run');
|
||||
|
||||
function loadEnvFile(filePath) {
|
||||
if (!fs.existsSync(filePath)) return;
|
||||
for (const line of fs.readFileSync(filePath, 'utf8').split('\n')) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||
const eq = trimmed.indexOf('=');
|
||||
if (eq < 0) continue;
|
||||
const key = trimmed.slice(0, eq).trim();
|
||||
const value = trimmed.slice(eq + 1).trim();
|
||||
if (!process.env[key]) process.env[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
loadEnvFile(path.join(root, '../../.env.local'));
|
||||
loadEnvFile(path.join(root, '.env'));
|
||||
|
||||
const pool = createDbPool();
|
||||
const conn = await pool.getConnection();
|
||||
|
||||
try {
|
||||
const [rows] = await conn.query(
|
||||
`SELECT pp.id AS post_id, pp.title, pp.status AS post_status,
|
||||
pr.id AS publication_id, pr.page_id, pr.page_version_id, pr.url_slug, pr.security_scan_id,
|
||||
u.username
|
||||
FROM plaza_posts pp
|
||||
JOIN h5_publish_records pr ON pr.id = pp.publication_id
|
||||
JOIN h5_page_versions pv ON pv.id = pr.page_version_id
|
||||
JOIN h5_assets a ON a.id = pv.content_asset_id
|
||||
JOIN h5_users u ON u.id = pp.user_id
|
||||
WHERE pr.url_slug LIKE 'demo-%' OR a.checksum = 'demo-seed'
|
||||
ORDER BY pp.published_at DESC`,
|
||||
);
|
||||
|
||||
if (rows.length === 0) {
|
||||
console.log('未发现演示内容,无需清理。');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
console.log(`${dryRun ? '[dry-run] ' : ''}将删除 ${rows.length} 条演示广场帖:`);
|
||||
for (const row of rows) {
|
||||
console.log(` - [${row.username}] ${row.title} (${row.url_slug})`);
|
||||
}
|
||||
|
||||
if (dryRun) {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
await conn.beginTransaction();
|
||||
|
||||
const publicationIds = [...new Set(rows.map((row) => row.publication_id))];
|
||||
const pageIds = [...new Set(rows.map((row) => row.page_id))];
|
||||
const scanIds = [...new Set(rows.map((row) => row.security_scan_id).filter(Boolean))];
|
||||
const postIds = rows.map((row) => row.post_id);
|
||||
|
||||
const [postResult] = await conn.query(
|
||||
`DELETE FROM plaza_posts WHERE id IN (${postIds.map(() => '?').join(',')})`,
|
||||
postIds,
|
||||
);
|
||||
|
||||
const [pubResult] = await conn.query(
|
||||
`DELETE FROM h5_publish_records WHERE id IN (${publicationIds.map(() => '?').join(',')})`,
|
||||
publicationIds,
|
||||
);
|
||||
|
||||
if (pageIds.length > 0) {
|
||||
await conn.query(
|
||||
`UPDATE h5_page_records SET current_publish_id = NULL WHERE id IN (${pageIds.map(() => '?').join(',')})`,
|
||||
pageIds,
|
||||
);
|
||||
await conn.query(
|
||||
`DELETE FROM h5_page_records WHERE id IN (${pageIds.map(() => '?').join(',')})`,
|
||||
pageIds,
|
||||
);
|
||||
}
|
||||
|
||||
if (scanIds.length > 0) {
|
||||
await conn.query(
|
||||
`DELETE FROM h5_security_scans WHERE id IN (${scanIds.map(() => '?').join(',')})`,
|
||||
scanIds,
|
||||
);
|
||||
}
|
||||
|
||||
const [assetResult] = await conn.query(
|
||||
`DELETE FROM h5_assets WHERE checksum = 'demo-seed'`,
|
||||
);
|
||||
|
||||
await conn.commit();
|
||||
|
||||
const [remaining] = await pool.query(`SELECT COUNT(*) AS cnt FROM plaza_posts`);
|
||||
console.log(`\n完成:删除 ${postResult.affectedRows} 条广场帖、${pubResult.affectedRows} 条发布记录、${assetResult.affectedRows} 个演示资产。`);
|
||||
console.log(`剩余广场帖:${remaining[0]?.cnt ?? 0} 条。`);
|
||||
} catch (error) {
|
||||
await conn.rollback();
|
||||
console.error('清理失败:', error);
|
||||
process.exitCode = 1;
|
||||
} finally {
|
||||
conn.release();
|
||||
await pool.end();
|
||||
}
|
||||
+10
-3
@@ -53,6 +53,7 @@ import {
|
||||
preparePublicationHtmlForEmbed,
|
||||
isPlazaEmbedRequest,
|
||||
publishedPageCspForEmbed,
|
||||
stripPublicationHtmlCspMeta,
|
||||
} from './plaza-embed.mjs';
|
||||
import { createCleanupService } from './mindspace-cleanup.mjs';
|
||||
import { createAgentJobService } from './mindspace-agent-jobs.mjs';
|
||||
@@ -3002,11 +3003,14 @@ api.use(
|
||||
|
||||
app.use('/api', api);
|
||||
|
||||
function publishedPageCsp(html, { embed = false } = {}) {
|
||||
function publishedPageCsp(html, { embed = false, raw = false } = {}) {
|
||||
const isFullHtml = /^\s*<!doctype html/i.test(html) || /^\s*<html[\s>]/i.test(html);
|
||||
if (embed && isFullHtml) {
|
||||
return publishedPageCspForEmbed(true);
|
||||
}
|
||||
if (raw && isFullHtml) {
|
||||
return "default-src 'none'; style-src 'unsafe-inline' https:; img-src data: https:; font-src https: data:; base-uri 'none'; form-action 'self'; frame-ancestors 'self'; script-src 'unsafe-inline'";
|
||||
}
|
||||
if (isFullHtml) {
|
||||
return "default-src 'none'; style-src 'unsafe-inline' https:; img-src data: https:; font-src https: data:; base-uri 'none'; form-action 'self'; frame-ancestors 'self'; script-src 'none'";
|
||||
}
|
||||
@@ -3197,7 +3201,8 @@ function publishedPageShellHtml({ iframeUrl, shareUrl, title }) {
|
||||
</head>
|
||||
<body>
|
||||
<div class="publication-shell">
|
||||
<iframe class="publication-frame" title="${safeTitle}" src="${iframeSrc}"></iframe>
|
||||
<!-- Game-like publications need script execution inside the shell iframe. -->
|
||||
<iframe class="publication-frame" title="${safeTitle}" src="${iframeSrc}" sandbox="allow-same-origin allow-scripts"></iframe>
|
||||
</div>
|
||||
<button type="button" class="publication-share-fab" id="publication-share-fab">分享</button>
|
||||
<div class="publication-share-sheet" id="publication-share-sheet" hidden>
|
||||
@@ -3392,6 +3397,8 @@ function sendPublishedPage(req, res, result, { embed = false, raw = false } = {}
|
||||
let html = result.html;
|
||||
if (embed) {
|
||||
html = preparePublicationHtmlForEmbed(html);
|
||||
} else if (raw) {
|
||||
html = stripPublicationHtmlCspMeta(html);
|
||||
}
|
||||
const isFullHtml = /^\s*<!doctype html/i.test(html) || /^\s*<html[\s>]/i.test(html);
|
||||
const canWrapWithShell = !embed && !raw && isFullHtml && result.publication?.accessMode !== 'password';
|
||||
@@ -3416,7 +3423,7 @@ function sendPublishedPage(req, res, result, { embed = false, raw = false } = {}
|
||||
);
|
||||
}
|
||||
res.set('Content-Type', 'text/html; charset=utf-8');
|
||||
res.set('Content-Security-Policy', publishedPageCsp(html, { embed }));
|
||||
res.set('Content-Security-Policy', publishedPageCsp(html, { embed, raw }));
|
||||
res.set(
|
||||
'Cache-Control',
|
||||
result.publication.accessMode === 'public' ? 'public, max-age=60' : 'private, no-store',
|
||||
|
||||
+6
-1
@@ -88,7 +88,12 @@ function AuthenticatedApp({
|
||||
);
|
||||
|
||||
return (
|
||||
<ChatProvider user={user} capabilities={capabilities} onUserUpdate={onUserUpdate}>
|
||||
<ChatProvider
|
||||
user={user}
|
||||
capabilities={capabilities}
|
||||
grantedSkills={grantedSkills}
|
||||
onUserUpdate={onUserUpdate}
|
||||
>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/space/*"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ChangeEvent, useEffect, useRef, useState } from 'react';
|
||||
import { ChangeEvent, useEffect, useRef, useState, type ClipboardEvent } from 'react';
|
||||
import { useNetworkStatus } from '../hooks/useNetworkStatus';
|
||||
import { openAvatarPicker } from '../utils/userAvatar';
|
||||
import { CHAT_SKILL_OPTIONS, filterChatSkills } from '../utils/chatSkills';
|
||||
@@ -18,6 +18,18 @@ type PendingChatImage = {
|
||||
local: boolean;
|
||||
};
|
||||
|
||||
function FileIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M14 2H7a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V7zm0 0v5h5"
|
||||
/>
|
||||
<path fill="currentColor" d="M9 13h6v1.5H9zm0 3h6v1.5H9zm0-6h3v1.5H9z" opacity=".72" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChatPanel({
|
||||
variant,
|
||||
user,
|
||||
@@ -61,11 +73,13 @@ export function ChatPanel({
|
||||
const [pendingImages, setPendingImages] = useState<PendingChatImage[]>([]);
|
||||
const [uploadingImage, setUploadingImage] = useState(false);
|
||||
const [imageError, setImageError] = useState<string | null>(null);
|
||||
const [voiceStopSignal, setVoiceStopSignal] = useState(0);
|
||||
const imageInputRef = useRef<HTMLInputElement>(null);
|
||||
const bottomRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef(input);
|
||||
const voiceBaseRef = useRef('');
|
||||
const pendingImagesRef = useRef(pendingImages);
|
||||
const suppressVoiceUpdateRef = useRef(false);
|
||||
|
||||
inputRef.current = input;
|
||||
pendingImagesRef.current = pendingImages;
|
||||
@@ -106,15 +120,18 @@ export function ChatPanel({
|
||||
};
|
||||
|
||||
const handleVoiceStart = () => {
|
||||
suppressVoiceUpdateRef.current = false;
|
||||
voiceBaseRef.current = inputRef.current;
|
||||
setVoiceNotice(null);
|
||||
};
|
||||
|
||||
const handleVoiceLiveTranscript = (text: string) => {
|
||||
if (suppressVoiceUpdateRef.current) return;
|
||||
setInput(mergeVoiceText(text));
|
||||
};
|
||||
|
||||
const handleVoiceTranscript = (text: string) => {
|
||||
if (suppressVoiceUpdateRef.current) return;
|
||||
setInput(mergeVoiceText(text));
|
||||
setVoiceNotice('已识别,可编辑后发送');
|
||||
};
|
||||
@@ -141,17 +158,26 @@ export function ChatPanel({
|
||||
const imagesToSend = pendingImages.map((item) => item.url);
|
||||
const sentImages = [...pendingImages];
|
||||
|
||||
suppressVoiceUpdateRef.current = true;
|
||||
if (voiceRecording) {
|
||||
setVoiceStopSignal((value) => value + 1);
|
||||
}
|
||||
setInput('');
|
||||
voiceBaseRef.current = '';
|
||||
setVoiceNotice(null);
|
||||
setImageError(null);
|
||||
try {
|
||||
await onSubmit(trimmed, imagesToSend);
|
||||
sentImages.forEach(revokePendingImage);
|
||||
setPendingImages([]);
|
||||
} catch (err) {
|
||||
suppressVoiceUpdateRef.current = false;
|
||||
setInput(trimmed);
|
||||
setPendingImages(sentImages);
|
||||
setImageError(err instanceof Error ? err.message : '发送失败,请重试');
|
||||
return;
|
||||
}
|
||||
suppressVoiceUpdateRef.current = false;
|
||||
};
|
||||
|
||||
const handleImageInputChange = async (event: ChangeEvent<HTMLInputElement>) => {
|
||||
@@ -218,6 +244,71 @@ export function ChatPanel({
|
||||
}
|
||||
};
|
||||
|
||||
const handlePaste = async (event: ClipboardEvent<HTMLTextAreaElement>) => {
|
||||
if (!onUploadImage || imageAttachmentDisabled) return;
|
||||
const items = Array.from(event.clipboardData.items ?? []);
|
||||
const imageItems = items.filter((item) => item.kind === 'file' && item.type.startsWith('image/'));
|
||||
if (imageItems.length === 0) return;
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
const files = imageItems
|
||||
.map((item) => item.getAsFile())
|
||||
.filter((file): file is File => Boolean(file));
|
||||
if (files.length === 0) return;
|
||||
|
||||
const remaining = MAX_PENDING_IMAGES - pendingImages.length;
|
||||
if (remaining <= 0) {
|
||||
setImageError(`最多支持一次性附加 ${MAX_PENDING_IMAGES} 张图片`);
|
||||
return;
|
||||
}
|
||||
|
||||
const toUpload = files.slice(0, remaining);
|
||||
if (files.length > remaining) {
|
||||
setImageError(`最多支持 ${MAX_PENDING_IMAGES} 张图片,本次仅粘贴前 ${remaining} 张`);
|
||||
} else {
|
||||
setImageError(null);
|
||||
}
|
||||
|
||||
const placeholders = toUpload.map((file, index) => ({
|
||||
id: `${Date.now()}-${index}-${file.name || 'pasted-image'}`,
|
||||
url: URL.createObjectURL(file),
|
||||
local: true,
|
||||
}));
|
||||
setPendingImages((prev) => [...prev, ...placeholders]);
|
||||
|
||||
setUploadingImage(true);
|
||||
try {
|
||||
const uploaded = await Promise.all(toUpload.map((file) => onUploadImage(file)));
|
||||
const placeholderIds = placeholders.map((item) => item.id);
|
||||
setPendingImages((prev) => {
|
||||
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 };
|
||||
});
|
||||
const seen = new Set<string>();
|
||||
return next.filter((item) => {
|
||||
if (seen.has(item.url)) return false;
|
||||
seen.add(item.url);
|
||||
return true;
|
||||
});
|
||||
});
|
||||
} catch (err) {
|
||||
setPendingImages((prev) => {
|
||||
const removeIds = new Set(placeholders.map((item) => item.id));
|
||||
prev.forEach((item) => {
|
||||
if (removeIds.has(item.id)) revokePendingImage(item);
|
||||
});
|
||||
return prev.filter((item) => !removeIds.has(item.id));
|
||||
});
|
||||
setImageError(err instanceof Error ? err.message : '图片上传失败');
|
||||
} finally {
|
||||
setUploadingImage(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveImage = (idToRemove: string) => {
|
||||
setPendingImages((prev) => {
|
||||
const target = prev.find((item) => item.id === idToRemove);
|
||||
@@ -316,7 +407,7 @@ export function ChatPanel({
|
||||
disabled={imageAttachmentDisabled}
|
||||
onClick={() => imageInputRef.current?.click()}
|
||||
>
|
||||
🖼️
|
||||
<FileIcon />
|
||||
</button>
|
||||
<input
|
||||
ref={imageInputRef}
|
||||
@@ -336,6 +427,7 @@ export function ChatPanel({
|
||||
disabled={voiceDisabled}
|
||||
readOnly={voiceRecording}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onPaste={handlePaste}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
@@ -351,6 +443,7 @@ export function ChatPanel({
|
||||
onVoiceComplete={handleVoiceComplete}
|
||||
onRecordingChange={setVoiceRecording}
|
||||
onError={setVoiceNotice}
|
||||
stopSignal={voiceStopSignal}
|
||||
/>
|
||||
</div>
|
||||
{chatSkills.length > 0 && (
|
||||
|
||||
@@ -41,6 +41,7 @@ export function VoiceInputButton({
|
||||
onVoiceComplete,
|
||||
onRecordingChange,
|
||||
onError,
|
||||
stopSignal = 0,
|
||||
}: {
|
||||
disabled?: boolean;
|
||||
onTranscript?: (text: string) => void;
|
||||
@@ -49,6 +50,7 @@ export function VoiceInputButton({
|
||||
onVoiceComplete?: () => void;
|
||||
onRecordingChange?: (recording: boolean) => void;
|
||||
onError?: (message: string) => void;
|
||||
stopSignal?: number;
|
||||
}) {
|
||||
const [available, setAvailable] = useState(true);
|
||||
const [browserActive, setBrowserActive] = useState(false);
|
||||
@@ -62,6 +64,7 @@ export function VoiceInputButton({
|
||||
const startingRef = useRef(false);
|
||||
const browserCommittedRef = useRef(false);
|
||||
const prevRecordingRef = useRef(false);
|
||||
const lastStopSignalRef = useRef(stopSignal);
|
||||
|
||||
useEffect(() => {
|
||||
setAvailable(isVoiceInputAvailable());
|
||||
@@ -131,6 +134,19 @@ export function VoiceInputButton({
|
||||
resetSession();
|
||||
}, [browserActive, resetSession, wechatMode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (stopSignal === lastStopSignalRef.current) return;
|
||||
lastStopSignalRef.current = stopSignal;
|
||||
setBrowserActive(false);
|
||||
if (wechatMode) {
|
||||
void stopWechatVoiceRecord().catch(() => {
|
||||
// best-effort stop when the parent explicitly ends the current draft
|
||||
});
|
||||
setWechatPhase('idle');
|
||||
pendingWechatStopRef.current = false;
|
||||
}
|
||||
}, [stopSignal, wechatMode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!wechatMode) return;
|
||||
let cancelled = false;
|
||||
|
||||
@@ -17,7 +17,7 @@ export function VoiceInputDialog({
|
||||
onSend: (text: string) => void;
|
||||
onError?: (message: string) => void;
|
||||
}) {
|
||||
const { phase, text, analyser, liveRecognition, updateText, stopListening, finishFallbackRecording } =
|
||||
const { phase, text, analyser, liveRecognition, stopListening, finishFallbackRecording, resetSession } =
|
||||
useVoiceSession({
|
||||
active: open && !disabled,
|
||||
onError,
|
||||
@@ -25,8 +25,9 @@ export function VoiceInputDialog({
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
stopListening();
|
||||
resetSession();
|
||||
onClose();
|
||||
}, [onClose, stopListening]);
|
||||
}, [onClose, resetSession, stopListening]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return undefined;
|
||||
@@ -53,6 +54,7 @@ export function VoiceInputDialog({
|
||||
const value = text.trim();
|
||||
if (!value || disabled) return;
|
||||
stopListening();
|
||||
resetSession();
|
||||
onSend(value);
|
||||
onClose();
|
||||
};
|
||||
|
||||
@@ -10,15 +10,17 @@ const ChatContext = createContext<ChatContextValue | null>(null);
|
||||
export function ChatProvider({
|
||||
user,
|
||||
capabilities,
|
||||
grantedSkills,
|
||||
onUserUpdate,
|
||||
children,
|
||||
}: {
|
||||
user?: PortalUser | null;
|
||||
capabilities?: CapabilityMap | null;
|
||||
grantedSkills?: string[];
|
||||
onUserUpdate?: (user: PortalUser) => void;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const chat = useTKMindChat(user, onUserUpdate, capabilities);
|
||||
const chat = useTKMindChat(user, onUserUpdate, capabilities, grantedSkills);
|
||||
|
||||
return (
|
||||
<ChatContext.Provider value={chat}>
|
||||
|
||||
@@ -33,6 +33,7 @@ import type {
|
||||
} from '../types';
|
||||
import { buildContextPrefix } from '../utils/mindspaceChatContext';
|
||||
import { buildUserAddressPrefix } from '../utils/userAddress';
|
||||
import { buildAutoChatSkillPrefix } from '../../chat-skills.mjs';
|
||||
import {
|
||||
CHAT_IMAGE_UPLOAD_MAX_INPUT_BYTES,
|
||||
CHAT_IMAGE_UPLOAD_MAX_OUTPUT_BYTES,
|
||||
@@ -90,6 +91,7 @@ export function useTKMindChat(
|
||||
user?: PortalUser | null,
|
||||
onUserUpdate?: (user: PortalUser) => void,
|
||||
capabilities?: CapabilityMap | null,
|
||||
grantedSkills?: string[],
|
||||
) {
|
||||
const canUseProjectMemory = Boolean(capabilities?.context_memory);
|
||||
const [session, setSession] = useState<Session | null>(null);
|
||||
@@ -665,7 +667,8 @@ export function useTKMindChat(
|
||||
? buildContextPrefix(options.mindspaceContext)
|
||||
: '';
|
||||
const userPrefix = buildUserAddressPrefix(userRef.current);
|
||||
const agentPrefix = `${userPrefix}${mindspacePrefix}`;
|
||||
const skillPrefix = buildAutoChatSkillPrefix(trimmed, grantedSkills ?? []);
|
||||
const agentPrefix = `${userPrefix}${mindspacePrefix}${skillPrefix}`;
|
||||
const userMessage = buildUserMessage(trimmed, {
|
||||
agentText: `${agentPrefix}${trimmed}`,
|
||||
displayText: trimmed,
|
||||
@@ -693,7 +696,7 @@ export function useTKMindChat(
|
||||
activeRequestId.current = null;
|
||||
}
|
||||
},
|
||||
[notifyInsufficientBalance, session, chatState],
|
||||
[notifyInsufficientBalance, session, chatState, grantedSkills],
|
||||
);
|
||||
|
||||
const stop = useCallback(async () => {
|
||||
|
||||
+45
-32
@@ -1566,7 +1566,7 @@ body,
|
||||
.chat-image-attachments {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.chat-image-upload-error {
|
||||
@@ -1576,17 +1576,19 @@ body,
|
||||
|
||||
.chat-image-attachment-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
max-height: 168px;
|
||||
flex-wrap: nowrap;
|
||||
gap: 6px;
|
||||
padding-bottom: 2px;
|
||||
max-width: 100%;
|
||||
overflow-y: auto;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.chat-image-attachment-item {
|
||||
position: relative;
|
||||
flex: 0 0 auto;
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
width: 58px;
|
||||
height: 58px;
|
||||
}
|
||||
|
||||
.chat-image-attachment-item a {
|
||||
@@ -1596,9 +1598,9 @@ body,
|
||||
}
|
||||
|
||||
.chat-image-attachment-thumb {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
border-radius: 10px;
|
||||
width: 58px;
|
||||
height: 58px;
|
||||
border-radius: 8px;
|
||||
object-fit: cover;
|
||||
border: 1px solid rgba(24, 33, 29, 0.1);
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
@@ -1606,34 +1608,39 @@ body,
|
||||
|
||||
.chat-image-attachment-remove {
|
||||
position: absolute;
|
||||
right: 6px;
|
||||
top: 6px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
right: 4px;
|
||||
top: 4px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
color: var(--color-text-primary);
|
||||
background: rgba(0, 0, 0, 0.58);
|
||||
font-size: 14px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.chat-image-upload-trigger {
|
||||
position: absolute;
|
||||
left: 8px;
|
||||
left: 10px;
|
||||
top: 7px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: 1px solid rgba(24, 33, 29, 0.14);
|
||||
border-radius: 8px;
|
||||
background: rgba(24, 33, 29, 0.06);
|
||||
color: #52605a;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border: 1px solid rgba(24, 33, 29, 0.18);
|
||||
border-radius: 7px;
|
||||
background: rgba(24, 33, 29, 0.08);
|
||||
color: #5d6963;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.chat-image-upload-trigger:hover:not(:disabled) {
|
||||
background: rgba(24, 33, 29, 0.12);
|
||||
color: #4b5651;
|
||||
}
|
||||
|
||||
.chat-image-upload-trigger:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
@@ -1652,7 +1659,7 @@ body,
|
||||
|
||||
.chat-input-row textarea.input.chat-input-field {
|
||||
width: 100%;
|
||||
padding: 8px 50px 8px 12px;
|
||||
padding: 8px 46px 8px 42px;
|
||||
font-size: 14px;
|
||||
line-height: 1.35;
|
||||
resize: none;
|
||||
@@ -1788,21 +1795,24 @@ body,
|
||||
|
||||
.voice-btn {
|
||||
position: absolute;
|
||||
right: 6px;
|
||||
right: 7px;
|
||||
bottom: 6px;
|
||||
z-index: 1;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
color: #888;
|
||||
border: 1px solid rgba(24, 33, 29, 0.18);
|
||||
border-radius: 7px;
|
||||
background: rgba(24, 33, 29, 0.08);
|
||||
color: #5d6963;
|
||||
cursor: pointer;
|
||||
transition: color 0.15s ease;
|
||||
transition:
|
||||
background-color 120ms ease,
|
||||
color 120ms ease,
|
||||
border-color 120ms ease;
|
||||
}
|
||||
|
||||
.voice-btn:disabled {
|
||||
@@ -1811,12 +1821,15 @@ body,
|
||||
}
|
||||
|
||||
.voice-btn:hover:not(:disabled) {
|
||||
color: #666;
|
||||
background: rgba(24, 33, 29, 0.12);
|
||||
color: #4b5651;
|
||||
border-color: rgba(24, 33, 29, 0.28);
|
||||
}
|
||||
|
||||
.voice-btn-recording {
|
||||
color: #07c160;
|
||||
background: transparent;
|
||||
background: rgba(7, 193, 96, 0.12);
|
||||
border-color: rgba(7, 193, 96, 0.28);
|
||||
box-shadow: none;
|
||||
animation: none;
|
||||
}
|
||||
|
||||
+10
-7
@@ -1,5 +1,6 @@
|
||||
import type { Message, MessageContent } from '../types';
|
||||
import { mergeMessageContent } from '../../message-stream.mjs';
|
||||
import { stripUserAddressPrefix } from './userAddress';
|
||||
|
||||
const IMAGE_URL_LINE_RE = /^\[图片\d+]: (.+)$/;
|
||||
|
||||
@@ -76,7 +77,7 @@ export function normalizeUserMessageForApi(message: Message): Message {
|
||||
content: agentText ? [{ type: 'text', text: agentText }] : [],
|
||||
metadata: {
|
||||
...message.metadata,
|
||||
...(displayText ? { displayText } : {}),
|
||||
displayText,
|
||||
...(imageUrls.length ? { imageUrls } : {}),
|
||||
},
|
||||
};
|
||||
@@ -100,7 +101,7 @@ export function buildUserMessage(
|
||||
metadata: {
|
||||
userVisible: true,
|
||||
agentVisible: true,
|
||||
...(displayText ? { displayText } : {}),
|
||||
displayText,
|
||||
...(imageUrls.length ? { imageUrls } : {}),
|
||||
},
|
||||
};
|
||||
@@ -113,11 +114,13 @@ export function normalizeConversationMessages(messages: Message[]): Message[] {
|
||||
}
|
||||
|
||||
export function getDisplayText(message: Message): string {
|
||||
return (
|
||||
message.metadata.displayText ??
|
||||
getSystemNotificationText(message) ??
|
||||
getVisibleText(message)
|
||||
);
|
||||
if ('displayText' in message.metadata) {
|
||||
return message.metadata.displayText ?? '';
|
||||
}
|
||||
const systemText = getSystemNotificationText(message);
|
||||
if (systemText) return systemText;
|
||||
const visible = getVisibleText(message);
|
||||
return message.role === 'user' ? stripUserAddressPrefix(visible) : visible;
|
||||
}
|
||||
|
||||
export function pushMessage(messages: Message[], incoming: Message): Message[] {
|
||||
|
||||
@@ -21,3 +21,12 @@ export function buildUserAddressPrefix(
|
||||
|
||||
`;
|
||||
}
|
||||
|
||||
/** Strip hidden user-identity prefix when UI falls back to agent-side content. */
|
||||
export function stripUserAddressPrefix(text: string): string {
|
||||
if (!text.startsWith('[用户身份]\n')) return text;
|
||||
const separator = '\n\n';
|
||||
const end = text.indexOf(separator);
|
||||
if (end === -1) return text;
|
||||
return text.slice(end + separator.length).trim();
|
||||
}
|
||||
|
||||
+1
-1
@@ -620,7 +620,7 @@ export function createUserAuth(pool, options = {}) {
|
||||
await pool.query(
|
||||
`INSERT INTO h5_user_skill_grants (subject_type, subject_id, skill_name, enabled, updated_at)
|
||||
VALUES ('role', 'user', ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE skill_name = skill_name`,
|
||||
ON DUPLICATE KEY UPDATE enabled = VALUES(enabled), updated_at = VALUES(updated_at)`,
|
||||
[name, enabled ? 1 : 0, now],
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user