Compare commits

..

10 Commits

Author SHA1 Message Date
john c6ae7cd21c fix(wechat): trust configured imgproxy generated images
Memind CI / Test, build, and release guards (pull_request) Successful in 2m56s
2026-07-22 09:05:18 +08:00
tkmind 1765cac65a merge: honor explicit WeChat page generation negation
Memind CI / Test, build, and release guards (push) Successful in 2m49s
Merge real-WeChat acceptance fix after successful CI.
2026-07-21 15:44:24 +00:00
john efbbe69e2c fix(wechat): honor explicit page generation negation
Memind CI / Test, build, and release guards (pull_request) Successful in 2m56s
2026-07-21 23:40:29 +08:00
john c2e2209344 fix(memory): sync new memories into pgvector
Memind CI / Test, build, and release guards (push) Successful in 2m31s
2026-07-21 23:30:18 +08:00
tkmind 6f4a3b53d0 merge: add WeChat image generation delivery policy
Memind CI / Test, build, and release guards (push) Successful in 2m39s
Merge service-account image generation policy after successful CI validation.
2026-07-21 15:27:00 +00:00
john 28581310da feat(wechat): add image generation delivery policy
Memind CI / Test, build, and release guards (pull_request) Successful in 2m45s
2026-07-21 23:22:06 +08:00
john bfb1f6fea9 fix(memory): initialize candidates and enforce lifecycle rollout
Memind CI / Test, build, and release guards (push) Successful in 3m29s
2026-07-21 22:55:19 +08:00
tkmind 0dd9331e5b merge: verify MindSpace service replacement
Memind CI / Test, build, and release guards (push) Successful in 2m15s
CI run 1065 passed. Fixes the 8082 listener race and validates the replacement launchd job.
2026-07-21 10:59:04 +00:00
john 4e6db07123 fix: verify MindSpace service replacement
Memind CI / Test, build, and release guards (pull_request) Successful in 2m26s
2026-07-21 18:55:23 +08:00
tkmind fe4b8c0da9 merge: preserve MindSpace runtime data during release
Memind CI / Test, build, and release guards (push) Successful in 2m30s
CI run 1063 passed. Required release safety fix for the image_make production canary.
2026-07-21 10:41:21 +00:00
26 changed files with 1852 additions and 94 deletions
+1
View File
@@ -30,6 +30,7 @@ bash scripts/check-release-ready.sh
| MindSpace remote 页面 sync + storage 缺失缩略图 fallback | [docs/regression-guards/mindspace-remote-page-sync-and-thumbnail.md](docs/regression-guards/mindspace-remote-page-sync-and-thumbnail.md) |
| Page Data 数据集注册、绑定与交付验收 | [docs/regression-guards/page-data-delivery-contract.md](docs/regression-guards/page-data-delivery-contract.md) |
| H5 SSE 断线续播、Portal/Goose 游标映射与 Finish 终态恢复 | [docs/regression-guards/h5-session-stream-replay.md](docs/regression-guards/h5-session-stream-replay.md) |
| Memory V2 候选表初始化与生命周期灰度作用域 | [docs/regression-guards/memory-v2-candidate-and-lifecycle.md](docs/regression-guards/memory-v2-candidate-and-lifecycle.md) |
索引:[docs/regression-guards/README.md](docs/regression-guards/README.md)
+20
View File
@@ -96,6 +96,26 @@ IMAGE_MAKE_MEMIND_CONFIG_TOKEN=<与 Portal IMAGE_MAKE_TOKEN 相同的值>
重试三次,并为每次重试使用新的幂等键。生产启用图片生成前必须同时验证生图 Provider 与视觉
审核 Provider,不能只验证 `/health`
## 微信服务号聊天与页面策略
服务号通道使用独立的图片策略,不改变 H5 输入区现有的 `auto / required / disabled` 三态:
1. 服务号普通聊天只有在用户明确要求“生成图片 / 生图 / 画一张”等意图时,才强制加载
`image-generation` 并调用一次 `generate_image(purpose=inline_image)`;用户明确说不要图片时禁止调用。
2. 服务号生成的每个正式用户内容页面,默认必须在本轮生成一张新的缩略图源图。优先调用一次
`generate_image(purpose=hero)`,页面 Hero、`mindspace-cover.cover` 与后续缩略图派生复用这张图,
禁止为了卡片或信息流再次调用 `card_cover` / `feed_cover`
3. 正文独立插图与缩略图策略分离:只有用户明确要求正文配图,或正文确有不同画面需求时才调用
`inline_image`。用户说“不要图片”时,正文与 Hero 不展示图片,但正式页面仍保留新的缩略图源图。
4. 管理后台、密码查看、重定向、下载包装和错误辅助页不要求单独生图。
5. 页面交付前必须确认 `mindspace-cover.cover` 引用当前轮工具返回的 `asset.htmlSrc`;历史 `jobId`
旧图、默认渐变和占位路径均不能满足交付条件。验证失败时保留草稿但禁止发送页面链接。
6. 独立生成图片会转换为微信可接受的 JPEG,上传临时图片素材后以原生图片客服消息发送;素材上传
失败时降级发送 MindSpace 公网图片链接。
配置 `H5_WECHAT_MP_FRESH_PAGE_THUMBNAILS=0` 可只关闭服务号“页面必须新缩略图”硬门,作为紧急回滚;
默认开启。该开关不会修改 H5 聊天图片策略。
## 发布前置条件
Portal runtime 不包含独立 `image_make` 服务。正式启用前必须确保:
+18 -1
View File
@@ -79,7 +79,16 @@ The additive user-scoped management API is:
Lifecycle workers are disabled by default. When explicitly enabled they run
expiration, conservative compaction observation, candidate promotion, and
reflection observation according to the rollout mode; none of these operations
blocks the chat path.
blocks the chat path. `off` creates no worker scope and performs no lifecycle
mutation, `canary` is always user-scoped to the configured rollout IDs, and only
`active` permits an unscoped global worker run.
When candidate persistence is enabled, the Portal runtime idempotently creates
`h5_memory_v2_candidates` before enabling the MySQL candidate store. DDL failure
is fail-open for Portal chat and falls back to bounded in-memory candidates;
`memind_adm` uses the same schema helper during bootstrap and fails startup rather
than serving a permanently broken candidate API. The table is additive and must
not be dropped as part of an application rollback.
The pgvector adapter does not create tables or generate embeddings. It only defines the adapter contract for a future semantic memory backend and requires explicit `enabled: true`, an injected PostgreSQL pool, and either an input embedding or an injected `embedQuery(...)` function.
@@ -385,6 +394,14 @@ Optional table name for pgvector retrieval. Defaults to `memory_embeddings`.
Optional PostgreSQL pool size for Memory V2 pgvector retrieval. Defaults to `5`.
`MEMORY_PGVECTOR_SYNC_USER_LIMIT`
Maximum number of recent active memories synchronously upserted for the current
user after a successful `write(...)`, `compact(...)`, or candidate promotion.
Defaults to `50`. When a session ID is available the sync is additionally scoped
to that session. This immediate, idempotent sync keeps newly saved memories
recallable without waiting for the separate global checkpoint backfill.
## Current Integration Points
Memory V2 is only allowed to sit above existing memory code.
+1
View File
@@ -10,6 +10,7 @@
| [mindspace-remote-page-sync-and-thumbnail.md](./mindspace-remote-page-sync-and-thumbnail.md) | ① remote 模式 public HTML 入库 sync ② storage 缺失时缩略图/读页回退 workspace HTML |
| [page-data-delivery-contract.md](./page-data-delivery-contract.md) | 数据集注册、绑定、真实 page UUID 与交付验收 |
| [h5-session-stream-replay.md](./h5-session-stream-replay.md) | Portal/Goose SSE 游标映射、断线续播与 Finish 终态恢复 |
| [memory-v2-candidate-and-lifecycle.md](./memory-v2-candidate-and-lifecycle.md) | 候选记忆表幂等初始化、Portal fail-open、生命周期 off/canary/active 作用域 |
## 自动化
@@ -0,0 +1,53 @@
# Memory V2 候选表与生命周期灰度守卫
## 已知故障
生产曾出现 `h5_memory_v2_candidates` 不存在:候选记忆写入和后台查询报错,
但 Agent 召回仍在 shadow 模式执行,因此表现为“有召回事件、回答未注入、个人记忆状态降级”。
同时,生命周期 worker 只检查了 worker 开关;`expire()` 没有检查 rollout
作用域。若忘却开关开启,即使 `MEMORY_LIFECYCLE_ROLLOUT_MODE=off`,定时器也可能
归档全量用户的过期记忆。
灰度还曾出现 MySQL 已成功保存新记忆,但 pgvector 仍只包含旧数据:runtime 从全局
`updated_at=0` 游标开始做有限批次 backfill,新写入行可能长期排在批次之外。结果是
`agent_memory_resolved` 显示已注入,但回答只拿到旧的无关记忆。
## 必须保留的行为
1. `MEMORY_CANDIDATE_PERSISTENCE_ENABLED=1` 且 MySQL 可用时,Portal 必须先执行
`ensurePersonalMemoryCandidateSchema()`,再创建候选记忆 store。
2. 候选表 DDL 失败不能阻塞 Portal 聊天启动;Portal 应记录告警并退回 bounded-memory。
3. memind_adm 必须在创建候选 store 前完成同一幂等建表;初始化失败时后台启动失败,
不允许以“页面可用但候选接口持续报错”的半初始化状态运行。
4. 生命周期 rollout 语义必须严格一致:
- `off`:不得执行任何写操作,也不得启动 worker 定时器。
- `canary`:只允许 `MEMORY_LIFECYCLE_ROLLOUT_USER_IDS` 中的用户。
- `active`:才允许无 user scope 的全局任务。
5. `forgetMemory()``expire()` 都必须同时满足功能开关与 rollout 作用域。
6. 用户记忆 `write/compact` 成功后,必须在返回前按 `userId + sessionId` 将本次活跃记忆
幂等 upsert 到 pgvector;候选晋升成功后也必须按实际晋升用户同步。不得依赖从零开始
的全局有限批次 backfill 来保证新记忆可立即召回。
## 回归检查
```bash
node --test memory-v2-personal-store.test.mjs memory-v2-lifecycle.test.mjs \
memory-v2-pgvector-backfill.test.mjs memory-v2-runtime.test.mjs
npm test
```
memind_adm 同时执行:
```bash
node --test server/personal-memory-candidate-store.test.mjs
npm test
```
灰度上线后还必须验证:候选表存在、候选写入成功、后台可查询,以及一个测试用户完成
“保存 → 候选 → 晋升 → 新会话召回 → 回答注入”的闭环。未通过前不得切换全量 active。
## 回滚
代码可以按标准 runtime/release 回滚;候选表是加法结构,回滚时保留空表或已有数据,
不得为了代码回滚直接删除表。先把候选、生命周期和 Agent 注入模式切回 shadow/off。
+19 -4
View File
@@ -33,6 +33,15 @@ export function resolveMemoryV2LifecyclePolicy(env = process.env) {
};
}
export function resolveMemoryV2LifecycleWorkerScopes(policy = {}) {
if (!policy.enabled) return [];
if (policy.rolloutMode === 'active') return [null];
if (policy.rolloutMode === 'canary') {
return [...new Set((policy.rolloutUserIds ?? []).map(String).filter(Boolean))];
}
return [];
}
function normalizeItem(row) {
return {
id: String(row.id),
@@ -71,7 +80,7 @@ export function createMemoryV2LifecycleService({ pool = null, env = process.env,
}
async function forgetMemory({ userId, memoryId } = {}) {
if (!canRun(userId) && !policy.forgettingEnabled) return { ok: true, updated: false, skipped: true, reason: 'disabled' };
if (!policy.forgettingEnabled || !canRun(userId)) return { ok: true, updated: false, skipped: true, reason: 'disabled' };
if (!pool?.query || !userId || !memoryId) return { ok: false, updated: false, reason: 'invalid_input' };
const [result] = await pool.query(
`UPDATE ${MEMORY_TABLE} SET status = 'deleted', updated_at = ? WHERE id = ? AND user_id = ? AND status <> 'deleted'`,
@@ -82,7 +91,9 @@ export function createMemoryV2LifecycleService({ pool = null, env = process.env,
}
async function expire({ userId = null } = {}) {
if (!pool?.query || !policy.forgettingEnabled) return { ok: true, skipped: true, reason: 'disabled', expired: 0 };
if (!pool?.query || !policy.forgettingEnabled || !canRun(userId)) {
return { ok: true, skipped: true, reason: 'disabled', expired: 0 };
}
const cutoff = now() - policy.retentionDays * 86400000;
const params = [cutoff];
let scope = '';
@@ -115,6 +126,7 @@ export function createMemoryV2LifecycleService({ pool = null, env = process.env,
params,
);
let promoted = 0;
const promotedUserIds = new Set();
for (const row of rows) {
const id = crypto.createHash('sha256').update(`${row.user_id}\n${row.memory_type}\n${row.content}`).digest('hex');
const hash = crypto.createHash('sha256').update(`${row.user_id}\n${row.content}`).digest('hex');
@@ -124,10 +136,13 @@ export function createMemoryV2LifecycleService({ pool = null, env = process.env,
VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`,
[id, row.user_id, row.memory_type === 'semantic' ? 'knowledge' : row.memory_type, hash, row.content, null, row.session_id, row.confidence, 'active', row.evidence_json, row.created_at, now()],
);
if (Number(result?.affectedRows ?? 0) > 0) promoted += 1;
if (Number(result?.affectedRows ?? 0) > 0) {
promoted += 1;
promotedUserIds.add(String(row.user_id));
}
}
metrics.promote += 1;
return { ok: true, skipped: false, promoted };
return { ok: true, skipped: false, promoted, promotedUserIds: [...promotedUserIds] };
}
async function reflect({ userId = null } = {}) {
+84 -2
View File
@@ -1,6 +1,10 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createMemoryV2LifecycleService, resolveMemoryV2LifecyclePolicy } from './memory-v2-lifecycle.mjs';
import {
createMemoryV2LifecycleService,
resolveMemoryV2LifecyclePolicy,
resolveMemoryV2LifecycleWorkerScopes,
} from './memory-v2-lifecycle.mjs';
test('lifecycle policy defaults to safe disabled workers', () => {
const policy = resolveMemoryV2LifecyclePolicy({});
@@ -25,6 +29,16 @@ test('lifecycle canary only runs for configured users', async () => {
assert.equal((await lifecycle.promote({ userId: 'u-2' })).skipped, true);
});
test('lifecycle worker scopes match off, canary, and active rollout modes', () => {
assert.deepEqual(resolveMemoryV2LifecycleWorkerScopes({ enabled: true, rolloutMode: 'off' }), []);
assert.deepEqual(resolveMemoryV2LifecycleWorkerScopes({
enabled: true,
rolloutMode: 'canary',
rolloutUserIds: ['u-1', 'u-1', 'u-2'],
}), ['u-1', 'u-2']);
assert.deepEqual(resolveMemoryV2LifecycleWorkerScopes({ enabled: true, rolloutMode: 'active' }), [null]);
});
test('forget is fail-safe when lifecycle is disabled', async () => {
const lifecycle = createMemoryV2LifecycleService({ env: { MEMORY_LIFECYCLE_ENABLED: '0' } });
assert.deepEqual(await lifecycle.forgetMemory({ userId: 'u-1', memoryId: 'm-1' }), {
@@ -39,9 +53,77 @@ test('list and forget use user-scoped SQL', async () => {
if (sql.startsWith('SELECT')) return [[{ id: 'm-1', user_id: 'u-1', label: 'fact', memory_text: 'x', status: 'active', confidence: 0.9, created_at: 1, updated_at: 2 }]];
return [{ affectedRows: 1 }];
} };
const lifecycle = createMemoryV2LifecycleService({ pool, env: { MEMORY_LIFECYCLE_ENABLED: '1', MEMORY_LIFECYCLE_FORGETTING_ENABLED: '1' } });
const lifecycle = createMemoryV2LifecycleService({ pool, env: {
MEMORY_LIFECYCLE_ENABLED: '1',
MEMORY_LIFECYCLE_FORGETTING_ENABLED: '1',
MEMORY_LIFECYCLE_ROLLOUT_MODE: 'active',
} });
assert.equal((await lifecycle.listMemories({ userId: 'u-1' }))[0].id, 'm-1');
assert.equal((await lifecycle.forgetMemory({ userId: 'u-1', memoryId: 'm-1' })).updated, true);
assert.match(calls[0].sql, /user_id = \?/);
assert.match(calls[1].sql, /user_id = \?/);
});
test('forget and expire never mutate data while lifecycle rollout is off', async () => {
const calls = [];
const pool = { query: async (...args) => { calls.push(args); return [{ affectedRows: 1 }]; } };
const lifecycle = createMemoryV2LifecycleService({ pool, env: {
MEMORY_LIFECYCLE_ENABLED: '1',
MEMORY_LIFECYCLE_FORGETTING_ENABLED: '1',
MEMORY_LIFECYCLE_ROLLOUT_MODE: 'off',
} });
assert.equal((await lifecycle.forgetMemory({ userId: 'u-1', memoryId: 'm-1' })).skipped, true);
assert.equal((await lifecycle.expire()).skipped, true);
assert.equal(calls.length, 0);
});
test('expire is limited to the configured canary user', async () => {
const calls = [];
const pool = { query: async (sql, params) => { calls.push({ sql, params }); return [{ affectedRows: 2 }]; } };
const lifecycle = createMemoryV2LifecycleService({ pool, now: () => 1_000_000, env: {
MEMORY_LIFECYCLE_ENABLED: '1',
MEMORY_LIFECYCLE_FORGETTING_ENABLED: '1',
MEMORY_LIFECYCLE_ROLLOUT_MODE: 'canary',
MEMORY_LIFECYCLE_ROLLOUT_USER_IDS: 'u-1',
MEMORY_POLICY_RETENTION_DAYS: '1',
} });
assert.equal((await lifecycle.expire({ userId: 'u-2' })).skipped, true);
assert.equal((await lifecycle.expire({ userId: 'u-1' })).expired, 2);
assert.equal(calls.length, 1);
assert.match(calls[0].sql, /user_id = \?/);
assert.equal(calls[0].params.at(-1), 'u-1');
});
test('promotion reports the users whose memories were inserted', async () => {
const pool = {
async query(sql) {
if (sql.startsWith('SELECT * FROM h5_memory_v2_candidates')) {
return [[{
user_id: 'u-1',
memory_type: 'episodic',
content: '记住灰度代号',
session_id: 's-1',
confidence: 0.9,
evidence_json: '{}',
created_at: 100,
}]];
}
if (sql.startsWith('INSERT IGNORE INTO h5_user_memory_items')) {
return [{ affectedRows: 1 }];
}
throw new Error(`Unexpected SQL: ${sql}`);
},
};
const lifecycle = createMemoryV2LifecycleService({ pool, env: {
MEMORY_LIFECYCLE_ENABLED: '1',
MEMORY_PROMOTION_ENABLED: '1',
MEMORY_LIFECYCLE_ROLLOUT_MODE: 'canary',
MEMORY_LIFECYCLE_ROLLOUT_USER_IDS: 'u-1',
} });
const result = await lifecycle.promote({ userId: 'u-1' });
assert.equal(result.promoted, 1);
assert.deepEqual(result.promotedUserIds, ['u-1']);
});
+108 -50
View File
@@ -60,61 +60,16 @@ function normalizeLegacyMemory(row) {
};
}
export async function loadLegacyMemoryBackfillBatch(mysqlPool, { cursor = {}, limit = DEFAULT_LIMIT } = {}) {
if (!mysqlPool?.query) {
throw new Error('loadLegacyMemoryBackfillBatch requires a MySQL pool with query(sql, params)');
}
const resolvedCursor = normalizeCursor(cursor);
const resolvedLimit = normalizeLimit(limit);
const [rows] = await mysqlPool.query(
`SELECT id, user_id, label, memory_text, evidence_message_id, source_session_id,
confidence, created_at, updated_at
FROM h5_user_memory_items
WHERE status = 'active'
AND (updated_at > ? OR (updated_at = ? AND id > ?))
ORDER BY updated_at ASC, id ASC
LIMIT ?`,
[resolvedCursor.updatedAt, resolvedCursor.updatedAt, resolvedCursor.id, resolvedLimit],
);
const memories = (rows ?? []).map((row) => normalizeLegacyMemory(row)).filter(Boolean);
const last = memories.at(-1);
return {
memories,
nextCursor: last ? { updatedAt: last.updatedAt, id: last.id } : resolvedCursor,
hasMore: memories.length === resolvedLimit,
};
}
export async function backfillLegacyMemoriesToPgvector({
mysqlPool,
pgPool = null,
embedMemory = null,
tableName = DEFAULT_TABLE,
cursor = {},
limit = DEFAULT_LIMIT,
dryRun = true,
} = {}) {
const table = quoteIdent(tableName);
const batch = await loadLegacyMemoryBackfillBatch(mysqlPool, { cursor, limit });
if (dryRun) {
return {
ok: true,
mode: 'dry-run',
scanned: batch.memories.length,
inserted: 0,
nextCursor: batch.nextCursor,
hasMore: batch.hasMore,
};
}
async function upsertLegacyMemories({ memories, pgPool, embedMemory, tableName }) {
if (!pgPool?.query) {
throw new Error('backfill apply requires a PostgreSQL pool with query(sql, params)');
throw new Error('pgvector sync requires a PostgreSQL pool with query(sql, params)');
}
if (typeof embedMemory !== 'function') {
throw new Error('backfill apply requires embedMemory(memory) => number[]');
throw new Error('pgvector sync requires embedMemory(memory) => number[]');
}
const table = quoteIdent(tableName);
let inserted = 0;
for (const memory of batch.memories) {
for (const memory of memories) {
const embedding = normalizeEmbedding(await embedMemory(memory));
if (!embedding) continue;
await pgPool.query(
@@ -146,6 +101,109 @@ export async function backfillLegacyMemoriesToPgvector({
);
inserted += 1;
}
return inserted;
}
export async function loadLegacyMemoryBackfillBatch(mysqlPool, { cursor = {}, limit = DEFAULT_LIMIT } = {}) {
if (!mysqlPool?.query) {
throw new Error('loadLegacyMemoryBackfillBatch requires a MySQL pool with query(sql, params)');
}
const resolvedCursor = normalizeCursor(cursor);
const resolvedLimit = normalizeLimit(limit);
const [rows] = await mysqlPool.query(
`SELECT id, user_id, label, memory_text, evidence_message_id, source_session_id,
confidence, created_at, updated_at
FROM h5_user_memory_items
WHERE status = 'active'
AND (updated_at > ? OR (updated_at = ? AND id > ?))
ORDER BY updated_at ASC, id ASC
LIMIT ?`,
[resolvedCursor.updatedAt, resolvedCursor.updatedAt, resolvedCursor.id, resolvedLimit],
);
const memories = (rows ?? []).map((row) => normalizeLegacyMemory(row)).filter(Boolean);
const last = memories.at(-1);
return {
memories,
nextCursor: last ? { updatedAt: last.updatedAt, id: last.id } : resolvedCursor,
hasMore: memories.length === resolvedLimit,
};
}
export async function loadLegacyUserMemorySyncBatch(
mysqlPool,
{ userId, sessionId = null, limit = DEFAULT_LIMIT } = {},
) {
if (!mysqlPool?.query) {
throw new Error('loadLegacyUserMemorySyncBatch requires a MySQL pool with query(sql, params)');
}
const resolvedUserId = String(userId ?? '').trim();
if (!resolvedUserId) throw new Error('loadLegacyUserMemorySyncBatch requires userId');
const resolvedSessionId = String(sessionId ?? '').trim();
const resolvedLimit = normalizeLimit(limit);
const sessionScope = resolvedSessionId ? ' AND source_session_id = ?' : '';
const params = resolvedSessionId
? [resolvedUserId, resolvedSessionId, resolvedLimit]
: [resolvedUserId, resolvedLimit];
const [rows] = await mysqlPool.query(
`SELECT id, user_id, label, memory_text, evidence_message_id, source_session_id,
confidence, created_at, updated_at
FROM h5_user_memory_items
WHERE status = 'active'
AND user_id = ?${sessionScope}
ORDER BY updated_at DESC, id DESC
LIMIT ?`,
params,
);
return (rows ?? []).map((row) => normalizeLegacyMemory(row)).filter(Boolean);
}
export async function syncLegacyUserMemoriesToPgvector({
mysqlPool,
pgPool,
embedMemory,
tableName = DEFAULT_TABLE,
userId,
sessionId = null,
limit = DEFAULT_LIMIT,
} = {}) {
const memories = await loadLegacyUserMemorySyncBatch(mysqlPool, { userId, sessionId, limit });
const inserted = await upsertLegacyMemories({ memories, pgPool, embedMemory, tableName });
return {
ok: true,
mode: 'user-sync',
scanned: memories.length,
inserted,
userId: String(userId),
sessionId: sessionId == null ? null : String(sessionId),
};
}
export async function backfillLegacyMemoriesToPgvector({
mysqlPool,
pgPool = null,
embedMemory = null,
tableName = DEFAULT_TABLE,
cursor = {},
limit = DEFAULT_LIMIT,
dryRun = true,
} = {}) {
const batch = await loadLegacyMemoryBackfillBatch(mysqlPool, { cursor, limit });
if (dryRun) {
return {
ok: true,
mode: 'dry-run',
scanned: batch.memories.length,
inserted: 0,
nextCursor: batch.nextCursor,
hasMore: batch.hasMore,
};
}
const inserted = await upsertLegacyMemories({
memories: batch.memories,
pgPool,
embedMemory,
tableName,
});
return {
ok: true,
+54
View File
@@ -3,6 +3,8 @@ import test from 'node:test';
import {
backfillLegacyMemoriesToPgvector,
loadLegacyMemoryBackfillBatch,
loadLegacyUserMemorySyncBatch,
syncLegacyUserMemoriesToPgvector,
} from './memory-v2-pgvector-backfill.mjs';
function createMysqlPool(rows) {
@@ -162,3 +164,55 @@ test('backfillLegacyMemoriesToPgvector apply requires pg pool and embedding func
/embedMemory/,
);
});
test('loadLegacyUserMemorySyncBatch scopes recent memories to one user and session', async () => {
const calls = [];
const mysqlPool = {
async query(sql, params) {
calls.push({ sql, params });
return [[legacyRows[1]]];
},
};
const memories = await loadLegacyUserMemorySyncBatch(mysqlPool, {
userId: 'user-1',
sessionId: 'session-2',
limit: 25,
});
assert.equal(memories.length, 1);
assert.equal(memories[0].id, 'mem-2');
assert.match(calls[0].sql, /user_id = \?/);
assert.match(calls[0].sql, /source_session_id = \?/);
assert.match(calls[0].sql, /ORDER BY updated_at DESC/);
assert.deepEqual(calls[0].params, ['user-1', 'session-2', 25]);
});
test('syncLegacyUserMemoriesToPgvector immediately upserts the scoped memory', async () => {
const pgCalls = [];
const mysqlPool = {
async query() {
return [[legacyRows[0]]];
},
};
const result = await syncLegacyUserMemoriesToPgvector({
mysqlPool,
pgPool: {
async query(sql, params) {
pgCalls.push({ sql, params });
return { rows: [] };
},
},
embedMemory: async () => [0.2, 0.4],
userId: 'user-1',
sessionId: 'session-1',
limit: 10,
});
assert.equal(result.mode, 'user-sync');
assert.equal(result.scanned, 1);
assert.equal(result.inserted, 1);
assert.match(pgCalls[0].sql, /INSERT INTO "memory_embeddings"/);
assert.equal(pgCalls[0].params[4], 'mem-1');
});
+76 -13
View File
@@ -5,15 +5,24 @@ import { createLegacyMemoryBackend, createMemoryV2, resolveMemoryV2Policy } from
import { createLettaHttpClient, createLettaMemoryBackend } from './memory-v2-letta.mjs';
import { createMemoryV2PluginBackends } from './memory-v2-plugin-backends.mjs';
import { createPersonalMemoryShadowPipeline } from './memory-v2-personal-shadow.mjs';
import { createPersonalMemoryCandidateStore } from './memory-v2-personal-store.mjs';
import {
createPersonalMemoryCandidateStore,
ensurePersonalMemoryCandidateSchema,
} from './memory-v2-personal-store.mjs';
import { createMem0HttpClient, createMem0MemoryBackend } from './memory-v2-mem0.mjs';
import { createNeo4jHttpClient, createNeo4jMemoryBackend } from './memory-v2-neo4j.mjs';
import { createPgvectorMemoryBackend } from './memory-v2-pgvector.mjs';
import { backfillLegacyMemoriesToPgvector } from './memory-v2-pgvector-backfill.mjs';
import {
backfillLegacyMemoriesToPgvector,
syncLegacyUserMemoriesToPgvector,
} from './memory-v2-pgvector-backfill.mjs';
import { createQdrantHttpClient, createQdrantMemoryBackend } from './memory-v2-qdrant.mjs';
import { createRedisStreamsClient, createRedisStreamsMemoryBackend } from './memory-v2-redis-streams.mjs';
import { createWeaviateHttpClient, createWeaviateMemoryBackend } from './memory-v2-weaviate.mjs';
import { createMemoryV2LifecycleService } from './memory-v2-lifecycle.mjs';
import {
createMemoryV2LifecycleService,
resolveMemoryV2LifecycleWorkerScopes,
} from './memory-v2-lifecycle.mjs';
const DEFAULT_PGVECTOR_URL_ENV = 'MEMORY_PGVECTOR_DATABASE_URL';
@@ -403,9 +412,17 @@ export async function createMemoryV2Runtime({
],
}));
const personalCandidateStore = readFlag(env, 'MEMORY_CANDIDATE_PERSISTENCE_ENABLED', false)
? createPersonalMemoryCandidateStore(mysqlPool)
: null;
let personalCandidateStore = null;
if (readFlag(env, 'MEMORY_CANDIDATE_PERSISTENCE_ENABLED', false) && mysqlPool?.query) {
try {
await ensurePersonalMemoryCandidateSchema(mysqlPool);
personalCandidateStore = createPersonalMemoryCandidateStore(mysqlPool);
} catch (err) {
logger?.warn?.(
`[memory-v2] candidate persistence unavailable: ${err instanceof Error ? err.message : err}`,
);
}
}
const personalShadowPipeline = createPersonalMemoryShadowPipeline({
env,
store: personalCandidateStore,
@@ -420,11 +437,22 @@ export async function createMemoryV2Runtime({
const lifecycle = createMemoryV2LifecycleService({ pool: mysqlPool, env, logger });
let lifecycleTimer = null;
const lifecycleWorkerEnabled = readFlag(env, 'MEMORY_LIFECYCLE_WORKER_ENABLED', false);
if (lifecycleWorkerEnabled && mysqlPool?.query) {
const lifecycleWorkerScopes = resolveMemoryV2LifecycleWorkerScopes(lifecycle.policy);
if (lifecycleWorkerEnabled && mysqlPool?.query && lifecycleWorkerScopes.length > 0) {
const intervalMs = Math.max(60_000, Number(lifecycle.policy.compactIntervalHours ?? 24) * 3_600_000);
const runLifecycle = () => lifecycle.expire().then(() => lifecycle.compact()).then(() => lifecycle.promote()).then(() => lifecycle.reflect()).catch((err) => {
logger?.warn?.(`[memory-v2] lifecycle worker skipped: ${err instanceof Error ? err.message : err}`);
});
const runLifecycle = async () => {
try {
for (const userId of lifecycleWorkerScopes) {
const input = userId == null ? {} : { userId };
await lifecycle.expire(input);
await lifecycle.compact(input);
await lifecycle.promote(input);
await lifecycle.reflect(input);
}
} catch (err) {
logger?.warn?.(`[memory-v2] lifecycle worker skipped: ${err instanceof Error ? err.message : err}`);
}
};
lifecycleTimer = setInterval(runLifecycle, intervalMs);
lifecycleTimer.unref?.();
}
@@ -439,8 +467,26 @@ export async function createMemoryV2Runtime({
let pgBackfillBusy = false;
let pgBackfillCursor = { updatedAt: 0, id: '' };
async function syncPgvectorFromLegacy() {
async function syncPgvectorFromLegacy({ userId = null, sessionId = null } = {}) {
if (!pgBackfillEnabled || !pgPoolRef || !pgEmbedQuery || !mysqlPool?.query) return;
if (userId) {
try {
await syncLegacyUserMemoriesToPgvector({
mysqlPool,
pgPool: pgPoolRef,
embedMemory: (item) => pgEmbedQuery(item.text, item),
tableName: env?.MEMORY_PGVECTOR_TABLE,
userId,
sessionId,
limit: Math.max(1, Number(env?.MEMORY_PGVECTOR_SYNC_USER_LIMIT ?? 50) || 50),
});
} catch (err) {
logger?.warn?.(
`[memory-v2] pgvector user sync skipped: ${err instanceof Error ? err.message : err}`,
);
}
return;
}
if (pgBackfillBusy) return;
pgBackfillBusy = true;
try {
@@ -468,12 +514,29 @@ export async function createMemoryV2Runtime({
}
}
const originalLifecyclePromote = lifecycle.promote.bind(lifecycle);
lifecycle.promote = async (input = {}) => {
const result = await originalLifecyclePromote(input);
if (Number(result?.promoted ?? 0) > 0) {
const promotedUserIds = Array.isArray(result?.promotedUserIds)
? result.promotedUserIds
: input?.userId ? [input.userId] : [];
for (const userId of promotedUserIds) {
await syncPgvectorFromLegacy({ userId });
}
}
return result;
};
function wrapMemorySideEffect(methodName) {
const original = memory[methodName].bind(memory);
memory[methodName] = async (input = {}) => {
const result = await original(input);
if ((result?.memories ?? 0) > 0 || (result?.analyzed ?? 0) > 0) {
void syncPgvectorFromLegacy();
if (input?.userId || (result?.memories ?? 0) > 0 || (result?.analyzed ?? 0) > 0) {
await syncPgvectorFromLegacy({
userId: input?.userId ?? null,
sessionId: input?.sessionId ?? null,
});
}
return result;
};
+217
View File
@@ -76,6 +76,223 @@ test('createMemoryV2Runtime does not open pg pool when embedding module is missi
assert.equal(pgvector.reason, 'embedding_module_not_configured');
});
test('createMemoryV2Runtime ensures candidate schema before enabling MySQL persistence', async () => {
const calls = [];
const mysqlPool = {
async query(sql, params = []) {
calls.push({ sql, params });
if (sql.startsWith('CREATE TABLE')) return [[], []];
if (sql.startsWith('INSERT')) return [{ affectedRows: 1 }, []];
throw new Error(`Unexpected SQL: ${sql}`);
},
};
const memory = await createMemoryV2Runtime({
logger: silentLogger(),
legacyMemoryService: legacyService(),
mysqlPool,
env: {
MEMORY_CANDIDATE_ENABLED: '1',
MEMORY_CANDIDATE_MODE: 'shadow',
MEMORY_CANDIDATE_PERSISTENCE_ENABLED: '1',
},
});
assert.equal(memory.getStatus().personalMemory.persistence, 'mysql');
assert.match(calls[0].sql, /CREATE TABLE IF NOT EXISTS `h5_memory_v2_candidates`/);
const observed = await memory.observePersonalMemory({
userId: 'u-1',
sessionId: 's-1',
messages: [{ role: 'user', content: '请记住我偏好使用简洁的中文回答' }],
});
assert.equal(observed.accepted, 1);
assert.equal(calls[1].sql.startsWith('INSERT'), true);
await memory.close();
});
test('createMemoryV2Runtime fails open when candidate schema initialization fails', async () => {
const logger = silentLogger();
const memory = await createMemoryV2Runtime({
logger,
legacyMemoryService: legacyService(),
mysqlPool: { async query() { throw new Error('ddl denied'); } },
env: {
MEMORY_CANDIDATE_ENABLED: '1',
MEMORY_CANDIDATE_MODE: 'shadow',
MEMORY_CANDIDATE_PERSISTENCE_ENABLED: '1',
},
});
assert.equal(memory.getStatus().personalMemory.persistence, 'bounded-memory');
assert.match(logger.warnings[0], /candidate persistence unavailable: ddl denied/);
await memory.close();
});
test('createMemoryV2Runtime syncs a successful user memory write into pgvector before returning', async () => {
const mysqlCalls = [];
const pgCalls = [];
class FakePool {
async query(sql, params) {
pgCalls.push({ sql, params });
return { rows: [] };
}
async end() {}
}
const mysqlPool = {
async query(sql, params) {
mysqlCalls.push({ sql, params });
if (sql.includes('FROM h5_user_memory_items')) {
return [[{
id: 'mem-new',
user_id: 'u-1',
label: 'fact',
memory_text: '灰度代号 MEM-NEW',
evidence_message_id: 'msg-1',
source_session_id: 's-new',
confidence: 0.9,
status: 'active',
created_at: 100,
updated_at: 200,
}]];
}
throw new Error(`Unexpected MySQL query: ${sql}`);
},
};
const memory = await createMemoryV2Runtime({
logger: silentLogger(),
mysqlPool,
legacyMemoryService: legacyService(),
env: {
MEMORY_ENABLED: '1',
MEMORY_EVENT_LOG_ENABLED: '1',
MEMORY_BACKEND: 'pgvector',
MEMORY_VECTOR_ENABLED: '1',
MEMORY_PGVECTOR_DATABASE_URL: 'postgresql://local/memory',
MEMORY_PGVECTOR_EMBEDDING_MODULE: './fake-embed.mjs',
},
async importPg() { return { Pool: FakePool }; },
async importModule() { return { embedQuery: async () => [0.1, 0.2] }; },
});
const result = await memory.write({ userId: 'u-1', sessionId: 's-new', messages: [] });
assert.equal(result.memories, 1);
assert.match(mysqlCalls[0].sql, /user_id = \?/);
assert.match(mysqlCalls[0].sql, /source_session_id = \?/);
assert.deepEqual(mysqlCalls[0].params, ['u-1', 's-new', 50]);
const insert = pgCalls.find((call) => call.sql.includes('INSERT INTO "memory_embeddings"'));
assert.ok(insert);
assert.equal(insert.params[1], '灰度代号 MEM-NEW');
await memory.close();
});
test('createMemoryV2Runtime retries scoped pgvector sync after a deduplicated memory write', async () => {
const pgCalls = [];
class FakePool {
async query(sql, params) {
pgCalls.push({ sql, params });
return { rows: [] };
}
async end() {}
}
const mysqlPool = {
async query(sql) {
if (sql.includes('FROM h5_user_memory_items')) {
return [[{
id: 'mem-existing',
user_id: 'u-1',
label: 'fact',
memory_text: '需要重试同步的既有记忆',
source_session_id: 's-existing',
confidence: 0.9,
created_at: 100,
updated_at: 200,
}]];
}
throw new Error(`Unexpected MySQL query: ${sql}`);
},
};
const memory = await createMemoryV2Runtime({
logger: silentLogger(),
mysqlPool,
legacyMemoryService: {
...legacyService(),
async saveAndAnalyze() {
return { saved: 0, analyzed: 0, memories: 0 };
},
},
env: {
MEMORY_ENABLED: '1',
MEMORY_BACKEND: 'pgvector',
MEMORY_VECTOR_ENABLED: '1',
MEMORY_PGVECTOR_DATABASE_URL: 'postgresql://local/memory',
MEMORY_PGVECTOR_EMBEDDING_MODULE: './fake-embed.mjs',
},
async importPg() { return { Pool: FakePool }; },
async importModule() { return { embedQuery: async () => [0.1, 0.2] }; },
});
const result = await memory.write({ userId: 'u-1', sessionId: 's-existing', messages: [] });
assert.equal(result.memories, 0);
const insert = pgCalls.find((call) => call.sql.includes('INSERT INTO "memory_embeddings"'));
assert.ok(insert);
assert.equal(insert.params[1], '需要重试同步的既有记忆');
await memory.close();
});
test('createMemoryV2Runtime syncs promoted canary candidates into pgvector', async () => {
const pgCalls = [];
class FakePool {
async query(sql, params) {
pgCalls.push({ sql, params });
return { rows: [] };
}
async end() {}
}
const mysqlPool = {
async query(sql) {
if (sql.startsWith('SELECT * FROM h5_memory_v2_candidates')) {
return [[{
user_id: 'u-1', memory_type: 'episodic', content: '候选灰度代号',
session_id: 's-1', confidence: 0.9, evidence_json: '{}', created_at: 100,
}]];
}
if (sql.startsWith('INSERT IGNORE INTO h5_user_memory_items')) return [{ affectedRows: 1 }];
if (sql.includes('FROM h5_user_memory_items')) {
return [[{
id: 'mem-promoted', user_id: 'u-1', label: 'episodic', memory_text: '候选灰度代号',
source_session_id: 's-1', confidence: 0.9, status: 'active', created_at: 100, updated_at: 200,
}]];
}
throw new Error(`Unexpected MySQL query: ${sql}`);
},
};
const memory = await createMemoryV2Runtime({
logger: silentLogger(),
mysqlPool,
legacyMemoryService: legacyService(),
env: {
MEMORY_ENABLED: '1',
MEMORY_BACKEND: 'pgvector',
MEMORY_VECTOR_ENABLED: '1',
MEMORY_PGVECTOR_DATABASE_URL: 'postgresql://local/memory',
MEMORY_PGVECTOR_EMBEDDING_MODULE: './fake-embed.mjs',
MEMORY_LIFECYCLE_ENABLED: '1',
MEMORY_PROMOTION_ENABLED: '1',
MEMORY_LIFECYCLE_ROLLOUT_MODE: 'canary',
MEMORY_LIFECYCLE_ROLLOUT_USER_IDS: 'u-1',
},
async importPg() { return { Pool: FakePool }; },
async importModule() { return { embedQuery: async () => [0.3, 0.4] }; },
});
const result = await memory.lifecycle.promote({ userId: 'u-1' });
assert.equal(result.promoted, 1);
assert.deepEqual(result.promotedUserIds, ['u-1']);
const insert = pgCalls.find((call) => call.sql.includes('INSERT INTO "memory_embeddings"'));
assert.ok(insert);
assert.equal(insert.params[1], '候选灰度代号');
await memory.close();
});
test('createMemoryV2Runtime selects pgvector only when pool and embedding are configured', async () => {
const queries = [];
let poolEnded = false;
+1 -1
View File
@@ -58,7 +58,7 @@
"test:scenario": "node scripts/run-scenario-test.mjs",
"verify:children-hobby-diet-survey": "node scripts/verify-children-hobby-diet-survey.mjs",
"test:scenario:john4-diet": "node scripts/run-scenario-test.mjs --scenario john4-children-hobby-diet-update",
"test": "node --test auth.test.mjs asr-proxy.test.mjs billing.test.mjs billing-token-state.test.mjs billing-recharge.test.mjs wechat-pay.test.mjs wechat-oauth.test.mjs wechat-mp.test.mjs schedule-intent.test.mjs schedule-reminder-worker.test.mjs capabilities.test.mjs policies.test.mjs chat-skills.test.mjs chat-intent-router.test.mjs chat-finish-sync.test.mjs chat-agent-run-gate.test.mjs conversation-display.test.mjs user-publish.test.mjs user-memory-profile.test.mjs skills-registry.test.mjs skill-runtime-policy.test.mjs excel-analyst.test.mjs agent-run-gateway.test.mjs agent-run-routes.test.mjs session-broker.test.mjs sse-event-taxonomy.test.mjs goosed-proxy-boundary.test.mjs agent-run-stream.test.mjs mindspace-h5-html-finish-guard.test.mjs admin-routes.test.mjs image-make-admin-config.test.mjs asset-gateway.test.mjs image-make-client.test.mjs mindspace-image-generation.test.mjs mindspace-image-generation-routes.test.mjs mindspace-image-review.test.mjs mindspace-run-public-html-scope.test.mjs direct-chat-service.test.mjs tool-gateway.test.mjs mindspace.test.mjs mindspace-scan.test.mjs mindspace-assets.test.mjs mindspace-local-runtime-services.test.mjs mindspace-local-server-adapter.test.mjs mindspace-public-asset-token.test.mjs mindspace-remote-server-adapter.test.mjs mindspace-server-adapter.test.mjs mindspace-pages.test.mjs mindspace-page-sync-service.test.mjs public-site-bases.test.mjs mindspace-html-download-links.test.mjs mindspace-page-purge.test.mjs mindspace-public-delivery.test.mjs mindspace-public-page-context.test.mjs mindspace-published-page-csp.test.mjs mindspace-published-script-localize.test.mjs agent-run-deliverable-check.test.mjs mindspace-public-route.test.mjs mindspace-publications.test.mjs mindspace-public-links.test.mjs mindspace-chat-save.test.mjs mindspace-chat-docx-package.test.mjs mindspace-public-finish-sync.test.mjs mindspace-chat-context.test.mjs mindspace-canonical-url.test.mjs mindspace-conversation-package.test.mjs mindspace-conversation-package-backfill.test.mjs mindspace-conversation-package-public-html.test.mjs mindspace-conversation-package-verify.test.mjs mindspace-conversation-package-registry.test.mjs mindspace-conversation-package-routes.test.mjs mindspace-conversation-package-store.test.mjs mindspace-conversation-schema.test.mjs mindspace-runtime-config.test.mjs mindspace-config.test.mjs mindspace-analytics.test.mjs mindspace-service.test.mjs mindspace-storage-adapter.test.mjs mindspace-content-scan.test.mjs mindspace-html-localize.test.mjs mindspace-visual-editor.test.mjs mindspace-cleanup.test.mjs mindspace-thumbnails.test.mjs mindspace-workspace-thumbnails.test.mjs mindspace-workspace-sync.test.mjs mindspace-asset-preview.test.mjs mindspace-agent-jobs.test.mjs mindspace-agent-runner.test.mjs mindspace-sandbox-mcp.test.mjs mindspace-userdata-postgres.test.mjs postgres-user-data-space-service.test.mjs user-data-space-service.test.mjs page-data-routes.test.mjs page-access-policy.test.mjs page-access-visitor.test.mjs page-data-public-service.test.mjs page-data-integration.test.mjs page-data-log-store.test.mjs page-data-ops.test.mjs page-data-session-store.test.mjs page-data-browser-client.test.mjs page-data-policy-index.test.mjs message-stream.test.mjs mindspace-service/mindspace-rpc-server.test.mjs plaza-posts.test.mjs plaza-interactions.test.mjs plaza-algorithm.test.mjs plaza-seo.test.mjs plaza-ops.test.mjs user-auth.test.mjs llm-providers.test.mjs admin-guard.test.mjs user-feedback.test.mjs memory-v2.test.mjs memory-v2-admin-config.test.mjs memory-v2-lifecycle.test.mjs memory-v2-adapter-scaffold.test.mjs memory-v2-backend-contract.test.mjs memory-v2-health.test.mjs memory-v2-runtime.test.mjs memory-v2-plugin-backends.test.mjs memory-v2-pgvector.test.mjs memory-v2-pgvector-schema.test.mjs memory-v2-pgvector-backfill.test.mjs memory-v2-pgvector-smoke.test.mjs memory-v2-qdrant.test.mjs memory-v2-weaviate.test.mjs memory-v2-mem0.test.mjs memory-v2-letta.test.mjs memory-v2-external-adapters.test.mjs scripts/embed-memory-v2-local-hash.test.mjs scripts/check-memory-v2-app-canary.test.mjs scripts/check-memory-v2-config-gaps.test.mjs scripts/check-memory-v2-contracts.test.mjs scripts/check-memory-v2-health.test.mjs scripts/check-memory-v2-session-flow.test.mjs scripts/check-memory-v2-stack.test.mjs scripts/setup-memory-v2-pgvector-schema.test.mjs scripts/backfill-memory-v2-pgvector.test.mjs scripts/scaffold-memory-v2-backend.test.mjs scripts/smoke-memory-v2-pgvector.test.mjs scripts/smoke-memory-v2-qdrant.test.mjs scripts/smoke-memory-v2-external.test.mjs scripts/mock-memory-v2-services.test.mjs",
"test": "node --test auth.test.mjs asr-proxy.test.mjs billing.test.mjs billing-token-state.test.mjs billing-recharge.test.mjs wechat-pay.test.mjs wechat-oauth.test.mjs wechat-mp.test.mjs wechat-media.test.mjs wechat/image-generation-policy.test.mjs wechat/verify/generated-thumbnail.test.mjs schedule-intent.test.mjs schedule-reminder-worker.test.mjs capabilities.test.mjs policies.test.mjs chat-skills.test.mjs chat-intent-router.test.mjs chat-finish-sync.test.mjs chat-agent-run-gate.test.mjs conversation-display.test.mjs user-publish.test.mjs user-memory-profile.test.mjs skills-registry.test.mjs skill-runtime-policy.test.mjs excel-analyst.test.mjs agent-run-gateway.test.mjs agent-run-routes.test.mjs session-broker.test.mjs sse-event-taxonomy.test.mjs goosed-proxy-boundary.test.mjs agent-run-stream.test.mjs mindspace-h5-html-finish-guard.test.mjs admin-routes.test.mjs image-make-admin-config.test.mjs asset-gateway.test.mjs image-make-client.test.mjs mindspace-image-generation.test.mjs mindspace-image-generation-routes.test.mjs mindspace-image-review.test.mjs mindspace-run-public-html-scope.test.mjs direct-chat-service.test.mjs tool-gateway.test.mjs mindspace.test.mjs mindspace-scan.test.mjs mindspace-assets.test.mjs mindspace-local-runtime-services.test.mjs mindspace-local-server-adapter.test.mjs mindspace-public-asset-token.test.mjs mindspace-remote-server-adapter.test.mjs mindspace-server-adapter.test.mjs mindspace-pages.test.mjs mindspace-page-sync-service.test.mjs public-site-bases.test.mjs mindspace-html-download-links.test.mjs mindspace-page-purge.test.mjs mindspace-public-delivery.test.mjs mindspace-public-page-context.test.mjs mindspace-published-page-csp.test.mjs mindspace-published-script-localize.test.mjs agent-run-deliverable-check.test.mjs mindspace-public-route.test.mjs mindspace-publications.test.mjs mindspace-public-links.test.mjs mindspace-chat-save.test.mjs mindspace-chat-docx-package.test.mjs mindspace-public-finish-sync.test.mjs mindspace-chat-context.test.mjs mindspace-canonical-url.test.mjs mindspace-conversation-package.test.mjs mindspace-conversation-package-backfill.test.mjs mindspace-conversation-package-public-html.test.mjs mindspace-conversation-package-verify.test.mjs mindspace-conversation-package-registry.test.mjs mindspace-conversation-package-routes.test.mjs mindspace-conversation-package-store.test.mjs mindspace-conversation-schema.test.mjs mindspace-runtime-config.test.mjs mindspace-config.test.mjs mindspace-analytics.test.mjs mindspace-service.test.mjs mindspace-storage-adapter.test.mjs mindspace-content-scan.test.mjs mindspace-html-localize.test.mjs mindspace-visual-editor.test.mjs mindspace-cleanup.test.mjs mindspace-thumbnails.test.mjs mindspace-workspace-thumbnails.test.mjs mindspace-workspace-sync.test.mjs mindspace-asset-preview.test.mjs mindspace-agent-jobs.test.mjs mindspace-agent-runner.test.mjs mindspace-sandbox-mcp.test.mjs mindspace-userdata-postgres.test.mjs postgres-user-data-space-service.test.mjs user-data-space-service.test.mjs page-data-routes.test.mjs page-access-policy.test.mjs page-access-visitor.test.mjs page-data-public-service.test.mjs page-data-integration.test.mjs page-data-log-store.test.mjs page-data-ops.test.mjs page-data-session-store.test.mjs page-data-browser-client.test.mjs page-data-policy-index.test.mjs message-stream.test.mjs mindspace-service/mindspace-rpc-server.test.mjs plaza-posts.test.mjs plaza-interactions.test.mjs plaza-algorithm.test.mjs plaza-seo.test.mjs plaza-ops.test.mjs user-auth.test.mjs llm-providers.test.mjs admin-guard.test.mjs user-feedback.test.mjs memory-v2.test.mjs memory-v2-admin-config.test.mjs memory-v2-lifecycle.test.mjs memory-v2-adapter-scaffold.test.mjs memory-v2-backend-contract.test.mjs memory-v2-health.test.mjs memory-v2-runtime.test.mjs memory-v2-plugin-backends.test.mjs memory-v2-pgvector.test.mjs memory-v2-pgvector-schema.test.mjs memory-v2-pgvector-backfill.test.mjs memory-v2-pgvector-smoke.test.mjs memory-v2-qdrant.test.mjs memory-v2-weaviate.test.mjs memory-v2-mem0.test.mjs memory-v2-letta.test.mjs memory-v2-external-adapters.test.mjs scripts/embed-memory-v2-local-hash.test.mjs scripts/check-memory-v2-app-canary.test.mjs scripts/check-memory-v2-config-gaps.test.mjs scripts/check-memory-v2-contracts.test.mjs scripts/check-memory-v2-health.test.mjs scripts/check-memory-v2-session-flow.test.mjs scripts/check-memory-v2-stack.test.mjs scripts/setup-memory-v2-pgvector-schema.test.mjs scripts/backfill-memory-v2-pgvector.test.mjs scripts/scaffold-memory-v2-backend.test.mjs scripts/smoke-memory-v2-pgvector.test.mjs scripts/smoke-memory-v2-qdrant.test.mjs scripts/smoke-memory-v2-external.test.mjs scripts/mock-memory-v2-services.test.mjs",
"test:image-review": "node --test mindspace-image-review.test.mjs mindspace-image-generation.test.mjs",
"test:mindspace-service": "node --test mindspace-service/mindspace-rpc-server.test.mjs",
"verify:chat-finish-sync": "node scripts/verify-chat-finish-sync.mjs",
+44
View File
@@ -6,6 +6,10 @@ const releaseScript = fs.readFileSync(
new URL('./scripts/release-portal-runtime-prod.sh', import.meta.url),
'utf8',
);
const mindSpaceReleaseScript = fs.readFileSync(
new URL('./scripts/release-mindspace-service-prod.sh', import.meta.url),
'utf8',
);
test('103 release remounts goosed after swapping the live directory', () => {
assert.match(releaseScript, /remount_goosed_after_live_swap\(\)/);
@@ -23,3 +27,43 @@ test('103 release remounts goosed after swapping the live directory', () => {
assert.ok(remountIndex > swapIndex, 'goosed remount must happen after the live swap');
assert.ok(portalStartIndex > remountIndex, 'Portal must start only after goosed remount succeeds');
});
test('MindSpace release preserves runtime data and returns it during rollback', () => {
assert.match(mindSpaceReleaseScript, /PERSISTED_DATA_MOVED=0/);
assert.match(mindSpaceReleaseScript, /PERSISTED_USERS_MOVED=0/);
assert.match(mindSpaceReleaseScript, /mv "\$\{OLD_APP_ARCHIVE\}\/data" "\$\{APP_DIR\}\/data"/);
assert.match(mindSpaceReleaseScript, /mv "\$\{OLD_APP_ARCHIVE\}\/users" "\$\{APP_DIR\}\/users"/);
assert.match(mindSpaceReleaseScript, /mv "\$\{APP_DIR\}\/data" "\$\{OLD_APP_ARCHIVE\}\/data"/);
assert.match(mindSpaceReleaseScript, /mv "\$\{APP_DIR\}\/users" "\$\{OLD_APP_ARCHIVE\}\/users"/);
});
test('MindSpace release waits for the old listener and verifies the new launchd job', () => {
const bootoutIndex = mindSpaceReleaseScript.indexOf(
'launchctl bootout "${LAUNCHD_GUI}/${SERVICE_LABEL}"',
);
const listenerWaitIndex = mindSpaceReleaseScript.indexOf('lsof -nP -iTCP:8082', bootoutIndex);
const swapIndex = mindSpaceReleaseScript.indexOf('mv "${NEW_DIR}" "${APP_DIR}"');
const bootstrapIndex = mindSpaceReleaseScript.indexOf(
'launchctl bootstrap "${LAUNCHD_GUI}" "${SERVICE_PLIST}"',
swapIndex,
);
const portalRestartIndex = mindSpaceReleaseScript.indexOf(
'launchctl kickstart -k "${LAUNCHD_GUI}/${PORTAL_LABEL}"',
bootstrapIndex,
);
const finalServiceCheckIndex = mindSpaceReleaseScript.indexOf('service_is_running', portalRestartIndex);
assert.ok(bootoutIndex >= 0, 'old MindSpace launchd job must be stopped');
assert.ok(listenerWaitIndex > bootoutIndex, 'release must wait for the old 8082 listener to exit');
assert.ok(swapIndex > listenerWaitIndex, 'runtime swap must happen after the old listener exits');
assert.ok(bootstrapIndex > swapIndex, 'new launchd job must start after the runtime swap');
assert.doesNotMatch(
mindSpaceReleaseScript.slice(bootstrapIndex, bootstrapIndex + 120),
/\|\| true/,
'launchctl bootstrap failures must not be ignored',
);
assert.ok(
finalServiceCheckIndex > portalRestartIndex,
'MindSpace launchd state must be rechecked after Portal restarts',
);
});
+23 -5
View File
@@ -203,6 +203,10 @@ say() {
printf '\n[remote %s] %s\n' "$(date +%H:%M:%S)" "$*"
}
service_is_running() {
launchctl print "${LAUNCHD_GUI}/${SERVICE_LABEL}" 2>/dev/null | grep -q 'state = running'
}
rollback() {
set +e
say "rollback: restoring previous state"
@@ -314,6 +318,16 @@ EOF
say "切换 MindSpace service live 目录"
launchctl bootout "${LAUNCHD_GUI}/${SERVICE_LABEL}" >/dev/null 2>&1 || true
for _ in $(seq 1 60); do
if ! lsof -nP -iTCP:8082 -sTCP:LISTEN >/dev/null 2>&1; then
break
fi
sleep 1
done
if lsof -nP -iTCP:8082 -sTCP:LISTEN >/dev/null 2>&1; then
echo "旧 MindSpace service 未在超时内退出,拒绝切换 runtime" >&2
exit 1
fi
rm -rf "${OLD_APP_ARCHIVE}"
if [[ -d "${APP_DIR}" ]]; then
mv "${APP_DIR}" "${OLD_APP_ARCHIVE}"
@@ -335,19 +349,20 @@ fi
say "启动 MindSpace service"
mkdir -p "${APP_DIR}/data/mindspace" "${APP_DIR}/users"
launchctl bootstrap "${LAUNCHD_GUI}" "${SERVICE_PLIST}" >/dev/null 2>&1 || true
launchctl enable "${LAUNCHD_GUI}/${SERVICE_LABEL}" >/dev/null 2>&1 || true
launchctl kickstart -k "${LAUNCHD_GUI}/${SERVICE_LABEL}" >/dev/null 2>&1 || true
launchctl bootstrap "${LAUNCHD_GUI}" "${SERVICE_PLIST}" >/dev/null
SERVICE_STARTED=1
launchctl enable "${LAUNCHD_GUI}/${SERVICE_LABEL}" >/dev/null
launchctl kickstart -k "${LAUNCHD_GUI}/${SERVICE_LABEL}" >/dev/null
for _ in $(seq 1 30); do
code="$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:8082/health || true)"
if [[ "${code}" == "200" ]]; then
if [[ "${code}" == "200" ]] && service_is_running; then
break
fi
sleep 1
done
[[ "$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:8082/health || true)" == "200" ]]
service_is_running
[[ "$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:8082/mindspace/v1/contract || true)" == "200" ]]
set_env() {
@@ -395,6 +410,9 @@ for _ in $(seq 1 30); do
sleep 1
done
[[ "$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:8081/api/status || true)" == "200" ]]
sleep 3
[[ "$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:8082/health || true)" == "200" ]]
service_is_running
trap - ERR
@@ -403,7 +421,7 @@ printf 'release_id=%s\n' "${RELEASE_ID}"
printf 'service_root=%s\n' "${APP_DIR}"
printf 'service_backup=%s\n' "${APP_BACKUP}"
printf 'env_backup=%s\n' "${ENV_BACKUP}"
printf 'service_health=%s\n' "$(curl -s http://127.0.0.1:8082/health)"
printf 'service_health=%s\n' "$(curl -fsS http://127.0.0.1:8082/health)"
REMOTE
say "发布完成"
+100
View File
@@ -3,12 +3,15 @@ import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { fetch as undiciFetch } from 'undici';
import sharp from 'sharp';
import { buildPublicUrl, PUBLISH_ROOT_DIR, PUBLIC_ZONE_DIR } from './user-publish.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const DEFAULT_WECHAT_MEDIA_URL = 'https://api.weixin.qq.com/cgi-bin/media/get';
const DEFAULT_WECHAT_MEDIA_UPLOAD_URL = 'https://api.weixin.qq.com/cgi-bin/media/upload';
const DEFAULT_MAX_IMAGE_BYTES = 10 * 1024 * 1024;
const DEFAULT_MAX_OUTBOUND_IMAGE_BYTES = 2 * 1024 * 1024;
const DEFAULT_MAX_ATTACHMENT_BYTES = 30 * 1024 * 1024;
const ALLOWED_IMAGE_MIME_TYPES = new Map([
['image/jpeg', 'jpg'],
@@ -96,6 +99,103 @@ export async function downloadTemporaryMedia(accessToken, mediaId, { wechatFetch
return { buffer, contentType };
}
async function normalizeWechatOutboundImage(buffer, maxBytes = DEFAULT_MAX_OUTBOUND_IMAGE_BYTES) {
ensureImageWithinLimit(buffer, DEFAULT_MAX_IMAGE_BYTES * 2);
const attempts = [
{ width: 2048, quality: 86 },
{ width: 1600, quality: 74 },
{ width: 1280, quality: 62 },
];
for (const attempt of attempts) {
const normalized = await sharp(buffer, { sequentialRead: true })
.rotate()
.resize({
width: attempt.width,
height: attempt.width,
fit: 'inside',
withoutEnlargement: true,
})
.flatten({ background: '#ffffff' })
.jpeg({ quality: attempt.quality, progressive: true, mozjpeg: true })
.toBuffer();
if (normalized.length <= maxBytes) {
return { buffer: normalized, contentType: 'image/jpeg', filename: 'memind-generated.jpg' };
}
}
throw new Error(`生成图片压缩后仍超过微信图片限制(${maxBytes} bytes`);
}
export async function uploadWechatGeneratedImage(
accessToken,
publicUrl,
{
wechatFetch = undiciFetch,
publicBaseUrl = '',
allowedPublicBaseUrls = [],
uploadUrl = DEFAULT_WECHAT_MEDIA_UPLOAD_URL,
maxBytes = DEFAULT_MAX_OUTBOUND_IMAGE_BYTES,
} = {},
) {
if (!accessToken) throw new Error('缺少微信 access_token');
if (!publicUrl) throw new Error('缺少生成图片公网地址');
const resolvedUrl = new URL(String(publicUrl), publicBaseUrl || undefined).toString();
const allowedOrigins = new Set();
for (const baseUrl of [publicBaseUrl, ...allowedPublicBaseUrls]) {
if (!baseUrl) continue;
try {
allowedOrigins.add(new URL(String(baseUrl)).origin);
} catch {
// Ignore invalid optional bases; at least one valid configured origin is required below.
}
}
if (allowedOrigins.size > 0 && !allowedOrigins.has(new URL(resolvedUrl).origin)) {
throw new Error('生成图片地址不属于当前 MindSpace 可信公网域名');
}
const sourceResponse = await wechatFetch(resolvedUrl, {
method: 'GET',
headers: { Accept: 'image/*,*/*;q=0.8' },
});
if (!sourceResponse.ok) {
const text = await sourceResponse.text().catch(() => '');
throw new Error(text || `生成图片下载失败 (${sourceResponse.status})`);
}
const declaredBytes = Number(sourceResponse.headers.get('content-length') ?? 0);
if (Number.isFinite(declaredBytes) && declaredBytes > DEFAULT_MAX_IMAGE_BYTES * 2) {
throw new Error('生成图片下载体积超过安全限制');
}
const sourceBuffer = Buffer.from(await sourceResponse.arrayBuffer());
const normalized = await normalizeWechatOutboundImage(sourceBuffer, maxBytes);
const form = new FormData();
form.append(
'media',
new Blob([normalized.buffer], { type: normalized.contentType }),
normalized.filename,
);
const endpoint = new URL(uploadUrl);
endpoint.searchParams.set('access_token', accessToken);
endpoint.searchParams.set('type', 'image');
const response = await wechatFetch(endpoint.toString(), {
method: 'POST',
body: form,
});
const text = await response.text().catch(() => '');
let payload = {};
try {
payload = text ? JSON.parse(text) : {};
} catch {
payload = {};
}
if (!response.ok || Number(payload?.errcode ?? 0) !== 0 || !String(payload?.media_id ?? '').trim()) {
const detail = String(payload?.errmsg ?? text ?? '').trim() || `HTTP ${response.status}`;
throw new Error(`微信生成图片素材上传失败:${detail}`);
}
return {
mediaId: String(payload.media_id),
bytes: normalized.buffer.length,
contentType: normalized.contentType,
};
}
export function buildWechatImagePublicUrl({
publicBaseUrl,
publishKey,
+73
View File
@@ -0,0 +1,73 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import sharp from 'sharp';
import { uploadWechatGeneratedImage } from './wechat-media.mjs';
test('uploadWechatGeneratedImage converts a generated asset and uploads WeChat image media', async () => {
const source = await sharp({
create: { width: 32, height: 32, channels: 3, background: '#336699' },
}).webp().toBuffer();
const calls = [];
const result = await uploadWechatGeneratedImage(
'access-1',
'/MindSpace/user/public/images/fresh.webp',
{
publicBaseUrl: 'https://example.com',
wechatFetch: async (url, init = {}) => {
calls.push({ url: String(url), init });
if (String(url).startsWith('https://example.com/')) {
return new Response(source, { status: 200, headers: { 'Content-Type': 'image/webp' } });
}
if (String(url).includes('/cgi-bin/media/upload')) {
assert.equal(init.method, 'POST');
assert.ok(init.body instanceof FormData);
return new Response(JSON.stringify({ type: 'image', media_id: 'wx-media-1', created_at: 1 }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
throw new Error(`unexpected url: ${url}`);
},
},
);
assert.equal(result.mediaId, 'wx-media-1');
assert.equal(result.contentType, 'image/jpeg');
assert.ok(result.bytes > 0);
assert.match(calls[1].url, /access_token=access-1/);
assert.match(calls[1].url, /type=image/);
});
test('uploadWechatGeneratedImage accepts configured imgproxy origin and rejects unknown origins', async () => {
const source = await sharp({
create: { width: 16, height: 16, channels: 3, background: '#884422' },
}).png().toBuffer();
const wechatFetch = async (url) => {
if (String(url).startsWith('https://img.example.com/')) {
return new Response(source, { status: 200, headers: { 'Content-Type': 'image/png' } });
}
return new Response(JSON.stringify({ type: 'image', media_id: 'wx-media-imgproxy' }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
};
const accepted = await uploadWechatGeneratedImage(
'access-2',
'https://img.example.com/signed/generated.webp',
{
publicBaseUrl: 'https://app.example.com',
allowedPublicBaseUrls: ['https://img.example.com'],
wechatFetch,
},
);
assert.equal(accepted.mediaId, 'wx-media-imgproxy');
await assert.rejects(
uploadWechatGeneratedImage('access-2', 'https://untrusted.example.net/generated.webp', {
publicBaseUrl: 'https://app.example.com',
allowedPublicBaseUrls: ['https://img.example.com'],
wechatFetch,
}),
/可信公网域名/,
);
});
+7
View File
@@ -36,6 +36,11 @@ export function loadWechatMpConfig(env = process.env) {
env.H5_WECHAT_MP_APP_SECRET?.trim() ?? env.H5_WECHAT_APP_SECRET?.trim() ?? '';
const token = env.H5_WECHAT_MP_TOKEN?.trim() ?? '';
const publicBaseUrl = env.H5_PUBLIC_BASE_URL?.trim()?.replace(/\/$/, '') ?? '';
const generatedImagePublicBaseUrls = [...new Set([
publicBaseUrl,
env.IMGPROXY_BASE_URL?.trim()?.replace(/\/$/, '') ?? '',
...parseCsvList(env.H5_WECHAT_MP_GENERATED_IMAGE_BASE_URLS).map((value) => value.replace(/\/$/, '')),
].filter(Boolean))];
const enabledFlag = env.H5_WECHAT_MP_ENABLED === '1';
const bindPath = env.H5_WECHAT_MP_BIND_PATH?.trim() || '/auth/wechat/authorize?intent=login';
return {
@@ -73,6 +78,7 @@ export function loadWechatMpConfig(env = process.env) {
DEFAULT_WECHAT_JSAPI_TICKET_URL,
mediaPublicBaseUrl:
env.H5_WECHAT_MP_MEDIA_PUBLIC_BASE_URL?.trim()?.replace(/\/$/, '') || publicBaseUrl,
generatedImagePublicBaseUrls,
maxImageBytes: Math.max(1, Number(env.H5_WECHAT_MP_MAX_IMAGE_BYTES ?? 10 * 1024 * 1024)),
maxFileBytes: Math.max(1, Number(env.H5_WECHAT_MP_MAX_FILE_BYTES ?? 30 * 1024 * 1024)),
acceptVoice: env.H5_WECHAT_MP_ACCEPT_VOICE !== '0',
@@ -81,6 +87,7 @@ export function loadWechatMpConfig(env = process.env) {
acceptLocation: env.H5_WECHAT_MP_ACCEPT_LOCATION !== '0',
acceptLink: env.H5_WECHAT_MP_ACCEPT_LINK !== '0',
mediaAnalysisGrayUsers: parseCsvList(env.H5_WECHAT_MP_MEDIA_GRAY_USERS),
requireFreshPageThumbnail: env.H5_WECHAT_MP_FRESH_PAGE_THUMBNAILS !== '0',
encodingAesKey: env.H5_WECHAT_MP_ENCODING_AES_KEY?.trim() ?? '',
};
}
+168 -9
View File
@@ -13,6 +13,7 @@ import {
downloadTemporaryMedia,
persistWechatAttachment,
persistWechatImage,
uploadWechatGeneratedImage,
} from './wechat-media.mjs';
import { normalizeWechatName, resolveWechatAddressName } from './wechat/user/display-name.mjs';
import { buildAckText } from './wechat/ack/ack-provider.mjs';
@@ -27,13 +28,23 @@ import { classifyWechatIntent } from './wechat/intent/classifier.mjs';
import { isPageGenerateText, isTopicResetText } from './wechat/intent/patterns.mjs';
import { isPageDataIntent } from './chat-skills.mjs';
import { resolvePageGenerateOutcome } from './wechat/handlers/page-generate.mjs';
import {
buildWechatImageRunMetadata,
resolveWechatImageGenerationPolicy,
WECHAT_PAGE_THUMBNAIL_MODE,
} from './wechat/image-generation-policy.mjs';
import { buildWechatAgentPrompt } from './wechat/prompts/chat-general.mjs';
import {
buildPageGenerateAgentPrompt,
buildPagePublishFailureText,
} from './wechat/prompts/page-generate.mjs';
import { selectSendableHtmlArtifacts } from './wechat/verify/page-artifact.mjs';
import {
collectWechatGeneratedImages,
verifyFreshWechatPageThumbnails,
} from './wechat/verify/generated-thumbnail.mjs';
import { resolveBillingTokenState } from './billing-token-state.mjs';
import { ensureWorkspaceHtmlThumbnail } from './mindspace-workspace-thumbnails.mjs';
import {
buildPageDataCollectFailureText,
buildPageDataDeliveryArtifactsFromBindResult,
@@ -333,6 +344,13 @@ function splitWechatText(text, maxBytes = WECHAT_CUSTOMER_TEXT_MAX_BYTES) {
return chunks.length > 0 ? chunks : ['我先收到了,但这次没有生成可发送的文本结果。'];
}
function appendGeneratedImageFallbackLink(text, generatedImage) {
const normalized = String(text ?? '').trim();
const publicUrl = String(generatedImage?.publicUrl ?? '').trim();
if (!publicUrl || normalized.includes(publicUrl)) return normalized;
return [normalized, `图片链接:${publicUrl}`].filter(Boolean).join('\n');
}
export { splitWechatText, WECHAT_CUSTOMER_TEXT_MAX_BYTES };
function flattenSessionTools(extensions = []) {
@@ -1079,6 +1097,9 @@ export function sanitizeWechatAgentOutboundText(text) {
function formatWechatAgentFailureMessage(err) {
const message = err instanceof Error ? err.message : String(err);
if (err?.code === 'WECHAT_IMAGE_GENERATION_REQUIRED') {
return '这次图片生成没有获得新的有效位图,所以我没有发送旧图或占位图。请稍后重试。';
}
if (isHtmlPublishFailureMessage(message)) return buildHtmlPublishFailureText();
if (isRecoverableWechatAgentSessionError(message) || isWechatAgentApiErrorText(message)) {
return '刚才专属会话状态异常,我已切换到新会话。请再发一次你的需求。';
@@ -1490,6 +1511,7 @@ export function createWechatMpService({
enabled: false,
};
}
const requireFreshPageThumbnail = config.requireFreshPageThumbnail === true;
config = {
...loadWechatMpConfig({}),
...config,
@@ -1507,6 +1529,7 @@ export function createWechatMpService({
mediaAnalysisGrayUsers: Array.isArray(config.mediaAnalysisGrayUsers)
? config.mediaAnalysisGrayUsers
: [],
requireFreshPageThumbnail,
asrTarget: config.asrTarget || DEFAULT_ASR_TARGET,
};
@@ -1771,6 +1794,37 @@ export function createWechatMpService({
}
};
const sendCustomerServiceImage = async (openid, generatedImage) => {
const publicUrl = String(generatedImage?.publicUrl ?? '').trim();
if (!publicUrl) throw new Error('生成图片缺少可发送的公网地址');
const accessToken = await getStableAccessToken();
const uploaded = await uploadWechatGeneratedImage(accessToken, publicUrl, {
wechatFetch,
publicBaseUrl: config.publicBaseUrl,
allowedPublicBaseUrls: config.generatedImagePublicBaseUrls,
});
const payload = await readJsonResponse(
await wechatFetch(
`${config.customerServiceUrl}?access_token=${encodeURIComponent(accessToken)}`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
touser: openid,
msgtype: 'image',
image: { media_id: uploaded.mediaId },
}),
},
),
);
if (Number(payload?.errcode ?? 0) !== 0) {
const errcode = Number(payload?.errcode ?? 0);
const errmsg = String(payload?.errmsg ?? '').trim() || 'unknown_error';
throw new Error(`微信图片客服消息发送失败 errcode=${errcode} errmsg=${errmsg}`);
}
return uploaded;
};
const sendTextToUser = async (userId, content) => {
const openid = await userAuth.getWechatOpenidForUser(userId, config.appId);
if (!openid) {
@@ -1779,6 +1833,43 @@ export function createWechatMpService({
await sendCustomerServiceText(openid, content);
};
const enforceFreshPageThumbnailDelivery = async ({
artifacts,
reply,
openid,
user,
imagePolicy,
publishDir,
}) => {
if (imagePolicy?.pageThumbnailMode !== WECHAT_PAGE_THUMBNAIL_MODE.REQUIRED_FRESH) return;
const images = collectWechatGeneratedImages(reply?.messages ?? []);
const verification = verifyFreshWechatPageThumbnails(artifacts, images);
if (verification.ok) {
try {
for (const match of verification.matches) {
const htmlRelativePath = path.relative(publishDir, match.artifact.localPath);
if (!htmlRelativePath || htmlRelativePath.startsWith('..') || path.isAbsolute(htmlRelativePath)) {
throw new Error('缩略图页面路径不在用户发布目录内');
}
await ensureWorkspaceHtmlThumbnail(publishDir, htmlRelativePath);
}
return;
} catch (error) {
verification.ok = false;
verification.reason = `thumbnail_render_failed:${error instanceof Error ? error.message : String(error)}`;
}
}
const text = buildPagePublishFailureText({ missingFreshThumbnail: true });
try {
await sendCustomerServiceText(openid, text, user);
} catch (sendErr) {
logger.error?.('WeChat MP fresh thumbnail failure notice failed:', sendErr);
}
const error = new Error(`wechat_page_fresh_thumbnail_required:${verification.reason}`);
error.code = 'WECHAT_PAGE_FRESH_THUMBNAIL_REQUIRED';
throw markWechatUserNotified(error);
};
const ensureSessionProvider = async (sessionId) => {
if (!applySessionLlmProvider || !sessionId) return;
const applied = await applySessionLlmProvider(sessionId);
@@ -2023,7 +2114,7 @@ export function createWechatMpService({
}
};
const buildIntentMetadata = (intent, { mediaAnalysisEnabled = false } = {}) => {
const buildIntentMetadata = (intent, { mediaAnalysisEnabled = false, imagePolicy = null } = {}) => {
const mediaPublicUrl = intent.media?.publicUrl || null;
const mediaItems = Array.isArray(intent.recentMediaItems) && intent.recentMediaItems.length > 0
? intent.recentMediaItems
@@ -2055,6 +2146,7 @@ export function createWechatMpService({
recognition: intent.msgType === 'voice' ? intent.agentText || null : null,
location: intent.location || null,
link: intent.link || null,
memindRun: buildWechatImageRunMetadata(imagePolicy),
};
};
@@ -2099,6 +2191,11 @@ export function createWechatMpService({
const mediaAnalysisEnabled = isWechatMediaGrayUser(user, config.mediaAnalysisGrayUsers);
const resetCandidate =
intent.msgType === 'text' || intent.msgType === 'voice' ? intent.agentText : '';
const imagePolicy = resolveWechatImageGenerationPolicy({
text: resetCandidate,
isPageGenerate: wechatIntent.kind === 'page.generate',
requireFreshPageThumbnail: config.requireFreshPageThumbnail,
});
// Page Data delivery owns persistent files, datasets and two publication
// policies. Reusing a conversational route here can make a new request
// inspect/retry unrelated historical pages from that session.
@@ -2139,14 +2236,17 @@ export function createWechatMpService({
const requestStartedAt = Date.now();
const agentPrompt =
wechatIntent.kind === 'page.generate'
? buildPageGenerateAgentPrompt(intent, { wantsDocx: wechatIntent.wantsDocx })
: buildWechatAgentPrompt(intent);
? buildPageGenerateAgentPrompt(intent, {
wantsDocx: wechatIntent.wantsDocx,
imagePolicy,
})
: buildWechatAgentPrompt(intent, { imagePolicy });
const reply = await executeSessionReply(
(pathname, init) => fetchForSession(sessionId, pathname, init),
sessionId,
requestId,
agentPrompt,
buildIntentMetadata(intent, { mediaAnalysisEnabled }),
buildIntentMetadata(intent, { mediaAnalysisEnabled, imagePolicy }),
{
submitReply: submitSessionReply
? ({ requestId: replyRequestId, userMessage }) =>
@@ -2159,6 +2259,12 @@ export function createWechatMpService({
: null,
},
);
const generatedImages = collectWechatGeneratedImages(reply?.messages ?? []);
if (imagePolicy.standaloneImageMode === 'required' && generatedImages.length === 0) {
const error = new Error('图片生成没有获得本轮新的有效位图,请稍后重试');
error.code = 'WECHAT_IMAGE_GENERATION_REQUIRED';
throw error;
}
const workingDir = publishLayout?.publishDir ?? (await userAuth.resolveWorkingDir(user.userId));
const linkExistsForRequest = resolveLinkExistsForWorkingDir(workingDir, linkExists);
const {
@@ -2237,6 +2343,16 @@ export function createWechatMpService({
} else if (htmlGenerationNeedsRetry || suspiciousPublishClaim) {
throw new Error('stale_session_poisoned_completion');
}
if (wechatIntent.kind === 'page.generate') {
await enforceFreshPageThumbnailDelivery({
artifacts: publishArtifacts,
reply,
openid: inbound.fromUserName,
user,
imagePolicy,
publishDir: workingDir,
});
}
const pageDataOutcome = await enforcePageDataCollectDelivery({
reply,
intent,
@@ -2263,11 +2379,23 @@ export function createWechatMpService({
const tokenState = await resolveWechatBillingTokenState(sessionId, reply.tokenState);
await userAuth.billSessionUsage(user.userId, sessionId, tokenState, requestId);
}
const finalizedReply = await maybeAttachPublishedHtmlLink(reply, {
let finalizedReply = await maybeAttachPublishedHtmlLink(reply, {
workingDir,
publicBaseUrl: config.publicBaseUrl,
artifacts: publishArtifacts,
});
if (
wechatIntent.kind !== 'page.generate'
&& imagePolicy.standaloneImageMode !== 'disabled'
&& generatedImages.length > 0
) {
try {
await sendCustomerServiceImage(inbound.fromUserName, generatedImages[0]);
} catch (sendErr) {
logger.warn?.('WeChat MP generated image native delivery failed, falling back to link:', sendErr);
finalizedReply = appendGeneratedImageFallbackLink(finalizedReply, generatedImages[0]);
}
}
scheduleWechatSessionSnapshotRefresh(sessionId, user.userId);
await sendCustomerServiceText(inbound.fromUserName, await guardScheduleReply(finalizedReply), user, {
verifiedHtmlUrls: publishArtifacts.map((artifact) => artifact.url),
@@ -2309,14 +2437,17 @@ export function createWechatMpService({
const retryStartedAt = Date.now();
const retryPrompt =
wechatIntent.kind === 'page.generate'
? buildPageGenerateAgentPrompt(intent, { wantsDocx: wechatIntent.wantsDocx })
: buildWechatAgentPrompt(intent);
? buildPageGenerateAgentPrompt(intent, {
wantsDocx: wechatIntent.wantsDocx,
imagePolicy,
})
: buildWechatAgentPrompt(intent, { imagePolicy });
const reply = await executeSessionReply(
(pathname, init) => fetchForSession(sessionId, pathname, init),
sessionId,
retryId,
retryPrompt,
buildIntentMetadata(intent, { mediaAnalysisEnabled }),
buildIntentMetadata(intent, { mediaAnalysisEnabled, imagePolicy }),
{
submitReply: submitSessionReply
? ({ requestId: replyRequestId, userMessage }) =>
@@ -2329,6 +2460,12 @@ export function createWechatMpService({
: null,
},
);
const generatedImages = collectWechatGeneratedImages(reply?.messages ?? []);
if (imagePolicy.standaloneImageMode === 'required' && generatedImages.length === 0) {
const error = new Error('图片生成没有获得本轮新的有效位图,请稍后重试');
error.code = 'WECHAT_IMAGE_GENERATION_REQUIRED';
throw error;
}
const workingDir = publishLayout?.publishDir ?? (await userAuth.resolveWorkingDir(user.userId));
const linkExistsForRequest = resolveLinkExistsForWorkingDir(workingDir, linkExists);
const {
@@ -2404,6 +2541,16 @@ export function createWechatMpService({
} else if (htmlGenerationNeedsRetry || suspiciousPublishClaim) {
throw new Error(buildHtmlPublishFailureText());
}
if (wechatIntent.kind === 'page.generate') {
await enforceFreshPageThumbnailDelivery({
artifacts: publishArtifacts,
reply,
openid: inbound.fromUserName,
user,
imagePolicy,
publishDir: workingDir,
});
}
const pageDataOutcome = await enforcePageDataCollectDelivery({
reply,
intent,
@@ -2430,11 +2577,23 @@ export function createWechatMpService({
const tokenState = await resolveWechatBillingTokenState(sessionId, reply.tokenState);
await userAuth.billSessionUsage(user.userId, sessionId, tokenState, retryId);
}
const finalizedReply = await maybeAttachPublishedHtmlLink(reply, {
let finalizedReply = await maybeAttachPublishedHtmlLink(reply, {
workingDir,
publicBaseUrl: config.publicBaseUrl,
artifacts: publishArtifacts,
});
if (
wechatIntent.kind !== 'page.generate'
&& imagePolicy.standaloneImageMode !== 'disabled'
&& generatedImages.length > 0
) {
try {
await sendCustomerServiceImage(inbound.fromUserName, generatedImages[0]);
} catch (sendErr) {
logger.warn?.('WeChat MP generated image native delivery failed, falling back to link:', sendErr);
finalizedReply = appendGeneratedImageFallbackLink(finalizedReply, generatedImages[0]);
}
}
scheduleWechatSessionSnapshotRefresh(sessionId, user.userId);
await sendCustomerServiceText(inbound.fromUserName, await guardScheduleReply(finalizedReply), user, {
verifiedHtmlUrls: publishArtifacts.map((artifact) => artifact.url),
+321 -2
View File
@@ -3,6 +3,7 @@ import crypto from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import test from 'node:test';
import sharp from 'sharp';
import {
buildWechatAgentPrompt,
buildWechatTextReply,
@@ -62,8 +63,9 @@ function inboundXml({
].join('');
}
function previewReadyPageHtml({ title = 'Page', subtitle = '测试页面' } = {}) {
return `<!doctype html><html><head><meta name="description" content="${subtitle}"><meta name="mindspace-cover" content='{"tag":"页面","accent":"#3366cc","accent2":"#112233","subtitle":"${subtitle}"}'><title>${title}</title></head><body><main>${'x'.repeat(600)}</main><p data-mindspace-page-tag="platform-brand">TKMind · 智趣</p></body></html>`;
function previewReadyPageHtml({ title = 'Page', subtitle = '测试页面', cover = '' } = {}) {
const coverField = cover ? `,"cover":"${cover}"` : '';
return `<!doctype html><html><head><meta name="description" content="${subtitle}"><meta name="mindspace-cover" content='{"tag":"页面","accent":"#3366cc","accent2":"#112233","subtitle":"${subtitle}"${coverField}}'><title>${title}</title></head><body><main>${'x'.repeat(600)}</main><p data-mindspace-page-tag="platform-brand">TKMind · 智趣</p></body></html>`;
}
function jsonEscapedPreviewReadyPageHtml(options = {}) {
@@ -95,6 +97,7 @@ function createBoundWechatService({
unboundTextPrefix: '请先绑定',
progressDelayMs: 0,
maxImageBytes: 1024 * 1024,
requireFreshPageThumbnail: false,
...config,
},
userAuth: {
@@ -856,9 +859,16 @@ test('loadWechatMpConfig requires full config and enable flag', () => {
H5_WECHAT_MP_APP_SECRET: 'secret',
H5_WECHAT_MP_TOKEN: 'token',
H5_PUBLIC_BASE_URL: 'https://example.com',
IMGPROXY_BASE_URL: 'https://img.example.com',
});
assert.equal(config.enabled, true);
assert.equal(config.bindPath, '/auth/wechat/authorize?intent=login');
assert.equal(config.requireFreshPageThumbnail, true);
assert.deepEqual(config.generatedImagePublicBaseUrls, [
'https://example.com',
'https://img.example.com',
]);
assert.equal(loadWechatMpConfig({ H5_WECHAT_MP_FRESH_PAGE_THUMBNAILS: '0' }).requireFreshPageThumbnail, false);
});
test('verifyWechatMpSignature accepts valid signature', () => {
@@ -4932,3 +4942,312 @@ test('wechat mp service exposes route status and can recreate route for bound us
assert.equal(calls.some((item) => item === 'register:session-new'), true);
assert.equal(calls.includes('upserted'), true);
});
test('wechat mp page delivery requires and verifies a current-run fresh thumbnail', async () => {
const token = 'token';
const timestamp = '1710000000';
const nonce = 'nonce';
const workspaceRoot = fs.mkdtempSync('/tmp/wechat-mp-fresh-thumbnail-');
const htmlPath = path.join(workspaceRoot, 'public', 'fresh.html');
const wechatPayloads = [];
const generated = {
ok: true,
jobId: 'job-fresh-page',
source: { mimeType: 'image/webp', width: 1280, height: 720 },
asset: {
id: 'asset-fresh-page',
htmlSrc: 'images/fresh-page.webp',
publicUrl: 'https://example.com/MindSpace/user-1/public/images/fresh-page.webp',
workspaceRelativePath: 'public/images/fresh-page.webp',
},
};
const pageHtml = previewReadyPageHtml({
title: 'Fresh',
subtitle: '本轮新缩略图',
cover: generated.asset.htmlSrc,
});
const pageImage = await sharp({
create: { width: 64, height: 64, channels: 3, background: '#3366cc' },
}).webp().toBuffer();
const eventFrame = (event) => `data: ${JSON.stringify(event)}\n\n`;
const service = createBoundWechatService({
token,
config: { requireFreshPageThumbnail: true },
userAuth: {
async resolveWorkingDir() {
return workspaceRoot;
},
async getUserPublishLayout() {
return {
publishDir: workspaceRoot,
displayName: 'John',
username: 'john',
slug: 'john',
constraints: null,
};
},
},
sessionApiFetch: async (sessionId, pathname, init = {}) => {
assert.equal(sessionId, 'session-1');
if (pathname === '/sessions/session-1/events') {
return new Response(
[
eventFrame({
type: 'Message',
request_id: 'req-fresh-page',
message: {
id: 'assistant-tools',
role: 'assistant',
metadata: { userVisible: true },
content: [
{
id: 'call-page-skill',
type: 'toolRequest',
toolCall: {
value: { name: 'load_skill', arguments: { name: 'static-page-publish' } },
},
},
{
id: 'call-generate',
type: 'toolRequest',
toolCall: {
value: {
name: 'sandbox-fs__generate_image',
arguments: { purpose: 'hero' },
},
},
},
{
id: 'call-generate',
type: 'toolResponse',
toolResult: {
status: 'success',
value: { content: [{ type: 'text', text: JSON.stringify(generated) }] },
},
},
{
id: 'call-write-page',
type: 'toolRequest',
toolCall: {
value: {
name: 'sandbox-fs__write_file',
arguments: { path: 'public/fresh.html', content: pageHtml },
},
},
},
],
},
}),
eventFrame({
type: 'Message',
request_id: 'req-fresh-page',
message: {
id: 'assistant-final',
role: 'assistant',
metadata: { userVisible: true },
content: [{
type: 'text',
text: '页面已完成:https://example.com/MindSpace/user-1/public/fresh.html',
}],
},
}),
eventFrame({
type: 'Finish',
request_id: 'req-fresh-page',
token_state: { inputTokens: 1, outputTokens: 2 },
}),
].join(''),
{ status: 200, headers: { 'Content-Type': 'text/event-stream' } },
);
}
if (pathname === '/sessions/session-1/reply') {
const body = JSON.parse(init.body);
assert.equal(body.user_message.metadata.memindRun.channel, 'wechat_mp');
assert.equal(body.user_message.metadata.memindRun.pageThumbnailMode, 'required_fresh');
assert.equal(body.user_message.metadata.memindRun.inlineImageMode, 'auto');
assert.match(body.user_message.content[0].text, /服务号页面新缩略图硬要求/);
assert.match(body.user_message.content[0].text, /本轮新生成资产/);
fs.mkdirSync(path.dirname(htmlPath), { recursive: true });
fs.writeFileSync(
htmlPath,
pageHtml,
'utf8',
);
fs.mkdirSync(path.join(workspaceRoot, 'public', 'images'), { recursive: true });
fs.writeFileSync(path.join(workspaceRoot, 'public', generated.asset.htmlSrc), pageImage);
return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } });
}
if (pathname === '/agent/harness_remember' || pathname === '/agent/harness_bootstrap') {
return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } });
}
throw new Error(`unexpected session path: ${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')) {
wechatPayloads.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 = () => 'req-fresh-page';
try {
const result = await service.handleInboundMessage(
inboundXml({ content: '请生成一个旅行 html 页面,文件名 public/fresh.html' }),
{ timestamp, nonce, signature: signatureFor(token, timestamp, nonce) },
);
assert.equal(result.status, 200);
await result.task;
assert.equal(fs.existsSync(path.join(workspaceRoot, 'public', 'fresh.thumbnail.svg')), true);
} finally {
crypto.randomUUID = originalRandomUuid;
fs.rmSync(workspaceRoot, { recursive: true, force: true });
}
assert.equal(wechatPayloads.length, 1);
assert.equal(wechatPayloads[0].msgtype, 'text');
assert.match(wechatPayloads[0].text.content, /fresh\.html/);
});
test('wechat mp standalone image intent sends a native image message and text', async () => {
const token = 'token';
const timestamp = '1710000000';
const nonce = 'nonce';
const sourceImage = await sharp({
create: { width: 48, height: 48, channels: 3, background: '#cc8844' },
}).webp().toBuffer();
const wechatPayloads = [];
const generated = {
ok: true,
jobId: 'job-chat-image',
source: { mimeType: 'image/webp', width: 1024, height: 1024 },
asset: {
id: 'asset-chat-image',
htmlSrc: 'images/chat-image.webp',
publicUrl: 'https://example.com/MindSpace/user-1/public/images/chat-image.webp',
workspaceRelativePath: 'public/images/chat-image.webp',
},
};
const eventFrame = (event) => `data: ${JSON.stringify(event)}\n\n`;
const service = createBoundWechatService({
token,
sessionApiFetch: async (sessionId, pathname, init = {}) => {
assert.equal(sessionId, 'session-1');
if (pathname === '/sessions/session-1/events') {
return new Response(
[
eventFrame({
type: 'Message',
request_id: 'req-chat-image',
message: {
id: 'assistant-tools',
role: 'assistant',
metadata: { userVisible: true },
content: [
{
id: 'call-image',
type: 'toolRequest',
toolCall: {
value: {
name: 'sandbox-fs__generate_image',
arguments: { purpose: 'inline_image' },
},
},
},
{
id: 'call-image',
type: 'toolResponse',
toolResult: {
status: 'success',
value: { content: [{ type: 'text', text: JSON.stringify(generated) }] },
},
},
],
},
}),
eventFrame({
type: 'Message',
request_id: 'req-chat-image',
message: {
id: 'assistant-final',
role: 'assistant',
metadata: { userVisible: true },
content: [{ type: 'text', text: '新图片已经生成。' }],
},
}),
eventFrame({
type: 'Finish',
request_id: 'req-chat-image',
token_state: { inputTokens: 1, outputTokens: 2 },
}),
].join(''),
{ status: 200, headers: { 'Content-Type': 'text/event-stream' } },
);
}
if (pathname === '/sessions/session-1/reply') {
const body = JSON.parse(init.body);
assert.equal(body.user_message.metadata.memindRun.imageGenerationMode, 'required');
assert.equal(body.user_message.metadata.memindRun.pageThumbnailMode, 'auto');
assert.match(body.user_message.content[0].text, /purpose=`inline_image`/);
return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } });
}
if (pathname === '/agent/harness_remember' || pathname === '/agent/harness_bootstrap') {
return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } });
}
throw new Error(`unexpected session path: ${pathname}`);
},
wechatFetch: async (url, init = {}) => {
const target = String(url);
if (target.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 (target === generated.asset.publicUrl) {
return new Response(sourceImage, { status: 200, headers: { 'Content-Type': 'image/webp' } });
}
if (target.includes('/cgi-bin/media/upload')) {
assert.ok(init.body instanceof FormData);
return new Response(JSON.stringify({ type: 'image', media_id: 'wx-generated-1' }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
if (target.includes('/cgi-bin/message/custom/send')) {
wechatPayloads.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 = () => 'req-chat-image';
try {
const result = await service.handleInboundMessage(
inboundXml({ content: '帮我生成一张雨夜橘猫图片' }),
{ timestamp, nonce, signature: signatureFor(token, timestamp, nonce) },
);
assert.equal(result.status, 200);
await result.task;
} finally {
crypto.randomUUID = originalRandomUuid;
}
assert.deepEqual(wechatPayloads.map((payload) => payload.msgtype), ['image', 'text']);
assert.equal(wechatPayloads[0].image.media_id, 'wx-generated-1');
assert.match(wechatPayloads[1].text.content, /新图片已经生成/);
});
+94
View File
@@ -0,0 +1,94 @@
import {
IMAGE_GENERATION_MODE,
resolveImageGenerationDecision,
} from '../chat-intent-router.mjs';
export const WECHAT_PAGE_THUMBNAIL_MODE = {
AUTO: 'auto',
REQUIRED_FRESH: 'required_fresh',
};
const INLINE_IMAGE_REQUIRED_PATTERNS = [
/(?:正文|内容|段落|章节).{0,12}(?:配图|插图|图片)/u,
/(?:每段|每章|文中|文章中).{0,12}(?:配图|插图|图片)/u,
/(?:多张|若干张|几张).{0,12}(?:配图|插图|图片)/u,
];
export function resolveWechatImageGenerationPolicy({
text,
isPageGenerate = false,
requireFreshPageThumbnail = false,
} = {}) {
const normalizedText = String(text ?? '').trim();
const requested = resolveImageGenerationDecision({ text: normalizedText });
const pageThumbnailMode = isPageGenerate && requireFreshPageThumbnail
? WECHAT_PAGE_THUMBNAIL_MODE.REQUIRED_FRESH
: WECHAT_PAGE_THUMBNAIL_MODE.AUTO;
const inlineImageMode = requested.mode === IMAGE_GENERATION_MODE.DISABLED
? IMAGE_GENERATION_MODE.DISABLED
: INLINE_IMAGE_REQUIRED_PATTERNS.some((pattern) => pattern.test(normalizedText))
? IMAGE_GENERATION_MODE.REQUIRED
: IMAGE_GENERATION_MODE.AUTO;
const standaloneImageMode = isPageGenerate
? IMAGE_GENERATION_MODE.AUTO
: requested.mode;
return {
channel: 'wechat_mp',
imageGenerationMode: pageThumbnailMode === WECHAT_PAGE_THUMBNAIL_MODE.REQUIRED_FRESH
? IMAGE_GENERATION_MODE.REQUIRED
: standaloneImageMode,
pageThumbnailMode,
inlineImageMode,
standaloneImageMode,
requested,
};
}
export function buildWechatImageRunMetadata(policy) {
if (!policy) return { channel: 'wechat_mp' };
return {
channel: 'wechat_mp',
imageGenerationMode: policy.imageGenerationMode,
pageThumbnailMode: policy.pageThumbnailMode,
inlineImageMode: policy.inlineImageMode,
};
}
export function buildWechatStandaloneImageInstruction(policy, { sourceMessageId = '' } = {}) {
if (!policy || policy.standaloneImageMode === IMAGE_GENERATION_MODE.AUTO) return '';
if (policy.standaloneImageMode === IMAGE_GENERATION_MODE.DISABLED) {
return '【图片策略】用户明确不要生成图片。本轮禁止调用 generate_image,只返回文字。';
}
const idempotencyKey = sourceMessageId
? `wechat-${String(sourceMessageId).replace(/[^a-zA-Z0-9._-]+/g, '-').slice(0, 80)}-image`
: '';
return [
'【微信服务号生图任务】用户明确要求生成一张新图片。',
'必须先调用 `load_skill` → `image-generation`,再调用一次 `sandbox-fs__generate_image`purpose=`inline_image`。',
'必须使用本轮新返回的 PNG/JPEG/WebP 资产;禁止复用历史图片、SVG、CSS 绘图、占位图或网页图片。',
idempotencyKey ? `调用 generate_image 时传入 idempotency_key=${idempotencyKey}` : '',
'成功后简短说明结果,不要输出内部工具步骤;失败时如实报告,禁止声称已经生成。',
].filter(Boolean).join('\n');
}
export function buildWechatPageImageInstruction(policy, { sourceMessageId = '' } = {}) {
if (policy?.pageThumbnailMode !== WECHAT_PAGE_THUMBNAIL_MODE.REQUIRED_FRESH) return '';
const keyPrefix = sourceMessageId
? `wechat-${String(sourceMessageId).replace(/[^a-zA-Z0-9._-]+/g, '-').slice(0, 72)}-page`
: 'wechat-page';
const inlineInstruction = policy.inlineImageMode === IMAGE_GENERATION_MODE.DISABLED
? '用户明确不要正文图片:页面正文和 Hero 不展示图片,但缩略图源图仍必须生成并只写入 mindspace-cover.cover。'
: policy.inlineImageMode === IMAGE_GENERATION_MODE.REQUIRED
? '用户明确要求正文配图:缩略图源图之外,只有正文确实需要不同画面时才可按需调用 inline_image。'
: '正文独立插图按意图判断;没有明确必要时不要额外调用 inline_image。';
return [
'【服务号页面新缩略图硬要求】每个本轮交付给用户的正式内容页面都必须使用本轮新生成、与主题一致的位图作为缩略图源图。',
'写 HTML 前先调用 `load_skill` → `image-generation`,再为每个正式内容页面各调用一次 `sandbox-fs__generate_image`,优先 purpose=`hero`。',
`幂等键使用 ${keyPrefix}-<页面序号>-thumbnail;不同正式页面不得共用同一张图。`,
'生成成功后,把返回的 asset.htmlSrc 原样写入对应页面的 mindspace-cover.cover。页面需要主视觉时复用同一张图作为 Hero,禁止再调用 card_cover/feed_cover。',
'管理后台、密码查看、重定向、下载包装和错误辅助页不属于正式内容页面,不要求单独生图;这类页面必须在 head 写 `<meta name="mindspace-page-role" content="auxiliary">` 供交付守卫识别。',
inlineInstruction,
'当前轮没有新的成功 generate_image、没有新 jobId,或正式页面未引用本轮资产时,禁止交付页面链接或声称完成。',
].join('\n');
}
+64
View File
@@ -0,0 +1,64 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
buildWechatPageImageInstruction,
buildWechatStandaloneImageInstruction,
resolveWechatImageGenerationPolicy,
WECHAT_PAGE_THUMBNAIL_MODE,
} from './image-generation-policy.mjs';
import { classifyWechatIntent } from './intent/classifier.mjs';
test('service-account page always requires a fresh thumbnail without forcing body images', () => {
const policy = resolveWechatImageGenerationPolicy({
text: '请做一个苏州旅行页面',
isPageGenerate: true,
requireFreshPageThumbnail: true,
});
assert.equal(policy.pageThumbnailMode, WECHAT_PAGE_THUMBNAIL_MODE.REQUIRED_FRESH);
assert.equal(policy.imageGenerationMode, 'required');
assert.equal(policy.inlineImageMode, 'auto');
assert.equal(policy.standaloneImageMode, 'auto');
assert.match(buildWechatPageImageInstruction(policy), /每个本轮交付给用户的正式内容页面/);
});
test('no-image page request disables body images but keeps required fresh thumbnail', () => {
const policy = resolveWechatImageGenerationPolicy({
text: '生成一个活动页面,只要文字,不要图片',
isPageGenerate: true,
requireFreshPageThumbnail: true,
});
assert.equal(policy.pageThumbnailMode, 'required_fresh');
assert.equal(policy.inlineImageMode, 'disabled');
assert.match(buildWechatPageImageInstruction(policy), /正文和 Hero 不展示图片/);
});
test('explicit body illustration request is independent from the page thumbnail', () => {
const policy = resolveWechatImageGenerationPolicy({
text: '生成一个科普页面,每个章节都要正文插图',
isPageGenerate: true,
requireFreshPageThumbnail: true,
});
assert.equal(policy.inlineImageMode, 'required');
});
test('standalone image intent gets an inline_image tool contract', () => {
const policy = resolveWechatImageGenerationPolicy({ text: '帮我生成一张雨夜橘猫图片' });
assert.equal(policy.standaloneImageMode, 'required');
const instruction = buildWechatStandaloneImageInstruction(policy, { sourceMessageId: 'msg-1' });
assert.match(instruction, /purpose=`inline_image`/);
assert.match(instruction, /idempotency_key=wechat-msg-1-image/);
});
test('explicitly declining a page keeps standalone image generation out of page flow', () => {
const text = '请生成一张雨夜橘猫插画,只生成图片,不要生成页面';
const intent = classifyWechatIntent({ msgType: 'text', agentText: text });
assert.equal(intent.kind, 'chat.general');
const policy = resolveWechatImageGenerationPolicy({
text,
isPageGenerate: intent.kind === 'page.generate',
requireFreshPageThumbnail: true,
});
assert.equal(policy.pageThumbnailMode, 'auto');
assert.equal(policy.standaloneImageMode, 'required');
});
+4
View File
@@ -3,6 +3,9 @@
export const PAGE_GENERATE_PATTERN =
/(?:生成|创建|做|写|帮我.*(?:生成|创建|做|写)).*(?:html|页面|网页|page|文件)/iu;
export const PAGE_GENERATE_NEGATION_PATTERN =
/(?:不要|无需|不用|别|不需要)\s*(?:再\s*)?(?:生成|创建|制作|做|写)\s*(?:任何|一个|新的)?\s*(?:html|页面|网页|page|文件)/iu;
export const DOCX_DOWNLOAD_PATTERN =
/(?:(?:word|docx|\.docx|\.doc|文档).*(?:下载|链接|导出|给我)|(?:下载|导出|提供|给我).*(?:word|docx|\.docx|\.doc|文档))/iu;
@@ -21,6 +24,7 @@ export const CONNECTIVITY_TEST_PATTERN = /^(测试\s*\d*|test\s*\d*)[!!。.\s]
export function isPageGenerateText(text) {
const normalized = String(text ?? '').trim();
if (!normalized) return false;
if (PAGE_GENERATE_NEGATION_PATTERN.test(normalized)) return false;
return PAGE_GENERATE_PATTERN.test(normalized);
}
+7 -1
View File
@@ -2,8 +2,9 @@ import { buildAutoChatSkillPrefix, isPageDataIntent } from '../../chat-skills.mj
import { shouldUseScheduleAssistant } from '../../schedule-intent.mjs';
import { buildCurrentTimeAgentPrefix } from '../../user-memory-profile.mjs';
import { isPageGenerateText, wantsDocxDownload } from '../intent/patterns.mjs';
import { buildWechatStandaloneImageInstruction } from '../image-generation-policy.mjs';
export function buildWechatAgentPrompt(intent, { grantedSkills = [] } = {}) {
export function buildWechatAgentPrompt(intent, { grantedSkills = [], imagePolicy = null } = {}) {
const msgType = String(intent?.msgType ?? 'text');
const agentText = intent?.agentText ?? intent?.content ?? '';
const autoSkillPrefix =
@@ -54,6 +55,9 @@ export function buildWechatAgentPrompt(intent, { grantedSkills = [] } = {}) {
'',
].filter(Boolean).join('\n')
: '';
const imageGenerationHint = buildWechatStandaloneImageInstruction(imagePolicy, {
sourceMessageId: intent?.msgId,
});
if (msgType === 'voice') {
return [
@@ -61,6 +65,7 @@ export function buildWechatAgentPrompt(intent, { grantedSkills = [] } = {}) {
pageDataCollectHint,
currentTimeHint,
scheduleAssistantHint,
imageGenerationHint,
'【微信服务号语音消息】用户通过语音输入,以下是微信识别结果。',
'',
`用户语音识别文本:${autoSkillPrefix}${String(agentText).trim()}`,
@@ -132,6 +137,7 @@ export function buildWechatAgentPrompt(intent, { grantedSkills = [] } = {}) {
if (pageDataCollectHint) lines.push(pageDataCollectHint);
if (pagePublishHint) lines.push(pagePublishHint);
if (scheduleAssistantHint) lines.push(scheduleAssistantHint);
if (imageGenerationHint) lines.push(imageGenerationHint);
lines.push(
'【微信服务号新消息】请只回答下面这条用户消息,不要主动延续无关的历史话题。',
'若用户只是在测试连通性,请一句话确认收到即可,不要展开旧任务。',
+26 -6
View File
@@ -1,9 +1,10 @@
import { buildCurrentTimeAgentPrefix } from '../../user-memory-profile.mjs';
import { buildWechatPageImageInstruction } from '../image-generation-policy.mjs';
/**
* Service-account-only page generation prompt. Does not use H5 buildAutoChatSkillPrefix.
*/
export function buildPageGenerateAgentPrompt(intent, { wantsDocx = false } = {}) {
export function buildPageGenerateAgentPrompt(intent, { wantsDocx = false, imagePolicy = null } = {}) {
const topic = String(intent?.agentText ?? intent?.displayText ?? '').trim();
const scheduleTimezone = process.env.H5_DEFAULT_TIMEZONE || 'Asia/Shanghai';
const docxBlock = wantsDocx
@@ -13,24 +14,33 @@ export function buildPageGenerateAgentPrompt(intent, { wantsDocx = false } = {})
'',
].join('\n')
: '';
const imageBlock = buildWechatPageImageInstruction(imagePolicy, {
sourceMessageId: intent?.msgId,
});
const coverExample = imageBlock
? '<本轮 generate_image 返回的 asset.htmlSrc>'
: 'assets/hero.jpg';
return [
'【微信服务号 · 页面生成任务】',
'这是服务号专用页面生成,不是普通聊天。必须按步骤完成,未完成前禁止告诉用户“已生成/已发布”。',
'',
docxBlock,
imageBlock,
'步骤(必须全部完成):',
'1. 调用 `load_skill`,参数 name=`static-page-publish`。',
'1. 必须先调用 `load_skill`,参数 name=`static-page-publish`。',
'2. 阅读技能说明后,用 sandbox-fs 的 `write_file` 或 `edit_file` 写入 `public/*.html`。',
'3. 禁止 shell/cat/heredoc/cp 写 HTML(不会出现在公网 MindSpace)。',
'4. 写完后确认目标文件已落盘,且内容是用户要的完整页面(不是占位 stub)。',
'5. 在 `<head>` 写入微信分享卡片所需元数据(必须):',
' - `<meta name="description" content="一句话摘要">`',
' - `<meta name="mindspace-cover" content=\'{"tag":"主题","accent":"#主色","accent2":"#辅色","subtitle":"卖点","cover":"assets/hero.jpg"}\'>`',
' tag/accent/subtitle 必须与页面主题一致;有 hero 图时 cover 指向同目录 raster 图(jpg/png)。',
` - \`<meta name="mindspace-cover" content='{"tag":"主题","accent":"#主色","accent2":"#辅色","subtitle":"卖点","cover":"${coverExample}"}'>\``,
imageBlock
? ' tag/accent/subtitle 必须与页面主题一致;cover 必须引用本轮新生成资产,不得省略、复用旧图或填写占位路径。'
: ' tag/accent/subtitle 必须与页面主题一致;有 hero 图时 cover 指向同目录 raster 图(jpg/png)。',
'6. 交付前运行 `npm run check:mindspace-cover`(或 node scripts/check-mindspace-cover.mjs --user <uid>);有 error 则补元数据后再回复链接。',
'7. 页脚加 `<p data-mindspace-page-tag="platform-brand">TKMind · 智趣</p>`(必须;禁止省略、不要用邮箱/tkmind.ai,也不要把该行 opacity 设太低导致看不见)。',
'8. 回复里只给一个正式域名链接;没有落盘或缺少上述元数据就不要发链接。',
'8. 回复里只给一个正式域名的唯一正确链接;没有落盘或缺少上述元数据就不要发链接。',
'',
buildCurrentTimeAgentPrefix({ timezone: scheduleTimezone }),
`用户需求:${topic}`,
@@ -39,7 +49,17 @@ export function buildPageGenerateAgentPrompt(intent, { wantsDocx = false } = {})
.join('\n');
}
export function buildPagePublishFailureText({ missingSharePreview = false, missingPlatformBrand = false } = {}) {
export function buildPagePublishFailureText({
missingSharePreview = false,
missingPlatformBrand = false,
missingFreshThumbnail = false,
} = {}) {
if (missingFreshThumbnail) {
return [
'这次页面内容已生成,但没有完成服务号要求的本轮新缩略图,所以我先不发页面链接。',
'请直接重发一次完整页面需求,我会重新生成主题匹配的新缩略图并完成页面交付。',
].join('\n');
}
if (missingPlatformBrand) {
return [
'这次页面已落盘,但缺少页脚平台品牌标记(data-mindspace-page-tag="platform-brand"),所以我先不发链接。',
+152
View File
@@ -0,0 +1,152 @@
import fs from 'node:fs';
import path from 'node:path';
const RASTER_MIME_PATTERN = /^image\/(?:png|jpeg|webp)$/i;
function parseToolResultJson(toolResult) {
const content = toolResult?.value?.content;
if (!Array.isArray(content)) return null;
for (const item of content) {
if (item?.type !== 'text' || !String(item.text ?? '').trim()) continue;
try {
return JSON.parse(String(item.text));
} catch {
// A non-JSON tool message cannot prove a fresh generated image.
}
}
return null;
}
function normalizeGeneratedImage(result) {
const mimeType = String(result?.source?.mimeType ?? result?.asset?.mimeType ?? '').toLowerCase();
if (!result?.ok || !String(result?.jobId ?? '').trim() || !RASTER_MIME_PATTERN.test(mimeType)) {
return null;
}
const asset = result.asset ?? {};
const htmlSrc = String(asset.htmlSrc ?? '').trim();
const publicUrl = String(asset.publicUrl ?? '').trim();
const workspaceRelativePath = String(asset.workspaceRelativePath ?? '').trim();
if (!htmlSrc && !publicUrl && !workspaceRelativePath) return null;
return {
jobId: String(result.jobId),
mimeType,
assetId: String(asset.id ?? '').trim() || null,
htmlSrc: htmlSrc || null,
publicUrl: publicUrl || null,
workspaceRelativePath: workspaceRelativePath || null,
};
}
export function collectWechatGeneratedImages(messages = []) {
const requestNames = new Map();
const images = [];
for (const message of messages) {
for (const item of message?.content ?? []) {
if (item?.type === 'toolRequest') {
const name = String(item?.toolCall?.value?.name ?? '').trim();
if (item.id && name) requestNames.set(String(item.id), name);
continue;
}
if (item?.type !== 'toolResponse') continue;
const name = requestNames.get(String(item.id ?? '')) ?? String(item?.toolResult?.name ?? '');
if (!name.endsWith('generate_image')) continue;
const toolResult = item.toolResult ?? {};
if (toolResult.status !== 'success' || toolResult?.value?.isError === true) continue;
const generated = normalizeGeneratedImage(parseToolResultJson(toolResult));
if (generated && !images.some((image) => image.jobId === generated.jobId)) images.push(generated);
}
}
return images;
}
function decodeHtmlAttribute(value) {
return String(value ?? '')
.replaceAll('&quot;', '"')
.replaceAll('&#34;', '"')
.replaceAll('&#39;', "'")
.replaceAll('&amp;', '&');
}
export function extractMindspaceCoverPath(html) {
const tag = String(html ?? '').match(/<meta[^>]*name=["']mindspace-cover["'][^>]*>/i)?.[0] ?? '';
if (!tag) return '';
const match = tag.match(/content=(['"])([\s\S]*?)\1/i);
if (!match?.[2]) return '';
try {
const meta = JSON.parse(decodeHtmlAttribute(match[2]));
return String(meta.cover ?? meta.image ?? '').trim();
} catch {
return '';
}
}
function normalizeRelativeAssetPath(value) {
const clean = String(value ?? '')
.trim()
.split(/[?#]/, 1)[0]
.replace(/\\/g, '/')
.replace(/^\.\//, '')
.replace(/^\/+/, '');
return clean.startsWith('public/') ? clean.slice('public/'.length) : clean;
}
function imageMatchesCover(image, cover, artifact) {
if (!cover) return false;
if (/^https?:\/\//i.test(cover)) return image.publicUrl === cover;
const coverPath = normalizeRelativeAssetPath(cover);
const artifactRelativePath = normalizeRelativeAssetPath(artifact?.relativePath ?? '');
const artifactDir = path.posix.dirname(artifactRelativePath);
const resolvedCover = normalizeRelativeAssetPath(path.posix.join(artifactDir === '.' ? '' : artifactDir, coverPath));
const candidates = [image.htmlSrc, image.workspaceRelativePath]
.map(normalizeRelativeAssetPath)
.filter(Boolean);
return candidates.includes(coverPath) || candidates.includes(resolvedCover);
}
const AUXILIARY_PAGE_PATH_PATTERN = /(?:^|[-_.\/])(?:admin|manage|management|backend|password|redirect|download|error)(?:[-_.\/]|$)/i;
export function isWechatAuxiliaryPageArtifact(artifact) {
const relativePath = String(artifact?.relativePath ?? '').trim().replace(/\\/g, '/');
if (AUXILIARY_PAGE_PATH_PATTERN.test(relativePath)) return true;
const localPath = String(artifact?.localPath ?? '').trim();
if (!localPath || !fs.existsSync(localPath)) return false;
const html = fs.readFileSync(localPath, 'utf8');
return /<meta[^>]*name=["']mindspace-page-role["'][^>]*content=["'](?:admin|auxiliary)["']/i.test(html)
|| /<[^>]+data-mindspace-page-role=["'](?:admin|auxiliary)["']/i.test(html);
}
export function verifyFreshWechatPageThumbnails(artifacts = [], images = []) {
if (!Array.isArray(artifacts) || artifacts.length === 0) {
return { ok: false, reason: 'missing_page_artifact', matches: [] };
}
const eligibleArtifacts = artifacts.filter((artifact) => !isWechatAuxiliaryPageArtifact(artifact));
const skippedArtifacts = artifacts.filter((artifact) => isWechatAuxiliaryPageArtifact(artifact));
if (eligibleArtifacts.length === 0) {
return { ok: true, reason: null, matches: [], skippedArtifacts };
}
if (!Array.isArray(images) || images.length === 0) {
return { ok: false, reason: 'fresh_image_not_generated', matches: [], skippedArtifacts };
}
const usedJobs = new Set();
const matches = [];
for (const artifact of eligibleArtifacts) {
const localPath = String(artifact?.localPath ?? '').trim();
if (!localPath || !fs.existsSync(localPath)) {
return { ok: false, reason: 'missing_page_artifact', artifact, matches, skippedArtifacts };
}
const cover = extractMindspaceCoverPath(fs.readFileSync(localPath, 'utf8'));
const image = images.find((candidate) => !usedJobs.has(candidate.jobId) && imageMatchesCover(candidate, cover, artifact));
if (!image) {
return {
ok: false,
reason: cover ? 'cover_not_from_current_run' : 'missing_generated_cover',
artifact,
matches,
skippedArtifacts,
};
}
usedJobs.add(image.jobId);
matches.push({ artifact, image, cover });
}
return { ok: true, reason: null, matches, skippedArtifacts };
}
+117
View File
@@ -0,0 +1,117 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
import {
collectWechatGeneratedImages,
extractMindspaceCoverPath,
verifyFreshWechatPageThumbnails,
} from './generated-thumbnail.mjs';
function generatedImageMessages({ jobId = 'job-1', htmlSrc = 'images/fresh.webp' } = {}) {
const result = {
ok: true,
jobId,
source: { mimeType: 'image/webp' },
asset: {
id: `asset-${jobId}`,
htmlSrc,
publicUrl: `https://example.com/MindSpace/user/public/${htmlSrc}`,
workspaceRelativePath: `public/${htmlSrc}`,
},
};
return [{
role: 'assistant',
content: [
{
id: `call-${jobId}`,
type: 'toolRequest',
toolCall: { value: { name: 'sandbox-fs__generate_image', arguments: { purpose: 'hero' } } },
},
{
id: `call-${jobId}`,
type: 'toolResponse',
toolResult: {
status: 'success',
value: { content: [{ type: 'text', text: JSON.stringify(result) }] },
},
},
],
}];
}
test('fresh page thumbnail must reference a current-run generated image', () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'wechat-fresh-cover-'));
try {
const htmlPath = path.join(root, 'page.html');
fs.writeFileSync(
htmlPath,
'<meta name="mindspace-cover" content=\'{"cover":"images/fresh.webp"}\'>',
'utf8',
);
const images = collectWechatGeneratedImages(generatedImageMessages());
assert.equal(images.length, 1);
assert.equal(extractMindspaceCoverPath(fs.readFileSync(htmlPath, 'utf8')), 'images/fresh.webp');
const result = verifyFreshWechatPageThumbnails(
[{ localPath: htmlPath, relativePath: 'public/page.html' }],
images,
);
assert.equal(result.ok, true);
assert.equal(result.matches[0].image.jobId, 'job-1');
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
});
test('old cover path and sharing one generated image across two pages fail closed', () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'wechat-stale-cover-'));
try {
const first = path.join(root, 'one.html');
const second = path.join(root, 'two.html');
fs.writeFileSync(first, '<meta name="mindspace-cover" content=\'{"cover":"images/fresh.webp"}\'>');
fs.writeFileSync(second, '<meta name="mindspace-cover" content=\'{"cover":"images/fresh.webp"}\'>');
const images = collectWechatGeneratedImages(generatedImageMessages());
const reused = verifyFreshWechatPageThumbnails(
[
{ localPath: first, relativePath: 'public/one.html' },
{ localPath: second, relativePath: 'public/two.html' },
],
images,
);
assert.equal(reused.ok, false);
assert.equal(reused.reason, 'cover_not_from_current_run');
fs.writeFileSync(first, '<meta name="mindspace-cover" content=\'{"cover":"images/old.webp"}\'>');
const stale = verifyFreshWechatPageThumbnails(
[{ localPath: first, relativePath: 'public/one.html' }],
images,
);
assert.equal(stale.ok, false);
assert.equal(stale.reason, 'cover_not_from_current_run');
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
});
test('auxiliary admin pages do not require a separate generated thumbnail', () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'wechat-aux-cover-'));
try {
const front = path.join(root, 'survey.html');
const admin = path.join(root, 'survey-admin.html');
fs.writeFileSync(front, '<meta name="mindspace-cover" content=\'{"cover":"images/fresh.webp"}\'>');
fs.writeFileSync(admin, '<meta name="mindspace-page-role" content="auxiliary">');
const result = verifyFreshWechatPageThumbnails(
[
{ localPath: front, relativePath: 'public/survey.html' },
{ localPath: admin, relativePath: 'public/survey-admin.html' },
],
collectWechatGeneratedImages(generatedImageMessages()),
);
assert.equal(result.ok, true);
assert.equal(result.matches.length, 1);
assert.equal(result.skippedArtifacts.length, 1);
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
});