Compare commits

...

9 Commits

Author SHA1 Message Date
john 753c1eec23 fix(scheduled-task): forward worker env to sandbox MCP
Memind CI / Test, build, and release guards (push) Successful in 6m3s
goosed stdio MCPs do not inherit Portal env, so scheduled_task_create
always reported worker disabled even when H5_REMINDER_WORKER_ENABLED=1.
Forward worker flags in sandboxMcpEnvs and add a regression test.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-10 20:49:36 +08:00
john 62fbea0936 docs: 登记 feature/md-admin-domain 分支闭环
Memind CI / Test, build, and release guards (push) Successful in 4m37s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-10 17:41:20 +08:00
john 871688550e docs(analytics): 管理后台域名改为 md.tkmind.cn
Memind CI / Test, build, and release guards (push) Has been cancelled
同步 Analytics 发布 runbook 与 admin 默认 PUBLIC_HOST,与 memind_adm 生产入口一致。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-10 17:38:31 +08:00
john d570856cbd docs: register feature/analytics-release-docs branch disposition
Memind CI / Test, build, and release guards (push) Failing after 1m46s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-10 16:33:19 +08:00
john 8348ce3625 docs: add analytics release runbook and 105 Umami topology
Memind CI / Test, build, and release guards (push) Has been cancelled
Cross-repo publish order for memind-analytics, Portal, and memind_adm;
update platform and 105 operations docs for systemd artifact deploy.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-10 16:21:57 +08:00
john 2799c808ee fix(analytics): guard missing request headers when reading referrer hint
Memind CI / Test, build, and release guards (push) Failing after 12m47s
Prevent publication delivery from crashing when test or proxy requests omit
headers while attaching SEO/GEO discovery metadata.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-10 14:26:24 +08:00
john bbc9ed5821 Merge branch 'feature/analytics-seo-geo-discovery'
Memind CI / Test, build, and release guards (push) Failing after 4m26s
2026-08-10 14:18:16 +08:00
john 6ba95c6229 fix(scheduled-task): reject meta-only phrases in taskSpec parsing
Memind CI / Test, build, and release guards (push) Successful in 7m31s
Treat hollow requests like「我要做一个定时执行任务」as missing taskSpec so
WeChat preflight asks for both schedule and executable content instead of
accepting「执行」as a deliverable task description.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-12 01:36:38 +08:00
john c122bd5664 feat(analytics): retire Rybbit and add SEO/GEO discovery tracking
Document Umami as the sole analytics platform, remove Rybbit proxy and
tracker code, and classify public page traffic with discovery_channel for
adm SEO/GEO dashboards.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-12 01:34:05 +08:00
36 changed files with 781 additions and 462 deletions
@@ -1,6 +1,6 @@
---
description: MindSpace 生成页埋点必须上报创建者 username,且不得用 owner_id identify 匿名访客
globs: mindspace-analytics.mjs,mindspace-rybbit.mjs,mindspace-analytics.test.mjs,mindspace-rybbit.test.mjs,server/portal-session-routes.mjs,server/portal-integration-services-bootstrap.mjs
globs: mindspace-analytics.mjs,mindspace-analytics-discovery.mjs,mindspace-analytics.test.mjs,mindspace-analytics-discovery.test.mjs,server/portal-session-routes.mjs,server/portal-integration-services-bootstrap.mjs
alwaysApply: false
---
@@ -10,7 +10,7 @@ Umami「创建用户」依赖 `event_data.username` / session `username`。路
## 必须
- 服务端 `page_generated``sendMindSpaceAnalyticsEvent` / `sendMindSpaceRybbitEvent`)的事件属性 **必须**包含 `username`(创建者展示名,来自 `resolveAnalyticsOwnerLabel`
- 服务端 `page_generated``sendMindSpaceAnalyticsEvent`)的事件属性 **必须**包含 `username`(创建者展示名,来自 `resolveAnalyticsOwnerLabel`
- 可同时保留 `owner_label` 作兼容,值与 `username` 相同
- Public HTML 内嵌脚本的 `metadata` **必须**继续带 `username`(给 scroll/click 等客户端事件)
@@ -22,5 +22,5 @@ Umami「创建用户」依赖 `event_data.username` / session `username`。路
## 改完必跑
```bash
node --test mindspace-analytics.test.mjs mindspace-rybbit.test.mjs
node --test mindspace-analytics.test.mjs mindspace-analytics-discovery.test.mjs
```
+5 -11
View File
@@ -18,8 +18,7 @@ H5_PORT=8081
# pnpm dev / dev-core 在未显式覆盖时会把 H5_PUBLIC_BASE_URL 设为 8081。
H5_PUBLIC_BASE_URL=http://127.0.0.1:5173
# Local Umami analytics (disabled by default; points only to the local
# memind-analytics service when enabled).
# 访问统计平台说明(Umami 唯一;Rybbit 已退役 2026-08)见 docs/analytics-platform.md
# MEMIND_ANALYTICS_ENABLED=true
# MEMIND_ANALYTICS_URL=http://127.0.0.1:3100
# MEMIND_ANALYTICS_WEBSITE_ID=<local Umami Website ID>
@@ -30,12 +29,7 @@ H5_PUBLIC_BASE_URL=http://127.0.0.1:5173
# MEMIND_PRODUCT_ANALYTICS_ENABLED=true
# MEMIND_PRODUCT_ANALYTICS_WEBSITE_ID=<local Umami product Website ID>
# Local Rybbit analytics (same-origin /rybbit proxy to rybbit.tkmind.cn).
# Create a Rybbit Site for localhost / 127.0.0.1, then set its numeric site_id.
# MEMIND_RYBBIT_ENABLED=true
# MEMIND_RYBBIT_URL=https://rybbit.tkmind.cn
# MEMIND_RYBBIT_SITE_ID=<numeric site id>
# MEMIND_RYBBIT_ID_SECRET=<local-only pseudonymization secret>
# 访问统计仅使用 UmamiRybbit 已退役,见 docs/analytics-platform.md
# 生产 H5 public base 当前临时切到 https://mm.tkmind.cn。
# 后续公网 H5 不再依赖 105 转发链路;m.tkmind.cn 仅作为 legacy/rollback 记录处理。
@@ -221,7 +215,7 @@ H5_PUBLIC_BASE_URL=http://127.0.0.1:5173
# 登录仍在主域完成;后台凭 H5_COOKIE_DOMAIN=.tkmind.cn 共享的会话 Cookie 鉴权(生产务必设置)。
ADMIN_PORT=8082
ADMIN_HOST=127.0.0.1
ADMIN_PUBLIC_HOST=gadm.tkmind.cn
ADMIN_PUBLIC_HOST=md.tkmind.cn
# 本进程挂载哪些控制台(可拆分架构):admin=平台超管, ops=广场运营。
# 默认两者合一;将来拆成两个进程时,A 实例设 admin、B 实例设 ops,路由代码无需改动。
@@ -229,9 +223,9 @@ ADMIN_PUBLIC_HOST=gadm.tkmind.cn
# 每控制台独立网络护栏(逗号分隔;留空=放开。支持精确 IP 与 CIDR)。
# 超管面建议收得最紧(仅内网/VPN),广场运营可放宽给审核团队网段。
# ADMIN_API_ALLOWED_HOSTS=gadm.tkmind.cn
# ADMIN_API_ALLOWED_HOSTS=md.tkmind.cn
# ADMIN_API_IP_ALLOWLIST=10.0.0.0/8,127.0.0.1
# OPS_API_ALLOWED_HOSTS=gadm.tkmind.cn
# OPS_API_ALLOWED_HOSTS=md.tkmind.cn
# OPS_API_IP_ALLOWLIST=
# 多用户系统(MySQL 配置后启用;未配置时回退到 H5_ACCESS_PASSWORD
+2 -1
View File
@@ -101,13 +101,14 @@ Cursor 额外加载:`.cursor/rules/mindspace-publish-chat-finish-guards.mdc`
## 仓库惯例
- 本地开发:`pnpm dev`(见 [README.md](README.md)
- 访问统计:**仅 Umami**Rybbit 已退役,见 [docs/analytics-platform.md](docs/analytics-platform.md)、[docs/local-analytics.md](docs/local-analytics.md)、**[docs/analytics-release-runbook.md](docs/analytics-release-runbook.md)**105 / 103 发布顺序)
- Page Data API:见 [docs/page-data-api-usage.md](docs/page-data-api-usage.md);改动相关路径后执行 `npm run verify:page-data`
- 生产隔离:[docs/service-isolation-runbook.md](docs/service-isolation-runbook.md)
- 发版须 Git commit,禁止本机直 `rsync``103/105`
## 必读:管理后台 UI 边界(memind_adm 5174
**平台管理后台的前端 UI 只能在独立仓库 `memind_adm`(本地 5174 / 生产 gadm)开发,禁止在 Memind 本仓库的 `ops/`(约 3002)新增管理功能。**
**平台管理后台的前端 UI 只能在独立仓库 `memind_adm`(本地 5174 / 生产 [md.tkmind.cn](https://md.tkmind.cn))开发,禁止在 Memind 本仓库的 `ops/`(约 3002)新增管理功能。**
| 层级 | 位置 | 说明 |
|------|------|------|
+7 -7
View File
@@ -15,10 +15,10 @@ test('parseList splits, trims, and drops empties', () => {
});
test('hostAllowed: empty list allows all, otherwise matches hostname ignoring port/case', () => {
assert.equal(hostAllowed('gadm.tkmind.cn:8082', []), true);
assert.equal(hostAllowed('GADM.tkmind.cn', ['gadm.tkmind.cn']), true);
assert.equal(hostAllowed('evil.example.com', ['gadm.tkmind.cn']), false);
assert.equal(hostAllowed('gadm.tkmind.cn:443', ['gadm.tkmind.cn', 'localhost']), true);
assert.equal(hostAllowed('md.tkmind.cn:8082', []), true);
assert.equal(hostAllowed('MD.tkmind.cn', ['md.tkmind.cn']), true);
assert.equal(hostAllowed('evil.example.com', ['md.tkmind.cn']), false);
assert.equal(hostAllowed('md.tkmind.cn:443', ['md.tkmind.cn', 'localhost']), true);
});
test('ipMatches: exact and IPv4-mapped IPv6', () => {
@@ -48,7 +48,7 @@ test('buildNetworkGuard returns null when nothing configured', () => {
test('buildNetworkGuard denies on host then ip, allows when both pass', () => {
const guard = buildNetworkGuard({
allowedHosts: ['gadm.tkmind.cn'],
allowedHosts: ['md.tkmind.cn'],
ipAllowlist: ['10.0.0.0/8'],
onDeny: (_res, _req, reason) => `deny:${reason}`,
});
@@ -60,8 +60,8 @@ test('buildNetworkGuard denies on host then ip, allows when both pass', () => {
};
assert.equal(guard(mk('evil.com', '10.1.1.1'), {}, next), 'deny:host');
assert.equal(guard(mk('gadm.tkmind.cn', '8.8.8.8'), {}, next), 'deny:ip');
assert.equal(guard(mk('md.tkmind.cn', '8.8.8.8'), {}, next), 'deny:ip');
assert.equal(nexted, false);
guard(mk('gadm.tkmind.cn', '10.1.1.1'), {}, next);
guard(mk('md.tkmind.cn', '10.1.1.1'), {}, next);
assert.equal(nexted, true);
});
+2 -2
View File
@@ -5,7 +5,7 @@
// Serves the platform super-admin API (/admin-api/*) and the plaza operations
// console API (/api/ops/v1/*) on its own port, isolated from the public
// user-facing server (server.mjs). In production this is fronted by
// gadm.tkmind.cn; in dev the ops SPA (:3002) proxies to it directly.
// md.tkmind.cn; in dev the ops SPA (:3002) proxies to it directly.
//
// It trusts the shared session cookie (H5_COOKIE_DOMAIN=.tkmind.cn), so login
// continues to happen on the main app domain — this process only reads the
@@ -43,7 +43,7 @@ loadEnvFile(path.join(__dirname, '.env'));
const PORT = Number(process.env.ADMIN_PORT ?? 8082);
const HOST = process.env.ADMIN_HOST ?? '127.0.0.1';
const PUBLIC_HOST = process.env.ADMIN_PUBLIC_HOST ?? 'gadm.tkmind.cn';
const PUBLIC_HOST = process.env.ADMIN_PUBLIC_HOST ?? 'md.tkmind.cn';
const getToken = (req) => parseCookies(req.get('cookie'))[USER_COOKIE];
+8
View File
@@ -606,6 +606,14 @@ function sandboxMcpEnvs(sandboxMcp, mcpTools) {
envs[key] = String(value).trim();
}
}
// goosed stdio MCPs do not inherit Portal env; forward worker flags so
// scheduled_task_create can report the same enablement as server.mjs.
for (const key of ['H5_REMINDER_WORKER_ENABLED', 'H5_SCHEDULED_TASK_WORKER_ENABLED']) {
const value = process.env[key];
if (value != null && String(value).trim() !== '') {
envs[key] = String(value).trim();
}
}
if (mcpTools.includes('generate_image')) {
if (sandboxMcp.agentApiBaseUrl) {
envs.MINDSPACE_AGENT_API_BASE_URL = sandboxMcp.agentApiBaseUrl;
+25
View File
@@ -507,6 +507,31 @@ test('static_publish with sandboxMcp uses stdio sandbox-fs extension instead of
assert.ok(!allTools.includes('shell'), 'shell should not be exposed');
});
test('sandboxMcpEnvs forwards scheduled task worker flags from portal env', () => {
const previousReminder = process.env.H5_REMINDER_WORKER_ENABLED;
const previousScheduled = process.env.H5_SCHEDULED_TASK_WORKER_ENABLED;
process.env.H5_REMINDER_WORKER_ENABLED = '1';
delete process.env.H5_SCHEDULED_TASK_WORKER_ENABLED;
try {
const caps = { ...DEFAULT_USER_CAPABILITIES, static_publish: true };
const policy = buildAgentExtensionPolicy(caps, {
sandboxMcp: {
serverPath: '/opt/portal/mindspace-sandbox-mcp.mjs',
sandboxRoot: '/opt/h5/MindSpace/abc123',
},
});
const sandboxExt = policy.extensionOverrides.find((ext) => ext.name === 'sandbox-fs');
assert.ok(sandboxExt);
assert.equal(sandboxExt.envs.H5_REMINDER_WORKER_ENABLED, '1');
assert.equal(sandboxExt.envs.H5_SCHEDULED_TASK_WORKER_ENABLED, undefined);
} finally {
if (previousReminder === undefined) delete process.env.H5_REMINDER_WORKER_ENABLED;
else process.env.H5_REMINDER_WORKER_ENABLED = previousReminder;
if (previousScheduled === undefined) delete process.env.H5_SCHEDULED_TASK_WORKER_ENABLED;
else process.env.H5_SCHEDULED_TASK_WORKER_ENABLED = previousScheduled;
}
});
test('sandboxMcp emits a scoped logical workspace token only for a bound session', () => {
const secret =
'mindspace-mcp-policy-secret-1234';
+25
View File
@@ -11,6 +11,7 @@
| 定位 | 云侧入口 / 代理 / 历史链路,不承担可写业务数据主存储 |
| H5 运行目录 | `/root/tkmind_go/ui/h5` |
| Plaza 运行目录 | `/root/tkmind_go/ui/plaza` |
| **Umami Analytics** | `/opt/memind-analytics`systemd + 原生 artifact,见下节) |
| 数据真身 | 在 `103`MindSpace、users、data 等),见 [103/105 升级记录](103-105-upgrade-runbook-2026-06-26.md) |
本地开发仓库是 `/Users/john/Project/Memind`;生产 runtime 发布目标是 `103`
@@ -56,6 +57,29 @@
**不要**使用已停用的 `rsync_to_server.sh` 或直接 rsync 到 `105` 作为常规发布手段(见 `PRODUCTION_RELEASE_RULES.md`)。
## 105 Umami Analytics`/opt/memind-analytics`
Memind 访问统计(Umami)独立部署在 105**不是** Portal H5 目录的一部分。
| 项 | 说明 |
|----|------|
| 公网 | `https://fxi.tkmind.cn` |
| 进程 | `systemctl status memind-analytics-umami` |
| 数据库 | Docker Compose 仅 `db``127.0.0.1:55432` |
| 发布 | 在 **memind-analytics** 仓库执行 `bash scripts/release-umami-105.sh --yes` |
| 文档 | memind-analytics `deploy/105-runbook.md`Memind [analytics-release-runbook.md](analytics-release-runbook.md) |
**禁止** SSH 到 105 手改 `/opt/memind-analytics/live` 内文件或 overlay;变更必须来自 memind-analytics Git 发布。
验收:
```bash
ssh root@120.26.184.105 '
systemctl is-active memind-analytics-umami
curl -sf http://127.0.0.1:3100/api/heartbeat
'
```
## 105 Portal runtime 发布
`105` 上当前 H5 由 `systemd` 服务 `goose-h5` 运行,真实目录是:
@@ -196,5 +220,6 @@ ssh root@120.26.184.105 "sed -i \"s/M广场/M发现/\" /root/tkmind_go/ui/h5/scr
## 相关文档
- [生产 / 测试 / 预览隔离规程](service-isolation-runbook.md)
- [Analytics / SEO·GEO 发布 Runbook](analytics-release-runbook.md)
- [Portal 无源码迁移说明](no-source-portal-migration.md)
- [103/105 一次性升级实施记录](103-105-upgrade-runbook-2026-06-26.md)
+50
View File
@@ -0,0 +1,50 @@
# Memind 访问统计平台说明
## 当前唯一平台:Umami
Memind 公开页与生成页的真实用户访问统计 **仅使用 Umami**`memind-analytics` 独立部署,生产入口 `fxi.tkmind.cn`)。
- Portal 通过同源 `/analytics/*` 代理 Umami tracker
- 管理后台(memind_adm)通过 SSO 进入 Umami,并提供 **SEO / GEO 发现流量** 专用看板
- 生成页事件维度见 `mindspace-analytics.mjs`(含 `discovery_channel` / `discovery_source`
本地配置见 [local-analytics.md](local-analytics.md)。
## 已退役:Rybbit
**Rybbit 已于 2026-08 下线,不再部署、不再注入 tracker、不再提供 SSO。**
历史背景:Rybbit 曾作为 Umami 并行的行为分析方案(`/rybbit/*` 代理、`MEMIND_RYBBIT_*` 环境变量、memind_adm Rybbit SSO)。该方案已废弃。
**禁止**在新代码、文档、环境变量示例或运维脚本中重新引入 Rybbit 相关配置。若看到遗留引用,应删除而非「可选保留」。
## SEO / GEO 流量口径
| 字段 | 含义 |
|------|------|
| `discovery_channel` | `seo`(搜索引擎)/ `geo`(生成式引擎)/ `direct` / `referral` |
| `discovery_source` | 具体来源,如 `google``baidu``chatgpt``perplexity` |
| `referrer_host` | 外部 referrer 域名(不含路径) |
分类逻辑见 `mindspace-analytics-discovery.mjs`。Shell + iframe 场景会通过 `memind_discovery` 查询参数向内层传播来源。
## 相关仓库
| 仓库 | 职责 |
|------|------|
| **Memind** | tracker 注入、discovery 分类、Umami 代理 |
| **memind-analytics** | Umami 定制部署、`memind-pages` APIoverlay 在 `deploy/umami/overlay/` |
| **memind_adm** | Analytics 配置、SEO/GEO 看板、Umami SSO |
## 生产发布
跨仓库顺序与命令见 **[analytics-release-runbook.md](analytics-release-runbook.md)**。
| 步骤 | 仓库 | 命令 |
|------|------|------|
| 1 | memind-analytics | `bash scripts/release-umami-105.sh --yes` |
| 2 | Memind103 Portal | `bash scripts/release-portal-runtime-prod.sh --yes` |
| 3 | memind_adm103 · md.tkmind.cn | `bash scripts/release-prod.sh --yes` |
105 Umami 细节见 memind-analytics 仓库 `deploy/105-runbook.md`
md.tkmind.cn Analytics 环境变量见 memind_adm `docs/analytics-deploy.md`
+131
View File
@@ -0,0 +1,131 @@
# Analytics / SEO·GEO 生产发布 Runbook
跨仓库发布顺序与命令速查。统计平台说明见 [analytics-platform.md](analytics-platform.md)。
## 架构(2026-08 起)
```text
公开页 / H5
-> Memind /analytics/* 代理
-> 105 Umamisystemd + 原生 artifact127.0.0.1:3100
-> PostgreSQL(仅 db 容器,127.0.0.1:55432
memind_adm SEO/GEO 看板
-> /admin-api/analytics/seo-geo-pages
-> 105 Umami memind-pages APIoverlay
```
**已废弃:** Rybbit105 上 Docker 编译 Umami 镜像;Docker 跑 Umami 应用容器(`docker-umami` profile 仅遗留)。
## 推荐发布顺序
| 顺序 | 仓库 | 何时必须发 | 命令 |
|------|------|------------|------|
| 1 | **memind-analytics** | 改了 `deploy/umami/overlay/``memind-pages` API | `bash scripts/release-umami-105.sh --yes` |
| 2 | **Memind** | 改了 discovery 埋点、Umami 代理、公开页 delivery | `bash scripts/release-portal-runtime-prod.sh` |
| 3 | **memind_adm** | 改了 SEO/GEO 看板、Analytics 配置页、Umami 代理 | `bash scripts/release-prod.sh --yes` |
只改 overlay / API 时,发 **105** 即可;只改 adm 看板 UI 时,发 **103 md.tkmind.cnmemind_adm** 即可。
Memind discovery 字段(`discovery_channel` 等)未上线前,adm 看板可能暂无 SEO/GEO 分列数据。
## 1. 105 Umamimemind-analytics
工作目录:本机 `memind-analytics` 仓库(或 worktree `memind-analytics-release-main`)。
```bash
bash scripts/release-umami-105.sh --yes
```
脚本会:
1. 打包部署文件上传到 `105:/opt/memind-analytics/incoming/`
2. **在 105 原生** `pnpm install`(复用 `node_modules` 缓存)+ `pnpm run build-docker`
3. 产出 artifact → `/opt/memind-analytics/releases/umami-<release_id>/`
4. `backup/backup.sh` 备份 PostgreSQL
5. 切换 `/opt/memind-analytics/live` symlink`systemctl restart memind-analytics-umami`
### 监控(SSH 断开不影响构建)
```bash
ssh root@120.26.184.105 tail -f /opt/memind-analytics/.build/build-<release_id>.log
# 或
ssh root@120.26.184.105 tail -f /opt/memind-analytics/.build/release-<release_id>.log
```
### 验收
```bash
ssh root@120.26.184.105 '
systemctl is-active memind-analytics-umami
curl -sf http://127.0.0.1:3100/api/heartbeat
readlink /opt/memind-analytics/live
'
```
公网:`https://fxi.tkmind.cn/api/heartbeat`
详细路径、回滚与 nginx 见 **memind-analytics** 仓库 [`deploy/105-runbook.md`](https://git.tkmind.cn/tkmind/memind-analytics/src/branch/main/deploy/105-runbook.md)。
## 2. 103 PortalMemind
```bash
cd /Users/john/Project/Memind
bash scripts/check-release-ready.sh # 发布闸门
bash scripts/release-portal-runtime-prod.sh --yes
```
与 Analytics 相关的 Memind 改动通常包括:
- `mindspace-analytics-discovery.mjs` — SEO/GEO 分类
- `mindspace-analytics.mjs` — 事件维度
- Portal `/analytics/*` 反代
本地环境变量见 [local-analytics.md](local-analytics.md)。
## 3. 103 管理后台(memind_adm
```bash
cd /Users/john/Project/memind_adm
bash scripts/release-prod.sh --yes
```
### 生产 `.env`Analytics 相关)
```env
# SEO/GEO 看板拉 Umami memind-pages API 所需(105 SSO-only 时用 MEMIND_UMAMI_SSO_SECRET
MEMIND_UMAMI_SSO_SECRET=<与 105 .env 一致>
UMAMI_SSO_USERNAME=admin
UMAMI_URL=https://fxi.tkmind.cn # 可选,默认 http://127.0.0.1:3100
UMAMI_WEBSITE_ID=<Memind 公共页面 Website ID>
H5_PUBLIC_BASE_URL=https://md.tkmind.cn
```
103 需能访问 105 Umami(内网或配置的 `UMAMI_URL`)。
### 验收
1. [md.tkmind.cn/analytics](https://md.tkmind.cn/analytics) → **Analytics 配置** → 「打开 Umami 分析后台」可 SSO 登录
2. [md.tkmind.cn/analytics/seo-geo](https://md.tkmind.cn/analytics/seo-geo) → **SEO / GEO 流量** → 表格加载无 SSO/凭证错误
3. 抽样对比 Umami「所有生成页面明细」与 adm 看板列口径一致
详见 memind_adm [`docs/analytics-deploy.md`](../memind_adm/docs/analytics-deploy.md)。
## 常见问题
| 现象 | 处理 |
|------|------|
| 105 构建 `check-db` 失败 | 应使用 `build-docker`(已写入 `build-umami-artifact.sh`),勿改回 `pnpm build` |
| `Umami database is not running` | 使用 `backup/backup.sh`,不要用根目录旧版 `backup.sh` |
| 105 构建很慢 | 首次 install+compile 约 2040 分钟;后续 overlay 小改可复用 `node_modules` |
| adm SEO/GEO 看板空 | 确认 Memind discovery 已发布且有真实 SEO/GEO 流量 |
| Rybbit 相关报错 | 已退役,删除遗留 env/文档引用,见 [analytics-platform.md](analytics-platform.md) |
## 相关文档
| 文档 | 内容 |
|------|------|
| [analytics-platform.md](analytics-platform.md) | 平台边界、字段口径、Rybbit 退役 |
| [local-analytics.md](local-analytics.md) | 本地 Umami 与 Memind env |
| [105-server-operations.md](105-server-operations.md) | 105 运维边界(含 `/opt/memind-analytics` |
| memind-analytics `README.md` | Umami 仓库运维与 overlay |
| memind_adm `docs/analytics-deploy.md` | md.tkmind.cn Analytics / SEO·GEO 配置 |
+55 -1
View File
@@ -13,10 +13,39 @@
- 远端功能分支默认在闭环后删除;发布和审计依据使用 `main` commit、tag、CI 记录或 runtime manifest,不使用功能分支名。
- 这类保留分支必须在本文件登记清楚:分支名、最终 HEAD、进入 `origin/main` 的提交、验证摘要,以及“禁止再次引用”的状态。
## `feature/md-admin-domain`Memind + memind_adm
**状态:禁止再次引用。改动已提交并进入 `origin/main`,该分支保留仅用于只读追溯,不是待合并开发分支。**
审计日期:2026-08-11
分支 HEAD`8716885`Memind)、`0da7f04`memind_adm
`origin/main` 对应提交:同上
### 原始用途
- 管理后台公网域名从 `gadm.tkmind.cn` 更正为 `md.tkmind.cn`
- 文档与 nginx 示例明确 **105 反代 → 103 memind_adm**5174/8085
- memind_adm 默认 `VITE_BASE_PATH=/`,使 `/analytics/seo-geo` 在根路径可访问
### 生产发布
| 仓库 | release_id |
|------|------------|
| memind_adm | `20260810-173922-0da7f04` |
| 105 nginx | `md.tkmind.cn.conf` 已 reload |
### 验证摘要
- `https://md.tkmind.cn/analytics/seo-geo` → 200
- 103 `8085/health` → ok5174 `base=/`
- `/ops/analytics/seo-geo` → 301 到 `/analytics/seo-geo`
## `feature/fix-rybbit-publication-tracking`
**状态:禁止再次引用。改动已提交并进入 `origin/main`,该分支保留仅用于只读追溯,不是待合并开发分支。**
> **2026-08 补充**:该分支引入的 Rybbit tracking 已于 2026-08 全量下线并自代码库移除;当前访问统计 **仅 Umami**,见 [analytics-platform.md](analytics-platform.md)。勿再从本分支恢复 Rybbit 相关实现。
审计日期:2026-07-27
分支 HEAD`90ee4c3`
`origin/main` 对应提交:`90ee4c3`
@@ -40,7 +69,7 @@
本分支闭环前已通过:
- `node --test mindspace-rybbit.test.mjs server/portal-domain-services-bootstrap.test.mjs server/portal-published-page-delivery.test.mjs server/portal-publication-shell.test.mjs`
- `node --test mindspace-analytics.test.mjs mindspace-analytics-discovery.test.mjs server/portal-domain-services-bootstrap.test.mjs server/portal-published-page-delivery.test.mjs server/portal-publication-shell.test.mjs`(历史验证含 `mindspace-rybbit.test.mjs`Rybbit 已移除)
- `npm run verify:chat-finish-sync`
- `npm run verify:mindspace-publish-guards`
- `npm run verify:mindspace-publish-guards:full`
@@ -452,3 +481,28 @@ Portal,避免在线修改稳定 `.env`,并确保稳定 8081 与其他用户
- 保留本地分支名用于审计追溯。
- **不要** merge、cherry-pick 或从该分支构建 runtime/artifact。
- 103 生产依据 `44121df` / release manifest `20260805-095542-44121df`
## `feature/analytics-release-docs`
**状态:禁止再次引用。改动已提交并进入 `origin/main`,该分支保留仅用于只读追溯。**
审计日期:2026-08-11
分支 HEAD`8348ce3`
`origin/main` 对应提交:`8348ce3`
### 原始用途
补充 Analytics / SEO·GEO 跨仓库发布文档:105 Umami 原生 artifact + systemd 拓扑、发布顺序、Memind 与 memind_adm 链接。
### 验证摘要
- 文档-only;无 runtime 变更
- memind-analytics 文档已 push 至 `main` @ `24f67b0`
- memind_adm 文档已 merge 至 `main` @ `a67d5a1`
### 最终处置
- 保留本地分支名用于审计追溯。
- **不要** merge、cherry-pick 或从该分支继续开发。
- 后续 Analytics 发布以 `docs/analytics-release-runbook.md` 为准。
+17 -55
View File
@@ -1,79 +1,41 @@
# Local Memind analytics (Umami + Rybbit)
# Local Memind analytics (Umami)
## Umami (optional)
> **Rybbit 已退役**,见 [analytics-platform.md](analytics-platform.md)。下文仅描述 Umami。
Memind can still proxy `/analytics/*` to a local Umami service at
`http://127.0.0.1:3100`.
Memind 通过同源 `/analytics/*` 代理本地或远程 Umami 服务(默认 `http://127.0.0.1:3100`)。
```dotenv
MEMIND_ANALYTICS_ENABLED=true
MEMIND_ANALYTICS_URL=http://127.0.0.1:3100
MEMIND_ANALYTICS_WEBSITE_ID=<website-id>
MEMIND_ANALYTICS_ID_SECRET=<random-local-secret>
MEMIND_ANALYTICS_IDENTITY_MODE=raw
MEMIND_ANALYTICS_IDENTITY_MODE=pseudonymous
MEMIND_ANALYTICS_DOMAINS=127.0.0.1,localhost
```
`MEMIND_ANALYTICS_IDENTITY_MODE=raw` sends the stable Memind user ID to an
explicitly approved first-party analytics service. Omit it elsewhere to keep the
pseudonymous default.
`MEMIND_ANALYTICS_IDENTITY_MODE=raw` 仅在明确批准的第一方分析环境中使用;其他环境保持 pseudonymous 默认。
Create a second Umami Website for the Memind product shell. Keeping it separate
prevents chat, MindSpace, and feedback navigation from inflating generated-page
views. Do not create a Website per page or per user.
## Product shell(独立 Website
为 Memind 产品壳(H5 SPA)创建第二个 Umami Website,避免聊天与导航事件污染生成页 PV:
```dotenv
MEMIND_PRODUCT_ANALYTICS_ENABLED=true
MEMIND_PRODUCT_ANALYTICS_WEBSITE_ID=<product-website-id>
```
The product shell loads its tracker from the same-origin
`/analytics/script.js` endpoint. It records one standard page view per SPA route
transition, `product_click` for links/buttons, and 10/30-second route engagement
events. Query strings are restricted to a small allowlist and click labels never
copy arbitrary chat or generated-page text. Authenticated product events use the
same configured identity as generated pages while remaining in the separate
product Website.
产品壳 tracker 同样走 `/analytics/script.js`,记录 SPA 路由 PV、`product_click` 与 10/30 秒参与事件。
## Rybbit (recommended for behavior analytics)
## SEO / GEO 发现流量
Rybbit runs on 105 as `https://rybbit.tkmind.cn`. Local Memind does not talk to
103 for analytics. It proxies same-origin `/rybbit/*` to Rybbit `/api/*` so the
browser script stays first-party:
公开页 Umami 事件附带 `discovery_channel``seo` / `geo` / `direct` / `referral`)与 `discovery_source`。memind_adm **Analytics → SEO / GEO 流量** 看板按页展示 SEO 与 GEO 来源的浏览明细(口径与 Umami「生成页明细」一致)。
```text
Generated Page
-> /rybbit/script.js -> https://rybbit.tkmind.cn/api/script.js
-> /rybbit/track -> https://rybbit.tkmind.cn/api/track
```
## 管理后台
1. In Rybbit, create one Site for the local host you use (`127.0.0.1` or
`localhost`). Do not create a Site per page or per user.
2. Put the numeric Site ID and a local pseudonymization secret in Memind `.env`:
- **memind_adm → Analytics 配置**Umami Website ID、密钥、SSO 入口
- **memind_adm → SEO / GEO 流量**:按页监控搜索引擎 / 生成式引擎来源访问
```dotenv
MEMIND_RYBBIT_ENABLED=true
MEMIND_RYBBIT_URL=https://rybbit.tkmind.cn
MEMIND_RYBBIT_SITE_ID=2
MEMIND_RYBBIT_ID_SECRET=<random-local-secret>
```
生产发布与环境变量见 memind_adm [`docs/analytics-deploy.md`](../../memind_adm/docs/analytics-deploy.md)。
跨仓库发布顺序见 [analytics-release-runbook.md](analytics-release-runbook.md)。
If `MEMIND_RYBBIT_ID_SECRET` is omitted, Portal bootstrap reuses the
MindSpace analytics secret stored in `mindspace_config` (the same value
configured below in memind_adm → Analytics 配置).
3. Restart the local Memind server. Full generated HTML pages receive a
same-origin `/rybbit/script.js` tracker. Publication routes under
`/u/:owner/pages/:slug` and `/MindSpace/:user/public/...` both receive
the tracker. The tracker identifies the visitor
with a stable pseudonymous owner ID, then records engagement events such as
`page_click`, scroll depth, and dwell time. Initial pageviews come from
Rybbit's site setting `trackInitialPageView`.
4. Open Rybbit from local `memind_adm` → Analytics 配置 →「打开 Rybbit 分析后台」
(SSO). That path requires matching `MEMIND_RYBBIT_SSO_SECRET` /
`RYBBIT_SSO_EMAIL` with the 105 Rybbit deployment.
Both integrations are fail-open: missing configuration or a down analytics
service leaves page generation and page delivery unchanged. Do not expose the
Rybbit or Umami dashboards directly to end users.
Analytics 服务 fail-open:未配置或 Umami 不可用时,页面生成与交付不受影响。勿向终端用户暴露 Umami 控制台。
+2
View File
@@ -21,6 +21,8 @@
- `105``103` 的关键差异是:
`105` 为 Linux 环境,**不要携带本机 Mac 的 `node_modules`**;应在 `105` 切换前本机执行 `npm install --omit=dev --no-package-lock`
Umami Analytics105 `/opt/memind-analytics`)是**独立发布链**,不走 Portal runtime 脚本。见 [analytics-release-runbook.md](analytics-release-runbook.md)。
## 唯一入口
```bash
+177
View File
@@ -0,0 +1,177 @@
export const DISCOVERY_QUERY_PARAM = 'memind_discovery';
export const DISCOVERY_SOURCE_QUERY_PARAM = 'memind_discovery_source';
const SEO_HOST_RULES = [
{ source: 'google', hosts: ['google.com', 'google.com.hk', 'google.co.jp', 'google.co.uk', 'google.ca', 'google.com.tw'] },
{ source: 'baidu', hosts: ['baidu.com'] },
{ source: 'bing', hosts: ['bing.com'] },
{ source: 'sogou', hosts: ['sogou.com'] },
{ source: 'so360', hosts: ['so.com'] },
{ source: 'sm', hosts: ['sm.cn'] },
{ source: 'yandex', hosts: ['yandex.ru', 'yandex.com', 'yandex.by'] },
{ source: 'duckduckgo', hosts: ['duckduckgo.com'] },
{ source: 'yahoo', hosts: ['search.yahoo.com', 'yahoo.com'] },
{ source: 'naver', hosts: ['search.naver.com', 'naver.com'] },
{ source: 'ecosia', hosts: ['ecosia.org'] },
{ source: 'brave', hosts: ['search.brave.com'] },
];
const GEO_HOST_RULES = [
{ source: 'chatgpt', hosts: ['chatgpt.com', 'chat.openai.com'] },
{ source: 'openai', hosts: ['openai.com'] },
{ source: 'perplexity', hosts: ['perplexity.ai'] },
{ source: 'claude', hosts: ['claude.ai', 'anthropic.com'] },
{ source: 'gemini', hosts: ['gemini.google.com'] },
{ source: 'copilot', hosts: ['copilot.microsoft.com'] },
{ source: 'you', hosts: ['you.com'] },
{ source: 'phind', hosts: ['phind.com'] },
{ source: 'kagi', hosts: ['kagi.com'] },
{ source: 'poe', hosts: ['poe.com'] },
{ source: 'meta_ai', hosts: ['meta.ai'] },
{ source: 'doubao', hosts: ['doubao.com'] },
{ source: 'tongyi', hosts: ['tongyi.aliyun.com', 'qianwen.aliyun.com'] },
{ source: 'deepseek', hosts: ['chat.deepseek.com', 'deepseek.com'] },
];
function normalizeHostname(value) {
return String(value ?? '').trim().toLowerCase().replace(/^www\./, '').slice(0, 255);
}
function parseReferrerHost(referrer) {
const raw = String(referrer ?? '').trim();
if (!raw) return '';
try {
return normalizeHostname(new URL(raw).hostname);
} catch {
return '';
}
}
function matchHostRule(hostname, rules = []) {
const host = normalizeHostname(hostname);
if (!host) return '';
for (const rule of rules) {
for (const candidate of rule.hosts) {
const normalized = normalizeHostname(candidate);
if (!normalized) continue;
if (host === normalized || host.endsWith(`.${normalized}`)) {
return rule.source;
}
}
}
return '';
}
function normalizeDiscoveryChannel(value) {
const channel = String(value ?? '').trim().toLowerCase();
return channel === 'seo' || channel === 'geo' ? channel : '';
}
function classifyByUtm(utmSource = '', utmMedium = '') {
const source = String(utmSource ?? '').trim().toLowerCase();
const medium = String(utmMedium ?? '').trim().toLowerCase();
if (!source && !medium) return null;
if (medium === 'organic' || /seo|search|sem/.test(source)) {
return { discovery_channel: 'seo', discovery_source: source || 'utm' };
}
if (/geo|ai|llm|gpt|copilot|perplexity|chatgpt/.test(source)) {
return { discovery_channel: 'geo', discovery_source: source || 'utm' };
}
return null;
}
export function classifyDiscoveryTrafficSource({
referrer = '',
utmSource = '',
utmMedium = '',
discoveryChannel = '',
discoverySource = '',
pageHost = '',
} = {}) {
const normalizedChannel = normalizeDiscoveryChannel(discoveryChannel);
if (normalizedChannel) {
const source = String(discoverySource ?? '').trim().toLowerCase().slice(0, 32) || 'other';
return {
discovery_channel: normalizedChannel,
discovery_source: source,
referrer_host: parseReferrerHost(referrer),
};
}
const utmMatch = classifyByUtm(utmSource, utmMedium);
const referrerHost = parseReferrerHost(referrer);
const currentHost = normalizeHostname(pageHost);
const externalHost =
referrerHost && (!currentHost || referrerHost !== currentHost)
? referrerHost
: '';
if (utmMatch) {
return {
discovery_channel: utmMatch.discovery_channel,
discovery_source: utmMatch.discovery_source,
referrer_host: externalHost,
};
}
const geoSource = matchHostRule(externalHost, GEO_HOST_RULES);
if (geoSource) {
return {
discovery_channel: 'geo',
discovery_source: geoSource,
referrer_host: externalHost,
};
}
const seoSource = matchHostRule(externalHost, SEO_HOST_RULES);
if (seoSource) {
return {
discovery_channel: 'seo',
discovery_source: seoSource,
referrer_host: externalHost,
};
}
if (externalHost) {
return {
discovery_channel: 'referral',
discovery_source: 'other',
referrer_host: externalHost,
};
}
return {
discovery_channel: 'direct',
discovery_source: 'direct',
referrer_host: '',
};
}
export function appendDiscoveryPropagationParams(url, discovery = {}) {
const channel = normalizeDiscoveryChannel(discovery.discovery_channel);
if (!channel || !url) return String(url ?? '');
let next = String(url);
const joiner = next.includes('?') ? '&' : '?';
next += `${joiner}${encodeURIComponent(DISCOVERY_QUERY_PARAM)}=${encodeURIComponent(channel)}`;
const source = String(discovery.discovery_source ?? '').trim().toLowerCase();
if (source && source !== 'direct' && source !== 'other') {
next += `&${encodeURIComponent(DISCOVERY_SOURCE_QUERY_PARAM)}=${encodeURIComponent(source.slice(0, 32))}`;
}
return next;
}
export function buildInlineDiscoveryResolverScript(referrerHint = '') {
const rules = {
seo: SEO_HOST_RULES,
geo: GEO_HOST_RULES,
};
const serializedRules = JSON.stringify(rules)
.replaceAll('<', '\\u003c')
.replaceAll('>', '\\u003e')
.replaceAll('&', '\\u0026');
const serializedHint = JSON.stringify(String(referrerHint ?? '').slice(0, 512))
.replaceAll('<', '\\u003c')
.replaceAll('>', '\\u003e')
.replaceAll('&', '\\u0026');
return `(function(){var R=${serializedRules},H=${serializedHint};function norm(v){return String(v||"").trim().toLowerCase().replace(/^www\\./,"").slice(0,255)}function hostFrom(u){try{return norm(new URL(String(u||"")).hostname)}catch(e){return""}}function matchHost(h,rules){h=norm(h);if(!h)return"";for(var i=0;i<rules.length;i++){var rule=rules[i];for(var j=0;j<rule.hosts.length;j++){var c=norm(rule.hosts[j]);if(h===c||h.endsWith("."+c))return rule.source}}return""}function byUtm(s,m){s=String(s||"").toLowerCase();m=String(m||"").toLowerCase();if(!s&&!m)return null;if(m==="organic"||/seo|search|sem/.test(s))return{discovery_channel:"seo",discovery_source:s||"utm",referrer_host:""};if(/geo|ai|llm|gpt|copilot|perplexity|chatgpt/.test(s))return{discovery_channel:"geo",discovery_source:s||"utm",referrer_host:""};return null}return function(){var p=new URLSearchParams(location.search),c=String(p.get("${DISCOVERY_QUERY_PARAM}")||"").toLowerCase(),s=String(p.get("${DISCOVERY_SOURCE_QUERY_PARAM}")||"").toLowerCase().slice(0,32);if(c==="seo"||c==="geo")return{discovery_channel:c,discovery_source:s||"other",referrer_host:""};var utm=byUtm(p.get("utm_source"),p.get("utm_medium"));if(utm){var refHost=hostFrom(document.referrer)||hostFrom(H);utm.referrer_host=refHost&&refHost!==norm(location.hostname)?refHost:"";return utm}var refHost=hostFrom(document.referrer);if(!refHost||refHost===norm(location.hostname))refHost=hostFrom(H);var geo=matchHost(refHost,R.geo);if(geo)return{discovery_channel:"geo",discovery_source:geo,referrer_host:refHost};var seo=matchHost(refHost,R.seo);if(seo)return{discovery_channel:"seo",discovery_source:seo,referrer_host:refHost};if(refHost)return{discovery_channel:"referral",discovery_source:"other",referrer_host:refHost};return{discovery_channel:"direct",discovery_source:"direct",referrer_host:""}}})()`;
}
+142
View File
@@ -0,0 +1,142 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import vm from 'node:vm';
import {
appendDiscoveryPropagationParams,
buildInlineDiscoveryResolverScript,
classifyDiscoveryTrafficSource,
DISCOVERY_QUERY_PARAM,
DISCOVERY_SOURCE_QUERY_PARAM,
} from './mindspace-analytics-discovery.mjs';
test('classifies common SEO referrers', () => {
assert.deepEqual(
classifyDiscoveryTrafficSource({
referrer: 'https://www.google.com/search?q=demo',
}),
{
discovery_channel: 'seo',
discovery_source: 'google',
referrer_host: 'google.com',
},
);
assert.deepEqual(
classifyDiscoveryTrafficSource({
referrer: 'https://www.baidu.com/s?wd=demo',
}),
{
discovery_channel: 'seo',
discovery_source: 'baidu',
referrer_host: 'baidu.com',
},
);
});
test('classifies common GEO referrers', () => {
assert.deepEqual(
classifyDiscoveryTrafficSource({
referrer: 'https://chatgpt.com/c/abc',
}),
{
discovery_channel: 'geo',
discovery_source: 'chatgpt',
referrer_host: 'chatgpt.com',
},
);
assert.deepEqual(
classifyDiscoveryTrafficSource({
referrer: 'https://www.perplexity.ai/search/demo',
}),
{
discovery_channel: 'geo',
discovery_source: 'perplexity',
referrer_host: 'perplexity.ai',
},
);
});
test('prefers explicit discovery query params for iframe propagation', () => {
assert.deepEqual(
classifyDiscoveryTrafficSource({
discoveryChannel: 'seo',
discoverySource: 'google',
referrer: 'https://m.tkmind.cn/u/alice/pages/demo',
pageHost: 'm.tkmind.cn',
}),
{
discovery_channel: 'seo',
discovery_source: 'google',
referrer_host: 'm.tkmind.cn',
},
);
});
test('falls back to server referrer hint when browser referrer is same-origin', () => {
assert.deepEqual(
classifyDiscoveryTrafficSource({
referrer: 'https://m.tkmind.cn/u/alice/pages/demo',
pageHost: 'm.tkmind.cn',
}),
{
discovery_channel: 'direct',
discovery_source: 'direct',
referrer_host: '',
},
);
});
test('appendDiscoveryPropagationParams only propagates seo or geo channels', () => {
const seoUrl = appendDiscoveryPropagationParams('/u/alice/pages/demo?view=raw', {
discovery_channel: 'seo',
discovery_source: 'google',
});
assert.match(seoUrl, new RegExp(`${DISCOVERY_QUERY_PARAM}=seo`));
assert.match(seoUrl, new RegExp(`${DISCOVERY_SOURCE_QUERY_PARAM}=google`));
const directUrl = appendDiscoveryPropagationParams('/u/alice/pages/demo?view=raw', {
discovery_channel: 'direct',
discovery_source: 'direct',
});
assert.equal(directUrl, '/u/alice/pages/demo?view=raw');
});
test('inline discovery resolver matches server-side classification from referrer hint', () => {
const result = vm.runInNewContext(
`(${buildInlineDiscoveryResolverScript('https://www.google.com/search?q=demo')})()`,
{
URL,
URLSearchParams,
location: {
hostname: 'm.tkmind.cn',
search: '',
},
document: { referrer: '' },
},
);
assert.deepEqual(JSON.parse(JSON.stringify(result)), {
discovery_channel: 'seo',
discovery_source: 'google',
referrer_host: 'google.com',
});
});
test('inline discovery resolver reads propagated query params inside iframe', () => {
const result = vm.runInNewContext(
`(${buildInlineDiscoveryResolverScript('')})()`,
{
URL,
URLSearchParams,
location: {
hostname: 'm.tkmind.cn',
search: '?view=raw&memind_discovery=geo&memind_discovery_source=chatgpt',
},
document: { referrer: 'https://m.tkmind.cn/u/alice/pages/demo' },
},
);
assert.deepEqual(JSON.parse(JSON.stringify(result)), {
discovery_channel: 'geo',
discovery_source: 'chatgpt',
referrer_host: '',
});
});
+5 -1
View File
@@ -1,5 +1,7 @@
import crypto from 'node:crypto';
import { buildInlineDiscoveryResolverScript } from './mindspace-analytics-discovery.mjs';
const ANALYTICS_MARKER = 'data-memind-analytics="1"';
export function resolveMindSpaceAnalyticsConfig(env = process.env) {
@@ -171,6 +173,7 @@ export function injectMindSpaceAnalytics(html, {
generatedAt = '',
channel = 'h5',
viewerIdentity = null,
referrerHint = '',
config = resolveMindSpaceAnalyticsConfig(),
} = {}) {
const source = String(html ?? '');
@@ -187,7 +190,8 @@ export function injectMindSpaceAnalytics(html, {
];
if (config.domains) attrs.push(`data-domains="${config.domains.replaceAll('"', '&quot;')}"`);
const viewerJson = viewerIdentity ? jsonForInlineScript(viewerIdentity) : 'null';
const block = `<script ${attrs.join(' ')} defer src="${config.scriptPath}"></script><script ${ANALYTICS_MARKER}>(function(){var d=${jsonForInlineScript(metadata)},v=${viewerJson},seen={};function safeTarget(h){if(!h)return'';try{var u=new URL(h,location.href);return u.origin===location.origin?u.pathname:'external:'+u.hostname;}catch{return'';}}function t(n,x){if(!window.umami||typeof window.umami.track!=='function')return;var p=Object.assign({},d,{page_url:location.href,page_route:location.pathname,page_title:document.title},x||{});window.umami.track(n,p);}function identifyViewer(){if(!v||!v.distinctId||!window.umami||typeof window.umami.identify!=='function')return;window.umami.identify(v.distinctId,{username:v.username||'',memind_page_url:location.href,owner_segment:v.ownerSegment||'',plan_type:v.planType||'',channel:v.channel||'public',surface:'generated_page',identity_mode:v.identityMode||'pseudonymous'});}function pageview(){if(!window.umami||typeof window.umami.track!=='function')return;window.umami.track();}function once(n,x){if(seen[n])return;seen[n]=1;t(n,x);}function ready(){identifyViewer();pageview();document.addEventListener('click',function(e){var el=e.target&&e.target.closest?e.target.closest('a,button,[role="button"],[data-umami-event]'):null;if(!el)return;var custom=el.getAttribute('data-umami-event');var href=el.tagName==='A'?el.getAttribute('href')||'':'';t(custom||'page_click',{element:el.tagName.toLowerCase(),element_id:el.id||'',event_label:(custom||el.getAttribute('aria-label')||'').slice(0,100),target_path:safeTarget(href)});},{passive:true});document.addEventListener('submit',function(e){var form=e.target;t('page_form_submit',{form_id:form&&form.id||'',form_action:safeTarget(form&&form.getAttribute('action')||'')});},{passive:true});var marks=[25,50,75,90];function scroll(){var h=document.documentElement.scrollHeight-window.innerHeight;if(h<=0){once('page_scroll_100');return;}var pct=Math.round(window.scrollY/h*100);marks.forEach(function(m){if(pct>=m)once('page_scroll_'+m);});}window.addEventListener('scroll',scroll,{passive:true});setTimeout(function(){once('page_engaged_10s');},10000);setTimeout(function(){once('page_engaged_30s');},30000);scroll();}if(document.readyState==='loading'){document.addEventListener('DOMContentLoaded',ready,{once:true});}else{ready();}})();</script>`;
const resolveDiscovery = buildInlineDiscoveryResolverScript(referrerHint);
const block = `<script ${attrs.join(' ')} defer src="${config.scriptPath}"></script><script ${ANALYTICS_MARKER}>(function(){var d=${jsonForInlineScript(metadata)},v=${viewerJson},seen={},resolveDiscovery=${resolveDiscovery};function safeTarget(h){if(!h)return'';try{var u=new URL(h,location.href);return u.origin===location.origin?u.pathname:'external:'+u.hostname;}catch{return'';}}function t(n,x){if(!window.umami||typeof window.umami.track!=='function')return;var p=Object.assign({},d,{page_url:location.href,page_route:location.pathname,page_title:document.title},x||{});window.umami.track(n,p);}function identifyViewer(){if(!v||!v.distinctId||!window.umami||typeof window.umami.identify!=='function')return;window.umami.identify(v.distinctId,{username:v.username||'',memind_page_url:location.href,owner_segment:v.ownerSegment||'',plan_type:v.planType||'',channel:v.channel||'public',surface:'generated_page',identity_mode:v.identityMode||'pseudonymous'});}function pageview(){if(!window.umami||typeof window.umami.track!=='function')return;window.umami.track(function(p){return Object.assign({},p||{},{data:Object.assign({},(p&&p.data)||{},d)});});}function once(n,x){if(seen[n])return;seen[n]=1;t(n,x);}function ready(){identifyViewer();Object.assign(d,resolveDiscovery());pageview();document.addEventListener('click',function(e){var el=e.target&&e.target.closest?e.target.closest('a,button,[role="button"],[data-umami-event]'):null;if(!el)return;var custom=el.getAttribute('data-umami-event');var href=el.tagName==='A'?el.getAttribute('href')||'':'';t(custom||'page_click',{element:el.tagName.toLowerCase(),element_id:el.id||'',event_label:(custom||el.getAttribute('aria-label')||'').slice(0,100),target_path:safeTarget(href)});},{passive:true});document.addEventListener('submit',function(e){var form=e.target;t('page_form_submit',{form_id:form&&form.id||'',form_action:safeTarget(form&&form.getAttribute('action')||'')});},{passive:true});var marks=[25,50,75,90];function scroll(){var h=document.documentElement.scrollHeight-window.innerHeight;if(h<=0){once('page_scroll_100');return;}var pct=Math.round(window.scrollY/h*100);marks.forEach(function(m){if(pct>=m)once('page_scroll_'+m);});}window.addEventListener('scroll',scroll,{passive:true});setTimeout(function(){once('page_engaged_10s');},10000);setTimeout(function(){once('page_engaged_30s');},30000);scroll();}if(document.readyState==='loading'){document.addEventListener('DOMContentLoaded',ready,{once:true});}else{ready();}})();</script>`;
if (/<\/head>/i.test(source)) return source.replace(/<\/head>/i, `${block}</head>`);
return source.replace(/<body\b/i, `${block}<body`);
}
+34 -18
View File
@@ -127,8 +127,8 @@ test('injects one local same-origin tracker with page dimensions', () => {
assert.match(out, /data-auto-track="false"/);
assert.doesNotMatch(out, /umami\.identify\(d\.owner_id/);
assert.match(out, /function identifyViewer\(\)/);
assert.match(out, /identifyViewer\(\);pageview\(\)/);
assert.match(out, /function pageview\(\).*window\.umami\.track\(\)/);
assert.match(out, /Object\.assign\(d,resolveDiscovery\(\)\);pageview\(\)/);
assert.match(out, /function pageview\(\).*window\.umami\.track\(function\(p\)/);
assert.doesNotMatch(out, /t\('page_view'\)/);
assert.match(out, /page_id/);
assert.match(out, /owner_segment/);
@@ -140,17 +140,20 @@ test('injects one local same-origin tracker with page dimensions', () => {
assert.match(out, /page_form_submit/);
assert.match(out, /page_scroll_/);
assert.match(out, /page_engaged_10s/);
assert.match(out, /discovery_channel/);
assert.match(out, /resolveDiscovery=/);
assert.match(out, /"owner_id":"user-123"/);
assert.match(out, /"generated_at":"2026-07-20T01:02:03.000Z"/);
assert.equal(injectMindSpaceAnalytics(out, { ownerId: 'user-123', config: { enabled: true, websiteId: 'local-website', idSecret: 'secret' } }), out);
});
test('public visitors skip creator identify and only send a standard page view', () => {
test('public visitors skip creator identify and attach discovery metadata to page views', () => {
const out = injectMindSpaceAnalytics('<!doctype html><html><head></head><body></body></html>', {
ownerId: 'user-123',
ownerSegment: 'plan:pro',
ownerLabel: '张三',
channel: 'h5',
referrerHint: 'https://www.google.com/search?q=demo',
config: {
enabled: true,
websiteId: 'local-website',
@@ -164,6 +167,8 @@ test('public visitors skip creator identify and only send a standard page view',
const calls = [];
vm.runInNewContext(inlineScript, {
URL,
URLSearchParams,
window: {
umami: {
identify: (...args) => calls.push(['identify', ...args]),
@@ -178,12 +183,23 @@ test('public visitors skip creator identify and only send a standard page view',
title: 'Demo',
documentElement: { scrollHeight: 1600 },
addEventListener: () => {},
referrer: 'https://www.google.com/search?q=demo',
},
location: {
href: 'https://m.tkmind.cn/MindSpace/demo/public/page.html',
pathname: '/MindSpace/demo/public/page.html',
hostname: 'm.tkmind.cn',
search: '',
},
location: { href: 'https://m.tkmind.cn/MindSpace/demo/public/page.html', pathname: '/MindSpace/demo/public/page.html' },
setTimeout: () => {},
});
assert.deepEqual(JSON.parse(JSON.stringify(calls)), [['track']]);
assert.equal(calls.length, 1);
assert.equal(calls[0][0], 'track');
const payload = calls[0][1]({});
assert.equal(payload.data.discovery_channel, 'seo');
assert.equal(payload.data.discovery_source, 'google');
assert.equal(payload.data.referrer_host, 'google.com');
});
test('logged-in public visitors identify themselves without using the page creator id', () => {
@@ -208,6 +224,8 @@ test('logged-in public visitors identify themselves without using the page creat
const calls = [];
vm.runInNewContext(inlineScript, {
URL,
URLSearchParams,
window: {
umami: {
identify: (...args) => calls.push(['identify', ...args]),
@@ -223,22 +241,20 @@ test('logged-in public visitors identify themselves without using the page creat
documentElement: { scrollHeight: 1600 },
addEventListener: () => {},
},
location: { href: 'https://m.tkmind.cn/MindSpace/demo/public/page.html', pathname: '/MindSpace/demo/public/page.html' },
location: {
href: 'https://m.tkmind.cn/MindSpace/demo/public/page.html',
pathname: '/MindSpace/demo/public/page.html',
hostname: 'm.tkmind.cn',
search: '',
},
setTimeout: () => {},
});
assert.deepEqual(JSON.parse(JSON.stringify(calls)), [
['identify', viewerId, {
username: '李四',
memind_page_url: 'https://m.tkmind.cn/MindSpace/demo/public/page.html',
owner_segment: 'plan:free',
plan_type: 'free',
channel: 'public',
surface: 'generated_page',
identity_mode: 'pseudonymous',
}],
['track'],
]);
assert.equal(calls.length, 2);
assert.equal(calls[0][0], 'identify');
assert.equal(calls[0][1], viewerId);
assert.equal(calls[1][0], 'track');
assert.equal(typeof calls[1][1], 'function');
assert.notEqual(viewerId, 'user-123');
});
-125
View File
@@ -1,125 +0,0 @@
import crypto from 'node:crypto';
import {
pseudonymizeAnalyticsId,
resolveAnalyticsOwnerLabel,
resolveAnalyticsOwnerSegment,
} from './mindspace-analytics.mjs';
const RYBBIT_MARKER = 'data-memind-rybbit="1"';
export function resolveMindSpaceRybbitConfig(
env = process.env,
{ idSecret = '' } = {},
) {
const enabled = String(env.MEMIND_RYBBIT_ENABLED ?? '').toLowerCase() === 'true';
const siteId = String(env.MEMIND_RYBBIT_SITE_ID ?? '').trim();
const secret = [
env.MEMIND_RYBBIT_ID_SECRET,
env.MEMIND_ANALYTICS_ID_SECRET,
idSecret,
]
.map((value) => String(value ?? '').trim())
.find(Boolean) ?? '';
const rybbitUrl = String(env.MEMIND_RYBBIT_URL ?? env.RYBBIT_URL ?? 'https://rybbit.tkmind.cn').trim()
|| 'https://rybbit.tkmind.cn';
return {
enabled: enabled && Boolean(siteId) && Boolean(secret),
siteId,
idSecret: secret,
rybbitUrl: rybbitUrl.replace(/\/$/, ''),
// Same-origin proxy path. Script auto-discovers host by splitting on /script.js,
// so /rybbit/script.js makes events go to /rybbit/track → proxied to /api/track.
scriptPath: String(env.MEMIND_RYBBIT_SCRIPT_PATH ?? '/rybbit/script.js').trim() || '/rybbit/script.js',
hostPath: String(env.MEMIND_RYBBIT_HOST_PATH ?? '/rybbit').trim() || '/rybbit',
};
}
export { resolveAnalyticsOwnerLabel, resolveAnalyticsOwnerSegment, pseudonymizeAnalyticsId };
function jsonForInlineScript(value) {
return JSON.stringify(value)
.replaceAll('<', '\\u003c')
.replaceAll('>', '\\u003e')
.replaceAll('&', '\\u0026');
}
export function sendMindSpaceRybbitEvent({
config,
eventName,
ownerId,
pageId = '',
publicationId = '',
agentRunId = '',
channel = 'h5',
ownerSegment = 'unknown',
ownerLabel = '未命名用户',
url = '',
} = {}) {
if (!config?.enabled || !config.siteId || !config.idSecret || !eventName) return Promise.resolve(false);
const owner = pseudonymizeAnalyticsId(ownerId, config.idSecret);
if (!owner) return Promise.resolve(false);
const endpoint = `${String(config.rybbitUrl || 'https://rybbit.tkmind.cn').replace(/\/$/, '')}/api/track`;
const username = resolveAnalyticsOwnerLabel({ displayName: ownerLabel });
const payload = {
site_id: String(config.siteId),
type: 'custom_event',
event_name: String(eventName).slice(0, 256),
pathname: url || '/',
page_title: '',
referrer: '',
hostname: '127.0.0.1',
user_id: owner,
properties: JSON.stringify({
owner_id: owner,
page_id: String(pageId || ''),
publication_id: String(publicationId || ''),
agent_run_id: String(agentRunId || ''),
channel,
owner_segment: String(ownerSegment || 'unknown'),
username,
owner_label: username,
}),
};
return fetch(endpoint, {
method: 'POST',
headers: { 'content-type': 'application/json', 'user-agent': 'Memind/local-rybbit' },
body: JSON.stringify(payload),
signal: AbortSignal.timeout(1500),
}).then((response) => response.ok).catch(() => false);
}
export function injectMindSpaceRybbit(html, {
ownerId,
pageId = '',
publicationId = '',
ownerSegment = 'unknown',
ownerLabel = '未命名用户',
channel = 'h5',
config = resolveMindSpaceRybbitConfig(),
} = {}) {
const source = String(html ?? '');
if (!config?.enabled || !config.siteId || !/^\s*(<!doctype html|<html\b)/i.test(source)) return source;
if (source.includes(RYBBIT_MARKER)) return source;
const owner = pseudonymizeAnalyticsId(ownerId, config.idSecret);
if (!owner) return source;
const metadata = {
owner_id: owner,
username: resolveAnalyticsOwnerLabel({ displayName: ownerLabel }),
owner_segment: String(ownerSegment || 'unknown'),
page_id: String(pageId || ''),
publication_id: String(publicationId || ''),
channel,
};
const attrs = [
RYBBIT_MARKER,
`data-site-id="${String(config.siteId).replaceAll('"', '&quot;')}"`,
'defer',
`src="${config.scriptPath}"`,
];
// Manual identify + engagement events; keep initial pageview from Rybbit site settings.
const block = `<script ${attrs.join(' ')}></script><script ${RYBBIT_MARKER}>(function(){var d=${jsonForInlineScript(metadata)},seen={},tries=0;function api(){return window.rybbit;}function t(n,x){var r=api();if(!r||typeof r.event!=='function')return;var p=Object.assign({},d,{page_url:location.href,page_title:document.title},x||{});r.event(n,p);}function identify(){var r=api();if(!r||typeof r.identify!=='function')return;r.identify(d.owner_id,{username:d.username,memind_page_url:location.href,owner_segment:d.owner_segment,channel:d.channel,page_id:d.page_id,publication_id:d.publication_id});}function once(n,x){if(seen[n])return;seen[n]=1;t(n,x);}function wire(){identify();document.addEventListener('click',function(e){var el=e.target&&e.target.closest?e.target.closest('a,button,[role="button"],[data-rybbit-event],[data-umami-event]'):null;if(!el)return;var custom=el.getAttribute('data-rybbit-event')||el.getAttribute('data-umami-event');var href=el.tagName==='A'?el.getAttribute('href')||'':'';t(custom||'page_click',{element:el.tagName.toLowerCase(),element_id:el.id||'',event_label:(custom||el.getAttribute('aria-label')||'').slice(0,100),target_url:href.slice(0,500)});},{passive:true});document.addEventListener('submit',function(e){var form=e.target;t('page_form_submit',{form_id:form&&form.id||'',form_action:form&&form.getAttribute('action')||''});},{passive:true});var marks=[25,50,75,90];function scroll(){var h=document.documentElement.scrollHeight-window.innerHeight;if(h<=0){once('page_scroll_100');return;}var pct=Math.round(window.scrollY/h*100);marks.forEach(function(m){if(pct>=m)once('page_scroll_'+m);});}window.addEventListener('scroll',scroll,{passive:true});setTimeout(function(){once('page_engaged_10s');},10000);setTimeout(function(){once('page_engaged_30s');},30000);scroll();}function ready(){if(api()){wire();return;}if(tries++>40)return;setTimeout(ready,50);}if(document.readyState==='loading'){document.addEventListener('DOMContentLoaded',ready,{once:true});}else{ready();}})();</script>`;
if (/<\/head>/i.test(source)) return source.replace(/<\/head>/i, `${block}</head>`);
return source.replace(/<body\b/i, `${block}<body`);
}
-117
View File
@@ -1,117 +0,0 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import vm from 'node:vm';
import {
injectMindSpaceRybbit,
resolveMindSpaceRybbitConfig,
} from './mindspace-rybbit.mjs';
test('rybbit config is disabled unless explicitly enabled and configured', () => {
assert.equal(resolveMindSpaceRybbitConfig({ MEMIND_RYBBIT_ENABLED: 'true' }).enabled, false);
assert.equal(resolveMindSpaceRybbitConfig({
MEMIND_RYBBIT_ENABLED: 'true',
MEMIND_RYBBIT_SITE_ID: '2',
}).enabled, false);
const config = resolveMindSpaceRybbitConfig({
MEMIND_RYBBIT_ENABLED: 'true',
MEMIND_RYBBIT_SITE_ID: '2',
MEMIND_RYBBIT_ID_SECRET: 'local-secret',
MEMIND_RYBBIT_URL: 'https://rybbit.tkmind.cn',
});
assert.equal(config.enabled, true);
assert.equal(config.siteId, '2');
assert.equal(config.rybbitUrl, 'https://rybbit.tkmind.cn');
assert.equal(config.hostPath, '/rybbit');
assert.equal(config.scriptPath, '/rybbit/script.js');
});
test('rybbit config can reuse stored analytics secret', () => {
const config = resolveMindSpaceRybbitConfig({
MEMIND_RYBBIT_ENABLED: 'true',
MEMIND_RYBBIT_SITE_ID: '2',
MEMIND_RYBBIT_ID_SECRET: '',
MEMIND_ANALYTICS_ID_SECRET: '',
}, {
idSecret: 'stored-secret',
});
assert.equal(config.enabled, true);
assert.equal(config.idSecret, 'stored-secret');
});
test('injects one local same-origin rybbit tracker with page dimensions', () => {
const html = '<!doctype html><html><head><title>Demo</title></head><body><h1>Demo</h1></body></html>';
const out = injectMindSpaceRybbit(html, {
ownerId: 'user-123',
ownerLabel: '张三',
pageId: 'page-1',
publicationId: 'pub-1',
config: {
enabled: true,
siteId: '2',
idSecret: 'secret',
scriptPath: '/rybbit/script.js',
hostPath: '/rybbit',
},
});
assert.match(out, /src="\/rybbit\/script\.js"/);
assert.match(out, /data-site-id="2"/);
assert.match(out, /data-memind-rybbit="1"/);
assert.match(out, /r\.identify\(d\.owner_id,/);
assert.match(out, /r\.event\(n,p\)/);
assert.match(out, /page_click/);
assert.match(out, /page_form_submit/);
assert.match(out, /page_scroll_/);
assert.match(out, /page_engaged_10s/);
assert.match(out, /"username":"张三"/);
assert.doesNotMatch(out, /user-123/);
assert.equal(injectMindSpaceRybbit(out, {
ownerId: 'user-123',
config: { enabled: true, siteId: '2', idSecret: 'secret' },
}), out);
});
test('identifies the pseudonymous owner once rybbit is ready', () => {
const out = injectMindSpaceRybbit('<!doctype html><html><head></head><body></body></html>', {
ownerId: 'user-123',
ownerSegment: 'plan:pro',
ownerLabel: '张三',
channel: 'h5',
config: {
enabled: true,
siteId: '2',
idSecret: 'secret',
scriptPath: '/rybbit/script.js',
hostPath: '/rybbit',
},
});
const inlineScript = out.match(/<script data-memind-rybbit="1">([\s\S]*?)<\/script>/)?.[1];
assert.ok(inlineScript);
const calls = [];
const timers = [];
vm.runInNewContext(inlineScript, {
window: {
rybbit: {
identify: (...args) => calls.push(['identify', ...args]),
event: (...args) => calls.push(['event', ...args]),
},
innerHeight: 800,
scrollY: 0,
addEventListener: () => {},
},
document: {
readyState: 'complete',
title: 'Demo',
documentElement: { scrollHeight: 1600 },
addEventListener: () => {},
},
location: { href: 'http://127.0.0.1:8081/MindSpace/demo/public/page.html' },
setTimeout: (fn) => { timers.push(fn); },
});
assert.equal(calls[0]?.[0], 'identify');
assert.match(String(calls[0]?.[1] ?? ''), /^[a-f0-9]{32}$/);
assert.equal(calls[0]?.[2]?.username, '张三');
assert.equal(calls[0]?.[2]?.owner_segment, 'plan:pro');
});
+1 -1
View File
@@ -2,7 +2,7 @@
> **重要:本目录不再接受新的管理后台功能开发。**
>
> 所有平台管理后台 UI(用户、计费、图片额度、策略、模型中心等)必须在独立仓库 **`memind_adm`** 中实现,本地开发端口 **5174**(生产 gadm),API 端口 **8085**。
> 所有平台管理后台 UI(用户、计费、图片额度、策略、模型中心等)必须在独立仓库 **`memind_adm`** 中实现,本地开发端口 **5174**(生产 [md.tkmind.cn](https://md.tkmind.cn)),API 端口 **8085**。
>
> 本目录(Memind `ops/`,本地约 **3002**)仅保留 **Plaza 运营**相关页面(审核、精选、创作者等)及历史遗留的超级管理路由;**不得**在此新增页面、导航项或 API 客户端。
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -942,7 +942,7 @@ export const AUTOMATION_SUITES = Object.freeze([
'services/orchestrator/shadow-observer.test.mjs',
'services/orchestrator/observability.test.mjs',
'mindspace-analytics.test.mjs',
'mindspace-rybbit.test.mjs',
'mindspace-analytics-discovery.test.mjs',
'system-disclosure-policy.test.mjs',
],
},
-1
View File
@@ -547,7 +547,6 @@ export async function createLocalGateStack({
H5_REMINDER_WORKER_ENABLED: '0',
MEMIND_ANALYTICS_ENABLED: '0',
MINDSPACE_ANALYTICS_ENABLED: '0',
MEMIND_RYBBIT_ENABLED: 'false',
};
baseUrl = `http://127.0.0.1:${port}`;
if (beforePortalStart) {
-1
View File
@@ -8,7 +8,6 @@ const ALLOWED_TKMIND_HOSTS = new Set([
'go.tkmind.cn',
'm.tkmind.cn',
'plaza.tkmind.cn',
'rybbit.tkmind.cn',
]);
function isPrivateIpv4(hostname) {
+22
View File
@@ -25,6 +25,23 @@ const WEEKDAY_LABELS = [
['周六', '星期六'],
];
const META_TASK_SPEC_PATTERNS = [
/^我要?(?:做|创建|设置|弄)?(?:一个|个)?(?:定时(?:自动)?)?执行(?:任务)?$/u,
/^帮我?(?:做|创建|设置|弄)?(?:一个|个)?(?:定时(?:自动)?)?执行(?:任务)?$/u,
/^请?(?:帮我)?(?:做|创建|设置|弄)?(?:一个|个)?(?:定时(?:自动)?)?执行(?:任务)?$/u,
/^定时(?:自动)?执行(?:任务)?$/u,
/^执行(?:任务)?$/u,
/^做(?:一个|个)?(?:定时(?:自动)?)?(?:执行)?任务$/u,
/^创建(?:一个|个)?定时(?:自动)?执行(?:任务)?$/u,
/^想(?:要)?(?:做|创建|设置)?(?:一个|个)?(?:定时(?:自动)?)?执行(?:任务)?$/u,
];
function isMetaScheduledTaskSpec(spec) {
const normalized = String(spec ?? '').replace(/\s+/g, '').trim();
if (!normalized) return true;
return META_TASK_SPEC_PATTERNS.some((pattern) => pattern.test(normalized));
}
function wantsScheduledTaskAutomation(compact) {
if (SCHEDULE_MARKERS.test(compact)) return true;
if (!RECURRENCE_MARKERS.test(compact)) return false;
@@ -48,6 +65,7 @@ function extractScheduledTaskSpec(text) {
const original = String(text ?? '').trim();
if (!original) return null;
let spec = original
.replace(/^(?:不是|别是|并非)(?:待办|代办|提醒|日程|闹钟)[,、:\s]*/u, '')
.replace(
/^(?:帮我)?(?:设|设置|创建|添加|想创建)(?:一个|个)?(?:定时(?:自动)?任务|定时执行任务)?[:,,、\s]*/u,
'',
@@ -62,9 +80,12 @@ function extractScheduledTaskSpec(text) {
)
.replace(/(?:周[一二三四五六日天]|星期[一二三四五六日天])/gu, ' ')
.replace(/(?:帮我|请|麻烦)/gu, ' ')
.replace(/^执行[,、:\s]+/u, '')
.replace(/[,、:\s]+执行$/u, '')
.replace(/\s+/g, ' ')
.trim();
if (!spec || spec.length < 3) return null;
if (isMetaScheduledTaskSpec(spec)) return null;
if (!EXECUTE_VERBS.test(spec.replace(/\s+/g, ''))) return null;
return spec;
}
@@ -302,6 +323,7 @@ export function formatScheduledTaskListReply(tasks = []) {
export {
extractScheduledTaskSpec,
isMetaScheduledTaskSpec,
parseWeekday,
resolveOnceRunAtLocal,
};
+21
View File
@@ -90,3 +90,24 @@ test('isScheduledTaskIntent excludes none', () => {
assert.equal(isScheduledTaskIntent({ action: 'create_scheduled_task' }), true);
assert.equal(isScheduledTaskIntent({ action: 'none' }), false);
});
test('meta scheduled-task phrases require task_spec clarification', () => {
for (const text of [
'我要做一个定时执行任务',
'定时自动执行任务',
'不是待办,定时执行任务',
]) {
const intent = parseScheduledTaskIntent(text);
assert.equal(intent.action, 'create_scheduled_task', text);
assert.ok(intent.needsClarification?.includes('task_spec'), text);
assert.ok(intent.needsClarification?.includes('schedule'), text);
assert.equal(extractScheduledTaskSpec(text), null, text);
}
});
test('combined phrase keeps substantive taskSpec after time prefix', () => {
const text = '帮我创建一个定时执行任务,23:00执行,搜索今日新闻';
const intent = parseScheduledTaskIntent(text);
assert.equal(intent.needsClarification, undefined);
assert.match(intent.taskSpec, /搜索今日新闻/u);
});
+1 -24
View File
@@ -17,7 +17,6 @@ import {
resolveProductAnalyticsConfig,
sendMindSpaceAnalyticsEvent,
} from './mindspace-analytics.mjs';
import { resolveMindSpaceRybbitConfig } from './mindspace-rybbit.mjs';
import { startWorkspaceAssetSyncWatcher } from './mindspace-workspace-sync.mjs';
import { attachRequestId, sendData, sendError } from './api-response.mjs';
import { attachPortalAgentJobRoutes } from './server/portal-agent-job-routes.mjs';
@@ -330,7 +329,6 @@ let authPool = null;
let pageDataService = null;
let pageDataPublicService = null;
let mindSpaceAnalyticsConfig = resolveMindSpaceAnalyticsConfig();
let mindSpaceRybbitConfig = resolveMindSpaceRybbitConfig();
async function bootstrapUserAuth() {
try {
@@ -362,11 +360,6 @@ async function bootstrapUserAuth() {
mindSpaceAnalyticsConfig,
domainServices.mindSpaceAnalyticsConfig,
);
Object.assign(
mindSpaceRybbitConfig,
domainServices.mindSpaceRybbitConfig ??
mindSpaceRybbitConfig,
);
scheduleService = domainServices.scheduleService;
scheduledTaskService = domainServices.scheduledTaskService;
pageDataService = domainServices.pageDataService;
@@ -549,7 +542,6 @@ async function bootstrapUserAuth() {
systemDisclosurePolicyService,
sessionSnapshotService,
mindSpaceAnalyticsConfig,
mindSpaceRybbitConfig,
subscriptionService,
apiTarget: API_TARGET,
apiSecret: API_SECRET,
@@ -1420,7 +1412,6 @@ attachPortalSessionRoutes(api, {
mindSpacePublicFinish,
getMemoryV2: () => memoryV2,
getMindSpaceAnalyticsConfig: () => mindSpaceAnalyticsConfig,
getMindSpaceRybbitConfig: () => mindSpaceRybbitConfig,
ownsAgentSession,
unregisterAgentSessionForUser,
beginSessionPageDelivery,
@@ -1504,26 +1495,12 @@ app.use('/analytics', createProxyMiddleware({
secure: false,
pathRewrite: { [`^${mindSpaceAnalyticsConfig.hostPath}`]: '' },
}));
app.use('/rybbit', createProxyMiddleware({
target: 'https://rybbit.tkmind.cn',
router: () =>
mindSpaceRybbitConfig.rybbitUrl ||
process.env.MEMIND_RYBBIT_URL ||
process.env.RYBBIT_URL ||
'https://rybbit.tkmind.cn',
changeOrigin: true,
secure: true,
// Express strips the /rybbit mount. Rybbit serves trackers under /api/*.
pathRewrite: { '^/': '/api/' },
}));
const { serveUserPublishFile } =
createPortalWorkspacePublicationDelivery({
internalAgentSecret:
INTERNAL_AGENT_SECRET,
analyticsConfig:
mindSpaceAnalyticsConfig,
rybbitConfig:
mindSpaceRybbitConfig,
getAuthPool: () => authPool,
getUserAuth: () => userAuth,
getMindSpacePages: () => mindSpacePages,
@@ -1550,7 +1527,7 @@ const sendPublishedPage =
registerLongImageArtifact:
registerPublishedLongImageArtifactForConversation,
analyticsConfig: mindSpaceAnalyticsConfig,
rybbitConfig: mindSpaceRybbitConfig,
analyticsConfig: mindSpaceAnalyticsConfig,
getAuthPool: () => authPool,
getUserAuth: () => userAuth,
getMindSpacePages: () => mindSpacePages,
@@ -1,5 +1,4 @@
import { ensureMindSpaceConfig, loadMindSpaceConfig, loadMindSpaceConfigCached } from '../mindspace-config.mjs';
import { resolveMindSpaceRybbitConfig } from '../mindspace-rybbit.mjs';
import { createMindSearchConfigService } from '../mindsearch-config.mjs';
import { createMindSpaceService } from '../mindspace.mjs';
import {
@@ -109,10 +108,6 @@ export async function bootstrapPortalDomainServices({
scriptPath: '/analytics/script.js',
};
}
const resolvedRybbitConfig = resolveMindSpaceRybbitConfig(env, {
idSecret: storedMindSpaceConfig?.analytics?.idSecret ?? '',
});
const scheduleService = createScheduleServiceFn(pool, {
defaultTimezone:
env.H5_DEFAULT_TIMEZONE || 'Asia/Shanghai',
@@ -289,7 +284,6 @@ export async function bootstrapPortalDomainServices({
return {
mindSearchConfigService,
mindSpaceAnalyticsConfig: resolvedAnalyticsConfig,
mindSpaceRybbitConfig: resolvedRybbitConfig,
scheduleService,
scheduledTaskService,
pageDataService,
@@ -61,8 +61,6 @@ function createBootstrapSetup(overrides = {}) {
env: {
H5_DEFAULT_TIMEZONE: 'Asia/Singapore',
PLAZA_REDIS_URL: 'redis://plaza',
MEMIND_RYBBIT_ENABLED: 'true',
MEMIND_RYBBIT_SITE_ID: '2',
},
runtime: {
maxFileBytes: 100,
@@ -325,9 +323,6 @@ test('domain bootstrap preserves schema and service assembly order', async () =>
result.mindSpaceAnalyticsConfig.scriptPath,
'/analytics/script.js',
);
assert.equal(result.mindSpaceRybbitConfig.enabled, true);
assert.equal(result.mindSpaceRybbitConfig.siteId, '2');
assert.equal(result.mindSpaceRybbitConfig.idSecret, 'secret-1');
});
test('domain bootstrap preserves delayed user, snapshot, and Page Data dependencies', async () => {
@@ -4,9 +4,6 @@ import {
resolveAnalyticsPlan,
sendMindSpaceAnalyticsEvent,
} from '../mindspace-analytics.mjs';
import {
sendMindSpaceRybbitEvent,
} from '../mindspace-rybbit.mjs';
import { createPageEditSessionService } from '../mindspace-page-edit-session.mjs';
import { createNotificationDispatcher } from '../notification-dispatcher.mjs';
import { startScheduleReminderWorker } from '../schedule-reminder-worker.mjs';
@@ -39,7 +36,6 @@ export async function bootstrapPortalIntegrationServices({
chatIntentRouter,
systemDisclosurePolicyService,
mindSpaceAnalyticsConfig,
mindSpaceRybbitConfig,
subscriptionService,
apiTarget,
apiSecret,
@@ -54,8 +50,6 @@ export async function bootstrapPortalIntegrationServices({
resolveAnalyticsPlanFn = resolveAnalyticsPlan,
sendMindSpaceAnalyticsEventFn =
sendMindSpaceAnalyticsEvent,
sendMindSpaceRybbitEventFn =
sendMindSpaceRybbitEvent,
createNotificationDispatcherFn =
createNotificationDispatcher,
startScheduleReminderWorkerFn =
@@ -214,10 +208,6 @@ export async function bootstrapPortalIntegrationServices({
config: mindSpaceAnalyticsConfig,
...analyticsPayload,
});
void sendMindSpaceRybbitEventFn({
config: mindSpaceRybbitConfig,
...analyticsPayload,
});
}
},
applySessionLlmProvider: (sessionId) =>
@@ -124,7 +124,6 @@ function createSetup(overrides = {}) {
},
sessionSnapshotService,
mindSpaceAnalyticsConfig: { enabled: true },
mindSpaceRybbitConfig: { enabled: true },
subscriptionService,
apiTarget: 'http://api',
apiSecret: 'secret',
@@ -164,10 +163,6 @@ function createSetup(overrides = {}) {
calls.push(['analytics', event]);
return Promise.resolve();
},
sendMindSpaceRybbitEventFn(event) {
calls.push(['rybbit', event]);
return Promise.resolve();
},
createNotificationDispatcherFn(receivedOptions) {
calls.push(['notification-dispatcher']);
notificationOptions = receivedOptions;
@@ -425,13 +420,6 @@ test('preserves generated-page analytics projection', async () => {
analyticsCall[1].generatedAt,
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/,
);
const rybbitCall = setup.calls.find(
([name]) => name === 'rybbit',
);
assert.deepEqual(rybbitCall[1], {
...analyticsCall[1],
config: { enabled: true },
});
});
test('preserves notification, recharge, reminder, and page-edit wiring', async () => {
+20 -19
View File
@@ -13,8 +13,9 @@ import {
resolveAnalyticsPlan,
} from '../mindspace-analytics.mjs';
import {
injectMindSpaceRybbit,
} from '../mindspace-rybbit.mjs';
appendDiscoveryPropagationParams,
classifyDiscoveryTrafficSource,
} from '../mindspace-analytics-discovery.mjs';
import {
rewriteBrokenMindSpacePublicImageUrls,
rewritePublicationCanonicalAssetUrls,
@@ -58,8 +59,8 @@ async function decoratePublicationHtmlAnalytics(
{
result,
analyticsConfig,
rybbitConfig,
viewer = null,
referrerHint = '',
getAuthPool = () => null,
getUserAuth = () => null,
getMindSpacePages = () => null,
@@ -122,17 +123,9 @@ async function decoratePublicationHtmlAnalytics(
pageId,
publicationId,
viewerIdentity: buildViewerAnalyticsIdentity(viewer, analyticsConfig),
referrerHint,
config: analyticsConfig,
});
decorated = injectMindSpaceRybbit(decorated, {
ownerId: result?.ownerId ?? '',
ownerSegment,
ownerLabel,
pageId,
publicationId,
channel: 'publication',
config: rybbitConfig,
});
return decorated;
}
@@ -141,7 +134,6 @@ export function createPortalPublishedPageDelivery({
renderLongImage =
renderLongImageBuffer,
analyticsConfig = { enabled: false },
rybbitConfig = { enabled: false },
getAuthPool = () => null,
getUserAuth = () => null,
getMindSpacePages = () => null,
@@ -179,6 +171,9 @@ export function createPortalPublishedPageDelivery({
);
}
const origin = resolveRequestOrigin(req);
const referrerHint = String(
req.headers?.referer ?? req.headers?.referrer ?? req.get?.('referer') ?? '',
).trim();
const originalPath =
req.originalUrl || req.url || '';
const sharePath = removeQueryParam(
@@ -243,8 +238,8 @@ export function createPortalPublishedPageDelivery({
html = await decoratePublicationHtmlAnalytics(html, {
result,
analyticsConfig,
rybbitConfig,
viewer: req.currentUser ?? null,
referrerHint,
getAuthPool,
getUserAuth,
getMindSpacePages,
@@ -345,10 +340,16 @@ export function createPortalPublishedPageDelivery({
if (canWrapWithShell) {
const title =
detectPublishedPageTitle(html);
const rawUrl = appendQueryParam(
sharePath || originalPath,
'view',
'raw',
const rawUrl = appendDiscoveryPropagationParams(
appendQueryParam(
sharePath || originalPath,
'view',
'raw',
),
classifyDiscoveryTrafficSource({
referrer: referrerHint,
pageHost: origin ? new URL(origin).hostname : '',
}),
);
const longImageUrl = appendQueryParam(
sharePath || originalPath,
@@ -386,8 +387,8 @@ export function createPortalPublishedPageDelivery({
shellHtml = await decoratePublicationHtmlAnalytics(shellHtml, {
result,
analyticsConfig,
rybbitConfig,
viewer: req.currentUser ?? null,
referrerHint,
getAuthPool,
getUserAuth,
getMindSpacePages,
+21 -8
View File
@@ -61,16 +61,16 @@ function createResult(overrides = {}) {
};
}
test('injects rybbit tracker for publication HTML delivery', async () => {
test('injects umami tracker for publication HTML delivery', async () => {
const sendPublishedPage =
createPortalPublishedPageDelivery({
async registerLongImageArtifact() {},
rybbitConfig: {
analyticsConfig: {
enabled: true,
siteId: '2',
websiteId: 'local-website',
idSecret: 'secret',
scriptPath: '/rybbit/script.js',
hostPath: '/rybbit',
scriptPath: '/analytics/script.js',
hostPath: '/analytics',
},
});
const response = createResponse();
@@ -85,8 +85,8 @@ test('injects rybbit tracker for publication HTML delivery', async () => {
},
{ raw: true },
);
assert.match(response.body, /src="\/rybbit\/script\.js"/);
assert.match(response.body, /data-site-id="2"/);
assert.match(response.body, /src="\/analytics\/script\.js"/);
assert.match(response.body, /data-website-id="local-website"/);
assert.doesNotMatch(response.body, /user-123/);
});
@@ -97,7 +97,12 @@ test('wraps a normal full HTML publication in the share shell', async () => {
});
const response = createResponse();
await sendPublishedPage(
createRequest(),
createRequest({
headers: {
host: 'portal.example',
referer: 'https://www.google.com/search?q=demo',
},
}),
response,
createResult(),
);
@@ -110,6 +115,14 @@ test('wraps a normal full HTML publication in the share shell', async () => {
response.body,
/view=raw/,
);
assert.match(
response.body,
/memind_discovery=seo/,
);
assert.match(
response.body,
/memind_discovery_source=google/,
);
assert.equal(
response.headers['Cache-Control'],
'public, max-age=60',
-11
View File
@@ -10,9 +10,6 @@ import {
resolveAnalyticsPlan,
sendMindSpaceAnalyticsEvent,
} from '../mindspace-analytics.mjs';
import {
sendMindSpaceRybbitEvent,
} from '../mindspace-rybbit.mjs';
import {
markPageDeliveryContractReady,
preparePageDeliveryContract,
@@ -49,7 +46,6 @@ export function attachPortalSessionRoutes(
getMindSpacePublicFinish = () => null,
getMemoryV2 = () => null,
getMindSpaceAnalyticsConfig = () => null,
getMindSpaceRybbitConfig = () => null,
ownsAgentSession,
unregisterAgentSessionForUser,
beginSessionPageDelivery,
@@ -72,8 +68,6 @@ export function attachPortalSessionRoutes(
hasPublicHtmlWriteRequestInSessionEvent,
sendMindSpaceAnalyticsEventFn =
sendMindSpaceAnalyticsEvent,
sendMindSpaceRybbitEventFn =
sendMindSpaceRybbitEvent,
resolveAnalyticsOwnerSegmentFn =
resolveAnalyticsOwnerSegment,
resolveAnalyticsOwnerLabelFn =
@@ -466,11 +460,6 @@ export function attachPortalSessionRoutes(
getMindSpaceAnalyticsConfig(),
...analyticsPayload,
});
void sendMindSpaceRybbitEventFn({
config:
getMindSpaceRybbitConfig(),
...analyticsPayload,
});
}
})
.catch((error) => {
@@ -5,9 +5,6 @@ import {
resolveAnalyticsOwnerSegment,
resolveAnalyticsPlan,
} from '../mindspace-analytics.mjs';
import {
injectMindSpaceRybbit,
} from '../mindspace-rybbit.mjs';
import {
buildPublishedHtmlViewContext,
resolveMindSpacePageDataContext,
@@ -63,7 +60,6 @@ function deliveryNotFoundMessage(reason) {
export function createPortalWorkspacePublicationDelivery({
internalAgentSecret,
analyticsConfig,
rybbitConfig,
getAuthPool = () => null,
getUserAuth = () => null,
getMindSpacePages = () => null,
@@ -266,24 +262,11 @@ export function createPortalWorkspacePublicationDelivery({
req.currentUser ?? null,
analyticsConfig,
),
referrerHint: String(
req.headers?.referer ?? req.headers?.referrer ?? req.get?.('referer') ?? '',
).trim(),
config: analyticsConfig,
});
html = injectMindSpaceRybbit(html, {
ownerId: delivery.ownerId ?? '',
ownerSegment:
resolveAnalyticsOwnerSegment(
pageOwner ?? {},
),
ownerLabel: resolveAnalyticsOwnerLabel(
pageOwner ?? {},
),
pageId: pageDataContext?.pageId ?? '',
publicationId:
pageDataContext?.publicationId ??
pageDataContext?.publication_id ??
'',
config: rybbitConfig,
});
const mindSpaceConfig = getAuthPool()
? await getMindSpaceConfig(getAuthPool()).catch(() => ({ seoGeo: { enabled: false } }))
: { seoGeo: { enabled: false } };
@@ -99,7 +99,6 @@ function createSetup(overrides = {}) {
createPortalWorkspacePublicationDelivery({
internalAgentSecret: 'test-secret',
analyticsConfig: { enabled: false },
rybbitConfig: { enabled: false },
getAuthPool: () => null,
getUserAuth: () => userAuth,
getMindSpacePages: () => null,