Compare commits

..

5 Commits

Author SHA1 Message Date
john ac520d8ae5 fix(mindspace-public): 公开页区分作者与访客视图 + 图片加载自动重试
工作区直链 /MindSpace/<userId>/public/*.html 此前对所有人(含匿名访客、
转发链接收到的其他登录用户)展示完全相同的悬浮操作按钮,导致非作者也能
看到"发布 Plaza"入口——而 Plaza 发布会把内容归属到操作者自己的账号下,
必须只对作者开放。

- server.mjs: serveUserPublishFile 用现有 session/cookie 鉴权判断访问者是
  否等于 URL 中的 ownerKey,结果透传给 sendPublishFile;该响应内容因人
  而异,显式加 Cache-Control: private, no-store(原来未设置任何缓存头)
- mindspace-public-share-widget.mjs: injectPublicFileShareButton 新增
  isOwner 参数(默认 true 保持兼容),非作者时隐藏"发布 Plaza"按钮与确认
  弹窗,"保存长图"/"公开分享"对所有访客保留
- mindspace-public-delivery.mjs: 透传 isOwner,并注入图片重试脚本
- mindspace-public-image-retry.mjs(新增): 页面级 onerror 之前用捕获阶段
  监听拦截 mindspace 资产图片的加载失败,带退避自动重试 3 次,避免刚生成
  页面时的资产物化竞态被"永久占位符"放大成显示故障

本地起服务用 owner / 非 owner 已登录用户 / 匿名访客三种身份实测验证。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-07 10:56:26 +08:00
john b4171ca268 chore(test): 新增统一测试入口与端到端场景模拟脚本
- scripts/run-memind-tests.mjs:按改动文件自动匹配 test + verify 范围
  (quick/changed/guards/release/full 模式),配套 memind-test skill
- scripts/run-scenario-test.mjs + scenario-test-lib.mjs:真实登录 + 多轮
  聊天 + 页面生成 + 公网可访问性的端到端模拟,配套 memind-scenario-test
  skill 与首个场景 scenarios/suzhou-page.json
- package.json 新增 test:memind / test:scenario 脚本别名

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-07 10:56:26 +08:00
john ec8d0464a3 fix(chat-intent): 隐式攻略/页面生成请求直接路由到 static-page-publish
短句如"苏州攻略页面"此前会被规则/LLM 误判为 product-campaign-page(先追问
促销信息),导致用户体验成"卡住不动"。规则层扩大页面生成意图识别,并新增
coercePageGenerationSkill 在 LLM 判为 product-campaign-page 但明显是内容页
时强制纠正为 static-page-publish,避免不必要的追问。同步更新两个技能文档
的适用边界说明。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-07 10:56:26 +08:00
john 7edafefc56 fix(mindspace): 生成页面统一使用中文友好无衬线字体
页脚品牌行、纯文字页面模板与技能提示词此前用 Georgia/Times 衬线体,中文渲染
效果差;统一改为系统无衬线字体栈(PingFang SC / Microsoft YaHei 等)。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-07 10:56:26 +08:00
john 0d6258d00a chore: remove mm.tkmind.cn references 2026-07-07 10:01:32 +08:00
36 changed files with 1784 additions and 198 deletions
@@ -0,0 +1,108 @@
---
name: memind-scenario-test
description: >-
Run Memind end-to-end scenario simulations (login, multi-turn chat, page
generation, public URL checks). Use when the user asks for 模拟场景测试, 场景测试,
E2E 流程验证, 模拟用户操作, or describes a step-by-step chat/page test flow.
---
# Memind 场景模拟测试
用户说「**帮我模拟场景测试**」或描述多步聊天/做页面流程时,**必须**走本 skill,不要手动拼 curl 或浏览器点选。
## 唯一入口
```bash
node scripts/run-scenario-test.mjs --scenario <id>
```
列出场景:
```bash
node scripts/run-scenario-test.mjs --list
```
## 前置条件
1. 本地 Portal 已运行(默认 `http://127.0.0.1:8081`,可用 `pnpm dev`
2. goosed / agent 链路可用(场景会真实发消息并等待 agent 回复)
3. 账户默认 `john` / `888888`;密码可用 `JOHN_PASSWORD` 覆盖
启动前检查:
```bash
curl -sf http://127.0.0.1:8081/auth/status
```
## 默认场景:苏州攻略页面
对应用户定义的流程,场景文件 [`scenarios/suzhou-page.json`](../../../scenarios/suzhou-page.json)
1. 登录 `john` / `888888`
2. 发送 `hi`,等待 assistant 回复
3. 发送 `帮我看看苏州攻略,1日攻略,做一个页面`
4. 验证:收到回复、含苏州关键词、有公网页面链接、HTTP 200、页面内容命中关键词
执行:
```bash
node scripts/run-scenario-test.mjs --scenario suzhou-page
```
## Agent 工作流
1. **确认场景**:用户未指定时默认 `suzhou-page`;说了别的流程则查 `--list` 或新建场景文件
2. **确认服务**Portal `/auth/status` 必须 200
3. **执行脚本**:完整跑完,不要中途改用浏览器手点
4. **失败处理**:读失败项 → 修代码或环境 → 重跑同一场景
5. **汇报**:用下方模板
## 扩展新场景
复制 [`scenarios/_template.json`](../../../scenarios/_template.json) 为 `scenarios/<id>.json`
步骤类型(当前支持):
| action | 说明 |
|--------|------|
| `login` | HTTP 登录,取 cookie + userId |
| `chat` | 发消息(同 session 多轮),等 run 终态 + assistant 新回复 |
`chat.expect` 可选断言:
- `assistantMinChars` / `timeoutMs`
- `replyKeywords`:回复文本关键词
- `page.keywords` / `requirePublicLink` / `requireHttp200` / `requireMindspaceCover`
新增更复杂步骤(截图、微信、多账户)时:先扩展 `scripts/scenario-test-lib.mjs`,再加场景 JSON,**不要**在 skill 里写死一次性命令。
## 结果模板
```markdown
## 场景测试结果
- 场景:suzhou-page(苏州攻略一日游页面)
- Portalhttp://127.0.0.1:8081
- 账户:john
- 结果:通过 / 失败
### 步骤
1. 登录 — 通过
2. hi → 回复 — 通过(N 字)
3. 苏州页面 — 通过 / 失败
### 页面(如有)
- 链接:
- HTTP
- 关键词:
### 失败项与下一步
```
## 与 memind-test 的分工
| 诉求 | 用哪个 |
|------|--------|
| 模拟真实用户聊天 + 做页面 | **本 skill**`run-scenario-test.mjs` |
| 改代码后跑单测/回归守卫 | `memind-test` skill`run-memind-tests.mjs` |
| 103 发包 | `portal-release` skill |
+78
View File
@@ -0,0 +1,78 @@
---
name: memind-test
description: >-
Run Memind scoped or release tests automatically after code changes. Use when
the user asks to test, verify, run tests, check regressions, or before
merging/releasing; also after editing protected MindSpace/chat/agent paths.
---
# Memind 自动测试
不要手动拼凑 `node --test` 命令。统一入口:
```bash
node scripts/run-memind-tests.mjs --mode <mode>
```
## 选模式
| 用户意图 | 模式 |
|----------|------|
| 改完代码、日常验证 | `changed`(默认) |
| 快速冒烟 | `quick` |
| 只跑回归守卫 | `guards` |
| 发包前 | `release` |
| 全量单测 | `full` |
示例:
```bash
node scripts/run-memind-tests.mjs
node scripts/run-memind-tests.mjs --mode quick
node scripts/run-memind-tests.mjs --mode release
```
对比基准分支时用 `--base origin/main`
## Agent 工作流
1. **判断模式**:用户说「测试/验证/跑一下」→ `changed`;说「发包前」→ `release`;说「全量」→ `full`
2. **执行**:在仓库根目录跑脚本,不要跳过。
3. **失败时**:读报错 → 修代码 → 用同一模式重跑,直到通过。
4. **汇报**:用下方模板,不要只说「通过了」。
## 受保护路径(改后必跑 guards)
改这些文件时,即使 `changed` 未命中也要加跑 `guards`
- `mindspace-public-finish-sync.mjs``chat-finish-sync.mjs`
- `conversation-display.mjs``src/utils/message.ts``src/hooks/useTKMindChat.ts`
- `mindspace-pages.mjs``mindspace-page-sync-service.mjs``server.mjs`
```bash
node scripts/run-memind-tests.mjs --mode guards
```
详见 [docs/regression-guards/README.md](../../../docs/regression-guards/README.md)。
## 结果模板
```markdown
## 测试结果
- 模式:`changed` / `quick` / `guards` / `release` / `full`
- 变更文件:N 个(列出关键路径)
- 执行:X 个 test + Y 个 verify
- 结果:通过 / 失败
### 失败项(如有)
- 命令:
- 错误摘要:
- 下一步:
```
## 与发布 skill 的关系
- 日常开发:本 skill + `changed` / `guards`
- 103 生产发布:用 `portal-release` skill,其发包前测试与本 skill 的 `release` 模式一致
- 不要混用「手动挑几个 test 文件」替代上述入口
@@ -0,0 +1,108 @@
---
name: memind-scenario-test
description: >-
Run Memind end-to-end scenario simulations (login, multi-turn chat, page
generation, public URL checks). Use when the user asks for 模拟场景测试, 场景测试,
E2E 流程验证, 模拟用户操作, or describes a step-by-step chat/page test flow.
---
# Memind 场景模拟测试
用户说「**帮我模拟场景测试**」或描述多步聊天/做页面流程时,**必须**走本 skill,不要手动拼 curl 或浏览器点选。
## 唯一入口
```bash
node scripts/run-scenario-test.mjs --scenario <id>
```
列出场景:
```bash
node scripts/run-scenario-test.mjs --list
```
## 前置条件
1. 本地 Portal 已运行(默认 `http://127.0.0.1:8081`,可用 `pnpm dev`
2. goosed / agent 链路可用(场景会真实发消息并等待 agent 回复)
3. 账户默认 `john` / `888888`;密码可用 `JOHN_PASSWORD` 覆盖
启动前检查:
```bash
curl -sf http://127.0.0.1:8081/auth/status
```
## 默认场景:苏州攻略页面
对应用户定义的流程,场景文件 [`scenarios/suzhou-page.json`](../../../scenarios/suzhou-page.json)
1. 登录 `john` / `888888`
2. 发送 `hi`,等待 assistant 回复
3. 发送 `帮我看看苏州攻略,1日攻略,做一个页面`
4. 验证:收到回复、含苏州关键词、有公网页面链接、HTTP 200、页面内容命中关键词
执行:
```bash
node scripts/run-scenario-test.mjs --scenario suzhou-page
```
## Agent 工作流
1. **确认场景**:用户未指定时默认 `suzhou-page`;说了别的流程则查 `--list` 或新建场景文件
2. **确认服务**Portal `/auth/status` 必须 200
3. **执行脚本**:完整跑完,不要中途改用浏览器手点
4. **失败处理**:读失败项 → 修代码或环境 → 重跑同一场景
5. **汇报**:用下方模板
## 扩展新场景
复制 [`scenarios/_template.json`](../../../scenarios/_template.json) 为 `scenarios/<id>.json`
步骤类型(当前支持):
| action | 说明 |
|--------|------|
| `login` | HTTP 登录,取 cookie + userId |
| `chat` | 发消息(同 session 多轮),等 run 终态 + assistant 新回复 |
`chat.expect` 可选断言:
- `assistantMinChars` / `timeoutMs`
- `replyKeywords`:回复文本关键词
- `page.keywords` / `requirePublicLink` / `requireHttp200` / `requireMindspaceCover`
新增更复杂步骤(截图、微信、多账户)时:先扩展 `scripts/scenario-test-lib.mjs`,再加场景 JSON,**不要**在 skill 里写死一次性命令。
## 结果模板
```markdown
## 场景测试结果
- 场景:suzhou-page(苏州攻略一日游页面)
- Portalhttp://127.0.0.1:8081
- 账户:john
- 结果:通过 / 失败
### 步骤
1. 登录 — 通过
2. hi → 回复 — 通过(N 字)
3. 苏州页面 — 通过 / 失败
### 页面(如有)
- 链接:
- HTTP
- 关键词:
### 失败项与下一步
```
## 与 memind-test 的分工
| 诉求 | 用哪个 |
|------|--------|
| 模拟真实用户聊天 + 做页面 | **本 skill**`run-scenario-test.mjs` |
| 改代码后跑单测/回归守卫 | `memind-test` skill`run-memind-tests.mjs` |
| 103 发包 | `portal-release` skill |
+79
View File
@@ -0,0 +1,79 @@
---
name: memind-test
description: >-
Run Memind scoped or release tests automatically after code changes. Use when
the user asks to test, verify, run tests, check regressions, or before
merging/releasing; also after editing protected MindSpace/chat/agent paths.
---
# Memind 自动测试
不要手动拼凑 `node --test` 命令。统一入口:
```bash
node scripts/run-memind-tests.mjs --mode <mode>
```
## 选模式
| 用户意图 | 模式 |
|----------|------|
| 改完代码、日常验证 | `changed`(默认) |
| 快速冒烟 | `quick` |
| 只跑回归守卫 | `guards` |
| 发包前 | `release` |
| 全量单测 | `full` |
示例:
```bash
node scripts/run-memind-tests.mjs
node scripts/run-memind-tests.mjs --mode quick
node scripts/run-memind-tests.mjs --mode release
```
对比基准分支时用 `--base origin/main`
## Agent 工作流
1. **判断模式**:用户说「测试/验证/跑一下」→ `changed`;说「发包前」→ `release`;说「全量」→ `full`
2. **执行**:在仓库根目录跑脚本,不要跳过。
3. **失败时**:读报错 → 修代码 → 用同一模式重跑,直到通过。
4. **汇报**:用下方模板,不要只说「通过了」。
## 受保护路径(改后必跑 guards)
改这些文件时,即使 `changed` 未命中也要加跑 `guards`
- `mindspace-public-finish-sync.mjs``chat-finish-sync.mjs`
- `conversation-display.mjs``src/utils/message.ts``src/hooks/useTKMindChat.ts`
- `mindspace-pages.mjs``mindspace-page-sync-service.mjs``server.mjs`
```bash
node scripts/run-memind-tests.mjs --mode guards
```
详见 [docs/regression-guards/README.md](../../../docs/regression-guards/README.md)。
## 结果模板
```markdown
## 测试结果
- 模式:`changed` / `quick` / `guards` / `release` / `full`
- 变更文件:N 个(列出关键路径)
- 执行:X 个 test + Y 个 verify
- 结果:通过 / 失败
### 失败项(如有)
- 命令:
- 错误摘要:
- 下一步:
```
## 与发布 skill 的关系
- 日常开发:本 skill + `changed` / `guards`
- **模拟用户聊天/做页面**:用 `memind-scenario-test` skill`run-scenario-test.mjs`
- 103 生产发布:用 `portal-release` skill,其发包前测试与本 skill 的 `release` 模式一致
- 不要混用「手动挑几个 test 文件」替代上述入口
+42 -11
View File
@@ -3,6 +3,8 @@ import {
buildWebNewsSkillPrompt,
extractSelectedChatSkillName,
hasExplicitChatSkillPrompt,
isPageGenerationIntent,
isProductCampaignIntent,
} from './chat-skills.mjs';
import { isDirectChatSessionId } from './direct-chat-service.mjs';
import {
@@ -111,6 +113,26 @@ function buildRealtimeInfoClassification() {
}, { source: 'rule' });
}
export function coercePageGenerationSkill(classification, text, { grantedSkills = [] } = {}) {
if (!classification || classification.route !== CHAT_INTENT_ROUTE.AGENT) return classification;
if (!isPageGenerationIntent(text) || isProductCampaignIntent(text)) return classification;
if (grantedSkills.length > 0 && !grantedSkills.includes('static-page-publish')) return classification;
const shouldUsePublishSkill =
!classification.suggestedSkill || classification.suggestedSkill === 'product-campaign-page';
if (!shouldUsePublishSkill) return classification;
const reasonSuffix = classification.suggestedSkill === 'product-campaign-page'
? '(内容页意图,改用 static-page-publish'
: '(内容页生成意图)';
return {
...classification,
suggestedSkill: 'static-page-publish',
agentBrief: '生成或更新 MindSpace 公开内容页面,并返回可访问链接;信息不足时使用合理默认,不要停在追问。',
reason: `${classification.reason}${reasonSuffix}`,
};
}
export function coerceRealtimeWebSkill(classification, text, { grantedSkills = [] } = {}) {
if (!classification || classification.route !== CHAT_INTENT_ROUTE.AGENT) return classification;
if (!isRealtimeInfoQuestion(text)) return classification;
@@ -276,6 +298,10 @@ function buildRouterSystemPrompt(grantedSkills = []) {
? '若走 agent_orchestration,可在 suggested_skill 中填写最匹配的 skill 名称(须来自上述列表),否则填 null。'
: 'suggested_skill 通常填 null。',
'',
'skill 选择补充:',
'- static-page-publish:攻略/游记/主题内容页、城市介绍、活动介绍、任何「做页面/生成链接」且无商品购买跳转需求。',
'- product-campaign-page:仅当用户明确提供商品链接或要求购买按钮/电商转化时使用,不要用于旅游攻略或纯内容页。',
'',
'只输出 JSON,不要 markdown,不要解释:',
'{"route":"direct_chat|agent_orchestration","confidence":0.0,"reason":"一句话","suggested_skill":null,"agent_brief":"给 Agent 的执行要点,direct_chat 时可为空"}',
].join('\n');
@@ -713,14 +739,15 @@ export function classifyWithRules({
if (
includeIntentPatterns &&
normalized &&
OBVIOUS_AGENT_PATTERNS.some((pattern) => pattern.test(normalized))
(OBVIOUS_AGENT_PATTERNS.some((pattern) => pattern.test(normalized))
|| isPageGenerationIntent(normalized))
) {
return finalizeRouterClassification(normalizeClassification({
route: CHAT_INTENT_ROUTE.AGENT,
confidence: 0.95,
reason: '明确需要生成或发布页面/文件',
suggested_skill: 'static-page-publish',
agent_brief: '生成或更新 MindSpace 公开页面,并返回可访问链接。',
agent_brief: '生成或更新 MindSpace 公开页面,并返回可访问链接;信息不足时使用合理默认,不要停在追问。',
}, { source: 'rule' }), decisionContext);
}
if (includeIntentPatterns && normalized && isRealtimeInfoQuestion(normalized)) {
@@ -848,12 +875,16 @@ export function createChatIntentRouter(options = {}) {
sessionId,
sessionMessageCount,
};
const finalizeWithRealtimeCoercion = (classification) => {
const finalizeWithCoercion = (classification) => {
if (!classification) return classification;
const base = { ...classification };
delete base.decision;
return finalizeRouterClassification(
coerceRealtimeWebSkill(base, text, { grantedSkills }),
coercePageGenerationSkill(
coerceRealtimeWebSkill(base, text, { grantedSkills }),
text,
{ grantedSkills },
),
decisionContext,
);
};
@@ -867,9 +898,9 @@ export function createChatIntentRouter(options = {}) {
includeIntentPatterns: true,
llmRouterEnabled: Boolean(policy.enabled),
});
if (ruleResult) return finalizeWithRealtimeCoercion(ruleResult);
if (ruleResult) return finalizeWithCoercion(ruleResult);
if (!isEnabled()) {
return finalizeWithRealtimeCoercion(normalizeClassification({
return finalizeWithCoercion(normalizeClassification({
route: policy.fallbackRoute,
confidence: 0.5,
reason: '意图路由未启用,走默认通道',
@@ -905,7 +936,7 @@ export function createChatIntentRouter(options = {}) {
'Chat intent router',
);
} catch (err) {
return finalizeWithRealtimeCoercion(normalizeClassification({
return finalizeWithCoercion(normalizeClassification({
route: policy.fallbackRoute,
confidence: 0,
reason: err instanceof Error ? err.message : '意图路由失败,走默认通道',
@@ -913,7 +944,7 @@ export function createChatIntentRouter(options = {}) {
}, { source: 'fallback', fallbackRoute: policy.fallbackRoute }));
}
if (!completion?.ok) {
return finalizeWithRealtimeCoercion(normalizeClassification({
return finalizeWithCoercion(normalizeClassification({
route: policy.fallbackRoute,
confidence: 0,
reason: completion?.message ?? '意图路由失败,走默认通道',
@@ -923,7 +954,7 @@ export function createChatIntentRouter(options = {}) {
const parsed = parseRouterJson(completion.reply);
if (!parsed) {
return finalizeWithRealtimeCoercion(normalizeClassification({
return finalizeWithCoercion(normalizeClassification({
route: policy.fallbackRoute,
confidence: 0,
reason: '意图路由响应无法解析,走默认通道',
@@ -931,7 +962,7 @@ export function createChatIntentRouter(options = {}) {
}, { source: 'fallback', fallbackRoute: policy.fallbackRoute }));
}
const classification = finalizeWithRealtimeCoercion({
const classification = finalizeWithCoercion({
...normalizeClassification(parsed, { source: 'llm', fallbackRoute: policy.fallbackRoute }),
providerKeyId: completion.providerKeyId ?? policy.modelProviderKeyId ?? null,
model: completion.model ?? policy.model ?? null,
@@ -941,7 +972,7 @@ export function createChatIntentRouter(options = {}) {
classification.route === CHAT_INTENT_ROUTE.DIRECT_CHAT &&
classification.confidence < policy.minConfidence
) {
return finalizeWithRealtimeCoercion(normalizeClassification({
return finalizeWithCoercion(normalizeClassification({
...classification,
route: policy.fallbackRoute,
reason: `${classification.reason}(置信度 ${classification.confidence} 低于阈值,走默认通道)`,
+32
View File
@@ -7,6 +7,7 @@ import {
buildRouterNormalizedDecision,
CHAT_INTENT_ROUTE,
classifyWithRules,
coercePageGenerationSkill,
createChatIntentRouter,
createManagedChatIntentRouter,
isNormalizedRouterDecisionEnabled,
@@ -49,6 +50,37 @@ test('classifyWithRules routes page generation to agent orchestration', () => {
assert.equal(result.suggestedSkill, 'static-page-publish');
});
test('classifyWithRules routes implicit travel guide page requests to static-page-publish', () => {
const result = classifyWithRules({
text: '苏州攻略页面',
userMessage: {
role: 'user',
content: [{ type: 'text', text: '苏州攻略页面' }],
metadata: { displayText: '苏州攻略页面' },
},
});
assert.equal(result.route, CHAT_INTENT_ROUTE.AGENT);
assert.equal(result.suggestedSkill, 'static-page-publish');
assert.match(result.agentBrief, /不要停在追问/);
});
test('coercePageGenerationSkill replaces product-campaign-page for content pages', () => {
const coerced = coercePageGenerationSkill(
{
route: CHAT_INTENT_ROUTE.AGENT,
suggestedSkill: 'product-campaign-page',
reason: '模型路由判定',
agentBrief: '',
confidence: 0.9,
source: 'llm',
},
'苏州攻略页面',
{ grantedSkills: ['static-page-publish', 'product-campaign-page'] },
);
assert.equal(coerced.suggestedSkill, 'static-page-publish');
assert.match(coerced.reason, /static-page-publish/);
});
test('classifyWithRules routes themed article requests to agent orchestration', () => {
const result = classifyWithRules({
text: '帮我写个主题文章',
+21 -4
View File
@@ -17,8 +17,28 @@ const 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,
// 隐式页面需求:主题/攻略 + 页面,无显式动词(如「苏州攻略页面」)
/(?:攻略|指南|游记|手册|简介|介绍|展示).{0,8}(?:页面|网页|H5|h5|html)/u,
/[^\s,。!?]{2,20}(?:攻略|指南).{0,4}(?:页面|网页)/u,
];
const PRODUCT_CAMPAIGN_INTENT_PATTERNS = [
/(?:商品|购买|下单|种草|带货|促销|上架|SKU|店铺|电商)/u,
/(?:https?:\/\/|taobao|tmall|jd\.com|商品链接|购买链接|购买按钮)/iu,
];
export function isPageGenerationIntent(text) {
const normalized = String(text ?? '').trim();
if (!normalized) return false;
return PAGE_GENERATION_INTENT_PATTERNS.some((pattern) => pattern.test(normalized));
}
export function isProductCampaignIntent(text) {
const normalized = String(text ?? '').trim();
if (!normalized) return false;
return PRODUCT_CAMPAIGN_INTENT_PATTERNS.some((pattern) => pattern.test(normalized));
}
export const CHAT_SKILL_DEFINITIONS = [
{
id: 'web-search',
@@ -149,10 +169,7 @@ export function filterChatSkills(options, ctx) {
export function buildAutoChatSkillPrefix(text, grantedSkills = []) {
const trimmed = String(text ?? '').trim();
if (!trimmed) return '';
if (
grantedSkills.includes(PUBLISH_SKILL_NAME) &&
PAGE_GENERATION_INTENT_PATTERNS.some((pattern) => pattern.test(trimmed))
) {
if (grantedSkills.includes(PUBLISH_SKILL_NAME) && isPageGenerationIntent(trimmed)) {
return buildChatSkillPrompt('generate-page', PUBLISH_SKILL_NAME);
}
if (!grantedSkills.includes('web')) return '';
+13
View File
@@ -5,6 +5,7 @@ import {
buildChatSkillPrompt,
CHAT_SKILL_DEFINITIONS,
filterChatSkills,
isPageGenerationIntent,
} from './chat-skills.mjs';
test('filterChatSkills shows summarize and analyze without granted skills', () => {
@@ -79,3 +80,15 @@ test('buildAutoChatSkillPrefix enables publish for page generation requests', ()
assert.match(prefix, /禁止只给本地路径/);
assert.equal(buildAutoChatSkillPrefix('帮我做一个 Hello 糖的 H5 页面', []), '');
});
test('isPageGenerationIntent matches implicit travel guide page requests', () => {
assert.equal(isPageGenerationIntent('苏州攻略页面'), true);
assert.equal(isPageGenerationIntent('帮我看看苏州攻略,1日攻略,做一个页面'), true);
assert.equal(isPageGenerationIntent('你好'), false);
});
test('buildAutoChatSkillPrefix enables publish for implicit page requests', () => {
const prefix = buildAutoChatSkillPrefix('苏州攻略页面', ['static-page-publish']);
assert.match(prefix, /static-page-publish/);
assert.match(prefix, /禁止询问是否还要发布/);
});
-52
View File
@@ -1,52 +0,0 @@
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;
}
}
+6 -6
View File
@@ -8,8 +8,8 @@ Production safety rules:
- Do not delete PG data.
- Do not delete or rewrite MindSpace user data. On current 103 production, MindSpace Service root is `/Users/john/MindSpace`; `/Users/john/Project/Memind/MindSpace` is legacy compatibility/storage context. Confirm against [103 runtime topology](./103-runtime-topology.md) before any file operation.
- Keep H5 public traffic on `https://mm.tkmind.cn`.
- Treat `m.tkmind.cn` / 105 tunnel as legacy rollback transport, not the primary H5 path.
- Keep H5 public traffic on `https://m.tkmind.cn`.
- Treat the 105 tunnel path as legacy rollback transport, not the primary H5 path.
- Keep default production state closed unless a rollout window is explicitly active.
## Default Closed State
@@ -18,7 +18,7 @@ Expected production state:
```bash
cd /Users/john/Project/Memind
curl -fsS https://mm.tkmind.cn/api/runtime/status
curl -fsS https://m.tkmind.cn/api/runtime/status
node scripts/check-agent-run-worker.mjs
node scripts/runtime-slo-report.mjs
```
@@ -140,7 +140,7 @@ launchctl kickstart -k "gui/$(id -u)/cn.tkmind.memind-portal"
4. Verify Portal policy.
```bash
curl -fsS https://mm.tkmind.cn/api/runtime/status | node -e '
curl -fsS https://m.tkmind.cn/api/runtime/status | node -e '
let s="";process.stdin.on("data",d=>s+=d);process.stdin.on("end",()=>{
const j=JSON.parse(s);
console.log(JSON.stringify({
@@ -297,7 +297,7 @@ MEMIND_AGENT_RUN_WORKER_START=0 bash /Users/john/Project/Memind/scripts/install-
4. Verify closed state.
```bash
curl -fsS https://mm.tkmind.cn/api/runtime/status
curl -fsS https://m.tkmind.cn/api/runtime/status
node /Users/john/Project/Memind/scripts/check-agent-run-worker.mjs
node /Users/john/Project/Memind/scripts/runtime-slo-report.mjs
```
@@ -309,7 +309,7 @@ If code tasks appear stuck:
```bash
node /Users/john/Project/Memind/scripts/check-agent-run-worker.mjs
tail -200 /Users/john/Library/Logs/memind-agent-run-worker.log
curl -fsS https://mm.tkmind.cn/api/runtime/status
curl -fsS https://m.tkmind.cn/api/runtime/status
```
If `oldestPendingAgeMs` grows while worker is running:
@@ -11,13 +11,13 @@
- 分支: `memind-streaming-runtime-20260702`
- 最新提交: 本文档所在分支 HEAD,提交信息 `fix: harden auth middleware against transient db errors`
- 最新 release: `20260702-104734-212e163` 后续改造以生产备份和分支提交方式推进。
- 当前公网入口: `https://mm.tkmind.cn`
- 当前公网入口: `https://m.tkmind.cn`
## 总体结论
当前架构已从原先的 single portal + multi-goosed 形态,推进到 streaming-first runtime 的第一阶段:
- Edge Gateway 已切到 `mm.tkmind.cn -> local nginx -> Portal :8081`
- Edge Gateway 已切到 `m.tkmind.cn -> local nginx -> Portal :8081`
- Stream Controller 已具备 SSE headers、abort propagation、backpressure pipeline。
- Redis Router 已启用,承担 worker runtime state。
- Goose Worker Pool 已从固定单点走向四 worker 可观测调度。
@@ -48,12 +48,12 @@
- 验证:
- `node --test agent-run-gateway.test.mjs tool-gateway.test.mjs`: `15 passed`
- `npm run build:portal-runtime`: passed
- `https://mm.tkmind.cn/api/runtime/status`: `ok=true`
- `https://m.tkmind.cn/api/runtime/status`: `ok=true`
- `scripts/check-tool-runtime.mjs`: `ok=true`
- `scripts/runtime-slo-report.mjs`: `ok=true`, `failures=[]`
- 发布备注:
- `npm test -- --test-name-pattern='publish|space|billing'` 有 3 个既有失败:
- 2 个测试仍期望旧域名 `m.tkmind.cn`,但当前生产基线是 `mm.tkmind.cn`
- 2 个测试仍期望旧域名 `m.tkmind.cn`,但当前生产基线是 `m.tkmind.cn`
- 1 个测试期望 executor command 缺失,但本机当前已有对应命令
- 因失败项与 P6.3 无关,发布使用 `--skip-tests`,仍执行构建和 publish guard。
- 数据边界:
@@ -102,7 +102,7 @@
- related tests: `43 passed`
- 发布运行态:
- 发布脚本在 legacy `m.tkmind.cn` tunnel 检查处返回 1。
- `mm.tkmind.cn` 主路径和 Portal runtime 已正常。
- `m.tkmind.cn` 主路径和 Portal runtime 已正常。
- 已清理占用 8081 的孤儿 Portal node,让 LaunchAgent 接管。
- final Portal listener: pid `86462`, port `8081`
- 第二次真实 canary:
@@ -473,9 +473,9 @@ Data boundary:
- `npm run build:portal-runtime`: passed
- 发布备注:
- release 脚本在 legacy `m.tkmind.cn` tunnel step 短暂返回失败。
- Portal runtime 已切换,`mm.tkmind.cn` 主路径健康。
- Portal runtime 已切换,`m.tkmind.cn` 主路径健康。
- 后续检查显示 `cn.tkmind.memind-portal-tunnel` 已恢复 running。
- 当前 H5 主路径仍为 `https://mm.tkmind.cn`,不依赖 105 转发。
- 当前 H5 主路径仍为 `https://m.tkmind.cn`,不依赖 105 转发。
- Final runtime:
- `toolRuntime.codeRunPolicy.enabled=false`
- `toolRuntime.codeRunPolicy.userAllowlist=[]`
@@ -580,10 +580,10 @@ Data boundary:
结果: 通过。
- `https://mm.tkmind.cn/api/status` 返回 `ok`
- `H5_PUBLIC_BASE_URL=https://mm.tkmind.cn`
- `https://m.tkmind.cn/api/status` 返回 `ok`
- `H5_PUBLIC_BASE_URL=https://m.tkmind.cn`
- `m.tkmind.cn` 已作为 legacy/rollback 记录,不作为后续 H5 主路径。
- nginx `mm.tkmind.cn` 已对 SSE 路由禁用 buffering/cache/gzip,并设置长 read/send timeout。
- nginx `m.tkmind.cn` 已对 SSE 路由禁用 buffering/cache/gzip,并设置长 read/send timeout。
- SSE route:
- `/api/sessions/<id>/events`
- `/api/agent/runs/<id>/events`
@@ -7,7 +7,7 @@
执行状态:
- P0 StreamController v1: 已完成代码落地、语法检查、Portal kickstart 和基础健康验证。
- P1 Gateway SSE 配置: 已完成,`mm.tkmind.cn` 接入 PortalSSE route 已禁缓冲。
- P1 Gateway SSE 配置: 已完成,`m.tkmind.cn` 接入 PortalSSE route 已禁缓冲。
- P2 Redis Runtime State + Router v1: 已完成可回退版本并启用 Redis。
- P3 Observability: 已完成第一版,只读 runtime 状态接口已上线。
- P4 Tool Gateway v1: 已完成第一步,普通用户默认不再暴露 Aider/OpenHands,显式用户白名单保留。
@@ -53,7 +53,7 @@
## 当前基线
- Memind Portal: `/Users/john/Project/Memind/server.mjs`,本机 `:8081`
- H5 public base: `https://mm.tkmind.cn``m.tkmind.cn` 暂时不再作为 H5 public base
- H5 public base: `https://m.tkmind.cn`
- goosed worker pool: Docker/Colima 内 `goosed-prod-1..9`,宿主机 `18006..18014`2026-07-07 确认 9 个 healthy)。
- H5 上游配置: `TKMIND_API_TARGETS=https://127.0.0.1:18006,...,18014`(须与 compose 容器数一致)。
- 当前已有 session affinity: start 时分配 worker,后续 reply/events 回到同一 worker。
@@ -117,7 +117,7 @@ flowchart LR
### P1: Gateway SSE 配置
目标: `mm.tkmind.cn` 新入口和本机入口不缓冲 SSE;后续 H5 不再走 105 nginx 反向隧道转发。
目标: `m.tkmind.cn` 新入口和本机入口不缓冲 SSE;后续 H5 不再走 105 nginx 反向隧道转发。
任务:
@@ -128,12 +128,12 @@ flowchart LR
- `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/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。
- `m.tkmind.cn/api/status` 返回后端 `ok` 或明确 JSON health,而不是维护页 HTML。
- SSE 响应头包含 `X-Accel-Buffering: no`
### P2: Redis Runtime State + Router v1
@@ -220,7 +220,7 @@ 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
curl -sk https://m.tkmind.cn/api/runtime/status
docker exec memind-runtime-redis redis-cli DEL memind:runtime:worker:goosed-3:drain
```
@@ -233,7 +233,7 @@ docker exec memind-runtime-redis redis-cli DEL memind:runtime:worker:goosed-3:dr
## 执行顺序
1. P0 立即执行,低风险高收益。
2. P1 基于 `mm.tkmind.cn` 新入口执行;除非明确回滚,不再改造 105 H5 转发链路。
2. P1 基于 `m.tkmind.cn` 新入口执行;除非明确回滚,不再改造 105 H5 转发链路。
3. P2/P3 一起推进,先观测再调度。
4. P4/P5 在流式链路稳定后推进。
@@ -272,7 +272,7 @@ docker exec memind-runtime-redis redis-cli DEL memind:runtime:worker:goosed-3:dr
决策:
- H5 public base 从 `https://m.tkmind.cn` 临时切换为 `https://mm.tkmind.cn`
- H5 public base 从 `https://m.tkmind.cn` 临时切换为 `https://m.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 改造目标。
@@ -285,25 +285,25 @@ docker exec memind-runtime-redis redis-cli DEL memind:runtime:worker:goosed-3:dr
已执行:
-`H5_PUBLIC_BASE_URL` 改为 `https://mm.tkmind.cn`
-`VITE_MINDSPACE_BASE` 改为 `https://mm.tkmind.cn`
- 将 Portal 启动脚本默认 `H5_PUBLIC_BASE_URL` 改为 `https://mm.tkmind.cn`
-`H5_PUBLIC_BASE_URL` 改为 `https://m.tkmind.cn`
-`VITE_MINDSPACE_BASE` 改为 `https://m.tkmind.cn`
- 将 Portal 启动脚本默认 `H5_PUBLIC_BASE_URL` 改为 `https://m.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`
- 运行环境确认: `H5_PUBLIC_BASE_URL=https://m.tkmind.cn``VITE_MINDSPACE_BASE=https://m.tkmind.cn`
- 本机 `http://127.0.0.1:8081/api/status` 返回 `ok`
- 九个 goosed worker `18006..18014` 均返回 `ok`
- 公网 `https://mm.tkmind.cn/api/status` 当前返回 nginx `502 Bad Gateway`,说明域名侧入口尚未接到当前 Portal;该问题纳入 P1 新入口修复,不再回到 105 转发链路处理。
- 公网 `https://m.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`
- `/opt/homebrew/etc/nginx/servers/` 下对应 `m.tkmind.cn` 的生产入口配置
- `/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`
@@ -311,11 +311,11 @@ docker exec memind-runtime-redis redis-cli DEL memind:runtime:worker:goosed-3:dr
备份:
- `/opt/homebrew/etc/nginx/backups/mm.tkmind.cn.conf.bak-streaming-20260702-0635`
- `/opt/homebrew/etc/nginx/backups/` 下对应 `m.tkmind.cn` 的 streaming 备份配置
已完成:
- `mm.tkmind.cn` 通过本机 nginx 反向代理到 Portal `127.0.0.1:8081`
- `m.tkmind.cn` 通过本机 nginx 反向代理到 Portal `127.0.0.1:8081`
-`/api/sessions/<id>/events``/api/agent/runs/<id>/events` 增加 SSE 专用 nginx location:
- `proxy_buffering off`
- `proxy_cache off`
@@ -324,16 +324,16 @@ docker exec memind-runtime-redis redis-cli DEL memind:runtime:worker:goosed-3:dr
- `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`
- 微信菜单脚本默认入口改为 `https://m.tkmind.cn``https://m.tkmind.cn/space`
- `server.mjs``H5_PUBLIC_BASE_URL` 缺省值改为 `https://m.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`
- `https://m.tkmind.cn/api/status` 返回 `ok`
- 未登录访问 `https://m.tkmind.cn/api/sessions/test-session/events` 返回 `401`,且响应头包含 `x-accel-buffering: no`
- 未登录访问 `https://m.tkmind.cn/api/agent/runs/test-run/events` 返回 `401`,且响应头包含 `x-accel-buffering: no`
### 2026-07-02 P2 Redis Runtime State + Router v1
@@ -375,7 +375,7 @@ docker exec memind-runtime-redis redis-cli DEL memind:runtime:worker:goosed-3:dr
- `memind:runtime:worker:goosed-3:active_streams=1`
- `memind:runtime:worker:goosed-3:heartbeat=<timestamp>`
- `memind:runtime:stream:20260701_24:status=active`
- `https://mm.tkmind.cn/api/status` 返回 `ok`
- `https://m.tkmind.cn/api/status` 返回 `ok`
- 九个 goosed worker `18006..18014` 均返回 `ok`
### 2026-07-02 P3 Observability v1
@@ -398,11 +398,11 @@ docker exec memind-runtime-redis redis-cli DEL memind:runtime:worker:goosed-3:dr
验证:
- `/opt/homebrew/opt/node@24/bin/node --check /Users/john/Project/Memind/server.mjs` 通过。
- `https://mm.tkmind.cn/api/runtime/status` 返回 `ok: true`
- `https://m.tkmind.cn/api/runtime/status` 返回 `ok: true`
- 返回中 `router.enabled=true`namespace 为 `memind:runtime`
- 返回中九个目标 `18006..18014``healthy: true`
- `https://mm.tkmind.cn/api/status` 返回 `ok`
- 未登录访问 `https://mm.tkmind.cn/api/sessions/test-session/events` 返回 `401`,且响应头包含 `x-accel-buffering: no`
- `https://m.tkmind.cn/api/status` 返回 `ok`
- 未登录访问 `https://m.tkmind.cn/api/sessions/test-session/events` 返回 `401`,且响应头包含 `x-accel-buffering: no`
### 2026-07-02 P4 Tool Gateway v1 第一阶段
@@ -435,10 +435,10 @@ docker exec memind-runtime-redis redis-cli DEL memind:runtime:worker:goosed-3:dr
验证:
- `/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`
- `https://m.tkmind.cn/api/runtime/status` 返回四个 worker `healthy=true`
- `https://m.tkmind.cn/api/runtime/status` 返回四个 worker `drain=false`
- `https://m.tkmind.cn/api/status` 返回 `ok`
- 未登录访问 `https://m.tkmind.cn/api/sessions/test-session/events` 返回 `401`,且响应头包含 `x-accel-buffering: no`
### 2026-07-02 生产同步分支
@@ -453,7 +453,7 @@ docker exec memind-runtime-redis redis-cli DEL memind:runtime:worker:goosed-3:dr
- 新建干净源码目录 `/Users/john/Project/memind-clean-main-20260702`
-`origin/main` 创建分支 `memind-streaming-runtime-20260702`
- 迁入本次已在生产验证的 StreamController、Redis Router、runtime status、`mm.tkmind.cn` 和 nginx SSE 配置样例。
- 迁入本次已在生产验证的 StreamController、Redis Router、runtime status、`m.tkmind.cn` 和 nginx SSE 配置样例。
- 新增远程开发机同步说明 `docs/architecture/remote-dev-sync.md`
限制:
@@ -485,7 +485,7 @@ docker exec memind-runtime-redis redis-cli DEL memind:runtime:worker:goosed-3:dr
- `score`
- 新增只读检查脚本 `scripts/check-stream-runtime.mjs`
- 新增 drain 运维脚本 `scripts/runtime-worker-drain.mjs`
- runtime 构建模板同步上述脚本,并将 RUNBOOK 中主路径更新为 `mm.tkmind.cn -> local nginx -> Portal :8081`
- runtime 构建模板同步上述脚本,并将 RUNBOOK 中主路径更新为 `m.tkmind.cn -> local nginx -> Portal :8081`
- 本地和远端分支提交 `4420cca chore: add runtime observability ops`
- 修正检查脚本为 `HEAD` 探测 SSE 入口,避免健康检查打开真实上游 SSE;提交 `c9b7252 fix: avoid opening sse in runtime check`
@@ -576,8 +576,8 @@ docker exec memind-runtime-redis redis-cli DEL memind:runtime:worker:goosed-3:dr
生产验证:
- `https://mm.tkmind.cn/api/status` 返回 `ok`
- `https://mm.tkmind.cn/api/runtime/status` 返回 router enabled,全部 goosed target healthy。
- `https://m.tkmind.cn/api/status` 返回 `ok`
- `https://m.tkmind.cn/api/runtime/status` 返回 router enabled,全部 goosed target healthy。
- `scripts/check-stream-runtime.mjs` 返回 `ok=true`
- `scripts/check-tool-runtime.mjs` 返回 `ok=true`,普通 chat 不注入 Aider/OpenHandscode mode 对白名单用户可注入。
- `scripts/runtime-worker-drain.mjs status` 返回 `ok=true`,四个 worker drain=false。
@@ -676,7 +676,7 @@ docker exec memind-runtime-redis redis-cli DEL memind:runtime:worker:goosed-3:dr
生产验证:
- `https://mm.tkmind.cn/api/status` 返回 `ok`
- `https://m.tkmind.cn/api/status` 返回 `ok`
- `/api/runtime/status.toolRuntime.codeRunsEnabled=false`
- 全部 goosed target healthy。
- `scripts/check-agent-code-run-entry.mjs` 返回 `ok=true`
@@ -735,7 +735,7 @@ docker exec memind-runtime-redis redis-cli DEL memind:runtime:worker:goosed-3:dr
- `dryRun=true`
- 四个 worker 均列出。
- 当前无 stale worker,未执行 apply。
- `https://mm.tkmind.cn/api/status` 返回 `ok`
- `https://m.tkmind.cn/api/status` 返回 `ok`
### 2026-07-02 P5.7 Worker Metrics 采样接入
@@ -799,7 +799,7 @@ docker exec memind-runtime-redis redis-cli DEL memind:runtime:worker:goosed-3:dr
- `containerHealth`
- `metricsSampledAt`
-`score`
- `https://mm.tkmind.cn/api/status` 返回 `ok`
- `https://m.tkmind.cn/api/status` 返回 `ok`
- `scripts/check-stream-runtime.mjs` 返回 `ok=true`
- `scripts/check-agent-code-run-entry.mjs` 返回 `ok=true`
- `scripts/runtime-worker-drain.mjs reconcile` 返回 `ok=true``dryRun=true`
@@ -903,7 +903,7 @@ docker exec memind-runtime-redis redis-cli DEL memind:runtime:worker:goosed-3:dr
- `/Users/john/Project/Memind/server.mjs`
- `/Users/john/Project/Memind/scripts/runtime-slo-report.mjs`
- 已执行 `launchctl kickstart -k gui/$(id -u)/cn.tkmind.memind-portal`
- 重启窗口内 `mm.tkmind.cn` 曾短暂返回 nginx `502`Portal 随后正常监听 `:8081`,公网 `https://mm.tkmind.cn/api/status` 返回 `ok`
- 重启窗口内 `m.tkmind.cn` 曾短暂返回 nginx `502`Portal 随后正常监听 `:8081`,公网 `https://m.tkmind.cn/api/status` 返回 `ok`
生产验证:
@@ -963,7 +963,7 @@ docker exec memind-runtime-redis redis-cli DEL memind:runtime:worker:goosed-3:dr
- `/Users/john/Project/Memind/server.mjs`
- `/Users/john/Project/Memind/scripts/runtime-slo-report.mjs`
- 已执行 `launchctl kickstart -k gui/$(id -u)/cn.tkmind.memind-portal`
- 重启窗口内 `mm.tkmind.cn` 曾短暂返回 nginx `502`Portal 随后正常监听 `:8081`,公网 `https://mm.tkmind.cn/api/status` 返回 `ok`
- 重启窗口内 `m.tkmind.cn` 曾短暂返回 nginx `502`Portal 随后正常监听 `:8081`,公网 `https://m.tkmind.cn/api/status` 返回 `ok`
生产验证:
@@ -1029,7 +1029,7 @@ docker exec memind-runtime-redis redis-cli DEL memind:runtime:worker:goosed-3:dr
- `/Users/john/Project/Memind/scripts/agent-run-worker.mjs`
- `/Users/john/Project/Memind/RUNBOOK.txt`
- 已执行 `launchctl kickstart -k gui/$(id -u)/cn.tkmind.memind-portal`
- 重启窗口内 `mm.tkmind.cn` 曾短暂返回 nginx `502`Portal 随后正常监听 `:8081`,公网 runtime status 返回 `ok=true`
- 重启窗口内 `m.tkmind.cn` 曾短暂返回 nginx `502`Portal 随后正常监听 `:8081`,公网 runtime status 返回 `ok=true`
生产验证:
@@ -1084,7 +1084,7 @@ docker exec memind-runtime-redis redis-cli DEL memind:runtime:worker:goosed-3:dr
- `/Users/john/Project/Memind/scripts/runtime-slo-report.mjs`
- `/Users/john/Project/Memind/scripts/agent-run-worker.mjs`
- 已执行 `launchctl kickstart -k gui/$(id -u)/cn.tkmind.memind-portal`
- 重启窗口内 `mm.tkmind.cn` 曾短暂返回 nginx `502`Portal 随后正常监听 `:8081`,公网 `https://mm.tkmind.cn/api/status` 返回 `ok`
- 重启窗口内 `m.tkmind.cn` 曾短暂返回 nginx `502`Portal 随后正常监听 `:8081`,公网 `https://m.tkmind.cn/api/status` 返回 `ok`
生产验证:
@@ -1333,7 +1333,7 @@ docker exec memind-runtime-redis redis-cli DEL memind:runtime:worker:goosed-3:dr
- `toolRuntime.queue.inFlight=0`
- `toolRuntime.queue.pendingDispatches=0`
- `toolRuntime.queue.statusCounts={}`
- `mm.tkmind.cn` runtime health 仍为 `ok=true`
- `m.tkmind.cn` runtime health 仍为 `ok=true`
### 2026-07-02 P5.13 Runtime SLO Report Retention
@@ -1444,7 +1444,7 @@ docker exec memind-runtime-redis redis-cli DEL memind:runtime:worker:goosed-3:dr
发布后状态:
- `https://mm.tkmind.cn/api/status`: `ok`
- `https://m.tkmind.cn/api/status`: `ok`
- `toolRuntime.codeRunsEnabled=false`
- `toolRuntime.chatInjectsCodeTools=false`
- `toolRuntime.queue.autoDispatch=true`
@@ -1536,7 +1536,7 @@ canary 验证:
- `/Users/john/Project/backups/memind/memind-persisted-20260702-085706-a0af049-before.tar.gz`
- `/Users/john/Project/archives/Memind-source-before-20260702-085706-a0af049`
- 恢复后验证:
- `https://mm.tkmind.cn/api/status`: `ok`
- `https://m.tkmind.cn/api/status`: `ok`
- `toolRuntime.codeRunsEnabled=false`
- `toolRuntime.chatInjectsCodeTools=false`
- `toolRuntime.queue.autoDispatch=true`
@@ -1668,8 +1668,8 @@ canary 验证:
- 未删除 PG 数据
- 未删除或改写 MindSpace 用户数据
- 发布验证:
- `https://mm.tkmind.cn/api/status`: `ok`
- `https://mm.tkmind.cn/api/runtime/status`: `ok=true`
- `https://m.tkmind.cn/api/status`: `ok`
- `https://m.tkmind.cn/api/runtime/status`: `ok=true`
- `toolRuntime.codeRunsEnabled=false`
- `toolRuntime.chatInjectsCodeTools=false`
- `toolRuntime.queue.toolGateway.enabled=false`
@@ -1809,7 +1809,7 @@ Dry-run canary:
- 生产 `.env``MEMIND_TOOL_GATEWAY_*`
- 生产 `.env``MEMIND_AGENT_CODE_RUNS_*`
- `https://mm.tkmind.cn/api/runtime/status`:
- `https://m.tkmind.cn/api/runtime/status`:
- `ok=true`
- `toolRuntime.codeRunsEnabled=false`
- `toolRuntime.queue.toolGateway.enabled=false`
@@ -1891,7 +1891,7 @@ file proof:
- 生产 `.env``MEMIND_TOOL_GATEWAY_*`
- 生产 `.env``MEMIND_AGENT_CODE_RUNS_*`
- `https://mm.tkmind.cn/api/runtime/status`:
- `https://m.tkmind.cn/api/runtime/status`:
- `ok=true`
- `toolRuntime.codeRunsEnabled=false`
- `toolRuntime.queue.toolGateway.enabled=false`
@@ -1986,7 +1986,7 @@ file proof:
- 已恢复 `.env`
- 临时登录 session 已 revoke。
- `https://mm.tkmind.cn/api/runtime/status`:
- `https://m.tkmind.cn/api/runtime/status`:
- `ok=true`
- `toolRuntime.codeRunsEnabled=false`
- `toolRuntime.queue.autoDispatch=true`
@@ -2056,11 +2056,11 @@ file proof:
- release 脚本第一次在已知历史 docx 缺失链接处停止。
- 按用户要求不修复/不删除/不改写历史用户数据,第二次发布使用 `ALLOW_MINDSPACE_PUBLIC_LINK_ISSUES=1` 仅跳过该守卫。
- release 过程中脚本完成当前 live 全目录备份和持久目录备份。
- 105 legacy tunnel step 仍由脚本执行并健康,但 H5 主路径继续是 `https://mm.tkmind.cn`
- 105 legacy tunnel step 仍由脚本执行并健康,但 H5 主路径继续是 `https://m.tkmind.cn`
发布后状态:
- `https://mm.tkmind.cn/api/runtime/status`:
- `https://m.tkmind.cn/api/runtime/status`:
- `ok=true`
- `toolRuntime.codeRunsEnabled=false`
- `toolRuntime.queue.autoDispatch=true`
@@ -2150,7 +2150,7 @@ installer 默认行为:
发布后状态:
- `https://mm.tkmind.cn/api/runtime/status`:
- `https://m.tkmind.cn/api/runtime/status`:
- `ok=true`
- `toolRuntime.codeRunsEnabled=false`
- `toolRuntime.queue.autoDispatch=true`
@@ -2266,7 +2266,7 @@ canary repo:
- `installed-disabled`
- `state=not running`
- no `agent-run-worker.mjs` process
- `https://mm.tkmind.cn/api/runtime/status`:
- `https://m.tkmind.cn/api/runtime/status`:
- `ok=true`
- `toolRuntime.codeRunsEnabled=false`
- `toolRuntime.queue.autoDispatch=true`
@@ -2338,7 +2338,7 @@ canary repo:
发布后验证:
- `https://mm.tkmind.cn/api/runtime/status`:
- `https://m.tkmind.cn/api/runtime/status`:
- `ok=true`
- `toolRuntime.codeRunsEnabled=false`
- `toolRuntime.queue.autoDispatch=true`
@@ -2426,14 +2426,14 @@ runtime/status:
- release 脚本在 legacy `m.tkmind.cn` tunnel step 短暂返回失败;Portal runtime 已切换且健康。
- 后续检查显示:
- `mm.tkmind.cn` 主路径正常。
- `m.tkmind.cn` 主路径正常。
- `cn.tkmind.memind-portal-tunnel` 已恢复 running。
- Portal node pid `35983` 正在监听 `:8081`
- 该 legacy tunnel 不是当前 H5 主路径,当前 H5 主路径仍是 `https://mm.tkmind.cn`
- 该 legacy tunnel 不是当前 H5 主路径,当前 H5 主路径仍是 `https://m.tkmind.cn`
发布后状态:
- `https://mm.tkmind.cn/api/runtime/status`:
- `https://m.tkmind.cn/api/runtime/status`:
- `ok=true`
- `toolRuntime.codeRunPolicy.enabled=false`
- `toolRuntime.codeRunPolicy.userAllowlist=[]`
@@ -2722,7 +2722,7 @@ runtime/status:
验证:
- `https://mm.tkmind.cn/api/status` 返回 `ok`
- `https://m.tkmind.cn/api/status` 返回 `ok`
- `/api/runtime/status`:
- `codeRunPolicy.requireValidation=true`
- `toolRuntime.queue.maxConcurrentRuns=2`
@@ -2849,8 +2849,8 @@ runtime/status:
生产验证:
- `https://mm.tkmind.cn/api/status` 返回 `ok`
- `https://mm.tkmind.cn/auth/status` 未登录返回 `200 {"authenticated":false,"mode":"user"}`
- `https://m.tkmind.cn/api/status` 返回 `ok`
- `https://m.tkmind.cn/auth/status` 未登录返回 `200 {"authenticated":false,"mode":"user"}`
- `/api/runtime/status.toolRuntime`:
- `codeRunsEnabled=true`
- `requireValidation=true`
@@ -2909,7 +2909,7 @@ runtime/status:
生产验证:
- `https://mm.tkmind.cn/` 已加载新 bundle:
- `https://m.tkmind.cn/` 已加载新 bundle:
- `/assets/index-jYf9W5vk.js`
- `/api/runtime/status`:
- `ok=true`
@@ -2990,7 +2990,7 @@ runtime/status:
生产验证:
- `https://mm.tkmind.cn/api/status` 返回 `ok`
- `https://m.tkmind.cn/api/status` 返回 `ok`
- `/api/runtime/status.toolRuntime.queue`:
- `maxConcurrentRuns=2`
- `heartbeatMs=30000`
+7 -7
View File
@@ -19,7 +19,7 @@
- 不在生产运行目录执行 `git reset --hard``git clean -fdx``rm -rf`、数据库清理或数据目录同步删除。
- 不提交生产 `.env`、数据库、`MindSpace/``data/``users/``.tailscale/``logs/`、证书、密钥和运行缓存。
- 本次 H5 public base 临时切到 `https://mm.tkmind.cn`
- 本次 H5 public base 临时切到 `https://m.tkmind.cn`
- 后续公网 H5 不再依赖 105 转发链路;105 只保留 legacy/rollback 说明。
## 远程开发机拉取方式
@@ -51,8 +51,8 @@ cp .env.example .env
生产关键项:
```text
H5_PUBLIC_BASE_URL=https://mm.tkmind.cn
VITE_MINDSPACE_BASE=https://mm.tkmind.cn
H5_PUBLIC_BASE_URL=https://m.tkmind.cn
VITE_MINDSPACE_BASE=https://m.tkmind.cn
MEMIND_RUNTIME_REDIS_URL=redis://127.0.0.1:6379/0
MEMIND_RUNTIME_REDIS_NAMESPACE=memind:runtime
```
@@ -62,8 +62,8 @@ 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`
- Gateway SSE 示例: `m.tkmind.cn` 对应的 nginx SSE 配置
- H5 public base: `m.tkmind.cn`
- 普通用户默认不暴露 Aider/OpenHands,显式白名单保留。
## 验证命令
@@ -71,8 +71,8 @@ MEMIND_RUNTIME_REDIS_NAMESPACE=memind:runtime
```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
curl -sk https://m.tkmind.cn/api/status
curl -sk https://m.tkmind.cn/api/runtime/status
```
## 回滚思路
+1 -1
View File
@@ -110,7 +110,7 @@ Plaza 本地开发说明见 [plaza-local.md](./plaza-local.md)。生产发布、
|---|---|---|
| 配置真相 | 复制 `.env.example`,只填本地联调值 | 仅在 103 维护,**不提交 Git** |
| `TKMIND_API_TARGET` | 通常单实例 `https://127.0.0.1:18006` | 9 个 goosed target`18006``18014`),见 [103 runtime topology](./103-runtime-topology.md) |
| `H5_PUBLIC_BASE_URL` | `http://127.0.0.1:5173` | `https://mm.tkmind.cn` |
| `H5_PUBLIC_BASE_URL` | `http://127.0.0.1:5173` | `https://m.tkmind.cn` |
| `MEMIND_SESSION_BROKER_ENABLED` | Patch 2 起可本地设 `1` 验证 | 灰度窗口由运维在 103 `.env` 单独开启 |
| RDS / Redis | 可连本地 MySQL 或留空 | 生产 RDS + Redis,见 103 `.env` |
+5 -2
View File
@@ -2,6 +2,8 @@ export const MINDSPACE_PAGE_TAG_ATTR = 'data-mindspace-page-tag';
export const MINDSPACE_PAGE_TAG_PLATFORM_BRAND = 'platform-brand';
export const PLATFORM_BRAND_TEXT = 'TKMind · 智趣';
export const PLATFORM_BRAND_STYLE_ID = 'mindspace-platform-brand-style';
export const MINDSPACE_PAGE_FONT_STACK =
'-apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif';
const PLATFORM_BRAND_VISIBILITY_STYLE = `<style id="${PLATFORM_BRAND_STYLE_ID}">
[data-mindspace-page-tag="${MINDSPACE_PAGE_TAG_PLATFORM_BRAND}"] {
@@ -9,9 +11,10 @@ const PLATFORM_BRAND_VISIBILITY_STYLE = `<style id="${PLATFORM_BRAND_STYLE_ID}">
opacity: 1 !important;
visibility: visible !important;
color: #9aa3a0 !important;
font-family: ${MINDSPACE_PAGE_FONT_STACK} !important;
font-size: 12px !important;
font-weight: 600 !important;
letter-spacing: 0.08em !important;
font-weight: 500 !important;
letter-spacing: 0.02em !important;
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.35);
}
</style>`;
+1
View File
@@ -39,6 +39,7 @@ test('injectPlatformBrandVisibilityStyle adds readable brand css', () => {
const result = injectPlatformBrandVisibilityStyle(html);
assert.match(result, new RegExp(`id="${PLATFORM_BRAND_STYLE_ID}"`));
assert.match(result, /opacity:\s*1 !important/);
assert.match(result, /font-family:.*PingFang SC/);
});
test('ensurePlatformBrandFooter appends brand line when marker is missing', () => {
+1 -1
View File
@@ -185,7 +185,7 @@ function renderPreviewHtml(page) {
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; img-src data:; font-src 'none'; base-uri 'none'; form-action 'none'">
<title>${escapeHtml(page.title)}</title>
<style>
:root { color: #18211d; background: #f5f0e5; font-family: Georgia, "Times New Roman", serif; }
:root { color: #18211d; background: #f5f0e5; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif; }
* { box-sizing: border-box; }
body { margin: 0; padding: clamp(24px, 7vw, 88px); background: radial-gradient(circle at 90% 0, #d8e8df, transparent 34rem), #f5f0e5; }
article { width: min(820px, 100%); margin: 0 auto; padding: clamp(28px, 6vw, 72px); border: 1px solid #d7d0c1; border-radius: 28px; background: rgba(255,253,247,.94); box-shadow: 0 24px 70px rgba(45,53,47,.12); }
+8 -1
View File
@@ -2,6 +2,7 @@ import crypto from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import { preparePublishedPlatformBrand } from './mindspace-page-tag.mjs';
import { injectPublicImageRetryScript } from './mindspace-public-image-retry.mjs';
const INLINE_SCRIPT_PATTERN = /<script\b(?![^>]*\bsrc\b)[^>]*>([\s\S]*?)<\/script>/gi;
@@ -48,6 +49,7 @@ export async function handleMindSpaceLongImageDownload({
export function decorateMindSpacePublishedHtml({
html,
embed = false,
isOwner = true,
context,
htmlFilePath = '',
fileExists = fs.existsSync,
@@ -82,9 +84,14 @@ export function decorateMindSpacePublishedHtml({
nextHtml = injectWechatShareBridge(nextHtml, { pageUrl: context.pageUrl });
}
const shareInjection = !embed
? injectPublicFileShareButton(nextHtml)
? injectPublicFileShareButton(nextHtml, { isOwner })
: { html: nextHtml, scriptHashes: [] };
nextHtml = shareInjection.html;
// Retries transient asset-download failures client-side before a page's own onerror
// permanently swaps the <img> for a placeholder. See mindspace-public-image-retry.mjs.
if (!embed) {
nextHtml = injectPublicImageRetryScript(nextHtml).html;
}
const scriptHashes = collectInlineScriptHashes(nextHtml);
return {
html: nextHtml,
+31 -5
View File
@@ -97,6 +97,7 @@ test('handleMindSpaceLongImageDownload falls back to local html path without pag
test('decorateMindSpacePublishedHtml returns decorated html and csp', () => {
const pageScript = "document.body.dataset.ready='1'";
const shareScript = "document.body.dataset.share='1'";
let sharedIsOwner;
const result = decorateMindSpacePublishedHtml({
html: `<html><body><script>${pageScript}</script>demo</body></html>`,
embed: false,
@@ -110,10 +111,13 @@ test('decorateMindSpacePublishedHtml returns decorated html and csp', () => {
preparePublicationHtmlForEmbed: (value) => value,
injectOgTags: (value, meta) => `${value}<!--og:${meta.pageUrl}-->`,
injectWechatShareBridge: (value, meta) => `${value}<!--wechat:${meta.pageUrl}-->`,
injectPublicFileShareButton: (value) => ({
html: value.replace('</body>', `<script>${shareScript}</script></body>`),
scriptHashes: ['ignored'],
}),
injectPublicFileShareButton: (value, options) => {
sharedIsOwner = options?.isOwner;
return {
html: value.replace('</body>', `<script>${shareScript}</script></body>`),
scriptHashes: ['ignored'],
};
},
publishedPageCsp: (_value, options) => JSON.stringify(options),
isWechatUserAgent: () => true,
});
@@ -121,12 +125,34 @@ test('decorateMindSpacePublishedHtml returns decorated html and csp', () => {
assert.match(result.html, /og:/);
assert.match(result.html, /wechat:/);
assert.match(result.html, /dataset\.share/);
assert.match(result.html, /data-mindspace-image-retry="1"/);
assert.equal(result.allowEmbedFrame, false);
assert.equal(sharedIsOwner, true);
const options = JSON.parse(result.csp);
assert.equal(options.embed, false);
assert.equal(options.wechatShare, true);
assert.deepEqual(options.scriptHashes, collectInlineScriptHashes(result.html));
assert.equal(options.scriptHashes.length, 2);
assert.equal(options.scriptHashes.length, 3);
});
test('decorateMindSpacePublishedHtml forwards isOwner=false to the share button injector', () => {
let sharedIsOwner;
decorateMindSpacePublishedHtml({
html: '<html><body>demo</body></html>',
embed: false,
isOwner: false,
context: { origin: '', pageUrl: '', pageDirUrl: '', fallbackImageUrl: '' },
preparePublicationHtmlForEmbed: (value) => value,
injectOgTags: (value) => value,
injectWechatShareBridge: (value) => value,
injectPublicFileShareButton: (value, options) => {
sharedIsOwner = options?.isOwner;
return { html: value, scriptHashes: [] };
},
publishedPageCsp: (_value, options) => JSON.stringify(options),
isWechatUserAgent: () => false,
});
assert.equal(sharedIsOwner, false);
});
test('decorateMindSpacePublishedHtml supports embed mode without share injection', () => {
+67
View File
@@ -0,0 +1,67 @@
import crypto from 'node:crypto';
/**
* Public MindSpace pages (workspace `/MindSpace/<userId>/public/*.html`) reference images via
* `/api/mindspace/v1/assets/:id/download`, which is authorized per-request (owner cookie, signed
* `public_token`, or `/public/` referrer). Right after an agent generates a page, the very first
* image request can occasionally race asset materialization and return a transient 401/404/5xx.
*
* Generated pages usually ship a page-specific `onerror` fallback that permanently swaps the
* `<img>` for a static placeholder so a single transient failure becomes a permanent "broken
* image" until the visitor manually reloads. This script intercepts image load failures for
* MindSpace asset URLs (any page, any author) and retries a few times with backoff BEFORE the
* page's own `onerror` gets a chance to run; only once retries are exhausted does it let the
* original fallback take over.
*/
const IMAGE_RETRY_SCRIPT = `(function(){
var MAX_RETRIES=3;
var BASE_DELAY_MS=900;
var ASSET_PATTERN=/\\/api\\/mindspace\\/v1\\/assets\\/[^/]+\\/download/;
var retries=new WeakMap();
document.addEventListener('error',function(event){
var img=event.target;
if(!img||img.tagName!=='IMG')return;
var src=img.getAttribute('src')||'';
if(!ASSET_PATTERN.test(src))return;
var count=retries.get(img)||0;
if(count>=MAX_RETRIES)return;
event.stopImmediatePropagation();
retries.set(img,count+1);
setTimeout(function(){
try{
var url=new URL(src,location.href);
url.searchParams.set('_retry',String(count+1));
img.src=url.toString();
}catch(e){
img.src=src;
}
},BASE_DELAY_MS*(count+1));
},true);
})();`;
const IMAGE_RETRY_SCRIPT_HASH = crypto.createHash('sha256').update(IMAGE_RETRY_SCRIPT).digest('base64');
const MARKER_ATTR = 'data-mindspace-image-retry';
export function injectPublicImageRetryScript(html) {
const source = String(html ?? '');
if (!source || source.includes(MARKER_ATTR)) {
return { html: source, scriptHashes: [] };
}
const markup = `<script ${MARKER_ATTR}="1">${IMAGE_RETRY_SCRIPT}</script>`;
if (/<\/body>/i.test(source)) {
return {
html: source.replace(/<\/body>/i, `${markup}</body>`),
scriptHashes: [IMAGE_RETRY_SCRIPT_HASH],
};
}
return {
html: `${source}${markup}`,
scriptHashes: [IMAGE_RETRY_SCRIPT_HASH],
};
}
export const publicImageRetryInternals = {
IMAGE_RETRY_SCRIPT_HASH,
MARKER_ATTR,
};
+37
View File
@@ -0,0 +1,37 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
injectPublicImageRetryScript,
publicImageRetryInternals,
} from './mindspace-public-image-retry.mjs';
test('injectPublicImageRetryScript injects retry script before </body>', () => {
const result = injectPublicImageRetryScript('<!doctype html><html><body><h1>Hi</h1></body></html>');
assert.match(result.html, /data-mindspace-image-retry="1"/);
assert.match(result.html, /addEventListener\('error'/);
assert.match(result.html, /mindspace\\\/v1\\\/assets/);
assert.match(result.html, /stopImmediatePropagation/);
assert.equal(result.html.indexOf('data-mindspace-image-retry'), result.html.lastIndexOf('data-mindspace-image-retry'));
assert.match(result.html, /<script data-mindspace-image-retry="1">[\s\S]*<\/script><\/body>/);
assert.equal(result.scriptHashes.length, 1);
assert.equal(result.scriptHashes[0], publicImageRetryInternals.IMAGE_RETRY_SCRIPT_HASH);
});
test('injectPublicImageRetryScript appends when no closing body tag exists', () => {
const result = injectPublicImageRetryScript('<div>fragment</div>');
assert.match(result.html, /^<div>fragment<\/div><script data-mindspace-image-retry="1">/);
assert.equal(result.scriptHashes.length, 1);
});
test('injectPublicImageRetryScript is idempotent (skips if already injected)', () => {
const first = injectPublicImageRetryScript('<html><body>x</body></html>');
const second = injectPublicImageRetryScript(first.html);
assert.equal(second.html, first.html);
assert.equal(second.scriptHashes.length, 0);
});
test('injectPublicImageRetryScript handles empty input safely', () => {
const result = injectPublicImageRetryScript('');
assert.equal(result.html, '');
assert.equal(result.scriptHashes.length, 0);
});
+41 -32
View File
@@ -109,39 +109,17 @@ const PUBLIC_FILE_SHARE_SCRIPT_HASH = crypto
.update(PUBLIC_FILE_SHARE_SCRIPT)
.digest('base64');
export function injectPublicFileShareButton(html) {
export function injectPublicFileShareButton(html, { isOwner = true } = {}) {
const source = String(html ?? '');
if (!source || source.includes('data-mindspace-public-share')) {
return { html: source, scriptHashes: [] };
}
const markup = `
<style id="mindspace-public-share-style">
[data-mindspace-public-share]{position:fixed;right:18px;bottom:calc(18px + env(safe-area-inset-bottom,0px));z-index:2147483000;display:flex;flex-direction:column;align-items:flex-end;gap:6px;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}
[data-mindspace-public-share] div[data-mindspace-public-share-actions]{display:flex;gap:8px;justify-content:flex-end;flex-wrap:wrap}
[data-mindspace-public-share] button{border:0;border-radius:999px;padding:10px 15px;background:#2f6f57;color:#fff;font-size:14px;font-weight:700;box-shadow:0 8px 24px rgba(0,0,0,.18);cursor:pointer}
[data-mindspace-public-share] button[data-action="capture"]{background:#8f6b2f}
[data-mindspace-public-share] button[data-action="plaza"]{background:#5a4a7b}
[data-mindspace-public-share] button:active{transform:translateY(1px)}
[data-mindspace-public-share] button:disabled{opacity:.65;cursor:wait}
[data-mindspace-public-share] small{min-height:18px;max-width:220px;border-radius:999px;padding:4px 9px;background:rgba(255,255,255,.92);color:#245845;font-size:12px;text-align:right;box-shadow:0 4px 14px rgba(0,0,0,.1)}
[data-mindspace-public-share] small:empty{display:none}
[data-mindspace-public-share] small.is-error{color:#8b2d20}
[data-mindspace-public-share-dialog]{position:fixed;inset:0;z-index:2147483646;display:grid;place-items:center;padding:20px;background:rgba(7,12,10,.72);backdrop-filter:blur(8px)}
[data-mindspace-public-share-dialog][hidden]{display:none!important}
[data-mindspace-public-share-dialog-panel]{width:min(360px,100%);padding:24px;border-radius:22px;background:rgba(18,24,32,.96) !important;border:1px solid rgba(255,255,255,.12);box-shadow:0 24px 80px rgba(0,0,0,.38);text-align:center;color:#fff !important;color-scheme:dark;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}
[data-mindspace-public-share-dialog-panel] h3{margin:0 0 10px;font-size:18px;font-weight:600;color:#fff !important}
[data-mindspace-public-share-dialog-panel] p{margin:0 0 16px;font-size:14px;line-height:1.5;color:rgba(255,255,255,.86) !important}
[data-mindspace-public-share-dialog-panel] [data-plaza-view][hidden]{display:none!important}
[data-mindspace-public-share-dialog-actions]{display:flex;justify-content:center;gap:10px;flex-wrap:wrap}
[data-mindspace-public-share-dialog-actions] button{border-radius:999px;padding:8px 18px;font:inherit;font-size:14px;cursor:pointer;box-shadow:none}
[data-mindspace-public-share-dialog-actions] [data-action="plaza-cancel"],[data-mindspace-public-share-dialog-actions] [data-action="plaza-already-close"],[data-mindspace-public-share-dialog-actions] [data-action="plaza-success-close"],[data-mindspace-public-share-dialog-actions] [data-action="plaza-error-close"]{border:1px solid rgba(255,255,255,.18) !important;background:rgba(255,255,255,.08) !important;color:#fff !important}
[data-mindspace-public-share-dialog-actions] [data-action="plaza-confirm"]{border:1px solid rgba(47,111,87,.35) !important;background:#2f6f57 !important;color:#fff !important;font-weight:600}
[data-mindspace-public-share-dialog-link]{display:inline-block;margin:0 0 14px;font-size:14px;color:#8fd4b4 !important;text-decoration:underline}
[data-mindspace-public-share-dialog-link][hidden]{display:none!important}
@media(max-width:640px){[data-mindspace-public-share]{right:12px;bottom:calc(12px + env(safe-area-inset-bottom,0px))}[data-mindspace-public-share] button{padding:9px 13px;font-size:13px}}
</style>
<div data-mindspace-public-share>
<div data-mindspace-public-share-dialog hidden>
// REGRESSION GUARD: mindspace-public-owner-vs-visitor — the "发布 Plaza" entry mutates content
// ownership (publishes the author's page under the visitor's own Plaza account), so it must only
// render for the page owner. Anyone else opening this URL (share link, forwarded chat link) is a
// visitor and must NOT see it. Everything else (长图 / 分享) stays available to all viewers.
const plazaDialogMarkup = isOwner
? ` <div data-mindspace-public-share-dialog hidden>
<div data-mindspace-public-share-dialog-panel role="dialog" aria-modal="true" aria-labelledby="mindspace-public-plaza-title">
<div data-plaza-view="confirm">
<h3 id="mindspace-public-plaza-title">发布到 Plaza</h3>
@@ -180,10 +158,41 @@ export function injectPublicFileShareButton(html) {
</div>
</div>
</div>
<small aria-live="polite"></small>
`
: '';
const plazaButtonMarkup = isOwner
? ' <button type="button" data-action="plaza">发布 Plaza</button>\n'
: '';
const markup = `
<style id="mindspace-public-share-style">
[data-mindspace-public-share]{position:fixed;right:18px;bottom:calc(18px + env(safe-area-inset-bottom,0px));z-index:2147483000;display:flex;flex-direction:column;align-items:flex-end;gap:6px;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}
[data-mindspace-public-share] div[data-mindspace-public-share-actions]{display:flex;gap:8px;justify-content:flex-end;flex-wrap:wrap}
[data-mindspace-public-share] button{border:0;border-radius:999px;padding:10px 15px;background:#2f6f57;color:#fff;font-size:14px;font-weight:700;box-shadow:0 8px 24px rgba(0,0,0,.18);cursor:pointer}
[data-mindspace-public-share] button[data-action="capture"]{background:#8f6b2f}
[data-mindspace-public-share] button[data-action="plaza"]{background:#5a4a7b}
[data-mindspace-public-share] button:active{transform:translateY(1px)}
[data-mindspace-public-share] button:disabled{opacity:.65;cursor:wait}
[data-mindspace-public-share] small{min-height:18px;max-width:220px;border-radius:999px;padding:4px 9px;background:rgba(255,255,255,.92);color:#245845;font-size:12px;text-align:right;box-shadow:0 4px 14px rgba(0,0,0,.1)}
[data-mindspace-public-share] small:empty{display:none}
[data-mindspace-public-share] small.is-error{color:#8b2d20}
[data-mindspace-public-share-dialog]{position:fixed;inset:0;z-index:2147483646;display:grid;place-items:center;padding:20px;background:rgba(7,12,10,.72);backdrop-filter:blur(8px)}
[data-mindspace-public-share-dialog][hidden]{display:none!important}
[data-mindspace-public-share-dialog-panel]{width:min(360px,100%);padding:24px;border-radius:22px;background:rgba(18,24,32,.96) !important;border:1px solid rgba(255,255,255,.12);box-shadow:0 24px 80px rgba(0,0,0,.38);text-align:center;color:#fff !important;color-scheme:dark;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}
[data-mindspace-public-share-dialog-panel] h3{margin:0 0 10px;font-size:18px;font-weight:600;color:#fff !important}
[data-mindspace-public-share-dialog-panel] p{margin:0 0 16px;font-size:14px;line-height:1.5;color:rgba(255,255,255,.86) !important}
[data-mindspace-public-share-dialog-panel] [data-plaza-view][hidden]{display:none!important}
[data-mindspace-public-share-dialog-actions]{display:flex;justify-content:center;gap:10px;flex-wrap:wrap}
[data-mindspace-public-share-dialog-actions] button{border-radius:999px;padding:8px 18px;font:inherit;font-size:14px;cursor:pointer;box-shadow:none}
[data-mindspace-public-share-dialog-actions] [data-action="plaza-cancel"],[data-mindspace-public-share-dialog-actions] [data-action="plaza-already-close"],[data-mindspace-public-share-dialog-actions] [data-action="plaza-success-close"],[data-mindspace-public-share-dialog-actions] [data-action="plaza-error-close"]{border:1px solid rgba(255,255,255,.18) !important;background:rgba(255,255,255,.08) !important;color:#fff !important}
[data-mindspace-public-share-dialog-actions] [data-action="plaza-confirm"]{border:1px solid rgba(47,111,87,.35) !important;background:#2f6f57 !important;color:#fff !important;font-weight:600}
[data-mindspace-public-share-dialog-link]{display:inline-block;margin:0 0 14px;font-size:14px;color:#8fd4b4 !important;text-decoration:underline}
[data-mindspace-public-share-dialog-link][hidden]{display:none!important}
@media(max-width:640px){[data-mindspace-public-share]{right:12px;bottom:calc(12px + env(safe-area-inset-bottom,0px))}[data-mindspace-public-share] button{padding:9px 13px;font-size:13px}}
</style>
<div data-mindspace-public-share>
${plazaDialogMarkup} <small aria-live="polite"></small>
<div data-mindspace-public-share-actions>
<button type="button" data-action="plaza">发布 Plaza</button>
<button type="button" data-action="capture">保存长图</button>
${plazaButtonMarkup} <button type="button" data-action="capture">保存长图</button>
<button type="button" data-action="share">公开分享</button>
</div>
</div>
+26
View File
@@ -18,3 +18,29 @@ test('injectPublicFileShareButton adds plaza entry and confirm dialog', () => {
assert.match(result.html, /ALREADY_PUBLISHED/);
assert.equal(result.scriptHashes.length, 1);
});
test('injectPublicFileShareButton hides Plaza entry and dialog for non-owner viewers', () => {
const result = injectPublicFileShareButton(
'<!doctype html><html><body><h1>Hi</h1></body></html>',
{ isOwner: false },
);
assert.doesNotMatch(result.html, /<button type="button" data-action="plaza">/);
assert.doesNotMatch(result.html, /发布到 Plaza/);
assert.doesNotMatch(result.html, /<button type="button" data-action="plaza-confirm">/);
assert.doesNotMatch(result.html, /<div data-mindspace-public-share-dialog/);
assert.doesNotMatch(result.html, /data-mindspace-public-share-dialog-panel role="dialog"/);
// Non-Plaza actions must remain available to visitors.
assert.match(result.html, /data-action="capture"/);
assert.match(result.html, /data-action="share"/);
assert.match(result.html, /公开分享/);
assert.equal(result.scriptHashes.length, 1);
});
test('injectPublicFileShareButton keeps Plaza entry when isOwner explicitly true', () => {
const result = injectPublicFileShareButton(
'<!doctype html><html><body><h1>Hi</h1></body></html>',
{ isOwner: true },
);
assert.match(result.html, /data-action="plaza"/);
assert.match(result.html, /data-mindspace-public-share-dialog/);
});
+2
View File
@@ -53,6 +53,8 @@
"smoke:memory-v2-qdrant": "node scripts/smoke-memory-v2-qdrant.mjs",
"smoke:memory-v2-external": "node scripts/smoke-memory-v2-external.mjs",
"mock:memory-v2-services": "node scripts/mock-memory-v2-services.mjs",
"test:memind": "node scripts/run-memind-tests.mjs",
"test:scenario": "node scripts/run-scenario-test.mjs",
"test": "node --test auth.test.mjs asr-proxy.test.mjs billing.test.mjs billing-recharge.test.mjs wechat-pay.test.mjs wechat-oauth.test.mjs wechat-mp.test.mjs schedule-intent.test.mjs schedule-reminder-worker.test.mjs capabilities.test.mjs policies.test.mjs chat-skills.test.mjs chat-intent-router.test.mjs chat-finish-sync.test.mjs chat-agent-run-gate.test.mjs conversation-display.test.mjs user-publish.test.mjs user-memory-profile.test.mjs skills-registry.test.mjs agent-run-gateway.test.mjs agent-run-routes.test.mjs session-broker.test.mjs sse-event-taxonomy.test.mjs goosed-proxy-boundary.test.mjs agent-run-stream.test.mjs mindspace-h5-html-finish-guard.test.mjs admin-routes.test.mjs direct-chat-service.test.mjs tool-gateway.test.mjs mindspace.test.mjs mindspace-scan.test.mjs mindspace-assets.test.mjs mindspace-local-runtime-services.test.mjs mindspace-local-server-adapter.test.mjs mindspace-public-asset-token.test.mjs mindspace-remote-server-adapter.test.mjs mindspace-server-adapter.test.mjs mindspace-pages.test.mjs mindspace-page-sync-service.test.mjs public-site-bases.test.mjs mindspace-html-download-links.test.mjs mindspace-page-purge.test.mjs mindspace-public-delivery.test.mjs mindspace-public-page-context.test.mjs mindspace-public-route.test.mjs mindspace-publications.test.mjs mindspace-public-links.test.mjs mindspace-chat-save.test.mjs mindspace-chat-docx-package.test.mjs mindspace-public-finish-sync.test.mjs mindspace-chat-context.test.mjs mindspace-canonical-url.test.mjs mindspace-conversation-package.test.mjs mindspace-conversation-package-backfill.test.mjs mindspace-conversation-package-public-html.test.mjs mindspace-conversation-package-verify.test.mjs mindspace-conversation-package-registry.test.mjs mindspace-conversation-package-routes.test.mjs mindspace-conversation-package-store.test.mjs mindspace-conversation-schema.test.mjs mindspace-runtime-config.test.mjs mindspace-service.test.mjs mindspace-storage-adapter.test.mjs mindspace-content-scan.test.mjs mindspace-html-localize.test.mjs mindspace-visual-editor.test.mjs mindspace-cleanup.test.mjs mindspace-thumbnails.test.mjs mindspace-workspace-thumbnails.test.mjs mindspace-workspace-sync.test.mjs mindspace-asset-preview.test.mjs mindspace-agent-jobs.test.mjs mindspace-agent-runner.test.mjs mindspace-sandbox-mcp.test.mjs message-stream.test.mjs mindspace-service/mindspace-rpc-server.test.mjs plaza-posts.test.mjs plaza-interactions.test.mjs plaza-algorithm.test.mjs plaza-seo.test.mjs plaza-ops.test.mjs user-auth.test.mjs llm-providers.test.mjs admin-guard.test.mjs user-feedback.test.mjs memory-v2.test.mjs memory-v2-admin-config.test.mjs memory-v2-adapter-scaffold.test.mjs memory-v2-backend-contract.test.mjs memory-v2-health.test.mjs memory-v2-runtime.test.mjs memory-v2-plugin-backends.test.mjs memory-v2-pgvector.test.mjs memory-v2-pgvector-schema.test.mjs memory-v2-pgvector-backfill.test.mjs memory-v2-pgvector-smoke.test.mjs memory-v2-qdrant.test.mjs memory-v2-weaviate.test.mjs memory-v2-mem0.test.mjs memory-v2-letta.test.mjs memory-v2-external-adapters.test.mjs scripts/embed-memory-v2-local-hash.test.mjs scripts/check-memory-v2-app-canary.test.mjs scripts/check-memory-v2-config-gaps.test.mjs scripts/check-memory-v2-contracts.test.mjs scripts/check-memory-v2-health.test.mjs scripts/check-memory-v2-session-flow.test.mjs scripts/check-memory-v2-stack.test.mjs scripts/setup-memory-v2-pgvector-schema.test.mjs scripts/backfill-memory-v2-pgvector.test.mjs scripts/scaffold-memory-v2-backend.test.mjs scripts/smoke-memory-v2-pgvector.test.mjs scripts/smoke-memory-v2-qdrant.test.mjs scripts/smoke-memory-v2-external.test.mjs scripts/mock-memory-v2-services.test.mjs",
"test:mindspace-service": "node --test mindspace-service/mindspace-rpc-server.test.mjs",
"verify:chat-finish-sync": "node scripts/verify-chat-finish-sync.mjs",
@@ -6,7 +6,7 @@
<title>越南旅游指南</title>
<style>
:root { --accent: #2f6f57; --bg: #f4f0e7; }
body { margin: 0; font-family: Georgia, serif; background: var(--bg); color: #18211d; line-height: 1.7; }
body { margin: 0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif; background: var(--bg); color: #18211d; line-height: 1.7; }
main { max-width: 720px; margin: 0 auto; padding: 48px 24px 72px; }
h1 { font-size: 2.2rem; margin-bottom: 0.5rem; color: var(--accent); }
.lead { color: #5f6963; margin-bottom: 2rem; }
@@ -8,7 +8,7 @@
<title>越南旅游指南</title>
<style>
:root { --accent: #ff6b35; --accent2: #24243e; --bg: #fff8f2; }
body { margin: 0; font-family: Georgia, serif; background: linear-gradient(160deg, var(--bg), #ffe8d6); color: #18211d; line-height: 1.7; }
body { margin: 0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif; background: linear-gradient(160deg, var(--bg), #ffe8d6); color: #18211d; line-height: 1.7; }
.hero { position: relative; height: 280px; overflow: hidden; }
.hero img { width: 100%; height: 100%; object-fit: cover; display: block; }
.hero::after { content: ''; position: absolute; inset: 0; background: linear-gradient(to top, rgba(36,36,62,0.55), transparent 60%); }
+40
View File
@@ -0,0 +1,40 @@
{
"id": "your-scenario-id",
"name": "场景名称",
"description": "一句话说明这个场景测什么",
"account": {
"username": "john",
"password": "888888"
},
"steps": [
{
"action": "login",
"label": "登录"
},
{
"action": "chat",
"label": "第一步对话",
"message": "hi",
"expect": {
"assistantMinChars": 1,
"timeoutMs": 120000
}
},
{
"action": "chat",
"label": "第二步对话(可带页面断言)",
"message": "你的测试消息",
"expect": {
"assistantMinChars": 20,
"timeoutMs": 300000,
"replyKeywords": ["关键词"],
"page": {
"keywords": ["页面里应出现的词"],
"requirePublicLink": true,
"requireHttp200": true,
"requireMindspaceCover": false
}
}
}
]
}
+39
View File
@@ -0,0 +1,39 @@
{
"id": "suzhou-page",
"name": "苏州攻略一日游页面",
"description": "登录 john → 打招呼 → 请求生成苏州 1 日攻略页面 → 验证回复与公网可访问",
"account": {
"username": "john",
"password": "888888"
},
"steps": [
{
"action": "login",
"label": "登录 john 账户"
},
{
"action": "chat",
"label": "发送 hi 并等待回复",
"message": "hi",
"expect": {
"assistantMinChars": 1,
"timeoutMs": 120000
}
},
{
"action": "chat",
"label": "请求苏州攻略页面并验证",
"message": "帮我看看苏州攻略,1日攻略,做一个页面",
"expect": {
"assistantMinChars": 20,
"timeoutMs": 300000,
"replyKeywords": ["苏州"],
"page": {
"keywords": ["苏州"],
"requirePublicLink": true,
"requireHttp200": true
}
}
}
]
}
+1 -1
View File
@@ -403,7 +403,7 @@ async function writeMetadata() {
'Deployment and operations transport:',
' H5 public domain: m.tkmind.cn',
' Current public path: m.tkmind.cn -> 105 nginx -> 103 Portal :8081',
' Test-only path: mm.tkmind.cn -> 105 nginx -> local/103 test routing when explicitly enabled.',
' No separate public test domain remains; use explicit local/test overrides only when needed.',
' Production H5 and WeChat links should use m.tkmind.cn unless a test rollout explicitly overrides H5_PUBLIC_BASE_URL.',
'',
'Streaming runtime operations:',
+356
View File
@@ -0,0 +1,356 @@
#!/usr/bin/env node
/**
* Memind scoped test runner.
* Usage: node scripts/run-memind-tests.mjs [--mode quick|changed|guards|release|full] [--base REF]
*/
import { spawnSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
const root = path.resolve(new URL('..', import.meta.url).pathname);
const MODES = new Set(['quick', 'changed', 'guards', 'release', 'full']);
const QUICK_TESTS = [
'capabilities.test.mjs',
'llm-providers.test.mjs',
'user-auth.test.mjs',
'wechat-mp.test.mjs',
];
const GUARD_SCRIPTS = [
'verify:mindspace-publish-guards',
'verify:mindspace-page-sync-guards',
];
const RELEASE_TESTS = [
...QUICK_TESTS,
'wechat-oauth.test.mjs',
'wechat-pay.test.mjs',
'mindspace-public-finish-sync.test.mjs',
'chat-finish-sync.test.mjs',
'conversation-display.test.mjs',
'mindspace-pages.test.mjs',
'mindspace-page-sync-service.test.mjs',
];
const RELEASE_CHECKS = [
'check:mindspace-public-links',
'verify:mindspace-publish-guards:full',
];
const SCOPE_RULES = [
{
id: 'publish-chat-finish',
patterns: [
/mindspace-public-finish/i,
/chat-finish-sync/i,
/conversation-display/i,
/useTKMindChat/i,
/message\.ts$/,
/mindspace-h5-html-finish/i,
],
tests: [
'mindspace-public-finish-sync.test.mjs',
'mindspace-h5-html-finish-guard.test.mjs',
'chat-finish-sync.test.mjs',
'conversation-display.test.mjs',
],
verify: ['verify:mindspace-publish-guards'],
},
{
id: 'page-sync-thumbnail',
patterns: [
/mindspace-page-sync/i,
/mindspace-pages/i,
/public-site-bases/i,
/publicUrl\.ts$/,
/mindspace-thumbnails/i,
/mindspace-workspace-thumbnails/i,
],
tests: [
'mindspace-pages.test.mjs',
'mindspace-page-sync-service.test.mjs',
'public-site-bases.test.mjs',
'mindspace-thumbnails.test.mjs',
'mindspace-workspace-thumbnails.test.mjs',
],
verify: ['verify:mindspace-page-sync-guards'],
},
{
id: 'wechat',
patterns: [/wechat/i],
tests: [
'wechat-mp.test.mjs',
'wechat-oauth.test.mjs',
'wechat-pay.test.mjs',
'user-auth.test.mjs',
],
verify: ['verify:wechat-channel-isolation'],
},
{
id: 'billing',
patterns: [/billing/i, /sse-billing/i],
tests: [
'billing.test.mjs',
'billing-recharge.test.mjs',
'billing-subscription.test.mjs',
'sse-billing.test.mjs',
],
},
{
id: 'agent-run',
patterns: [/agent-run/i, /tool-gateway/i, /goosed-proxy/i],
tests: [
'agent-run-gateway.test.mjs',
'agent-run-routes.test.mjs',
'agent-run-stream.test.mjs',
'tool-gateway.test.mjs',
'goosed-proxy-boundary.test.mjs',
'chat-agent-run-gate.test.mjs',
],
verify: ['verify:goosed-proxy-boundary'],
},
{
id: 'memory-v2',
patterns: [/memory-v2/i, /scripts\/check-memory-v2/i, /scripts\/smoke-memory-v2/i],
tests: [
'memory-v2.test.mjs',
'memory-v2-runtime.test.mjs',
'memory-v2-health.test.mjs',
'memory-v2-backend-contract.test.mjs',
],
},
{
id: 'plaza',
patterns: [/plaza/i],
tests: [
'plaza-posts.test.mjs',
'plaza-interactions.test.mjs',
'plaza-algorithm.test.mjs',
'plaza-seo.test.mjs',
'plaza-ops.test.mjs',
],
},
{
id: 'mindspace-core',
patterns: [/mindspace/i, /server\.mjs$/, /src\/hooks\/useTKMind/i],
tests: [
'mindspace.test.mjs',
'mindspace-public-route.test.mjs',
'mindspace-public-links.test.mjs',
'mindspace-runtime-config.test.mjs',
],
},
];
function usage() {
console.log(`Usage:
node scripts/run-memind-tests.mjs [--mode quick|changed|guards|release|full] [--base REF]
Modes:
changed Run tests matched to git diff (default)
quick Fast smoke for routine checks
guards Regression guard scripts only
release Pre-portal-release checklist
full Entire npm test suite`);
}
function parseArgs(argv) {
let mode = 'changed';
let base = 'HEAD';
for (let i = 2; i < argv.length; i += 1) {
const arg = argv[i];
if (arg === '--mode') {
mode = argv[++i] ?? '';
} else if (arg === '--base') {
base = argv[++i] ?? 'HEAD';
} else if (arg === '-h' || arg === '--help') {
usage();
process.exit(0);
} else {
throw new Error(`Unknown argument: ${arg}`);
}
}
if (!MODES.has(mode)) {
throw new Error(`Invalid mode: ${mode}`);
}
return { mode, base };
}
function runStep(label, fn) {
console.log(`\n==> ${label}`);
const ok = fn();
if (!ok) {
throw new Error(`${label} failed`);
}
}
function runNodeTest(files) {
const existing = files.filter((file) => fs.existsSync(path.join(root, file)));
if (existing.length === 0) {
console.log('(skip: no matching test files on disk)');
return true;
}
const result = spawnSync(process.execPath, ['--test', ...existing], {
cwd: root,
stdio: 'inherit',
});
return result.status === 0;
}
function runNpmScript(script) {
const result = spawnSync('npm', ['run', script], {
cwd: root,
stdio: 'inherit',
shell: process.platform === 'win32',
});
return result.status === 0;
}
function gitChangedFiles(base) {
const result = spawnSync('git', ['diff', '--name-only', `${base}...HEAD`], {
cwd: root,
encoding: 'utf8',
});
if (result.status !== 0) {
const fallback = spawnSync('git', ['diff', '--name-only', base], {
cwd: root,
encoding: 'utf8',
});
if (fallback.status !== 0) {
return [];
}
return fallback.stdout
.split('\n')
.map((line) => line.trim())
.filter(Boolean);
}
return result.stdout
.split('\n')
.map((line) => line.trim())
.filter(Boolean);
}
function gitStagedAndUnstaged() {
const result = spawnSync('git', ['status', '--porcelain'], {
cwd: root,
encoding: 'utf8',
});
if (result.status !== 0) {
return [];
}
return result.stdout
.split('\n')
.map((line) => line.trim())
.filter(Boolean)
.map((line) => line.replace(/^. [^ ]+ -> /, '').replace(/^.. /, ''));
}
function directTestForSource(file) {
const base = path.basename(file).replace(/\.(mjs|ts|tsx|js)$/, '');
const candidates = [
`${base}.test.mjs`,
path.join('scripts', `${base}.test.mjs`),
path.join('mindspace-service', `${base}.test.mjs`),
];
return candidates.filter((candidate) => fs.existsSync(path.join(root, candidate)));
}
function resolveChangedScope(files) {
const tests = new Set(QUICK_TESTS);
const verify = new Set();
for (const file of files) {
for (const test of directTestForSource(file)) {
tests.add(test);
}
for (const rule of SCOPE_RULES) {
if (rule.patterns.some((pattern) => pattern.test(file))) {
for (const test of rule.tests) {
tests.add(test);
}
for (const script of rule.verify ?? []) {
verify.add(script);
}
}
}
}
return {
files,
tests: [...tests],
verify: [...verify],
};
}
function summarize(scope) {
console.log('\n--- planned scope ---');
if (scope.files?.length) {
console.log(`changed files (${scope.files.length}):`);
for (const file of scope.files) {
console.log(` - ${file}`);
}
}
console.log(`tests (${scope.tests.length}):`);
for (const test of scope.tests) {
console.log(` - ${test}`);
}
if (scope.verify.length) {
console.log(`verify scripts (${scope.verify.length}):`);
for (const script of scope.verify) {
console.log(` - npm run ${script}`);
}
}
}
function main() {
const { mode, base } = parseArgs(process.argv);
let scope;
switch (mode) {
case 'quick':
scope = { tests: QUICK_TESTS, verify: [] };
break;
case 'guards':
scope = { tests: [], verify: GUARD_SCRIPTS };
break;
case 'release':
scope = { tests: RELEASE_TESTS, verify: RELEASE_CHECKS };
break;
case 'full':
runStep('full test suite (npm test)', () => runNpmScript('test'));
console.log('\nmemind tests ok (full)');
return;
case 'changed':
default: {
const changed = [...new Set([...gitChangedFiles(base), ...gitStagedAndUnstaged()])];
if (changed.length === 0) {
console.log('No changed files detected; falling back to quick smoke.');
scope = { tests: QUICK_TESTS, verify: [], files: [] };
} else {
scope = resolveChangedScope(changed);
}
break;
}
}
summarize(scope);
for (const test of scope.tests) {
runStep(`node --test ${test}`, () => runNodeTest([test]));
}
for (const script of scope.verify) {
runStep(`npm run ${script}`, () => runNpmScript(script));
}
console.log(`\nmemind tests ok (${mode})`);
}
try {
main();
} catch (error) {
console.error(`\n${error.message}`);
process.exit(1);
}
+206
View File
@@ -0,0 +1,206 @@
#!/usr/bin/env node
/**
* Memind 场景模拟测试入口
*
* Usage:
* node scripts/run-scenario-test.mjs --scenario suzhou-page
* node scripts/run-scenario-test.mjs --list
* JOHN_PASSWORD=888888 node scripts/run-scenario-test.mjs
*/
import { loadH5Environment } from './load-env.mjs';
import {
createAgentRun,
createReporter,
listScenarios,
loadScenario,
loginViaApi,
resolvePortalBase,
snapshotPublicHtml,
verifyPageAccess,
waitForAssistantGrowth,
waitForRunTerminal,
extractAssistantTexts,
getSession,
} from './scenario-test-lib.mjs';
loadH5Environment(import.meta.dirname);
function usage() {
console.log(`Usage:
node scripts/run-scenario-test.mjs [--scenario <id>] [--list] [--port <port>]
Examples:
node scripts/run-scenario-test.mjs --scenario suzhou-page
node scripts/run-scenario-test.mjs --list`);
}
function parseArgs(argv) {
let scenarioId = 'suzhou-page';
let listOnly = false;
let port = Number(process.env.H5_PORT ?? 8081);
for (let i = 2; i < argv.length; i += 1) {
const arg = argv[i];
if (arg === '--scenario' && argv[i + 1]) {
scenarioId = argv[++i];
} else if (arg === '--port' && argv[i + 1]) {
port = Number(argv[++i]);
} else if (arg === '--list') {
listOnly = true;
} else if (arg === '-h' || arg === '--help') {
usage();
process.exit(0);
} else {
throw new Error(`未知参数: ${arg}`);
}
}
return { scenarioId, listOnly, port };
}
async function ensurePortalReady(baseUrl) {
const response = await fetch(`${baseUrl}/auth/status`);
if (!response.ok) {
throw new Error(`Portal 未就绪: ${baseUrl}/auth/status -> ${response.status}`);
}
}
async function runScenario(scenario, port) {
const reporter = createReporter();
const baseUrl = resolvePortalBase(port);
const account = {
username: scenario.account?.username ?? 'john',
password:
process.env.JOHN_PASSWORD
?? process.env.H5_ACCESS_PASSWORD
?? scenario.account?.password
?? '888888',
};
console.log(`==> 场景: ${scenario.name ?? scenario.id}`);
console.log(` Portal: ${baseUrl}`);
console.log(` 账户: ${account.username}\n`);
await ensurePortalReady(baseUrl);
let auth = null;
let sessionId = null;
let assistantCount = 0;
let assistantCombinedLength = 0;
let publishKey = null;
let htmlBefore = [];
for (const step of scenario.steps ?? []) {
const label = step.label ?? step.action;
console.log(`\n--- ${label} ---`);
if (step.action === 'login') {
auth = await loginViaApi(baseUrl, account, reporter);
publishKey = auth.user?.id ?? auth.user?.publishSlug ?? account.username;
continue;
}
if (step.action === 'chat') {
if (!auth) {
throw new Error('chat 步骤前必须先 login');
}
if (step.expect?.page) {
htmlBefore = await snapshotPublicHtml(publishKey);
}
const run = await createAgentRun(baseUrl, auth.cookie, {
message: step.message,
sessionId,
});
reporter.pass('提交消息', `"${step.message}" → run ${run.runId}`);
const terminal = await waitForRunTerminal(
baseUrl,
auth.cookie,
run.runId,
step.expect?.timeoutMs ?? 300_000,
);
sessionId = terminal.sessionId ?? terminal.agent_session_id ?? run.sessionId ?? sessionId;
if (terminal.status === 'failed') {
reporter.fail('run 终态', terminal.error ?? 'failed');
continue;
}
reporter.pass('run 终态', terminal.status);
if (!sessionId) {
reporter.fail('session', 'run 未回填 sessionId');
continue;
}
const reply = await waitForAssistantGrowth(baseUrl, auth.cookie, sessionId, {
previousCount: assistantCount,
previousCombinedLength: assistantCombinedLength,
minChars: step.expect?.assistantMinChars ?? 1,
timeoutMs: step.expect?.timeoutMs ?? 120_000,
});
if (!reply) {
reporter.fail('assistant 回复', '超时未收到新回复');
continue;
}
assistantCount = reply.count;
assistantCombinedLength = reply.combined.length;
reporter.pass('assistant 回复', `${reply.combined.length} 字 / ${reply.elapsedMs}ms`);
console.log('\n--- 回复摘要 ---');
console.log(reply.combined.slice(0, 600));
const keywords = step.expect?.replyKeywords ?? [];
if (keywords.length) {
const hit = keywords.filter((word) => reply.combined.includes(word));
if (hit.length === 0) {
reporter.fail('回复关键词', `未命中: ${keywords.join(', ')}`);
} else {
reporter.pass('回复关键词', hit.join(', '));
}
}
if (step.expect?.page) {
await verifyPageAccess({
baseUrl,
cookie: auth.cookie,
publishKey,
replyText: reply.combined,
htmlBefore,
expect: step.expect.page,
reporter,
});
}
continue;
}
reporter.fail('未知步骤', step.action ?? 'missing action');
}
return reporter.summary();
}
async function main() {
const { scenarioId, listOnly, port } = parseArgs(process.argv);
if (listOnly) {
const scenarios = await listScenarios();
console.log('可用场景:');
for (const item of scenarios) {
console.log(`- ${item.id}: ${item.name}${item.description ? `${item.description}` : ''}`);
}
return;
}
const scenario = await loadScenario(scenarioId);
const code = await runScenario(scenario, port);
process.exit(code);
}
main().catch((error) => {
console.error(error instanceof Error ? error.stack ?? error.message : error);
process.exit(1);
});
+324
View File
@@ -0,0 +1,324 @@
import crypto from 'node:crypto';
import fs from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { USER_COOKIE } from '../user-auth.mjs';
import { PUBLISH_ROOT_DIR } from '../user-publish.mjs';
const repoRoot = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
export function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
export function createReporter() {
const checks = [];
const issues = [];
return {
checks,
issues,
pass(label, detail = '') {
checks.push({ ok: true, label, detail });
console.log(`${label}${detail ? `: ${detail}` : ''}`);
},
fail(label, detail = '') {
issues.push({ label, detail });
checks.push({ ok: false, label, detail });
console.error(`${label}${detail ? `: ${detail}` : ''}`);
},
summary() {
console.log('\n=== 汇总 ===');
console.log(`通过: ${checks.filter((item) => item.ok).length}/${checks.length}`);
if (issues.length) {
console.log('失败项:');
for (const item of issues) {
console.log(` - ${item.label}: ${item.detail}`);
}
return 1;
}
console.log('场景测试全部通过');
return 0;
},
};
}
export function resolvePortalBase(port) {
return `http://127.0.0.1:${port}`;
}
export async function loginViaApi(baseUrl, { username, password }, reporter) {
const response = await fetch(`${baseUrl}/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
});
const body = await response.json().catch(() => ({}));
if (!response.ok || !body?.authenticated) {
throw new Error(`登录失败 ${response.status}: ${JSON.stringify(body)}`);
}
const setCookie = response.headers.getSetCookie?.() ?? [];
const cookieLine = setCookie.find((line) => line.startsWith(`${USER_COOKIE}=`))
?? response.headers.get('set-cookie');
const match = String(cookieLine ?? '').match(new RegExp(`${USER_COOKIE}=([^;]+)`));
const token = match?.[1] ? decodeURIComponent(match[1]) : null;
if (!token) {
throw new Error('登录成功但未解析到会话 Cookie');
}
reporter.pass('登录', `${username} (${body.user?.id ?? 'unknown-id'})`);
return {
token,
cookie: `${USER_COOKIE}=${token}`,
user: body.user ?? null,
};
}
function buildUserMessage(text) {
return {
id: crypto.randomUUID(),
role: 'user',
content: [{ type: 'text', text }],
metadata: { userVisible: true, displayText: text },
};
}
export async function createAgentRun(baseUrl, cookie, { message, sessionId = null }) {
const requestId = crypto.randomUUID();
const body = {
request_id: requestId,
user_message: buildUserMessage(message),
};
if (sessionId) body.session_id = sessionId;
const response = await fetch(`${baseUrl}/api/agent/runs`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Cookie: cookie,
},
body: JSON.stringify(body),
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(`POST /api/agent/runs ${response.status}: ${JSON.stringify(payload)}`);
}
const run = payload.run ?? payload;
return {
runId: run.id,
requestId,
sessionId: run.sessionId ?? run.agent_session_id ?? sessionId ?? null,
status: run.status,
};
}
export async function getAgentRun(baseUrl, cookie, runId) {
const response = await fetch(`${baseUrl}/api/agent/runs/${runId}`, {
headers: { Cookie: cookie },
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(`GET /api/agent/runs/${runId} ${response.status}: ${JSON.stringify(payload)}`);
}
return payload.run ?? payload;
}
export async function getSession(baseUrl, cookie, sessionId) {
const response = await fetch(`${baseUrl}/api/sessions/${encodeURIComponent(sessionId)}`, {
headers: { Cookie: cookie },
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
return { ok: false, status: response.status, payload };
}
return { ok: true, session: payload };
}
export function extractAssistantTexts(sessionPayload) {
const conversation = sessionPayload?.conversation
?? sessionPayload?.session?.conversation
?? sessionPayload?.messages
?? [];
const texts = [];
for (const message of conversation) {
if (message?.role !== 'assistant') continue;
const content = message?.content;
if (typeof content === 'string') texts.push(content);
else if (Array.isArray(content)) {
texts.push(
content
.map((item) => (item?.type === 'text' ? item.text ?? '' : ''))
.join(''),
);
}
}
return texts.filter(Boolean);
}
export function extractPublicLinks(text, baseUrl) {
const links = new Set();
const markdown = /\[[^\]]+\]\((https?:\/\/[^)]+|\/MindSpace\/[^)]+)\)/gi;
const bare = /(https?:\/\/[^\s)]+?\/MindSpace\/[^\s)]+?\.html)/gi;
const relative = /(\/MindSpace\/[^\s)]+?\.html)/gi;
for (const pattern of [markdown, bare, relative]) {
for (const match of text.matchAll(pattern)) {
const raw = match[1] ?? match[0];
if (!raw) continue;
links.add(raw.startsWith('http') ? raw : new URL(raw, baseUrl).href);
}
}
return [...links];
}
async function listPublicHtmlFiles(publishDir) {
const publicDir = path.join(publishDir, 'public');
try {
const entries = await fs.readdir(publicDir, { withFileTypes: true });
const files = [];
for (const entry of entries) {
if (!entry.isFile() || !entry.name.endsWith('.html')) continue;
const fullPath = path.join(publicDir, entry.name);
const stat = await fs.stat(fullPath);
files.push({
name: entry.name,
fullPath,
mtimeMs: stat.mtimeMs,
relativePublicPath: `public/${entry.name}`,
});
}
return files;
} catch {
return [];
}
}
export async function snapshotPublicHtml(publishKey) {
const publishDir = path.join(repoRoot, PUBLISH_ROOT_DIR, publishKey);
return listPublicHtmlFiles(publishDir);
}
export async function waitForRunTerminal(baseUrl, cookie, runId, timeoutMs) {
const started = Date.now();
let last = null;
while (Date.now() - started < timeoutMs) {
last = await getAgentRun(baseUrl, cookie, runId);
if (['succeeded', 'failed'].includes(last.status)) {
return last;
}
await sleep(2000);
}
throw new Error(`run ${runId} 超时未完成,最后状态 ${last?.status ?? 'unknown'}`);
}
export async function waitForAssistantGrowth(baseUrl, cookie, sessionId, {
previousCount = 0,
previousCombinedLength = 0,
minChars = 1,
timeoutMs = 120000,
}) {
const started = Date.now();
while (Date.now() - started < timeoutMs) {
const result = await getSession(baseUrl, cookie, sessionId);
if (result.ok) {
const texts = extractAssistantTexts(result.session);
const combined = texts.join('\n').trim();
const grew = texts.length > previousCount || combined.length > previousCombinedLength;
if (grew && combined.length >= minChars) {
return {
texts,
combined,
elapsedMs: Date.now() - started,
count: texts.length,
};
}
}
await sleep(2000);
}
return null;
}
export async function verifyPageAccess({
baseUrl,
cookie,
publishKey,
replyText,
htmlBefore = [],
expect = {},
reporter,
}) {
const keywords = expect.keywords ?? [];
const links = extractPublicLinks(replyText, baseUrl);
let pageUrl = links.find((url) => /\/MindSpace\/.+\.html/i.test(url)) ?? null;
if (!pageUrl && publishKey) {
const htmlAfter = await listPublicHtmlFiles(path.join(repoRoot, PUBLISH_ROOT_DIR, publishKey));
const beforeSet = new Set(htmlBefore.map((item) => item.fullPath));
const fresh = htmlAfter
.filter((item) => !beforeSet.has(item.fullPath))
.sort((a, b) => b.mtimeMs - a.mtimeMs);
if (fresh[0]) {
pageUrl = new URL(
`/MindSpace/${publishKey}/${fresh[0].relativePublicPath}`,
baseUrl,
).href;
reporter.pass('页面落盘', fresh[0].relativePublicPath);
}
}
if (expect.requirePublicLink !== false) {
if (!pageUrl) {
reporter.fail('公网页面链接', '回复中未找到 MindSpace 链接,public/ 也无新 HTML');
return false;
}
reporter.pass('公网页面链接', pageUrl);
}
if (pageUrl && expect.requireHttp200 !== false) {
const response = await fetch(pageUrl, { headers: cookie ? { Cookie: cookie } : {} });
const html = await response.text();
if (response.status !== 200) {
reporter.fail('页面 HTTP 状态', `${response.status} ${pageUrl}`);
return false;
}
reporter.pass('页面 HTTP 200', pageUrl);
if (keywords.length) {
const hit = keywords.filter((word) => html.includes(word));
if (hit.length === 0) {
reporter.fail('页面内容关键词', `未命中: ${keywords.join(', ')}`);
return false;
}
reporter.pass('页面内容关键词', hit.join(', '));
}
if (expect.requireMindspaceCover && !/mindspace-cover/i.test(html)) {
reporter.fail('页面元数据', '缺少 mindspace-cover');
return false;
}
}
return true;
}
export async function loadScenario(scenarioId) {
const scenarioPath = path.join(repoRoot, 'scenarios', `${scenarioId}.json`);
const raw = await fs.readFile(scenarioPath, 'utf8');
return JSON.parse(raw);
}
export async function listScenarios() {
const dir = path.join(repoRoot, 'scenarios');
const entries = await fs.readdir(dir, { withFileTypes: true });
const scenarios = [];
for (const entry of entries) {
if (!entry.isFile() || !entry.name.endsWith('.json') || entry.name.startsWith('_')) continue;
const scenario = JSON.parse(await fs.readFile(path.join(dir, entry.name), 'utf8'));
scenarios.push({
id: scenario.id ?? entry.name.replace(/\.json$/, ''),
name: scenario.name ?? scenario.id,
description: scenario.description ?? '',
});
}
return scenarios;
}
+17 -2
View File
@@ -5756,7 +5756,7 @@ async function ensurePublicHtmlPrivateAssetsMaterialized(filePath, html) {
return result.changed ? result.html : html;
}
async function sendPublishFile(req, res, filePath) {
async function sendPublishFile(req, res, filePath, { isOwner = true } = {}) {
if (!filePath.toLowerCase().endsWith('.html')) {
res.sendFile(filePath, (err) => {
if (err && !res.headersSent) res.status(404).json({ message: '文件不存在' });
@@ -5788,6 +5788,7 @@ async function sendPublishFile(req, res, filePath) {
const decorated = decorateMindSpacePublishedHtml({
html,
embed,
isOwner,
context,
htmlFilePath: filePath,
userAgent: req.get('user-agent') || '',
@@ -5804,6 +5805,10 @@ async function sendPublishFile(req, res, filePath) {
}
res.set('Content-Security-Policy', decorated.csp);
res.set('Content-Type', 'text/html; charset=utf-8');
// REGRESSION GUARD: mindspace-public-owner-vs-visitor — this HTML now varies by viewer
// (owner sees the Plaza entry, visitors don't), so it must never be cached/shared across
// sessions. There is no CDN/proxy_cache in front of this route today; keep it that way.
res.set('Cache-Control', 'private, no-store');
res.send(html);
}
@@ -5861,7 +5866,17 @@ async function serveUserPublishFile(req, res, next) {
}
}
await sendPublishFile(req, res, resolvedPath);
// REGRESSION GUARD: mindspace-public-owner-vs-visitor — this workspace URL is the one the
// agent hands back in chat and is reachable by anyone (no login required). Only the logged-in
// author (viewer.id === ownerKey) may see the "发布 Plaza" entry; everyone else is a visitor.
const viewer = req.userSession && userAuth
? await userAuth.getMe(req.userToken).catch(() => null)
: null;
const isOwner = Boolean(
viewer?.id && result.ownerKey && String(viewer.id).toLowerCase() === String(result.ownerKey).toLowerCase(),
);
await sendPublishFile(req, res, resolvedPath, { isOwner });
}
app.use('/temp', (req, res) => {
+5
View File
@@ -14,6 +14,11 @@ description: 商品宣传 / 活动页技能:基于商品链接、主题、图
- 用户上传商品图、Logo、模特图、详情图,希望生成页面文案和视觉结构
- 用户要做促销、上新、种草、达人推荐、节日活动或私域转化页面
## 何时不用
- 旅游攻略、城市介绍、游记、主题内容页 → 使用 `static-page-publish`,直接生成 HTML 并返回链接
- 用户未提供商品链接、也不要求购买跳转时,不要套用本技能
## 必问信息
如果用户没有一次性给全,优先只问这 3 个问题:
+8
View File
@@ -123,6 +123,14 @@ npm run check:mindspace-cover
`data-mindspace-page-tag` 的区域为平台固定信息:用户在编辑模式中不可见、不可改;预览与发布后正常显示。**禁止**把该行 CSS 透明度设过低(如 `opacity: 0.25`),否则页内看不见品牌。
## 中文字体(推荐)
中文页面正文与标题优先使用系统无衬线字体栈,**避免** Georgia、Times New Roman 等西文衬线体(中文显示发虚、不协调):
```css
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
```
## 按需附带文件下载(Word / PDF)
- 只有用户明确要求 Word / PDF 下载时才生成二进制文件
+1
View File
@@ -239,6 +239,7 @@ npm run check:mindspace-cover
- 完整 \`<!DOCTYPE html>\`\`lang="zh-CN"\`
- 移动端友好\`<meta name="viewport" content="width=device-width, initial-scale=1">\`
- 深色/浅色与内容一致主色在 CSS mindspace-cover 中保持一致
- 中文页面优先使用系统无衬线字体\`-apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif\`**避免** Georgia、Times 等西文衬线体
- 页脚平台品牌行**必须**使用 \`data-mindspace-page-tag="platform-brand"\` 标记,显示为 **TKMind · 智趣**,**禁止**使用邮箱或 \`tkmind.ai\`
\`\`\`html