Compare commits

..

11 Commits

Author SHA1 Message Date
john 1b9c65fec1 docs: record rebased image-quota fix SHA
Memind CI / Test, build, and release guards (push) Successful in 4m40s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-14 21:46:57 +08:00
john ab08839a54 docs: register fix/image-quota-migration-backfill branch disposition
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-14 21:46:33 +08:00
john 8cf1c772a7 fix(billing): backfill finite image quota after free-plan rebuild
Expired paid users were rebuilt as free with period_images_limit=0, which the quota system treats as unlimited. Write the catalog quota on rebuild and repair existing finite plans on schema ensure.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-14 21:46:33 +08:00
john 41f245c88a fix(wechat): rotate image_url-poisoned sessions for report follow-ups
Memind CI / Test, build, and release guards (push) Has been cancelled
Goose cannot persist historical image scrub (PUT 405), so DeepSeek rejects
image_url on the next turn. Reattach the recent report image and rotate to a
fresh session instead of treating the follow-up as a missing page source.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-14 21:45:02 +08:00
john fe2d5d7aa1 fix(release-gate): map index.html to the UI impact domain
Memind CI / Test, build, and release guards (push) Successful in 7m50s
Unblock Core+Impact selection for H5 shell title changes.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-14 09:46:34 +08:00
john 443544d1f7 fix(h5): show TKMind as the user-facing product name
Memind CI / Test, build, and release guards (push) Has been cancelled
Keep the chat chrome, page title, and related copy aligned with the existing TKMind brand.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-14 09:42:50 +08:00
john 36d3a91f8d test(chat): widen intent-router timeout fallback window
Memind CI / Test, build, and release guards (push) Successful in 5m32s
CI failed on a 5ms/20ms race that let the LLM reply win before the timeout fallback. Give the abort path more slack so the guard stays deterministic.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-13 16:15:16 +08:00
john 4e789663a1 feat(seo): index public online pages without confirmation
Memind CI / Test, build, and release guards (push) Failing after 5m19s
Drop the user_confirmed_at gate so public, online, unexpired pages can enter sitemap/llms and receive SEO/GEO tags. Defaults now enable all discovery switches; stored all-off config is still preserved until admin saves.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-13 15:51:48 +08:00
john 8a88cd53e8 fix(h5-chat): resolve logged-in blank page from canSubmit TDZ
Memind CI / Test, build, and release guards (push) Failing after 4m28s
Move canSubmit above sendButtonLabel so authenticated ChatPanel render no longer throws before initialization.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-13 11:33:26 +08:00
john baf89c5d04 docs: register 2026-08-12 dev and related branch dispositions
Memind CI / Test, build, and release guards (push) Successful in 4m10s
Record closure for achievements showcase, WeChat menu rename, runtime profile packaging, SEO/GEO admin catalog, and achievements delivery fix branches.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-13 10:26:49 +08:00
john 19dacc0e5d docs: register feature/h5-queued-chat-submit branch disposition
Memind CI / Test, build, and release guards (push) Successful in 4m21s
Record audit trail for the H5 queued chat submit fix merged at 558c4ef.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-13 09:21:12 +08:00
31 changed files with 725 additions and 58 deletions
+30
View File
@@ -1000,6 +1000,36 @@ export async function ensurePlanCatalogSchema(pool) {
[nextTokens, now, planType, previousTokens],
);
}
// Repair active subscriptions that inherited DEFAULT 0 (unlimited) from the
// pre-image-quota free-plan backfill, but whose catalog quota is finite.
// True unlimited plans (catalog period_images = 0) are left unchanged.
await pool.query(
`INSERT INTO h5_image_quota_ledger
(id, user_id, delta, balance_after, reason, ref_id, operator_id, note, created_at)
SELECT UUID(), s.user_id, p.period_images,
GREATEST(0, p.period_images + s.period_images_bonus - s.period_images_used),
'plan_change', s.id, NULL,
'回填套餐默认图片额度(补建漏写)', ?
FROM h5_subscriptions s
INNER JOIN h5_plan_catalog p ON p.plan_type = s.plan_type
WHERE s.status = 'active'
AND s.expires_at > ?
AND s.period_images_limit = 0
AND p.period_images > 0`,
[now, now],
);
await pool.query(
`UPDATE h5_subscriptions s
INNER JOIN h5_plan_catalog p ON p.plan_type = s.plan_type
SET s.period_images_limit = p.period_images,
s.updated_at = ?
WHERE s.status = 'active'
AND s.expires_at > ?
AND s.period_images_limit = 0
AND p.period_images > 0`,
[now, now],
);
}
export function createPlanCatalogService(pool) {
+14 -2
View File
@@ -74,12 +74,24 @@ describe('ensurePlanCatalogSchema', () => {
await ensurePlanCatalogSchema(pool);
const planUpdates = queries.filter(({ sql }) => sql.includes('UPDATE h5_plan_catalog'));
const subUpdates = queries.filter(({ sql }) => sql.includes('UPDATE h5_subscriptions'));
const tokenSubUpdates = queries.filter(({ sql }) => (
sql.includes('UPDATE h5_subscriptions') && sql.includes('period_tokens_limit')
));
const imageSubUpdates = queries.filter(({ sql }) => (
sql.includes('UPDATE h5_subscriptions s') && sql.includes('period_images_limit')
));
const imageLedgerInserts = queries.filter(({ sql }) => (
sql.includes('INSERT INTO h5_image_quota_ledger') && sql.includes('补建漏写')
));
assert.ok(planUpdates.some(({ params }) => params[0] === 450_000 && params[2] === 'free' && params[3] === 150_000));
assert.ok(planUpdates.some(({ params }) => params[0] === 3_600_000 && params[2] === 'lite' && params[3] === 1_200_000));
assert.ok(planUpdates.some(({ params }) => params[0] === 13_500_000 && params[2] === 'standard' && params[3] === 4_500_000));
assert.equal(subUpdates.length, 3);
assert.equal(tokenSubUpdates.length, 3);
assert.equal(imageSubUpdates.length, 1);
assert.equal(imageLedgerInserts.length, 1);
assert.match(imageSubUpdates[0].sql, /p\.period_images > 0/);
assert.match(imageSubUpdates[0].sql, /s\.period_images_limit = 0/);
});
});
+46
View File
@@ -75,6 +75,52 @@ function stripAgentImageText(text) {
.trim();
}
function stripImageUrlLines(text) {
return String(text ?? '').replace(IMAGE_URL_LINES_RE, '').trim();
}
/**
* After Qwen VL analysis, Goose must receive text only. DeepSeek rejects
* `image_url` parts, and native Goose may expand metadata.imageUrls into them.
* Keep archived/preview URLs for UI history; leave the VL note in the text.
*/
export function detachCurrentTurnImagesForTextProvider(message, canonicalImageUrls = []) {
if (!message) return message;
const metadata =
message.metadata && typeof message.metadata === 'object' && !Array.isArray(message.metadata)
? { ...message.metadata }
: {};
const urls = (Array.isArray(canonicalImageUrls) && canonicalImageUrls.length > 0
? canonicalImageUrls
: Array.isArray(metadata.imageUrls) ? metadata.imageUrls : []
).filter((url) => typeof url === 'string' && url.trim());
if (urls.length > 0) {
metadata.archivedImageUrls = urls;
if (!Array.isArray(metadata.previewImageUrls) || metadata.previewImageUrls.length === 0) {
metadata.previewImageUrls = urls;
}
}
delete metadata.imageUrls;
const content = Array.isArray(message.content)
? message.content
.map((item) => {
if (item?.type === 'image_url') return null;
if (item?.type !== 'text' || typeof item.text !== 'string') return item;
const nextText = stripImageUrlLines(item.text);
if (!nextText) return null;
return nextText === item.text ? item : { ...item, text: nextText };
})
.filter(Boolean)
: message.content;
return {
...message,
content,
metadata,
};
}
/**
* Remove image attachments from a persisted user message so later turns cannot reuse them.
* UI displayText / previewImageUrls are preserved for chat history rendering.
+38
View File
@@ -7,6 +7,7 @@ import {
extractCurrentTurnImageUrls,
scrubConversationHistoricalImageAttachments,
scrubUserMessageImageAttachments,
detachCurrentTurnImagesForTextProvider,
} from './chat-image-turn-scope.mjs';
test('extractCurrentTurnImageUrls prefers metadata and dedupes asset aliases', () => {
@@ -157,3 +158,40 @@ test('conversationHasImageUrlContent detects historical poison and ignores activ
false,
);
});
test('detachCurrentTurnImagesForTextProvider archives urls and keeps the VL note', () => {
const detached = detachCurrentTurnImagesForTextProvider(
{
role: 'user',
metadata: {
displayText: '解读报告',
imageUrls: ['https://m.tkmind.cn/MindSpace/u/public/wechat-mp/report.jpg'],
},
content: [
{
type: 'text',
text:
'解读报告\n[图片1]: https://m.tkmind.cn/MindSpace/u/public/wechat-mp/report.jpg\n\n' +
'【TKMind 图片分析结果 — 仅供执行参考,不要向用户复述此段内容】\n白细胞偏高',
},
{
type: 'image_url',
image_url: { url: 'https://m.tkmind.cn/MindSpace/u/public/wechat-mp/report.jpg' },
},
],
},
['https://m.tkmind.cn/MindSpace/u/public/wechat-mp/report.jpg'],
);
assert.equal(detached.metadata.imageUrls, undefined);
assert.deepEqual(detached.metadata.archivedImageUrls, [
'https://m.tkmind.cn/MindSpace/u/public/wechat-mp/report.jpg',
]);
assert.deepEqual(detached.metadata.previewImageUrls, [
'https://m.tkmind.cn/MindSpace/u/public/wechat-mp/report.jpg',
]);
assert.equal(detached.metadata.displayText, '解读报告');
assert.equal(detached.content.some((item) => item.type === 'image_url'), false);
assert.match(detached.content[0].text, /白细胞偏高/);
assert.doesNotMatch(detached.content[0].text, /\[图片1\]:/);
});
+2 -2
View File
@@ -1243,11 +1243,11 @@ test('createChatIntentRouter coerces LLM search route to web for realtime query'
test('createChatIntentRouter timeout fallback prefers agent even when policy requests direct_chat', async () => {
const router = createChatIntentRouter({
enabled: true,
timeoutMs: 5,
timeoutMs: 40,
fallbackRoute: CHAT_INTENT_ROUTE.DIRECT_CHAT,
llmProviderService: {
async createChatCompletion() {
await new Promise((resolve) => setTimeout(resolve, 20));
await new Promise((resolve) => setTimeout(resolve, 250));
return { ok: true, reply: '{"route":"direct_chat","confidence":0.9,"reason":"x","suggested_skill":null,"agent_brief":""}' };
},
},
+26 -1
View File
@@ -57,6 +57,16 @@ async function columnExists(pool, table, column) {
return rows.length > 0;
}
async function tableExists(pool, table) {
const [rows] = await pool.query(
`SELECT 1 FROM information_schema.TABLES
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ?
LIMIT 1`,
[table],
);
return rows.length > 0;
}
async function indexExists(pool, table, index) {
const [rows] = await pool.query(
`SELECT 1 FROM information_schema.STATISTICS
@@ -943,10 +953,23 @@ export async function migrateSchema(pool) {
// Back-fill free subscriptions for existing users who pre-date the subscription system.
// Idempotent: only inserts where no active subscription exists.
// period_images_limit defaults to 0 (= unlimited). Always write the catalog
// quota when the column exists, otherwise expired paid users become unlimited
// on the next Portal/Plaza start.
const hasImageLimit = await columnExists(pool, 'h5_subscriptions', 'period_images_limit');
const hasCatalog = await tableExists(pool, 'h5_plan_catalog');
const tokenSelect = hasCatalog ? 'COALESCE(c.period_tokens, 150000)' : '150000';
const imageSelect = hasCatalog ? 'COALESCE(c.period_images, 10)' : '10';
const catalogJoin = hasCatalog ? "LEFT JOIN h5_plan_catalog c ON c.plan_type = 'free'" : '';
const imageCols = hasImageLimit
? 'period_images_limit, period_images_used, period_images_bonus,'
: '';
const imageVals = hasImageLimit ? `${imageSelect}, 0, 0,` : '';
await pool.query(`
INSERT INTO h5_subscriptions
(id, user_id, plan_type, status,
period_tokens_limit, period_tokens_used,
${imageCols}
period_start, period_end, expires_at, overage_rate,
operator_id, note, created_at, updated_at)
SELECT
@@ -954,7 +977,8 @@ export async function migrateSchema(pool) {
u.id,
'free',
'active',
150000, 0,
${tokenSelect}, 0,
${imageVals}
UNIX_TIMESTAMP() * 1000,
(UNIX_TIMESTAMP() + 30 * 86400) * 1000,
(UNIX_TIMESTAMP() + 30 * 86400) * 1000,
@@ -964,6 +988,7 @@ export async function migrateSchema(pool) {
UNIX_TIMESTAMP() * 1000,
UNIX_TIMESTAMP() * 1000
FROM h5_users u
${catalogJoin}
WHERE NOT EXISTS (
SELECT 1 FROM h5_subscriptions s
WHERE s.user_id = u.id AND s.status = 'active'
+151
View File
@@ -3,6 +3,25 @@
本文件记录已经完成迁移、但仍可能因为 Git 拓扑或遗留 worktree 被误判为“尚未进入 `main`”的分支。
它是分支复用、合并、cherry-pick 和清理前的必查清单。
## `fix/image-quota-migration-backfill`
**状态:禁止再次引用。改动已提交并将随本分支合入 `origin/main`,该分支保留仅用于只读追溯,不是待合并开发分支。**
审计日期:2026-08-14
代码修复:`8cf1c772`
`origin/main` 对应提交:本登记与代码修复一并快进合入 main
### 原始用途
- 补建免费套餐时写入套餐目录图片额度,避免 `period_images_limit = 0` 被当成无限
- 启动时回填「上限为 0、但目录额度有限」的有效订阅
- 记录 2026-08-14 柯彤无限图片额度事故
### 验证摘要
- `node --test billing-subscription.test.mjs`27 passed
- 生产已回填 33 条误标无限的有效免费订阅;柯彤剩余 6 / 总计 10
## 处置规则
- 标记为“禁止再次引用”的分支只允许用于只读历史追溯。
@@ -527,3 +546,135 @@ Portal,避免在线修改稳定 `.env`,并确保稳定 8081 与其他用户
- 保留本地分支名用于审计追溯。
- **不要** merge、cherry-pick 或从该分支继续开发。
## `feature/2026-08-12-dev`
**状态:禁止再次引用。改动已提交并进入 `origin/main`,该分支保留仅用于只读追溯。**
审计日期:2026-08-13
分支 HEAD`421e204`
`origin/main` 对应提交:`421e204`
### 原始用途
MindSpace 成果展示:分页管理、统计聚合、Achievements 面板 UI 与 Portal API。
### 验证摘要
- Git 祖先检查确认分支 HEAD 已进入 `origin/main`
- `node --test mindspace-page-achievement-list.test.mjs mindspace-page-achievement-stats.test.mjs`5/5 通过。
### 最终处置
- 保留本地分支名用于审计追溯。
- **不要** merge、cherry-pick 或从该分支继续开发。
- 后续交付修复见 `fix/achievements-delivery-release`(已进入 `origin/main` @ `53b0d2c`)。
## `feature/wechat-memind-menu`
**状态:禁止再次引用。改动已提交并进入 `origin/main`,该分支保留仅用于只读追溯。**
审计日期:2026-08-13
分支 HEAD`e9ea55c`
`origin/main` 对应提交:`e9ea55c`
### 原始用途
微信公众号自定义菜单文案由 TKMind 更名为 Memind`scripts/wechat-mp-menu.mjs`)。
### 验证摘要
- Git 祖先检查确认分支 HEAD 已进入 `origin/main`
### 最终处置
- 保留本地分支名用于审计追溯。
- **不要** merge、cherry-pick 或从该分支继续开发。
## `fix/runtime-profile-packaging`
**状态:禁止再次引用。改动已提交并进入 `origin/main`,该分支保留仅用于只读追溯。**
审计日期:2026-08-13
分支 HEAD`6542a43`
`origin/main` 对应提交:`6542a43`
### 原始用途
Portal runtime 打包纳入 `memind-runtime-profile`,修复 WeChat 菜单脚本在 105/103 发布链路中缺少 runtime profile 的问题。
### 验证摘要
- Git 祖先检查确认分支 HEAD 已进入 `origin/main`
### 最终处置
- 保留本地分支名用于审计追溯。
- **不要** merge、cherry-pick 或从该分支继续开发。
## `feature/seo-geo-admin-catalog`
**状态:禁止再次引用。改动已提交并进入 `origin/main`,该分支保留仅用于只读追溯。**
审计日期:2026-08-13
分支 HEAD`fdc234e`
`origin/main` 对应提交:`fdc234e`
### 原始用途
管理后台 SEO/GEO 收录目录与爬虫统计(`mindspace-seo-geo-admin-catalog.mjs`);管理 UI 在 memind_adm 5174Memind 侧为共享业务模块与 API。
### 验证摘要
- Git 祖先检查确认分支 HEAD 已进入 `origin/main`
- `node --test mindspace-seo-geo-admin-catalog.test.mjs`3/3 通过。
### 最终处置
- 保留本地分支名用于审计追溯。
- **不要** merge、cherry-pick 或从该分支继续开发。
## `fix/achievements-delivery-release`
**状态:禁止再次引用。改动已提交并进入 `origin/main`,该分支保留仅用于只读追溯。**
审计日期:2026-08-13
分支 HEAD`53b0d2c`
`origin/main` 对应提交:`53b0d2c`
### 原始用途
成果展示交付跟进:Finish 时在 `finally` 释放静态 HTML 交付契约,避免重编辑页面卡在 HTTP 409;成果面板按 Asia/Shanghai 日历日分组,消除重复日期标题。
### 验证摘要
- Git 祖先检查确认分支 HEAD 已进入 `origin/main`
- `node --test mindspace-delivery-contract.test.mjs`3/3 通过。
### 最终处置
- 保留本地分支名用于审计追溯。
- **不要** merge、cherry-pick 或从该分支继续开发。
## `feature/h5-queued-chat-submit`
**状态:禁止再次引用。改动已提交并进入 `origin/main`,该分支保留仅用于只读追溯。**
审计日期:2026-08-13
分支 HEAD`558c4ef`
`origin/main` 对应提交:`558c4ef`
### 原始用途
H5 主会话在 waiting/streaming/connecting/loading 时静默丢弃后续消息;改为立即展示用户指令、提示排队,并在 composer 回到 idle 后自动提交。
### 验证摘要
- `node --test chat-agent-run-gate.test.mjs`13/13 通过
- `node scripts/run-memind-tests.mjs --mode changed`:通过
- `node scripts/run-memind-tests.mjs --mode guards`:通过
### 最终处置
- 保留本地分支名用于审计追溯。
- **不要** merge、cherry-pick 或从该分支继续开发。
@@ -0,0 +1,43 @@
# 2026-08-14 过期后补建免费套餐把图片额度写成无限
## 现象
生产管理后台用户详情里,部分普通用户的图片额度显示「无限」,输入框和「保存额度」被禁用。典型用户:柯彤(`wx_1ugymxba`),当时有效套餐为免费版,备注「系统迁移补建免费套餐」,`period_images_limit = 0`,已用 4 张。
后台把 `period_images_limit === 0` 视为无限,用户详情和 `setImageQuota` 都拒绝调整。这些用户可以无限制调用 `image_make`
## 根因
1. `initSchema` / `migrateSchema` 在用户没有有效订阅时会补建免费套餐,INSERT 只写了 token 额度,没有写 `period_images_limit`
2. 该列 `DEFAULT 0`,而计费把 `0` 设计成「无限」,不是「没有额度」。
3. 付费套餐过期后,Portal / Plaza 下次启动会给该用户补建免费套餐,于是变成无限生图。
生产套餐目录里免费版是 10 张/周期,不是无限。事故发生时有 33 条有效免费订阅命中该补建备注且上限为 0;另有 86 条正常免费订阅上限为 10。
## 修复
- 补建免费套餐时写入套餐目录的 `period_images`(无目录时回退 10)。
- `ensurePlanCatalogSchema` 启动时把「有效订阅上限为 0,但目录额度 > 0」的记录回填为目录值,并记一条 `plan_change` 流水。真正无限套餐(目录 `period_images = 0`)不改。
- 生产已对这 33 条有效订阅做一次数据回填。代码修复需随 Memind 发布后才会阻止再次补建漏写。
## 排查入口
```sql
SELECT u.username, u.display_name, s.plan_type, s.note,
s.period_images_limit, s.period_images_used, s.period_images_bonus
FROM h5_subscriptions s
JOIN h5_users u ON u.id = s.user_id
WHERE s.status = 'active'
AND s.expires_at > UNIX_TIMESTAMP() * 1000
AND s.period_images_limit = 0;
SELECT plan_type, name, period_images FROM h5_plan_catalog;
```
管理后台:用户详情「图片生成额度」显示无限且无法保存,同时计费里该用户是免费套餐、备注含「系统迁移补建」。
## 影响边界与回滚
- 目录里本身就是无限的套餐(`period_images = 0`)不会被回填。
- 已用量超过目录额度的用户回填后剩余为 0,不能再继续按无限额度生图。
- 回滚代码不会自动把已回填的上限改回 0;如需恢复无限,要显式改订阅或目录。
+4 -4
View File
@@ -205,15 +205,15 @@ Plaza 本地开发说明见 [plaza-local.md](./plaza-local.md)。生产发布、
## MindSpace SEO / GEO(本地联调)
默认关闭,与生产一致。在 **memind_adm**(见上表)打开 **MindSpace 配置**保存 **SEO / GEO** 卡片中的总开关与子开关。
默认开启。在 **memind_adm**(见上表)打开 **MindSpace 配置**可按需关闭 **SEO / GEO** 卡片中的总开关与子开关。
| 开关 | 作用 |
|------|------|
| 总开关 `seoGeo.enabled` | 关闭时交付链与改前一致;开启后按收录策略注入 meta / JSON-LD |
| 总开关 `seoGeo.enabled` | 关闭时注入 meta / JSON-LD;开启后按收录策略注入 |
| `seo.sitemap` / `seo.robots` / `geo.llms` | 控制 `GET /sitemap.xml``/robots.txt``/llms.txt` |
| `seo.baiduPush` | 用户确认公开 MindSpace 页或 Plaza 发帖后推送百度 |
| `seo.baiduPush` | 公开 MindSpace 页或 Plaza 发帖后推送百度 |
可索引页条件:`public` + `online` + `user_confirmed_at` 非空。私有页、未确认公开、embed 预览一律 `noindex`
可索引页条件:`public` + `online` + 未过期。密码、登录可见、owner_only、已过期、embed 预览一律 `noindex`
本地验证:
+4 -4
View File
@@ -4,11 +4,11 @@
## 保护内容
1. **收录策略硬规则**:仅 `access_mode=public``status=online` `user_confirmed_at` 非空的发布页允许 SEO/GEO 注入与 sitemap/llms 收录。
2. **私有页强制 noindex**:密码、登录可见、owner_only、未确认公开、工作区预览直链等必须注入 `noindex, nofollow` 并设置 `X-Robots-Tag`
3. **总开关默认关闭**`mindspace_config.seo_geo_config.enabled=false` 时,交付链行为与未上线前一致
1. **收录策略硬规则**:仅 `access_mode=public``status=online`未过期的发布页允许 SEO/GEO 注入与 sitemap/llms 收录。不再要求 `user_confirmed_at`
2. **私有页强制 noindex**:密码、登录可见、owner_only、已过期、工作区预览直链等必须注入 `noindex, nofollow` 并设置 `X-Robots-Tag`
3. **总开关默认开启**`mindspace_config.seo_geo_config` 缺省为全开;库内已保存的旧值仍以数据库为准,需在 memind_adm MindSpace 配置页保存后才会改写生产
4. **配置来源**memind_adm MindSpace 配置页 → `PATCH /admin-api/mindspace/config` → Portal `loadMindSpaceConfigCached()`
5. **百度推送**:仅在 admin 开启 `seo.baiduPush` 且页面可索引时触发;MindSpace 确认公开与 Plaza 发帖共用该开关。
5. **百度推送**:仅在 admin 开启 `seo.baiduPush` 且页面可索引时触发;公开页发布与 Plaza 发帖共用该开关。
## 必跑验证
+1 -1
View File
@@ -9,7 +9,7 @@
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="theme-color" content="#0f1419" />
<title>Memind</title>
<title>TKMind</title>
</head>
<body>
<div id="root"></div>
+8 -8
View File
@@ -59,18 +59,18 @@ function parseJsonObject(value, fallback = {}) {
export function defaultSeoGeoConfig() {
return {
enabled: false,
enabled: true,
seo: {
enabled: false,
enabled: true,
canonical: true,
sitemap: false,
robotsTxt: false,
baiduPush: false,
sitemap: true,
robotsTxt: true,
baiduPush: true,
},
geo: {
enabled: false,
jsonLd: false,
llmsTxt: false,
enabled: true,
jsonLd: true,
llmsTxt: true,
},
};
}
+38 -1
View File
@@ -9,6 +9,18 @@ import {
updateMindSpaceConfig,
} from './mindspace-config.mjs';
test('defaultSeoGeoConfig enables all discovery switches', () => {
const config = defaultSeoGeoConfig();
assert.equal(config.enabled, true);
assert.equal(config.seo.enabled, true);
assert.equal(config.seo.sitemap, true);
assert.equal(config.seo.robotsTxt, true);
assert.equal(config.seo.baiduPush, true);
assert.equal(config.geo.enabled, true);
assert.equal(config.geo.jsonLd, true);
assert.equal(config.geo.llmsTxt, true);
});
test('defaultMindSpaceConfig falls back to env or 5', () => {
assert.equal(defaultMindSpaceConfig({ MINDSPACE_FREE_PUBLIC_PAGE_LIMIT: '8' }).publicPageLimit, 8);
assert.equal(defaultMindSpaceConfig({ MINDSPACE_FREE_PUBLIC_PAGE_LIMIT: '0' }).publicPageLimit, 5);
@@ -24,7 +36,7 @@ test('normalizeSeoGeoConfig merges nested switches', () => {
assert.equal(config.enabled, true);
assert.equal(config.seo.sitemap, true);
assert.equal(config.geo.llmsTxt, true);
assert.equal(config.seo.baiduPush, false);
assert.equal(config.seo.baiduPush, true);
});
test('ensureMindSpaceConfig seeds the default row only when empty', async () => {
@@ -45,6 +57,31 @@ test('ensureMindSpaceConfig seeds the default row only when empty', async () =>
assert.deepEqual(calls[2].params.slice(0, 3), ['public_page_limit', '9', '公开页面数量上限']);
});
test('loadMindSpaceConfig keeps stored all-off seoGeo instead of new defaults', async () => {
const pool = {
async query(sql) {
if (sql.includes('FROM mindspace_config')) {
return [[
{
key: 'seo_geo_config',
value: JSON.stringify({
enabled: false,
seo: { enabled: false, canonical: true, sitemap: false, robotsTxt: false, baiduPush: false },
geo: { enabled: false, jsonLd: false, llmsTxt: false },
}),
},
]];
}
return [[]];
},
};
const config = await loadMindSpaceConfig(pool);
assert.equal(config.seoGeo.enabled, false);
assert.equal(config.seoGeo.seo.sitemap, false);
assert.equal(config.seoGeo.geo.jsonLd, false);
});
test('loadMindSpaceConfig merges stored config with fallback', async () => {
const pool = {
async query(sql) {
+1 -2
View File
@@ -40,7 +40,6 @@ export function isPublicationIndexable(publication, { now = Date.now() } = {}) {
if (!snapshot) return false;
if (snapshot.status !== 'online') return false;
if (snapshot.accessMode !== INDEXABLE_ACCESS_MODE) return false;
if (snapshot.userConfirmedAt == null) return false;
if (snapshot.expiresAt != null && snapshot.expiresAt <= now) return false;
return true;
}
@@ -74,7 +73,7 @@ export function resolveIndexPolicy({
return {
mode: 'indexable',
injectNoindex: false,
reason: 'public_confirmed',
reason: 'public_online',
seoEnabled: Boolean(seoGeoConfig.seo?.enabled),
geoEnabled: Boolean(seoGeoConfig.geo?.enabled),
canonicalEnabled: seoGeoConfig.seo?.canonical !== false,
+8 -1
View File
@@ -17,6 +17,13 @@ test('isPublicationIndexable accepts confirmed public online publications', () =
assert.equal(isPublicationIndexable(indexablePublication), true);
});
test('isPublicationIndexable accepts public online pages without confirmation', () => {
assert.equal(
isPublicationIndexable({ ...indexablePublication, userConfirmedAt: null }),
true,
);
});
test('isPublicationIndexable rejects private access modes', () => {
for (const accessMode of ['password', 'private_link', 'login_required', 'owner_only']) {
assert.equal(
@@ -44,7 +51,7 @@ test('resolveIndexPolicy noindexes non-public publications when enabled', () =>
assert.equal(policy.injectNoindex, true);
});
test('resolveIndexPolicy marks confirmed public pages indexable', () => {
test('resolveIndexPolicy marks public online pages indexable', () => {
const policy = resolveIndexPolicy({
seoGeoConfig: {
enabled: true,
+1 -2
View File
@@ -32,7 +32,6 @@ export async function listIndexablePublications(
LEFT JOIN h5_page_records p ON p.id = pr.page_id
WHERE pr.status = 'online'
AND pr.access_mode = 'public'
AND pr.user_confirmed_at IS NOT NULL
AND (pr.expires_at IS NULL OR pr.expires_at > ?)
ORDER BY pr.published_at DESC
LIMIT ? OFFSET ?`,
@@ -153,7 +152,7 @@ export function renderRobotsTxt({
export function renderLlmsTxt(entries, { origin = '' } = {}) {
const header = [
'# TKMind MindSpace public pages',
'# Only confirmed public publications are listed here.',
'# Public online publications are listed here.',
'',
];
const body = entries
+1 -1
View File
@@ -108,6 +108,6 @@ test('listAdminSeoGeoPublicationCatalog merges zero-traffic pages with view stat
assert.equal(result.data[0].indexable, true);
assert.equal(result.data[1].publicationId, 'pub-2');
assert.equal(result.data[1].totalViews, 0);
assert.equal(result.data[1].indexable, false);
assert.equal(result.data[1].indexable, true);
assert.match(result.data[1].publicUrl, /^https:\/\/m\.tkmind\.cn\//);
});
+2 -2
View File
@@ -24,7 +24,7 @@ test('decorateMindSpaceSeoGeoHtml noindexes private publications', () => {
assert.match(result.html, /noindex, nofollow/);
});
test('decorateMindSpaceSeoGeoHtml injects seo and geo for public pages', () => {
test('decorateMindSpaceSeoGeoHtml injects seo and geo for unconfirmed public pages', () => {
const result = decorateMindSpaceSeoGeoHtml(
'<html><head><title>Demo</title><meta name="description" content="摘要"></head><body></body></html>',
{
@@ -32,7 +32,7 @@ test('decorateMindSpaceSeoGeoHtml injects seo and geo for public pages', () => {
publication: {
status: 'online',
accessMode: 'public',
userConfirmedAt: Date.now(),
userConfirmedAt: null,
publicUrl: '/u/john/pages/demo',
},
context: {
+1 -1
View File
@@ -88,7 +88,7 @@ const IMPACT_RULES = Object.freeze([
{ groups: ['AUTH'], pattern: /(?:auth|access-policy|account|user-permission)/i },
{ groups: ['CFG'], pattern: /(?:config|provider|model-catalog|orchestrator|analytics|disclosure)/i },
{ groups: ['CFG'], pattern: /(?:^|\/)\.env(?:\.example)?$/i },
{ groups: ['UI'], pattern: /^(?:src\/|public\/)|\.(?:css|scss|tsx|vue)$/i },
{ groups: ['UI'], pattern: /^(?:src\/|public\/|index\.html$)|\.(?:css|scss|tsx|vue)$/i },
]);
const GROUP_DEPENDENCIES = Object.freeze({
+11
View File
@@ -17,6 +17,17 @@ test('docs-only changes use the compact core gate', async () => {
assert.equal(selection.unmapped_paths.length, 0);
});
test('H5 shell index.html maps to the UI domain', async () => {
const catalog = await loadScenarioCatalog();
const selection = selectImpactScenarios({
catalog,
changedPaths: ['index.html'],
});
assert.equal(selection.strategy, 'impact');
assert.deepEqual(selection.impact_groups, ['UI']);
assert.equal(selection.unmapped_paths.length, 0);
});
test('domain changes select the domain and dependency closure', async () => {
const catalog = await loadScenarioCatalog();
const selection = selectImpactScenarios({
+1 -1
View File
@@ -22,7 +22,7 @@ const MENU = {
button: [
{
type: 'view',
name: 'Memind',
name: 'TKMind',
url: 'https://m.tkmind.cn',
},
{
+1 -1
View File
@@ -146,7 +146,7 @@ npm run check:mindspace-cover
## SEO / GEO 元数据(推荐)
平台在 memind_adm **MindSpace 配置 → SEO / GEO** 开启后,会对**已确认公开**`access_mode=public`用户已确认)的发布页自动注入 canonical、结构化数据等。Agent 仍应写好基础 meta,以提升搜索与 AI 引用质量:
平台在 memind_adm **MindSpace 配置 → SEO / GEO** 开启后,会对**公开在线**`access_mode=public` `status=online`)的发布页自动注入 canonical、结构化数据等。Agent 仍应写好基础 meta,以提升搜索与 AI 引用质量:
```html
<title>页面真实主题</title>
+1 -1
View File
@@ -521,6 +521,7 @@ export function ChatPanel({
: chatState === 'waiting'
? '请求已提交…'
: null;
const canSubmit = input.trim() || pendingImages.length > 0 || pendingFiles.length > 0;
const sendButtonLabel =
uploadingImage || uploadingFile
? '上传中…'
@@ -579,7 +580,6 @@ export function ChatPanel({
};
const voiceDisabled = inputBlocked || uploadingImage || uploadingFile;
const canSubmit = input.trim() || pendingImages.length > 0 || pendingFiles.length > 0;
const uploadDisabled = !canUpload || busy || uploadingImage || uploadingFile || offlineBlocked || !!pendingTool;
const revokePendingImage = (item: PendingChatImage) => {
+2 -1
View File
@@ -8,6 +8,7 @@ import {
CHAT_IMAGE_MAX_SIDE,
compressImageForUpload,
} from '../utils/imageUpload';
import { APP_DISPLAY_NAME } from '../utils/appBrand';
import { FeedbackPageHeader } from './FeedbackPageHeader';
import { VoiceInputButton } from './VoiceInputButton';
@@ -277,7 +278,7 @@ export function FeedbackSubmitView({
>
<div className="feedback-board-toolbar">
<div className="feedback-board-toolbar-copy">
<p className="feedback-submit-eyebrow"> Memind</p>
<p className="feedback-submit-eyebrow"> {APP_DISPLAY_NAME}</p>
<h1> Bug </h1>
<p className="feedback-submit-desc feedback-board-desc">
便
+1 -1
View File
@@ -1,5 +1,5 @@
/** User-facing product name shown in nav, login, and page chrome. */
export const APP_DISPLAY_NAME = 'Memind';
export const APP_DISPLAY_NAME = 'TKMind';
/** Branded tagline suffix used on welcome and marketing surfaces. */
export const APP_DISPLAY_TAGLINE = `${APP_DISPLAY_NAME} 智趣`;
+3 -2
View File
@@ -12,9 +12,10 @@ export const SYSTEM_DISCLOSURE_MODE = Object.freeze({
});
export const DEFAULT_SYSTEM_DISCLOSURE_REFUSAL =
'出于安全与隐私考虑,我不能提供 TKMind、Memind 或 MindSpace 的内部技术实现信息。我可以继续帮助你了解公开功能、使用方法、服务边界,或者讨论不针对本系统的一般性技术原理。';
'出于安全与隐私考虑,我不能提供 TKMind 或 MindSpace 的内部技术实现信息。我可以继续帮助你了解公开功能、使用方法、服务边界,或者讨论不针对本系统的一般性技术原理。';
const DEFAULT_PRODUCT_NAMES = Object.freeze([
'tikmind',
'tkmind',
'memind',
'mindspace',
@@ -42,7 +43,7 @@ const TECHNICAL_CATEGORY_PATTERNS = Object.freeze({
/(?:底层|内部|系统|技术|整体|服务|运行时|agent)\s*(?:架构|设计|实现|原理|机制)/iu,
/(?:架构|设计|实现|原理|机制)\s*(?:图|说明|细节|文档|方案|是什|怎么|如何)/iu,
/(?:技术栈|开发语言|编程语言|前端框架|后端框架|开源组件|内部组件|依赖版本|运行框架)/iu,
/(?:tkmind|memind|mindspace).{0,24}(?:关系|区别|协作|调用链|怎么工作|如何工作)/iu,
/(?:tikmind|tkmind|memind|mindspace).{0,24}(?:关系|区别|协作|调用链|怎么工作|如何工作)/iu,
/\b(?:architecture|internals?|implementation|system design|runtime design)\b/iu,
],
model_and_routing: [
+8 -1
View File
@@ -111,12 +111,14 @@ test('buildVisionPayload sends all current-turn images to Qwen in order', async
assert.equal(analyzedImages.length, 2);
assert.deepEqual(analyzedImages.map((item) => item.rawUrl), imageUrls);
assert.deepEqual(result?.userMessage?.metadata?.imageUrls, [
assert.equal(result?.userMessage?.metadata?.imageUrls, undefined);
assert.deepEqual(result?.userMessage?.metadata?.archivedImageUrls, [
'/MindSpace/user-1/public/wechat-mp/first.jpg',
'/MindSpace/user-1/public/wechat-mp/second.jpg',
]);
assert.match(result?.userMessage?.content?.[0]?.text ?? '', /本轮用户仅上传 2 张图片/);
assert.match(result?.userMessage?.content?.[0]?.text ?? '', /图片2/);
assert.doesNotMatch(result?.userMessage?.content?.[0]?.text ?? '', /\[图片\d+\]:/);
});
test('buildVisionPayload does not mark billable usage when vision analysis fails', async () => {
@@ -171,4 +173,9 @@ test('buildVisionPayload strips image_url content parts for text-only Goose prov
false,
);
assert.match(result?.userMessage?.content?.[0]?.text ?? '', /蓝色方块/);
assert.equal(result?.userMessage?.metadata?.imageUrls, undefined);
assert.deepEqual(result?.userMessage?.metadata?.archivedImageUrls, [
'/api/mindspace/v1/assets/asset-7/download?inline=1',
]);
assert.doesNotMatch(result?.userMessage?.content?.[0]?.text ?? '', /\[图片\d+\]:/);
});
+12 -8
View File
@@ -35,6 +35,7 @@ import { extractAttachmentText } from './mindspace-attachment-text.mjs';
import {
buildCurrentTurnImageScopeNote,
conversationHasImageUrlContent,
detachCurrentTurnImagesForTextProvider,
extractCurrentTurnImageUrls,
scrubConversationHistoricalImageAttachments,
} from './chat-image-turn-scope.mjs';
@@ -1004,15 +1005,18 @@ export async function buildVisionPayload({
.filter((url) => typeof url === 'string' && url.trim());
return {
userMessage: {
...userMessage,
content: updatedContent,
metadata: {
...(userMessage.metadata ?? {}),
...(originalDisplayText ? { displayText: originalDisplayText } : {}),
...(canonicalImageUrls.length ? { imageUrls: canonicalImageUrls } : {}),
userMessage: detachCurrentTurnImagesForTextProvider(
{
...userMessage,
content: updatedContent,
metadata: {
...(userMessage.metadata ?? {}),
...(originalDisplayText ? { displayText: originalDisplayText } : {}),
...(canonicalImageUrls.length ? { imageUrls: canonicalImageUrls } : {}),
},
},
},
canonicalImageUrls,
),
billableImageCount: visionDescription ? 1 : 0,
};
}
+2
View File
@@ -1707,6 +1707,8 @@ test('submitSessionReplyForUser applies the shared Qwen vision preprocessing pat
const forwardedText = replyBodies[0]?.user_message?.content?.[0]?.text ?? '';
assert.match(forwardedText, /Qwen VL 图片描述/);
assert.match(forwardedText, /一件蓝色产品/);
assert.equal(replyBodies[0]?.user_message?.metadata?.imageUrls, undefined);
assert.ok(Array.isArray(replyBodies[0]?.user_message?.metadata?.archivedImageUrls));
});
});
+26 -10
View File
@@ -1088,6 +1088,12 @@ export function isWechatPageDataTask(text) {
return isPageDataIntent(text) || isPageDataDevIntent(text);
}
export function isWechatHistoricalImageSessionError(message) {
return /historical_image_session_update_unsupported|unknown variant [`']?image_url/i.test(
String(message ?? '').trim(),
);
}
export function isRecoverableWechatAgentSessionError(message) {
const normalized = String(message ?? '').trim();
if (!normalized) return false;
@@ -1095,9 +1101,7 @@ export function isRecoverableWechatAgentSessionError(message) {
if (/wechat_page_fresh_thumbnail_required:/i.test(normalized)) return true;
if (/403|404|not found|无权访问/i.test(normalized)) return true;
if (/tool_calls|tool_call_id|insufficient tool messages/i.test(normalized)) return true;
if (/historical_image_session_update_unsupported|unknown variant [`']?image_url/i.test(normalized)) {
return true;
}
if (isWechatHistoricalImageSessionError(normalized)) return true;
if (/session already has an active request|active request.*cancel/i.test(normalized)) return true;
if (/wechat_agent_incomplete_reply/i.test(normalized)) return true;
return false;
@@ -1712,7 +1716,13 @@ export function createWechatMpService({
const attachRecentMediaForFollowup = (openid, intent, mediaAnalysisEnabled) => {
if (!mediaAnalysisEnabled || intent?.msgType !== 'text' || intent?.media?.publicUrl) return;
const text = String(intent?.agentText ?? '');
if (!/(?:刚才|之前|上一|这张|这份|图片|图像|照片|文件|文档|表格|excel|word)/iu.test(text)) return;
if (
!/(?:刚才|之前|上一|这张|这份|图片|图像|照片|文件|文档|表格|excel|word|报告|解读|化验|检验|做成页面|做个页面|做页面|生成页面)/iu.test(
text,
)
) {
return;
}
const key = String(openid ?? '').trim();
const recent = recentMediaByOpenid.get(key);
if (
@@ -3099,8 +3109,13 @@ export function createWechatMpService({
const mayBeStaleSession =
sessionId
&& (isRecoverableWechatAgentSessionError(message) || isWechatAgentApiErrorText(message));
const historicalImageError = isWechatHistoricalImageSessionError(message);
if (mayBeStaleSession) {
if (sessionPageContinuation && isWechatPageContinuationRepairableError(message)) {
if (
sessionPageContinuation
&& !historicalImageError
&& isWechatPageContinuationRepairableError(message)
) {
const text = buildPagePublishFailureText();
await userAuth.clearWechatAgentRoute(config.appId, inbound.fromUserName).catch((clearErr) => {
logger.warn?.('WeChat MP page continuation repair route clear failed:', clearErr);
@@ -3116,7 +3131,7 @@ export function createWechatMpService({
repairError.wechatAgentSessionId = sessionId;
throw repairError;
}
if (sessionPageContinuation) {
if (sessionPageContinuation && !historicalImageError) {
await userAuth.clearWechatAgentRoute(config.appId, inbound.fromUserName).catch((clearErr) => {
logger.warn?.('WeChat MP contextual follow-up route clear failed:', clearErr);
});
@@ -3144,17 +3159,18 @@ export function createWechatMpService({
await rememberWechatUserContext(sessionId, user, { forceBootstrap: route.isNewSession });
const retryId = crypto.randomUUID();
const retryStartedAt = Date.now();
const retryPageContinuation = sessionPageContinuation && !historicalImageError;
const retryPrompt =
wechatIntent.kind === 'page.generate'
? buildPageGenerateAgentPrompt(intent, {
wantsDocx: wechatIntent.wantsDocx,
imagePolicy,
preferImmediateContext: sessionPageContinuation,
carriedSessionContent,
preferImmediateContext: retryPageContinuation,
carriedSessionContent: retryPageContinuation ? carriedSessionContent : '',
})
: buildWechatAgentPrompt(intent, {
imagePolicy,
preferSessionPageContinuation: sessionPageContinuation,
preferSessionPageContinuation: retryPageContinuation,
});
const reply = await executeSessionReply(
(pathname, init) => fetchForSession(sessionId, pathname, init),
@@ -3171,7 +3187,7 @@ export function createWechatMpService({
userId: user.userId,
sessionId,
userMessage,
preserveAgentPrompt: sessionPageContinuation,
preserveAgentPrompt: retryPageContinuation,
}),
submitReply: submitSessionReply
? ({ requestId: replyRequestId, userMessage }) =>
+238
View File
@@ -13,6 +13,7 @@ import {
findRecoverableWechatAgentErrorInReply,
isRecoverableWechatAgentSessionError,
isWechatAgentApiErrorText,
isWechatHistoricalImageSessionError,
sanitizeWechatAgentOutboundText,
loadWechatMpConfig,
maybeAttachPublishedHtmlLink,
@@ -385,6 +386,13 @@ test('WeChat session page continuation covers retry, edit, and poem edits', () =
shouldDeliverWechatHtmlArtifacts(poemEdit, { agentText: '把诗里第三段改长一点' }),
true,
);
assert.equal(
isWechatSessionPageContinuation(
classifyWechatIntent({ msgType: 'text', agentText: '解读详细报告,做成页面' }),
'解读详细报告,做成页面',
),
false,
);
});
test('WeChat immediate-context page skips fresh thumbnail requirement', () => {
@@ -3477,6 +3485,16 @@ test('isRecoverableWechatAgentSessionError detects poisoned tool_calls history',
isRecoverableWechatAgentSessionError('historical_image_session_update_unsupported:405'),
true,
);
assert.equal(
isWechatHistoricalImageSessionError('historical_image_session_update_unsupported:405'),
true,
);
assert.equal(
isWechatHistoricalImageSessionError(
'Request failed: Bad request (400): messages[74]: unknown variant `image_url`, expected `text`',
),
true,
);
assert.equal(isRecoverableWechatAgentSessionError('network timeout'), false);
});
@@ -3572,6 +3590,113 @@ test('wechat mp rotates and retries when historical image isolation is unsupport
assert.equal(sentPayloads[0].text.content, '新会话已恢复,可以继续。');
});
test('wechat mp rotates page continuation instead of dropping image_url session errors', async () => {
const token = 'token';
const timestamp = '1710000000';
const nonce = 'nonce';
const submittedSessions = [];
const sentPayloads = [];
let activeSessionId = 'session-1';
let routeCleared = false;
const poem = `《临江仙·秋思》${'昨夜西风凋碧树,独上高楼,望尽天涯路。'.repeat(3)}`;
const service = createBoundWechatService({
token,
startAgentSession: async () => ({ id: 'session-2' }),
userAuth: {
async getWechatAgentRoute() {
return routeCleared ? null : { agentSessionId: activeSessionId, status: 'active' };
},
async clearWechatAgentRoute() {
routeCleared = true;
},
async upsertWechatAgentRoute({ agentSessionId }) {
activeSessionId = agentSessionId;
routeCleared = false;
},
},
submitSessionReply: async ({ sessionId, options }) => {
submittedSessions.push(sessionId);
assert.equal(options?.requireHistoricalImageIsolation, true);
if (sessionId === 'session-1') {
throw new Error(
'Ran into this error: Request failed: Bad request (400): Failed to deserialize the JSON body into the target type: messages[74]: unknown variant `image_url`, expected `text`',
);
}
return { ok: true };
},
sessionApiFetch: async (sessionId, pathname) => {
if (pathname === `/sessions/${sessionId}`) {
return new Response(JSON.stringify({
conversation: sessionId === 'session-1'
? [{ role: 'assistant', content: [{ type: 'text', text: poem }] }]
: [],
}), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
if (pathname === `/sessions/${sessionId}/events`) {
if (sessionId === 'session-1') {
return new Response('', {
status: 200,
headers: { 'Content-Type': 'text/event-stream' },
});
}
return new Response(
[
'data: {"type":"Message","request_id":"req-page-retry","message":{"id":"assistant-2","role":"assistant","metadata":{"userVisible":true},"content":[{"type":"text","text":"新会话已恢复,可以继续。"}]}}\n\n',
'data: {"type":"Finish","request_id":"req-page-retry","token_state":{"inputTokens":1,"outputTokens":2}}\n\n',
].join(''),
{ status: 200, headers: { 'Content-Type': 'text/event-stream' } },
);
}
if (pathname === '/agent/harness_remember' || pathname === '/agent/harness_bootstrap') {
return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } });
}
throw new Error(`unexpected api path: ${sessionId} ${pathname}`);
},
wechatFetch: async (url, init = {}) => {
if (String(url).includes('/cgi-bin/stable_token')) {
return new Response(JSON.stringify({ access_token: 'access-1', expires_in: 7200 }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
if (String(url).includes('/cgi-bin/message/custom/send')) {
sentPayloads.push(JSON.parse(init.body));
return new Response(JSON.stringify({ errcode: 0, errmsg: 'ok' }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
throw new Error(`unexpected wechat url: ${url}`);
},
});
const originalRandomUuid = crypto.randomUUID;
crypto.randomUUID = (() => {
const ids = ['req-page-first', 'req-page-retry'];
return () => ids.shift() ?? 'req-page-retry';
})();
try {
const result = await service.handleInboundMessage(
inboundXml({ content: '把刚才的诗做成页面' }),
{ timestamp, nonce, signature: signatureFor(token, timestamp, nonce) },
);
await result.task;
} finally {
crypto.randomUUID = originalRandomUuid;
}
assert.deepEqual(submittedSessions, ['session-1', 'session-2']);
assert.equal(activeSessionId, 'session-2');
assert.equal(
sentPayloads.some((payload) => /没能可靠确认/.test(String(payload?.text?.content ?? ''))),
false,
);
});
test('findRecoverableWechatAgentErrorInReply scans all assistant messages', () => {
const toolCallsError =
"Request failed: Bad request (400): An assistant message with 'tool_calls' must be followed by tool messages responding to each 'tool_call_id'. (insufficient tool messages following tool_calls message).";
@@ -5318,6 +5443,119 @@ test('wechat mp serializes image and follow-up text and reattaches recent image'
assert.equal(submitCalls[1].userMessage.metadata.msgType, 'text');
});
test('wechat mp reattaches recent image for report interpretation follow-up', async () => {
const token = 'token';
const timestamp = '1710000000';
const nonce = 'nonce';
const testUserId = 'test-user-report-followup';
const submitCalls = [];
let eventCall = 0;
let releaseFirst = null;
const service = createBoundWechatService({
token,
config: {
mediaAnalysisGrayUsers: [testUserId],
},
userAuth: {
async findWechatUserByOpenid() {
return { userId: testUserId, status: 'active', nickname: '唐' };
},
},
sessionApiFetch: async (_sessionId, pathname) => {
if (pathname === '/sessions/session-1/events') {
eventCall += 1;
if (eventCall === 1) {
return new Response(
new ReadableStream({
start(controller) {
releaseFirst = () => {
controller.enqueue(
new TextEncoder().encode(
'data: {"type":"Message","message":{"id":"assistant-image","role":"assistant","metadata":{"userVisible":true},"content":[{"type":"text","text":"图片已识别。"}]}}\n\n' +
'data: {"type":"Finish"}\n\n',
),
);
controller.close();
};
},
}),
{ status: 200, headers: { 'Content-Type': 'text/event-stream' } },
);
}
return new Response(
[
'data: {"type":"Message","message":{"id":"assistant-followup","role":"assistant","metadata":{"userVisible":true},"content":[{"type":"text","text":"已解读报告。"}]}}\n\n',
'data: {"type":"Finish"}\n\n',
].join(''),
{ status: 200, headers: { 'Content-Type': 'text/event-stream' } },
);
}
if (pathname === '/agent/harness_remember' || pathname === '/agent/harness_bootstrap') {
return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } });
}
throw new Error(`unexpected api path: ${pathname}`);
},
submitSessionReply: async (input) => {
submitCalls.push(input);
return { ok: true };
},
wechatFetch: async (url) => {
if (String(url).includes('/cgi-bin/stable_token')) {
return new Response(JSON.stringify({ access_token: 'access-1', expires_in: 7200 }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
if (String(url).includes('/cgi-bin/media/get')) {
return new Response(Buffer.from([0x89, 0x50, 0x4e, 0x47]), {
status: 200,
headers: { 'Content-Type': 'image/png' },
});
}
if (String(url).includes('/cgi-bin/message/custom/send')) {
return new Response(JSON.stringify({ errcode: 0, errmsg: 'ok' }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
throw new Error(`unexpected wechat url: ${url}`);
},
});
try {
const imageResult = await service.handleInboundMessage(
inboundXml({
msgType: 'image',
content: '',
extraFields: { MediaId: 'media-report', PicUrl: 'https://wx.example.com/report.png' },
}),
{ timestamp, nonce, signature: signatureFor(token, timestamp, nonce) },
);
while (submitCalls.length === 0) await new Promise((resolve) => setTimeout(resolve, 0));
const followupResult = await service.handleInboundMessage(
inboundXml({ msgType: 'text', content: '解读详细报告,做成页面' }),
{ timestamp, nonce, signature: signatureFor(token, timestamp, nonce) },
);
await new Promise((resolve) => setTimeout(resolve, 0));
assert.equal(submitCalls.length, 1);
releaseFirst();
await imageResult.task;
await followupResult.task;
} finally {
fs.rmSync(path.join(process.cwd(), 'MindSpace', testUserId), { recursive: true, force: true });
}
assert.equal(submitCalls.length, 2);
assert.deepEqual(
submitCalls[1].userMessage.metadata.imageUrls,
submitCalls[0].userMessage.metadata.imageUrls,
);
assert.equal(submitCalls[1].userMessage.metadata.msgType, 'text');
assert.equal(submitCalls[1].userMessage.metadata.displayText, '解读详细报告,做成页面');
});
test('wechat mp groups consecutive images for one follow-up and clears the consumed batch', async () => {
const token = 'token';
const timestamp = '1710000000';