From 70492d9ebaf8afb7b62434875738cb222575c5ea Mon Sep 17 00:00:00 2001 From: john Date: Sat, 20 Jun 2026 15:08:10 +0800 Subject: [PATCH] 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 --- chat-skills.mjs | 27 +++++ chat-skills.test.mjs | 10 ++ docs/g2-load-balancing.md | 42 ++++---- docs/release-deploy.md | 32 +++--- docs/service-isolation-runbook.md | 10 +- mindspace-assets.mjs | 51 ++++----- mindspace-assets.test.mjs | 1 + mindspace-attachment-text.mjs | 161 ++++++++++++++++++++++++++++ mindspace-attachment-text.test.mjs | 40 +++++++ mindspace-content-scan.mjs | 17 ++- mindspace-content-scan.test.mjs | 16 +++ mindspace-publications.mjs | 1 + scripts/purge-plaza-demo.mjs | 114 ++++++++++++++++++++ server.mjs | 13 ++- src/App.tsx | 7 +- src/components/ChatPanel.tsx | 97 ++++++++++++++++- src/components/VoiceInputButton.tsx | 16 +++ src/components/VoiceInputDialog.tsx | 6 +- src/context/ChatProvider.tsx | 4 +- src/hooks/useTKMindChat.ts | 7 +- src/index.css | 77 +++++++------ src/utils/message.ts | 17 +-- src/utils/userAddress.ts | 9 ++ user-auth.mjs | 2 +- 24 files changed, 648 insertions(+), 129 deletions(-) create mode 100644 mindspace-attachment-text.mjs create mode 100644 mindspace-attachment-text.test.mjs create mode 100644 scripts/purge-plaza-demo.mjs diff --git a/chat-skills.mjs b/chat-skills.mjs index d1eb240..8486d0f 100644 --- a/chat-skills.mjs +++ b/chat-skills.mjs @@ -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'); +} diff --git a/chat-skills.test.mjs b/chat-skills.test.mjs index ba399af..52920bd 100644 --- a/chat-skills.test.mjs +++ b/chat-skills.test.mjs @@ -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']), ''); +}); diff --git a/docs/g2-load-balancing.md b/docs/g2-load-balancing.md index afbb6dc..08d96c0 100644 --- a/docs/g2-load-balancing.md +++ b/docs/g2-load-balancing.md @@ -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。 diff --git a/docs/release-deploy.md b/docs/release-deploy.md index bd7dccc..30487fd 100644 --- a/docs/release-deploy.md +++ b/docs/release-deploy.md @@ -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,便于回滚: diff --git a/docs/service-isolation-runbook.md b/docs/service-isolation-runbook.md index a24d8e3..9bb9335 100644 --- a/docs/service-isolation-runbook.md +++ b/docs/service-isolation-runbook.md @@ -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 diff --git a/mindspace-assets.mjs b/mindspace-assets.mjs index b7a42bf..e4010fa 100644 --- a/mindspace-assets.mjs +++ b/mindspace-assets.mjs @@ -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, diff --git a/mindspace-assets.test.mjs b/mindspace-assets.test.mjs index 771bd2b..b8a247c 100644 --- a/mindspace-assets.test.mjs +++ b/mindspace-assets.test.mjs @@ -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'); diff --git a/mindspace-attachment-text.mjs b/mindspace-attachment-text.mjs new file mode 100644 index 0000000..bfa483f --- /dev/null +++ b/mindspace-attachment-text.mjs @@ -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('')) { + const texts = [...block.matchAll(/]*>([\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(/]*>([\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('')) { + const cells = []; + for (const cell of rowBlock.matchAll(/]*?(?:t="([^"]+)")?[^>]*>(?:[\s\S]*?([\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(/]*>([\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, +}; diff --git a/mindspace-attachment-text.test.mjs b/mindspace-attachment-text.test.mjs new file mode 100644 index 0000000..381a0d5 --- /dev/null +++ b/mindspace-attachment-text.test.mjs @@ -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/); +}); diff --git a/mindspace-content-scan.mjs b/mindspace-content-scan.mjs index 77ed4b4..a5a1043 100644 --- a/mindspace-content-scan.mjs +++ b/mindspace-content-scan.mjs @@ -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); diff --git a/mindspace-content-scan.test.mjs b/mindspace-content-scan.test.mjs index 4c4535c..d11c823 100644 --- a/mindspace-content-scan.test.mjs +++ b/mindspace-content-scan.test.mjs @@ -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( + '', + { 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\nkey=sk_test_1234567890abcdef', diff --git a/mindspace-publications.mjs b/mindspace-publications.mjs index 8ab2c72..1703e23 100644 --- a/mindspace-publications.mjs +++ b/mindspace-publications.mjs @@ -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' diff --git a/scripts/purge-plaza-demo.mjs b/scripts/purge-plaza-demo.mjs new file mode 100644 index 0000000..317d29a --- /dev/null +++ b/scripts/purge-plaza-demo.mjs @@ -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(); +} diff --git a/server.mjs b/server.mjs index 6045f8b..e144d9e 100644 --- a/server.mjs +++ b/server.mjs @@ -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*]/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 }) {
- + +
{chatSkills.length > 0 && ( diff --git a/src/components/VoiceInputButton.tsx b/src/components/VoiceInputButton.tsx index 69963bb..de336db 100644 --- a/src/components/VoiceInputButton.tsx +++ b/src/components/VoiceInputButton.tsx @@ -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; diff --git a/src/components/VoiceInputDialog.tsx b/src/components/VoiceInputDialog.tsx index 0da711d..a4e01e2 100644 --- a/src/components/VoiceInputDialog.tsx +++ b/src/components/VoiceInputDialog.tsx @@ -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(); }; diff --git a/src/context/ChatProvider.tsx b/src/context/ChatProvider.tsx index 64f351d..7b1bada 100644 --- a/src/context/ChatProvider.tsx +++ b/src/context/ChatProvider.tsx @@ -10,15 +10,17 @@ const ChatContext = createContext(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 ( diff --git a/src/hooks/useTKMindChat.ts b/src/hooks/useTKMindChat.ts index 2060448..e81228e 100644 --- a/src/hooks/useTKMindChat.ts +++ b/src/hooks/useTKMindChat.ts @@ -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(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 () => { diff --git a/src/index.css b/src/index.css index 2c808e5..c16cd36 100644 --- a/src/index.css +++ b/src/index.css @@ -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; } diff --git a/src/utils/message.ts b/src/utils/message.ts index 502db06..ac87c60 100644 --- a/src/utils/message.ts +++ b/src/utils/message.ts @@ -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[] { diff --git a/src/utils/userAddress.ts b/src/utils/userAddress.ts index 5d1422d..d95243e 100644 --- a/src/utils/userAddress.ts +++ b/src/utils/userAddress.ts @@ -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(); +} diff --git a/user-auth.mjs b/user-auth.mjs index c6de15d..e201510 100644 --- a/user-auth.mjs +++ b/user-auth.mjs @@ -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], ); }