feat: add streaming runtime router
This commit is contained in:
+11
-2
@@ -10,6 +10,15 @@ TKMIND_SERVER__SECRET_KEY=local-dev-secret
|
||||
H5_PORT=8081
|
||||
H5_PUBLIC_BASE_URL=http://127.0.0.1:5173
|
||||
|
||||
# 生产 H5 public base 当前临时切到 https://mm.tkmind.cn。
|
||||
# 后续公网 H5 不再依赖 105 转发链路;m.tkmind.cn 仅作为 legacy/rollback 记录处理。
|
||||
# H5_PUBLIC_BASE_URL=https://mm.tkmind.cn
|
||||
# VITE_MINDSPACE_BASE=https://mm.tkmind.cn
|
||||
|
||||
# Memind 2.0 runtime control plane(生产启用 Redis;本地开发可留空回退到健康轮询)
|
||||
# MEMIND_RUNTIME_REDIS_URL=redis://127.0.0.1:6379/0
|
||||
# MEMIND_RUNTIME_REDIS_NAMESPACE=memind:runtime
|
||||
|
||||
# ===== memind_adm(独立后台服务)=====
|
||||
# 平台超管 /admin-api/* 与广场运营 /api/ops/v1/* 已从公网服务拆出,独立进程运行。
|
||||
# 登录仍在主域完成;后台凭 H5_COOKIE_DOMAIN=.tkmind.cn 共享的会话 Cookie 鉴权(生产务必设置)。
|
||||
@@ -94,8 +103,8 @@ H5_ACCESS_PASSWORD=change-me
|
||||
#
|
||||
# H5_WECHAT_NOTIFY_URL=https://goo.tkmind.cn/webhooks/wechat-pay/notify
|
||||
# 微信内 JSAPI 支付(服务号网页):
|
||||
# - 服务号「设置 → 功能设置 → JS接口安全域名」:m.tkmind.cn
|
||||
# - 商户平台「产品中心 → 开发配置 → 支付授权目录」:https://m.tkmind.cn/
|
||||
# - 服务号「设置 → 功能设置 → JS接口安全域名」:mm.tkmind.cn
|
||||
# - 商户平台「产品中心 → 开发配置 → 支付授权目录」:https://mm.tkmind.cn/
|
||||
# - 用户须先用同一服务号微信 OAuth 登录(获取 openid)后再充值
|
||||
# 本地调试验签可临时开启(生产务必关闭):
|
||||
# H5_WECHAT_SKIP_NOTIFY_VERIFY=0
|
||||
|
||||
@@ -13,6 +13,12 @@ Required persisted items to inherit on the host:
|
||||
Bundled alongside server.mjs (required for sandbox-fs MCP):
|
||||
mindspace-sandbox-mcp.mjs (esbuild bundle; includes schedule-service deps)
|
||||
|
||||
Post-deploy validation (scripts/check-mindspace-public-links.mjs):
|
||||
Scans MindSpace/*/public/*.html for missing relative href/src/cover targets.
|
||||
|
||||
Runtime install mode: bundle-node-modules
|
||||
Bundle target: node24
|
||||
|
||||
Platform agent skills (synced into user workspaces):
|
||||
skills/
|
||||
Key runtime differences must stay in .env, not in the artifact:
|
||||
@@ -22,6 +28,10 @@ Key runtime differences must stay in .env, not in the artifact:
|
||||
H5_USERS_ROOT / MINDSPACE_STORAGE_ROOT / MEMIND_SHARED_PUBLISH_ROOT
|
||||
|
||||
Deployment and operations transport:
|
||||
105 fixed IP: 120.26.184.105
|
||||
103 / Studio fixed IP: 58.38.22.103
|
||||
H5 public domain: mm.tkmind.cn
|
||||
2026-07-02 routing decision:
|
||||
- Temporarily move H5 public base from m.tkmind.cn to mm.tkmind.cn.
|
||||
- Future H5 public traffic must not depend on the 105 nginx -> 127.0.0.1:19081 -> reverse SSH tunnel -> Portal :8081 path.
|
||||
- Keep legacy 105/tunnel scripts only for rollback or explicitly requested migration work.
|
||||
Do not switch back to 10.10.* LAN paths unless explicitly required.
|
||||
|
||||
@@ -13,16 +13,17 @@ fi
|
||||
|
||||
export NODE_ENV=production
|
||||
export H5_PORT="${H5_PORT:-8081}"
|
||||
export H5_PUBLIC_BASE_URL="${H5_PUBLIC_BASE_URL:-https://m.tkmind.cn}"
|
||||
export H5_PUBLIC_BASE_URL="${H5_PUBLIC_BASE_URL:-https://mm.tkmind.cn}"
|
||||
export TKMIND_API_TARGETS="${TKMIND_API_TARGETS:-https://127.0.0.1:18006,https://127.0.0.1:18007,https://127.0.0.1:18008,https://127.0.0.1:18009}"
|
||||
export TKMIND_API_TARGET="${TKMIND_API_TARGET:-https://127.0.0.1:18006}"
|
||||
export TKMIND_API_TARGET_1="${TKMIND_API_TARGET_1:-https://127.0.0.1:18007}"
|
||||
export GOOSED_MCP_NODE_PATH="${GOOSED_MCP_NODE_PATH:-/usr/local/bin/node}"
|
||||
export GOOSED_MCP_SERVER_PATH="${GOOSED_MCP_SERVER_PATH:-/opt/portal/mindspace-sandbox-mcp.mjs}"
|
||||
|
||||
NODE_BIN="${NODE_BIN:-/opt/homebrew/opt/node@24/bin/node}"
|
||||
if [[ ! -x "${NODE_BIN}" ]]; then
|
||||
NODE_BIN="$(command -v node)"
|
||||
fi
|
||||
|
||||
export GOOSED_MCP_NODE_PATH="${GOOSED_MCP_NODE_PATH:-${NODE_BIN}}"
|
||||
export GOOSED_MCP_SERVER_PATH="${GOOSED_MCP_SERVER_PATH:-${ROOT}/mindspace-sandbox-mcp.mjs}"
|
||||
|
||||
exec "${NODE_BIN}" "${ROOT}/server.mjs"
|
||||
|
||||
+1032
-243
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,52 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name mm.tkmind.cn;
|
||||
|
||||
location /.well-known/acme-challenge/ {
|
||||
root /opt/homebrew/var/www;
|
||||
}
|
||||
|
||||
location / {
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl;
|
||||
http2 on;
|
||||
server_name mm.tkmind.cn;
|
||||
|
||||
ssl_certificate /opt/homebrew/etc/nginx/ssl/mm.tkmind.cn.crt;
|
||||
ssl_certificate_key /opt/homebrew/etc/nginx/ssl/mm.tkmind.cn.key;
|
||||
ssl_session_timeout 1d;
|
||||
ssl_session_cache shared:SSL:10m;
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_prefer_server_ciphers off;
|
||||
|
||||
location ~ ^/api/(sessions/[^/]+/events|agent/runs/[^/]+/events)$ {
|
||||
proxy_pass http://127.0.0.1:8081;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header Connection "";
|
||||
proxy_buffering off;
|
||||
proxy_cache off;
|
||||
gzip off;
|
||||
proxy_read_timeout 3600s;
|
||||
proxy_send_timeout 3600s;
|
||||
add_header X-Accel-Buffering no always;
|
||||
}
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:8081;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 300s;
|
||||
proxy_send_timeout 300s;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,439 @@
|
||||
# Memind 2.0 Streaming Agent Runtime 改造计划
|
||||
|
||||
更新时间: 2026-07-02
|
||||
|
||||
执行状态:
|
||||
|
||||
- P0 StreamController v1: 已完成代码落地、语法检查、Portal kickstart 和基础健康验证。
|
||||
- P1 Gateway SSE 配置: 已完成,`mm.tkmind.cn` 接入 Portal,SSE route 已禁缓冲。
|
||||
- P2 Redis Runtime State + Router v1: 已完成可回退版本并启用 Redis。
|
||||
- P3 Observability: 已完成第一版,只读 runtime 状态接口已上线。
|
||||
- P4 Tool Gateway v1: 已完成第一步,普通用户默认不再暴露 Aider/OpenHands,显式用户白名单保留。
|
||||
- P5 Worker Pool 运维化: 已完成第一步,Redis Router 支持 worker drain。
|
||||
- 生产同步分支: 已从远程 `origin/main` 新建干净副本和分支 `memind-streaming-runtime-20260702`,用于远程开发机后续直接拉取。
|
||||
|
||||
## 目标
|
||||
|
||||
把当前 Memind H5 + multi-goosed 架构升级为 streaming-first agent runtime:
|
||||
|
||||
- 易扩展: goosed worker pool 横向扩展,调度层按压力分配新会话。
|
||||
- 低延迟: SSE 首 token 优先,代理层禁缓冲、及时 flush、处理 backpressure。
|
||||
- 高稳定: 流式链路、工具执行、worker 状态、持久化状态分层隔离。
|
||||
|
||||
## 当前基线
|
||||
|
||||
- Memind Portal: `/Users/john/Project/Memind/server.mjs`,本机 `:8081`。
|
||||
- H5 public base: `https://mm.tkmind.cn`;`m.tkmind.cn` 暂时不再作为 H5 public base。
|
||||
- goosed worker pool: Docker/Colima 内 `goosed-prod-1..4`,宿主机 `18006..18009`,当前健康。
|
||||
- H5 上游配置: `TKMIND_API_TARGETS=https://127.0.0.1:18006,...,18009`。
|
||||
- 当前已有 session affinity: start 时分配 worker,后续 reply/events 回到同一 worker。
|
||||
- imgproxy: 原生 `127.0.0.1:20082`,兼容代理 `10.10.0.2:20081`。
|
||||
- 风险点: SSE 代理尚未完整处理 `flushHeaders`、`X-Accel-Buffering`、客户端断开 abort、写入 backpressure 和统一 pipeline 收尾。
|
||||
|
||||
## 目标架构
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
U["H5 / WeChat / Web"] --> E["Edge Gateway (Nginx / Envoy)"]
|
||||
E --> S["Stream Controller (Memind Portal v2)"]
|
||||
S --> R["Router / Scheduler"]
|
||||
R <--> Redis["Redis Runtime State"]
|
||||
S <--> Redis
|
||||
R --> G1["goosed-prod-1"]
|
||||
R --> G2["goosed-prod-2"]
|
||||
R --> G3["goosed-prod-3"]
|
||||
R --> G4["goosed-prod-4"]
|
||||
G1 --> T["Tool Gateway"]
|
||||
G2 --> T
|
||||
G3 --> T
|
||||
G4 --> T
|
||||
T --> A["Aider Service"]
|
||||
T --> O["OpenHands Service"]
|
||||
S --> PG["PostgreSQL / MySQL Persistence"]
|
||||
```
|
||||
|
||||
## 分层原则
|
||||
|
||||
1. Control Plane: Redis + Router,负责 worker 心跳、active stream、错误率、首 token 延迟、session worker pointer。
|
||||
2. Stream Data Plane: Memind Portal StreamController + goosed worker,负责低延迟流式传输。
|
||||
3. Persistence: 现有数据库只保存最终状态、会话历史、计费、发布记录,不保存高频 runtime state。
|
||||
4. Worker Affinity: goosed 尽量业务无状态,但 session 运行期必须 worker-affine;已存在 session 优先回原 worker。
|
||||
5. Tool Isolation: Aider/OpenHands 从默认全开逐步改为按任务启用、队列化、限流、超时和熔断。
|
||||
|
||||
## 阶段计划
|
||||
|
||||
### P0: StreamController v1 加固
|
||||
|
||||
目标: 不引入新基础设施,先把现有 H5 SSE 链路做稳。
|
||||
|
||||
任务:
|
||||
|
||||
- 为 `/api/sessions/:sessionId/events` 增加 SSE 专用响应头:
|
||||
- `Content-Type: text/event-stream; charset=utf-8`
|
||||
- `Cache-Control: no-cache, no-transform`
|
||||
- `Connection: keep-alive`
|
||||
- `X-Accel-Buffering: no`
|
||||
- 在响应开始后调用 `flushHeaders()`。
|
||||
- 使用 `AbortController` 将客户端断开传递到 goosed upstream fetch。
|
||||
- 用 backpressure-aware writable sink 替代裸 `res.write()`。
|
||||
- 用 `pipeline()` 统一处理 source、sanitizer、billing、client sink 的关闭和错误。
|
||||
- 保留 keepalive 和 billing balance event。
|
||||
|
||||
验收:
|
||||
|
||||
- `node --check /Users/john/Project/Memind/server.mjs` 通过。
|
||||
- 本机 `http://127.0.0.1:8081/api/status` 仍返回 `ok`。
|
||||
- 四个 `https://127.0.0.1:18006..18009/status` 仍返回 `ok`。
|
||||
|
||||
### P1: Gateway SSE 配置
|
||||
|
||||
目标: `mm.tkmind.cn` 新入口和本机入口不缓冲 SSE;后续 H5 不再走 105 nginx 反向隧道转发。
|
||||
|
||||
任务:
|
||||
|
||||
- 对 `/api/sessions/*/events`、`/api/agent/runs/*/events` 配置:
|
||||
- `proxy_buffering off`
|
||||
- `proxy_cache off`
|
||||
- `gzip off`
|
||||
- `proxy_read_timeout 3600`
|
||||
- `proxy_send_timeout 3600`
|
||||
- `add_header X-Accel-Buffering no always`
|
||||
- 确认 `mm.tkmind.cn/api/status` 返回真实 H5 API,而不是维护页 HTML。
|
||||
- 将 `m.tkmind.cn -> 105 nginx -> 127.0.0.1:19081 -> reverse SSH tunnel -> Portal :8081` 标记为 legacy/rollback-only。
|
||||
|
||||
验收:
|
||||
|
||||
- `mm.tkmind.cn/api/status` 返回后端 `ok` 或明确 JSON health,而不是维护页 HTML。
|
||||
- SSE 响应头包含 `X-Accel-Buffering: no`。
|
||||
|
||||
### P2: Redis Runtime State + Router v1
|
||||
|
||||
目标: 从 round-robin 升级为 pressure-aware routing。
|
||||
|
||||
Redis key:
|
||||
|
||||
```text
|
||||
worker:{id}:heartbeat
|
||||
worker:{id}:active_streams
|
||||
worker:{id}:active_sessions
|
||||
worker:{id}:ewma_first_token_ms
|
||||
worker:{id}:error_rate
|
||||
worker:{id}:memory_pressure
|
||||
worker:{id}:drain
|
||||
session:{session_id}:worker
|
||||
stream:{stream_id}:status
|
||||
stream:{stream_id}:started_at
|
||||
```
|
||||
|
||||
调度分数:
|
||||
|
||||
```text
|
||||
score =
|
||||
active_streams * 3
|
||||
+ active_sessions * 1
|
||||
+ ewma_first_token_ms * 0.01
|
||||
+ error_rate * 5
|
||||
+ memory_pressure * 2
|
||||
```
|
||||
|
||||
原则:
|
||||
|
||||
- 已存在 session 优先使用 `session:{id}:worker`。
|
||||
- 新 session 选择最低 score 的健康 worker。
|
||||
- heartbeat 过期 worker 不接新 session。
|
||||
- 不做无损中途迁移,除非 goose 支持完整 session restore。
|
||||
|
||||
### P3: Observability
|
||||
|
||||
目标: 调度不靠感觉。
|
||||
|
||||
指标:
|
||||
|
||||
- stream open/close/abort count
|
||||
- active_streams gauge
|
||||
- first_token_latency_ms
|
||||
- stream_duration_ms
|
||||
- worker fetch error rate
|
||||
- billing finish count/error count
|
||||
|
||||
先写日志和内存聚合,后续接 Prometheus 或 Redis。
|
||||
|
||||
### P4: Tool Gateway v1
|
||||
|
||||
目标: Aider/OpenHands 工具隔离。
|
||||
|
||||
任务:
|
||||
|
||||
- 普通聊天默认不暴露 Aider/OpenHands。
|
||||
- 代码任务按策略启用 Aider 或 OpenHands。
|
||||
- Tool Gateway 提供 queue、timeout、retry、并发上限、失败熔断。
|
||||
- stream 中输出 tool progress event,避免用户界面无反馈。
|
||||
|
||||
第一阶段实际落地:
|
||||
|
||||
- 普通用户默认不暴露 Aider/OpenHands。
|
||||
- 保留显式 user capability override,作为白名单。
|
||||
- 后续再将 Aider/OpenHands 从 goosed platform extension 进一步拆成 queue-based Tool Gateway。
|
||||
|
||||
### P5: Worker Pool 运维化
|
||||
|
||||
目标: 多 goosed 的发布、回滚、扩缩容可控。
|
||||
|
||||
任务:
|
||||
|
||||
- 固化 `goosed-prod-1..N` compose/启动配置。
|
||||
- 每个 worker 独立日志、健康检查、资源上限。
|
||||
- drain 模式: 不接新 session,等 active stream 清零后升级。
|
||||
- 保留一键回滚镜像 tag。
|
||||
|
||||
Drain 操作:
|
||||
|
||||
```bash
|
||||
docker exec memind-runtime-redis redis-cli SET memind:runtime:worker:goosed-3:drain 1
|
||||
curl -sk https://mm.tkmind.cn/api/runtime/status
|
||||
docker exec memind-runtime-redis redis-cli DEL memind:runtime:worker:goosed-3:drain
|
||||
```
|
||||
|
||||
说明:
|
||||
|
||||
- `drain=1` 后 Router 不再给该 worker 分配新 session。
|
||||
- 已存在 session 仍保持 worker affinity,不做中途迁移。
|
||||
- 等 `activeStreams=0` 后再重启或升级该 worker。
|
||||
|
||||
## 执行顺序
|
||||
|
||||
1. P0 立即执行,低风险高收益。
|
||||
2. P1 基于 `mm.tkmind.cn` 新入口执行;除非明确回滚,不再改造 105 H5 转发链路。
|
||||
3. P2/P3 一起推进,先观测再调度。
|
||||
4. P4/P5 在流式链路稳定后推进。
|
||||
|
||||
## 执行记录
|
||||
|
||||
### 2026-07-02 P0 StreamController v1
|
||||
|
||||
变更文件:
|
||||
|
||||
- `/Users/john/Project/Memind/server.mjs`
|
||||
|
||||
备份:
|
||||
|
||||
- `/Users/john/Project/Memind/server.mjs.bak-streamcontroller-20260702-0628`
|
||||
|
||||
已完成:
|
||||
|
||||
- `proxySessionEvents` 增加 upstream `AbortController`,客户端断开时主动 abort goosed SSE fetch。
|
||||
- SSE 响应头升级为 `text/event-stream; charset=utf-8`、`no-cache, no-transform`、`X-Accel-Buffering: no`。
|
||||
- 响应开始后调用 `flushHeaders()`。
|
||||
- 用 `Writable` sink 处理 `res.write()` backpressure,并保留 billing balance event 注入。
|
||||
- 用 `pipeline(source, sanitizer, billing, sink)` 统一管理 SSE 管道收尾。
|
||||
- 通过 `launchctl kickstart -k gui/$(id -u)/cn.tkmind.memind-portal` 让变更生效。
|
||||
|
||||
验证:
|
||||
|
||||
- `/opt/homebrew/opt/node@24/bin/node --check /Users/john/Project/Memind/server.mjs` 通过。
|
||||
- Portal 新 PID: `89465`,监听 `*:8081`。
|
||||
- `http://127.0.0.1:8081/api/status` 返回 `ok`。
|
||||
- `https://127.0.0.1:18006/status` 返回 `ok`。
|
||||
- `https://127.0.0.1:18007/status` 返回 `ok`。
|
||||
- `https://127.0.0.1:18008/status` 返回 `ok`。
|
||||
- `https://127.0.0.1:18009/status` 返回 `ok`。
|
||||
|
||||
### 2026-07-02 H5 public base 临时切换
|
||||
|
||||
决策:
|
||||
|
||||
- H5 public base 从 `https://m.tkmind.cn` 临时切换为 `https://mm.tkmind.cn`。
|
||||
- 后续 H5 公网访问不再走 105 转发链路。
|
||||
- 旧链路 `m.tkmind.cn -> 105 nginx -> 127.0.0.1:19081 -> reverse SSH tunnel -> Portal :8081` 仅保留为 legacy/rollback-only 说明,不作为 Memind 2.0 改造目标。
|
||||
|
||||
变更文件:
|
||||
|
||||
- `/Users/john/Project/Memind/.env`
|
||||
- `/Users/john/Project/Memind/scripts/run-memind-portal-prod.sh`
|
||||
- `/Users/john/Project/Memind/RUNBOOK.txt`
|
||||
- `/Users/john/Project/memind_architecture/memind-2-streaming-agent-runtime-plan.md`
|
||||
|
||||
已执行:
|
||||
|
||||
- 将 `H5_PUBLIC_BASE_URL` 改为 `https://mm.tkmind.cn`。
|
||||
- 将 `VITE_MINDSPACE_BASE` 改为 `https://mm.tkmind.cn`。
|
||||
- 将 Portal 启动脚本默认 `H5_PUBLIC_BASE_URL` 改为 `https://mm.tkmind.cn`。
|
||||
- 更新 `RUNBOOK.txt`,明确 105 转发链路不再作为后续 H5 公网路径。
|
||||
- 通过 `launchctl kickstart -k gui/$(id -u)/cn.tkmind.memind-portal` 重启 Portal。
|
||||
|
||||
验证:
|
||||
|
||||
- Portal 新 PID: `94324`,监听 `*:8081`。
|
||||
- 运行环境确认: `H5_PUBLIC_BASE_URL=https://mm.tkmind.cn`,`VITE_MINDSPACE_BASE=https://mm.tkmind.cn`。
|
||||
- 本机 `http://127.0.0.1:8081/api/status` 返回 `ok`。
|
||||
- 四个 goosed worker `18006..18009` 均返回 `ok`。
|
||||
- 公网 `https://mm.tkmind.cn/api/status` 当前返回 nginx `502 Bad Gateway`,说明域名侧入口尚未接到当前 Portal;该问题纳入 P1 新入口修复,不再回到 105 转发链路处理。
|
||||
|
||||
### 2026-07-02 P1 Gateway SSE 配置
|
||||
|
||||
变更文件:
|
||||
|
||||
- `/opt/homebrew/etc/nginx/servers/mm.tkmind.cn.conf`
|
||||
- `/Users/john/Project/Memind/scripts/wechat-mp-menu.mjs`
|
||||
- `/Users/john/Project/Memind/server.mjs`
|
||||
- `/Users/john/Project/Memind/dist/dev/wechat-share-demo.html`
|
||||
- `/Users/john/Project/Memind/public/dev/wechat-share-demo.html`
|
||||
|
||||
备份:
|
||||
|
||||
- `/opt/homebrew/etc/nginx/backups/mm.tkmind.cn.conf.bak-streaming-20260702-0635`
|
||||
|
||||
已完成:
|
||||
|
||||
- `mm.tkmind.cn` 通过本机 nginx 反向代理到 Portal `127.0.0.1:8081`。
|
||||
- 为 `/api/sessions/<id>/events` 和 `/api/agent/runs/<id>/events` 增加 SSE 专用 nginx location:
|
||||
- `proxy_buffering off`
|
||||
- `proxy_cache off`
|
||||
- `gzip off`
|
||||
- `proxy_read_timeout 3600s`
|
||||
- `proxy_send_timeout 3600s`
|
||||
- `add_header X-Accel-Buffering no always`
|
||||
- 通用 `location /` 增加 `proxy_read_timeout 300s` 和 `proxy_send_timeout 300s`。
|
||||
- 微信菜单脚本默认入口改为 `https://mm.tkmind.cn` 和 `https://mm.tkmind.cn/space`。
|
||||
- `server.mjs` 内 `H5_PUBLIC_BASE_URL` 缺省值改为 `https://mm.tkmind.cn`。
|
||||
|
||||
验证:
|
||||
|
||||
- `nginx -t` 通过。
|
||||
- `nginx -s reload` 已执行。
|
||||
- `https://mm.tkmind.cn/api/status` 返回 `ok`。
|
||||
- 未登录访问 `https://mm.tkmind.cn/api/sessions/test-session/events` 返回 `401`,且响应头包含 `x-accel-buffering: no`。
|
||||
- 未登录访问 `https://mm.tkmind.cn/api/agent/runs/test-run/events` 返回 `401`,且响应头包含 `x-accel-buffering: no`。
|
||||
|
||||
### 2026-07-02 P2 Redis Runtime State + Router v1
|
||||
|
||||
变更文件:
|
||||
|
||||
- `/Users/john/Project/Memind/server.mjs`
|
||||
- `/Users/john/Project/Memind/.env`
|
||||
|
||||
新增运行组件:
|
||||
|
||||
- Docker 容器: `memind-runtime-redis`
|
||||
- 镜像: `redis:7-alpine`
|
||||
- 绑定: `127.0.0.1:6379->6379`
|
||||
- 持久化卷: `memind-runtime-redis-data`
|
||||
- 启动参数: `redis-server --appendonly yes`
|
||||
- 重启策略: `unless-stopped`
|
||||
|
||||
已完成:
|
||||
|
||||
- 新增可选 `createRuntimeRouter`。
|
||||
- `MEMIND_RUNTIME_REDIS_URL` 未配置时自动回退到原 round-robin。
|
||||
- `MEMIND_RUNTIME_REDIS_URL` 配置后启用 Redis scheduler。
|
||||
- 新 session 选择最低 score 的健康 worker;同分时仍按轮转顺序打散,避免全部压到第一个 worker。
|
||||
- session pointer 写入 Redis:
|
||||
- `memind:runtime:session:<id>:worker`
|
||||
- `memind:runtime:session:<id>:target`
|
||||
- SSE 打开/关闭维护 Redis active stream:
|
||||
- `memind:runtime:worker:<id>:active_streams`
|
||||
- `memind:runtime:worker:<id>:heartbeat`
|
||||
- `memind:runtime:stream:<id>:status`
|
||||
|
||||
验证:
|
||||
|
||||
- `docker exec memind-runtime-redis redis-cli PING` 返回 `PONG`。
|
||||
- Redis AOF 已开启。
|
||||
- Portal 运行环境包含 `MEMIND_RUNTIME_REDIS_URL=redis://127.0.0.1:6379/0`。
|
||||
- Portal 日志出现 `[RuntimeRouter] Redis scheduler enabled`。
|
||||
- Redis 当前可见 key 示例:
|
||||
- `memind:runtime:worker:goosed-3:active_streams=1`
|
||||
- `memind:runtime:worker:goosed-3:heartbeat=<timestamp>`
|
||||
- `memind:runtime:stream:20260701_24:status=active`
|
||||
- `https://mm.tkmind.cn/api/status` 返回 `ok`。
|
||||
- 四个 goosed worker `18006..18009` 均返回 `ok`。
|
||||
|
||||
### 2026-07-02 P3 Observability v1
|
||||
|
||||
变更文件:
|
||||
|
||||
- `/Users/john/Project/Memind/server.mjs`
|
||||
|
||||
已完成:
|
||||
|
||||
- 新增只读接口 `GET /api/runtime/status`。
|
||||
- 接口返回:
|
||||
- `publicBaseUrl`
|
||||
- Redis Router 是否启用
|
||||
- Redis namespace
|
||||
- 每个 worker 的 `target`、`activeStreams`、`activeSessions`、`ewmaFirstTokenMs`、`errorRate`、`memoryPressure`、`heartbeat`
|
||||
- 每个 `TKMIND_API_TARGETS` 上游的 `/status` 健康状态
|
||||
- 该接口与 `/api/status` 一样作为运维健康读接口,不要求登录;不返回任何密钥。
|
||||
|
||||
验证:
|
||||
|
||||
- `/opt/homebrew/opt/node@24/bin/node --check /Users/john/Project/Memind/server.mjs` 通过。
|
||||
- `https://mm.tkmind.cn/api/runtime/status` 返回 `ok: true`。
|
||||
- 返回中 `router.enabled=true`,namespace 为 `memind:runtime`。
|
||||
- 返回中四个目标 `18006..18009` 均 `healthy: true`。
|
||||
- `https://mm.tkmind.cn/api/status` 返回 `ok`。
|
||||
- 未登录访问 `https://mm.tkmind.cn/api/sessions/test-session/events` 返回 `401`,且响应头包含 `x-accel-buffering: no`。
|
||||
|
||||
### 2026-07-02 P4 Tool Gateway v1 第一阶段
|
||||
|
||||
已完成:
|
||||
|
||||
- 只读验证发现数据库中 `role=user` 曾将 `aider`、`openhands` 覆盖为 `allowed=1`。
|
||||
- 已将 `h5_capability_grants` 中 `subject_type='role' AND subject_id='user'` 的 `aider`、`openhands` 改为 `allowed=0`。
|
||||
- 保留两个显式 user override:
|
||||
- `a6fb1e97-2b0f-447b-b138-4561d8e5c53e`
|
||||
- `a70ff537-8908-486e-9b6c-042e07cc25db`
|
||||
|
||||
效果:
|
||||
|
||||
- 普通用户新建/恢复 session 时,Portal policy 不再把 Aider/OpenHands 放入 `extension_overrides`。
|
||||
- 已授权用户仍可继续使用。
|
||||
- goosed 全局 config 仍保留 Aider/OpenHands enabled,避免破坏显式授权用户和既有会话;后续 Tool Gateway 阶段再拆 queue/timeout/retry。
|
||||
|
||||
### 2026-07-02 P5 Worker Pool Drain v1
|
||||
|
||||
变更文件:
|
||||
|
||||
- `/Users/john/Project/Memind/server.mjs`
|
||||
|
||||
已完成:
|
||||
|
||||
- Redis Router scoring 支持读取 `memind:runtime:worker:<id>:drain`。
|
||||
- 当 drain 值为 `1` / `true` / `yes` 时,该 worker 对新 session 的 score 为不可选。
|
||||
- `/api/runtime/status` 返回每个 worker 的 `drain` 字段。
|
||||
|
||||
验证:
|
||||
|
||||
- `/opt/homebrew/opt/node@24/bin/node --check /Users/john/Project/Memind/server.mjs` 通过。
|
||||
- `https://mm.tkmind.cn/api/runtime/status` 返回四个 worker `healthy=true`。
|
||||
- `https://mm.tkmind.cn/api/runtime/status` 返回四个 worker `drain=false`。
|
||||
- `https://mm.tkmind.cn/api/status` 返回 `ok`。
|
||||
- 未登录访问 `https://mm.tkmind.cn/api/sessions/test-session/events` 返回 `401`,且响应头包含 `x-accel-buffering: no`。
|
||||
|
||||
### 2026-07-02 生产同步分支
|
||||
|
||||
决策:
|
||||
|
||||
- 当前是生产环境,改造过程中不随意删除数据、不清理持久化目录、不覆盖旧本地脏改动。
|
||||
- `/Users/john/Project/Memind` 是生产运行产物目录,不是 git 工作树。
|
||||
- 生产 release manifest 指向 `git_head=4fc59729ee222734628dca001e172c127c84488f`。
|
||||
- 远端 `https://git.tkmind.cn/tkmind/memind.git` 的 `origin/main` 当前正是该提交。
|
||||
|
||||
已执行:
|
||||
|
||||
- 新建干净源码目录 `/Users/john/Project/memind-clean-main-20260702`。
|
||||
- 从 `origin/main` 创建分支 `memind-streaming-runtime-20260702`。
|
||||
- 迁入本次已在生产验证的 StreamController、Redis Router、runtime status、`mm.tkmind.cn` 和 nginx SSE 配置样例。
|
||||
- 新增远程开发机同步说明 `docs/architecture/remote-dev-sync.md`。
|
||||
|
||||
限制:
|
||||
|
||||
- 不提交生产 `.env`、数据库、`MindSpace/`、`data/`、`users/`、`.tailscale/`、`logs/`、证书和密钥。
|
||||
- 只提交代码、配置模板、部署样例和架构/运维文档。
|
||||
|
||||
## 回滚策略
|
||||
|
||||
- P0: 修改前保留 `server.mjs` 备份;如启动失败,恢复备份并 `launchctl kickstart` Portal。
|
||||
- P1: 修改 nginx 前备份 conf;`nginx -t` 成功后再 reload。
|
||||
- P2: Redis Router 默认可通过 env 开关退回当前 `pickTarget()` round-robin。
|
||||
- P4: Tool Gateway 默认关闭,通过用户策略逐步放量。
|
||||
@@ -0,0 +1,82 @@
|
||||
# Remote Dev Sync Runbook
|
||||
|
||||
更新时间: 2026-07-02
|
||||
|
||||
## 目标
|
||||
|
||||
让远程开发电脑和当前生产主机的 Memind 代码版本保持一致,同时避免覆盖生产数据、密钥、用户文件和本机脏改动。
|
||||
|
||||
## 当前基线
|
||||
|
||||
- 生产运行目录: `/Users/john/Project/Memind`
|
||||
- 生产 release manifest: `release_id=20260701-220548-4fc5972`
|
||||
- 生产来源提交: `4fc59729ee222734628dca001e172c127c84488f`
|
||||
- 干净同步分支: `memind-streaming-runtime-20260702`
|
||||
- 干净分支目录: `/Users/john/Project/memind-clean-main-20260702`
|
||||
- 远端仓库: `https://git.tkmind.cn/tkmind/memind.git`
|
||||
|
||||
## 硬约束
|
||||
|
||||
- 不在生产运行目录执行 `git reset --hard`、`git clean -fdx`、`rm -rf`、数据库清理或数据目录同步删除。
|
||||
- 不提交生产 `.env`、数据库、`MindSpace/`、`data/`、`users/`、`.tailscale/`、`logs/`、证书、密钥和运行缓存。
|
||||
- 本次 H5 public base 临时切到 `https://mm.tkmind.cn`。
|
||||
- 后续公网 H5 不再依赖 105 转发链路;105 只保留 legacy/rollback 说明。
|
||||
|
||||
## 远程开发机拉取方式
|
||||
|
||||
```bash
|
||||
git clone https://git.tkmind.cn/tkmind/memind.git memind
|
||||
cd memind
|
||||
git fetch origin
|
||||
git switch memind-streaming-runtime-20260702
|
||||
```
|
||||
|
||||
如果远程开发机已有仓库:
|
||||
|
||||
```bash
|
||||
cd /path/to/memind
|
||||
git fetch origin
|
||||
git switch memind-streaming-runtime-20260702
|
||||
git pull --ff-only
|
||||
```
|
||||
|
||||
## 配置方式
|
||||
|
||||
从 `.env.example` 复制生成本机 `.env`,然后只在本机填入密钥和数据库连接:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
生产关键项:
|
||||
|
||||
```text
|
||||
H5_PUBLIC_BASE_URL=https://mm.tkmind.cn
|
||||
VITE_MINDSPACE_BASE=https://mm.tkmind.cn
|
||||
MEMIND_RUNTIME_REDIS_URL=redis://127.0.0.1:6379/0
|
||||
MEMIND_RUNTIME_REDIS_NAMESPACE=memind:runtime
|
||||
```
|
||||
|
||||
## 本分支包含的改造
|
||||
|
||||
- StreamController v1: SSE headers、flush、abort propagation、backpressure-aware pipeline。
|
||||
- Redis Runtime Router v1: session pointer、worker score、active stream、drain。
|
||||
- Runtime status: `/api/runtime/status`。
|
||||
- Gateway SSE 示例: `deploy/nginx/mm.tkmind.cn.conf`。
|
||||
- H5 public base: `mm.tkmind.cn`。
|
||||
- 普通用户默认不暴露 Aider/OpenHands,显式白名单保留。
|
||||
|
||||
## 验证命令
|
||||
|
||||
```bash
|
||||
node --check server.mjs
|
||||
node --check scripts/wechat-mp-menu.mjs
|
||||
curl -sk https://mm.tkmind.cn/api/status
|
||||
curl -sk https://mm.tkmind.cn/api/runtime/status
|
||||
```
|
||||
|
||||
## 回滚思路
|
||||
|
||||
- 代码回滚: 切回 `origin/main` 对应提交 `4fc59729ee222734628dca001e172c127c84488f`。
|
||||
- 配置回滚: 恢复生产主机 `/Users/john/Project/Memind/.env.bak-mm-domain-20260702-0633`。
|
||||
- Worker drain 回滚: 删除 Redis drain key,例如 `DEL memind:runtime:worker:goosed-3:drain`。
|
||||
@@ -242,7 +242,7 @@ async function writeMetadata() {
|
||||
'',
|
||||
'export NODE_ENV=production',
|
||||
'export H5_PORT="${H5_PORT:-8081}"',
|
||||
'export H5_PUBLIC_BASE_URL="${H5_PUBLIC_BASE_URL:-https://m.tkmind.cn}"',
|
||||
'export H5_PUBLIC_BASE_URL="${H5_PUBLIC_BASE_URL:-https://mm.tkmind.cn}"',
|
||||
'export TKMIND_API_TARGETS="${TKMIND_API_TARGETS:-https://127.0.0.1:18006,https://127.0.0.1:18007,https://127.0.0.1:18008,https://127.0.0.1:18009}"',
|
||||
'export TKMIND_API_TARGET="${TKMIND_API_TARGET:-https://127.0.0.1:18006}"',
|
||||
'export TKMIND_API_TARGET_1="${TKMIND_API_TARGET_1:-https://127.0.0.1:18007}"',
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
#!/usr/bin/env bash
|
||||
# Memind Portal 生产进程(m.tkmind.cn / Plaza API 共用 :8081)
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
@@ -14,11 +13,10 @@ fi
|
||||
|
||||
export NODE_ENV=production
|
||||
export H5_PORT="${H5_PORT:-8081}"
|
||||
export H5_HOST="${H5_HOST:-127.0.0.1}"
|
||||
export H5_PUBLIC_BASE_URL="https://m.tkmind.cn"
|
||||
export TKMIND_API_TARGETS="${TKMIND_API_TARGETS_OVERRIDE:-${TKMIND_API_TARGETS:-https://127.0.0.1:18006,https://127.0.0.1:18007,https://127.0.0.1:18008,https://127.0.0.1:18009}}"
|
||||
export TKMIND_API_TARGET="${TKMIND_API_TARGET_OVERRIDE:-https://127.0.0.1:18006}"
|
||||
export TKMIND_API_TARGET_1="${TKMIND_API_TARGET_1_OVERRIDE:-https://127.0.0.1:18007}"
|
||||
export H5_PUBLIC_BASE_URL="${H5_PUBLIC_BASE_URL:-https://mm.tkmind.cn}"
|
||||
export TKMIND_API_TARGETS="${TKMIND_API_TARGETS:-https://127.0.0.1:18006,https://127.0.0.1:18007,https://127.0.0.1:18008,https://127.0.0.1:18009}"
|
||||
export TKMIND_API_TARGET="${TKMIND_API_TARGET:-https://127.0.0.1:18006}"
|
||||
export TKMIND_API_TARGET_1="${TKMIND_API_TARGET_1:-https://127.0.0.1:18007}"
|
||||
|
||||
NODE_BIN="${NODE_BIN:-/opt/homebrew/opt/node@24/bin/node}"
|
||||
if [[ ! -x "${NODE_BIN}" ]]; then
|
||||
@@ -28,4 +26,4 @@ fi
|
||||
export GOOSED_MCP_NODE_PATH="${GOOSED_MCP_NODE_PATH:-${NODE_BIN}}"
|
||||
export GOOSED_MCP_SERVER_PATH="${GOOSED_MCP_SERVER_PATH:-${ROOT}/mindspace-sandbox-mcp.mjs}"
|
||||
|
||||
exec "${NODE_BIN}" server.mjs
|
||||
exec "${NODE_BIN}" "${ROOT}/server.mjs"
|
||||
|
||||
@@ -23,12 +23,12 @@ const MENU = {
|
||||
{
|
||||
type: 'view',
|
||||
name: 'TKMind',
|
||||
url: 'https://m.tkmind.cn',
|
||||
url: 'https://mm.tkmind.cn',
|
||||
},
|
||||
{
|
||||
type: 'view',
|
||||
name: 'M空间',
|
||||
url: 'https://m.tkmind.cn/space',
|
||||
url: 'https://mm.tkmind.cn/space',
|
||||
},
|
||||
{
|
||||
type: 'view',
|
||||
|
||||
+20
@@ -1774,6 +1774,26 @@ api.get('/status', async (_req, res, next) => {
|
||||
return next();
|
||||
});
|
||||
|
||||
api.get('/runtime/status', async (_req, res) => {
|
||||
await userAuthReady;
|
||||
if (!tkmindProxy?.getRuntimeStatus) {
|
||||
return res.status(503).json({ ok: false, message: 'runtime router unavailable' });
|
||||
}
|
||||
try {
|
||||
const status = await tkmindProxy.getRuntimeStatus();
|
||||
return res.json({
|
||||
ok: true,
|
||||
timestamp: new Date().toISOString(),
|
||||
...status,
|
||||
});
|
||||
} catch (err) {
|
||||
return res.status(502).json({
|
||||
ok: false,
|
||||
message: err instanceof Error ? err.message : 'runtime status failed',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
async function ensureUserMemoryCapability(req, res) {
|
||||
if (!userAuth) {
|
||||
res.status(503).json({ message: '未启用用户系统' });
|
||||
|
||||
+263
-23
@@ -1,6 +1,7 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { Readable, Transform } from 'node:stream';
|
||||
import { Readable, Transform, Writable } from 'node:stream';
|
||||
import { pipeline } from 'node:stream/promises';
|
||||
import { Agent, fetch as undiciFetch } from 'undici';
|
||||
import { appendBalanceEvent, createSseBillingTransform } from './sse-billing.mjs';
|
||||
import { developerToolsFromPolicy } from './capabilities.mjs';
|
||||
@@ -79,6 +80,164 @@ async function apiFetch(target, apiSecret, pathname, init = {}) {
|
||||
});
|
||||
}
|
||||
|
||||
function createRuntimeRouter({
|
||||
targets,
|
||||
targetHealthy,
|
||||
redisUrl = process.env.MEMIND_RUNTIME_REDIS_URL,
|
||||
namespace = process.env.MEMIND_RUNTIME_REDIS_NAMESPACE ?? 'memind:runtime',
|
||||
}) {
|
||||
if (!redisUrl || targets.length <= 1) return null;
|
||||
let clientPromise = null;
|
||||
let warned = false;
|
||||
const workerIdForTarget = (target) => {
|
||||
const idx = targets.indexOf(target);
|
||||
return idx >= 0 ? `goosed-${idx + 1}` : `goosed-${Buffer.from(String(target)).toString('base64url')}`;
|
||||
};
|
||||
const key = (...parts) => [namespace, ...parts].join(':');
|
||||
const getClient = async () => {
|
||||
if (!clientPromise) {
|
||||
clientPromise = import('redis')
|
||||
.then(async ({ createClient }) => {
|
||||
const client = createClient({ url: redisUrl });
|
||||
client.on('error', (err) => {
|
||||
if (!warned) {
|
||||
warned = true;
|
||||
console.warn('[RuntimeRouter] Redis error:', err instanceof Error ? err.message : err);
|
||||
}
|
||||
});
|
||||
await client.connect();
|
||||
console.log('[RuntimeRouter] Redis scheduler enabled');
|
||||
return client;
|
||||
})
|
||||
.catch((err) => {
|
||||
clientPromise = null;
|
||||
if (!warned) {
|
||||
warned = true;
|
||||
console.warn('[RuntimeRouter] Redis disabled:', err instanceof Error ? err.message : err);
|
||||
}
|
||||
return null;
|
||||
});
|
||||
}
|
||||
return clientPromise;
|
||||
};
|
||||
const readNumber = (value) => {
|
||||
const n = Number(value ?? 0);
|
||||
return Number.isFinite(n) ? n : 0;
|
||||
};
|
||||
const scoreWorker = async (client, target) => {
|
||||
const workerId = workerIdForTarget(target);
|
||||
const values = await client.mGet([
|
||||
key('worker', workerId, 'active_streams'),
|
||||
key('worker', workerId, 'active_sessions'),
|
||||
key('worker', workerId, 'ewma_first_token_ms'),
|
||||
key('worker', workerId, 'error_rate'),
|
||||
key('worker', workerId, 'memory_pressure'),
|
||||
key('worker', workerId, 'drain'),
|
||||
]);
|
||||
if (/^(1|true|yes)$/i.test(String(values[5] ?? ''))) return Number.POSITIVE_INFINITY;
|
||||
return (
|
||||
readNumber(values[0]) * 3 +
|
||||
readNumber(values[1]) +
|
||||
readNumber(values[2]) * 0.01 +
|
||||
readNumber(values[3]) * 5 +
|
||||
readNumber(values[4]) * 2
|
||||
);
|
||||
};
|
||||
return {
|
||||
async pickTarget(fallbackPick, orderedTargets = targets) {
|
||||
const client = await getClient();
|
||||
if (!client) return fallbackPick();
|
||||
let winner = null;
|
||||
for (const target of orderedTargets) {
|
||||
if (!await targetHealthy(target)) continue;
|
||||
const score = await scoreWorker(client, target).catch(() => Number.POSITIVE_INFINITY);
|
||||
if (!Number.isFinite(score)) continue;
|
||||
if (!winner || score < winner.score) winner = { target, score };
|
||||
}
|
||||
return winner?.target ?? fallbackPick();
|
||||
},
|
||||
async registerSession(sessionId, target) {
|
||||
const client = await getClient();
|
||||
if (!client || !sessionId || !target) return;
|
||||
const workerId = workerIdForTarget(target);
|
||||
await client
|
||||
.multi()
|
||||
.set(key('session', sessionId, 'worker'), workerId, { EX: 60 * 60 * 24 * 30 })
|
||||
.set(key('session', sessionId, 'target'), target, { EX: 60 * 60 * 24 * 30 })
|
||||
.exec()
|
||||
.catch(() => null);
|
||||
},
|
||||
async resolveSessionTarget(sessionId) {
|
||||
const client = await getClient();
|
||||
if (!client || !sessionId) return null;
|
||||
const target = await client.get(key('session', sessionId, 'target')).catch(() => null);
|
||||
return target && targets.includes(target) ? target : null;
|
||||
},
|
||||
async streamStarted(sessionId, target) {
|
||||
const client = await getClient();
|
||||
if (!client || !target) return;
|
||||
const workerId = workerIdForTarget(target);
|
||||
const streamKey = sessionId ? key('stream', sessionId, 'status') : null;
|
||||
const multi = client
|
||||
.multi()
|
||||
.incr(key('worker', workerId, 'active_streams'))
|
||||
.set(key('worker', workerId, 'heartbeat'), String(Date.now()), { EX: 30 });
|
||||
if (streamKey) multi.set(streamKey, 'active', { EX: 60 * 60 });
|
||||
await multi.exec().catch(() => null);
|
||||
},
|
||||
async streamEnded(sessionId, target) {
|
||||
const client = await getClient();
|
||||
if (!client || !target) return;
|
||||
const workerId = workerIdForTarget(target);
|
||||
const streamKey = sessionId ? key('stream', sessionId, 'status') : null;
|
||||
const activeKey = key('worker', workerId, 'active_streams');
|
||||
const nextValue = await client.decr(activeKey).catch(() => null);
|
||||
const multi = client
|
||||
.multi()
|
||||
.set(key('worker', workerId, 'heartbeat'), String(Date.now()), { EX: 30 });
|
||||
if (Number(nextValue ?? 0) < 0) multi.set(activeKey, '0');
|
||||
if (streamKey) multi.set(streamKey, 'closed', { EX: 600 });
|
||||
await multi.exec().catch(() => null);
|
||||
},
|
||||
async getStatus() {
|
||||
const client = await getClient();
|
||||
const workers = [];
|
||||
for (const target of targets) {
|
||||
const workerId = workerIdForTarget(target);
|
||||
const values = client
|
||||
? await client
|
||||
.mGet([
|
||||
key('worker', workerId, 'active_streams'),
|
||||
key('worker', workerId, 'active_sessions'),
|
||||
key('worker', workerId, 'ewma_first_token_ms'),
|
||||
key('worker', workerId, 'error_rate'),
|
||||
key('worker', workerId, 'memory_pressure'),
|
||||
key('worker', workerId, 'heartbeat'),
|
||||
key('worker', workerId, 'drain'),
|
||||
])
|
||||
.catch(() => [])
|
||||
: [];
|
||||
workers.push({
|
||||
id: workerId,
|
||||
target,
|
||||
activeStreams: readNumber(values?.[0]),
|
||||
activeSessions: readNumber(values?.[1]),
|
||||
ewmaFirstTokenMs: readNumber(values?.[2]),
|
||||
errorRate: readNumber(values?.[3]),
|
||||
memoryPressure: readNumber(values?.[4]),
|
||||
heartbeat: values?.[5] ? Number(values[5]) : null,
|
||||
drain: /^(1|true|yes)$/i.test(String(values?.[6] ?? '')),
|
||||
});
|
||||
}
|
||||
return {
|
||||
enabled: Boolean(client),
|
||||
namespace,
|
||||
workers,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function readJsonBody(req) {
|
||||
if (req.method === 'GET' || req.method === 'HEAD') return null;
|
||||
const chunks = [];
|
||||
@@ -566,6 +725,7 @@ export function createTkmindProxy({
|
||||
return false;
|
||||
}
|
||||
}
|
||||
const runtimeRouter = createRuntimeRouter({ targets, targetHealthy });
|
||||
|
||||
function isGeneratedSessionName(name) {
|
||||
const normalized = typeof name === 'string' ? name.trim() : '';
|
||||
@@ -675,12 +835,56 @@ export function createTkmindProxy({
|
||||
|
||||
async function pickTarget() {
|
||||
if (targets.length <= 1) return primaryTarget;
|
||||
for (let i = 0; i < targets.length; i += 1) {
|
||||
const target = targets[rrIdx];
|
||||
const fallbackPick = async () => {
|
||||
for (let i = 0; i < targets.length; i += 1) {
|
||||
const target = targets[rrIdx];
|
||||
rrIdx = (rrIdx + 1) % targets.length;
|
||||
if (await targetHealthy(target)) return target;
|
||||
}
|
||||
return primaryTarget;
|
||||
};
|
||||
if (runtimeRouter) {
|
||||
const orderedTargets = targets.map((_, i) => targets[(rrIdx + i) % targets.length]);
|
||||
rrIdx = (rrIdx + 1) % targets.length;
|
||||
if (await targetHealthy(target)) return target;
|
||||
return runtimeRouter.pickTarget(fallbackPick, orderedTargets);
|
||||
}
|
||||
return primaryTarget;
|
||||
return fallbackPick();
|
||||
}
|
||||
|
||||
async function rememberSessionTarget(sessionId, target) {
|
||||
if (!runtimeRouter || !sessionId || !target) return;
|
||||
await runtimeRouter.registerSession(sessionId, target).catch(() => null);
|
||||
}
|
||||
|
||||
async function markStreamStarted(sessionId, target) {
|
||||
if (!runtimeRouter || !sessionId || !target) return;
|
||||
await runtimeRouter.streamStarted(sessionId, target).catch(() => null);
|
||||
}
|
||||
|
||||
async function markStreamEnded(sessionId, target) {
|
||||
if (!runtimeRouter || !sessionId || !target) return;
|
||||
await runtimeRouter.streamEnded(sessionId, target).catch(() => null);
|
||||
}
|
||||
|
||||
async function getRuntimeStatus() {
|
||||
const targetStatuses = [];
|
||||
for (const target of targets) {
|
||||
targetStatuses.push({
|
||||
target,
|
||||
healthy: await targetHealthy(target),
|
||||
});
|
||||
}
|
||||
const routerStatus = runtimeRouter
|
||||
? await runtimeRouter.getStatus().catch((err) => ({
|
||||
enabled: false,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
}))
|
||||
: { enabled: false, reason: 'MEMIND_RUNTIME_REDIS_URL not configured' };
|
||||
return {
|
||||
publicBaseUrl: process.env.H5_PUBLIC_BASE_URL ?? null,
|
||||
router: routerStatus,
|
||||
targets: targetStatuses,
|
||||
};
|
||||
}
|
||||
|
||||
async function startSessionForUser(
|
||||
@@ -713,6 +917,7 @@ export function createTkmindProxy({
|
||||
if (!session?.id) {
|
||||
throw new Error('创建会话失败:缺少 session id');
|
||||
}
|
||||
await rememberSessionTarget(session.id, startTarget);
|
||||
await userAuth.registerAgentSession(userId, session.id, startTarget);
|
||||
if (resolvedSessionPolicy?.gooseMode) {
|
||||
const modeRes = await apiFetch(startTarget, apiSecret, '/agent/update_session', {
|
||||
@@ -756,6 +961,8 @@ export function createTkmindProxy({
|
||||
async function resolveTarget(sessionId) {
|
||||
if (targets.length <= 1 || !sessionId) return primaryTarget;
|
||||
try {
|
||||
const routedTarget = await runtimeRouter?.resolveSessionTarget(sessionId);
|
||||
if (routedTarget) return routedTarget;
|
||||
const { target, node } = await userAuth.getSessionTarget(sessionId);
|
||||
// Prefer the pinned URL: stable across reordering/resizing the target list.
|
||||
// Only honor it if that upstream is still configured; otherwise fall back to
|
||||
@@ -975,6 +1182,7 @@ export function createTkmindProxy({
|
||||
}
|
||||
const session = JSON.parse(text);
|
||||
if (session?.id) {
|
||||
await rememberSessionTarget(session.id, startTarget);
|
||||
await userAuth.registerAgentSession(
|
||||
req.currentUser.id,
|
||||
session.id,
|
||||
@@ -1228,16 +1436,25 @@ export function createTkmindProxy({
|
||||
];
|
||||
|
||||
const proxySessionEvents = async (req, res, sessionId, { onAfterFinish, onEvent } = {}) => {
|
||||
const upstreamAbort = new AbortController();
|
||||
let clientClosed = false;
|
||||
const abortUpstream = () => {
|
||||
clientClosed = true;
|
||||
upstreamAbort.abort();
|
||||
};
|
||||
req.once('close', abortUpstream);
|
||||
try {
|
||||
const pathname = `/sessions/${sessionId}/events`;
|
||||
const sessionTarget = await resolveTarget(sessionId);
|
||||
const upstream = await apiFetch(sessionTarget, apiSecret, pathname, {
|
||||
method: 'GET',
|
||||
signal: upstreamAbort.signal,
|
||||
headers: {
|
||||
Accept: 'text/event-stream',
|
||||
'Last-Event-ID': req.get('last-event-id') ?? '',
|
||||
},
|
||||
});
|
||||
if (clientClosed) return;
|
||||
|
||||
if (!upstream.ok || !upstream.body) {
|
||||
const text = await upstream.text().catch(() => '');
|
||||
@@ -1246,9 +1463,11 @@ export function createTkmindProxy({
|
||||
}
|
||||
|
||||
res.status(upstream.status);
|
||||
res.setHeader('Content-Type', 'text/event-stream');
|
||||
res.setHeader('Cache-Control', 'no-cache');
|
||||
res.setHeader('Content-Type', 'text/event-stream; charset=utf-8');
|
||||
res.setHeader('Cache-Control', 'no-cache, no-transform');
|
||||
res.setHeader('Connection', 'keep-alive');
|
||||
res.setHeader('X-Accel-Buffering', 'no');
|
||||
res.flushHeaders?.();
|
||||
|
||||
let pendingBalance = null;
|
||||
const billingTransform = createSseBillingTransform({
|
||||
@@ -1280,27 +1499,47 @@ export function createTkmindProxy({
|
||||
|
||||
const source = Readable.fromWeb(upstream.body);
|
||||
const linkSanitizer = createSessionEventSanitizer(req.currentUser, { onEvent });
|
||||
const waitForDrain = () => new Promise((resolve) => res.once('drain', resolve));
|
||||
const writeClientChunk = async (chunk) => {
|
||||
if (res.writableEnded || clientClosed) return;
|
||||
let needsDrain = !res.write(chunk);
|
||||
if (pendingBalance != null && !res.writableEnded && !clientClosed) {
|
||||
needsDrain = !res.write(appendBalanceEvent(pendingBalance)) || needsDrain;
|
||||
pendingBalance = null;
|
||||
}
|
||||
if (needsDrain && !res.writableEnded && !clientClosed) {
|
||||
await waitForDrain();
|
||||
}
|
||||
};
|
||||
const clientSink = new Writable({
|
||||
write(chunk, _encoding, callback) {
|
||||
writeClientChunk(chunk).then(() => callback(), callback);
|
||||
},
|
||||
});
|
||||
|
||||
// 每 20s 发一个注释行保持连接,防止 nginx/代理因静默超时切断 SSE
|
||||
const keepalive = setInterval(() => {
|
||||
if (!res.writableEnded) res.write(': keepalive\n\n');
|
||||
}, 20000);
|
||||
const stopKeepalive = () => clearInterval(keepalive);
|
||||
|
||||
billingTransform.on('data', (chunk) => {
|
||||
res.write(chunk);
|
||||
if (pendingBalance != null) {
|
||||
res.write(appendBalanceEvent(pendingBalance));
|
||||
pendingBalance = null;
|
||||
if (!res.writableEnded && !clientClosed) {
|
||||
const ok = res.write(': keepalive\n\n');
|
||||
if (!ok) source.pause();
|
||||
}
|
||||
});
|
||||
billingTransform.on('end', () => { stopKeepalive(); res.end(); });
|
||||
billingTransform.on('error', () => { stopKeepalive(); res.end(); });
|
||||
linkSanitizer.on('error', () => { stopKeepalive(); res.end(); });
|
||||
source.on('error', () => { stopKeepalive(); res.end(); });
|
||||
req.on('close', stopKeepalive);
|
||||
source.pipe(linkSanitizer).pipe(billingTransform);
|
||||
}, 20000);
|
||||
res.on('drain', () => source.resume());
|
||||
await markStreamStarted(sessionId, sessionTarget);
|
||||
try {
|
||||
await pipeline(source, linkSanitizer, billingTransform, clientSink);
|
||||
} finally {
|
||||
clearInterval(keepalive);
|
||||
req.off('close', abortUpstream);
|
||||
await markStreamEnded(sessionId, sessionTarget);
|
||||
if (!res.writableEnded) res.end();
|
||||
}
|
||||
} catch (err) {
|
||||
req.off('close', abortUpstream);
|
||||
if (clientClosed || upstreamAbort.signal.aborted) {
|
||||
if (!res.writableEnded) res.end();
|
||||
return;
|
||||
}
|
||||
res.status(502).json({
|
||||
message: sanitizeUserFacingProxyMessage(
|
||||
err instanceof Error ? err.message : 'SSE 代理失败',
|
||||
@@ -1467,6 +1706,7 @@ export function createTkmindProxy({
|
||||
proxySessionEvents,
|
||||
resolveTarget,
|
||||
startSessionForUser,
|
||||
getRuntimeStatus,
|
||||
submitSessionReplyForUser,
|
||||
apiFetch: async (pathname, init) => apiFetch(await pickTarget(), apiSecret, pathname, init),
|
||||
apiFetchTo: (target, pathname, init) => apiFetch(target, apiSecret, pathname, init),
|
||||
|
||||
@@ -128,7 +128,7 @@ test('sanitizeSessionConversationPublicHtmlLinks removes internal user prompt no
|
||||
test('buildVisionPayload injects public standard image urls for page generation', async () => {
|
||||
const payload = await buildVisionPayload({
|
||||
userId: 'user-1',
|
||||
publishLayout: { publicUrl: 'https://m.tkmind.cn/MindSpace/user-1/' },
|
||||
publishLayout: { publicUrl: 'https://mm.tkmind.cn/MindSpace/user-1/' },
|
||||
userMessage: {
|
||||
content: [
|
||||
{
|
||||
@@ -143,7 +143,7 @@ test('buildVisionPayload injects public standard image urls for page generation'
|
||||
});
|
||||
|
||||
const text = payload?.userMessage?.content?.[0]?.text ?? '';
|
||||
assert.match(text, /https:\/\/m\.tkmind\.cn\/MindSpace\/user-1\/public\/images\/2026-06-29\/hero\.jpg/);
|
||||
assert.match(text, /https:\/\/mm\.tkmind\.cn\/MindSpace\/user-1\/public\/images\/2026-06-29\/hero\.jpg/);
|
||||
assert.doesNotMatch(text, /plain\/local:\/\//);
|
||||
assert.match(text, /无需 cookie 的公开压缩标准图片/);
|
||||
assert.match(text, /不得改写图片里人物的年龄、性别、人数或主体关系/);
|
||||
|
||||
+1
-1
@@ -40,7 +40,7 @@ export function resolveUserAddressName({ displayName, username, slug } = {}) {
|
||||
}
|
||||
|
||||
export function resolvePublicBaseUrl(env = process.env) {
|
||||
return (env.H5_PUBLIC_BASE_URL ?? 'https://m.tkmind.cn').replace(/\/$/, '');
|
||||
return (env.H5_PUBLIC_BASE_URL ?? 'https://mm.tkmind.cn').replace(/\/$/, '');
|
||||
}
|
||||
|
||||
export const PUBLISH_KEY_UUID =
|
||||
|
||||
Reference in New Issue
Block a user