Compare commits

..

19 Commits

Author SHA1 Message Date
john a9fe3d01a7 fix(release-gate): map canary rollback script to REL impact domain
Memind CI / Test, build, and release guards (push) Failing after 4m1s
rollback-portal-canary-prod.sh is part of the 103 canary workflow and must
not block impact selection when it changes alongside release scripts.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-14 22:54:26 +08:00
john c9b2fcba87 fix(wechat): treat report page follow-ups as session continuation
Memind CI / Test, build, and release guards (push) Has been cancelled
Phrases like "解读详细报告,做成页面" now bind to the in-session report
context instead of behaving like a brand-new page topic.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-14 22:53:00 +08:00
john b97f607817 fix(canary): drop Docker preflight on native 103 goosed
Memind CI / Test, build, and release guards (push) Failing after 2m35s
103 no longer runs goosed-prod Docker. Canary preflight now checks the
native 18006-18014 pool, and release-gate tests match 127.0.0.1 health probes.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-14 22:42:23 +08:00
john e59809eff4 fix(canary): share the native 103 goosed pool instead of Docker
Memind CI / Test, build, and release guards (push) Failing after 2m40s
103 retired Docker goosed on 2026-07-30. Portal canary now health-checks
18006-18014 and inherits those targets, instead of requiring goosed-prod-1.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-14 22:35:58 +08:00
john 19c8cdb970 test(wechat): give voice reco polling enough time in CI
Memind CI / Test, build, and release guards (push) Successful in 4m1s
The mocked second poll result was racing a 50ms deadline on a loaded
runner, so CI failed with an empty transcript.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-14 22:27:26 +08:00
john fd56f087c6 fix(canary): reuse the healthy shared DeepSeek proxy on 18036
Memind CI / Test, build, and release guards (push) Failing after 2m48s
Stable 103 already runs deepseek-no-think on 18036. Require that contract
instead of demanding a free port, so canary install does not fight production.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-14 22:23:07 +08:00
john 25be530d84 fix(release-gate): invalidate only the suite whose command file changed
Memind CI / Test, build, and release guards (push) Successful in 3m33s
A change to one run-release-gate script must not force every live and
upgrade suite to rerun.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-14 22:15:44 +08:00
john 2f240e7500 fix(release-gate): skip duplicate CI work and stop live LLM blowups
Shared paths like db.mjs were pulling PAGE/DATA live agent suites into every
hotfix. Keep those cases for actual page-data changes, resume passed suites on
the same artifact, and fail fast on Docker/port issues instead of rerunning 100+
scenarios.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-14 22:15:16 +08:00
john 293ac69a76 fix(release-gate): assert AI survey delivery instead of plan keywords
Memind CI / Test, build, and release guards (push) Successful in 4m16s
DATA-01 told the agent not to stop at 方案确认, then failed because the first
reply no longer contains those words. Assert 问卷/后台 delivery instead.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-14 22:00:53 +08:00
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
49 changed files with 1276 additions and 139 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'
+169
View File
@@ -3,6 +3,66 @@
本文件记录已经完成迁移、但仍可能因为 Git 拓扑或遗留 worktree 被误判为“尚未进入 `main`”的分支。
它是分支复用、合并、cherry-pick 和清理前的必查清单。
## `feature/gate-data01-delivery-keywords`
**状态:禁止再次引用。改动已提交并进入 `origin/main`,该分支保留仅用于只读追溯,不是待合并开发分支。**
审计日期:2026-08-14
分支 HEAD`293ac69a`
`origin/main` 对应提交:`293ac69a`
### 原始用途
DATA-01 提示要求不要停在方案确认,却仍断言回复包含「方案/确认」。改为断言「问卷/后台」交付,并登记微信 image_url 分支处置。
### 最终处置
- 保留本地分支名用于审计追溯。
- 不要从该分支继续开发、merge、cherry-pick 或构建 runtime/artifact。
## `feature/wechat-image-url-session-rotate`
**状态:禁止再次引用。改动已提交并进入 `origin/main`,该分支保留仅用于只读追溯,不是待合并开发分支。**
审计日期:2026-08-14
分支 HEAD`41f245c8`
`origin/main` 对应提交:`41f245c8`(其后主线继续快进了图片额度回填提交)
### 原始用途
Goose native 对历史图片清洗返回 PUT 405DeepSeek 下一轮拒绝 `image_url`。微信「解读详细报告,做成页面」跟进应换新会话并重新附上报告图,且 VL 后不再把 `imageUrls` 传给文本模型。
### 验证摘要
- `node --test wechat-mp.test.mjs`94 passed
- `node --test chat-image-turn-scope.test.mjs tkmind-proxy-vision.test.mjs tkmind-proxy.test.mjs`53 passed
### 最终处置
- 保留本地分支名用于审计追溯。
- 不要从该分支继续开发。
- 不要 merge、cherry-pick 该分支提交或从该分支构建 runtime/artifact。
- 后续开发必须从最新 `origin/main` 新建分支。
## `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,6 +587,115 @@ 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`,该分支保留仅用于只读追溯。**
@@ -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`
本地验证:
+9
View File
@@ -63,7 +63,16 @@
Core Gate 之外,选择器以当前 103 manifest 中的 `git_head` 为 base commit,比较候选
commit 的 changed paths,并按 `release-gate/impact.mjs` 的版本化规则选择业务域和依赖闭包。
`PAGE-01``PAGE-02``DATA-01``DATA-04` 是真 LLM 交付场景:只在 Page Data / 页面交付
产品代码变更时选中。`db.mjs``server.mjs` 等共享路径仍展开 DATA/PAGE 的确定性套件,
但不自动展开上述真 LLM 场景。文件名含 `image` 不等于 IMGPGIMGPG 只匹配
image-generation / imgproxy / thumbnail / user-image-url 等产品路径。
正常风险分层报告中的被选场景必须真实执行,不能标记为 `not_applicable`
同一 artifact SHA 下允许续跑:已通过的 suite 可携带证据,失败项、命令文件变更的 suite、
以及新 commit 上的真 LLM suite 必须重跑。端口占用或 Docker 未就绪必须在 suite 开始前预检失败,
不得把环境问题记成业务场景失败后再整轮重来。
Gitea CI 对同一 `origin/main` SHA 为 `success` 时,发布脚本可跳过与 CI 重复的本地
`npm test` / verify;禁止 `--skip-tests`,也不得跳过 Gate report。
### 3.3 关键路径与阻断规则
+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 发帖共用该开关。
## 必跑验证
+5 -3
View File
@@ -67,9 +67,11 @@ node scripts/run-release-gate-impact.mjs --artifact .runtime/portal --deployed-c
它固定执行 16 项核心场景,再根据 `<103-stable-sha>..HEAD` 的 changed paths 选择业务域及
依赖闭包。`server.mjs`、鉴权/会话基础设施、schema/migration、依赖、runtime 构建、
生产启动/发布脚本和 Gate 自身会展开到预定义影响域;未映射运行时代码直接失败。发布脚本在有效报告
缺失或过期时自动执行该入口,不再要求人工先跑多个 mode 或逐项填写 129 条豁免
离线 `--dry-run` 不连接 103;如需模拟风险分层,可设置
生产启动/发布脚本和 Gate 自身会展开到预定义影响域;未映射运行时代码直接失败。
`PAGE-01`/`PAGE-02`/`DATA-01``DATA-04` 只在页面交付或 Page Data 产品代码变更时选中
同一 artifact 的 impact 报告可续跑已通过 suite;失败项与真 LLM suite 在新 commit 上重跑。
发布脚本在有效报告缺失或过期时自动执行该入口,并在 Gitea CI `success` 时跳过与 CI 重复的
本地 npm test/verify。离线 `--dry-run` 不连接 103;如需模拟风险分层,可设置
`MEMIND_RELEASE_BASE_COMMIT=<known-stable-sha>`;未提供有效基线时直接阻断。
2026-07-26 本地补齐验证中,历史完整报告为 180/187 通过;`REL-01` 因当前仍在功能
+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: {
@@ -82,12 +82,12 @@ test('Gate, artifact, candidate routing and rollback share one compatibility con
assert.match(stableRunner, /export MEMIND_DEEPSEEK_DISABLE_THINKING="\$\{MEMIND_DEEPSEEK_DISABLE_THINKING:-1\}"/);
assert.match(stableRunner, /export MEMIND_GOOSED_HOST_GATEWAY="\$\{MEMIND_GOOSED_HOST_GATEWAY:-host\.docker\.internal\}"/);
assert.match(candidateRunner, /export MEMIND_DEEPSEEK_DISABLE_THINKING=1/);
assert.match(candidateRunner, /export MEMIND_GOOSED_HOST_GATEWAY=host\.docker\.internal/);
assert.match(candidateRunner, /MEMIND_CANARY_GOOSED_URL/);
assert.match(compatRunner, /source "\$\{STABLE_ROOT\}\/\.env"/);
assert.match(compatRunner, /export MEMIND_DEEPSEEK_PROXY_ENTRYPOINT=1/);
assert.match(canaryRelease, /run-deepseek-compat-proxy-candidate\.sh/);
assert.match(canaryRelease, /MEMIND_CANARY_CANDIDATE_HEALTH_URLS/);
assert.match(canaryRelease, /host\.docker\.internal:\$\{DEEPSEEK_COMPAT_PORT\}\/health/);
assert.match(canaryRelease, /http:\/\/127\.0\.0\.1:\$\{DEEPSEEK_COMPAT_PORT\}\/health/);
assert.match(canaryRelease, /bootout.*DEEPSEEK_COMPAT_LABEL/);
assert.match(canaryRollback, /bootout.*DEEPSEEK_COMPAT_LABEL/);
});
+35 -4
View File
@@ -17,6 +17,25 @@ export const CORE_SCENARIO_IDS = Object.freeze([
'COMP-09',
]);
// Live LLM product scenarios. Dependency closure from db.mjs / shared infra
// still selects the DATA/PAGE deterministic suites; these four-to-six cases
// only run when page-delivery or Page Data product code actually changed.
export const LIVE_LLM_SCENARIO_IDS = Object.freeze([
'PAGE-01',
'PAGE-02',
'DATA-01',
'DATA-02',
'DATA-03',
'DATA-04',
]);
const LIVE_LLM_PATH_PATTERNS = Object.freeze([
/(?:^|\/)page-data-[^/]+\.mjs$/i,
/(?:^|\/)mindspace-public-finish-sync\.mjs$/i,
/(?:^|\/)mindspace-page-data[^/]*\.mjs$/i,
/^scripts\/run-release-gate-page(?:-data)?-scenarios\.mjs$/i,
]);
const CRITICAL_IMPACT_RULES = Object.freeze([
{
groups: ['AGENT', 'CFG', 'UI'],
@@ -28,7 +47,7 @@ const CRITICAL_IMPACT_RULES = Object.freeze([
},
{
groups: ['CFG', 'REL'],
pattern: /^(?:release-gate\/|scripts\/(?:build-portal-runtime|check-release-ready|release-|run-release-gate|verify-release-gate|verify-canary-))/i,
pattern: /^(?:release-gate\/|scripts\/(?:build-portal-runtime|check-release-ready|release-|rollback-portal-canary-prod|run-release-gate|verify-release-gate|verify-canary-))/i,
},
{
groups: ['AGENT', 'CFG', 'REL'],
@@ -58,6 +77,7 @@ const NON_RUNTIME_PATHS = Object.freeze([
/^\.gitea\/workflows\//i,
/^\.runtime\//i,
/^docs\//i,
/^scenarios\//i,
/^\.cursor\//i,
/^\.codex\//i,
/^scripts\/dev(?:-|\.|\/)/i,
@@ -79,7 +99,7 @@ const IMPACT_RULES = Object.freeze([
{ groups: ['SCHED'], pattern: /(?:schedule|scheduler|reminder|cron)/i },
{ groups: ['SEARCH'], pattern: /(?:search|weather|market|news-provider)/i },
{ groups: ['XLS'], pattern: /(?:excel|xlsx|spreadsheet)/i },
{ groups: ['IMGPG'], pattern: /(?:image|thumbnail|cover|imgproxy)/i },
{ groups: ['IMGPG'], pattern: /(?:image-to-page|image-generation|imgproxy|thumbnails?|user-image-url|plaza-cover)/i },
{ groups: ['FILE'], pattern: /(?:file|attachment|upload|document|pdf|docx|csv)/i },
{ groups: ['MS'], pattern: /mindspace/i },
{ groups: ['PAGE'], pattern: /(?:public-(?:page|finish)|published-page|publication|page-delivery|mindspace-public)/i },
@@ -88,7 +108,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({
@@ -114,6 +134,13 @@ function matchesAny(patterns, relativePath) {
return patterns.some((pattern) => pattern.test(relativePath));
}
export function pathTriggersLiveLlmScenarios(changedPaths) {
return normalizePaths(changedPaths).some((relativePath) => (
!matchesAny(NON_RUNTIME_PATHS, relativePath)
&& LIVE_LLM_PATH_PATTERNS.some((pattern) => pattern.test(relativePath))
));
}
function closeGroupDependencies(initialGroups) {
const groups = new Set(initialGroups);
const pending = [...groups];
@@ -172,10 +199,13 @@ export function selectImpactScenarios({
}
const impactGroups = closeGroupDependencies(directGroups);
const strategy = impactGroups.length > 0 ? 'impact' : 'core';
const includeLiveLlm = pathTriggersLiveLlmScenarios(normalizedPaths);
const selected = new Set(CORE_SCENARIO_IDS);
for (const scenario of catalog) {
if (impactGroups.includes(scenario.group)) selected.add(scenario.id);
if (!impactGroups.includes(scenario.group)) continue;
if (!includeLiveLlm && LIVE_LLM_SCENARIO_IDS.includes(scenario.id)) continue;
selected.add(scenario.id);
}
const selectedIds = catalog
@@ -193,5 +223,6 @@ export function selectImpactScenarios({
full_gate_reasons: [],
selected_ids: selectedIds,
selected_total: selectedIds.length,
live_llm_selected: includeLiveLlm,
};
}
+58
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({
@@ -43,6 +54,10 @@ test('critical paths expand mapped domains and unmapped paths block release', as
critical.impact_groups,
['AGENT', 'AUTH', 'CFG', 'CHAT', 'DATA', 'FILE', 'MS', 'PAGE'],
);
assert.equal(critical.live_llm_selected, false);
assert.equal(critical.selected_ids.includes('DATA-01'), false);
assert.equal(critical.selected_ids.includes('PAGE-01'), false);
assert.equal(critical.selected_ids.includes('DATA-06'), true);
assert.deepEqual(critical.full_gate_reasons, []);
assert.throws(
@@ -62,6 +77,49 @@ test('critical paths expand mapped domains and unmapped paths block release', as
);
});
test('chat image turn-scope does not expand the image-to-page live domain', async () => {
const catalog = await loadScenarioCatalog();
const selection = selectImpactScenarios({
catalog,
changedPaths: ['chat-image-turn-scope.mjs', 'wechat-mp.mjs'],
});
assert.equal(selection.impact_groups.includes('IMGPG'), false);
assert.equal(selection.selected_ids.includes('PAGE-01'), false);
assert.equal(selection.selected_ids.includes('DATA-01'), false);
assert.equal(selection.selected_ids.includes('WX-01'), true);
assert.equal(selection.selected_ids.includes('CHAT-01'), true);
});
test('Page Data product code still selects live LLM DATA scenarios', async () => {
const catalog = await loadScenarioCatalog();
const selection = selectImpactScenarios({
catalog,
changedPaths: ['page-data-routes.mjs'],
});
assert.equal(selection.live_llm_selected, true);
assert.equal(selection.selected_ids.includes('DATA-01'), true);
assert.equal(selection.selected_ids.includes('DATA-06'), true);
});
test('image-generation files still map to IMGPG', async () => {
const catalog = await loadScenarioCatalog();
const selection = selectImpactScenarios({
catalog,
changedPaths: ['mindspace-image-generation.mjs'],
});
assert.equal(selection.impact_groups.includes('IMGPG'), true);
});
test('scenario fixtures are non-runtime and do not block mapping', async () => {
const catalog = await loadScenarioCatalog();
const selection = selectImpactScenarios({
catalog,
changedPaths: ['scenarios/ai-usage-survey.json', 'docs/branch-disposition.md'],
});
assert.equal(selection.strategy, 'core');
assert.equal(selection.live_llm_selected, false);
});
test('release policy changes use mapped REL and CFG domains without selecting the catalog', async () => {
const catalog = await loadScenarioCatalog();
const selection = selectImpactScenarios({
+79
View File
@@ -0,0 +1,79 @@
import fs from 'node:fs';
import net from 'node:net';
import { spawnSync } from 'node:child_process';
export const GATE_SUITE_PORTS = Object.freeze({
'runtime-sanitized-data-upgrade': 19085,
'page-data-product-scenarios': 19086,
'page-content-delivery-scenarios': 19087,
'runtime-production-homolog-cold-start': 19081,
});
const DOCKER_SOCKET_CANDIDATES = [
process.env.DOCKER_HOST?.replace(/^unix:\/\//, ''),
`${process.env.HOME ?? ''}/.docker/run/docker.sock`,
'/var/run/docker.sock',
].filter(Boolean);
export function dockerSocketExists() {
return DOCKER_SOCKET_CANDIDATES.some((socketPath) => {
try {
return fs.existsSync(socketPath);
} catch {
return false;
}
});
}
export function assertDockerDaemonAvailable() {
if (!dockerSocketExists()) {
throw new Error(
'Release gate REL-11 needs Docker; start Docker Desktop and retry. Missing docker.sock.',
);
}
const result = spawnSync('docker', ['info'], {
encoding: 'utf8',
timeout: 15_000,
});
if (result.status !== 0) {
throw new Error(
`Release gate REL-11 needs a running Docker daemon: ${(result.stderr || result.error?.message || 'docker info failed').trim().slice(0, 500)}`,
);
}
}
export function assertLoopbackPortAvailable(port, host = '127.0.0.1') {
return new Promise((resolve, reject) => {
const server = net.createServer();
server.once('error', (error) => {
if (error?.code === 'EADDRINUSE') {
reject(new Error(
`Release gate port ${host}:${port} is already in use; stop the stale local gate process before retrying`,
));
return;
}
reject(error);
});
server.once('listening', () => {
server.close((closeError) => {
if (closeError) reject(closeError);
else resolve();
});
});
server.listen(port, host);
});
}
export async function preflightImpactSuites(suites) {
const ports = [...new Set(
suites
.map((suite) => GATE_SUITE_PORTS[suite.id])
.filter((port) => Number.isInteger(port)),
)].sort((left, right) => left - right);
for (const port of ports) {
await assertLoopbackPortAvailable(port);
}
if (suites.some((suite) => suite.id === 'runtime-linux-dependency-closure')) {
assertDockerDaemonAvailable();
}
}
+23
View File
@@ -0,0 +1,23 @@
import assert from 'node:assert/strict';
import net from 'node:net';
import test from 'node:test';
import { GATE_SUITE_PORTS, assertLoopbackPortAvailable } from './preflight.mjs';
test('gate suites pin isolated loopback ports', () => {
assert.equal(GATE_SUITE_PORTS['runtime-sanitized-data-upgrade'], 19085);
assert.equal(GATE_SUITE_PORTS['page-data-product-scenarios'], 19086);
assert.equal(GATE_SUITE_PORTS['page-content-delivery-scenarios'], 19087);
});
test('assertLoopbackPortAvailable rejects an occupied port', async () => {
const server = net.createServer();
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
const { port } = server.address();
await assert.rejects(
() => assertLoopbackPortAvailable(port),
/already in use/,
);
await new Promise((resolve) => server.close(resolve));
await assertLoopbackPortAvailable(port);
});
+13 -6
View File
@@ -143,6 +143,10 @@ test('production canary verifies the exact Gate artifact before any 103 prefligh
assert.match(source, /run-release-gate-impact\.mjs/);
assert.match(source, /MEMIND_RELEASE_BASE_COMMIT/);
assert.match(source, /-z "\$\{DEPLOYED_SHA\}".*"\$\{DRY_RUN\}" -ne 1/);
assert.match(source, /resolve-release-ci-status\.mjs/);
assert.match(source, /skipping duplicate local npm test\/verify/);
const ciSkipIndex = source.indexOf('resolve-release-ci-status.mjs');
assert.ok(ciSkipIndex > 0 && ciSkipIndex < gateIndex, 'CI reuse must happen before Gate verification');
});
test('production canary keeps stable 8081 live and switches only after verified backups and fallback', async () => {
@@ -150,11 +154,12 @@ test('production canary keeps stable 8081 live and switches only after verified
const fullBackup = source.indexOf('Create and verify the full stable backup');
const persistBackup = source.indexOf('Create and verify the persisted-data backup');
const edgeBackup = source.indexOf('Create and verify the active 105 nginx routing backup');
const goosedStart = source.indexOf('Start an isolated goosed candidate on 18015');
const goosedStart = source.indexOf('Use the native goosed pool 18006-18014');
const candidateStart = source.indexOf('Start the passive candidate Portal on 18081');
const deepseekCompatStart = source.indexOf(
'Start the DeepSeek tool-round compatibility proxy on 18036',
);
assert.match(source, /Reuse the already healthy DeepSeek compatibility proxy/);
const proxyStart = source.indexOf('Start the fail-closed identity router on 18082');
const tunnelStart = source.indexOf('Start the isolated 105 reverse tunnel on 19082');
const edgeSwitch = source.indexOf(
@@ -178,6 +183,8 @@ test('production canary keeps stable 8081 live and switches only after verified
assert.match(source, /CANARY_PROXY_PORT=18082/);
assert.match(source, /CANARY_TUNNEL_REMOTE_PORT=19082/);
assert.match(source, /DEEPSEEK_COMPAT_PORT=18036/);
assert.match(source, /Use the native goosed pool 18006-18014/);
assert.doesNotMatch(source, /docker inspect goosed-prod-1/);
assert.match(source, /deepseek-no-think-proxy\.mjs/);
assert.match(source, /run-deepseek-compat-proxy-candidate\.sh/);
assert.match(source, /MEMIND_CANARY_CANDIDATE_HEALTH_URLS/);
@@ -216,7 +223,7 @@ test('production canary keeps stable 8081 live and switches only after verified
assert.doesNotMatch(source, /bootout.*cn\.tkmind\.memind-portal/);
});
test('candidate runner overrides stable host MCP paths with container-visible paths', async (t) => {
test('candidate runner inherits native goosed MCP paths from the stable root', async (t) => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'memind-canary-runner-'));
t.after(() => fs.rm(tempRoot, { recursive: true, force: true }));
@@ -284,14 +291,14 @@ test('candidate runner overrides stable host MCP paths with container-visible pa
assert.equal(result.status, 0, result.stderr);
assert.match(result.stdout, new RegExp(`^cwd=${ROOT}$`, 'm'));
assert.match(result.stdout, /^mcp_node=\/usr\/local\/bin\/node$/m);
assert.match(result.stdout, /^mcp_node=\/opt\/homebrew\/opt\/node@24\/bin\/node$/m);
assert.match(
result.stdout,
/^mcp_server=\/opt\/portal\/mindspace-sandbox-mcp\.mjs$/m,
/^mcp_server=\/Users\/john\/Project\/Memind\/mindspace-sandbox-mcp\.mjs$/m,
);
assert.match(result.stdout, /^deepseek_disable=1$/m);
assert.match(result.stdout, /^deepseek_port=18036$/m);
assert.match(result.stdout, /^deepseek_gateway=host\.docker\.internal$/m);
assert.match(result.stdout, /^deepseek_gateway=wrong\.invalid$/m);
assert.match(result.stdout, /^deepseek_base=unset$/m);
assert.match(result.stdout, /^deepseek_host=unset$/m);
assert.match(result.stdout, /^page_data_review=1$/m);
@@ -310,7 +317,7 @@ test('canary rollback stops the DeepSeek compatibility process with the other ca
/DEEPSEEK_COMPAT_LABEL="cn\.tkmind\.memind-deepseek-compat-candidate"/,
);
assert.match(source, /bootout.*DEEPSEEK_COMPAT_LABEL/);
assert.match(source, /docker rm -f goosed-prod-canary/);
assert.doesNotMatch(source, /docker rm -f goosed-prod-canary/);
});
test('DeepSeek compatibility runner preserves stable upstream config but enforces candidate controls', async (t) => {
+88
View File
@@ -0,0 +1,88 @@
import fs from 'node:fs/promises';
import path from 'node:path';
const REPOSITORY_CHECK_IDS = new Set(['REL-01', 'REL-02', 'REL-04']);
export function commandFilesForSuite(suite) {
return (suite.command ?? [])
.slice(1)
.filter((arg) => typeof arg === 'string' && !arg.startsWith('-'))
.map((arg) => arg.replaceAll('\\', '/'));
}
export function suiteInvalidatedByChanges(suite, changedPaths) {
const normalized = new Set((changedPaths ?? []).map((item) => String(item).replaceAll('\\', '/')));
if (normalized.has('release-gate/coverage.mjs')) return true;
return commandFilesForSuite(suite).some((filePath) => normalized.has(filePath));
}
export function previousScenarioMap(report) {
return new Map((report?.scenarios ?? []).map((scenario) => [scenario.id, scenario]));
}
export function shouldRerunImpactSuite({
suite,
selectedIds,
previousReport,
artifactSha256,
commitSha,
changedPathsSincePrevious = [],
}) {
const selectedInSuite = suite.scenarios.filter((scenarioId) => selectedIds.has(scenarioId));
if (selectedInSuite.length === 0) return false;
if (!previousReport || previousReport.artifact_sha256 !== artifactSha256) return true;
if (suiteInvalidatedByChanges(suite, changedPathsSincePrevious)) return true;
if (suite.mode === 'scenarios' && previousReport.commit_sha !== commitSha) return true;
const previous = previousScenarioMap(previousReport);
return selectedInSuite.some((scenarioId) => previous.get(scenarioId)?.status !== 'passed');
}
export function carryForwardScenario(previousScenario) {
return {
...previousScenario,
evidence: [
...(previousScenario.evidence ?? []),
'carried_forward=true',
],
};
}
export async function findImpactResumeReport({
reportRoot,
artifactSha256,
commitSha,
}) {
let entries = [];
try {
entries = await fs.readdir(reportRoot, { withFileTypes: true });
} catch (error) {
if (error.code === 'ENOENT') return null;
throw error;
}
const candidates = [];
for (const entry of entries) {
if (!entry.isDirectory() || entry.name === 'local') continue;
const reportPath = path.join(reportRoot, entry.name, 'report.json');
try {
const report = JSON.parse(await fs.readFile(reportPath, 'utf8'));
if (report?.mode !== 'impact') continue;
if (report.artifact_sha256 !== artifactSha256) continue;
if (!Array.isArray(report.scenarios)) continue;
candidates.push({ report, reportPath, commitSha: report.commit_sha });
} catch {
// ignore unreadable reports
}
}
if (candidates.length === 0) return null;
const sameCommit = candidates.find((candidate) => candidate.commitSha === commitSha);
if (sameCommit) return sameCommit;
candidates.sort((left, right) => (
Date.parse(right.report.completed_at ?? 0) - Date.parse(left.report.completed_at ?? 0)
));
return candidates[0];
}
export { REPOSITORY_CHECK_IDS };
+107
View File
@@ -0,0 +1,107 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
carryForwardScenario,
commandFilesForSuite,
shouldRerunImpactSuite,
suiteInvalidatedByChanges,
} from './resume.mjs';
const chatSuite = {
id: 'chat-routing-contract',
mode: 'deterministic',
scenarios: ['CHAT-01', 'CHAT-02'],
command: [process.execPath, '--test', 'chat-router.test.mjs'],
};
const liveSuite = {
id: 'page-data-product-scenarios',
mode: 'scenarios',
scenarios: ['DATA-01', 'DATA-02'],
command: [process.execPath, 'scripts/run-release-gate-page-data-scenarios.mjs'],
};
const previousReport = {
mode: 'impact',
commit_sha: 'a'.repeat(40),
artifact_sha256: 'b'.repeat(64),
scenarios: [
{ id: 'CHAT-01', status: 'passed', evidence: ['suite=chat-routing-contract'] },
{ id: 'CHAT-02', status: 'passed', evidence: ['suite=chat-routing-contract'] },
{ id: 'DATA-01', status: 'failed', evidence: ['suite=page-data-product-scenarios'] },
{ id: 'DATA-02', status: 'passed', evidence: ['suite=page-data-product-scenarios'] },
],
};
test('commandFilesForSuite skips node and flags', () => {
assert.deepEqual(
commandFilesForSuite({ command: [process.execPath, '--test', 'chat-router.test.mjs'] }),
['chat-router.test.mjs'],
);
});
test('same commit and artifact skips passed suites and reruns failed live suites', () => {
const selectedIds = new Set(['CHAT-01', 'CHAT-02', 'DATA-01', 'DATA-02']);
assert.equal(shouldRerunImpactSuite({
suite: chatSuite,
selectedIds,
previousReport,
artifactSha256: previousReport.artifact_sha256,
commitSha: previousReport.commit_sha,
}), false);
assert.equal(shouldRerunImpactSuite({
suite: liveSuite,
selectedIds,
previousReport,
artifactSha256: previousReport.artifact_sha256,
commitSha: previousReport.commit_sha,
}), true);
});
test('artifact mismatch forces every selected suite to rerun', () => {
assert.equal(shouldRerunImpactSuite({
suite: chatSuite,
selectedIds: new Set(['CHAT-01', 'CHAT-02']),
previousReport,
artifactSha256: 'c'.repeat(64),
commitSha: previousReport.commit_sha,
}), true);
});
test('live suites rerun on a new commit even when previous cases passed', () => {
const passedLive = {
...previousReport,
scenarios: [
{ id: 'DATA-01', status: 'passed' },
{ id: 'DATA-02', status: 'passed' },
],
};
assert.equal(shouldRerunImpactSuite({
suite: liveSuite,
selectedIds: new Set(['DATA-01', 'DATA-02']),
previousReport: passedLive,
artifactSha256: passedLive.artifact_sha256,
commitSha: 'd'.repeat(40),
}), true);
});
test('changing a suite command file invalidates that suite', () => {
assert.equal(suiteInvalidatedByChanges(chatSuite, ['chat-router.test.mjs']), true);
assert.equal(suiteInvalidatedByChanges(chatSuite, ['wechat-mp.mjs']), false);
assert.equal(suiteInvalidatedByChanges(chatSuite, ['release-gate/coverage.mjs']), true);
assert.equal(
suiteInvalidatedByChanges(liveSuite, ['scripts/run-release-gate-runtime-container.mjs']),
false,
);
assert.equal(
suiteInvalidatedByChanges(liveSuite, ['scripts/run-release-gate-page-data-scenarios.mjs']),
true,
);
});
test('carryForwardScenario keeps prior evidence and marks reuse', () => {
const carried = carryForwardScenario({ id: 'CHAT-01', status: 'passed', evidence: ['old'] });
assert.equal(carried.status, 'passed');
assert.deepEqual(carried.evidence, ['old', 'carried_forward=true']);
});
+54 -5
View File
@@ -6,7 +6,14 @@ import { assertPortalRuntimePath, hashArtifact, inspectPortalRuntime } from './a
import { loadScenarioCatalog } from './catalog.mjs';
import { AUTOMATION_SUITES, validateAutomationSuites } from './coverage.mjs';
import { selectImpactScenarios } from './impact.mjs';
import { preflightImpactSuites } from './preflight.mjs';
import { loadActiveRegressionCorpus } from './regression-corpus.mjs';
import {
REPOSITORY_CHECK_IDS,
carryForwardScenario,
findImpactResumeReport,
shouldRerunImpactSuite,
} from './resume.mjs';
import {
buildIncrementalReport,
findCarryForwardBaseline,
@@ -419,24 +426,61 @@ export async function executeImpactReleaseGate(options) {
const suites = AUTOMATION_SUITES.filter(
(suite) => suite.scenarios.some((scenarioId) => selectedIds.has(scenarioId)),
);
const resume = await findImpactResumeReport({
reportRoot: options.reportRoot,
artifactSha256: artifact.sha256,
commitSha,
});
let changedPathsSincePrevious = [];
if (resume?.commitSha && resume.commitSha !== commitSha) {
try {
changedPathsSincePrevious = await listChangedPathsBetween(resume.commitSha, commitSha);
} catch {
changedPathsSincePrevious = ['release-gate/coverage.mjs'];
}
}
const suitesToRun = suites.filter((suite) => shouldRerunImpactSuite({
suite,
selectedIds,
previousReport: resume?.report ?? null,
artifactSha256: artifact.sha256,
commitSha,
changedPathsSincePrevious,
}));
const skippedSuites = suites.filter((suite) => !suitesToRun.includes(suite));
const previousById = new Map((resume?.report?.scenarios ?? []).map((scenario) => [scenario.id, scenario]));
for (const suite of skippedSuites) {
for (const scenarioId of suite.scenarios) {
if (!selectedIds.has(scenarioId) || REPOSITORY_CHECK_IDS.has(scenarioId)) continue;
const previous = previousById.get(scenarioId);
if (!previous) continue;
const current = byId.get(scenarioId);
Object.assign(current, carryForwardScenario(previous));
}
}
if (suitesToRun.length > 0) {
await preflightImpactSuites(suitesToRun);
}
const executions = await runSuitesWithConcurrency(
suites,
suitesToRun,
options.suiteConcurrency,
(suite) => runSuite(suite, outputDir, options.timeoutMs),
);
for (let index = 0; index < suites.length; index += 1) {
const suite = suites[index];
for (let index = 0; index < suitesToRun.length; index += 1) {
const suite = suitesToRun[index];
const execution = executions[index];
for (const scenarioId of suite.scenarios) {
if (!selectedIds.has(scenarioId)) continue;
const scenario = byId.get(scenarioId);
scenario.status = execution.code === 0 && !execution.timedOut ? 'passed' : 'failed';
scenario.reason = scenario.status === 'passed' ? null : 'automation_suite_failed';
scenario.evidence.push(
scenario.evidence = [
`suite=${suite.id}`,
`log=${execution.logPath}`,
...suite.cases[scenarioId].map((assertedCase) => `asserted_case=${assertedCase}`),
);
'carried_forward=false',
];
}
}
@@ -457,6 +501,11 @@ export async function executeImpactReleaseGate(options) {
startedAt,
completedAt,
});
report.resume = {
baseline_commit: resume?.commitSha ?? null,
carried_suites: skippedSuites.map((suite) => suite.id),
reran_suites: suitesToRun.map((suite) => suite.id),
};
await writeGateReport(report, outputDir);
return { report, outputDir, selection };
}
+1 -1
View File
@@ -19,7 +19,7 @@
"expect": {
"assistantMinChars": 80,
"timeoutMs": 600000,
"replyKeywords": ["方案", "确认"],
"replyKeywords": ["问卷", "后台"],
"forbidReplyPatterns": ["8899", "127.0.0.1:", "PLACEHOLDER_PAGE_ID", "survey-api"]
}
},
+30 -45
View File
@@ -50,8 +50,8 @@ Deploys a candidate Portal beside the stable 103 runtime:
stable Portal 127.0.0.1:8081
canary router 127.0.0.1:18082
candidate Portal 127.0.0.1:18081
candidate goosed 127.0.0.1:18015
DeepSeek compat 0.0.0.0:18036 (host/container only)
native goosed 127.0.0.1:18006-18014
DeepSeek compat 0.0.0.0:18036 (host only; reused if already healthy)
The stable runtime is not replaced. A dedicated reverse tunnel exposes the
router only to 105 at 127.0.0.1:19082. The committed release workflow updates
@@ -116,15 +116,19 @@ fi
ALLOW_MAIN_RELEASE=1 bash "${ROOT}/scripts/check-release-ready.sh" --skip-fetch
say "Run release source guards"
(
cd "${ROOT}"
npm test -- --test-name-pattern='publish|space|billing' >/dev/null
npm run verify:mindspace-publish-guards >/dev/null
npm run verify:mindspace-page-sync-guards >/dev/null
npm run verify:h5-session-patches >/dev/null
npm run verify:page-data >/dev/null
npm run check:mindspace-public-links >/dev/null
)
if node "${ROOT}/scripts/resolve-release-ci-status.mjs" --commit "${FULL_SHA}"; then
say "Gitea CI already succeeded for ${FULL_SHA}; skipping duplicate local npm test/verify"
else
(
cd "${ROOT}"
npm test -- --test-name-pattern='publish|space|billing' >/dev/null
npm run verify:mindspace-publish-guards >/dev/null
npm run verify:mindspace-page-sync-guards >/dev/null
npm run verify:h5-session-patches >/dev/null
npm run verify:page-data >/dev/null
npm run check:mindspace-public-links >/dev/null
)
fi
required_runtime_paths=(
server.mjs
@@ -179,7 +183,7 @@ printf '%s %s\n' "${bundle_sha}" "$(basename "${BUNDLE_PATH}")" > "${SHA_PATH}"
echo "git_branch=${branch}"
echo "artifact_tree=.runtime/portal"
echo "artifact_bundle_sha256=${bundle_sha}"
echo "routing=stable:8081,proxy:18082,edge-tunnel:19082,candidate:18081,goosed-canary:18015,deepseek-compat:18036"
echo "routing=stable:8081,proxy:18082,edge-tunnel:19082,candidate:18081,goosed-native:18006-18014,deepseek-compat:18036"
echo "canary_usernames=${CANARY_USERNAMES}"
echo "canary_wechat_user_ids=${CANARY_WECHAT_USER_IDS}"
echo "canary_wechat_page_data_aider_review_enabled=${CANARY_WECHAT_PAGE_DATA_AIDER_REVIEW_ENABLED}"
@@ -198,14 +202,16 @@ say "Run 103 read-only preflight"
ssh -o BatchMode=yes -o ConnectTimeout=15 "${HOST}" \
"test -d '${STABLE_DIR}' \
&& test -f '${STABLE_DIR}/.env' \
&& test -f '${GOOSED_DIR}/docker-compose.prod.yml' \
&& test -x '${CANARY_AIDER_BIN}' \
&& curl -fsS http://127.0.0.1:8081/api/status >/dev/null \
&& ! lsof -nP -iTCP:${CANARY_PROXY_PORT} -sTCP:LISTEN >/dev/null 2>&1 \
&& ! lsof -nP -iTCP:${DEEPSEEK_COMPAT_PORT} -sTCP:LISTEN >/dev/null 2>&1 \
&& { ! lsof -nP -iTCP:${DEEPSEEK_COMPAT_PORT} -sTCP:LISTEN >/dev/null 2>&1 \
|| curl -fsS --max-time 5 http://127.0.0.1:${DEEPSEEK_COMPAT_PORT}/health \
| grep -q '"deepseekThinking":"disabled"'; } \
&& ! lsof -nP -iTCP:18081 -sTCP:LISTEN >/dev/null 2>&1 \
&& ! lsof -nP -iTCP:18015 -sTCP:LISTEN >/dev/null 2>&1 \
&& /opt/homebrew/bin/docker inspect goosed-prod-1 >/dev/null \
&& for _p in 18006 18007 18008 18009 18010 18011 18012 18013 18014; do \
curl -kfsS --max-time 2 https://127.0.0.1:\${_p}/status | grep -qx ok; \
done \
&& test \"\$(df -Pk '${REMOTE_ROOT}' | awk 'NR==2 {print \$4}')\" -gt 10485760 \
&& ssh -o BatchMode=yes -o ConnectTimeout=10 '${EDGE_HOST}' \
\"test -f '${EDGE_MOBILE_CONFIG}' \
@@ -333,8 +339,6 @@ PROXY_PLIST="${HOME}/Library/LaunchAgents/${CANARY_PROXY_LABEL}.plist"
CANARY_TUNNEL_PLIST="${HOME}/Library/LaunchAgents/${CANARY_TUNNEL_LABEL}.plist"
DEEPSEEK_COMPAT_PLIST="${HOME}/Library/LaunchAgents/${DEEPSEEK_COMPAT_LABEL}.plist"
SECRET_FILE="${HOME}/.config/memind/canary-router.secret"
DOCKER_BIN="/opt/homebrew/bin/docker"
GOOSED_COMPOSE="${GOOSED_DIR}/docker-compose.prod.yml"
EDGE_MOBILE_BACKUP="${EDGE_MOBILE_CONFIG}.before-canary-${RELEASE_ID}"
EDGE_WECHAT_BACKUP="${EDGE_WECHAT_CONFIG}.before-canary-${RELEASE_ID}"
@@ -441,7 +445,6 @@ stop_candidate_services() {
launchctl bootout "${LAUNCHD_GUI}/${CANARY_PROXY_LABEL}" >/dev/null 2>&1 || true
launchctl bootout "${LAUNCHD_GUI}/${PORTAL_CANDIDATE_LABEL}" >/dev/null 2>&1 || true
launchctl bootout "${LAUNCHD_GUI}/${DEEPSEEK_COMPAT_LABEL}" >/dev/null 2>&1 || true
"${DOCKER_BIN}" rm -f goosed-prod-canary >/dev/null 2>&1 || true
}
rollback() {
@@ -508,30 +511,15 @@ say "Stop an older canary without touching stable Portal 8081"
restore_edge_to_stable
stop_candidate_services
say "Start an isolated goosed candidate on 18015"
(
cd "${GOOSED_DIR}"
set -a
# shellcheck disable=SC1091
source .env
set +a
export CANDIDATE_RUNTIME_DIR="${CANDIDATE_DIR}"
"${DOCKER_BIN}" compose -p goosed-prod \
-f "${GOOSED_COMPOSE}" \
-f "${CANDIDATE_DIR}/scripts/goosed-canary.compose.yml" \
up -d --no-deps goosed-canary
)
for _ in $(seq 1 60); do
if [[ "$(curl -skS -m 5 https://127.0.0.1:18015/status 2>/dev/null || true)" == "ok" ]]; then
break
fi
sleep 2
say "Use the native goosed pool 18006-18014"
for _p in 18006 18007 18008 18009 18010 18011 18012 18013 18014; do
[[ "$(curl -skS -m 5 "https://127.0.0.1:${_p}/status" 2>/dev/null || true)" == "ok" ]]
done
[[ "$(curl -skS -m 5 https://127.0.0.1:18015/status 2>/dev/null || true)" == "ok" ]]
"${DOCKER_BIN}" exec goosed-prod-canary \
sh -lc 'test -x /usr/local/bin/node && test -f /opt/portal/mindspace-sandbox-mcp.mjs'
say "Start the DeepSeek tool-round compatibility proxy on 18036"
if deepseek_compat_healthy; then
say "Reuse the already healthy DeepSeek compatibility proxy on ${DEEPSEEK_COMPAT_PORT}"
else
cat > "${DEEPSEEK_COMPAT_PLIST}" <<EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
@@ -560,9 +548,7 @@ for _ in $(seq 1 30); do
sleep 1
done
deepseek_compat_healthy
"${DOCKER_BIN}" exec goosed-prod-canary \
sh -lc "curl -fsS --max-time 5 http://host.docker.internal:${DEEPSEEK_COMPAT_PORT}/health \
| grep -q '\"deepseekThinking\":\"disabled\"'"
fi
say "Start the passive candidate Portal on 18081"
cat > "${CANDIDATE_PLIST}" <<EOF
@@ -578,7 +564,6 @@ cat > "${CANDIDATE_PLIST}" <<EOF
<key>MEMIND_CANARY_STABLE_ROOT</key><string>${STABLE_DIR}</string>
<key>MEMIND_CANARY_RELEASE_ID</key><string>${RELEASE_ID}</string>
<key>MEMIND_CANARY_CANDIDATE_PORT</key><string>18081</string>
<key>MEMIND_CANARY_GOOSED_URL</key><string>https://127.0.0.1:18015</string>
<key>MEMIND_CANARY_DEEPSEEK_PROXY_PORT</key><string>${DEEPSEEK_COMPAT_PORT}</string>
<key>MEMIND_CANARY_WECHAT_PAGE_DATA_AIDER_REVIEW_ENABLED</key><string>${CANARY_WECHAT_PAGE_DATA_AIDER_REVIEW_ENABLED}</string>
<key>MEMIND_CANARY_WECHAT_PAGE_DATA_AIDER_REVIEW_USER_IDS</key><string>${CANARY_WECHAT_PAGE_DATA_AIDER_REVIEW_USER_IDS}</string>
@@ -809,7 +794,7 @@ printf 'stable_health=http://127.0.0.1:8081/api/status\n'
printf 'proxy_health=http://127.0.0.1:%s/__memind_canary/health\n' "${CANARY_PROXY_PORT}"
printf 'edge_tunnel=http://127.0.0.1:%s/api/status\n' "${CANARY_TUNNEL_REMOTE_PORT}"
printf 'candidate_health=http://127.0.0.1:18081/api/status\n'
printf 'candidate_goosed=https://127.0.0.1:18015/status\n'
printf 'candidate_goosed=https://127.0.0.1:18006-18014/status\n'
printf 'deepseek_compat_health=http://127.0.0.1:%s/health\n' "${DEEPSEEK_COMPAT_PORT}"
REMOTE_SCRIPT
+11 -7
View File
@@ -153,13 +153,17 @@ say "本地预检查"
check_release_scope
if [[ "${SKIP_TESTS}" -ne 1 ]]; then
say "运行最小验证"
(
cd "${ROOT}"
npm test -- --test-name-pattern='publish|space|billing' >/dev/null
npm run verify:mindspace-publish-guards >/dev/null
npm run verify:page-data >/dev/null
)
if node "${ROOT}/scripts/resolve-release-ci-status.mjs" --commit "$(git -C "${ROOT}" rev-parse HEAD)"; then
say "Gitea CI already succeeded; skipping duplicate local npm test/verify"
else
say "运行最小验证"
(
cd "${ROOT}"
npm test -- --test-name-pattern='publish|space|billing' >/dev/null
npm run verify:mindspace-publish-guards >/dev/null
npm run verify:page-data >/dev/null
)
fi
fi
if [[ "${SKIP_BUILD}" -ne 1 ]]; then
-1
View File
@@ -72,7 +72,6 @@ launchctl bootout "${LAUNCHD_GUI}/${CANARY_TUNNEL_LABEL}" >/dev/null 2>&1 || tru
launchctl bootout "${LAUNCHD_GUI}/${CANARY_PROXY_LABEL}" >/dev/null 2>&1 || true
launchctl bootout "${LAUNCHD_GUI}/${PORTAL_CANDIDATE_LABEL}" >/dev/null 2>&1 || true
launchctl bootout "${LAUNCHD_GUI}/${DEEPSEEK_COMPAT_LABEL}" >/dev/null 2>&1 || true
/opt/homebrew/bin/docker rm -f goosed-prod-canary >/dev/null 2>&1 || true
rm -f "${STABLE_DIR}/.release-drain"
curl -fsS http://127.0.0.1:8081/api/status >/dev/null
+8 -7
View File
@@ -27,17 +27,18 @@ export H5_REMINDER_WORKER_ENABLED=0
export H5_PORT="${MEMIND_CANARY_CANDIDATE_PORT:-18081}"
export H5_HOST=127.0.0.1
export H5_PUBLIC_BASE_URL="${H5_PUBLIC_BASE_URL:-https://m.tkmind.cn}"
export TKMIND_API_TARGETS="${MEMIND_CANARY_GOOSED_URL:-https://127.0.0.1:18015}"
export TKMIND_API_TARGET="${MEMIND_CANARY_GOOSED_URL:-https://127.0.0.1:18015}"
# Extensions are spawned inside goosed-canary, where the candidate artifact is
# mounted at /opt/portal. Never inherit host-only MCP paths from the stable .env.
export GOOSED_MCP_NODE_PATH=/usr/local/bin/node
export GOOSED_MCP_SERVER_PATH=/opt/portal/mindspace-sandbox-mcp.mjs
if [[ -n "${MEMIND_CANARY_GOOSED_URL:-}" ]]; then
export TKMIND_API_TARGETS="${MEMIND_CANARY_GOOSED_URL}"
export TKMIND_API_TARGET="${MEMIND_CANARY_GOOSED_URL}"
# Isolated Docker goosed mounts the candidate artifact at /opt/portal.
export GOOSED_MCP_NODE_PATH=/usr/local/bin/node
export GOOSED_MCP_SERVER_PATH=/opt/portal/mindspace-sandbox-mcp.mjs
export MEMIND_GOOSED_HOST_GATEWAY=host.docker.internal
fi
# DeepSeek V4 tool rounds must use the same compatibility contract exercised
# by the release Gate. Stable .env values cannot disable or redirect it.
export MEMIND_DEEPSEEK_DISABLE_THINKING=1
export MEMIND_DEEPSEEK_NO_THINK_PORT="${MEMIND_CANARY_DEEPSEEK_PROXY_PORT:-18036}"
export MEMIND_GOOSED_HOST_GATEWAY=host.docker.internal
export H5_WECHAT_MP_PAGE_DATA_AIDER_REVIEW_ENABLED="${MEMIND_CANARY_WECHAT_PAGE_DATA_AIDER_REVIEW_ENABLED:-0}"
export H5_WECHAT_MP_PAGE_DATA_AIDER_REVIEW_USERS="${MEMIND_CANARY_WECHAT_PAGE_DATA_AIDER_REVIEW_USER_IDS:-}"
export AIDER_BIN="${MEMIND_CANARY_AIDER_BIN:-/opt/homebrew/bin/aider}"
@@ -3,10 +3,12 @@ import { spawn } from 'node:child_process';
import path from 'node:path';
import { assertPortalRuntimePath, inspectPortalRuntime } from '../release-gate/artifact.mjs';
import { assertDockerDaemonAvailable } from '../release-gate/preflight.mjs';
const root = path.resolve(new URL('..', import.meta.url).pathname);
const runtime = assertPortalRuntimePath(path.join(root, '.runtime', 'portal'), { repoRoot: root });
const image = process.env.RELEASE_GATE_NODE_IMAGE || 'node:24-bookworm';
assertDockerDaemonAvailable();
async function runDocker(commandArgs, { input = '' } = {}) {
return new Promise((resolve, reject) => {
+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: '解读详细报告,做成页面' }),
'解读详细报告,做成页面',
),
true,
);
});
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';
+1 -1
View File
@@ -45,7 +45,7 @@ test('transcribeWechatVoiceViaRecoApi uploads mp3 and polls reco result', async
},
convertToMp3: async () => mp3Buffer,
pollIntervalMs: 1,
pollTimeoutMs: 50,
pollTimeoutMs: 2_000,
now: () => Date.now(),
});
+10
View File
@@ -32,6 +32,15 @@ export const PAGE_CONTENT_EDIT_PATTERN =
const PAGE_LINK_MISSING_RETRY_PATTERN =
/(?:页面|链接|新闻页|新闻页面|html).{0,16}(?:没有生成|没生成|未生成|没发|未发)/iu;
export const REPORT_PAGE_CONTINUATION_PATTERN =
/(?:解读|分析|说明).{0,12}(?:详细)?(?:报告|化验|检验)|(?:报告|化验|检验).{0,12}(?:解读|做成|生成).{0,8}(?:页面|网页)/iu;
export function isWechatReportPageFollowup(wechatIntent, text) {
const normalized = String(text ?? '').trim();
if (!normalized || wechatIntent?.kind !== 'page.generate') return false;
return REPORT_PAGE_CONTINUATION_PATTERN.test(normalized);
}
export function isWechatPageLinkRetryText(text) {
const normalized = String(text ?? '').trim();
return Boolean(normalized && PAGE_LINK_MISSING_RETRY_PATTERN.test(normalized));
@@ -69,6 +78,7 @@ export function isWechatSessionPageContinuation(wechatIntent, text) {
if (isWechatImmediateContextPageCreate(wechatIntent, normalized)) return true;
if (wechatIntent?.kind === 'page.generate' && isWechatPageEditText(normalized)) return true;
if (wechatIntent?.kind === 'chat.general' && isWechatContentEditFollowup(normalized)) return true;
if (isWechatReportPageFollowup(wechatIntent, normalized)) return true;
return false;
}
+9
View File
@@ -37,3 +37,12 @@ test('WeChat page continuation rejects full new-topic page requests', () => {
assert.equal(intent.kind, 'page.generate');
assert.equal(isWechatSessionPageContinuation(intent, intent.topic), false);
});
test('WeChat page continuation recognizes report interpretation follow-ups', () => {
const intent = classifyWechatIntent({
msgType: 'text',
agentText: '解读详细报告,做成页面',
});
assert.equal(intent.kind, 'page.generate');
assert.equal(isWechatSessionPageContinuation(intent, intent.topic), true);
});