Compare commits

...

3 Commits

Author SHA1 Message Date
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
21 changed files with 1503 additions and 76 deletions
+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` 服务。正式启用前必须确保:
+8
View File
@@ -394,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.
@@ -9,6 +9,10 @@
作用域。若忘却开关开启,即使 `MEMORY_LIFECYCLE_ROLLOUT_MODE=off`,定时器也可能
归档全量用户的过期记忆。
灰度还曾出现 MySQL 已成功保存新记忆,但 pgvector 仍只包含旧数据:runtime 从全局
`updated_at=0` 游标开始做有限批次 backfill,新写入行可能长期排在批次之外。结果是
`agent_memory_resolved` 显示已注入,但回答只拿到旧的无关记忆。
## 必须保留的行为
1. `MEMORY_CANDIDATE_PERSISTENCE_ENABLED=1` 且 MySQL 可用时,Portal 必须先执行
@@ -21,11 +25,15 @@
- `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-runtime.test.mjs
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
```
+6 -2
View File
@@ -126,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');
@@ -135,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 } = {}) {
+32
View File
@@ -95,3 +95,35 @@ test('expire is limited to the configured canary user', async () => {
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');
});
+42 -4
View File
@@ -12,7 +12,10 @@ import {
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';
@@ -464,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 {
@@ -493,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;
};
+166
View File
@@ -127,6 +127,172 @@ test('createMemoryV2Runtime fails open when candidate schema initialization fail
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",
+90
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,93 @@ 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 = '',
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();
if (publicBaseUrl && new URL(resolvedUrl).origin !== new URL(publicBaseUrl).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,
+38
View File
@@ -0,0 +1,38 @@
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/);
});
+1
View File
@@ -81,6 +81,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() ?? '',
};
}
+167 -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,36 @@ 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,
});
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 +1832,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 +2113,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 +2145,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 +2190,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 +2235,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 +2258,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 +2342,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 +2378,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 +2436,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 +2459,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 +2540,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 +2576,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),
+316 -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: {
@@ -859,6 +862,8 @@ test('loadWechatMpConfig requires full config and enable flag', () => {
});
assert.equal(config.enabled, true);
assert.equal(config.bindPath, '/auth/wechat/authorize?intent=login');
assert.equal(config.requireFreshPageThumbnail, true);
assert.equal(loadWechatMpConfig({ H5_WECHAT_MP_FRESH_PAGE_THUMBNAILS: '0' }).requireFreshPageThumbnail, false);
});
test('verifyWechatMpSignature accepts valid signature', () => {
@@ -4932,3 +4937,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');
}
+49
View File
@@ -0,0 +1,49 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
buildWechatPageImageInstruction,
buildWechatStandaloneImageInstruction,
resolveWechatImageGenerationPolicy,
WECHAT_PAGE_THUMBNAIL_MODE,
} from './image-generation-policy.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/);
});
+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 });
}
});