Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c6ae7cd21c | |||
| 1765cac65a | |||
| efbbe69e2c | |||
| c2e2209344 | |||
| 6f4a3b53d0 | |||
| 28581310da | |||
| bfb1f6fea9 | |||
| 0dd9331e5b | |||
| 4e6db07123 | |||
| fe4b8c0da9 |
@@ -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)
|
||||
|
||||
|
||||
@@ -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` 服务。正式启用前必须确保:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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 } = {}) {
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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 "发布完成"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
/可信公网域名/,
|
||||
);
|
||||
});
|
||||
@@ -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
@@ -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
@@ -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, /新图片已经生成/);
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
'【微信服务号新消息】请只回答下面这条用户消息,不要主动延续无关的历史话题。',
|
||||
'若用户只是在测试连通性,请一句话确认收到即可,不要展开旧任务。',
|
||||
|
||||
@@ -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"),所以我先不发链接。',
|
||||
|
||||
@@ -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('"', '"')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll(''', "'")
|
||||
.replaceAll('&', '&');
|
||||
}
|
||||
|
||||
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 };
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user