Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ac520d8ae5 | |||
| b4171ca268 | |||
| ec8d0464a3 | |||
| 7edafefc56 | |||
| 0d6258d00a |
@@ -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(苏州攻略一日游页面)
|
||||
- Portal:http://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 |
|
||||
@@ -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(苏州攻略一日游页面)
|
||||
- Portal:http://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 |
|
||||
@@ -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
@@ -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} 低于阈值,走默认通道)`,
|
||||
|
||||
@@ -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
@@ -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 '';
|
||||
|
||||
@@ -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, /禁止询问是否还要发布/);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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` 接入 Portal,SSE route 已禁缓冲。
|
||||
- P1 Gateway SSE 配置: 已完成,`m.tkmind.cn` 接入 Portal,SSE 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/OpenHands,code 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`
|
||||
|
||||
@@ -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
@@ -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` |
|
||||
|
||||
|
||||
@@ -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>`;
|
||||
|
||||
@@ -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
@@ -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); }
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
|
||||
@@ -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%); }
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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:',
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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
@@ -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) => {
|
||||
|
||||
@@ -14,6 +14,11 @@ description: 商品宣传 / 活动页技能:基于商品链接、主题、图
|
||||
- 用户上传商品图、Logo、模特图、详情图,希望生成页面文案和视觉结构
|
||||
- 用户要做促销、上新、种草、达人推荐、节日活动或私域转化页面
|
||||
|
||||
## 何时不用
|
||||
|
||||
- 旅游攻略、城市介绍、游记、主题内容页 → 使用 `static-page-publish`,直接生成 HTML 并返回链接
|
||||
- 用户未提供商品链接、也不要求购买跳转时,不要套用本技能
|
||||
|
||||
## 必问信息
|
||||
|
||||
如果用户没有一次性给全,优先只问这 3 个问题:
|
||||
|
||||
@@ -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 下载时才生成二进制文件
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user