diff --git a/.env.example b/.env.example
index 7203e3b..95a637d 100644
--- a/.env.example
+++ b/.env.example
@@ -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
diff --git a/.runtime/portal/RUNBOOK.txt b/.runtime/portal/RUNBOOK.txt
index 35616b6..3edce79 100644
--- a/.runtime/portal/RUNBOOK.txt
+++ b/.runtime/portal/RUNBOOK.txt
@@ -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.
diff --git a/.runtime/portal/scripts/run-memind-portal-prod.sh b/.runtime/portal/scripts/run-memind-portal-prod.sh
index 703366e..5ef9b2d 100755
--- a/.runtime/portal/scripts/run-memind-portal-prod.sh
+++ b/.runtime/portal/scripts/run-memind-portal-prod.sh
@@ -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"
diff --git a/.runtime/portal/server.mjs b/.runtime/portal/server.mjs
index 1635960..1c34779 100644
--- a/.runtime/portal/server.mjs
+++ b/.runtime/portal/server.mjs
@@ -6155,6 +6155,16 @@ async function migrateSchema(pool2) {
`ALTER TABLE h5_users ADD COLUMN signup_source VARCHAR(32) NULL DEFAULT 'password' AFTER workspace_root`
);
}
+ if (!await columnExists(pool2, "h5_users", "low_balance_gift_eligible")) {
+ await pool2.query(
+ `ALTER TABLE h5_users ADD COLUMN low_balance_gift_eligible TINYINT(1) NOT NULL DEFAULT 0 AFTER signup_source`
+ );
+ }
+ if (!await columnExists(pool2, "h5_users", "low_balance_gift_granted_at")) {
+ await pool2.query(
+ `ALTER TABLE h5_users ADD COLUMN low_balance_gift_granted_at BIGINT NULL AFTER low_balance_gift_eligible`
+ );
+ }
if (!await columnExists(pool2, "h5_user_sessions", "goosed_node")) {
await pool2.query(
`ALTER TABLE h5_user_sessions ADD COLUMN goosed_node TINYINT UNSIGNED NOT NULL DEFAULT 0`
@@ -6165,6 +6175,11 @@ async function migrateSchema(pool2) {
`ALTER TABLE h5_user_sessions ADD COLUMN goosed_target VARCHAR(255) NULL AFTER goosed_node`
);
}
+ if (!await columnExists(pool2, "h5_user_sessions", "origin")) {
+ await pool2.query(
+ `ALTER TABLE h5_user_sessions ADD COLUMN origin ENUM('h5', 'wechat') NOT NULL DEFAULT 'h5' AFTER user_id`
+ );
+ }
if (!await columnExists(pool2, "h5_session_snapshots", "display_title")) {
await pool2.query(
`ALTER TABLE h5_session_snapshots ADD COLUMN display_title VARCHAR(512) NOT NULL DEFAULT '' AFTER name`
@@ -6801,7 +6816,8 @@ function createAgentRunEventsHandler({
// tkmind-proxy.mjs
import fs5 from "node:fs";
import path6 from "node:path";
-import { Readable, Transform as Transform2 } from "node:stream";
+import { Readable, Transform as Transform2, Writable } from "node:stream";
+import { pipeline } from "node:stream/promises";
import { Agent, fetch as undiciFetch } from "undici";
// sse-billing.mjs
@@ -7486,9 +7502,11 @@ import path3 from "node:path";
import { fileURLToPath as fileURLToPath3 } from "node:url";
var __dirname2 = path3.dirname(fileURLToPath3(import.meta.url));
var PUBLISH_SKILL_NAME = "static-page-publish";
+var DOCX_SKILL_NAME = "docx-generate";
var PUBLISH_ROOT_DIR = "MindSpace";
var PUBLIC_ZONE_DIR = "public";
var PUBLISH_SKILL_DIR = path3.join(__dirname2, "skills", PUBLISH_SKILL_NAME);
+var DOCX_SKILL_DIR = path3.join(__dirname2, "skills", DOCX_SKILL_NAME);
var WORKSPACE_HINTS_FILENAME = ".tkmindhints";
var LEGACY_WORKSPACE_HINTS_FILENAME = ".goosehints";
function renderBrandingBlock(userAddressName) {
@@ -7513,7 +7531,7 @@ function resolveUserAddressName({ displayName, username, slug } = {}) {
return "\u7528\u6237";
}
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(/\/$/, "");
}
var PUBLISH_KEY_UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
function resolvePublishKey(user) {
@@ -7593,7 +7611,7 @@ description: \u5728\u4E13\u5C5E MindSpace \u76EE\u5F55\u751F\u6210\u53EF\u516C\u
1. **\u552F\u4E00\u53EF\u5199\u76EE\u5F55**\uFF1A\`${publishDir}\`
2. **\u7981\u6B62**\u4F7F\u7528\u7EDD\u5BF9\u8DEF\u5F84\uFF08\u5982 \`/Users/...\`\u3001\`../\` \u8DF3\u51FA\u76EE\u5F55\uFF09
-3. **\u5141\u8BB8**\u5728\u672C\u76EE\u5F55\u5185\u4F7F\u7528 \`write_file\`\u3001\`edit_file\`\u3001\`read_file\`\u3001\`list_dir\`\uFF1B**\u7981\u6B62**\u8BBF\u95EE\u6B64\u76EE\u5F55\u5916\u7684\u8DEF\u5F84\uFF08\u542B \`../\`\u3001\u5176\u5B83\u7528\u6237\u76EE\u5F55\u3001\u9879\u76EE\u6839\u76EE\u5F55\uFF1B\u7CFB\u7EDF\u4F1A\u5728 OS \u5C42\u62E6\u622A\u8D8A\u754C\u8BBF\u95EE\uFF09
+3. **\u5141\u8BB8**\u5728\u672C\u76EE\u5F55\u5185\u4F7F\u7528 \`write_file\`\u3001\`edit_file\`\u3001\`read_file\`\u3001\`list_dir\`\u3001\`generate_docx\`\uFF1B**\u7981\u6B62**\u8BBF\u95EE\u6B64\u76EE\u5F55\u5916\u7684\u8DEF\u5F84\uFF08\u542B \`../\`\u3001\u5176\u5B83\u7528\u6237\u76EE\u5F55\u3001\u9879\u76EE\u6839\u76EE\u5F55\uFF1B\u7CFB\u7EDF\u4F1A\u5728 OS \u5C42\u62E6\u622A\u8D8A\u754C\u8BBF\u95EE\uFF09
4. **\u7981\u6B62**\u5B50 Agent\u3001\u6269\u5C55\u7BA1\u7406\u3001\u4FEE\u6539\u5DE5\u4F5C\u533A\u5916\u6587\u4EF6
5. \u9875\u9762\u9ED8\u8BA4\u5199\u5165 \`public/\` \u5206\u533A\uFF0C\u4F7F\u7528\u76F8\u5BF9\u8DEF\u5F84\u5982 \`public/report.html\`\u3001\`public/assets/chart.png\`
6. \u751F\u6210 HTML \u540E\uFF0C\u5411\u7528\u6237\u63D0\u4F9B\u53EF\u8BBF\u95EE\u94FE\u63A5\uFF0C\u683C\u5F0F\uFF1A
@@ -7607,7 +7625,23 @@ description: \u5728\u4E13\u5C5E MindSpace \u76EE\u5F55\u751F\u6210\u53EF\u516C\u
3. \u5728 \`
\` \u5199\u5165 **mindspace-cover** \u5143\u6570\u636E\uFF08\u89C1\u4E0B\u6587\uFF0C\u5FC5\u987B\u4E0E\u9875\u9762\u4E3B\u9898\u4E00\u81F4\uFF09
4. \u9875\u9762\u5185\u8D44\u6E90\u4F7F\u7528**\u76F8\u5BF9\u8DEF\u5F84**\uFF08\`assets/foo.png\`\uFF09\uFF0C\u4E0D\u8981\u7528\u78C1\u76D8\u7EDD\u5BF9\u8DEF\u5F84
5. \u4FDD\u5B58 HTML \u540E\uFF0C\u670D\u52A1\u7AEF\u4F1A**\u7ACB\u5373**\u751F\u6210\u540C\u540D\u9884\u89C8\u56FE \`<\u6587\u4EF6\u540D>.thumbnail.svg\`\uFF08Agent \u4EA4\u4E92\u9636\u6BB5\u5373\u751F\u6548\uFF0C\u65E0\u9700\u7B49\u7528\u6237\u4FDD\u5B58\u5230\u300C\u6211\u7684\u7A7A\u95F4\u300D\uFF09
-6. \u5B8C\u6210\u540E\u6309\u300C\u56DE\u590D\u683C\u5F0F\u300D\u8FD4\u56DE\u53EF\u70B9\u51FB\u94FE\u63A5
+6. \u4E0B\u8F7D\u6309\u94AE\u7684\u76F8\u5BF9\u94FE\u63A5\u76EE\u6807\uFF08\u5982 \`public/report.docx\`\uFF09\u5FC5\u987B\u901A\u8FC7 \`generate_docx\` \u843D\u76D8\uFF0C\u4E14 basename \u4E0E HTML \u4E00\u81F4
+7. \u5B8C\u6210\u540E\u6309\u300C\u56DE\u590D\u683C\u5F0F\u300D\u8FD4\u56DE\u53EF\u70B9\u51FB\u94FE\u63A5
+
+## \u4F34\u751F\u4E0B\u8F7D\u6587\u4EF6\uFF08\u5FC5\u987B\uFF09
+
+- \u76F8\u5BF9\u8DEF\u5F84\u4E0B\u8F7D\u94FE\u63A5\uFF08\`.docx\` / \`.pdf\` \u7B49\uFF09\u5FC5\u987B\u5728 HTML \u540C\u76EE\u5F55\u6216\u5B50\u76EE\u5F55\u771F\u5B9E\u5B58\u5728
+- Word \u6587\u6863\u5FC5\u987B\u7528 sandbox-fs \u7684 \`generate_docx\` \u751F\u6210\uFF1B\u7981\u6B62\u7528 \`computercontroller\` / shell \u751F\u6210\u751F\u4EA7\u4E0B\u8F7D\u6587\u4EF6\u4F5C\u4E3A\u4EA4\u4ED8\u4F9D\u636E
+- \u6539 HTML \u6587\u4EF6\u540D\u65F6\u540C\u6B65 rename/copy \u4F34\u751F\u6587\u4EF6\uFF1B\u4ECE \`oa/\` \u590D\u5236\u5230 \`public/\` \u518D\u94FE\u63A5
+- \u53EF\u8DD1 \`node scripts/check-mindspace-public-links.mjs\` \u81EA\u68C0
+
+## Word \u4E0B\u8F7D\u9875\uFF08\u5FC5\u987B\uFF09
+
+- \u5982\u679C\u7528\u6237\u660E\u786E\u8981\u6C42\u9875\u9762\u91CC**\u53EF\u4E0B\u8F7D Word / docx**\uFF0C\u5FC5\u987B\u5148 \`load_skill\` \u2192 \`docx-generate\`
+- \u7528 \`.agents/skills/docx-generate/generate_docx.py\` \u751F\u6210 \`public/\u6587\u4EF6\u540D.docx\`
+- \u751F\u6210\u540E\u5FC5\u987B\u7528 \`list_dir public\`\uFF08\u6216\u7B49\u4EF7\u65B9\u5F0F\uFF09\u786E\u8BA4\u76EE\u6807 \`.docx\` \u5DF2\u5B58\u5728\uFF0C\u518D\u5199 \`public/\u9875\u9762.html\`
+- HTML \u4E2D\u53EA\u80FD\u4F7F\u7528\u540C\u76EE\u5F55\u76F8\u5BF9\u8DEF\u5F84\u94FE\u63A5\u6587\u6863\uFF0C\u4F8B\u5982 \`\u4E0B\u8F7D Word \u6587\u6863\`
+- **\u7981\u6B62**\u53EA\u5199\u4E0B\u8F7D\u6309\u94AE\u6216 \`.docx\` \u94FE\u63A5\uFF0C\u5374\u6CA1\u6709\u5148\u628A\u76EE\u6807\u6587\u6863\u843D\u76D8
## \u56DE\u590D\u683C\u5F0F\uFF08\u5FC5\u987B\uFF09
@@ -7676,6 +7710,7 @@ description: \u5728\u4E13\u5C5E MindSpace \u76EE\u5F55\u751F\u6210\u53EF\u516C\u
- \u4E0D\u8981\u5199\u5165\u5F53\u524D\u5DE5\u4F5C\u533A\u4EE5\u5916\u7684\u4EFB\u4F55\u76EE\u5F55
- shell \u4EC5\u7528\u4E8E\u672C\u76EE\u5F55\u5185\u6574\u7406\u6587\u4EF6/\u7B80\u5355\u811A\u672C\uFF1B\u4E0D\u8981 \`rm -rf\` \u8D8A\u754C\u8DEF\u5F84\u3001\u4E0D\u8981\u5B89\u88C5\u7CFB\u7EDF\u7EA7\u4F9D\u8D56
- **\u7981\u6B62**\u5728 HTML \u4E2D\u7528 \`data:...;base64,...\` \u5185\u5D4C Word/PDF\uFF1B\u4E8C\u8FDB\u5236\u6587\u4EF6\u5355\u72EC\u843D\u76D8\u540E\u7528\u76F8\u5BF9\u8DEF\u5F84\u94FE\u63A5\uFF08\u89C1 \`docx-generate\` \u6280\u80FD\uFF09
+- \u7528\u6237\u8981\u6C42 Word / docx \u4E0B\u8F7D\u65F6\uFF0C\u7981\u6B62\u8DF3\u8FC7 \`docx-generate\` \u76F4\u63A5\u5728 HTML \u4E2D\u4F2A\u9020\u4E0B\u8F7D\u94FE\u63A5
## \u793A\u4F8B
@@ -7710,6 +7745,12 @@ ${renderBrandingBlock(addressName)}
- **\u7981\u6B62**\u7528\u516C\u7F51 URL\u3001curl\u3001wget \u6216 read_image \u53BB\u300C\u5217\u76EE\u5F55\u300D\u300C\u8BFB CSV\u300D\u2014\u2014\u9759\u6001\u7AD9\u70B9\u4E0D\u63D0\u4F9B\u76EE\u5F55\u7D22\u5F15
- \u6709 \`shell\` \u65F6\u7528 \`ls oa/\`\u3001\`find .\`\u3001\`cat oa/file.csv\`\uFF1B\u6709 \`tree\` \u65F6\u7528 \`tree oa/\`
- \u4E0D\u8981\u8BF4\u300C\u6211\u6CA1\u6709 shell\u300D\u2014\u2014\u82E5\u7CFB\u7EDF\u63D0\u793A\u5DF2\u5217\u51FA shell\uFF0C\u5C31\u5FC5\u987B\u7528 shell \u5728\u5DE5\u4F5C\u533A\u5185\u64CD\u4F5C
+
+## Word \u4E0B\u8F7D\u9875
+
+- \u82E5\u7528\u6237\u8981\u6C42\u9875\u9762\u91CC\u53EF\u4E0B\u8F7D Word / docx\uFF1A\u5FC5\u987B\u5148 \`load_skill\` \u2192 \`docx-generate\`
+- \u5148\u751F\u6210\u5E76\u786E\u8BA4 \`public/*.docx\` \u5DF2\u5B58\u5728\uFF0C\u518D\u5199 \`public/*.html\`
+- HTML \u4E2D\u53EA\u80FD\u7528\u540C\u76EE\u5F55\u76F8\u5BF9\u8DEF\u5F84\u94FE\u63A5\u8BE5\u6587\u6863\uFF0C\u7981\u6B62\u53EA\u5199\u4E0B\u8F7D\u6309\u94AE\u5374\u6CA1\u6709\u5148\u628A \`.docx\` \u843D\u76D8
`;
}
function buildSandboxSessionConstraints({ baseConstraints, developerTools = [] }) {
@@ -7744,9 +7785,12 @@ function buildSandboxSessionConstraints({ baseConstraints, developerTools = [] }
"## \u751F\u6210 / \u53D1\u5E03 HTML \u9875\u9762",
"- \u4F60\u6709 write_file/edit_file \u5DE5\u5177\uFF1A**\u5FC5\u987B\u7531\u4F60**\u5199\u5165 `public/xxx.html`\uFF08\u6216\u5DE5\u4F5C\u533A\u6839\u76EE\u5F55 `.html`\uFF09",
"- \u5F00\u59CB\u524D\u6267\u884C load_skill \u2192 `static-page-publish`\uFF0C\u6309\u6280\u80FD\u8BF4\u660E\u5199\u5165 mindspace-cover \u5143\u6570\u636E",
+ "- \u5982\u679C\u7528\u6237\u8981\u6C42 Word / docx \u4E0B\u8F7D\uFF1A\u5148 `load_skill` \u2192 `docx-generate`\uFF0C\u751F\u6210\u5E76\u786E\u8BA4 `public/*.docx` \u5DF2\u5B58\u5728\uFF0C\u518D\u5199 HTML \u7528\u76F8\u5BF9\u8DEF\u5F84\u94FE\u63A5",
"- **\u7981\u6B62**\u7528 shell / cat / heredoc / echo / cp \u5199\u5165 HTML\uFF1Bshell \u5728\u5BB9\u5668\u5185\u6267\u884C\uFF0C\u6587\u4EF6\u4E0D\u4F1A\u51FA\u73B0\u5728\u516C\u7F51 MindSpace \u8DEF\u5F84",
+ "- \u7528 `apps__create_app` \u8BBE\u8BA1\u9875\u9762\u65F6\uFF0C\u6700\u540E\u4ECD\u8981\u6309 `static-page-publish` skill \u628A\u5185\u5BB9 write_file \u843D\u5230 `public/`\uFF0C\u624D\u6709\u516C\u7F51\u94FE\u63A5",
"- **\u7981\u6B62**\u8BA9\u7528\u6237\u624B\u52A8\u4FDD\u5B58\u5230 public \u6216\u8BF4\u65E0\u6CD5\u751F\u6210\u9875\u9762\uFF08\u9664\u975E write_file \u8C03\u7528\u5931\u8D25\uFF09",
- "- \u5B8C\u6210\u540E\u56DE\u590D `[\u9875\u9762\u6807\u9898](\u516C\u7F51URL)` \u53EF\u70B9\u51FB\u94FE\u63A5\uFF1B\u5199\u5165 `public/` \u65F6 URL \u5FC5\u987B\u542B `/public/` \u8DEF\u5F84\u6BB5"
+ "- \u5B8C\u6210\u540E\u56DE\u590D `[\u9875\u9762\u6807\u9898](\u516C\u7F51URL)` \u53EF\u70B9\u51FB\u94FE\u63A5\uFF1B\u5199\u5165 `public/` \u65F6 URL \u5FC5\u987B\u542B `/public/` \u8DEF\u5F84\u6BB5\uFF1B\u6309\u672C\u4F1A\u8BDD\u7ED9\u51FA\u7684\u516C\u7F51\u524D\u7F00\u6A21\u677F\u62FC\u63A5\u771F\u5B9E\u5730\u5740",
+ "- \u4E0B\u8F7D\u9644\u4EF6\uFF1AWord \u7528 `generate_docx` \u5199\u5165 `public/*.docx`\uFF0C\u76F8\u5BF9\u94FE\u63A5\u6587\u4EF6\u5FC5\u987B\u5DF2\u5728 `public/` \u843D\u76D8\uFF0Cbasename \u4E0E HTML \u4E00\u81F4"
);
return lines.join("\n");
}
@@ -7764,9 +7808,15 @@ function buildPublishConstraints({ slug, username, publicBaseUrl, publishDir, di
"- **\u7981\u6B62**\uFF1A\u8BBF\u95EE `assets/` \u5185\u90E8\u8DEF\u5F84\u3001\u5176\u5B83\u7528\u6237\u76EE\u5F55\u3001\u4E3B\u673A\u7EDD\u5BF9\u8DEF\u5F84\uFF1B\u7981\u6B62\u7528\u516C\u7F51 URL \u5217\u76EE\u5F55\u6216\u8BFB CSV",
"- **\u8DEF\u5F84\u89C4\u5219**\uFF1A\u53EA\u7528\u76F8\u5BF9\u8DEF\u5F84\uFF1B\u7981\u6B62 `../`\uFF1B\u5DE5\u4F5C\u533A\u5916\u7684\u8DEF\u5F84\u4F1A\u88AB\u7CFB\u7EDF\u62D2\u7EDD\uFF08OS \u5C42\u5F3A\u5236\uFF0C\u975E\u8F6F\u7EA6\u675F\uFF09",
"- **\u751F\u6210\u9875\u9762\uFF08\u5FC5\u987B\u4EB2\u81EA\u5B8C\u6210\uFF09**\uFF1A\u5148 `load_skill` \u2192 `static-page-publish`\uFF0C\u518D\u7528 `write_file`/`edit_file` \u5199\u5165 `public/\u9875\u9762.html`",
+ "- **Word \u4E0B\u8F7D\u9875\uFF08\u5FC5\u987B\u4EB2\u81EA\u5B8C\u6210\uFF09**\uFF1A\u82E5\u7528\u6237\u8981\u6C42 Word / docx \u4E0B\u8F7D\uFF0C\u5FC5\u987B\u5148 `load_skill` \u2192 `docx-generate`\uFF0C\u751F\u6210\u5E76\u786E\u8BA4 `public/*.docx` \u5DF2\u5B58\u5728\uFF0C\u518D\u5728 HTML \u4E2D\u7528\u76F8\u5BF9\u8DEF\u5F84\u94FE\u63A5",
"- **\u7528\u6237\u53EF\u89C1\u56DE\u590D**\uFF1A\u4E0D\u8981\u5411\u7528\u6237\u590D\u8FF0 load_skill\u3001\u6280\u80FD\u66F4\u65B0\u3001\u9875\u811A\u6807\u8BB0\u3001mindspace-cover \u7B49\u5185\u90E8\u5B9E\u73B0\uFF1B\u5B8C\u6210\u540E\u76F4\u63A5\u7ED9\u51FA\u9875\u9762\u94FE\u63A5\u6216\u7ED3\u679C",
"- **\u7981\u6B62**\u7528 shell \u5199\u5165 HTML\uFF1B**\u7981\u6B62**\u8BA9\u7528\u6237\u300C\u624B\u52A8\u4FDD\u5B58\u5230 public \u76EE\u5F55\u300D\u6216\u8BF4\u300C\u6211\u65E0\u6CD5\u751F\u6210\u9875\u9762\u300D\u2014\u2014\u9664\u975E write_file \u5DF2\u5931\u8D25\u5E76\u62A5\u544A\u9519\u8BEF",
"- \u5B8C\u6210\u540E\u7ED9\u51FA Markdown \u53EF\u70B9\u51FB\u516C\u7F51\u94FE\u63A5 `[\u6807\u9898](URL)`\uFF1B\u5199\u5165 `public/\u9875\u9762.html` \u65F6 URL \u4E3A `.../MindSpace/<\u7528\u6237ID>/public/\u9875\u9762.html`",
+ "- \u82E5\u5148\u7528 `apps__create_app` \u8BBE\u8BA1/\u9884\u89C8\uFF0C\u6700\u540E\u4ECD\u8981\u628A\u5185\u5BB9 write_file \u843D\u5230 `public/\u9875\u9762.html` \u624D\u6709\u516C\u7F51\u94FE\u63A5",
+ "- **\u9700\u8981\u67E5\u5B9E\u65F6/\u771F\u5B9E\u4E16\u754C\u4FE1\u606F\u65F6**\uFF1A\u5148 `load_skill` \u2192 `web`\uFF0C\u4F18\u5148 `web_search`/`fetch_url`\uFF1B\u56FD\u5185 google.com \u4E0D\u53EF\u8FBE\uFF0C\u591A\u6B21\u65E0\u679C\u65F6\u6539\u8D70 Bing/360 \u7B49\u53EF\u8FBE\u641C\u7D22\u6E90",
+ "- **\u7981\u6B62**\u8BA9\u7528\u6237\u300C\u624B\u52A8\u4FDD\u5B58\u5230 public \u76EE\u5F55\u300D\u6216\u8BF4\u300C\u6211\u65E0\u6CD5\u751F\u6210\u9875\u9762\u300D\u2014\u2014\u9664\u975E write_file \u5DF2\u5931\u8D25\u5E76\u62A5\u544A\u9519\u8BEF",
+ "- \u5B8C\u6210\u540E\u7ED9\u51FA Markdown \u53EF\u70B9\u51FB\u516C\u7F51\u94FE\u63A5 `[\u6807\u9898](URL)`\uFF1B\u5199\u5165 `public/\u9875\u9762.html` \u65F6 URL \u4E3A `.../MindSpace/<\u7528\u6237ID>/public/\u9875\u9762.html`\uFF0C\u6309\u6A21\u677F\u62FC\u771F\u5B9E\u5730\u5740",
+ "- \u4E0B\u8F7D\u94FE\u63A5\uFF08\u5982 `report.docx`\uFF09\u5FC5\u987B\u4E0E HTML \u540C\u76EE\u5F55\u4E14\u6587\u4EF6\u540D\u4E00\u81F4\uFF1BWord \u5FC5\u987B\u7528 sandbox-fs `generate_docx` \u751F\u6210\uFF0C\u4E0D\u8981\u7528 `computercontroller` / shell \u4F5C\u4E3A\u4EA4\u4ED8\u4F9D\u636E",
`- \u53D1\u5E03\u6280\u80FD\uFF1A\`${PUBLISH_SKILL_NAME}\`\uFF08\u751F\u6210\u9875\u9762\u524D\u5E94 load_skill\uFF09`
].join("\n");
}
@@ -7780,7 +7830,16 @@ function ensureWorkspaceHintsInstalled(publishDir, context) {
}
return hintsPath;
}
+function ensureDocxSkillInstalled(publishDir) {
+ if (!fs3.existsSync(DOCX_SKILL_DIR)) return null;
+ const skillRoot = path3.join(publishDir, ".agents", "skills", DOCX_SKILL_NAME);
+ fs3.rmSync(skillRoot, { recursive: true, force: true });
+ fs3.mkdirSync(path3.dirname(skillRoot), { recursive: true });
+ fs3.cpSync(DOCX_SKILL_DIR, skillRoot, { recursive: true });
+ return path3.join(skillRoot, "SKILL.md");
+}
function ensurePublishSkillInstalled(publishDir, context) {
+ ensureDocxSkillInstalled(publishDir);
const skillRoot = path3.join(publishDir, ".agents", "skills", PUBLISH_SKILL_NAME);
fs3.mkdirSync(skillRoot, { recursive: true });
const skillPath = path3.join(skillRoot, "SKILL.md");
@@ -8001,6 +8060,13 @@ function resolveTimeOfDayPeriod(hour) {
if (h < 18) return { period: "\u4E0B\u5348", greeting: "\u4E0B\u5348\u597D" };
return { period: "\u665A\u4E0A", greeting: "\u665A\u4E0A\u597D" };
}
+function buildCurrentTimeAgentPrefix({ now = Date.now(), timezone = DEFAULT_TIMEZONE } = {}) {
+ return [
+ "\u3010TKMind \u65F6\u95F4\u57FA\u51C6 - \u4EC5\u4F9B\u56DE\u7B54\u65F6\u95F4/\u65E5\u671F/\u65F6\u6BB5\u95EE\u5019\uFF0C\u4E0D\u8981\u5411\u7528\u6237\u590D\u8FF0\u3011",
+ renderCurrentTimeAnchor({ now, timezone }),
+ ""
+ ].join("\n");
+}
function renderCurrentTimeAnchor({ now = Date.now(), timezone = DEFAULT_TIMEZONE } = {}) {
const { year, month, day } = formatDateParts(now, timezone);
const { hour, minute } = formatLocalClockParts(now, timezone);
@@ -8499,6 +8565,141 @@ async function apiFetch(target, apiSecret, pathname, init = {}) {
dispatcher: isHttpsTarget(target) ? insecureDispatcher : void 0
});
}
+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 = [];
@@ -8718,10 +8919,10 @@ function extractImageUrlsFromMessage(userMessage) {
function messageHasImages(userMessage) {
return extractImageUrlsFromMessage(userMessage).length > 0;
}
-function injectTaskRoutingHint(body, sessionPolicy) {
+function prependAgentTextToUserMessage(body, transformText) {
const originalText = firstUserText(body?.user_message);
if (!originalText?.trim()) return body;
- const agentText = buildTaskRoutingAgentText(originalText, sessionPolicy);
+ const agentText = transformText(originalText);
if (!agentText || agentText === originalText) return body;
const userMessage = body.user_message ?? {};
const content = Array.isArray(userMessage.content) ? [...userMessage.content] : [];
@@ -8745,6 +8946,19 @@ function injectTaskRoutingHint(body, sessionPolicy) {
}
};
}
+function injectCurrentTimeAnchor(body, timezone) {
+ const tz = String(timezone ?? process.env.H5_DEFAULT_TIMEZONE ?? "Asia/Shanghai").trim() || "Asia/Shanghai";
+ return prependAgentTextToUserMessage(body, (originalText) => {
+ if (originalText.includes("TKMind \u5F53\u524D\u65F6\u95F4\u57FA\u51C6")) return originalText;
+ return `${buildCurrentTimeAgentPrefix({ timezone: tz })}${originalText}`;
+ });
+}
+function injectTaskRoutingHint(body, sessionPolicy) {
+ return prependAgentTextToUserMessage(
+ body,
+ (originalText) => buildTaskRoutingAgentText(originalText, sessionPolicy)
+ );
+}
async function buildVisionPayload({
userMessage,
userId,
@@ -8887,6 +9101,7 @@ function createTkmindProxy({
return false;
}
}
+ const runtimeRouter = createRuntimeRouter({ targets, targetHealthy });
function isGeneratedSessionName2(name) {
const normalized = typeof name === "string" ? name.trim() : "";
return /^20\d{6}(?:[_-]\d+)?$/.test(normalized);
@@ -8977,12 +9192,50 @@ 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(userId, {
workingDir,
@@ -9010,6 +9263,7 @@ function createTkmindProxy({
throw new Error("\u521B\u5EFA\u4F1A\u8BDD\u5931\u8D25\uFF1A\u7F3A\u5C11 session id");
}
await userAuth2.registerAgentSession(userId, session.id, startTarget);
+ await rememberSessionTarget(session.id, startTarget);
if (resolvedSessionPolicy?.gooseMode) {
const modeRes = await apiFetch(startTarget, apiSecret, "/agent/update_session", {
method: "POST",
@@ -9047,6 +9301,8 @@ function createTkmindProxy({
async function resolveTarget(sessionId) {
if (targets.length <= 1 || !sessionId) return primaryTarget;
try {
+ const routedTarget = await runtimeRouter?.resolveSessionTarget(sessionId);
+ if (routedTarget && targets.includes(routedTarget)) return routedTarget;
const { target, node } = await userAuth2.getSessionTarget(sessionId);
if (target && targets.includes(target)) return target;
return targets[node] ?? primaryTarget;
@@ -9242,6 +9498,7 @@ function createTkmindProxy({
session.id,
startTarget
);
+ await rememberSessionTarget(session.id, startTarget);
if (sessionPolicy.gooseMode) {
const modeRes = await apiFetch(startTarget, apiSecret, "/agent/update_session", {
method: "POST",
@@ -9399,6 +9656,15 @@ function createTkmindProxy({
}
const sessions = [...sessionsById.values()].sort(sortSessionsByRecent);
await enrichSessionHistory(sessions);
+ if (typeof userAuth2.getSessionOrigins === "function" && sessions.length > 0) {
+ try {
+ const origins = await userAuth2.getSessionOrigins(sessions.map((s) => s.id));
+ for (const session of sessions) {
+ session.origin = origins.get(session.id) ?? "h5";
+ }
+ } catch {
+ }
+ }
const summaries = sessions.map(projectSessionSummary).filter((item) => matchesSessionQuery(item, query));
const paged = summaries.slice(offset, offset + limit);
res.json({
@@ -9451,25 +9717,36 @@ 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(() => "");
res.status(upstream.status).send(text);
return;
}
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({
onFinish: async (event) => {
@@ -9499,36 +9776,45 @@ function createTkmindProxy({
});
const source = Readable.fromWeb(upstream.body);
const linkSanitizer = createSessionEventSanitizer(req.currentUser, { onEvent });
- const keepalive = setInterval(() => {
- if (!res.writableEnded) res.write(": keepalive\n\n");
- }, 2e4);
- const stopKeepalive = () => clearInterval(keepalive);
- billingTransform.on("data", (chunk) => {
- res.write(chunk);
- if (pendingBalance != null) {
- res.write(appendBalanceEvent(pendingBalance));
+ 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);
+ }
});
- 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);
+ const keepalive = setInterval(() => {
+ if (!res.writableEnded && !clientClosed) {
+ const ok = res.write(": keepalive\n\n");
+ if (!ok) source.pause();
+ }
+ }, 2e4);
+ 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 \u4EE3\u7406\u5931\u8D25",
@@ -9579,8 +9865,10 @@ function createTkmindProxy({
}
let baseBody = req.body;
if (isReplyPath && !policyState.unrestricted) {
+ const publishLayout = await userAuth2.getUserPublishLayout(req.currentUser.id).catch(() => null);
+ baseBody = injectCurrentTimeAnchor(req.body, publishLayout?.timezone);
baseBody = injectTaskRoutingHint(
- req.body,
+ baseBody,
await userAuth2.getAgentSessionPolicy(req.currentUser.id)
);
}
@@ -9661,6 +9949,7 @@ function createTkmindProxy({
sessionScoped,
proxyFallback,
proxySessionEvents,
+ getRuntimeStatus,
resolveTarget,
startSessionForUser,
submitSessionReplyForUser,
@@ -10126,8 +10415,12 @@ ${zoneLines.join("\n")}
- \u7528\u6237\u8981\u7F51\u9875 / HTML / \u62A5\u544A / \u5206\u4EAB\u94FE\u63A5\u65F6\uFF1A**\u4F60\u5FC5\u987B\u4EB2\u81EA\u5B8C\u6210**\uFF0C\u4E0D\u8981\u63A8\u7ED9\u7528\u6237\u624B\u52A8\u64CD\u4F5C
- \u5148 \`load_skill\` \u2192 \`static-page-publish\`\uFF0C\u518D\u4F7F\u7528 \`write_file\` \u521B\u5EFA \`public/\u9875\u9762\u540D.html\`\uFF08\u9700\u8981\u8C03\u6574\u5DF2\u6709\u9875\u9762\u65F6\u7528 \`edit_file\`\uFF09
+- \u5982\u679C\u9875\u9762\u91CC\u8FD8\u8981\u63D0\u4F9B Word / docx \u4E0B\u8F7D\uFF1A\u5FC5\u987B\u5148 \`load_skill\` \u2192 \`docx-generate\`\uFF0C\u751F\u6210 \`public/\u6587\u4EF6\u540D.docx\` \u5E76\u786E\u8BA4\u6587\u4EF6\u5B58\u5728\uFF0C\u518D\u5199 HTML \u7528\u76F8\u5BF9\u8DEF\u5F84\u94FE\u63A5\u8BE5\u6587\u6863
+- \u9875\u9762\u9700\u8981 Word / docx \u4E0B\u8F7D\u65F6\uFF0C\u5FC5\u987B\u7528 sandbox-fs \u7684 \`generate_docx\` \u5199\u5165 \`public/*.docx\`\uFF0C\u4E0D\u8981\u7528 shell/computercontroller \u751F\u6210\u751F\u4EA7\u4E0B\u8F7D\u6587\u4EF6
+- \u82E5\u7528 \`apps__create_app\` \u8BBE\u8BA1/\u9884\u89C8\u9875\u9762\uFF1A\u8FD9\u4E00\u6B65\u53EA\u662F\u5728 Apps \u7A97\u53E3\u5185\u521B\u5EFA\u4EA4\u4E92\u5F0F App\uFF0C**\u8FD8\u6CA1\u6709\u516C\u7F51\u94FE\u63A5**\uFF1B\u5FC5\u987B\u7D27\u63A5\u7740\u6309 \`static-page-publish\` skill \u628A\u6700\u7EC8\u5185\u5BB9 \`write_file\` \u843D\u5230 \`public/*.html\`\uFF0C\u624D\u80FD\u7ED9\u51FA\u771F\u5B9E\u53EF\u8BBF\u95EE\u7684\u94FE\u63A5
- \u5199\u5165 \`\` \u7684 **mindspace-cover** \u5143\u6570\u636E\uFF08\u8BE6\u89C1 \`.agents/skills/static-page-publish/SKILL.md\`\uFF09
- \u4FDD\u5B58\u540E\u7ACB\u5373\u7ED9\u51FA Markdown \u53EF\u70B9\u51FB\u516C\u7F51\u94FE\u63A5 \`[\u6807\u9898](URL)\`\uFF1B\u5199\u5165 \`public/\u9875\u9762.html\` \u65F6 URL \u4E3A \`.../MindSpace/<\u7528\u6237ID>/public/\u9875\u9762.html\`
+- \u7ED9\u94FE\u63A5\u65F6\u6309 \`static-page-publish\` skill \u91CC\u7684\u516C\u7F51\u524D\u7F00\u6A21\u677F\u62FC\u63A5\u771F\u5B9E\u5730\u5740\uFF0C\u4E0D\u786E\u5B9A\u57DF\u540D\u65F6\u7528\u76F8\u5BF9\u8DEF\u5F84 \`public/xxx.html\` \u8BF4\u660E\uFF0C\u4E0D\u8981\u731C\u4E00\u4E2A\u57DF\u540D\u5145\u6570
- **\u7981\u6B62**\u56DE\u590D\u300C\u8BF7\u624B\u52A8\u4FDD\u5B58\u5230 public \u76EE\u5F55\u300D\u300C\u6211\u65E0\u6CD5\u751F\u6210\u9875\u9762\u300D\u2014\u2014\u9664\u975E \`write_file\` / \`edit_file\` \u8C03\u7528\u5DF2\u5931\u8D25\u5E76\u8BF4\u660E\u5177\u4F53\u9519\u8BEF
## \u5DE5\u4F5C\u533A\u6587\u4EF6\u4E0E OA \u754C\u9762
@@ -10152,6 +10445,10 @@ function buildUserSpaceConstraints({ username, workspaceRoot, publicBaseUrl, slu
"- **\u7981\u6B62**\uFF1A\u8BBF\u95EE\u5176\u5B83\u7528\u6237\u76EE\u5F55\u3001MindSpace \u6839\u76EE\u5F55\u3001data/mindspace \u5185\u90E8\u8DEF\u5F84\u3001\u4E3B\u673A\u7EDD\u5BF9\u8DEF\u5F84",
"- **\u7981\u6B62**\u7528\u516C\u7F51 URL \u5217\u76EE\u5F55\u6216\u8BFB CSV\uFF1B\u751F\u6210 HTML \u5199\u5165 `public/` \u5E76\u7ED9\u51FA\u516C\u7F51\u94FE\u63A5",
"- **\u751F\u6210\u9875\u9762**\uFF1A\u5148 `load_skill` \u2192 `static-page-publish`\uFF0C\u518D\u7528 `write_file` / `edit_file` \u5199\u5165 `public/*.html`\uFF1B**\u7981\u6B62**\u8BA9\u7528\u6237\u624B\u52A8\u4FDD\u5B58\u6587\u4EF6",
+ "- **Word \u4E0B\u8F7D\u9875**\uFF1A\u82E5\u7528\u6237\u8981\u6C42 Word / docx \u4E0B\u8F7D\uFF0C\u5FC5\u987B\u5148 `load_skill` \u2192 `docx-generate` \u751F\u6210 `public/*.docx` \u5E76\u786E\u8BA4\u6587\u4EF6\u5B58\u5728\uFF0C\u518D\u5728 HTML \u91CC\u7528\u76F8\u5BF9\u8DEF\u5F84\u94FE\u63A5\u8BE5\u6587\u6863",
+ "- \u7528 `apps__create_app` \u8BBE\u8BA1\u9875\u9762\u65F6\uFF0C\u6700\u540E\u4E00\u6B65\u4ECD\u8981\u6309 `static-page-publish` skill \u628A\u5185\u5BB9 `write_file` \u843D\u5230 `public/*.html`\uFF0C\u518D\u7ED9\u51FA\u4E0B\u65B9\u524D\u7F00\u62FC\u51FA\u7684\u771F\u5B9E\u94FE\u63A5\uFF0C\u4E0D\u8981\u505C\u5728 App \u9636\u6BB5\u5C31\u56DE\u590D\u94FE\u63A5",
+ "- **\u9700\u8981\u67E5\u5B9E\u65F6/\u771F\u5B9E\u4E16\u754C\u4FE1\u606F\uFF08\u5982\u673A\u6784\u540D\u5355\u3001\u65B0\u95FB\u3001\u884C\u60C5\uFF09\u65F6**\uFF1A\u5148 `load_skill` \u2192 `web`\uFF0C\u4F18\u5148\u7528\u5176 `web_search`/`fetch_url`\uFF1B\u56FD\u5185\u73AF\u5883\u4E0B google.com \u4E0D\u53EF\u8FBE\uFF0C`web_search` \u591A\u6B21\u65E0\u679C\u65F6\u6539\u8D70 Bing/360 \u7B49\u53EF\u8FBE\u641C\u7D22\u6E90\uFF0C\u907F\u514D\u5BF9\u53CD\u722C\u7AD9\u70B9\u53CD\u590D\u786C\u6293",
+ "- **Word \u4E0B\u8F7D\u9875**\uFF1A\u82E5\u7528\u6237\u8981\u6C42 Word / docx \u4E0B\u8F7D\uFF0C\u5FC5\u987B\u5148 `load_skill` \u2192 `docx-generate` \u751F\u6210 `public/*.docx` \u5E76\u786E\u8BA4\u6587\u4EF6\u5B58\u5728\uFF0C\u518D\u5728 HTML \u91CC\u7528\u76F8\u5BF9\u8DEF\u5F84\u94FE\u63A5\u8BE5\u6587\u6863\uFF1B\u4E0D\u8981\u7528 shell/computercontroller \u751F\u6210\u751F\u4EA7\u4E0B\u8F7D\u6587\u4EF6",
publicBaseUrl && slug ? `- \u516C\u7F51 HTML \u524D\u7F00\uFF08\u516C\u5F00\u533A\uFF09\uFF1A\`${publicBaseUrl}/${PUBLISH_ROOT_DIR}/${slug}/public/\`\uFF08\u5199\u5165 \`public/\u9875\u9762.html\` \u65F6\u5206\u4EAB\u94FE\u63A5\u5FC5\u987B\u542B \`public/\`\uFF09` : null
].filter(Boolean).join("\n");
}
@@ -10440,6 +10737,8 @@ function createUserAuth(pool2, options = {}) {
const publicBaseUrl = resolvePublicBaseUrl(env);
const skillCatalog = listPlatformSkillCatalog(h5Root);
const defaultSignupBalanceCents = Number(options.defaultSignupBalanceCents ?? 500);
+ const lowBalanceGiftThresholdCents = Number(options.lowBalanceGiftThresholdCents ?? 100);
+ const lowBalanceGiftAmountCents = Number(options.lowBalanceGiftAmountCents ?? 1e3);
const sessionTtlMs = Number(options.sessionTtlMs ?? 7 * 24 * 60 * 60 * 1e3);
const loginMaxFailures = Number(options.loginMaxFailures ?? 5);
const loginFailureWindowMs = Number(options.loginFailureWindowMs ?? 5 * 60 * 1e3);
@@ -10635,6 +10934,67 @@ function createUserAuth(pool2, options = {}) {
[userId, amount, now]
);
};
+ const shouldEnableLowBalanceGift = ({ isAdmin = false, initialBalanceCents }) => {
+ return !isAdmin && Number(initialBalanceCents) === defaultSignupBalanceCents && defaultSignupBalanceCents > 0;
+ };
+ const grantLowBalanceGiftIfNeeded = async (conn, { userId, currentBalance, nextBalance, now }) => {
+ if (lowBalanceGiftAmountCents <= 0 || lowBalanceGiftThresholdCents < 0 || nextBalance > lowBalanceGiftThresholdCents) {
+ return { gifted: false, balanceAfter: nextBalance };
+ }
+ const [rows] = await conn.query(
+ `SELECT low_balance_gift_eligible, low_balance_gift_granted_at
+ FROM h5_users
+ WHERE id = ?
+ FOR UPDATE`,
+ [userId]
+ );
+ const user = rows[0];
+ if (!user || !Boolean(user.low_balance_gift_eligible) || user.low_balance_gift_granted_at != null) {
+ return { gifted: false, balanceAfter: nextBalance };
+ }
+ const giftedBalance = nextBalance + lowBalanceGiftAmountCents;
+ await conn.query(
+ `UPDATE h5_user_wallets
+ SET balance_cents = ?, updated_at = ?
+ WHERE user_id = ?`,
+ [giftedBalance, now, userId]
+ );
+ await conn.query(
+ `UPDATE h5_users
+ SET low_balance_gift_eligible = 0,
+ low_balance_gift_granted_at = ?,
+ status = CASE WHEN status = 'suspended' THEN 'active' ELSE status END,
+ updated_at = ?
+ WHERE id = ?`,
+ [now, now, userId]
+ );
+ await conn.query(
+ `INSERT INTO h5_billing_ledger
+ (user_id, type, amount_cents, tokens, note, operator_id, created_at)
+ VALUES (?, 'adjust', ?, 0, ?, NULL, ?)`,
+ [userId, lowBalanceGiftAmountCents, "\u65B0\u7528\u6237\u4F4E\u4F59\u989D\u81EA\u52A8\u8D60\u9001", now]
+ );
+ await conn.query(
+ `INSERT INTO h5_user_notifications
+ (id, user_id, channel, notification_type, title, body, data_json, status, read_at, created_at, updated_at)
+ VALUES (?, ?, 'web', 'low_balance_gift', ?, ?, ?, 'unread', NULL, ?, ?)`,
+ [
+ crypto6.randomUUID(),
+ userId,
+ "\u65B0\u7528\u6237\u989D\u5EA6\u5DF2\u81EA\u52A8\u8865\u9001",
+ `\u68C0\u6D4B\u5230\u4F60\u7684\u4F59\u989D\u5DF2\u4F4E\u4E8E \xA5${(lowBalanceGiftThresholdCents / 100).toFixed(2)}\uFF0C\u7CFB\u7EDF\u5DF2\u81EA\u52A8\u8D60\u9001 \xA5${(lowBalanceGiftAmountCents / 100).toFixed(2)} \u65B0\u7528\u6237\u989D\u5EA6\u3002\u672C\u798F\u5229\u4EC5\u53EF\u9886\u53D6\u4E00\u6B21\u3002`,
+ JSON.stringify({
+ triggerBalanceCents: nextBalance,
+ previousBalanceCents: currentBalance,
+ giftAmountCents: lowBalanceGiftAmountCents,
+ thresholdCents: lowBalanceGiftThresholdCents
+ }),
+ now,
+ now
+ ]
+ );
+ return { gifted: true, balanceAfter: giftedBalance };
+ };
const register = async ({ username, password, displayName, email }) => {
const normalized = normalizeUsername(username);
if (!isValidUsername(normalized)) {
@@ -10657,8 +11017,9 @@ function createUserAuth(pool2, options = {}) {
await conn.query(
`INSERT INTO h5_users
(id, username, slug, email, display_name, salt, password_hash, password_algorithm,
- role, status, plan_type, workspace_root, created_at, updated_at)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'user', 'active', 'free', ?, ?, ?)`,
+ role, status, plan_type, workspace_root, low_balance_gift_eligible, low_balance_gift_granted_at,
+ created_at, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'user', 'active', 'free', ?, 1, NULL, ?, ?)`,
[
userId,
normalized,
@@ -10946,6 +11307,22 @@ function createUserAuth(pool2, options = {}) {
);
return new Set(rows.map((row) => row.agent_session_id));
};
+ const setSessionOrigin = async (agentSessionId, origin) => {
+ if (!agentSessionId || origin !== "h5" && origin !== "wechat") return;
+ await pool2.query(
+ `UPDATE h5_user_sessions SET origin = ? WHERE agent_session_id = ?`,
+ [origin, agentSessionId]
+ );
+ };
+ const getSessionOrigins = async (agentSessionIds = []) => {
+ const ids = [...new Set(agentSessionIds)].filter(Boolean);
+ if (ids.length === 0) return /* @__PURE__ */ new Map();
+ const [rows] = await pool2.query(
+ `SELECT agent_session_id, origin FROM h5_user_sessions WHERE agent_session_id IN (?)`,
+ [ids]
+ );
+ return new Map(rows.map((row) => [row.agent_session_id, row.origin]));
+ };
const unregisterAgentSession = async (userId, agentSessionId) => {
await pool2.query(
`DELETE FROM h5_user_sessions WHERE agent_session_id = ? AND user_id = ?`,
@@ -11062,13 +11439,15 @@ function createUserAuth(pool2, options = {}) {
const { salt, passwordHash, passwordAlgorithm } = createPasswordRecord(password);
const now = Date.now();
const conn = await pool2.getConnection();
+ const initialBalanceCents = Number(balanceCents ?? defaultSignupBalanceCents);
try {
await conn.beginTransaction();
await conn.query(
`INSERT INTO h5_users
(id, username, slug, email, display_name, salt, password_hash, password_algorithm,
- role, status, plan_type, workspace_root, created_at, updated_at)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'active', 'free', ?, ?, ?)`,
+ role, status, plan_type, workspace_root, low_balance_gift_eligible, low_balance_gift_granted_at,
+ created_at, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'active', 'free', ?, ?, NULL, ?, ?)`,
[
userId,
normalized,
@@ -11080,6 +11459,7 @@ function createUserAuth(pool2, options = {}) {
passwordAlgorithm,
isAdmin ? "admin" : "user",
root,
+ shouldEnableLowBalanceGift({ isAdmin, initialBalanceCents }) ? 1 : 0,
now,
now
]
@@ -11087,7 +11467,7 @@ function createUserAuth(pool2, options = {}) {
await conn.query(
`INSERT INTO h5_user_wallets (user_id, balance_cents, tokens_used, updated_at)
VALUES (?, ?, 0, ?)`,
- [userId, Number(balanceCents ?? defaultSignupBalanceCents), now]
+ [userId, initialBalanceCents, now]
);
await conn.query(
`INSERT INTO h5_user_path_grants (user_id, path, mode) VALUES (?, ?, 'readwrite')`,
@@ -11367,6 +11747,7 @@ function createUserAuth(pool2, options = {}) {
amountCents: amount,
operatorId: operatorId ?? null,
paymentOrderId,
+ dedupeKey: paymentOrderId ? `recharge:${paymentOrderId}` : null,
notificationType: rechargeType,
title,
body,
@@ -11521,7 +11902,11 @@ function createUserAuth(pool2, options = {}) {
let tokensUsedAfter = null;
if (costCents > 0) {
const [walletRows] = await conn.query(
- `SELECT balance_cents, tokens_used FROM h5_user_wallets WHERE user_id = ? FOR UPDATE`,
+ `SELECT w.balance_cents, w.tokens_used, u.status
+ FROM h5_user_wallets w
+ JOIN h5_users u ON u.id = w.user_id
+ WHERE w.user_id = ?
+ FOR UPDATE`,
[userId]
);
const wallet = walletRows[0];
@@ -11567,7 +11952,14 @@ function createUserAuth(pool2, options = {}) {
now
]
);
- if (nextBalance <= 0) {
+ const lowBalanceGift = await grantLowBalanceGiftIfNeeded(conn, {
+ userId,
+ currentBalance,
+ nextBalance,
+ now
+ });
+ balanceAfter = lowBalanceGift.balanceAfter;
+ if (balanceAfter <= 0) {
await conn.query(`UPDATE h5_users SET status = 'suspended', updated_at = ? WHERE id = ?`, [
now,
userId
@@ -12276,6 +12668,13 @@ function createUserAuth(pool2, options = {}) {
openid
]);
};
+ const touchWechatAgentRoute = async (appId, openid, now = Date.now()) => {
+ if (!appId || !openid) return;
+ await pool2.query(
+ `UPDATE h5_wechat_agent_routes SET updated_at = ? WHERE app_id = ? AND openid = ?`,
+ [now, appId, openid]
+ );
+ };
const recordWechatMpMessage = async ({
appId,
openid,
@@ -12551,8 +12950,9 @@ function createUserAuth(pool2, options = {}) {
await conn.query(
`INSERT INTO h5_users
(id, username, slug, email, display_name, salt, password_hash, password_algorithm,
- role, status, plan_type, workspace_root, signup_source, created_at, updated_at)
- VALUES (?, ?, ?, NULL, ?, ?, ?, ?, 'user', 'active', 'free', ?, 'wechat', ?, ?)`,
+ role, status, plan_type, workspace_root, signup_source, low_balance_gift_eligible,
+ low_balance_gift_granted_at, created_at, updated_at)
+ VALUES (?, ?, ?, NULL, ?, ?, ?, ?, 'user', 'active', 'free', ?, 'wechat', 1, NULL, ?, ?)`,
[
userId,
normalized,
@@ -12834,6 +13234,7 @@ function createUserAuth(pool2, options = {}) {
getWechatAgentRoute,
upsertWechatAgentRoute,
clearWechatAgentRoute,
+ touchWechatAgentRoute,
recordWechatMpMessage,
finishWechatMpMessage,
insertWechatMpMessageDetail,
@@ -12853,6 +13254,8 @@ function createUserAuth(pool2, options = {}) {
unregisterAgentSession,
ownsSession,
listOwnedSessionIds,
+ setSessionOrigin,
+ getSessionOrigins,
canUseChat,
getUserById,
getUserPublic,
@@ -16107,6 +16510,38 @@ function sendError(res, req, status, code, message, details) {
});
}
+// notification-dispatcher.mjs
+function createNotificationDispatcher({ sendWechatTextToUser, logger = console } = {}) {
+ const sendWechat = async (userId, text) => {
+ if (typeof sendWechatTextToUser !== "function") return false;
+ await sendWechatTextToUser(userId, text);
+ return true;
+ };
+ return {
+ async sendRechargeSuccess({ userId, title, body, dedupeKey = null }) {
+ const sent = await sendWechat(userId, `${title}
+${body}`.trim());
+ logger.info?.("Notification dispatch:", {
+ type: "recharge_success",
+ userId,
+ dedupeKey,
+ sent
+ });
+ return sent;
+ },
+ async sendScheduleNotification({ userId, text }) {
+ const sent = await sendWechat(userId, text);
+ logger.info?.("Notification dispatch:", {
+ type: "schedule_notification",
+ userId,
+ dedupeKey: null,
+ sent
+ });
+ return sent;
+ }
+ };
+}
+
// mindspace-audit.mjs
function createMindSpaceAuditWriter(pool2) {
const write = async ({
@@ -18850,8 +19285,9 @@ function rowToExecutorBinding(row, keyPublic = null) {
}
function executorEnvForProfile(executor, profile, model, { includeSecret = false } = {}) {
const apiKeyValue = includeSecret ? profile.apiKey : "[hidden]";
- const baseUrl = resolveApiBaseUrl(profile.apiUrl);
const providerId = profile.providerId;
+ const resolvedApiUrl = profile.apiUrl || BUILTIN_PROVIDER_TEST_URLS[providerId] || "";
+ const baseUrl = resolveApiBaseUrl(resolvedApiUrl);
const common = {
TKMIND_EXECUTOR: executor,
TKMIND_EXECUTOR_PROVIDER: providerId,
@@ -18902,7 +19338,19 @@ function normalizeLaunchMode(mode) {
const normalized = String(mode ?? "headless").trim().toLowerCase();
return normalized === "serve" ? "serve" : "headless";
}
+function resolveExecutorCommand(executor) {
+ const candidates = executor === "aider" ? [process.env.AIDER_BIN, process.env.GOOSE_AIDER_BIN, "aider"] : executor === "openhands" ? [process.env.OPENHANDS_BIN, process.env.GOOSE_OPENHANDS_BIN, "openhands"] : [executor];
+ return candidates.map((item) => String(item ?? "").trim()).find(Boolean) ?? executor;
+}
function commandExists(command) {
+ if (path21.isAbsolute(command)) {
+ try {
+ fs17.accessSync(command, fs17.constants.X_OK);
+ return true;
+ } catch {
+ return false;
+ }
+ }
const probe = spawnSync("sh", ["-lc", `command -v ${JSON.stringify(command).slice(1, -1)}`], {
stdio: "ignore"
});
@@ -19002,11 +19450,12 @@ function buildExecutorLaunchPlan(runtime, options = {}) {
}
if (runtime.executor === "aider") {
const hasInstruction = Boolean(instruction);
+ const command = resolveExecutorCommand("aider");
return {
ok: true,
executor: "aider",
cwd,
- command: "aider",
+ command,
args: [
"--model",
runtime.model,
@@ -19028,12 +19477,13 @@ function buildExecutorLaunchPlan(runtime, options = {}) {
message: "OpenHands headless \u6A21\u5F0F\u9700\u8981 instruction"
};
}
+ const command = resolveExecutorCommand("openhands");
const args = mode === "serve" ? ["serve", "--mount-cwd"] : ["--headless", "--json", "--override-with-envs", "--task", instruction];
return {
ok: true,
executor: "openhands",
cwd,
- command: "openhands",
+ command,
args,
env,
notes: [
@@ -26490,74 +26940,130 @@ async function resolveChatSaveAnalysis({
import fs22 from "node:fs";
import path26 from "node:path";
var PUBLIC_HTML_PATH_PATTERN = /(?:^|[^a-z0-9_./-])(public\/[a-z0-9][a-z0-9._/-]{0,255}\.html)\b/i;
-var PUBLIC_DOCX_HREF_PATTERN = /href=["']([^"'#?\s]+\.docx)["']/gi;
-function normalizePublicDocxHref(href) {
+var PUBLIC_DOWNLOAD_ATTR_PATTERN = /(?:href|src)=["']([^"'#?\s]+)["']/gi;
+var DEFAULT_DOWNLOAD_SYNC_MIN_SIZE = 1024;
+function normalizePublicDownloadHref(href) {
const clean = String(href ?? "").split("?")[0].split("#")[0].replace(/^\.\//, "").trim();
- if (!clean || clean.includes("://") || clean.startsWith("data:")) return null;
+ if (!clean || clean.includes("://") || clean.startsWith("data:") || !DOWNLOADABLE_FILE_PATTERN.test(clean)) {
+ return null;
+ }
if (clean.startsWith("../oa/")) return clean.slice(3);
if (clean.startsWith("oa/")) return clean;
if (!clean.includes("/")) return `public/${clean}`;
return clean;
}
-function extractPublicDocxReferencesFromHtml(publishDir) {
+function extractPublicDownloadReferencesFromHtml(publishDir) {
const root = path26.resolve(String(publishDir ?? ""));
const publicDir = path26.join(root, "public");
if (!fs22.existsSync(publicDir) || !fs22.statSync(publicDir).isDirectory()) return [];
- const refs = /* @__PURE__ */ new Set();
+ const refs = /* @__PURE__ */ new Map();
for (const file of fs22.readdirSync(publicDir)) {
if (!file.toLowerCase().endsWith(".html")) continue;
const content = fs22.readFileSync(path26.join(publicDir, file), "utf8");
- for (const match of content.matchAll(PUBLIC_DOCX_HREF_PATTERN)) {
- const relativePath = normalizePublicDocxHref(match[1]);
- if (relativePath) refs.add(relativePath);
+ const htmlRelativePath = `public/${file}`;
+ for (const match of content.matchAll(PUBLIC_DOWNLOAD_ATTR_PATTERN)) {
+ const relativePath = normalizePublicDownloadHref(match[1]);
+ if (relativePath && !refs.has(relativePath)) {
+ refs.set(relativePath, { relativePath, htmlRelativePath });
+ }
}
}
- return [...refs];
+ return [...refs.values()];
}
-function findLatestCompleteOaDocx(publishDir, { minSize = 8e3 } = {}) {
+function listOaDownloadCandidates(publishDir, extension, { minSize = DEFAULT_DOWNLOAD_SYNC_MIN_SIZE } = {}) {
const oaDir = path26.join(path26.resolve(String(publishDir ?? "")), "oa");
- if (!fs22.existsSync(oaDir) || !fs22.statSync(oaDir).isDirectory()) return null;
- const candidates = fs22.readdirSync(oaDir).filter((name) => name.toLowerCase().endsWith(".docx")).map((name) => {
+ if (!fs22.existsSync(oaDir) || !fs22.statSync(oaDir).isDirectory()) return [];
+ return fs22.readdirSync(oaDir).filter((name) => name.toLowerCase().endsWith(extension)).map((name) => {
const absolutePath = path26.join(oaDir, name);
const stat = fs22.statSync(absolutePath);
return { name, absolutePath, size: stat.size, mtimeMs: stat.mtimeMs };
}).filter((item) => item.size >= minSize).sort((a, b) => b.mtimeMs - a.mtimeMs || b.size - a.size);
- return candidates[0] ?? null;
}
-function syncPublicDocxDownloads({ publishDir, minCompleteSize = 8e3 } = {}) {
+function resolvePublicDownloadSyncSource(root, relativePath, htmlRelativePath, { minSize = DEFAULT_DOWNLOAD_SYNC_MIN_SIZE } = {}) {
+ const extension = path26.extname(relativePath).toLowerCase();
+ const basename = path26.basename(relativePath);
+ const htmlStemFilename = path26.posix.basename(htmlRelativePath).replace(/\.html$/i, extension);
+ const exactCandidates = [
+ path26.posix.join("oa", basename),
+ path26.posix.join("oa", htmlStemFilename)
+ ];
+ for (const candidate of exactCandidates) {
+ const absolutePath = path26.resolve(root, candidate);
+ if (absolutePath === path26.resolve(root, relativePath)) continue;
+ try {
+ const stat = fs22.statSync(absolutePath);
+ if (stat.isFile() && stat.size >= minSize) {
+ return {
+ name: path26.basename(candidate),
+ absolutePath,
+ size: stat.size,
+ strategy: "exact_oa_match"
+ };
+ }
+ } catch {
+ }
+ }
+ const fallbackCandidates = listOaDownloadCandidates(root, extension, { minSize });
+ if (fallbackCandidates.length === 1) {
+ return { ...fallbackCandidates[0], strategy: "single_oa_fallback" };
+ }
+ return null;
+}
+function syncPublicDocxDownloads({
+ publishDir,
+ minCompleteSize = DEFAULT_DOWNLOAD_SYNC_MIN_SIZE
+} = {}) {
const root = path26.resolve(String(publishDir ?? ""));
- if (!root) return { synced: [], skipped: [] };
- const source = findLatestCompleteOaDocx(root, { minSize: minCompleteSize });
- if (!source) return { synced: [], skipped: [] };
+ if (!root) return { synced: [], skipped: [], missing: [], source: null, sourceByRelativePath: {} };
const synced = [];
const skipped = [];
- for (const relativePath of extractPublicDocxReferencesFromHtml(root)) {
+ const missing = [];
+ const sourceByRelativePath = {};
+ for (const reference of extractPublicDownloadReferencesFromHtml(root)) {
+ const { relativePath, htmlRelativePath } = reference;
const destination = path26.resolve(root, relativePath);
if (destination !== root && !destination.startsWith(`${root}${path26.sep}`)) {
skipped.push(relativePath);
continue;
}
- let shouldCopy = !fs22.existsSync(destination) || !fs22.statSync(destination).isFile();
- if (!shouldCopy) {
- try {
- shouldCopy = fs22.statSync(destination).size < source.size;
- } catch {
- shouldCopy = true;
- }
- }
- if (!shouldCopy) {
+ const source = resolvePublicDownloadSyncSource(root, relativePath, htmlRelativePath, {
+ minSize: minCompleteSize
+ });
+ const destinationExists = fs22.existsSync(destination) && fs22.statSync(destination).isFile();
+ if (destinationExists && !source) {
skipped.push(relativePath);
continue;
}
+ if (!source) {
+ missing.push(relativePath);
+ continue;
+ }
+ if (destinationExists) {
+ try {
+ if (fs22.statSync(destination).size >= source.size) {
+ skipped.push(relativePath);
+ continue;
+ }
+ } catch {
+ }
+ }
try {
fs22.mkdirSync(path26.dirname(destination), { recursive: true });
fs22.copyFileSync(source.absolutePath, destination);
synced.push(relativePath);
+ sourceByRelativePath[relativePath] = source.name;
} catch {
skipped.push(relativePath);
}
}
- return { synced, skipped, source: source.name };
+ const sourceNames = [...new Set(Object.values(sourceByRelativePath))];
+ return {
+ synced,
+ skipped,
+ missing,
+ source: sourceNames.length === 1 ? sourceNames[0] : null,
+ sourceByRelativePath
+ };
}
function messageText(message) {
const textParts = Array.isArray(message?.content) ? message.content.filter((item) => item?.type === "text" && typeof item.text === "string").map((item) => item.text.trim()).filter(Boolean) : [];
@@ -27002,6 +27508,8 @@ async function syncGeneratedPagesFromPublicAssets({
}
// mindspace-og-tags.mjs
+var PLATFORM_SITE_NAME = "TKMind \u667A\u8DA3";
+var PLATFORM_BRAND_ICON_PATH = "/brand/tkmind-icon.png";
function rawTitleFromHtml(html) {
return String(html).match(/]*>([^<]*)<\/title>/i)?.[1]?.trim() ?? "";
}
@@ -27026,47 +27534,136 @@ function rawMetaContent(html, attr, name) {
const match = String(html).match(pattern);
return match?.[1] || match?.[2] || "";
}
+function hasMeta(html, attr, name) {
+ return new RegExp(`]+${attr}=["']${name}["']`, "i").test(String(html));
+}
+function hasLinkRel(html, rel) {
+ return new RegExp(`]+rel=["'][^"']*\\b${rel}\\b[^"']*["']`, "i").test(String(html));
+}
function escapeScriptString(value) {
return JSON.stringify(String(value ?? "")).replace(/]+property=["']og:image["']/i.test(source)) return source;
- const signals = extractCoverSignals(source);
- const title = rawTitleFromHtml(source) || signals.title;
- const description = signals.subtitle || "";
- const imageUrl = resolveImageUrl(signals.image, { origin, pageDirUrl }) || fallbackImageUrl || null;
- const tags = [
- '',
- pageUrl ? `` : "",
- title ? `` : "",
- description ? `` : "",
- imageUrl ? `` : "",
- ``,
- title ? `` : "",
- description ? `` : "",
- imageUrl ? `` : ""
- ].filter(Boolean);
- const block = `
-${tags.map((t) => ` ${t}`).join("\n")}
-`;
+ if (!block) return source;
if (/<\/head>/i.test(source)) {
- return source.replace(/<\/head>/i, `${block}`);
+ return source.replace(/<\/head>/i, `${block}
+`);
}
if (/]*>/i.test(source)) {
return source.replace(/(]*>)/i, `$1${block}`);
}
- return source;
+ return `${block}
+${source}`;
+}
+function resolveShareDescription(html, { siteName = PLATFORM_SITE_NAME, meta = {} } = {}) {
+ return rawMetaContent(html, "property", "og:description") || rawMetaContent(html, "name", "description") || extractCoverSignals(html, meta).subtitle || `${siteName} \xB7 \u7CBE\u9009\u4F5C\u54C1`;
+}
+function resolveShareImageUrl(html, { origin, pageDirUrl, fallbackImageUrl = "", meta = {} } = {}) {
+ const existing = rawMetaContent(html, "property", "og:image");
+ if (existing) return existing;
+ const signals = extractCoverSignals(html, meta);
+ return resolveImageUrl(signals.image, { origin, pageDirUrl }) || fallbackImageUrl || "";
+}
+function extractSharePreviewMeta(html, {
+ origin = "",
+ pageUrl = "",
+ pageDirUrl = "",
+ fallbackImageUrl = "",
+ siteName = PLATFORM_SITE_NAME,
+ brandIconUrl = "",
+ meta = {}
+} = {}) {
+ const source = String(html ?? "");
+ const title = rawMetaContent(source, "property", "og:title") || rawTitleFromHtml(source) || "MindSpace \u9875\u9762";
+ const description = resolveShareDescription(source, { siteName, meta });
+ const imageUrl = resolveShareImageUrl(source, { origin, pageDirUrl, fallbackImageUrl, meta });
+ const resolvedSiteName = rawMetaContent(source, "property", "og:site_name") || siteName;
+ const iconUrl = brandIconUrl || (origin ? `${origin}${PLATFORM_BRAND_ICON_PATH}` : PLATFORM_BRAND_ICON_PATH);
+ return {
+ title,
+ description,
+ imageUrl,
+ siteName: resolvedSiteName,
+ iconUrl,
+ pageUrl
+ };
+}
+function injectOgTags(html, {
+ origin,
+ pageUrl,
+ pageDirUrl,
+ fallbackImageUrl = "",
+ siteName = PLATFORM_SITE_NAME,
+ brandIconUrl = "",
+ meta = {}
+} = {}) {
+ const source = String(html);
+ const preview = extractSharePreviewMeta(source, {
+ origin,
+ pageUrl,
+ pageDirUrl,
+ fallbackImageUrl,
+ siteName,
+ brandIconUrl,
+ meta
+ });
+ const tags = [];
+ if (!hasMeta(source, "property", "og:type")) {
+ tags.push('');
+ }
+ if (pageUrl && !hasMeta(source, "property", "og:url")) {
+ tags.push(``);
+ }
+ if (preview.title && !hasMeta(source, "property", "og:title")) {
+ tags.push(``);
+ }
+ if (preview.description && !hasMeta(source, "property", "og:description")) {
+ tags.push(``);
+ }
+ if (preview.imageUrl && !hasMeta(source, "property", "og:image")) {
+ tags.push(``);
+ }
+ if (preview.siteName && !hasMeta(source, "property", "og:site_name")) {
+ tags.push(``);
+ }
+ const twitterCard = preview.imageUrl ? "summary_large_image" : "summary";
+ if (!hasMeta(source, "name", "twitter:card")) {
+ tags.push(``);
+ }
+ if (preview.title && !hasMeta(source, "name", "twitter:title")) {
+ tags.push(``);
+ }
+ if (preview.description && !hasMeta(source, "name", "twitter:description")) {
+ tags.push(``);
+ }
+ if (preview.imageUrl && !hasMeta(source, "name", "twitter:image")) {
+ tags.push(``);
+ }
+ const links = [];
+ if (preview.iconUrl && !hasLinkRel(source, "icon")) {
+ links.push(``);
+ }
+ if (preview.iconUrl && !hasLinkRel(source, "apple-touch-icon")) {
+ links.push(``);
+ }
+ const block = [...tags, ...links].map((t) => ` ${t}`).join("\n");
+ if (!block) return source;
+ return appendBeforeHeadClose(source, `
+${block}
+`);
}
function injectWechatShareBridge(html, {
pageUrl,
- signatureEndpoint = "/auth/wechat/public-js-sdk-signature"
+ signatureEndpoint = "/auth/wechat/public-js-sdk-signature",
+ siteName = PLATFORM_SITE_NAME
} = {}) {
const source = String(html ?? "");
if (!pageUrl) return source;
if (source.includes('data-tkmind-wechat-share="1"')) return source;
const title = rawMetaContent(source, "property", "og:title") || rawTitleFromHtml(source);
- const description = rawMetaContent(source, "property", "og:description") || rawMetaContent(source, "name", "description") || "";
+ const resolvedSiteName = rawMetaContent(source, "property", "og:site_name") || siteName;
+ const description = resolveShareDescription(source, { siteName: resolvedSiteName });
const imageUrl = rawMetaContent(source, "property", "og:image") || "";
const script = `
`;
- if (/<\/head>/i.test(source)) {
- return source.replace(/<\/head>/i, `${script}
-`);
- }
- if (/]*>/i.test(source)) {
- return source.replace(/(]*>)/i, `$1${script}`);
- }
- return `${script}
-${source}`;
+ return appendBeforeHeadClose(source, script);
+}
+function renderWechatSharePreviewHtml(preview, { note = "" } = {}) {
+ const safe = (value) => String(value ?? "").replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """);
+ const title = safe(preview.title || "\u9875\u9762\u6807\u9898");
+ const description = safe(preview.description || "");
+ const siteName = safe(preview.siteName || PLATFORM_SITE_NAME);
+ const imageUrl = safe(preview.imageUrl || "");
+ const iconUrl = safe(preview.iconUrl || PLATFORM_BRAND_ICON_PATH);
+ const pageUrl = safe(preview.pageUrl || "");
+ const noteHtml = note ? `${safe(note)}
` : "";
+ return `
+
+
+
+
+ \u5FAE\u4FE1\u5206\u4EAB\u9884\u89C8 \xB7 ${title}
+
+
+
+
+
\u5FAE\u4FE1\u94FE\u63A5\u5361\u7247\u9884\u89C8
+
\u672C\u5730\u6A21\u62DF\u7684\u662F\u300C\u7C98\u8D34\u94FE\u63A5\u540E\u770B\u5230\u7684\u5361\u7247\u300D\uFF0C\u4E0D\u662F JS-SDK \u5185\u90E8\u5206\u4EAB\u5F39\u5C42\u3002${pageUrl ? `\u6E90\u94FE\u63A5\uFF1A${pageUrl}` : ""}
+ ${noteHtml}
+
+
+
${title}
+ ${description ? `
${description}
` : ""}
+
+
+ ${imageUrl ? `
` : `\u65E0\u56FE
`}
+
+
+
+
+`;
}
// mindspace-thumbnail-png.mjs
@@ -28832,6 +29481,147 @@ function shouldUseScheduleAssistant(text) {
return scheduleKeywords.test(compact) && timeKeywords.test(compact);
}
+// chat-skills.mjs
+var PUBLISH_SKILL_NAME2 = "static-page-publish";
+var WEB_INTENT_PATTERNS = [
+ /(?:今天|今日|最新|热点|热搜|新闻|头条|头条新闻|最新消息|发生了什么|最近发生)/u,
+ /(?:latest|breaking)\s+news/i,
+ /what(?:'s| is)?\s+happening/i
+];
+var 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
+];
+var PAGE_GENERATION_INTENT_PATTERNS = [
+ /(?:生成|做|制作|创建|设计|写|出)(?:一个|一份|个)?(?:H5|h5|HTML|html|网页|页面|活动页|宣传页|落地页|分享页)/u,
+ /(?:帮我|给我).*(?:H5|h5|HTML|html|网页|页面|活动页|宣传页|落地页|分享页)/u,
+ /(?:publish|create|generate|make|build|design).*(?:html|web\s?page|landing\s?page|h5)/i
+];
+var CHAT_SKILL_DEFINITIONS = [
+ {
+ id: "web-search",
+ label: "\u67E5\u8D44\u6599",
+ icon: "web",
+ skillName: "web",
+ requiresSkill: "web",
+ prefillOnly: true,
+ promptKey: "web"
+ },
+ {
+ id: "code-search",
+ label: "\u641C\u4EE3\u7801",
+ icon: "search",
+ skillName: "search",
+ requiresSkill: "search",
+ prefillOnly: true,
+ promptKey: "search"
+ },
+ {
+ id: "form-collect",
+ label: "\u8868\u5355\u6536\u96C6",
+ icon: "form",
+ skillName: "form-builder",
+ requiresSkill: "form-builder",
+ prefillOnly: true,
+ promptKey: "form-builder"
+ },
+ {
+ id: "table-view",
+ label: "\u6570\u636E\u8868\u683C",
+ icon: "table",
+ skillName: "table-viewer",
+ requiresSkill: "table-viewer",
+ prefillOnly: true,
+ promptKey: "table-viewer"
+ },
+ {
+ id: "product-campaign-page",
+ label: "\u5546\u54C1\u9875",
+ icon: "page",
+ skillName: "product-campaign-page",
+ requiresSkill: "product-campaign-page",
+ promptKey: "product-campaign-page"
+ },
+ {
+ id: "summarize",
+ label: "\u603B\u7ED3\u5185\u5BB9",
+ icon: "summary",
+ prefillOnly: true,
+ promptKey: "summarize"
+ },
+ {
+ id: "analyze",
+ label: "\u6DF1\u5EA6\u5206\u6790",
+ icon: "analyze",
+ prefillOnly: true,
+ promptKey: "analyze"
+ },
+ {
+ id: "generate-page",
+ label: "\u751F\u6210\u9875\u9762",
+ icon: "page",
+ skillName: PUBLISH_SKILL_NAME2,
+ requiresPublish: true,
+ promptKey: "generate-page"
+ }
+];
+function buildChatSkillPrompt(promptKey, skillName) {
+ switch (promptKey) {
+ case "web":
+ return `\u8BF7\u4F7F\u7528 ${skillName ?? "web"} \u6280\u80FD\uFF1A\u5E2E\u6211\u641C\u7D22\u5E76\u67E5\u9605\u76F8\u5173\u8D44\u6599\uFF08\u4F18\u5148\u5B98\u65B9\u6587\u6863\uFF09\uFF0C\u5E76\u7ED9\u51FA\u4E2D\u6587\u6458\u8981\u4E0E\u6765\u6E90\u94FE\u63A5\u3002\u6211\u7684\u95EE\u9898\u662F\uFF1A`;
+ case "search":
+ return `\u8BF7\u4F7F\u7528 ${skillName ?? "search"} \u6280\u80FD\uFF1A\u5E2E\u6211\u5728\u5DE5\u4F5C\u533A\u4E2D\u67E5\u627E\u4EE3\u7801\u6216\u6587\u4EF6\u3002\u6211\u8981\u627E\u7684\u662F\uFF1A`;
+ case "form-builder":
+ return `\u8BF7\u4F7F\u7528 ${skillName ?? "form-builder"} \u6280\u80FD\uFF1A\u8BF7\u7528\u4EA4\u4E92\u5F0F\u8868\u5355\u6536\u96C6\u4EE5\u4E0B\u573A\u666F\u6240\u9700\u7684\u7ED3\u6784\u5316\u5B57\u6BB5\uFF08\u5B57\u6BB5\u4E0D\u8D85\u8FC7 8 \u4E2A\uFF09\uFF1A`;
+ case "table-viewer":
+ return `\u8BF7\u4F7F\u7528 ${skillName ?? "table-viewer"} \u6280\u80FD\uFF1A\u8BF7\u628A\u4EE5\u4E0B\u6570\u636E\u6574\u7406\u6210\u53EF\u6392\u5E8F\u3001\u53EF\u7B5B\u9009\u7684\u4EA4\u4E92\u5F0F\u8868\u683C\uFF1A`;
+ case "product-campaign-page":
+ return `\u8BF7\u4F7F\u7528 ${skillName ?? "product-campaign-page"} \u6280\u80FD\uFF1A\u5E2E\u6211\u5236\u4F5C\u4E00\u4E2A\u5546\u54C1\u5BA3\u4F20 / \u6D3B\u52A8\u9875\u3002\u8BF7\u5148\u8BE2\u95EE\u5546\u54C1\u94FE\u63A5\u3001\u9875\u9762\u4E3B\u9898\u3001\u662F\u5426\u6709\u5546\u54C1\u56FE\u7247\u6216\u54C1\u724C\u7D20\u6750\uFF1B\u4FE1\u606F\u8DB3\u591F\u540E\uFF0C\u751F\u6210\u5305\u542B\u8D2D\u4E70\u8DF3\u8F6C\u6309\u94AE\u7684\u9875\u9762\u65B9\u6848\u3002\u5982\u679C\u6211\u786E\u8BA4\u8981\u53D1\u5E03\u6210 H5 \u9875\u9762\uFF0C\u8BF7\u7EE7\u7EED\u4F7F\u7528 static-page-publish \u6280\u80FD\u751F\u6210\u53EF\u8BBF\u95EE\u94FE\u63A5\u3002`;
+ case "summarize":
+ return "\u8BF7\u603B\u7ED3\u4EE5\u4E0B\u5185\u5BB9\uFF0C\u63D0\u70BC\u6838\u5FC3\u7ED3\u8BBA\u3001\u91CD\u70B9\u4FE1\u606F\u548C\u53EF\u6267\u884C\u5EFA\u8BAE\uFF08\u6761\u7406\u6E05\u6670\u3001\u4E2D\u6587\u8F93\u51FA\uFF09\uFF1A";
+ case "analyze":
+ return "\u8BF7\u6DF1\u5165\u5206\u6790\u4EE5\u4E0B\u5185\u5BB9\uFF0C\u7ED9\u51FA\u7ED3\u6784\u5316\u89E3\u8BFB\uFF1A\u80CC\u666F\u3001\u5173\u952E\u53D1\u73B0\u3001\u98CE\u9669\u6216\u673A\u4F1A\u3001\u4EE5\u53CA\u5EFA\u8BAE\u4E0B\u4E00\u6B65\uFF1A";
+ case "generate-page":
+ return `\u8BF7\u4F7F\u7528 ${skillName ?? PUBLISH_SKILL_NAME2} \u6280\u80FD\uFF1A\u5728\u6211\u7684\u4E13\u5C5E MindSpace \u53D1\u5E03\u76EE\u5F55\u751F\u6210\u9759\u6001 HTML \u9875\u9762\uFF0C\u5E76\u7ED9\u51FA\u53EF\u516C\u7F51\u8BBF\u95EE\u7684\u5B8C\u6574\u94FE\u63A5\u3002\u5FC5\u987B\u5148 load_skill\uFF0C\u518D\u7528 write_file/edit_file \u5199\u5165 public/\u9875\u9762.html\uFF1B\u5B8C\u6210\u540E\u76F4\u63A5\u8FD4\u56DE Markdown \u53EF\u70B9\u51FB\u516C\u7F51\u94FE\u63A5 \`[\u9875\u9762\u6807\u9898](URL)\`\u3002\u7981\u6B62\u53EA\u7ED9\u672C\u5730\u8DEF\u5F84\uFF08\u5982 hello/index.html\uFF09\uFF0C\u7981\u6B62\u8BE2\u95EE\u662F\u5426\u8FD8\u8981\u53D1\u5E03\uFF0C\u9664\u975E\u5199\u6587\u4EF6\u5DE5\u5177\u5B9E\u9645\u5931\u8D25\u3002\u5982\u679C\u9875\u9762\u91CC\u8FD8\u9700\u8981\u63D0\u4F9B Word / docx \u4E0B\u8F7D\uFF0C\u5FC5\u987B\u5148 \`load_skill\` \u2192 \`docx-generate\`\uFF0C\u751F\u6210 \`public/*.docx\` \u5E76\u786E\u8BA4\u6587\u4EF6\u5DF2\u5B58\u5728\uFF0C\u518D\u5199 HTML \u5E76\u7528\u540C\u76EE\u5F55\u76F8\u5BF9\u8DEF\u5F84\u94FE\u63A5\u8BE5\u6587\u6863\u3002`;
+ default:
+ return "";
+ }
+}
+function buildWebNewsSkillPrompt(skillName) {
+ return `\u8BF7\u4F7F\u7528 ${skillName ?? "web"} \u6280\u80FD\uFF1A\u5148\u641C\u7D22\u4ECA\u5929/\u6700\u65B0\u76F8\u5173\u7684\u65B0\u95FB\u4E0E\u70ED\u70B9\uFF0C\u4F18\u5148\u4E00\u624B\u6765\u6E90\u548C\u6743\u5A01\u5A92\u4F53\uFF0C\u6574\u7406 3-5 \u6761\u6700\u76F8\u5173\u7ED3\u679C\uFF0C\u6309\u65F6\u95F4\u6216\u70ED\u5EA6\u6392\u5E8F\uFF1B\u7136\u540E\u7ED9\u51FA\u4E2D\u6587\u6458\u8981\u3001\u5173\u952E\u4FE1\u606F\u3001\u4E8B\u4EF6\u80CC\u666F\u548C\u6765\u6E90\u94FE\u63A5\u3002\u6211\u7684\u95EE\u9898\u662F\uFF1A`;
+}
+function buildAutoChatSkillPrefix(text, grantedSkills = []) {
+ const trimmed = String(text ?? "").trim();
+ if (!trimmed) return "";
+ if (grantedSkills.includes(PUBLISH_SKILL_NAME2) && PAGE_GENERATION_INTENT_PATTERNS.some((pattern) => pattern.test(trimmed))) {
+ return buildChatSkillPrompt("generate-page", PUBLISH_SKILL_NAME2);
+ }
+ 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");
+}
+function stripKnownChatSkillPrompt(text) {
+ let next = String(text ?? "").trimStart();
+ const candidates = [];
+ for (const definition of CHAT_SKILL_DEFINITIONS) {
+ if (!definition.promptKey) continue;
+ candidates.push(buildChatSkillPrompt(definition.promptKey, definition.skillName));
+ }
+ candidates.push(buildWebNewsSkillPrompt("web"));
+ candidates.sort((a, b) => b.length - a.length);
+ for (const prompt of candidates) {
+ if (prompt && next.startsWith(prompt)) {
+ next = next.slice(prompt.length);
+ break;
+ }
+ }
+ return next.trim();
+}
+
// wechat-media.mjs
import crypto28 from "node:crypto";
import fs26 from "node:fs";
@@ -29111,6 +29901,7 @@ var DEFAULT_STATUS_TEXT = "\u6211\u5728\u8FD9\u8FB9\u3002\u4E0A\u4E00\u6761\u598
var DEFAULT_UNSUPPORTED_TEXT = "\u5F53\u524D\u5148\u652F\u6301\u6587\u5B57\u6D88\u606F\uFF0C\u4F60\u53EF\u4EE5\u76F4\u63A5\u53D1\u6587\u5B57\u7ED9\u6211\u3002";
var DEFAULT_UNBOUND_TEXT = "\u5148\u70B9\u8FD9\u91CC\u5B8C\u6210\u7ED1\u5B9A\uFF0C\u518D\u7EE7\u7EED\u548C\u4E13\u5C5E Agent \u5BF9\u8BDD\uFF1A";
var DEFAULT_PROGRESS_DELAY_MS = 8e3;
+var DEFAULT_SESSION_IDLE_ROTATE_MS = 30 * 60 * 1e3;
var PUBLIC_HTML_LINK_PATTERN2 = /https?:\/\/[^\s<>"')\]]+\/MindSpace\/([0-9a-f-]{36}|[a-z0-9._-]+)\/public\/([^\s<>"')\]]+\.html)/gi;
var SESSION_WORKSPACE_TOOL_NAMES = /* @__PURE__ */ new Set([
"write",
@@ -29381,6 +30172,11 @@ function looksLikeHtmlGenerationIntent(text) {
if (!normalized) return false;
return /(?:生成|创建|做|写|帮我.*(?:生成|创建|做|写)).*(?:html|页面|网页|page|文件)/i.test(normalized);
}
+function looksLikeDocxDownloadIntent(text) {
+ const normalized = String(text ?? "").trim();
+ if (!normalized) return false;
+ return /(?:(?:word|docx|\.docx|\.doc|文档).*(?:下载|链接|导出|给我)|(?:下载|导出|提供|给我).*(?:word|docx|\.docx|\.doc|文档))/iu.test(normalized);
+}
function usedStaticPagePublishSkill(messages = []) {
return messages.some(
(message) => message?.content?.some((item) => {
@@ -29987,8 +30783,16 @@ function normalizeWechatInboundIntent(inbound) {
}
return base;
}
-function buildWechatAgentPrompt(intent) {
+function buildWechatAgentPrompt(intent, { grantedSkills = [] } = {}) {
const msgType = String(intent?.msgType ?? "text");
+ const autoSkillPrefix = msgType === "text" || msgType === "voice" ? buildAutoChatSkillPrefix(intent?.agentText ?? intent?.content, grantedSkills) : "";
+ const docxDownloadHint = looksLikeHtmlGenerationIntent(intent?.agentText ?? intent?.content) && looksLikeDocxDownloadIntent(intent?.agentText ?? intent?.content) ? [
+ "\u3010Word \u4E0B\u8F7D\u8981\u6C42\u3011\u7528\u6237\u660E\u786E\u8981\u6C42\u9875\u9762\u91CC\u53EF\u4E0B\u8F7D Word / docx\u3002",
+ "\u5F00\u59CB\u524D\u5FC5\u987B\u5148\u8C03\u7528 `load_skill` \u2192 `docx-generate`\uFF0C\u5E76\u6309\u6280\u80FD\u8BF4\u660E\u751F\u6210\u76EE\u6807 `public/*.docx`\u3002",
+ "\u751F\u6210\u540E\u5FC5\u987B\u786E\u8BA4\u76EE\u6807 `.docx` \u5DF2\u5B58\u5728\uFF0C\u518D\u8C03\u7528 `load_skill` \u2192 `static-page-publish` \u5199 `public/*.html`\u3002",
+ "HTML \u4E2D\u53EA\u80FD\u7528\u540C\u76EE\u5F55\u76F8\u5BF9\u8DEF\u5F84\u94FE\u63A5\u8BE5\u6587\u6863\uFF1B\u7981\u6B62\u53EA\u5199\u4E0B\u8F7D\u6309\u94AE\u5374\u6CA1\u6709\u5148\u628A `.docx` \u843D\u76D8\u3002",
+ ""
+ ].join("\n") : "";
const pagePublishHint = looksLikeHtmlGenerationIntent(intent?.agentText ?? intent?.content) ? [
"\u3010\u9875\u9762\u53D1\u5E03\u6280\u80FD\u8981\u6C42\u3011\u8FD9\u6761\u6D88\u606F\u662F\u5728\u751F\u6210\u53EF\u8BBF\u95EE HTML \u9875\u9762\u3002",
"\u5F00\u59CB\u524D\u5FC5\u987B\u5148\u8C03\u7528 `load_skill` \u2192 `static-page-publish`\uFF0C\u4E0D\u80FD\u7701\u7565\uFF0C\u4E5F\u4E0D\u80FD\u5199\u5B8C\u9875\u9762\u540E\u518D\u8865\u8C03\u3002",
@@ -29999,25 +30803,29 @@ function buildWechatAgentPrompt(intent) {
""
].join("\n") : "";
const scheduleTimezone = process.env.H5_DEFAULT_TIMEZONE || "Asia/Shanghai";
+ const currentTimeHint = buildCurrentTimeAgentPrefix({ timezone: scheduleTimezone });
const scheduleAssistantHint = shouldUseScheduleAssistant(intent?.agentText ?? intent?.content) ? [
"\u3010\u65E5\u7A0B\u6280\u80FD\u8981\u6C42\u3011\u8FD9\u6761\u6D88\u606F\u6D89\u53CA\u5F85\u529E\u3001\u63D0\u9192\u6216\u65E5\u7A0B\u3002",
"\u5F00\u59CB\u524D\u5148\u52A0\u8F7D `schedule-assistant` skill\uFF0C\u5E76\u4E25\u683C\u6309 skill \u91CC\u7684\u8FB9\u754C\u6267\u884C\u3002",
"\u5199\u5165\u5DE5\u5177\u65F6\u4F18\u5148\u4F7F\u7528 startLocal / endLocal / remindLocal\uFF08YYYY-MM-DD HH:mm\uFF09\uFF0C\u4E0D\u8981\u81EA\u884C\u4F30\u7B97 Unix \u6BEB\u79D2\u3002",
- renderCurrentTimeAnchor({ timezone: scheduleTimezone }),
"\u53EA\u6709\u5728 `schedule_create_item` / `schedule_create_reminder` \u7B49\u5DE5\u5177\u6210\u529F\u8FD4\u56DE\u540E\uFF0C\u624D\u80FD\u544A\u8BC9\u7528\u6237\u201C\u5DF2\u7ECF\u8BBE\u7F6E\u597D\u4E86\u201D\u3002",
intent?.msgId ? `\u8C03\u7528 schedule_create_item \u65F6\u5FC5\u987B\u4F20\u5165 sourceMessageId: ${intent.msgId}` : "",
""
].filter(Boolean).join("\n") : "";
if (msgType === "voice") {
return [
+ docxDownloadHint,
+ currentTimeHint,
scheduleAssistantHint,
"\u3010\u5FAE\u4FE1\u670D\u52A1\u53F7\u8BED\u97F3\u6D88\u606F\u3011\u7528\u6237\u901A\u8FC7\u8BED\u97F3\u8F93\u5165\uFF0C\u4EE5\u4E0B\u662F\u5FAE\u4FE1\u8BC6\u522B\u7ED3\u679C\u3002",
"",
- `\u7528\u6237\u8BED\u97F3\u8BC6\u522B\u6587\u672C\uFF1A${String(intent?.agentText ?? "").trim()}`
+ `\u7528\u6237\u8BED\u97F3\u8BC6\u522B\u6587\u672C\uFF1A${autoSkillPrefix}${String(intent?.agentText ?? "").trim()}`
].filter(Boolean).join("\n");
}
if (msgType === "image") {
return [
+ docxDownloadHint,
+ currentTimeHint,
scheduleAssistantHint,
"\u3010\u5FAE\u4FE1\u670D\u52A1\u53F7\u56FE\u7247\u6D88\u606F\u3011\u7528\u6237\u53D1\u9001\u4E86\u56FE\u7247\u3002",
"\u5982\u7528\u6237\u6CA1\u6709\u660E\u786E\u8981\u6C42\uFF0C\u8BF7\u5148\u6839\u636E\u56FE\u7247\u5185\u5BB9\u7ED9\u51FA\u7B80\u77ED\u7406\u89E3\uFF0C\u5E76\u8BE2\u95EE\u4E0B\u4E00\u6B65\u3002",
@@ -30028,6 +30836,7 @@ function buildWechatAgentPrompt(intent) {
if (msgType === "location") {
const location = intent?.location ?? {};
return [
+ currentTimeHint,
scheduleAssistantHint,
"\u3010\u5FAE\u4FE1\u670D\u52A1\u53F7\u4F4D\u7F6E\u6D88\u606F\u3011\u7528\u6237\u53D1\u9001\u4E86\u5F53\u524D\u4F4D\u7F6E\u3002",
location.label ? `\u5730\u5740\uFF1A${location.label}` : "",
@@ -30039,6 +30848,7 @@ function buildWechatAgentPrompt(intent) {
if (msgType === "link") {
const link = intent?.link ?? {};
return [
+ currentTimeHint,
scheduleAssistantHint,
"\u3010\u5FAE\u4FE1\u670D\u52A1\u53F7\u94FE\u63A5\u6D88\u606F\u3011\u7528\u6237\u5206\u4EAB\u4E86\u94FE\u63A5\u3002",
link.title ? `\u6807\u9898\uFF1A${link.title}` : "",
@@ -30047,14 +30857,15 @@ function buildWechatAgentPrompt(intent) {
].filter(Boolean).join("\n");
}
const content = String(intent?.agentText ?? intent?.content ?? "").trim();
- const lines = [];
+ const lines = [currentTimeHint];
+ if (docxDownloadHint) lines.push(docxDownloadHint);
if (pagePublishHint) lines.push(pagePublishHint);
if (scheduleAssistantHint) lines.push(scheduleAssistantHint);
lines.push(
"\u3010\u5FAE\u4FE1\u670D\u52A1\u53F7\u65B0\u6D88\u606F\u3011\u8BF7\u53EA\u56DE\u7B54\u4E0B\u9762\u8FD9\u6761\u7528\u6237\u6D88\u606F\uFF0C\u4E0D\u8981\u4E3B\u52A8\u5EF6\u7EED\u65E0\u5173\u7684\u5386\u53F2\u8BDD\u9898\u3002",
"\u82E5\u7528\u6237\u53EA\u662F\u5728\u6D4B\u8BD5\u8FDE\u901A\u6027\uFF0C\u8BF7\u4E00\u53E5\u8BDD\u786E\u8BA4\u6536\u5230\u5373\u53EF\uFF0C\u4E0D\u8981\u5C55\u5F00\u65E7\u4EFB\u52A1\u3002",
"",
- `\u7528\u6237\u6D88\u606F\uFF1A${content}`
+ `\u7528\u6237\u6D88\u606F\uFF1A${autoSkillPrefix}${content}`
);
return lines.join("\n");
}
@@ -30118,6 +30929,10 @@ function loadWechatMpConfig(env = process.env) {
0,
Number(env.H5_WECHAT_MP_PROGRESS_DELAY_MS ?? DEFAULT_PROGRESS_DELAY_MS)
),
+ sessionIdleRotateMs: Math.max(
+ 0,
+ Number(env.H5_WECHAT_MP_SESSION_IDLE_ROTATE_MS ?? DEFAULT_SESSION_IDLE_ROTATE_MS)
+ ),
statusText: env.H5_WECHAT_MP_STATUS_TEXT?.trim() || DEFAULT_STATUS_TEXT,
unsupportedText: env.H5_WECHAT_MP_UNSUPPORTED_TEXT?.trim() || DEFAULT_UNSUPPORTED_TEXT,
unboundTextPrefix: env.H5_WECHAT_MP_UNBOUND_TEXT_PREFIX?.trim() || DEFAULT_UNBOUND_TEXT,
@@ -30605,6 +31420,17 @@ function createWechatMpService({
rawJson: intent.raw
});
};
+ const resolveGrantedSkills = async (userId) => {
+ try {
+ const row = await userAuth2.getUserById(userId);
+ if (!row) return [];
+ const caps = await userAuth2.resolveUserCapabilities(row);
+ return Array.isArray(caps?.grantedSkills) ? caps.grantedSkills : [];
+ } catch (err) {
+ logger.warn?.("WeChat MP granted skills lookup failed:", err);
+ return [];
+ }
+ };
const runIntentMessage = async ({ inbound, intent, user }) => {
const resetCandidate = intent.msgType === "text" || intent.msgType === "voice" ? intent.agentText : "";
const forceNew = isTopicResetIntent(resetCandidate);
@@ -32542,12 +33368,14 @@ function createFeedbackService(pool2) {
function startScheduleReminderWorker({
scheduleService: scheduleService2,
sendWechatTextToUser,
+ notificationDispatcher: notificationDispatcher2,
logger = console,
intervalMs = Number(process.env.H5_REMINDER_SCAN_INTERVAL_MS ?? 3e4),
maxAttempts = Number(process.env.H5_REMINDER_MAX_ATTEMPTS ?? 5),
runOnStart = true
} = {}) {
- if (!scheduleService2 || typeof sendWechatTextToUser !== "function") {
+ const sendScheduleNotification = notificationDispatcher2?.sendScheduleNotification?.bind(notificationDispatcher2) ?? (typeof sendWechatTextToUser === "function" ? async ({ userId, text }) => sendWechatTextToUser(userId, text) : null);
+ if (!scheduleService2 || typeof sendScheduleNotification !== "function") {
return { stop() {
} };
}
@@ -32579,7 +33407,7 @@ function startScheduleReminderWorker({
}
});
if (reminder.channel === "wechat") {
- await sendWechatTextToUser(reminder.userId, text);
+ await sendScheduleNotification({ userId: reminder.userId, text });
}
await scheduleService2.logDelivery({
reminderId: reminder.id,
@@ -32621,7 +33449,7 @@ function startScheduleReminderWorker({
timezone: subscription.timezone
}
});
- await sendWechatTextToUser(subscription.userId, text);
+ await sendScheduleNotification({ userId: subscription.userId, text });
await scheduleService2.logDelivery({
subscriptionId: subscription.id,
userId: subscription.userId,
@@ -32671,7 +33499,7 @@ function startScheduleReminderWorker({
balanceCents
}
});
- await sendWechatTextToUser(subscription.userId, text);
+ await sendScheduleNotification({ userId: subscription.userId, text });
await scheduleService2.logDelivery({
subscriptionId: subscription.id,
userId: subscription.userId,
@@ -32713,119 +33541,6 @@ function startScheduleReminderWorker({
};
}
-// chat-skills.mjs
-var PUBLISH_SKILL_NAME2 = "static-page-publish";
-var CHAT_SKILL_DEFINITIONS = [
- {
- id: "web-search",
- label: "\u67E5\u8D44\u6599",
- icon: "web",
- skillName: "web",
- requiresSkill: "web",
- prefillOnly: true,
- promptKey: "web"
- },
- {
- id: "code-search",
- label: "\u641C\u4EE3\u7801",
- icon: "search",
- skillName: "search",
- requiresSkill: "search",
- prefillOnly: true,
- promptKey: "search"
- },
- {
- id: "form-collect",
- label: "\u8868\u5355\u6536\u96C6",
- icon: "form",
- skillName: "form-builder",
- requiresSkill: "form-builder",
- prefillOnly: true,
- promptKey: "form-builder"
- },
- {
- id: "table-view",
- label: "\u6570\u636E\u8868\u683C",
- icon: "table",
- skillName: "table-viewer",
- requiresSkill: "table-viewer",
- prefillOnly: true,
- promptKey: "table-viewer"
- },
- {
- id: "product-campaign-page",
- label: "\u5546\u54C1\u9875",
- icon: "page",
- skillName: "product-campaign-page",
- requiresSkill: "product-campaign-page",
- promptKey: "product-campaign-page"
- },
- {
- id: "summarize",
- label: "\u603B\u7ED3\u5185\u5BB9",
- icon: "summary",
- prefillOnly: true,
- promptKey: "summarize"
- },
- {
- id: "analyze",
- label: "\u6DF1\u5EA6\u5206\u6790",
- icon: "analyze",
- prefillOnly: true,
- promptKey: "analyze"
- },
- {
- id: "generate-page",
- label: "\u751F\u6210\u9875\u9762",
- icon: "page",
- skillName: PUBLISH_SKILL_NAME2,
- requiresPublish: true,
- promptKey: "generate-page"
- }
-];
-function buildChatSkillPrompt(promptKey, skillName) {
- switch (promptKey) {
- case "web":
- return `\u8BF7\u4F7F\u7528 ${skillName ?? "web"} \u6280\u80FD\uFF1A\u5E2E\u6211\u641C\u7D22\u5E76\u67E5\u9605\u76F8\u5173\u8D44\u6599\uFF08\u4F18\u5148\u5B98\u65B9\u6587\u6863\uFF09\uFF0C\u5E76\u7ED9\u51FA\u4E2D\u6587\u6458\u8981\u4E0E\u6765\u6E90\u94FE\u63A5\u3002\u6211\u7684\u95EE\u9898\u662F\uFF1A`;
- case "search":
- return `\u8BF7\u4F7F\u7528 ${skillName ?? "search"} \u6280\u80FD\uFF1A\u5E2E\u6211\u5728\u5DE5\u4F5C\u533A\u4E2D\u67E5\u627E\u4EE3\u7801\u6216\u6587\u4EF6\u3002\u6211\u8981\u627E\u7684\u662F\uFF1A`;
- case "form-builder":
- return `\u8BF7\u4F7F\u7528 ${skillName ?? "form-builder"} \u6280\u80FD\uFF1A\u8BF7\u7528\u4EA4\u4E92\u5F0F\u8868\u5355\u6536\u96C6\u4EE5\u4E0B\u573A\u666F\u6240\u9700\u7684\u7ED3\u6784\u5316\u5B57\u6BB5\uFF08\u5B57\u6BB5\u4E0D\u8D85\u8FC7 8 \u4E2A\uFF09\uFF1A`;
- case "table-viewer":
- return `\u8BF7\u4F7F\u7528 ${skillName ?? "table-viewer"} \u6280\u80FD\uFF1A\u8BF7\u628A\u4EE5\u4E0B\u6570\u636E\u6574\u7406\u6210\u53EF\u6392\u5E8F\u3001\u53EF\u7B5B\u9009\u7684\u4EA4\u4E92\u5F0F\u8868\u683C\uFF1A`;
- case "product-campaign-page":
- return `\u8BF7\u4F7F\u7528 ${skillName ?? "product-campaign-page"} \u6280\u80FD\uFF1A\u5E2E\u6211\u5236\u4F5C\u4E00\u4E2A\u5546\u54C1\u5BA3\u4F20 / \u6D3B\u52A8\u9875\u3002\u8BF7\u5148\u8BE2\u95EE\u5546\u54C1\u94FE\u63A5\u3001\u9875\u9762\u4E3B\u9898\u3001\u662F\u5426\u6709\u5546\u54C1\u56FE\u7247\u6216\u54C1\u724C\u7D20\u6750\uFF1B\u4FE1\u606F\u8DB3\u591F\u540E\uFF0C\u751F\u6210\u5305\u542B\u8D2D\u4E70\u8DF3\u8F6C\u6309\u94AE\u7684\u9875\u9762\u65B9\u6848\u3002\u5982\u679C\u6211\u786E\u8BA4\u8981\u53D1\u5E03\u6210 H5 \u9875\u9762\uFF0C\u8BF7\u7EE7\u7EED\u4F7F\u7528 static-page-publish \u6280\u80FD\u751F\u6210\u53EF\u8BBF\u95EE\u94FE\u63A5\u3002`;
- case "summarize":
- return "\u8BF7\u603B\u7ED3\u4EE5\u4E0B\u5185\u5BB9\uFF0C\u63D0\u70BC\u6838\u5FC3\u7ED3\u8BBA\u3001\u91CD\u70B9\u4FE1\u606F\u548C\u53EF\u6267\u884C\u5EFA\u8BAE\uFF08\u6761\u7406\u6E05\u6670\u3001\u4E2D\u6587\u8F93\u51FA\uFF09\uFF1A";
- case "analyze":
- return "\u8BF7\u6DF1\u5165\u5206\u6790\u4EE5\u4E0B\u5185\u5BB9\uFF0C\u7ED9\u51FA\u7ED3\u6784\u5316\u89E3\u8BFB\uFF1A\u80CC\u666F\u3001\u5173\u952E\u53D1\u73B0\u3001\u98CE\u9669\u6216\u673A\u4F1A\u3001\u4EE5\u53CA\u5EFA\u8BAE\u4E0B\u4E00\u6B65\uFF1A";
- case "generate-page":
- return `\u8BF7\u4F7F\u7528 ${skillName ?? PUBLISH_SKILL_NAME2} \u6280\u80FD\uFF1A\u5728\u6211\u7684\u4E13\u5C5E MindSpace \u53D1\u5E03\u76EE\u5F55\u751F\u6210\u9759\u6001 HTML \u9875\u9762\uFF0C\u5E76\u7ED9\u51FA\u53EF\u516C\u7F51\u8BBF\u95EE\u7684\u5B8C\u6574\u94FE\u63A5\u3002\u5FC5\u987B\u5148 load_skill\uFF0C\u518D\u7528 write_file/edit_file \u5199\u5165 public/\u9875\u9762.html\uFF1B\u5B8C\u6210\u540E\u76F4\u63A5\u8FD4\u56DE Markdown \u53EF\u70B9\u51FB\u516C\u7F51\u94FE\u63A5 \`[\u9875\u9762\u6807\u9898](URL)\`\u3002\u7981\u6B62\u53EA\u7ED9\u672C\u5730\u8DEF\u5F84\uFF08\u5982 hello/index.html\uFF09\uFF0C\u7981\u6B62\u8BE2\u95EE\u662F\u5426\u8FD8\u8981\u53D1\u5E03\uFF0C\u9664\u975E\u5199\u6587\u4EF6\u5DE5\u5177\u5B9E\u9645\u5931\u8D25\u3002`;
- default:
- return "";
- }
-}
-function buildWebNewsSkillPrompt(skillName) {
- return `\u8BF7\u4F7F\u7528 ${skillName ?? "web"} \u6280\u80FD\uFF1A\u5148\u641C\u7D22\u4ECA\u5929/\u6700\u65B0\u76F8\u5173\u7684\u65B0\u95FB\u4E0E\u70ED\u70B9\uFF0C\u4F18\u5148\u4E00\u624B\u6765\u6E90\u548C\u6743\u5A01\u5A92\u4F53\uFF0C\u6574\u7406 3-5 \u6761\u6700\u76F8\u5173\u7ED3\u679C\uFF0C\u6309\u65F6\u95F4\u6216\u70ED\u5EA6\u6392\u5E8F\uFF1B\u7136\u540E\u7ED9\u51FA\u4E2D\u6587\u6458\u8981\u3001\u5173\u952E\u4FE1\u606F\u3001\u4E8B\u4EF6\u80CC\u666F\u548C\u6765\u6E90\u94FE\u63A5\u3002\u6211\u7684\u95EE\u9898\u662F\uFF1A`;
-}
-function stripKnownChatSkillPrompt(text) {
- let next = String(text ?? "").trimStart();
- const candidates = [];
- for (const definition of CHAT_SKILL_DEFINITIONS) {
- if (!definition.promptKey) continue;
- candidates.push(buildChatSkillPrompt(definition.promptKey, definition.skillName));
- }
- candidates.push(buildWebNewsSkillPrompt("web"));
- candidates.sort((a, b) => b.length - a.length);
- for (const prompt of candidates) {
- if (prompt && next.startsWith(prompt)) {
- next = next.slice(prompt.length);
- break;
- }
- }
- return next.trim();
-}
-
// conversation-display.mjs
var TASK_ROUTING_HINT_RE = /^【TKMind 路由提示】[\s\S]*?\n\n/;
var MINDSPACE_CONTEXT_RE = /^\[MindSpace 上下文\][\s\S]*?\n\n/;
@@ -33774,6 +34489,7 @@ var subscriptionService = null;
var wechatPayClient = null;
var wechatOAuthService = null;
var wechatMpService = null;
+var notificationDispatcher = null;
var scheduleService = null;
var feedbackService = null;
var scheduleReminderWorker = null;
@@ -34075,15 +34791,16 @@ async function bootstrapUserAuth() {
return tkmindProxy.apiFetchTo(target, pathname, init);
}) : null
});
- userAuth.setRechargeNotifier(async ({ userId, title, body }) => {
- if (!wechatMpService?.enabled) return;
- await wechatMpService.sendTextToUser(userId, `${title}
-${body}`.trim());
+ notificationDispatcher = createNotificationDispatcher({
+ sendWechatTextToUser: wechatMpService?.enabled ? (userId, text) => wechatMpService.sendTextToUser(userId, text) : null
+ });
+ userAuth.setRechargeNotifier(async ({ userId, title, body, dedupeKey }) => {
+ await notificationDispatcher.sendRechargeSuccess({ userId, title, body, dedupeKey });
});
if (process.env.H5_REMINDER_WORKER_ENABLED === "1" && wechatMpService?.enabled && scheduleService) {
scheduleReminderWorker = startScheduleReminderWorker({
scheduleService,
- sendWechatTextToUser: (userId, text) => wechatMpService.sendTextToUser(userId, text)
+ notificationDispatcher
});
console.log("Schedule reminder worker enabled");
}
@@ -35089,7 +35806,7 @@ var api = express2.Router();
api.use(jsonUnlessMultipart);
api.use(async (req, res, next) => {
await userAuthReady;
- if (req.path === "/status") return next();
+ if (req.path === "/status" || req.path === "/runtime/status") return next();
if (req.path.startsWith("/internal/agent/")) return next();
if (req.path === "/agent/mindspace_page_patch") return next();
if (req.path === "/agent/mindspace_asset_delete") return next();
@@ -35135,6 +35852,25 @@ api.get("/status", async (_req, res, next) => {
}
return next();
});
+api.get("/runtime/status", async (_req, res) => {
+ await userAuthReady;
+ if (!tkmindProxy) {
+ return res.status(503).json({ ok: false, message: "\u4F1A\u8BDD\u4EE3\u7406\u5C1A\u672A\u5C31\u7EEA" });
+ }
+ try {
+ const status = await tkmindProxy.getRuntimeStatus();
+ return res.json({
+ ok: true,
+ at: Date.now(),
+ ...status
+ });
+ } catch (err) {
+ return res.status(502).json({
+ ok: false,
+ message: err instanceof Error ? err.message : "\u8BFB\u53D6 runtime \u72B6\u6001\u5931\u8D25"
+ });
+ }
+});
async function ensureUserMemoryCapability(req, res) {
if (!userAuth) {
res.status(503).json({ message: "\u672A\u542F\u7528\u7528\u6237\u7CFB\u7EDF" });
@@ -37384,12 +38120,17 @@ api.get("/sessions/:sessionId/events", async (req, res, next) => {
messages = null;
}
}
- await syncPublicHtmlAfterFinish({
+ const syncResult = await syncPublicHtmlAfterFinish({
messages,
currentUser: req.currentUser,
publishDir,
syncWorkspaceAssets: WORKSPACE_MAINTENANCE_ENABLED && mindSpaceAssets ? (userId, options) => mindSpaceAssets.syncWorkspaceAssets(userId, options) : null
});
+ if (Array.isArray(syncResult?.docxSync?.missing) && syncResult.docxSync.missing.length > 0) {
+ console.warn(
+ `[MindSpace] missing public download files after finish for user ${uid}: ${syncResult.docxSync.missing.join(", ")}`
+ );
+ }
await syncUserGeneratedPages(uid);
};
return tkmindProxy.proxySessionEvents(req, res, sessionId, {
@@ -37510,6 +38251,13 @@ function injectPublicFileShareButton(html) {
function extractOgImageUrl(html) {
return String(html ?? "").match(/]+property=["']og:image["'][^>]+content=["']([^"']+)["']/i)?.[1] || String(html ?? "").match(/]+content=["']([^"']+)["'][^>]+property=["']og:image["']/i)?.[1] || "";
}
+function extractShareMetaFromPageHtml(html) {
+ const signals = extractCoverSignals(String(html ?? ""));
+ return {
+ title: detectPublishedPageTitle(html),
+ subtitle: signals.subtitle
+ };
+}
function appendQueryParam(url, key, value) {
const separator = url.includes("?") ? "&" : "?";
return `${url}${separator}${encodeURIComponent(key)}=${encodeURIComponent(value)}`;
@@ -37923,7 +38671,8 @@ function sendPublishedPage(req, res, result, { embed = false, raw = false } = {}
origin,
pageUrl,
pageDirUrl,
- fallbackImageUrl: extractOgImageUrl(html)
+ fallbackImageUrl: extractOgImageUrl(html),
+ meta: extractShareMetaFromPageHtml(html)
});
if (wechatShare) shellHtml = injectWechatShareBridge(shellHtml, { pageUrl });
} catch {
@@ -38380,6 +39129,46 @@ app.use(
"/plaza-covers",
express2.static(path30.join(__dirname5, "public/plaza-covers"), { maxAge: "7d" })
);
+app.use("/brand", express2.static(path30.join(__dirname5, "public/brand"), { maxAge: "7d" }));
+app.get("/dev/wechat-share-preview", async (req, res) => {
+ try {
+ const rawTarget = String(req.query.url ?? req.query.path ?? "").trim();
+ if (!rawTarget) {
+ return res.status(400).type("text/plain; charset=utf-8").send("\u7F3A\u5C11 url \u53C2\u6570\uFF0C\u4F8B\u5982 ?url=/MindSpace/john/public/demo.html");
+ }
+ const origin = resolveRequestOrigin(req) || `http://${HOST}:${PORT}`;
+ const targetUrl = rawTarget.startsWith("http") ? new URL(rawTarget) : new URL(rawTarget.startsWith("/") ? rawTarget : `/${rawTarget}`, origin);
+ const upstream = await fetch(targetUrl.toString(), {
+ headers: {
+ "user-agent": req.get("user-agent") || "tkmind-wechat-share-preview",
+ accept: "text/html,*/*"
+ },
+ redirect: "follow"
+ });
+ if (!upstream.ok) {
+ return res.status(upstream.status).type("text/plain; charset=utf-8").send(`\u9875\u9762\u8BF7\u6C42\u5931\u8D25\uFF1AHTTP ${upstream.status}`);
+ }
+ let html = await upstream.text();
+ const pageUrl = targetUrl.toString().split("#")[0];
+ const pageDirUrl = `${pageUrl.slice(0, pageUrl.lastIndexOf("/") + 1)}`;
+ html = injectOgTags(html, { origin: targetUrl.origin, pageUrl, pageDirUrl });
+ const preview = extractSharePreviewMeta(html, {
+ origin: targetUrl.origin,
+ pageUrl,
+ pageDirUrl
+ });
+ res.set("Content-Type", "text/html; charset=utf-8");
+ res.set("Cache-Control", "no-store");
+ return res.send(
+ renderWechatSharePreviewHtml(preview, {
+ note: "\u6B64\u9884\u89C8\u8BFB\u53D6\u9875\u9762\u6700\u7EC8 HTML \u4E2D\u7684 Open Graph \u6807\u7B7E\uFF0C\u53EF\u7528\u6765\u5BF9\u7167\u5FAE\u4FE1\u91CC\u7C98\u8D34\u94FE\u63A5\u540E\u7684\u5361\u7247\u6548\u679C\u3002"
+ })
+ );
+ } catch (err) {
+ return res.status(500).type("text/plain; charset=utf-8").send(String(err?.message ?? err));
+ }
+});
+app.use("/dev", express2.static(path30.join(__dirname5, "public/dev"), { maxAge: 0 }));
app.get(/^\/MP_verify_[A-Za-z0-9]+\.txt$/, (req, res) => {
const fileName = path30.basename(req.path);
const filePath = path30.join(__dirname5, "public", fileName);
diff --git a/deploy/nginx/mm.tkmind.cn.conf b/deploy/nginx/mm.tkmind.cn.conf
new file mode 100644
index 0000000..3bf326b
--- /dev/null
+++ b/deploy/nginx/mm.tkmind.cn.conf
@@ -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;
+ }
+}
diff --git a/docs/architecture/memind-2-streaming-agent-runtime-plan.md b/docs/architecture/memind-2-streaming-agent-runtime-plan.md
new file mode 100644
index 0000000..988cf74
--- /dev/null
+++ b/docs/architecture/memind-2-streaming-agent-runtime-plan.md
@@ -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//events` 和 `/api/agent/runs//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::worker`
+ - `memind:runtime:session::target`
+- SSE 打开/关闭维护 Redis active stream:
+ - `memind:runtime:worker::active_streams`
+ - `memind:runtime:worker::heartbeat`
+ - `memind:runtime:stream::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=`
+ - `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::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 默认关闭,通过用户策略逐步放量。
diff --git a/docs/architecture/remote-dev-sync.md b/docs/architecture/remote-dev-sync.md
new file mode 100644
index 0000000..7ff09cf
--- /dev/null
+++ b/docs/architecture/remote-dev-sync.md
@@ -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`。
diff --git a/scripts/build-portal-runtime.mjs b/scripts/build-portal-runtime.mjs
index b7b0325..6e036a1 100755
--- a/scripts/build-portal-runtime.mjs
+++ b/scripts/build-portal-runtime.mjs
@@ -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}"',
diff --git a/scripts/run-memind-portal-prod.sh b/scripts/run-memind-portal-prod.sh
index 5db5edb..5ef9b2d 100755
--- a/scripts/run-memind-portal-prod.sh
+++ b/scripts/run-memind-portal-prod.sh
@@ -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"
diff --git a/scripts/wechat-mp-menu.mjs b/scripts/wechat-mp-menu.mjs
index 187faa3..494c6e9 100755
--- a/scripts/wechat-mp-menu.mjs
+++ b/scripts/wechat-mp-menu.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',
diff --git a/server.mjs b/server.mjs
index d58d06d..59fc778 100644
--- a/server.mjs
+++ b/server.mjs
@@ -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: '未启用用户系统' });
diff --git a/tkmind-proxy.mjs b/tkmind-proxy.mjs
index a4508c7..24f638d 100644
--- a/tkmind-proxy.mjs
+++ b/tkmind-proxy.mjs
@@ -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),
diff --git a/tkmind-proxy.test.mjs b/tkmind-proxy.test.mjs
index 3373973..1097f53 100644
--- a/tkmind-proxy.test.mjs
+++ b/tkmind-proxy.test.mjs
@@ -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, /不得改写图片里人物的年龄、性别、人数或主体关系/);
diff --git a/user-publish.mjs b/user-publish.mjs
index e57366b..8749729 100644
--- a/user-publish.mjs
+++ b/user-publish.mjs
@@ -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 =