Compare commits

..

5 Commits

Author SHA1 Message Date
john 1e4f432754 fix(chat): 阻止同会话并发 Agent Run 导致消息错乱
前端 submit 改用 chatStateRef 同步拦截连发;后端 createRun 检测同 session 活跃任务并返回 409,避免多条回复交错落库。

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

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

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

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

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

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

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-07 10:56:26 +08:00
34 changed files with 1860 additions and 65 deletions
@@ -0,0 +1,108 @@
---
name: memind-scenario-test
description: >-
Run Memind end-to-end scenario simulations (login, multi-turn chat, page
generation, public URL checks). Use when the user asks for 模拟场景测试, 场景测试,
E2E 流程验证, 模拟用户操作, or describes a step-by-step chat/page test flow.
---
# Memind 场景模拟测试
用户说「**帮我模拟场景测试**」或描述多步聊天/做页面流程时,**必须**走本 skill,不要手动拼 curl 或浏览器点选。
## 唯一入口
```bash
node scripts/run-scenario-test.mjs --scenario <id>
```
列出场景:
```bash
node scripts/run-scenario-test.mjs --list
```
## 前置条件
1. 本地 Portal 已运行(默认 `http://127.0.0.1:8081`,可用 `pnpm dev`
2. goosed / agent 链路可用(场景会真实发消息并等待 agent 回复)
3. 账户默认 `john` / `888888`;密码可用 `JOHN_PASSWORD` 覆盖
启动前检查:
```bash
curl -sf http://127.0.0.1:8081/auth/status
```
## 默认场景:苏州攻略页面
对应用户定义的流程,场景文件 [`scenarios/suzhou-page.json`](../../../scenarios/suzhou-page.json)
1. 登录 `john` / `888888`
2. 发送 `hi`,等待 assistant 回复
3. 发送 `帮我看看苏州攻略,1日攻略,做一个页面`
4. 验证:收到回复、含苏州关键词、有公网页面链接、HTTP 200、页面内容命中关键词
执行:
```bash
node scripts/run-scenario-test.mjs --scenario suzhou-page
```
## Agent 工作流
1. **确认场景**:用户未指定时默认 `suzhou-page`;说了别的流程则查 `--list` 或新建场景文件
2. **确认服务**Portal `/auth/status` 必须 200
3. **执行脚本**:完整跑完,不要中途改用浏览器手点
4. **失败处理**:读失败项 → 修代码或环境 → 重跑同一场景
5. **汇报**:用下方模板
## 扩展新场景
复制 [`scenarios/_template.json`](../../../scenarios/_template.json) 为 `scenarios/<id>.json`
步骤类型(当前支持):
| action | 说明 |
|--------|------|
| `login` | HTTP 登录,取 cookie + userId |
| `chat` | 发消息(同 session 多轮),等 run 终态 + assistant 新回复 |
`chat.expect` 可选断言:
- `assistantMinChars` / `timeoutMs`
- `replyKeywords`:回复文本关键词
- `page.keywords` / `requirePublicLink` / `requireHttp200` / `requireMindspaceCover`
新增更复杂步骤(截图、微信、多账户)时:先扩展 `scripts/scenario-test-lib.mjs`,再加场景 JSON,**不要**在 skill 里写死一次性命令。
## 结果模板
```markdown
## 场景测试结果
- 场景:suzhou-page(苏州攻略一日游页面)
- Portalhttp://127.0.0.1:8081
- 账户:john
- 结果:通过 / 失败
### 步骤
1. 登录 — 通过
2. hi → 回复 — 通过(N 字)
3. 苏州页面 — 通过 / 失败
### 页面(如有)
- 链接:
- HTTP
- 关键词:
### 失败项与下一步
```
## 与 memind-test 的分工
| 诉求 | 用哪个 |
|------|--------|
| 模拟真实用户聊天 + 做页面 | **本 skill**`run-scenario-test.mjs` |
| 改代码后跑单测/回归守卫 | `memind-test` skill`run-memind-tests.mjs` |
| 103 发包 | `portal-release` skill |
+78
View File
@@ -0,0 +1,78 @@
---
name: memind-test
description: >-
Run Memind scoped or release tests automatically after code changes. Use when
the user asks to test, verify, run tests, check regressions, or before
merging/releasing; also after editing protected MindSpace/chat/agent paths.
---
# Memind 自动测试
不要手动拼凑 `node --test` 命令。统一入口:
```bash
node scripts/run-memind-tests.mjs --mode <mode>
```
## 选模式
| 用户意图 | 模式 |
|----------|------|
| 改完代码、日常验证 | `changed`(默认) |
| 快速冒烟 | `quick` |
| 只跑回归守卫 | `guards` |
| 发包前 | `release` |
| 全量单测 | `full` |
示例:
```bash
node scripts/run-memind-tests.mjs
node scripts/run-memind-tests.mjs --mode quick
node scripts/run-memind-tests.mjs --mode release
```
对比基准分支时用 `--base origin/main`
## Agent 工作流
1. **判断模式**:用户说「测试/验证/跑一下」→ `changed`;说「发包前」→ `release`;说「全量」→ `full`
2. **执行**:在仓库根目录跑脚本,不要跳过。
3. **失败时**:读报错 → 修代码 → 用同一模式重跑,直到通过。
4. **汇报**:用下方模板,不要只说「通过了」。
## 受保护路径(改后必跑 guards)
改这些文件时,即使 `changed` 未命中也要加跑 `guards`
- `mindspace-public-finish-sync.mjs``chat-finish-sync.mjs`
- `conversation-display.mjs``src/utils/message.ts``src/hooks/useTKMindChat.ts`
- `mindspace-pages.mjs``mindspace-page-sync-service.mjs``server.mjs`
```bash
node scripts/run-memind-tests.mjs --mode guards
```
详见 [docs/regression-guards/README.md](../../../docs/regression-guards/README.md)。
## 结果模板
```markdown
## 测试结果
- 模式:`changed` / `quick` / `guards` / `release` / `full`
- 变更文件:N 个(列出关键路径)
- 执行:X 个 test + Y 个 verify
- 结果:通过 / 失败
### 失败项(如有)
- 命令:
- 错误摘要:
- 下一步:
```
## 与发布 skill 的关系
- 日常开发:本 skill + `changed` / `guards`
- 103 生产发布:用 `portal-release` skill,其发包前测试与本 skill 的 `release` 模式一致
- 不要混用「手动挑几个 test 文件」替代上述入口
@@ -0,0 +1,108 @@
---
name: memind-scenario-test
description: >-
Run Memind end-to-end scenario simulations (login, multi-turn chat, page
generation, public URL checks). Use when the user asks for 模拟场景测试, 场景测试,
E2E 流程验证, 模拟用户操作, or describes a step-by-step chat/page test flow.
---
# Memind 场景模拟测试
用户说「**帮我模拟场景测试**」或描述多步聊天/做页面流程时,**必须**走本 skill,不要手动拼 curl 或浏览器点选。
## 唯一入口
```bash
node scripts/run-scenario-test.mjs --scenario <id>
```
列出场景:
```bash
node scripts/run-scenario-test.mjs --list
```
## 前置条件
1. 本地 Portal 已运行(默认 `http://127.0.0.1:8081`,可用 `pnpm dev`
2. goosed / agent 链路可用(场景会真实发消息并等待 agent 回复)
3. 账户默认 `john` / `888888`;密码可用 `JOHN_PASSWORD` 覆盖
启动前检查:
```bash
curl -sf http://127.0.0.1:8081/auth/status
```
## 默认场景:苏州攻略页面
对应用户定义的流程,场景文件 [`scenarios/suzhou-page.json`](../../../scenarios/suzhou-page.json)
1. 登录 `john` / `888888`
2. 发送 `hi`,等待 assistant 回复
3. 发送 `帮我看看苏州攻略,1日攻略,做一个页面`
4. 验证:收到回复、含苏州关键词、有公网页面链接、HTTP 200、页面内容命中关键词
执行:
```bash
node scripts/run-scenario-test.mjs --scenario suzhou-page
```
## Agent 工作流
1. **确认场景**:用户未指定时默认 `suzhou-page`;说了别的流程则查 `--list` 或新建场景文件
2. **确认服务**Portal `/auth/status` 必须 200
3. **执行脚本**:完整跑完,不要中途改用浏览器手点
4. **失败处理**:读失败项 → 修代码或环境 → 重跑同一场景
5. **汇报**:用下方模板
## 扩展新场景
复制 [`scenarios/_template.json`](../../../scenarios/_template.json) 为 `scenarios/<id>.json`
步骤类型(当前支持):
| action | 说明 |
|--------|------|
| `login` | HTTP 登录,取 cookie + userId |
| `chat` | 发消息(同 session 多轮),等 run 终态 + assistant 新回复 |
`chat.expect` 可选断言:
- `assistantMinChars` / `timeoutMs`
- `replyKeywords`:回复文本关键词
- `page.keywords` / `requirePublicLink` / `requireHttp200` / `requireMindspaceCover`
新增更复杂步骤(截图、微信、多账户)时:先扩展 `scripts/scenario-test-lib.mjs`,再加场景 JSON,**不要**在 skill 里写死一次性命令。
## 结果模板
```markdown
## 场景测试结果
- 场景:suzhou-page(苏州攻略一日游页面)
- Portalhttp://127.0.0.1:8081
- 账户:john
- 结果:通过 / 失败
### 步骤
1. 登录 — 通过
2. hi → 回复 — 通过(N 字)
3. 苏州页面 — 通过 / 失败
### 页面(如有)
- 链接:
- HTTP
- 关键词:
### 失败项与下一步
```
## 与 memind-test 的分工
| 诉求 | 用哪个 |
|------|--------|
| 模拟真实用户聊天 + 做页面 | **本 skill**`run-scenario-test.mjs` |
| 改代码后跑单测/回归守卫 | `memind-test` skill`run-memind-tests.mjs` |
| 103 发包 | `portal-release` skill |
+79
View File
@@ -0,0 +1,79 @@
---
name: memind-test
description: >-
Run Memind scoped or release tests automatically after code changes. Use when
the user asks to test, verify, run tests, check regressions, or before
merging/releasing; also after editing protected MindSpace/chat/agent paths.
---
# Memind 自动测试
不要手动拼凑 `node --test` 命令。统一入口:
```bash
node scripts/run-memind-tests.mjs --mode <mode>
```
## 选模式
| 用户意图 | 模式 |
|----------|------|
| 改完代码、日常验证 | `changed`(默认) |
| 快速冒烟 | `quick` |
| 只跑回归守卫 | `guards` |
| 发包前 | `release` |
| 全量单测 | `full` |
示例:
```bash
node scripts/run-memind-tests.mjs
node scripts/run-memind-tests.mjs --mode quick
node scripts/run-memind-tests.mjs --mode release
```
对比基准分支时用 `--base origin/main`
## Agent 工作流
1. **判断模式**:用户说「测试/验证/跑一下」→ `changed`;说「发包前」→ `release`;说「全量」→ `full`
2. **执行**:在仓库根目录跑脚本,不要跳过。
3. **失败时**:读报错 → 修代码 → 用同一模式重跑,直到通过。
4. **汇报**:用下方模板,不要只说「通过了」。
## 受保护路径(改后必跑 guards)
改这些文件时,即使 `changed` 未命中也要加跑 `guards`
- `mindspace-public-finish-sync.mjs``chat-finish-sync.mjs`
- `conversation-display.mjs``src/utils/message.ts``src/hooks/useTKMindChat.ts`
- `mindspace-pages.mjs``mindspace-page-sync-service.mjs``server.mjs`
```bash
node scripts/run-memind-tests.mjs --mode guards
```
详见 [docs/regression-guards/README.md](../../../docs/regression-guards/README.md)。
## 结果模板
```markdown
## 测试结果
- 模式:`changed` / `quick` / `guards` / `release` / `full`
- 变更文件:N 个(列出关键路径)
- 执行:X 个 test + Y 个 verify
- 结果:通过 / 失败
### 失败项(如有)
- 命令:
- 错误摘要:
- 下一步:
```
## 与发布 skill 的关系
- 日常开发:本 skill + `changed` / `guards`
- **模拟用户聊天/做页面**:用 `memind-scenario-test` skill`run-scenario-test.mjs`
- 103 生产发布:用 `portal-release` skill,其发包前测试与本 skill 的 `release` 模式一致
- 不要混用「手动挑几个 test 文件」替代上述入口
+17
View File
@@ -340,6 +340,23 @@ export function createAgentRunGateway({
return projectRun(existing);
}
// Prevent multiple concurrent agent runs on the same Goose session, which would
// cause replies to arrive out of order and appear garbled in the chat UI.
if (sessionId && !isDirectChatSessionId(sessionId)) {
const [activeRows] = await pool.query(
`SELECT id FROM h5_agent_runs
WHERE agent_session_id = ? AND status NOT IN ('succeeded', 'failed')
LIMIT 1`,
[sessionId],
);
if (activeRows.length > 0) {
const conflict = new Error('该会话有正在处理的任务,请等待完成后再发送');
conflict.code = 'SESSION_RUN_CONFLICT';
conflict.status = 409;
throw conflict;
}
}
const runId = crypto.randomUUID();
const createdAt = nowMs();
const runMessage = withRunMetadata(userMessage, {
+92
View File
@@ -1,4 +1,5 @@
import assert from 'node:assert/strict';
import crypto from 'node:crypto';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
@@ -29,6 +30,13 @@ function createFakePool() {
const [userId, requestId] = params;
return [[...runs.values()].filter((row) => row.user_id === userId && row.request_id === requestId)];
}
if (sql.includes('agent_session_id = ?') && sql.includes("status NOT IN ('succeeded', 'failed')")) {
const [sessionId] = params;
const active = [...runs.values()].filter(
(row) => row.agent_session_id === sessionId && !['succeeded', 'failed'].includes(row.status),
);
return [active.slice(0, 1).map((row) => ({ id: row.id }))];
}
if (sql.includes('SELECT * FROM h5_agent_runs WHERE id = ? LIMIT 1')) {
return [[runs.get(params[0])].filter(Boolean)];
}
@@ -1571,3 +1579,87 @@ test('markRun appends run_snapshot when MEMIND_RUN_STREAM_REPLAY=1', async () =>
else process.env.MEMIND_RUN_STREAM_REPLAY = previous;
}
});
test('createRun rejects with SESSION_RUN_CONFLICT when same session already has active run', async () => {
const pool = createFakePool();
const gateway = createAgentRunGateway({
pool,
userAuth: {},
tkmindProxy: {},
autoDispatch: false,
});
const activeRunId = crypto.randomUUID();
const now = Date.now();
pool.runs.set(activeRunId, {
id: activeRunId,
user_id: 'user-1',
agent_session_id: 'sess-conflict-1',
request_id: 'req-active',
status: 'running',
attempts: 1,
user_message_json: '{}',
error_message: null,
created_at: now,
updated_at: now,
started_at: now,
completed_at: null,
});
let conflictErr = null;
try {
await gateway.createRun('user-1', {
sessionId: 'sess-conflict-1',
requestId: 'req-conflict-2',
userMessage: { role: 'user', content: [{ type: 'text', text: 'second' }] },
});
} catch (err) {
conflictErr = err;
}
assert.ok(conflictErr, 'expected an error for duplicate active session run');
assert.equal(conflictErr.code, 'SESSION_RUN_CONFLICT');
assert.equal(conflictErr.status, 409);
pool.runs.get(activeRunId).status = 'succeeded';
const run3 = await gateway.createRun('user-1', {
sessionId: 'sess-conflict-1',
requestId: 'req-conflict-3',
userMessage: { role: 'user', content: [{ type: 'text', text: 'third' }] },
});
assert.equal(run3.requestId, 'req-conflict-3');
});
test('createRun does not apply per-session conflict check for direct-chat sessions', async () => {
const pool = createFakePool();
const gateway = createAgentRunGateway({
pool,
userAuth: {},
tkmindProxy: {},
autoDispatch: false,
});
const activeRunId = crypto.randomUUID();
const now = Date.now();
pool.runs.set(activeRunId, {
id: activeRunId,
user_id: 'user-1',
agent_session_id: 'h5direct_abc',
request_id: 'req-active-dc',
status: 'running',
attempts: 1,
user_message_json: '{}',
error_message: null,
created_at: now,
updated_at: now,
started_at: now,
completed_at: null,
});
const run2 = await gateway.createRun('user-1', {
sessionId: 'h5direct_abc',
requestId: 'req-dc-2',
userMessage: { role: 'user', content: [{ type: 'text', text: 'hi2' }] },
});
assert.equal(run2.requestId, 'req-dc-2');
});
+7
View File
@@ -127,6 +127,13 @@ export function createPostAgentRunsHandler({
});
response.status(202).json({ run });
} catch (err) {
if (err?.code === 'SESSION_RUN_CONFLICT') {
response.status(409).json({
message: err.message,
code: 'SESSION_RUN_CONFLICT',
});
return;
}
response.status(500).json({
message: err instanceof Error ? err.message : '创建任务失败',
});
+37
View File
@@ -464,6 +464,43 @@ test('POST /agent/runs rejects a session the user does not own', async () => {
assert.deepEqual(res.body, { message: '无权访问该会话' });
});
test('POST /agent/runs returns 409 when session already has active run', async () => {
const handler = createPostAgentRunsHandler({
userAuth: {
async ownsSession() {
return true;
},
},
agentRunGateway: {
async createRun() {
const err = new Error('该会话有正在处理的任务,请等待完成后再发送');
err.code = 'SESSION_RUN_CONFLICT';
err.status = 409;
throw err;
},
},
});
const res = createResponseRecorder();
await handler(
{
currentUser: { id: 'user-1' },
body: {
session_id: 'session-busy',
request_id: 'req-busy',
user_message: { role: 'user', content: [{ type: 'text', text: 'hello' }] },
},
},
res,
);
assert.equal(res.statusCode, 409);
assert.deepEqual(res.body, {
message: '该会话有正在处理的任务,请等待完成后再发送',
code: 'SESSION_RUN_CONFLICT',
});
});
test('POST /agent/runs validates ownership through sessionAccess when provided', async () => {
const validated = [];
const handler = createPostAgentRunsHandler({
+42 -11
View File
@@ -3,6 +3,8 @@ import {
buildWebNewsSkillPrompt,
extractSelectedChatSkillName,
hasExplicitChatSkillPrompt,
isPageGenerationIntent,
isProductCampaignIntent,
} from './chat-skills.mjs';
import { isDirectChatSessionId } from './direct-chat-service.mjs';
import {
@@ -111,6 +113,26 @@ function buildRealtimeInfoClassification() {
}, { source: 'rule' });
}
export function coercePageGenerationSkill(classification, text, { grantedSkills = [] } = {}) {
if (!classification || classification.route !== CHAT_INTENT_ROUTE.AGENT) return classification;
if (!isPageGenerationIntent(text) || isProductCampaignIntent(text)) return classification;
if (grantedSkills.length > 0 && !grantedSkills.includes('static-page-publish')) return classification;
const shouldUsePublishSkill =
!classification.suggestedSkill || classification.suggestedSkill === 'product-campaign-page';
if (!shouldUsePublishSkill) return classification;
const reasonSuffix = classification.suggestedSkill === 'product-campaign-page'
? '(内容页意图,改用 static-page-publish'
: '(内容页生成意图)';
return {
...classification,
suggestedSkill: 'static-page-publish',
agentBrief: '生成或更新 MindSpace 公开内容页面,并返回可访问链接;信息不足时使用合理默认,不要停在追问。',
reason: `${classification.reason}${reasonSuffix}`,
};
}
export function coerceRealtimeWebSkill(classification, text, { grantedSkills = [] } = {}) {
if (!classification || classification.route !== CHAT_INTENT_ROUTE.AGENT) return classification;
if (!isRealtimeInfoQuestion(text)) return classification;
@@ -276,6 +298,10 @@ function buildRouterSystemPrompt(grantedSkills = []) {
? '若走 agent_orchestration,可在 suggested_skill 中填写最匹配的 skill 名称(须来自上述列表),否则填 null。'
: 'suggested_skill 通常填 null。',
'',
'skill 选择补充:',
'- static-page-publish:攻略/游记/主题内容页、城市介绍、活动介绍、任何「做页面/生成链接」且无商品购买跳转需求。',
'- product-campaign-page:仅当用户明确提供商品链接或要求购买按钮/电商转化时使用,不要用于旅游攻略或纯内容页。',
'',
'只输出 JSON,不要 markdown,不要解释:',
'{"route":"direct_chat|agent_orchestration","confidence":0.0,"reason":"一句话","suggested_skill":null,"agent_brief":"给 Agent 的执行要点,direct_chat 时可为空"}',
].join('\n');
@@ -713,14 +739,15 @@ export function classifyWithRules({
if (
includeIntentPatterns &&
normalized &&
OBVIOUS_AGENT_PATTERNS.some((pattern) => pattern.test(normalized))
(OBVIOUS_AGENT_PATTERNS.some((pattern) => pattern.test(normalized))
|| isPageGenerationIntent(normalized))
) {
return finalizeRouterClassification(normalizeClassification({
route: CHAT_INTENT_ROUTE.AGENT,
confidence: 0.95,
reason: '明确需要生成或发布页面/文件',
suggested_skill: 'static-page-publish',
agent_brief: '生成或更新 MindSpace 公开页面,并返回可访问链接。',
agent_brief: '生成或更新 MindSpace 公开页面,并返回可访问链接;信息不足时使用合理默认,不要停在追问。',
}, { source: 'rule' }), decisionContext);
}
if (includeIntentPatterns && normalized && isRealtimeInfoQuestion(normalized)) {
@@ -848,12 +875,16 @@ export function createChatIntentRouter(options = {}) {
sessionId,
sessionMessageCount,
};
const finalizeWithRealtimeCoercion = (classification) => {
const finalizeWithCoercion = (classification) => {
if (!classification) return classification;
const base = { ...classification };
delete base.decision;
return finalizeRouterClassification(
coerceRealtimeWebSkill(base, text, { grantedSkills }),
coercePageGenerationSkill(
coerceRealtimeWebSkill(base, text, { grantedSkills }),
text,
{ grantedSkills },
),
decisionContext,
);
};
@@ -867,9 +898,9 @@ export function createChatIntentRouter(options = {}) {
includeIntentPatterns: true,
llmRouterEnabled: Boolean(policy.enabled),
});
if (ruleResult) return finalizeWithRealtimeCoercion(ruleResult);
if (ruleResult) return finalizeWithCoercion(ruleResult);
if (!isEnabled()) {
return finalizeWithRealtimeCoercion(normalizeClassification({
return finalizeWithCoercion(normalizeClassification({
route: policy.fallbackRoute,
confidence: 0.5,
reason: '意图路由未启用,走默认通道',
@@ -905,7 +936,7 @@ export function createChatIntentRouter(options = {}) {
'Chat intent router',
);
} catch (err) {
return finalizeWithRealtimeCoercion(normalizeClassification({
return finalizeWithCoercion(normalizeClassification({
route: policy.fallbackRoute,
confidence: 0,
reason: err instanceof Error ? err.message : '意图路由失败,走默认通道',
@@ -913,7 +944,7 @@ export function createChatIntentRouter(options = {}) {
}, { source: 'fallback', fallbackRoute: policy.fallbackRoute }));
}
if (!completion?.ok) {
return finalizeWithRealtimeCoercion(normalizeClassification({
return finalizeWithCoercion(normalizeClassification({
route: policy.fallbackRoute,
confidence: 0,
reason: completion?.message ?? '意图路由失败,走默认通道',
@@ -923,7 +954,7 @@ export function createChatIntentRouter(options = {}) {
const parsed = parseRouterJson(completion.reply);
if (!parsed) {
return finalizeWithRealtimeCoercion(normalizeClassification({
return finalizeWithCoercion(normalizeClassification({
route: policy.fallbackRoute,
confidence: 0,
reason: '意图路由响应无法解析,走默认通道',
@@ -931,7 +962,7 @@ export function createChatIntentRouter(options = {}) {
}, { source: 'fallback', fallbackRoute: policy.fallbackRoute }));
}
const classification = finalizeWithRealtimeCoercion({
const classification = finalizeWithCoercion({
...normalizeClassification(parsed, { source: 'llm', fallbackRoute: policy.fallbackRoute }),
providerKeyId: completion.providerKeyId ?? policy.modelProviderKeyId ?? null,
model: completion.model ?? policy.model ?? null,
@@ -941,7 +972,7 @@ export function createChatIntentRouter(options = {}) {
classification.route === CHAT_INTENT_ROUTE.DIRECT_CHAT &&
classification.confidence < policy.minConfidence
) {
return finalizeWithRealtimeCoercion(normalizeClassification({
return finalizeWithCoercion(normalizeClassification({
...classification,
route: policy.fallbackRoute,
reason: `${classification.reason}(置信度 ${classification.confidence} 低于阈值,走默认通道)`,
+32
View File
@@ -7,6 +7,7 @@ import {
buildRouterNormalizedDecision,
CHAT_INTENT_ROUTE,
classifyWithRules,
coercePageGenerationSkill,
createChatIntentRouter,
createManagedChatIntentRouter,
isNormalizedRouterDecisionEnabled,
@@ -49,6 +50,37 @@ test('classifyWithRules routes page generation to agent orchestration', () => {
assert.equal(result.suggestedSkill, 'static-page-publish');
});
test('classifyWithRules routes implicit travel guide page requests to static-page-publish', () => {
const result = classifyWithRules({
text: '苏州攻略页面',
userMessage: {
role: 'user',
content: [{ type: 'text', text: '苏州攻略页面' }],
metadata: { displayText: '苏州攻略页面' },
},
});
assert.equal(result.route, CHAT_INTENT_ROUTE.AGENT);
assert.equal(result.suggestedSkill, 'static-page-publish');
assert.match(result.agentBrief, /不要停在追问/);
});
test('coercePageGenerationSkill replaces product-campaign-page for content pages', () => {
const coerced = coercePageGenerationSkill(
{
route: CHAT_INTENT_ROUTE.AGENT,
suggestedSkill: 'product-campaign-page',
reason: '模型路由判定',
agentBrief: '',
confidence: 0.9,
source: 'llm',
},
'苏州攻略页面',
{ grantedSkills: ['static-page-publish', 'product-campaign-page'] },
);
assert.equal(coerced.suggestedSkill, 'static-page-publish');
assert.match(coerced.reason, /static-page-publish/);
});
test('classifyWithRules routes themed article requests to agent orchestration', () => {
const result = classifyWithRules({
text: '帮我写个主题文章',
+21 -4
View File
@@ -17,8 +17,28 @@ const PAGE_GENERATION_INTENT_PATTERNS = [
/(?:生成|做|制作|创建|设计|写|出)(?:一个|一份|个)?(?:H5|h5|HTML|html|网页|页面|活动页|宣传页|落地页|分享页)/u,
/(?:帮我|给我).*(?:H5|h5|HTML|html|网页|页面|活动页|宣传页|落地页|分享页)/u,
/(?:publish|create|generate|make|build|design).*(?:html|web\s?page|landing\s?page|h5)/i,
// 隐式页面需求:主题/攻略 + 页面,无显式动词(如「苏州攻略页面」)
/(?:攻略|指南|游记|手册|简介|介绍|展示).{0,8}(?:页面|网页|H5|h5|html)/u,
/[^\s,。!?]{2,20}(?:攻略|指南).{0,4}(?:页面|网页)/u,
];
const PRODUCT_CAMPAIGN_INTENT_PATTERNS = [
/(?:商品|购买|下单|种草|带货|促销|上架|SKU|店铺|电商)/u,
/(?:https?:\/\/|taobao|tmall|jd\.com|商品链接|购买链接|购买按钮)/iu,
];
export function isPageGenerationIntent(text) {
const normalized = String(text ?? '').trim();
if (!normalized) return false;
return PAGE_GENERATION_INTENT_PATTERNS.some((pattern) => pattern.test(normalized));
}
export function isProductCampaignIntent(text) {
const normalized = String(text ?? '').trim();
if (!normalized) return false;
return PRODUCT_CAMPAIGN_INTENT_PATTERNS.some((pattern) => pattern.test(normalized));
}
export const CHAT_SKILL_DEFINITIONS = [
{
id: 'web-search',
@@ -149,10 +169,7 @@ export function filterChatSkills(options, ctx) {
export function buildAutoChatSkillPrefix(text, grantedSkills = []) {
const trimmed = String(text ?? '').trim();
if (!trimmed) return '';
if (
grantedSkills.includes(PUBLISH_SKILL_NAME) &&
PAGE_GENERATION_INTENT_PATTERNS.some((pattern) => pattern.test(trimmed))
) {
if (grantedSkills.includes(PUBLISH_SKILL_NAME) && isPageGenerationIntent(trimmed)) {
return buildChatSkillPrompt('generate-page', PUBLISH_SKILL_NAME);
}
if (!grantedSkills.includes('web')) return '';
+13
View File
@@ -5,6 +5,7 @@ import {
buildChatSkillPrompt,
CHAT_SKILL_DEFINITIONS,
filterChatSkills,
isPageGenerationIntent,
} from './chat-skills.mjs';
test('filterChatSkills shows summarize and analyze without granted skills', () => {
@@ -79,3 +80,15 @@ test('buildAutoChatSkillPrefix enables publish for page generation requests', ()
assert.match(prefix, /禁止只给本地路径/);
assert.equal(buildAutoChatSkillPrefix('帮我做一个 Hello 糖的 H5 页面', []), '');
});
test('isPageGenerationIntent matches implicit travel guide page requests', () => {
assert.equal(isPageGenerationIntent('苏州攻略页面'), true);
assert.equal(isPageGenerationIntent('帮我看看苏州攻略,1日攻略,做一个页面'), true);
assert.equal(isPageGenerationIntent('你好'), false);
});
test('buildAutoChatSkillPrefix enables publish for implicit page requests', () => {
const prefix = buildAutoChatSkillPrefix('苏州攻略页面', ['static-page-publish']);
assert.match(prefix, /static-page-publish/);
assert.match(prefix, /禁止询问是否还要发布/);
});
+5 -2
View File
@@ -2,6 +2,8 @@ export const MINDSPACE_PAGE_TAG_ATTR = 'data-mindspace-page-tag';
export const MINDSPACE_PAGE_TAG_PLATFORM_BRAND = 'platform-brand';
export const PLATFORM_BRAND_TEXT = 'TKMind · 智趣';
export const PLATFORM_BRAND_STYLE_ID = 'mindspace-platform-brand-style';
export const MINDSPACE_PAGE_FONT_STACK =
'-apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif';
const PLATFORM_BRAND_VISIBILITY_STYLE = `<style id="${PLATFORM_BRAND_STYLE_ID}">
[data-mindspace-page-tag="${MINDSPACE_PAGE_TAG_PLATFORM_BRAND}"] {
@@ -9,9 +11,10 @@ const PLATFORM_BRAND_VISIBILITY_STYLE = `<style id="${PLATFORM_BRAND_STYLE_ID}">
opacity: 1 !important;
visibility: visible !important;
color: #9aa3a0 !important;
font-family: ${MINDSPACE_PAGE_FONT_STACK} !important;
font-size: 12px !important;
font-weight: 600 !important;
letter-spacing: 0.08em !important;
font-weight: 500 !important;
letter-spacing: 0.02em !important;
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.35);
}
</style>`;
+1
View File
@@ -39,6 +39,7 @@ test('injectPlatformBrandVisibilityStyle adds readable brand css', () => {
const result = injectPlatformBrandVisibilityStyle(html);
assert.match(result, new RegExp(`id="${PLATFORM_BRAND_STYLE_ID}"`));
assert.match(result, /opacity:\s*1 !important/);
assert.match(result, /font-family:.*PingFang SC/);
});
test('ensurePlatformBrandFooter appends brand line when marker is missing', () => {
+1 -1
View File
@@ -185,7 +185,7 @@ function renderPreviewHtml(page) {
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; img-src data:; font-src 'none'; base-uri 'none'; form-action 'none'">
<title>${escapeHtml(page.title)}</title>
<style>
:root { color: #18211d; background: #f5f0e5; font-family: Georgia, "Times New Roman", serif; }
:root { color: #18211d; background: #f5f0e5; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif; }
* { box-sizing: border-box; }
body { margin: 0; padding: clamp(24px, 7vw, 88px); background: radial-gradient(circle at 90% 0, #d8e8df, transparent 34rem), #f5f0e5; }
article { width: min(820px, 100%); margin: 0 auto; padding: clamp(28px, 6vw, 72px); border: 1px solid #d7d0c1; border-radius: 28px; background: rgba(255,253,247,.94); box-shadow: 0 24px 70px rgba(45,53,47,.12); }
+8 -1
View File
@@ -2,6 +2,7 @@ import crypto from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import { preparePublishedPlatformBrand } from './mindspace-page-tag.mjs';
import { injectPublicImageRetryScript } from './mindspace-public-image-retry.mjs';
const INLINE_SCRIPT_PATTERN = /<script\b(?![^>]*\bsrc\b)[^>]*>([\s\S]*?)<\/script>/gi;
@@ -48,6 +49,7 @@ export async function handleMindSpaceLongImageDownload({
export function decorateMindSpacePublishedHtml({
html,
embed = false,
isOwner = true,
context,
htmlFilePath = '',
fileExists = fs.existsSync,
@@ -82,9 +84,14 @@ export function decorateMindSpacePublishedHtml({
nextHtml = injectWechatShareBridge(nextHtml, { pageUrl: context.pageUrl });
}
const shareInjection = !embed
? injectPublicFileShareButton(nextHtml)
? injectPublicFileShareButton(nextHtml, { isOwner })
: { html: nextHtml, scriptHashes: [] };
nextHtml = shareInjection.html;
// Retries transient asset-download failures client-side before a page's own onerror
// permanently swaps the <img> for a placeholder. See mindspace-public-image-retry.mjs.
if (!embed) {
nextHtml = injectPublicImageRetryScript(nextHtml).html;
}
const scriptHashes = collectInlineScriptHashes(nextHtml);
return {
html: nextHtml,
+31 -5
View File
@@ -97,6 +97,7 @@ test('handleMindSpaceLongImageDownload falls back to local html path without pag
test('decorateMindSpacePublishedHtml returns decorated html and csp', () => {
const pageScript = "document.body.dataset.ready='1'";
const shareScript = "document.body.dataset.share='1'";
let sharedIsOwner;
const result = decorateMindSpacePublishedHtml({
html: `<html><body><script>${pageScript}</script>demo</body></html>`,
embed: false,
@@ -110,10 +111,13 @@ test('decorateMindSpacePublishedHtml returns decorated html and csp', () => {
preparePublicationHtmlForEmbed: (value) => value,
injectOgTags: (value, meta) => `${value}<!--og:${meta.pageUrl}-->`,
injectWechatShareBridge: (value, meta) => `${value}<!--wechat:${meta.pageUrl}-->`,
injectPublicFileShareButton: (value) => ({
html: value.replace('</body>', `<script>${shareScript}</script></body>`),
scriptHashes: ['ignored'],
}),
injectPublicFileShareButton: (value, options) => {
sharedIsOwner = options?.isOwner;
return {
html: value.replace('</body>', `<script>${shareScript}</script></body>`),
scriptHashes: ['ignored'],
};
},
publishedPageCsp: (_value, options) => JSON.stringify(options),
isWechatUserAgent: () => true,
});
@@ -121,12 +125,34 @@ test('decorateMindSpacePublishedHtml returns decorated html and csp', () => {
assert.match(result.html, /og:/);
assert.match(result.html, /wechat:/);
assert.match(result.html, /dataset\.share/);
assert.match(result.html, /data-mindspace-image-retry="1"/);
assert.equal(result.allowEmbedFrame, false);
assert.equal(sharedIsOwner, true);
const options = JSON.parse(result.csp);
assert.equal(options.embed, false);
assert.equal(options.wechatShare, true);
assert.deepEqual(options.scriptHashes, collectInlineScriptHashes(result.html));
assert.equal(options.scriptHashes.length, 2);
assert.equal(options.scriptHashes.length, 3);
});
test('decorateMindSpacePublishedHtml forwards isOwner=false to the share button injector', () => {
let sharedIsOwner;
decorateMindSpacePublishedHtml({
html: '<html><body>demo</body></html>',
embed: false,
isOwner: false,
context: { origin: '', pageUrl: '', pageDirUrl: '', fallbackImageUrl: '' },
preparePublicationHtmlForEmbed: (value) => value,
injectOgTags: (value) => value,
injectWechatShareBridge: (value) => value,
injectPublicFileShareButton: (value, options) => {
sharedIsOwner = options?.isOwner;
return { html: value, scriptHashes: [] };
},
publishedPageCsp: (_value, options) => JSON.stringify(options),
isWechatUserAgent: () => false,
});
assert.equal(sharedIsOwner, false);
});
test('decorateMindSpacePublishedHtml supports embed mode without share injection', () => {
+67
View File
@@ -0,0 +1,67 @@
import crypto from 'node:crypto';
/**
* Public MindSpace pages (workspace `/MindSpace/<userId>/public/*.html`) reference images via
* `/api/mindspace/v1/assets/:id/download`, which is authorized per-request (owner cookie, signed
* `public_token`, or `/public/` referrer). Right after an agent generates a page, the very first
* image request can occasionally race asset materialization and return a transient 401/404/5xx.
*
* Generated pages usually ship a page-specific `onerror` fallback that permanently swaps the
* `<img>` for a static placeholder — so a single transient failure becomes a permanent "broken
* image" until the visitor manually reloads. This script intercepts image load failures for
* MindSpace asset URLs (any page, any author) and retries a few times with backoff BEFORE the
* page's own `onerror` gets a chance to run; only once retries are exhausted does it let the
* original fallback take over.
*/
const IMAGE_RETRY_SCRIPT = `(function(){
var MAX_RETRIES=3;
var BASE_DELAY_MS=900;
var ASSET_PATTERN=/\\/api\\/mindspace\\/v1\\/assets\\/[^/]+\\/download/;
var retries=new WeakMap();
document.addEventListener('error',function(event){
var img=event.target;
if(!img||img.tagName!=='IMG')return;
var src=img.getAttribute('src')||'';
if(!ASSET_PATTERN.test(src))return;
var count=retries.get(img)||0;
if(count>=MAX_RETRIES)return;
event.stopImmediatePropagation();
retries.set(img,count+1);
setTimeout(function(){
try{
var url=new URL(src,location.href);
url.searchParams.set('_retry',String(count+1));
img.src=url.toString();
}catch(e){
img.src=src;
}
},BASE_DELAY_MS*(count+1));
},true);
})();`;
const IMAGE_RETRY_SCRIPT_HASH = crypto.createHash('sha256').update(IMAGE_RETRY_SCRIPT).digest('base64');
const MARKER_ATTR = 'data-mindspace-image-retry';
export function injectPublicImageRetryScript(html) {
const source = String(html ?? '');
if (!source || source.includes(MARKER_ATTR)) {
return { html: source, scriptHashes: [] };
}
const markup = `<script ${MARKER_ATTR}="1">${IMAGE_RETRY_SCRIPT}</script>`;
if (/<\/body>/i.test(source)) {
return {
html: source.replace(/<\/body>/i, `${markup}</body>`),
scriptHashes: [IMAGE_RETRY_SCRIPT_HASH],
};
}
return {
html: `${source}${markup}`,
scriptHashes: [IMAGE_RETRY_SCRIPT_HASH],
};
}
export const publicImageRetryInternals = {
IMAGE_RETRY_SCRIPT_HASH,
MARKER_ATTR,
};
+37
View File
@@ -0,0 +1,37 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
injectPublicImageRetryScript,
publicImageRetryInternals,
} from './mindspace-public-image-retry.mjs';
test('injectPublicImageRetryScript injects retry script before </body>', () => {
const result = injectPublicImageRetryScript('<!doctype html><html><body><h1>Hi</h1></body></html>');
assert.match(result.html, /data-mindspace-image-retry="1"/);
assert.match(result.html, /addEventListener\('error'/);
assert.match(result.html, /mindspace\\\/v1\\\/assets/);
assert.match(result.html, /stopImmediatePropagation/);
assert.equal(result.html.indexOf('data-mindspace-image-retry'), result.html.lastIndexOf('data-mindspace-image-retry'));
assert.match(result.html, /<script data-mindspace-image-retry="1">[\s\S]*<\/script><\/body>/);
assert.equal(result.scriptHashes.length, 1);
assert.equal(result.scriptHashes[0], publicImageRetryInternals.IMAGE_RETRY_SCRIPT_HASH);
});
test('injectPublicImageRetryScript appends when no closing body tag exists', () => {
const result = injectPublicImageRetryScript('<div>fragment</div>');
assert.match(result.html, /^<div>fragment<\/div><script data-mindspace-image-retry="1">/);
assert.equal(result.scriptHashes.length, 1);
});
test('injectPublicImageRetryScript is idempotent (skips if already injected)', () => {
const first = injectPublicImageRetryScript('<html><body>x</body></html>');
const second = injectPublicImageRetryScript(first.html);
assert.equal(second.html, first.html);
assert.equal(second.scriptHashes.length, 0);
});
test('injectPublicImageRetryScript handles empty input safely', () => {
const result = injectPublicImageRetryScript('');
assert.equal(result.html, '');
assert.equal(result.scriptHashes.length, 0);
});
+41 -32
View File
@@ -109,39 +109,17 @@ const PUBLIC_FILE_SHARE_SCRIPT_HASH = crypto
.update(PUBLIC_FILE_SHARE_SCRIPT)
.digest('base64');
export function injectPublicFileShareButton(html) {
export function injectPublicFileShareButton(html, { isOwner = true } = {}) {
const source = String(html ?? '');
if (!source || source.includes('data-mindspace-public-share')) {
return { html: source, scriptHashes: [] };
}
const markup = `
<style id="mindspace-public-share-style">
[data-mindspace-public-share]{position:fixed;right:18px;bottom:calc(18px + env(safe-area-inset-bottom,0px));z-index:2147483000;display:flex;flex-direction:column;align-items:flex-end;gap:6px;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}
[data-mindspace-public-share] div[data-mindspace-public-share-actions]{display:flex;gap:8px;justify-content:flex-end;flex-wrap:wrap}
[data-mindspace-public-share] button{border:0;border-radius:999px;padding:10px 15px;background:#2f6f57;color:#fff;font-size:14px;font-weight:700;box-shadow:0 8px 24px rgba(0,0,0,.18);cursor:pointer}
[data-mindspace-public-share] button[data-action="capture"]{background:#8f6b2f}
[data-mindspace-public-share] button[data-action="plaza"]{background:#5a4a7b}
[data-mindspace-public-share] button:active{transform:translateY(1px)}
[data-mindspace-public-share] button:disabled{opacity:.65;cursor:wait}
[data-mindspace-public-share] small{min-height:18px;max-width:220px;border-radius:999px;padding:4px 9px;background:rgba(255,255,255,.92);color:#245845;font-size:12px;text-align:right;box-shadow:0 4px 14px rgba(0,0,0,.1)}
[data-mindspace-public-share] small:empty{display:none}
[data-mindspace-public-share] small.is-error{color:#8b2d20}
[data-mindspace-public-share-dialog]{position:fixed;inset:0;z-index:2147483646;display:grid;place-items:center;padding:20px;background:rgba(7,12,10,.72);backdrop-filter:blur(8px)}
[data-mindspace-public-share-dialog][hidden]{display:none!important}
[data-mindspace-public-share-dialog-panel]{width:min(360px,100%);padding:24px;border-radius:22px;background:rgba(18,24,32,.96) !important;border:1px solid rgba(255,255,255,.12);box-shadow:0 24px 80px rgba(0,0,0,.38);text-align:center;color:#fff !important;color-scheme:dark;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}
[data-mindspace-public-share-dialog-panel] h3{margin:0 0 10px;font-size:18px;font-weight:600;color:#fff !important}
[data-mindspace-public-share-dialog-panel] p{margin:0 0 16px;font-size:14px;line-height:1.5;color:rgba(255,255,255,.86) !important}
[data-mindspace-public-share-dialog-panel] [data-plaza-view][hidden]{display:none!important}
[data-mindspace-public-share-dialog-actions]{display:flex;justify-content:center;gap:10px;flex-wrap:wrap}
[data-mindspace-public-share-dialog-actions] button{border-radius:999px;padding:8px 18px;font:inherit;font-size:14px;cursor:pointer;box-shadow:none}
[data-mindspace-public-share-dialog-actions] [data-action="plaza-cancel"],[data-mindspace-public-share-dialog-actions] [data-action="plaza-already-close"],[data-mindspace-public-share-dialog-actions] [data-action="plaza-success-close"],[data-mindspace-public-share-dialog-actions] [data-action="plaza-error-close"]{border:1px solid rgba(255,255,255,.18) !important;background:rgba(255,255,255,.08) !important;color:#fff !important}
[data-mindspace-public-share-dialog-actions] [data-action="plaza-confirm"]{border:1px solid rgba(47,111,87,.35) !important;background:#2f6f57 !important;color:#fff !important;font-weight:600}
[data-mindspace-public-share-dialog-link]{display:inline-block;margin:0 0 14px;font-size:14px;color:#8fd4b4 !important;text-decoration:underline}
[data-mindspace-public-share-dialog-link][hidden]{display:none!important}
@media(max-width:640px){[data-mindspace-public-share]{right:12px;bottom:calc(12px + env(safe-area-inset-bottom,0px))}[data-mindspace-public-share] button{padding:9px 13px;font-size:13px}}
</style>
<div data-mindspace-public-share>
<div data-mindspace-public-share-dialog hidden>
// REGRESSION GUARD: mindspace-public-owner-vs-visitor — the "发布 Plaza" entry mutates content
// ownership (publishes the author's page under the visitor's own Plaza account), so it must only
// render for the page owner. Anyone else opening this URL (share link, forwarded chat link) is a
// visitor and must NOT see it. Everything else (长图 / 分享) stays available to all viewers.
const plazaDialogMarkup = isOwner
? ` <div data-mindspace-public-share-dialog hidden>
<div data-mindspace-public-share-dialog-panel role="dialog" aria-modal="true" aria-labelledby="mindspace-public-plaza-title">
<div data-plaza-view="confirm">
<h3 id="mindspace-public-plaza-title">发布到 Plaza</h3>
@@ -180,10 +158,41 @@ export function injectPublicFileShareButton(html) {
</div>
</div>
</div>
<small aria-live="polite"></small>
`
: '';
const plazaButtonMarkup = isOwner
? ' <button type="button" data-action="plaza">发布 Plaza</button>\n'
: '';
const markup = `
<style id="mindspace-public-share-style">
[data-mindspace-public-share]{position:fixed;right:18px;bottom:calc(18px + env(safe-area-inset-bottom,0px));z-index:2147483000;display:flex;flex-direction:column;align-items:flex-end;gap:6px;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}
[data-mindspace-public-share] div[data-mindspace-public-share-actions]{display:flex;gap:8px;justify-content:flex-end;flex-wrap:wrap}
[data-mindspace-public-share] button{border:0;border-radius:999px;padding:10px 15px;background:#2f6f57;color:#fff;font-size:14px;font-weight:700;box-shadow:0 8px 24px rgba(0,0,0,.18);cursor:pointer}
[data-mindspace-public-share] button[data-action="capture"]{background:#8f6b2f}
[data-mindspace-public-share] button[data-action="plaza"]{background:#5a4a7b}
[data-mindspace-public-share] button:active{transform:translateY(1px)}
[data-mindspace-public-share] button:disabled{opacity:.65;cursor:wait}
[data-mindspace-public-share] small{min-height:18px;max-width:220px;border-radius:999px;padding:4px 9px;background:rgba(255,255,255,.92);color:#245845;font-size:12px;text-align:right;box-shadow:0 4px 14px rgba(0,0,0,.1)}
[data-mindspace-public-share] small:empty{display:none}
[data-mindspace-public-share] small.is-error{color:#8b2d20}
[data-mindspace-public-share-dialog]{position:fixed;inset:0;z-index:2147483646;display:grid;place-items:center;padding:20px;background:rgba(7,12,10,.72);backdrop-filter:blur(8px)}
[data-mindspace-public-share-dialog][hidden]{display:none!important}
[data-mindspace-public-share-dialog-panel]{width:min(360px,100%);padding:24px;border-radius:22px;background:rgba(18,24,32,.96) !important;border:1px solid rgba(255,255,255,.12);box-shadow:0 24px 80px rgba(0,0,0,.38);text-align:center;color:#fff !important;color-scheme:dark;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}
[data-mindspace-public-share-dialog-panel] h3{margin:0 0 10px;font-size:18px;font-weight:600;color:#fff !important}
[data-mindspace-public-share-dialog-panel] p{margin:0 0 16px;font-size:14px;line-height:1.5;color:rgba(255,255,255,.86) !important}
[data-mindspace-public-share-dialog-panel] [data-plaza-view][hidden]{display:none!important}
[data-mindspace-public-share-dialog-actions]{display:flex;justify-content:center;gap:10px;flex-wrap:wrap}
[data-mindspace-public-share-dialog-actions] button{border-radius:999px;padding:8px 18px;font:inherit;font-size:14px;cursor:pointer;box-shadow:none}
[data-mindspace-public-share-dialog-actions] [data-action="plaza-cancel"],[data-mindspace-public-share-dialog-actions] [data-action="plaza-already-close"],[data-mindspace-public-share-dialog-actions] [data-action="plaza-success-close"],[data-mindspace-public-share-dialog-actions] [data-action="plaza-error-close"]{border:1px solid rgba(255,255,255,.18) !important;background:rgba(255,255,255,.08) !important;color:#fff !important}
[data-mindspace-public-share-dialog-actions] [data-action="plaza-confirm"]{border:1px solid rgba(47,111,87,.35) !important;background:#2f6f57 !important;color:#fff !important;font-weight:600}
[data-mindspace-public-share-dialog-link]{display:inline-block;margin:0 0 14px;font-size:14px;color:#8fd4b4 !important;text-decoration:underline}
[data-mindspace-public-share-dialog-link][hidden]{display:none!important}
@media(max-width:640px){[data-mindspace-public-share]{right:12px;bottom:calc(12px + env(safe-area-inset-bottom,0px))}[data-mindspace-public-share] button{padding:9px 13px;font-size:13px}}
</style>
<div data-mindspace-public-share>
${plazaDialogMarkup} <small aria-live="polite"></small>
<div data-mindspace-public-share-actions>
<button type="button" data-action="plaza">发布 Plaza</button>
<button type="button" data-action="capture">保存长图</button>
${plazaButtonMarkup} <button type="button" data-action="capture">保存长图</button>
<button type="button" data-action="share">公开分享</button>
</div>
</div>
+26
View File
@@ -18,3 +18,29 @@ test('injectPublicFileShareButton adds plaza entry and confirm dialog', () => {
assert.match(result.html, /ALREADY_PUBLISHED/);
assert.equal(result.scriptHashes.length, 1);
});
test('injectPublicFileShareButton hides Plaza entry and dialog for non-owner viewers', () => {
const result = injectPublicFileShareButton(
'<!doctype html><html><body><h1>Hi</h1></body></html>',
{ isOwner: false },
);
assert.doesNotMatch(result.html, /<button type="button" data-action="plaza">/);
assert.doesNotMatch(result.html, /发布到 Plaza/);
assert.doesNotMatch(result.html, /<button type="button" data-action="plaza-confirm">/);
assert.doesNotMatch(result.html, /<div data-mindspace-public-share-dialog/);
assert.doesNotMatch(result.html, /data-mindspace-public-share-dialog-panel role="dialog"/);
// Non-Plaza actions must remain available to visitors.
assert.match(result.html, /data-action="capture"/);
assert.match(result.html, /data-action="share"/);
assert.match(result.html, /公开分享/);
assert.equal(result.scriptHashes.length, 1);
});
test('injectPublicFileShareButton keeps Plaza entry when isOwner explicitly true', () => {
const result = injectPublicFileShareButton(
'<!doctype html><html><body><h1>Hi</h1></body></html>',
{ isOwner: true },
);
assert.match(result.html, /data-action="plaza"/);
assert.match(result.html, /data-mindspace-public-share-dialog/);
});
+2
View File
@@ -53,6 +53,8 @@
"smoke:memory-v2-qdrant": "node scripts/smoke-memory-v2-qdrant.mjs",
"smoke:memory-v2-external": "node scripts/smoke-memory-v2-external.mjs",
"mock:memory-v2-services": "node scripts/mock-memory-v2-services.mjs",
"test:memind": "node scripts/run-memind-tests.mjs",
"test:scenario": "node scripts/run-scenario-test.mjs",
"test": "node --test auth.test.mjs asr-proxy.test.mjs billing.test.mjs billing-recharge.test.mjs wechat-pay.test.mjs wechat-oauth.test.mjs wechat-mp.test.mjs schedule-intent.test.mjs schedule-reminder-worker.test.mjs capabilities.test.mjs policies.test.mjs chat-skills.test.mjs chat-intent-router.test.mjs chat-finish-sync.test.mjs chat-agent-run-gate.test.mjs conversation-display.test.mjs user-publish.test.mjs user-memory-profile.test.mjs skills-registry.test.mjs agent-run-gateway.test.mjs agent-run-routes.test.mjs session-broker.test.mjs sse-event-taxonomy.test.mjs goosed-proxy-boundary.test.mjs agent-run-stream.test.mjs mindspace-h5-html-finish-guard.test.mjs admin-routes.test.mjs direct-chat-service.test.mjs tool-gateway.test.mjs mindspace.test.mjs mindspace-scan.test.mjs mindspace-assets.test.mjs mindspace-local-runtime-services.test.mjs mindspace-local-server-adapter.test.mjs mindspace-public-asset-token.test.mjs mindspace-remote-server-adapter.test.mjs mindspace-server-adapter.test.mjs mindspace-pages.test.mjs mindspace-page-sync-service.test.mjs public-site-bases.test.mjs mindspace-html-download-links.test.mjs mindspace-page-purge.test.mjs mindspace-public-delivery.test.mjs mindspace-public-page-context.test.mjs mindspace-public-route.test.mjs mindspace-publications.test.mjs mindspace-public-links.test.mjs mindspace-chat-save.test.mjs mindspace-chat-docx-package.test.mjs mindspace-public-finish-sync.test.mjs mindspace-chat-context.test.mjs mindspace-canonical-url.test.mjs mindspace-conversation-package.test.mjs mindspace-conversation-package-backfill.test.mjs mindspace-conversation-package-public-html.test.mjs mindspace-conversation-package-verify.test.mjs mindspace-conversation-package-registry.test.mjs mindspace-conversation-package-routes.test.mjs mindspace-conversation-package-store.test.mjs mindspace-conversation-schema.test.mjs mindspace-runtime-config.test.mjs mindspace-service.test.mjs mindspace-storage-adapter.test.mjs mindspace-content-scan.test.mjs mindspace-html-localize.test.mjs mindspace-visual-editor.test.mjs mindspace-cleanup.test.mjs mindspace-thumbnails.test.mjs mindspace-workspace-thumbnails.test.mjs mindspace-workspace-sync.test.mjs mindspace-asset-preview.test.mjs mindspace-agent-jobs.test.mjs mindspace-agent-runner.test.mjs mindspace-sandbox-mcp.test.mjs message-stream.test.mjs mindspace-service/mindspace-rpc-server.test.mjs plaza-posts.test.mjs plaza-interactions.test.mjs plaza-algorithm.test.mjs plaza-seo.test.mjs plaza-ops.test.mjs user-auth.test.mjs llm-providers.test.mjs admin-guard.test.mjs user-feedback.test.mjs memory-v2.test.mjs memory-v2-admin-config.test.mjs memory-v2-adapter-scaffold.test.mjs memory-v2-backend-contract.test.mjs memory-v2-health.test.mjs memory-v2-runtime.test.mjs memory-v2-plugin-backends.test.mjs memory-v2-pgvector.test.mjs memory-v2-pgvector-schema.test.mjs memory-v2-pgvector-backfill.test.mjs memory-v2-pgvector-smoke.test.mjs memory-v2-qdrant.test.mjs memory-v2-weaviate.test.mjs memory-v2-mem0.test.mjs memory-v2-letta.test.mjs memory-v2-external-adapters.test.mjs scripts/embed-memory-v2-local-hash.test.mjs scripts/check-memory-v2-app-canary.test.mjs scripts/check-memory-v2-config-gaps.test.mjs scripts/check-memory-v2-contracts.test.mjs scripts/check-memory-v2-health.test.mjs scripts/check-memory-v2-session-flow.test.mjs scripts/check-memory-v2-stack.test.mjs scripts/setup-memory-v2-pgvector-schema.test.mjs scripts/backfill-memory-v2-pgvector.test.mjs scripts/scaffold-memory-v2-backend.test.mjs scripts/smoke-memory-v2-pgvector.test.mjs scripts/smoke-memory-v2-qdrant.test.mjs scripts/smoke-memory-v2-external.test.mjs scripts/mock-memory-v2-services.test.mjs",
"test:mindspace-service": "node --test mindspace-service/mindspace-rpc-server.test.mjs",
"verify:chat-finish-sync": "node scripts/verify-chat-finish-sync.mjs",
@@ -6,7 +6,7 @@
<title>越南旅游指南</title>
<style>
:root { --accent: #2f6f57; --bg: #f4f0e7; }
body { margin: 0; font-family: Georgia, serif; background: var(--bg); color: #18211d; line-height: 1.7; }
body { margin: 0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif; background: var(--bg); color: #18211d; line-height: 1.7; }
main { max-width: 720px; margin: 0 auto; padding: 48px 24px 72px; }
h1 { font-size: 2.2rem; margin-bottom: 0.5rem; color: var(--accent); }
.lead { color: #5f6963; margin-bottom: 2rem; }
@@ -8,7 +8,7 @@
<title>越南旅游指南</title>
<style>
:root { --accent: #ff6b35; --accent2: #24243e; --bg: #fff8f2; }
body { margin: 0; font-family: Georgia, serif; background: linear-gradient(160deg, var(--bg), #ffe8d6); color: #18211d; line-height: 1.7; }
body { margin: 0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif; background: linear-gradient(160deg, var(--bg), #ffe8d6); color: #18211d; line-height: 1.7; }
.hero { position: relative; height: 280px; overflow: hidden; }
.hero img { width: 100%; height: 100%; object-fit: cover; display: block; }
.hero::after { content: ''; position: absolute; inset: 0; background: linear-gradient(to top, rgba(36,36,62,0.55), transparent 60%); }
+40
View File
@@ -0,0 +1,40 @@
{
"id": "your-scenario-id",
"name": "场景名称",
"description": "一句话说明这个场景测什么",
"account": {
"username": "john",
"password": "888888"
},
"steps": [
{
"action": "login",
"label": "登录"
},
{
"action": "chat",
"label": "第一步对话",
"message": "hi",
"expect": {
"assistantMinChars": 1,
"timeoutMs": 120000
}
},
{
"action": "chat",
"label": "第二步对话(可带页面断言)",
"message": "你的测试消息",
"expect": {
"assistantMinChars": 20,
"timeoutMs": 300000,
"replyKeywords": ["关键词"],
"page": {
"keywords": ["页面里应出现的词"],
"requirePublicLink": true,
"requireHttp200": true,
"requireMindspaceCover": false
}
}
}
]
}
+39
View File
@@ -0,0 +1,39 @@
{
"id": "suzhou-page",
"name": "苏州攻略一日游页面",
"description": "登录 john → 打招呼 → 请求生成苏州 1 日攻略页面 → 验证回复与公网可访问",
"account": {
"username": "john",
"password": "888888"
},
"steps": [
{
"action": "login",
"label": "登录 john 账户"
},
{
"action": "chat",
"label": "发送 hi 并等待回复",
"message": "hi",
"expect": {
"assistantMinChars": 1,
"timeoutMs": 120000
}
},
{
"action": "chat",
"label": "请求苏州攻略页面并验证",
"message": "帮我看看苏州攻略,1日攻略,做一个页面",
"expect": {
"assistantMinChars": 20,
"timeoutMs": 300000,
"replyKeywords": ["苏州"],
"page": {
"keywords": ["苏州"],
"requirePublicLink": true,
"requireHttp200": true
}
}
}
]
}
+356
View File
@@ -0,0 +1,356 @@
#!/usr/bin/env node
/**
* Memind scoped test runner.
* Usage: node scripts/run-memind-tests.mjs [--mode quick|changed|guards|release|full] [--base REF]
*/
import { spawnSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
const root = path.resolve(new URL('..', import.meta.url).pathname);
const MODES = new Set(['quick', 'changed', 'guards', 'release', 'full']);
const QUICK_TESTS = [
'capabilities.test.mjs',
'llm-providers.test.mjs',
'user-auth.test.mjs',
'wechat-mp.test.mjs',
];
const GUARD_SCRIPTS = [
'verify:mindspace-publish-guards',
'verify:mindspace-page-sync-guards',
];
const RELEASE_TESTS = [
...QUICK_TESTS,
'wechat-oauth.test.mjs',
'wechat-pay.test.mjs',
'mindspace-public-finish-sync.test.mjs',
'chat-finish-sync.test.mjs',
'conversation-display.test.mjs',
'mindspace-pages.test.mjs',
'mindspace-page-sync-service.test.mjs',
];
const RELEASE_CHECKS = [
'check:mindspace-public-links',
'verify:mindspace-publish-guards:full',
];
const SCOPE_RULES = [
{
id: 'publish-chat-finish',
patterns: [
/mindspace-public-finish/i,
/chat-finish-sync/i,
/conversation-display/i,
/useTKMindChat/i,
/message\.ts$/,
/mindspace-h5-html-finish/i,
],
tests: [
'mindspace-public-finish-sync.test.mjs',
'mindspace-h5-html-finish-guard.test.mjs',
'chat-finish-sync.test.mjs',
'conversation-display.test.mjs',
],
verify: ['verify:mindspace-publish-guards'],
},
{
id: 'page-sync-thumbnail',
patterns: [
/mindspace-page-sync/i,
/mindspace-pages/i,
/public-site-bases/i,
/publicUrl\.ts$/,
/mindspace-thumbnails/i,
/mindspace-workspace-thumbnails/i,
],
tests: [
'mindspace-pages.test.mjs',
'mindspace-page-sync-service.test.mjs',
'public-site-bases.test.mjs',
'mindspace-thumbnails.test.mjs',
'mindspace-workspace-thumbnails.test.mjs',
],
verify: ['verify:mindspace-page-sync-guards'],
},
{
id: 'wechat',
patterns: [/wechat/i],
tests: [
'wechat-mp.test.mjs',
'wechat-oauth.test.mjs',
'wechat-pay.test.mjs',
'user-auth.test.mjs',
],
verify: ['verify:wechat-channel-isolation'],
},
{
id: 'billing',
patterns: [/billing/i, /sse-billing/i],
tests: [
'billing.test.mjs',
'billing-recharge.test.mjs',
'billing-subscription.test.mjs',
'sse-billing.test.mjs',
],
},
{
id: 'agent-run',
patterns: [/agent-run/i, /tool-gateway/i, /goosed-proxy/i],
tests: [
'agent-run-gateway.test.mjs',
'agent-run-routes.test.mjs',
'agent-run-stream.test.mjs',
'tool-gateway.test.mjs',
'goosed-proxy-boundary.test.mjs',
'chat-agent-run-gate.test.mjs',
],
verify: ['verify:goosed-proxy-boundary'],
},
{
id: 'memory-v2',
patterns: [/memory-v2/i, /scripts\/check-memory-v2/i, /scripts\/smoke-memory-v2/i],
tests: [
'memory-v2.test.mjs',
'memory-v2-runtime.test.mjs',
'memory-v2-health.test.mjs',
'memory-v2-backend-contract.test.mjs',
],
},
{
id: 'plaza',
patterns: [/plaza/i],
tests: [
'plaza-posts.test.mjs',
'plaza-interactions.test.mjs',
'plaza-algorithm.test.mjs',
'plaza-seo.test.mjs',
'plaza-ops.test.mjs',
],
},
{
id: 'mindspace-core',
patterns: [/mindspace/i, /server\.mjs$/, /src\/hooks\/useTKMind/i],
tests: [
'mindspace.test.mjs',
'mindspace-public-route.test.mjs',
'mindspace-public-links.test.mjs',
'mindspace-runtime-config.test.mjs',
],
},
];
function usage() {
console.log(`Usage:
node scripts/run-memind-tests.mjs [--mode quick|changed|guards|release|full] [--base REF]
Modes:
changed Run tests matched to git diff (default)
quick Fast smoke for routine checks
guards Regression guard scripts only
release Pre-portal-release checklist
full Entire npm test suite`);
}
function parseArgs(argv) {
let mode = 'changed';
let base = 'HEAD';
for (let i = 2; i < argv.length; i += 1) {
const arg = argv[i];
if (arg === '--mode') {
mode = argv[++i] ?? '';
} else if (arg === '--base') {
base = argv[++i] ?? 'HEAD';
} else if (arg === '-h' || arg === '--help') {
usage();
process.exit(0);
} else {
throw new Error(`Unknown argument: ${arg}`);
}
}
if (!MODES.has(mode)) {
throw new Error(`Invalid mode: ${mode}`);
}
return { mode, base };
}
function runStep(label, fn) {
console.log(`\n==> ${label}`);
const ok = fn();
if (!ok) {
throw new Error(`${label} failed`);
}
}
function runNodeTest(files) {
const existing = files.filter((file) => fs.existsSync(path.join(root, file)));
if (existing.length === 0) {
console.log('(skip: no matching test files on disk)');
return true;
}
const result = spawnSync(process.execPath, ['--test', ...existing], {
cwd: root,
stdio: 'inherit',
});
return result.status === 0;
}
function runNpmScript(script) {
const result = spawnSync('npm', ['run', script], {
cwd: root,
stdio: 'inherit',
shell: process.platform === 'win32',
});
return result.status === 0;
}
function gitChangedFiles(base) {
const result = spawnSync('git', ['diff', '--name-only', `${base}...HEAD`], {
cwd: root,
encoding: 'utf8',
});
if (result.status !== 0) {
const fallback = spawnSync('git', ['diff', '--name-only', base], {
cwd: root,
encoding: 'utf8',
});
if (fallback.status !== 0) {
return [];
}
return fallback.stdout
.split('\n')
.map((line) => line.trim())
.filter(Boolean);
}
return result.stdout
.split('\n')
.map((line) => line.trim())
.filter(Boolean);
}
function gitStagedAndUnstaged() {
const result = spawnSync('git', ['status', '--porcelain'], {
cwd: root,
encoding: 'utf8',
});
if (result.status !== 0) {
return [];
}
return result.stdout
.split('\n')
.map((line) => line.trim())
.filter(Boolean)
.map((line) => line.replace(/^. [^ ]+ -> /, '').replace(/^.. /, ''));
}
function directTestForSource(file) {
const base = path.basename(file).replace(/\.(mjs|ts|tsx|js)$/, '');
const candidates = [
`${base}.test.mjs`,
path.join('scripts', `${base}.test.mjs`),
path.join('mindspace-service', `${base}.test.mjs`),
];
return candidates.filter((candidate) => fs.existsSync(path.join(root, candidate)));
}
function resolveChangedScope(files) {
const tests = new Set(QUICK_TESTS);
const verify = new Set();
for (const file of files) {
for (const test of directTestForSource(file)) {
tests.add(test);
}
for (const rule of SCOPE_RULES) {
if (rule.patterns.some((pattern) => pattern.test(file))) {
for (const test of rule.tests) {
tests.add(test);
}
for (const script of rule.verify ?? []) {
verify.add(script);
}
}
}
}
return {
files,
tests: [...tests],
verify: [...verify],
};
}
function summarize(scope) {
console.log('\n--- planned scope ---');
if (scope.files?.length) {
console.log(`changed files (${scope.files.length}):`);
for (const file of scope.files) {
console.log(` - ${file}`);
}
}
console.log(`tests (${scope.tests.length}):`);
for (const test of scope.tests) {
console.log(` - ${test}`);
}
if (scope.verify.length) {
console.log(`verify scripts (${scope.verify.length}):`);
for (const script of scope.verify) {
console.log(` - npm run ${script}`);
}
}
}
function main() {
const { mode, base } = parseArgs(process.argv);
let scope;
switch (mode) {
case 'quick':
scope = { tests: QUICK_TESTS, verify: [] };
break;
case 'guards':
scope = { tests: [], verify: GUARD_SCRIPTS };
break;
case 'release':
scope = { tests: RELEASE_TESTS, verify: RELEASE_CHECKS };
break;
case 'full':
runStep('full test suite (npm test)', () => runNpmScript('test'));
console.log('\nmemind tests ok (full)');
return;
case 'changed':
default: {
const changed = [...new Set([...gitChangedFiles(base), ...gitStagedAndUnstaged()])];
if (changed.length === 0) {
console.log('No changed files detected; falling back to quick smoke.');
scope = { tests: QUICK_TESTS, verify: [], files: [] };
} else {
scope = resolveChangedScope(changed);
}
break;
}
}
summarize(scope);
for (const test of scope.tests) {
runStep(`node --test ${test}`, () => runNodeTest([test]));
}
for (const script of scope.verify) {
runStep(`npm run ${script}`, () => runNpmScript(script));
}
console.log(`\nmemind tests ok (${mode})`);
}
try {
main();
} catch (error) {
console.error(`\n${error.message}`);
process.exit(1);
}
+206
View File
@@ -0,0 +1,206 @@
#!/usr/bin/env node
/**
* Memind 场景模拟测试入口。
*
* Usage:
* node scripts/run-scenario-test.mjs --scenario suzhou-page
* node scripts/run-scenario-test.mjs --list
* JOHN_PASSWORD=888888 node scripts/run-scenario-test.mjs
*/
import { loadH5Environment } from './load-env.mjs';
import {
createAgentRun,
createReporter,
listScenarios,
loadScenario,
loginViaApi,
resolvePortalBase,
snapshotPublicHtml,
verifyPageAccess,
waitForAssistantGrowth,
waitForRunTerminal,
extractAssistantTexts,
getSession,
} from './scenario-test-lib.mjs';
loadH5Environment(import.meta.dirname);
function usage() {
console.log(`Usage:
node scripts/run-scenario-test.mjs [--scenario <id>] [--list] [--port <port>]
Examples:
node scripts/run-scenario-test.mjs --scenario suzhou-page
node scripts/run-scenario-test.mjs --list`);
}
function parseArgs(argv) {
let scenarioId = 'suzhou-page';
let listOnly = false;
let port = Number(process.env.H5_PORT ?? 8081);
for (let i = 2; i < argv.length; i += 1) {
const arg = argv[i];
if (arg === '--scenario' && argv[i + 1]) {
scenarioId = argv[++i];
} else if (arg === '--port' && argv[i + 1]) {
port = Number(argv[++i]);
} else if (arg === '--list') {
listOnly = true;
} else if (arg === '-h' || arg === '--help') {
usage();
process.exit(0);
} else {
throw new Error(`未知参数: ${arg}`);
}
}
return { scenarioId, listOnly, port };
}
async function ensurePortalReady(baseUrl) {
const response = await fetch(`${baseUrl}/auth/status`);
if (!response.ok) {
throw new Error(`Portal 未就绪: ${baseUrl}/auth/status -> ${response.status}`);
}
}
async function runScenario(scenario, port) {
const reporter = createReporter();
const baseUrl = resolvePortalBase(port);
const account = {
username: scenario.account?.username ?? 'john',
password:
process.env.JOHN_PASSWORD
?? process.env.H5_ACCESS_PASSWORD
?? scenario.account?.password
?? '888888',
};
console.log(`==> 场景: ${scenario.name ?? scenario.id}`);
console.log(` Portal: ${baseUrl}`);
console.log(` 账户: ${account.username}\n`);
await ensurePortalReady(baseUrl);
let auth = null;
let sessionId = null;
let assistantCount = 0;
let assistantCombinedLength = 0;
let publishKey = null;
let htmlBefore = [];
for (const step of scenario.steps ?? []) {
const label = step.label ?? step.action;
console.log(`\n--- ${label} ---`);
if (step.action === 'login') {
auth = await loginViaApi(baseUrl, account, reporter);
publishKey = auth.user?.id ?? auth.user?.publishSlug ?? account.username;
continue;
}
if (step.action === 'chat') {
if (!auth) {
throw new Error('chat 步骤前必须先 login');
}
if (step.expect?.page) {
htmlBefore = await snapshotPublicHtml(publishKey);
}
const run = await createAgentRun(baseUrl, auth.cookie, {
message: step.message,
sessionId,
});
reporter.pass('提交消息', `"${step.message}" → run ${run.runId}`);
const terminal = await waitForRunTerminal(
baseUrl,
auth.cookie,
run.runId,
step.expect?.timeoutMs ?? 300_000,
);
sessionId = terminal.sessionId ?? terminal.agent_session_id ?? run.sessionId ?? sessionId;
if (terminal.status === 'failed') {
reporter.fail('run 终态', terminal.error ?? 'failed');
continue;
}
reporter.pass('run 终态', terminal.status);
if (!sessionId) {
reporter.fail('session', 'run 未回填 sessionId');
continue;
}
const reply = await waitForAssistantGrowth(baseUrl, auth.cookie, sessionId, {
previousCount: assistantCount,
previousCombinedLength: assistantCombinedLength,
minChars: step.expect?.assistantMinChars ?? 1,
timeoutMs: step.expect?.timeoutMs ?? 120_000,
});
if (!reply) {
reporter.fail('assistant 回复', '超时未收到新回复');
continue;
}
assistantCount = reply.count;
assistantCombinedLength = reply.combined.length;
reporter.pass('assistant 回复', `${reply.combined.length} 字 / ${reply.elapsedMs}ms`);
console.log('\n--- 回复摘要 ---');
console.log(reply.combined.slice(0, 600));
const keywords = step.expect?.replyKeywords ?? [];
if (keywords.length) {
const hit = keywords.filter((word) => reply.combined.includes(word));
if (hit.length === 0) {
reporter.fail('回复关键词', `未命中: ${keywords.join(', ')}`);
} else {
reporter.pass('回复关键词', hit.join(', '));
}
}
if (step.expect?.page) {
await verifyPageAccess({
baseUrl,
cookie: auth.cookie,
publishKey,
replyText: reply.combined,
htmlBefore,
expect: step.expect.page,
reporter,
});
}
continue;
}
reporter.fail('未知步骤', step.action ?? 'missing action');
}
return reporter.summary();
}
async function main() {
const { scenarioId, listOnly, port } = parseArgs(process.argv);
if (listOnly) {
const scenarios = await listScenarios();
console.log('可用场景:');
for (const item of scenarios) {
console.log(`- ${item.id}: ${item.name}${item.description ? `${item.description}` : ''}`);
}
return;
}
const scenario = await loadScenario(scenarioId);
const code = await runScenario(scenario, port);
process.exit(code);
}
main().catch((error) => {
console.error(error instanceof Error ? error.stack ?? error.message : error);
process.exit(1);
});
+324
View File
@@ -0,0 +1,324 @@
import crypto from 'node:crypto';
import fs from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { USER_COOKIE } from '../user-auth.mjs';
import { PUBLISH_ROOT_DIR } from '../user-publish.mjs';
const repoRoot = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
export function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
export function createReporter() {
const checks = [];
const issues = [];
return {
checks,
issues,
pass(label, detail = '') {
checks.push({ ok: true, label, detail });
console.log(`${label}${detail ? `: ${detail}` : ''}`);
},
fail(label, detail = '') {
issues.push({ label, detail });
checks.push({ ok: false, label, detail });
console.error(`${label}${detail ? `: ${detail}` : ''}`);
},
summary() {
console.log('\n=== 汇总 ===');
console.log(`通过: ${checks.filter((item) => item.ok).length}/${checks.length}`);
if (issues.length) {
console.log('失败项:');
for (const item of issues) {
console.log(` - ${item.label}: ${item.detail}`);
}
return 1;
}
console.log('场景测试全部通过');
return 0;
},
};
}
export function resolvePortalBase(port) {
return `http://127.0.0.1:${port}`;
}
export async function loginViaApi(baseUrl, { username, password }, reporter) {
const response = await fetch(`${baseUrl}/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
});
const body = await response.json().catch(() => ({}));
if (!response.ok || !body?.authenticated) {
throw new Error(`登录失败 ${response.status}: ${JSON.stringify(body)}`);
}
const setCookie = response.headers.getSetCookie?.() ?? [];
const cookieLine = setCookie.find((line) => line.startsWith(`${USER_COOKIE}=`))
?? response.headers.get('set-cookie');
const match = String(cookieLine ?? '').match(new RegExp(`${USER_COOKIE}=([^;]+)`));
const token = match?.[1] ? decodeURIComponent(match[1]) : null;
if (!token) {
throw new Error('登录成功但未解析到会话 Cookie');
}
reporter.pass('登录', `${username} (${body.user?.id ?? 'unknown-id'})`);
return {
token,
cookie: `${USER_COOKIE}=${token}`,
user: body.user ?? null,
};
}
function buildUserMessage(text) {
return {
id: crypto.randomUUID(),
role: 'user',
content: [{ type: 'text', text }],
metadata: { userVisible: true, displayText: text },
};
}
export async function createAgentRun(baseUrl, cookie, { message, sessionId = null }) {
const requestId = crypto.randomUUID();
const body = {
request_id: requestId,
user_message: buildUserMessage(message),
};
if (sessionId) body.session_id = sessionId;
const response = await fetch(`${baseUrl}/api/agent/runs`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Cookie: cookie,
},
body: JSON.stringify(body),
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(`POST /api/agent/runs ${response.status}: ${JSON.stringify(payload)}`);
}
const run = payload.run ?? payload;
return {
runId: run.id,
requestId,
sessionId: run.sessionId ?? run.agent_session_id ?? sessionId ?? null,
status: run.status,
};
}
export async function getAgentRun(baseUrl, cookie, runId) {
const response = await fetch(`${baseUrl}/api/agent/runs/${runId}`, {
headers: { Cookie: cookie },
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(`GET /api/agent/runs/${runId} ${response.status}: ${JSON.stringify(payload)}`);
}
return payload.run ?? payload;
}
export async function getSession(baseUrl, cookie, sessionId) {
const response = await fetch(`${baseUrl}/api/sessions/${encodeURIComponent(sessionId)}`, {
headers: { Cookie: cookie },
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
return { ok: false, status: response.status, payload };
}
return { ok: true, session: payload };
}
export function extractAssistantTexts(sessionPayload) {
const conversation = sessionPayload?.conversation
?? sessionPayload?.session?.conversation
?? sessionPayload?.messages
?? [];
const texts = [];
for (const message of conversation) {
if (message?.role !== 'assistant') continue;
const content = message?.content;
if (typeof content === 'string') texts.push(content);
else if (Array.isArray(content)) {
texts.push(
content
.map((item) => (item?.type === 'text' ? item.text ?? '' : ''))
.join(''),
);
}
}
return texts.filter(Boolean);
}
export function extractPublicLinks(text, baseUrl) {
const links = new Set();
const markdown = /\[[^\]]+\]\((https?:\/\/[^)]+|\/MindSpace\/[^)]+)\)/gi;
const bare = /(https?:\/\/[^\s)]+?\/MindSpace\/[^\s)]+?\.html)/gi;
const relative = /(\/MindSpace\/[^\s)]+?\.html)/gi;
for (const pattern of [markdown, bare, relative]) {
for (const match of text.matchAll(pattern)) {
const raw = match[1] ?? match[0];
if (!raw) continue;
links.add(raw.startsWith('http') ? raw : new URL(raw, baseUrl).href);
}
}
return [...links];
}
async function listPublicHtmlFiles(publishDir) {
const publicDir = path.join(publishDir, 'public');
try {
const entries = await fs.readdir(publicDir, { withFileTypes: true });
const files = [];
for (const entry of entries) {
if (!entry.isFile() || !entry.name.endsWith('.html')) continue;
const fullPath = path.join(publicDir, entry.name);
const stat = await fs.stat(fullPath);
files.push({
name: entry.name,
fullPath,
mtimeMs: stat.mtimeMs,
relativePublicPath: `public/${entry.name}`,
});
}
return files;
} catch {
return [];
}
}
export async function snapshotPublicHtml(publishKey) {
const publishDir = path.join(repoRoot, PUBLISH_ROOT_DIR, publishKey);
return listPublicHtmlFiles(publishDir);
}
export async function waitForRunTerminal(baseUrl, cookie, runId, timeoutMs) {
const started = Date.now();
let last = null;
while (Date.now() - started < timeoutMs) {
last = await getAgentRun(baseUrl, cookie, runId);
if (['succeeded', 'failed'].includes(last.status)) {
return last;
}
await sleep(2000);
}
throw new Error(`run ${runId} 超时未完成,最后状态 ${last?.status ?? 'unknown'}`);
}
export async function waitForAssistantGrowth(baseUrl, cookie, sessionId, {
previousCount = 0,
previousCombinedLength = 0,
minChars = 1,
timeoutMs = 120000,
}) {
const started = Date.now();
while (Date.now() - started < timeoutMs) {
const result = await getSession(baseUrl, cookie, sessionId);
if (result.ok) {
const texts = extractAssistantTexts(result.session);
const combined = texts.join('\n').trim();
const grew = texts.length > previousCount || combined.length > previousCombinedLength;
if (grew && combined.length >= minChars) {
return {
texts,
combined,
elapsedMs: Date.now() - started,
count: texts.length,
};
}
}
await sleep(2000);
}
return null;
}
export async function verifyPageAccess({
baseUrl,
cookie,
publishKey,
replyText,
htmlBefore = [],
expect = {},
reporter,
}) {
const keywords = expect.keywords ?? [];
const links = extractPublicLinks(replyText, baseUrl);
let pageUrl = links.find((url) => /\/MindSpace\/.+\.html/i.test(url)) ?? null;
if (!pageUrl && publishKey) {
const htmlAfter = await listPublicHtmlFiles(path.join(repoRoot, PUBLISH_ROOT_DIR, publishKey));
const beforeSet = new Set(htmlBefore.map((item) => item.fullPath));
const fresh = htmlAfter
.filter((item) => !beforeSet.has(item.fullPath))
.sort((a, b) => b.mtimeMs - a.mtimeMs);
if (fresh[0]) {
pageUrl = new URL(
`/MindSpace/${publishKey}/${fresh[0].relativePublicPath}`,
baseUrl,
).href;
reporter.pass('页面落盘', fresh[0].relativePublicPath);
}
}
if (expect.requirePublicLink !== false) {
if (!pageUrl) {
reporter.fail('公网页面链接', '回复中未找到 MindSpace 链接,public/ 也无新 HTML');
return false;
}
reporter.pass('公网页面链接', pageUrl);
}
if (pageUrl && expect.requireHttp200 !== false) {
const response = await fetch(pageUrl, { headers: cookie ? { Cookie: cookie } : {} });
const html = await response.text();
if (response.status !== 200) {
reporter.fail('页面 HTTP 状态', `${response.status} ${pageUrl}`);
return false;
}
reporter.pass('页面 HTTP 200', pageUrl);
if (keywords.length) {
const hit = keywords.filter((word) => html.includes(word));
if (hit.length === 0) {
reporter.fail('页面内容关键词', `未命中: ${keywords.join(', ')}`);
return false;
}
reporter.pass('页面内容关键词', hit.join(', '));
}
if (expect.requireMindspaceCover && !/mindspace-cover/i.test(html)) {
reporter.fail('页面元数据', '缺少 mindspace-cover');
return false;
}
}
return true;
}
export async function loadScenario(scenarioId) {
const scenarioPath = path.join(repoRoot, 'scenarios', `${scenarioId}.json`);
const raw = await fs.readFile(scenarioPath, 'utf8');
return JSON.parse(raw);
}
export async function listScenarios() {
const dir = path.join(repoRoot, 'scenarios');
const entries = await fs.readdir(dir, { withFileTypes: true });
const scenarios = [];
for (const entry of entries) {
if (!entry.isFile() || !entry.name.endsWith('.json') || entry.name.startsWith('_')) continue;
const scenario = JSON.parse(await fs.readFile(path.join(dir, entry.name), 'utf8'));
scenarios.push({
id: scenario.id ?? entry.name.replace(/\.json$/, ''),
name: scenario.name ?? scenario.id,
description: scenario.description ?? '',
});
}
return scenarios;
}
+17 -2
View File
@@ -5756,7 +5756,7 @@ async function ensurePublicHtmlPrivateAssetsMaterialized(filePath, html) {
return result.changed ? result.html : html;
}
async function sendPublishFile(req, res, filePath) {
async function sendPublishFile(req, res, filePath, { isOwner = true } = {}) {
if (!filePath.toLowerCase().endsWith('.html')) {
res.sendFile(filePath, (err) => {
if (err && !res.headersSent) res.status(404).json({ message: '文件不存在' });
@@ -5788,6 +5788,7 @@ async function sendPublishFile(req, res, filePath) {
const decorated = decorateMindSpacePublishedHtml({
html,
embed,
isOwner,
context,
htmlFilePath: filePath,
userAgent: req.get('user-agent') || '',
@@ -5804,6 +5805,10 @@ async function sendPublishFile(req, res, filePath) {
}
res.set('Content-Security-Policy', decorated.csp);
res.set('Content-Type', 'text/html; charset=utf-8');
// REGRESSION GUARD: mindspace-public-owner-vs-visitor — this HTML now varies by viewer
// (owner sees the Plaza entry, visitors don't), so it must never be cached/shared across
// sessions. There is no CDN/proxy_cache in front of this route today; keep it that way.
res.set('Cache-Control', 'private, no-store');
res.send(html);
}
@@ -5861,7 +5866,17 @@ async function serveUserPublishFile(req, res, next) {
}
}
await sendPublishFile(req, res, resolvedPath);
// REGRESSION GUARD: mindspace-public-owner-vs-visitor — this workspace URL is the one the
// agent hands back in chat and is reachable by anyone (no login required). Only the logged-in
// author (viewer.id === ownerKey) may see the "发布 Plaza" entry; everyone else is a visitor.
const viewer = req.userSession && userAuth
? await userAuth.getMe(req.userToken).catch(() => null)
: null;
const isOwner = Boolean(
viewer?.id && result.ownerKey && String(viewer.id).toLowerCase() === String(result.ownerKey).toLowerCase(),
);
await sendPublishFile(req, res, resolvedPath, { isOwner });
}
app.use('/temp', (req, res) => {
+5
View File
@@ -14,6 +14,11 @@ description: 商品宣传 / 活动页技能:基于商品链接、主题、图
- 用户上传商品图、Logo、模特图、详情图,希望生成页面文案和视觉结构
- 用户要做促销、上新、种草、达人推荐、节日活动或私域转化页面
## 何时不用
- 旅游攻略、城市介绍、游记、主题内容页 → 使用 `static-page-publish`,直接生成 HTML 并返回链接
- 用户未提供商品链接、也不要求购买跳转时,不要套用本技能
## 必问信息
如果用户没有一次性给全,优先只问这 3 个问题:
+8
View File
@@ -123,6 +123,14 @@ npm run check:mindspace-cover
`data-mindspace-page-tag` 的区域为平台固定信息:用户在编辑模式中不可见、不可改;预览与发布后正常显示。**禁止**把该行 CSS 透明度设过低(如 `opacity: 0.25`),否则页内看不见品牌。
## 中文字体(推荐)
中文页面正文与标题优先使用系统无衬线字体栈,**避免** Georgia、Times New Roman 等西文衬线体(中文显示发虚、不协调):
```css
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
```
## 按需附带文件下载(Word / PDF)
- 只有用户明确要求 Word / PDF 下载时才生成二进制文件
+9 -5
View File
@@ -1280,11 +1280,13 @@ export function useTKMindChat(
(value) => typeof value === 'string' && value.trim(),
);
if (!text.trim() && normalizedImageUrls.length === 0) return;
// Use the ref here (not the React state) so that rapid back-to-back calls in the
// same render cycle are blocked even before the state update has been re-rendered.
if (
chatState === 'streaming' ||
chatState === 'loading' ||
chatState === 'connecting' ||
chatState === 'waiting'
chatStateRef.current === 'streaming' ||
chatStateRef.current === 'loading' ||
chatStateRef.current === 'connecting' ||
chatStateRef.current === 'waiting'
) return;
const trimmed = text.trim();
@@ -1319,6 +1321,9 @@ export function useTKMindChat(
messageHistoryLoadedCountRef.current = messagesRef.current.length;
setMessages(messagesRef.current);
setChatState('waiting');
// Immediately reflect in the ref so any synchronous re-entry is blocked before
// the next React render cycle runs the useEffect that normally syncs this ref.
chatStateRef.current = 'waiting';
setError(null);
setPendingTool(null);
@@ -1484,7 +1489,6 @@ export function useTKMindChat(
[
notifyInsufficientBalance,
session,
chatState,
grantedSkills,
clearActiveRequestMissingTimer,
subscribeToSession,
+1
View File
@@ -239,6 +239,7 @@ npm run check:mindspace-cover
- 完整 \`<!DOCTYPE html>\`\`lang="zh-CN"\`
- 移动端友好\`<meta name="viewport" content="width=device-width, initial-scale=1">\`
- 深色/浅色与内容一致主色在 CSS mindspace-cover 中保持一致
- 中文页面优先使用系统无衬线字体\`-apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif\`**避免** Georgia、Times 等西文衬线体
- 页脚平台品牌行**必须**使用 \`data-mindspace-page-tag="platform-brand"\` 标记,显示为 **TKMind · 智趣**,**禁止**使用邮箱或 \`tkmind.ai\`
\`\`\`html