Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a9fe3d01a7 | |||
| c9b2fcba87 | |||
| b97f607817 | |||
| e59809eff4 | |||
| 19c8cdb970 | |||
| fd56f087c6 | |||
| 25be530d84 | |||
| 2f240e7500 | |||
| 293ac69a76 | |||
| 1b9c65fec1 | |||
| ab08839a54 | |||
| 8cf1c772a7 | |||
| 41f245c88a | |||
| fe2d5d7aa1 | |||
| 443544d1f7 | |||
| 36d3a91f8d | |||
| 4e789663a1 | |||
| 8a88cd53e8 | |||
| baf89c5d04 |
@@ -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) {
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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\]:/);
|
||||
});
|
||||
|
||||
@@ -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":""}' };
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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 405,DeepSeek 下一轮拒绝 `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 5174,Memind 侧为共享业务模块与 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
@@ -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`。
|
||||
|
||||
本地验证:
|
||||
|
||||
|
||||
@@ -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` 不等于 IMGPG;IMGPG 只匹配
|
||||
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,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 发帖共用该开关。
|
||||
|
||||
## 必跑验证
|
||||
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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\//);
|
||||
});
|
||||
|
||||
@@ -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
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 };
|
||||
@@ -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
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
"expect": {
|
||||
"assistantMinChars": 80,
|
||||
"timeoutMs": 600000,
|
||||
"replyKeywords": ["方案", "确认"],
|
||||
"replyKeywords": ["问卷", "后台"],
|
||||
"forbidReplyPatterns": ["8899", "127.0.0.1:", "PLACEHOLDER_PAGE_ID", "survey-api"]
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -22,7 +22,7 @@ const MENU = {
|
||||
button: [
|
||||
{
|
||||
type: 'view',
|
||||
name: 'Memind',
|
||||
name: 'TKMind',
|
||||
url: 'https://m.tkmind.cn',
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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,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} 智趣`;
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 }) =>
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user