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:
john
2026-06-20 15:08:10 +08:00
parent 2e5afe3bfd
commit 70492d9eba
24 changed files with 648 additions and 129 deletions
+27
View File
@@ -1,6 +1,18 @@
// Keep browser-safe: do not import user-publish.mjs (uses node:fs/path/url). // Keep browser-safe: do not import user-publish.mjs (uses node:fs/path/url).
const PUBLISH_SKILL_NAME = 'static-page-publish'; 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 = [ export const CHAT_SKILL_DEFINITIONS = [
{ {
id: 'web-search', 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) { export function filterChatSkills(options, ctx) {
return options.filter((skill) => { return options.filter((skill) => {
if (skill.requiresPublish && !ctx.canPublish) return false; if (skill.requiresPublish && !ctx.canPublish) return false;
@@ -90,3 +106,14 @@ export function filterChatSkills(options, ctx) {
return true; 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');
}
+10
View File
@@ -1,6 +1,7 @@
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { import {
buildAutoChatSkillPrefix,
buildChatSkillPrompt, buildChatSkillPrompt,
CHAT_SKILL_DEFINITIONS, CHAT_SKILL_DEFINITIONS,
filterChatSkills, 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 === 'web-search')?.prefillOnly, true);
assert.equal(CHAT_SKILL_DEFINITIONS.find((item) => item.id === 'generate-page')?.prefillOnly, undefined); 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
View File
@@ -6,17 +6,17 @@
## 流量拓扑 ## 流量拓扑
``` ```
用户 → Cloudflare(橙云) → Tunnel 1d8cda42 → Studio Caddy(:8090, g2-lb) 用户 → 阿里云解析 → 105 服务器公网入口
├─ 权重 19 → Studio 本机 portal 127.0.0.1:8081 ├─ 转发到本地 Mac 1.6 机器 → 本机 portal 127.0.0.1:8081
└─ 权重 1 → [SSH正向隧道 127.0.0.1:18080] → 105 portal :8080 └─ [SSH 正向隧道 127.0.0.1:18080] → 105 portal :8080
105 / Studio 两个 portal ── 都连 ──→ Studio goosed 100.99.38.66:18006(唯一实例) 105 / 本地主机 两个 portal ── 都连 ──→ 本地 goosed 100.99.38.66:18006(唯一实例)
发布页文件 ── 都读 ──→ Studio canonical /Users/john/Project/Memind/MindSpace 发布页文件 ── 都读 ──→ 本地 canonical /Users/john/Project/Memind/MindSpace
105 经反向隧道 + rclone 挂载到本地同路径) 105 经反向隧道 + rclone 挂载到本地同路径)
``` ```
- **为什么用 Caddy 加权而不是 Cloudflare LB**账号未开通 Cloudflare Load Balancing 付费插件(建 pool/monitor 报 `Access Failed`)。Caddy 加权反代免费、可精确控权重,并自带 active 健康检查实现自动摘除 - **为什么不用旧的 Cloudflare 直连**当前入口已经迁到阿里云解析,外网流量先到 105,再由 105 转发回本地 Mac 1.6 机器
- **为什么 105 经 SSH 隧道接入**:Caddy 一个 `reverse_proxy` 不能混用 http(本地 :8081)和 https(105 公网 :443)上游。用 Studio→105 正向隧道把 105 暴露成本地 HTTP 端口,两个上游就都是本地 http,同协议 - **为什么 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 脚本主机。 - 禁止:`HostName 120.26.184.105` 直接作为 105 脚本主机。
- 禁止:用 `105.tkmind.cn` 做 API 健康检查或同步链路入口。 - 禁止:用 `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 19 1` | 95% | 5%(默认上线值) |
| `weighted_round_robin 9 1` | 90% | 10% | | `weighted_round_robin 9 1` | 90% | 10% |
| `weighted_round_robin 4 1` | 80% | 20% | | `weighted_round_robin 4 1` | 80% | 20% |
@@ -53,7 +53,7 @@ curl -s http://127.0.0.1:18080/api/status # 应返回 ok
改完**零停机生效** 改完**零停机生效**
```bash ```bash
ssh studio # 或 ssh 100.99.38.66 ssh 本机入口机
cd ~/Project/Memind/scripts cd ~/Project/Memind/scripts
caddy validate --config g2-lb.Caddyfile # 可选,先校验 caddy validate --config g2-lb.Caddyfile # 可选,先校验
caddy reload --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 ```bash
# 打 N 次看分流比例 # 打 N 次看分流比例
@@ -83,25 +83,25 @@ for i in $(seq 1 40); do
| grep -i x-memind-upstream | awk '{print $2}' | tr -d '\r' | grep -i x-memind-upstream | awk '{print $2}' | tr -d '\r'
done | sort | uniq -c 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 | 加权反代 + 健康检查 | | 入口反代 | 本机入口机 `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` | | 正向隧道 | 本机入口机 `scripts/memind-fwd-tunnel.sh`LaunchAgent `cn.tkmind.memind-fwd-tunnel` | 本机 `127.0.0.1:18080` → 105 `:8080` |
| 反向隧道 | Studio `scripts/memind-mac-tunnel.sh`LaunchAgent `cn.tkmind.memind-tunnel` | 105 经此挂载 Studio 的 MindSpace 文件 | | 反向隧道 | 本机入口机 `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 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 文件挂载 | 105 systemd `.mount``/mnt/memind-shared`rclone over 反向隧道) | 发布页/工作区共享只读 |
## 关键约束(改动前必读) ## 关键约束(改动前必读)
- **105 永不跑 goose**。goosed 单实例只在 Studiosession 存 Studio 本地 SQLite,文件操作也在 Studio 本地。105 只是代理 + serve。 - **105 永不跑 goose**。goosed 单实例只在本地 Mac 1.6 机器session 存本地 SQLite,文件操作也在本地完成。105 只是代理 + serve。
- 105 的 portal 必须设 `MEMIND_WORKSPACE_MAINTENANCE=0``MINDSPACE_AGENT_JOBS_ENABLED=false`——工作区维护守护(缩略图/资产同步 watcher)只该在 Studio 跑,否则 105 对 rclone 挂载树做递归 fs.watch 会占满 libuv 线程池导致 portal 启动卡死。 - 105 的 portal 必须设 `MEMIND_WORKSPACE_MAINTENANCE=0``MINDSPACE_AGENT_JOBS_ENABLED=false`——工作区维护守护(缩略图/资产同步 watcher)只该在本地跑,否则 105 对 rclone 挂载树做递归 fs.watch 会占满 libuv 线程池导致 portal 启动卡死。
- 105 必须与 Studio 跑**同一份代码**(用 `sync-to-105.sh` Studio 同步)和**相同的 `TKMIND_SERVER__SECRET_KEY`**(否则连不上 goosed / 解不开共享 RDS 里的加密设置)。 - 105 必须与本地主机跑**同一份代码**(用 `sync-to-105.sh`主机同步)和**相同的 `TKMIND_SERVER__SECRET_KEY`**(否则连不上 goosed / 解不开共享 RDS 里的加密设置)。
- 调权重只动 `g2-lb.Caddyfile`,不要动 105 的 systemd 单元或 .env。 - 调权重只动 `g2-lb.Caddyfile`,不要动 105 的 systemd 单元或 .env。
+16 -16
View File
@@ -8,20 +8,20 @@
用户访问 `https://g2.tkmind.cn/` 的流量路径: 用户访问 `https://g2.tkmind.cn/` 的流量路径:
```text ```text
Cloudflare(橙云 阿里云解析(DNS
Cloudflare Tunnel 105 服务器(公网入口 / 反代)
Studio Caddy:8090g2 负载均衡 本地 Mac 1.6 机器(主服务
├─ 权重 19 → Studio Portal127.0.0.1:8081 ← 主流量 ├─ 主流量 → 本地 Portal127.0.0.1:8081
└─ 权重 1 → 105 Portal(经隧道 127.0.0.1:18080 → :8080 ← 灰度副机 └─ 备用/灰度 → 105 Portal(经隧道 127.0.0.1:18080 → :8080
``` ```
两台 Portal 都是 **无状态前端**,共用 Studio 上的 goosed 与 MindSpace 数据目录。 两台 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` | | 灰度副 | 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)。 更详细的流量与灰度比例说明见 [g2 负载均衡](./g2-load-balancing.md)。
@@ -57,7 +57,7 @@ cd /path/to/your/memind-repo # 开发完成、已自测的目录
4. rsync 代码 → Studio 生产目录 4. rsync 代码 → Studio 生产目录
5. rsync 后校验(`.env` 未变、MindSpace 未减少、patch 仍在) 5. rsync 后校验(`.env` 未变、MindSpace 未减少、patch 仍在)
6. Studio 上 `npm install` + `npm run build` 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 8. 经 Studio 触发 `scripts/sync-to-105.sh`,同步并重启 105
## 4. 发布前检查清单 ## 4. 发布前检查清单
@@ -81,7 +81,7 @@ pnpm test # 建议跑;涉及核心逻辑时必跑
### 4.2 生产在线(只读) ### 4.2 生产在线(只读)
```bash ```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 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,不改远端 | **每次正式发布前必做** | | `--dry-run` | 只预览 diff,不改远端 | **每次正式发布前必做** |
| `--only-100` | 只更新 Studio,不推 105 | 先让 95% 主流量生效,105 稍后 | | `--only-100` | 只更新本地主机,不推 105 | 先让主流量生效,105 稍后 |
| `--only-105` | 只触发 Studio→105 同步 | Studio 已是最新,只补 105 | | `--only-105` | 只触发本地主机→105 同步 | 主机已是最新,只补 105 |
| `--skip-build` | 跳过远端 `npm run build` | 纯后端改动且确认 dist 无需更新 | | `--skip-build` | 跳过远端 `npm run build` | 纯后端改动且确认 dist 无需更新 |
| `--no-restart` | 只同步代码,不重启服务 | 分批发布;需自行重启 | | `--no-restart` | 只同步代码,不重启服务 | 分批发布;需自行重启 |
| `--yes` / `-y` | 跳过交互确认 | 自动化或你已看过 dry-run | | `--yes` / `-y` | 跳过交互确认 | 自动化或你已看过 dry-run |
@@ -169,11 +169,11 @@ package.json 中的 `pnpm deploy:105` 等价于本地执行 `bash scripts/sync-t
| 阶段 | 用户可见影响 | | 阶段 | 用户可见影响 |
|------|----------------| |------|----------------|
| rsync + npm install + build | 无(旧进程仍在跑) | | rsync + npm install + build | 无(旧进程仍在跑) |
| Studio Portal 重启 | **8081 短暂不可用**,约 1040 秒;g2 主流量(约 95%可能短暂 502 | | 本地 Portal 重启 | **8081 短暂不可用**,约 1040 秒;主流量可能短暂 502 |
| 105 goose-h5 重启 | **灰度流量(约 5%** 短暂不可用 | | 105 goose-h5 重启 | **灰度流量(约 5%** 短暂不可用 |
| Plaza 重启 | plaza.tkmind.cn 可能短暂不可用;与 g2 主聊天无关 | | Plaza 重启 | plaza.tkmind.cn 可能短暂不可用;与 g2 主聊天无关 |
Caddy 会对不健康上游做 active health check,105 重启期间可能暂时从池中摘除。 入口反代会对不健康上游做健康检查,105 重启期间可能暂时从池中摘除。
## 8. 发布后验证 ## 8. 发布后验证
@@ -189,7 +189,7 @@ curl -s https://g2.tkmind.cn/api/status
g2 响应头 `X-Memind-Upstream` g2 响应头 `X-Memind-Upstream`
- `127.0.0.1:8081`Studio - `127.0.0.1:8081`本地 Mac 1.6 机器
- `127.0.0.1:18080` → 105 - `127.0.0.1:18080` → 105
```bash ```bash
@@ -208,7 +208,7 @@ curl -s -D - -o /dev/null https://g2.tkmind.cn/api/status | grep -i x-memind-ups
### 8.4 日志 ### 8.4 日志
```bash ```bash
# Studio Portal # 本地 Portal
tail -f ~/Library/Logs/memind-portal.log tail -f ~/Library/Logs/memind-portal.log
# Studio Plaza # Studio Plaza
@@ -223,7 +223,7 @@ ssh ssh105 'journalctl -u goose-h5 -n 50 --no-pager'
项目没有一键回滚脚本,常见做法: 项目没有一键回滚脚本,常见做法:
1. **代码回滚:** 在本地 git 回到上一个 good commit`./rsync_to_server.sh` 再发一版 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#事故恢复最小步骤) 3. **紧急恢复 Portal** 若 8081 挂了,见 [隔离规程 · 事故恢复](./service-isolation-runbook.md#事故恢复最小步骤)
发布前建议打 tag 或记录当前 commit,便于回滚: 发布前建议打 tag 或记录当前 commit,便于回滚:
+5 -5
View File
@@ -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` | | 生产 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` | | 测试 Portal | `/Users/john/Project/test/Memind` | 开发预览 API / Portal | `18081` |
| 测试 Vite | `/Users/john/Project/test/Memind` | 开发预览前端 | `15173` | | 测试 Vite | `/Users/john/Project/test/Memind` | 开发预览前端 | `15173` |
| 测试 Admin | `/Users/john/Project/test/Memind` | 开发预览后台 | `18082` | | 测试 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 ```bash
cd /Users/john/Project/Memind 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 curl -s http://127.0.0.1:8081/api/status
``` ```
如果 `8081` 没有监听,再恢复生产 Portal 如果 `8081` 没有监听,再恢复本地主机 Portal
```bash ```bash
cd /Users/john/Project/Memind cd /Users/john/Project/Memind
@@ -215,7 +215,7 @@ sleep 2
curl -s http://127.0.0.1:8081/api/status curl -s http://127.0.0.1:8081/api/status
``` ```
恢复后再检查 Caddy 恢复后再检查入口反代
```bash ```bash
curl -s http://127.0.0.1:8090/api/status curl -s http://127.0.0.1:8090/api/status
+19 -32
View File
@@ -3,12 +3,13 @@ import fs from 'node:fs/promises';
import path from 'node:path'; import path from 'node:path';
import { DEFAULT_MAX_FILE_BYTES } from './mindspace.mjs'; import { DEFAULT_MAX_FILE_BYTES } from './mindspace.mjs';
import { runBasicFileScan } from './mindspace-scan.mjs'; import { runBasicFileScan } from './mindspace-scan.mjs';
import { extractAttachmentText } from './mindspace-attachment-text.mjs';
import { import {
assetThumbnailKey, assetThumbnailKey,
ensureHtmlThumbnail, ensureHtmlThumbnail,
scheduleHtmlThumbnail, scheduleHtmlThumbnail,
} from './mindspace-thumbnails.mjs'; } 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 { canPreviewAsset, renderAssetPreviewHtml } from './mindspace-asset-preview.mjs';
import { createWorkspaceAssetSync } from './mindspace-workspace-sync.mjs'; import { createWorkspaceAssetSync } from './mindspace-workspace-sync.mjs';
@@ -257,7 +258,7 @@ export function createAssetService(pool, options = {}) {
const uploadId = idFactory(); const uploadId = idFactory();
const now = Date.now(); 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( await conn.query(
`UPDATE h5_user_spaces `UPDATE h5_user_spaces
SET reserved_bytes = reserved_bytes + ?, updated_at = ? SET reserved_bytes = reserved_bytes + ?, updated_at = ?
@@ -349,9 +350,8 @@ export function createAssetService(pool, options = {}) {
}; };
const completeUpload = async (userId, uploadId) => { const completeUpload = async (userId, uploadId) => {
const conn = await pool.getConnection(); const conn = await pool.getConnection();
let temporaryPath; let temporaryPath;
let finalPath;
try { try {
await conn.beginTransaction(); await conn.beginTransaction();
const [rows] = await conn.query( const [rows] = await conn.query(
@@ -389,20 +389,9 @@ export function createAssetService(pool, options = {}) {
const assetId = idFactory(); const assetId = idFactory();
const versionId = idFactory(); const versionId = idFactory();
const finalStorageKey = path.posix.join( const finalStorageKey = upload.temporary_storage_key;
'users',
userId,
'assets',
assetId,
'versions',
versionId,
);
temporaryPath = absoluteStoragePath(upload.temporary_storage_key); temporaryPath = absoluteStoragePath(upload.temporary_storage_key);
finalPath = absoluteStoragePath(finalStorageKey); const fileBuffer = await fs.readFile(temporaryPath);
await fs.mkdir(path.dirname(finalPath), { recursive: true });
await fs.rename(temporaryPath, finalPath);
const fileBuffer = await fs.readFile(finalPath);
const scan = runBasicFileScan(fileBuffer, { const scan = runBasicFileScan(fileBuffer, {
filename: upload.filename, filename: upload.filename,
mimeType: upload.detected_mime_type, mimeType: upload.detected_mime_type,
@@ -469,11 +458,6 @@ export function createAssetService(pool, options = {}) {
[now, assetId, uploadId, userId], [now, assetId, uploadId, userId],
); );
await conn.commit(); await conn.commit();
await mirrorToUserWorkspace(userId, {
categoryCode: upload.category_code,
filename: upload.filename,
sourcePath: finalPath,
});
return { return {
id: assetId, id: assetId,
categoryId: upload.category_id, categoryId: upload.category_id,
@@ -494,10 +478,6 @@ export function createAssetService(pool, options = {}) {
}; };
} catch (error) { } catch (error) {
await conn.rollback(); await conn.rollback();
if (finalPath && temporaryPath) {
await fs.mkdir(path.dirname(temporaryPath), { recursive: true }).catch(() => {});
await fs.rename(finalPath, temporaryPath).catch(() => {});
}
throw error; throw error;
} finally { } finally {
conn.release(); conn.release();
@@ -820,11 +800,6 @@ export function createAssetService(pool, options = {}) {
[buffer.length, now, category.space_id, userId], [buffer.length, now, category.space_id, userId],
); );
await conn.commit(); await conn.commit();
await mirrorToUserWorkspace(userId, {
categoryCode: category.category_code,
filename: normalizedFilename,
sourcePath: finalPath,
});
if (detectedMimeType === 'text/html') { if (detectedMimeType === 'text/html') {
scheduleHtmlThumbnail(storageRoot, assetThumbnailKey(userId, assetId), buffer.toString('utf8'), { scheduleHtmlThumbnail(storageRoot, assetThumbnailKey(userId, assetId), buffer.toString('utf8'), {
title: displayName || normalizedFilename, title: displayName || normalizedFilename,
@@ -891,6 +866,17 @@ export function createAssetService(pool, options = {}) {
return renderAssetPreviewHtml({ asset, buffer, downloadUrl }); 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 expireStaleUploads = async (now = Date.now()) => {
const [rows] = await pool.query( const [rows] = await pool.query(
`SELECT id, user_id, space_id, reserved_bytes, temporary_storage_key, status `SELECT id, user_id, space_id, reserved_bytes, temporary_storage_key, status
@@ -952,6 +938,7 @@ export function createAssetService(pool, options = {}) {
deleteAsset, deleteAsset,
readAsset, readAsset,
renderAssetPreview, renderAssetPreview,
extractAssetText,
renderAssetThumbnail, renderAssetThumbnail,
syncWorkspaceAssets: workspaceSync.syncUserWorkspace, syncWorkspaceAssets: workspaceSync.syncUserWorkspace,
expireStaleUploads, expireStaleUploads,
+1
View File
@@ -199,6 +199,7 @@ test('completeUpload marks safe files ready and blocks script payloads', async (
filename: 'note.txt', filename: 'note.txt',
sizeBytes: payload.length, sizeBytes: payload.length,
}); });
assert.match(state.uploads[0].temporary_storage_key, /^users\/user-1\/temp\//);
await service.writeUploadContent('user-1', upload.id, payload); await service.writeUploadContent('user-1', upload.id, payload);
const asset = await service.completeUpload('user-1', upload.id); const asset = await service.completeUpload('user-1', upload.id);
assert.equal(asset.status, 'quarantined'); assert.equal(asset.status, 'quarantined');
+161
View File
@@ -0,0 +1,161 @@
import zlib from 'node:zlib';
function escapeXml(text) {
return String(text ?? '')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&amp;/g, '&')
.replace(/&quot;/g, '"')
.replace(/&#39;/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,
};
+40
View File
@@ -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/);
});
+13 -4
View File
@@ -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([ const TRUSTED_EXTERNAL_HOSTS = new Set([
'fonts.googleapis.com', 'fonts.googleapis.com',
'fonts.gstatic.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); let matches = uniqueMatches(content, rule.pattern);
if (rule.type === 'external_link') { if (rule.type === 'external_link') {
matches = matches.filter((match) => !isTrustedExternalUrl(match[0])); matches = matches.filter((match) => !isTrustedExternalUrl(match[0]));
} }
if (!matches.length) return content; if (!matches.length) return content;
const blocking =
options.allowHtmlActiveContent && ACKNOWLEDGEABLE_HTML_ACTIVE_TYPES.has(rule.type)
? false
: rule.blocking;
findings.push({ findings.push({
id: crypto id: crypto
.createHash('sha256') .createHash('sha256')
@@ -199,7 +208,7 @@ function applyRule(content, rule, findings, redactions) {
riskLevel: rule.riskLevel, riskLevel: rule.riskLevel,
occurrenceCount: matches.length, occurrenceCount: matches.length,
sampleMasked: rule.mask(matches[0][0], matches[0]), sampleMasked: rule.mask(matches[0][0], matches[0]),
blocking: rule.blocking, blocking,
}); });
let next = String(content); let next = String(content);
if (typeof rule.replacement === 'function') { if (typeof rule.replacement === 'function') {
@@ -224,11 +233,11 @@ export function scanContent(content, options = {}) {
const findings = []; const findings = [];
const working = String(content ?? ''); const working = String(content ?? '');
for (const rule of TEXT_RULES) { for (const rule of TEXT_RULES) {
applyRule(working, rule, findings, []); applyRule(working, rule, findings, [], options);
} }
if (format === 'html') { if (format === 'html') {
for (const rule of HTML_RULES) { for (const rule of HTML_RULES) {
applyRule(working, rule, findings, []); applyRule(working, rule, findings, [], options);
} }
} }
return finalizeResult(findings); return finalizeResult(findings);
+16
View File
@@ -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', () => { test('redactContent masks secrets and strips unsafe html', () => {
const result = redactContent( const result = redactContent(
'手机号 13800138000\n邮箱 john@example.com\n<script>alert(1)</script>\nkey=sk_test_1234567890abcdef', '手机号 13800138000\n邮箱 john@example.com\n<script>alert(1)</script>\nkey=sk_test_1234567890abcdef',
+1
View File
@@ -283,6 +283,7 @@ export function createPublicationService(pool, options = {}) {
} }
const scan = scanContent(`${page.title}\n${page.summary ?? ''}\n${publishContent}`, { const scan = scanContent(`${page.title}\n${page.summary ?? ''}\n${publishContent}`, {
format: page.page_type === 'html' ? 'html' : 'text', format: page.page_type === 'html' ? 'html' : 'text',
allowHtmlActiveContent: page.page_type === 'html',
}); });
const suggestedUrlSlug = const suggestedUrlSlug =
conflict && accessMode !== 'private_link' conflict && accessMode !== 'private_link'
+114
View File
@@ -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
View File
@@ -53,6 +53,7 @@ import {
preparePublicationHtmlForEmbed, preparePublicationHtmlForEmbed,
isPlazaEmbedRequest, isPlazaEmbedRequest,
publishedPageCspForEmbed, publishedPageCspForEmbed,
stripPublicationHtmlCspMeta,
} from './plaza-embed.mjs'; } from './plaza-embed.mjs';
import { createCleanupService } from './mindspace-cleanup.mjs'; import { createCleanupService } from './mindspace-cleanup.mjs';
import { createAgentJobService } from './mindspace-agent-jobs.mjs'; import { createAgentJobService } from './mindspace-agent-jobs.mjs';
@@ -3002,11 +3003,14 @@ api.use(
app.use('/api', api); 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); const isFullHtml = /^\s*<!doctype html/i.test(html) || /^\s*<html[\s>]/i.test(html);
if (embed && isFullHtml) { if (embed && isFullHtml) {
return publishedPageCspForEmbed(true); 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) { 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'"; 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> </head>
<body> <body>
<div class="publication-shell"> <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> </div>
<button type="button" class="publication-share-fab" id="publication-share-fab">分享</button> <button type="button" class="publication-share-fab" id="publication-share-fab">分享</button>
<div class="publication-share-sheet" id="publication-share-sheet" hidden> <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; let html = result.html;
if (embed) { if (embed) {
html = preparePublicationHtmlForEmbed(html); html = preparePublicationHtmlForEmbed(html);
} else if (raw) {
html = stripPublicationHtmlCspMeta(html);
} }
const isFullHtml = /^\s*<!doctype html/i.test(html) || /^\s*<html[\s>]/i.test(html); const isFullHtml = /^\s*<!doctype html/i.test(html) || /^\s*<html[\s>]/i.test(html);
const canWrapWithShell = !embed && !raw && isFullHtml && result.publication?.accessMode !== 'password'; 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-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( res.set(
'Cache-Control', 'Cache-Control',
result.publication.accessMode === 'public' ? 'public, max-age=60' : 'private, no-store', result.publication.accessMode === 'public' ? 'public, max-age=60' : 'private, no-store',
+6 -1
View File
@@ -88,7 +88,12 @@ function AuthenticatedApp({
); );
return ( return (
<ChatProvider user={user} capabilities={capabilities} onUserUpdate={onUserUpdate}> <ChatProvider
user={user}
capabilities={capabilities}
grantedSkills={grantedSkills}
onUserUpdate={onUserUpdate}
>
<Routes> <Routes>
<Route <Route
path="/space/*" path="/space/*"
+95 -2
View File
@@ -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 { useNetworkStatus } from '../hooks/useNetworkStatus';
import { openAvatarPicker } from '../utils/userAvatar'; import { openAvatarPicker } from '../utils/userAvatar';
import { CHAT_SKILL_OPTIONS, filterChatSkills } from '../utils/chatSkills'; import { CHAT_SKILL_OPTIONS, filterChatSkills } from '../utils/chatSkills';
@@ -18,6 +18,18 @@ type PendingChatImage = {
local: boolean; 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({ export function ChatPanel({
variant, variant,
user, user,
@@ -61,11 +73,13 @@ export function ChatPanel({
const [pendingImages, setPendingImages] = useState<PendingChatImage[]>([]); const [pendingImages, setPendingImages] = useState<PendingChatImage[]>([]);
const [uploadingImage, setUploadingImage] = useState(false); const [uploadingImage, setUploadingImage] = useState(false);
const [imageError, setImageError] = useState<string | null>(null); const [imageError, setImageError] = useState<string | null>(null);
const [voiceStopSignal, setVoiceStopSignal] = useState(0);
const imageInputRef = useRef<HTMLInputElement>(null); const imageInputRef = useRef<HTMLInputElement>(null);
const bottomRef = useRef<HTMLDivElement>(null); const bottomRef = useRef<HTMLDivElement>(null);
const inputRef = useRef(input); const inputRef = useRef(input);
const voiceBaseRef = useRef(''); const voiceBaseRef = useRef('');
const pendingImagesRef = useRef(pendingImages); const pendingImagesRef = useRef(pendingImages);
const suppressVoiceUpdateRef = useRef(false);
inputRef.current = input; inputRef.current = input;
pendingImagesRef.current = pendingImages; pendingImagesRef.current = pendingImages;
@@ -106,15 +120,18 @@ export function ChatPanel({
}; };
const handleVoiceStart = () => { const handleVoiceStart = () => {
suppressVoiceUpdateRef.current = false;
voiceBaseRef.current = inputRef.current; voiceBaseRef.current = inputRef.current;
setVoiceNotice(null); setVoiceNotice(null);
}; };
const handleVoiceLiveTranscript = (text: string) => { const handleVoiceLiveTranscript = (text: string) => {
if (suppressVoiceUpdateRef.current) return;
setInput(mergeVoiceText(text)); setInput(mergeVoiceText(text));
}; };
const handleVoiceTranscript = (text: string) => { const handleVoiceTranscript = (text: string) => {
if (suppressVoiceUpdateRef.current) return;
setInput(mergeVoiceText(text)); setInput(mergeVoiceText(text));
setVoiceNotice('已识别,可编辑后发送'); setVoiceNotice('已识别,可编辑后发送');
}; };
@@ -141,17 +158,26 @@ export function ChatPanel({
const imagesToSend = pendingImages.map((item) => item.url); const imagesToSend = pendingImages.map((item) => item.url);
const sentImages = [...pendingImages]; const sentImages = [...pendingImages];
suppressVoiceUpdateRef.current = true;
if (voiceRecording) {
setVoiceStopSignal((value) => value + 1);
}
setInput(''); setInput('');
voiceBaseRef.current = '';
setVoiceNotice(null);
setImageError(null); setImageError(null);
try { try {
await onSubmit(trimmed, imagesToSend); await onSubmit(trimmed, imagesToSend);
sentImages.forEach(revokePendingImage); sentImages.forEach(revokePendingImage);
setPendingImages([]); setPendingImages([]);
} catch (err) { } catch (err) {
suppressVoiceUpdateRef.current = false;
setInput(trimmed); setInput(trimmed);
setPendingImages(sentImages); setPendingImages(sentImages);
setImageError(err instanceof Error ? err.message : '发送失败,请重试'); setImageError(err instanceof Error ? err.message : '发送失败,请重试');
return;
} }
suppressVoiceUpdateRef.current = false;
}; };
const handleImageInputChange = async (event: ChangeEvent<HTMLInputElement>) => { 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) => { const handleRemoveImage = (idToRemove: string) => {
setPendingImages((prev) => { setPendingImages((prev) => {
const target = prev.find((item) => item.id === idToRemove); const target = prev.find((item) => item.id === idToRemove);
@@ -316,7 +407,7 @@ export function ChatPanel({
disabled={imageAttachmentDisabled} disabled={imageAttachmentDisabled}
onClick={() => imageInputRef.current?.click()} onClick={() => imageInputRef.current?.click()}
> >
🖼 <FileIcon />
</button> </button>
<input <input
ref={imageInputRef} ref={imageInputRef}
@@ -336,6 +427,7 @@ export function ChatPanel({
disabled={voiceDisabled} disabled={voiceDisabled}
readOnly={voiceRecording} readOnly={voiceRecording}
onChange={(e) => setInput(e.target.value)} onChange={(e) => setInput(e.target.value)}
onPaste={handlePaste}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) { if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault(); e.preventDefault();
@@ -351,6 +443,7 @@ export function ChatPanel({
onVoiceComplete={handleVoiceComplete} onVoiceComplete={handleVoiceComplete}
onRecordingChange={setVoiceRecording} onRecordingChange={setVoiceRecording}
onError={setVoiceNotice} onError={setVoiceNotice}
stopSignal={voiceStopSignal}
/> />
</div> </div>
{chatSkills.length > 0 && ( {chatSkills.length > 0 && (
+16
View File
@@ -41,6 +41,7 @@ export function VoiceInputButton({
onVoiceComplete, onVoiceComplete,
onRecordingChange, onRecordingChange,
onError, onError,
stopSignal = 0,
}: { }: {
disabled?: boolean; disabled?: boolean;
onTranscript?: (text: string) => void; onTranscript?: (text: string) => void;
@@ -49,6 +50,7 @@ export function VoiceInputButton({
onVoiceComplete?: () => void; onVoiceComplete?: () => void;
onRecordingChange?: (recording: boolean) => void; onRecordingChange?: (recording: boolean) => void;
onError?: (message: string) => void; onError?: (message: string) => void;
stopSignal?: number;
}) { }) {
const [available, setAvailable] = useState(true); const [available, setAvailable] = useState(true);
const [browserActive, setBrowserActive] = useState(false); const [browserActive, setBrowserActive] = useState(false);
@@ -62,6 +64,7 @@ export function VoiceInputButton({
const startingRef = useRef(false); const startingRef = useRef(false);
const browserCommittedRef = useRef(false); const browserCommittedRef = useRef(false);
const prevRecordingRef = useRef(false); const prevRecordingRef = useRef(false);
const lastStopSignalRef = useRef(stopSignal);
useEffect(() => { useEffect(() => {
setAvailable(isVoiceInputAvailable()); setAvailable(isVoiceInputAvailable());
@@ -131,6 +134,19 @@ export function VoiceInputButton({
resetSession(); resetSession();
}, [browserActive, resetSession, wechatMode]); }, [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(() => { useEffect(() => {
if (!wechatMode) return; if (!wechatMode) return;
let cancelled = false; let cancelled = false;
+4 -2
View File
@@ -17,7 +17,7 @@ export function VoiceInputDialog({
onSend: (text: string) => void; onSend: (text: string) => void;
onError?: (message: string) => void; onError?: (message: string) => void;
}) { }) {
const { phase, text, analyser, liveRecognition, updateText, stopListening, finishFallbackRecording } = const { phase, text, analyser, liveRecognition, stopListening, finishFallbackRecording, resetSession } =
useVoiceSession({ useVoiceSession({
active: open && !disabled, active: open && !disabled,
onError, onError,
@@ -25,8 +25,9 @@ export function VoiceInputDialog({
const handleClose = useCallback(() => { const handleClose = useCallback(() => {
stopListening(); stopListening();
resetSession();
onClose(); onClose();
}, [onClose, stopListening]); }, [onClose, resetSession, stopListening]);
useEffect(() => { useEffect(() => {
if (!open) return undefined; if (!open) return undefined;
@@ -53,6 +54,7 @@ export function VoiceInputDialog({
const value = text.trim(); const value = text.trim();
if (!value || disabled) return; if (!value || disabled) return;
stopListening(); stopListening();
resetSession();
onSend(value); onSend(value);
onClose(); onClose();
}; };
+3 -1
View File
@@ -10,15 +10,17 @@ const ChatContext = createContext<ChatContextValue | null>(null);
export function ChatProvider({ export function ChatProvider({
user, user,
capabilities, capabilities,
grantedSkills,
onUserUpdate, onUserUpdate,
children, children,
}: { }: {
user?: PortalUser | null; user?: PortalUser | null;
capabilities?: CapabilityMap | null; capabilities?: CapabilityMap | null;
grantedSkills?: string[];
onUserUpdate?: (user: PortalUser) => void; onUserUpdate?: (user: PortalUser) => void;
children: ReactNode; children: ReactNode;
}) { }) {
const chat = useTKMindChat(user, onUserUpdate, capabilities); const chat = useTKMindChat(user, onUserUpdate, capabilities, grantedSkills);
return ( return (
<ChatContext.Provider value={chat}> <ChatContext.Provider value={chat}>
+5 -2
View File
@@ -33,6 +33,7 @@ import type {
} from '../types'; } from '../types';
import { buildContextPrefix } from '../utils/mindspaceChatContext'; import { buildContextPrefix } from '../utils/mindspaceChatContext';
import { buildUserAddressPrefix } from '../utils/userAddress'; import { buildUserAddressPrefix } from '../utils/userAddress';
import { buildAutoChatSkillPrefix } from '../../chat-skills.mjs';
import { import {
CHAT_IMAGE_UPLOAD_MAX_INPUT_BYTES, CHAT_IMAGE_UPLOAD_MAX_INPUT_BYTES,
CHAT_IMAGE_UPLOAD_MAX_OUTPUT_BYTES, CHAT_IMAGE_UPLOAD_MAX_OUTPUT_BYTES,
@@ -90,6 +91,7 @@ export function useTKMindChat(
user?: PortalUser | null, user?: PortalUser | null,
onUserUpdate?: (user: PortalUser) => void, onUserUpdate?: (user: PortalUser) => void,
capabilities?: CapabilityMap | null, capabilities?: CapabilityMap | null,
grantedSkills?: string[],
) { ) {
const canUseProjectMemory = Boolean(capabilities?.context_memory); const canUseProjectMemory = Boolean(capabilities?.context_memory);
const [session, setSession] = useState<Session | null>(null); const [session, setSession] = useState<Session | null>(null);
@@ -665,7 +667,8 @@ export function useTKMindChat(
? buildContextPrefix(options.mindspaceContext) ? buildContextPrefix(options.mindspaceContext)
: ''; : '';
const userPrefix = buildUserAddressPrefix(userRef.current); const userPrefix = buildUserAddressPrefix(userRef.current);
const agentPrefix = `${userPrefix}${mindspacePrefix}`; const skillPrefix = buildAutoChatSkillPrefix(trimmed, grantedSkills ?? []);
const agentPrefix = `${userPrefix}${mindspacePrefix}${skillPrefix}`;
const userMessage = buildUserMessage(trimmed, { const userMessage = buildUserMessage(trimmed, {
agentText: `${agentPrefix}${trimmed}`, agentText: `${agentPrefix}${trimmed}`,
displayText: trimmed, displayText: trimmed,
@@ -693,7 +696,7 @@ export function useTKMindChat(
activeRequestId.current = null; activeRequestId.current = null;
} }
}, },
[notifyInsufficientBalance, session, chatState], [notifyInsufficientBalance, session, chatState, grantedSkills],
); );
const stop = useCallback(async () => { const stop = useCallback(async () => {
+45 -32
View File
@@ -1566,7 +1566,7 @@ body,
.chat-image-attachments { .chat-image-attachments {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 6px; gap: 4px;
} }
.chat-image-upload-error { .chat-image-upload-error {
@@ -1576,17 +1576,19 @@ body,
.chat-image-attachment-grid { .chat-image-attachment-grid {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: nowrap;
gap: 8px; gap: 6px;
max-height: 168px; padding-bottom: 2px;
max-width: 100%;
overflow-y: auto; overflow-y: auto;
overflow-x: auto;
} }
.chat-image-attachment-item { .chat-image-attachment-item {
position: relative; position: relative;
flex: 0 0 auto; flex: 0 0 auto;
width: 72px; width: 58px;
height: 72px; height: 58px;
} }
.chat-image-attachment-item a { .chat-image-attachment-item a {
@@ -1596,9 +1598,9 @@ body,
} }
.chat-image-attachment-thumb { .chat-image-attachment-thumb {
width: 72px; width: 58px;
height: 72px; height: 58px;
border-radius: 10px; border-radius: 8px;
object-fit: cover; object-fit: cover;
border: 1px solid rgba(24, 33, 29, 0.1); border: 1px solid rgba(24, 33, 29, 0.1);
background: rgba(0, 0, 0, 0.04); background: rgba(0, 0, 0, 0.04);
@@ -1606,34 +1608,39 @@ body,
.chat-image-attachment-remove { .chat-image-attachment-remove {
position: absolute; position: absolute;
right: 6px; right: 4px;
top: 6px; top: 4px;
width: 22px; width: 18px;
height: 22px; height: 18px;
border: 0; border: 0;
border-radius: 50%; border-radius: 50%;
color: var(--color-text-primary); color: var(--color-text-primary);
background: rgba(0, 0, 0, 0.58); background: rgba(0, 0, 0, 0.58);
font-size: 14px; font-size: 12px;
cursor: pointer; cursor: pointer;
} }
.chat-image-upload-trigger { .chat-image-upload-trigger {
position: absolute; position: absolute;
left: 8px; left: 10px;
top: 7px; top: 7px;
width: 28px; width: 26px;
height: 28px; height: 26px;
border: 1px solid rgba(24, 33, 29, 0.14); border: 1px solid rgba(24, 33, 29, 0.18);
border-radius: 8px; border-radius: 7px;
background: rgba(24, 33, 29, 0.06); background: rgba(24, 33, 29, 0.08);
color: #52605a; color: #5d6963;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
cursor: pointer; cursor: pointer;
} }
.chat-image-upload-trigger:hover:not(:disabled) {
background: rgba(24, 33, 29, 0.12);
color: #4b5651;
}
.chat-image-upload-trigger:disabled { .chat-image-upload-trigger:disabled {
opacity: 0.5; opacity: 0.5;
cursor: not-allowed; cursor: not-allowed;
@@ -1652,7 +1659,7 @@ body,
.chat-input-row textarea.input.chat-input-field { .chat-input-row textarea.input.chat-input-field {
width: 100%; width: 100%;
padding: 8px 50px 8px 12px; padding: 8px 46px 8px 42px;
font-size: 14px; font-size: 14px;
line-height: 1.35; line-height: 1.35;
resize: none; resize: none;
@@ -1788,21 +1795,24 @@ body,
.voice-btn { .voice-btn {
position: absolute; position: absolute;
right: 6px; right: 7px;
bottom: 6px; bottom: 6px;
z-index: 1; z-index: 1;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: 26px; width: 28px;
height: 26px; height: 28px;
padding: 0; padding: 0;
border: none; border: 1px solid rgba(24, 33, 29, 0.18);
border-radius: 0; border-radius: 7px;
background: transparent; background: rgba(24, 33, 29, 0.08);
color: #888; color: #5d6963;
cursor: pointer; cursor: pointer;
transition: color 0.15s ease; transition:
background-color 120ms ease,
color 120ms ease,
border-color 120ms ease;
} }
.voice-btn:disabled { .voice-btn:disabled {
@@ -1811,12 +1821,15 @@ body,
} }
.voice-btn:hover:not(:disabled) { .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 { .voice-btn-recording {
color: #07c160; color: #07c160;
background: transparent; background: rgba(7, 193, 96, 0.12);
border-color: rgba(7, 193, 96, 0.28);
box-shadow: none; box-shadow: none;
animation: none; animation: none;
} }
+10 -7
View File
@@ -1,5 +1,6 @@
import type { Message, MessageContent } from '../types'; import type { Message, MessageContent } from '../types';
import { mergeMessageContent } from '../../message-stream.mjs'; import { mergeMessageContent } from '../../message-stream.mjs';
import { stripUserAddressPrefix } from './userAddress';
const IMAGE_URL_LINE_RE = /^\[图片\d+]: (.+)$/; const IMAGE_URL_LINE_RE = /^\[图片\d+]: (.+)$/;
@@ -76,7 +77,7 @@ export function normalizeUserMessageForApi(message: Message): Message {
content: agentText ? [{ type: 'text', text: agentText }] : [], content: agentText ? [{ type: 'text', text: agentText }] : [],
metadata: { metadata: {
...message.metadata, ...message.metadata,
...(displayText ? { displayText } : {}), displayText,
...(imageUrls.length ? { imageUrls } : {}), ...(imageUrls.length ? { imageUrls } : {}),
}, },
}; };
@@ -100,7 +101,7 @@ export function buildUserMessage(
metadata: { metadata: {
userVisible: true, userVisible: true,
agentVisible: true, agentVisible: true,
...(displayText ? { displayText } : {}), displayText,
...(imageUrls.length ? { imageUrls } : {}), ...(imageUrls.length ? { imageUrls } : {}),
}, },
}; };
@@ -113,11 +114,13 @@ export function normalizeConversationMessages(messages: Message[]): Message[] {
} }
export function getDisplayText(message: Message): string { export function getDisplayText(message: Message): string {
return ( if ('displayText' in message.metadata) {
message.metadata.displayText ?? return message.metadata.displayText ?? '';
getSystemNotificationText(message) ?? }
getVisibleText(message) 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[] { export function pushMessage(messages: Message[], incoming: Message): Message[] {
+9
View File
@@ -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
View File
@@ -620,7 +620,7 @@ export function createUserAuth(pool, options = {}) {
await pool.query( await pool.query(
`INSERT INTO h5_user_skill_grants (subject_type, subject_id, skill_name, enabled, updated_at) `INSERT INTO h5_user_skill_grants (subject_type, subject_id, skill_name, enabled, updated_at)
VALUES ('role', 'user', ?, ?, ?) 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], [name, enabled ? 1 : 0, now],
); );
} }