From 04e308e5829b8b52bb11ec4c02a73ab1db3fd61b Mon Sep 17 00:00:00 2001 From: John Date: Thu, 2 Jul 2026 06:55:26 +0800 Subject: [PATCH] feat: add streaming runtime router --- .env.example | 13 +- .runtime/portal/RUNBOOK.txt | 12 +- .../portal/scripts/run-memind-portal-prod.sh | 7 +- .runtime/portal/server.mjs | 1275 +++++++++++++---- deploy/nginx/mm.tkmind.cn.conf | 52 + .../memind-2-streaming-agent-runtime-plan.md | 439 ++++++ docs/architecture/remote-dev-sync.md | 82 ++ scripts/build-portal-runtime.mjs | 2 +- scripts/run-memind-portal-prod.sh | 12 +- scripts/wechat-mp-menu.mjs | 4 +- server.mjs | 20 + tkmind-proxy.mjs | 286 +++- tkmind-proxy.test.mjs | 4 +- user-publish.mjs | 2 +- 14 files changed, 1925 insertions(+), 285 deletions(-) create mode 100644 deploy/nginx/mm.tkmind.cn.conf create mode 100644 docs/architecture/memind-2-streaming-agent-runtime-plan.md create mode 100644 docs/architecture/remote-dev-sync.md 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}

` : ""} +
+ + ${siteName} +
+
+ ${imageUrl ? `` : `
\u65E0\u56FE
`} +
+
+
og:title ${title}
+
og:description ${description || "\uFF08\u7A7A\uFF09"}
+
og:site_name ${siteName}
+
og:image ${imageUrl || "\uFF08\u7A7A\uFF09"}
+
icon ${iconUrl}
+
+
+ +`; } // 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 =