diff --git a/.env.example b/.env.example
index ad8a3de..4b8df20 100644
--- a/.env.example
+++ b/.env.example
@@ -265,6 +265,8 @@ VITE_TKMIND_WORKING_DIR=/Users/john/PycharmProjects/tkmind
# IMAGE_MAKE_SEMANTIC_REVIEW_ENABLED=1
# IMAGE_MAKE_SEMANTIC_REVIEW_MIN_SCORE=70
# IMAGE_MAKE_SEMANTIC_REVIEW_MAX_ATTEMPTS=3
+# H5_WECHAT_MP_FRESH_PAGE_THUMBNAILS=1
+# H5_WECHAT_MP_FRESH_THUMBNAIL_REPAIR=0
# Sandbox MCP 调用 Portal 内部生图入口;容器内通常使用 host.docker.internal。
# MINDSPACE_AGENT_API_BASE_URL=http://127.0.0.1:8081/api
diff --git a/docs/image-make-integration.md b/docs/image-make-integration.md
index 3a18a1c..a6d88f4 100644
--- a/docs/image-make-integration.md
+++ b/docs/image-make-integration.md
@@ -116,6 +116,10 @@ IMAGE_MAKE_MEMIND_CONFIG_TOKEN=<与 Portal IMAGE_MAKE_TOKEN 相同的值>
配置 `H5_WECHAT_MP_FRESH_PAGE_THUMBNAILS=0` 可只关闭服务号“页面必须新缩略图”硬门,作为紧急回滚;
默认开启。该开关不会修改 H5 聊天图片策略。
+配置 `H5_WECHAT_MP_FRESH_THUMBNAIL_REPAIR=1` 可开启服务号缩略图确定性修复,默认关闭。修复仅在一个正式页面
+与一张本轮 hero 图片的映射唯一时,把本轮 `asset.htmlSrc` 写回 `mindspace-cover.cover` 并重新执行完整校验;
+多页、多图、缺少本轮新图或元数据无效时继续 fail closed。
+
## 发布前置条件
Portal runtime 不包含独立 `image_make` 服务。正式启用前必须确保:
diff --git a/docs/incidents/2026-07-22-wechat-fresh-thumbnail-delivery.md b/docs/incidents/2026-07-22-wechat-fresh-thumbnail-delivery.md
new file mode 100644
index 0000000..8473183
--- /dev/null
+++ b/docs/incidents/2026-07-22-wechat-fresh-thumbnail-delivery.md
@@ -0,0 +1,27 @@
+# 2026-07-22 微信服务号页面缩略图交付失败
+
+## 现象
+
+微信服务号用户要求生成带主题图片的页面。页面和候选图片已经生成,但服务号最终回复“没有完成本轮新缩略图”,未发送页面链接,并要求用户重新描述完整需求。
+
+## 根因
+
+这是两个连续问题叠加后的结果:
+
+1. 首轮图片语义审核未达阈值后,Agent 更换提示词再次调用图片工具,却复用了同一个服务号页面缩略图幂等键。`image_make` 对同键不同请求返回冲突,后续被表现为图片服务不可用。
+2. 后续重试已经生成并落盘合格 hero 图,但页面交付校验在页面文件与本轮工具写入快照不同步时得到 `missing_generated_cover`,形成假阴性,没有使用唯一的本轮 hero 图恢复 `mindspace-cover.cover`。
+
+## 修复
+
+- 仅对 `wechat-<消息>-page-<序号>-thumbnail` 且 `purpose=hero` 的请求,把提示词摘要加入 `image_make` 幂等键;同提示词仍可幂等,不同提示词不会冲突。其它图片调用的幂等行为不变。
+- 明确要求 Agent 对同一正式页面只调用一次图片工具;语义重试由图片服务内部完成。
+- 增加默认关闭的 `H5_WECHAT_MP_FRESH_THUMBNAIL_REPAIR`。开启后,只有“一个正式页面 + 一张本轮 hero 图”且页面已有合法 `mindspace-cover` 元数据时,才原子写回 cover 并重新执行完整校验。任何多页、多图、缺图或无效元数据情况继续 fail closed。
+- 增加结构化失败诊断,记录校验原因、页面、cover 和本轮生成资产,不记录图片提示词或用户正文。
+- 失败文案不再要求重述完整需求;页面内容保留,用户可回复“重试上次页面”。
+- 页面生成提示增加禁止杜撰产品指标、客户数、SLA、认证、案例、排名和承诺的约束。
+
+## 影响边界与回滚
+
+- 正常页面生成、H5 聊天、非服务号图片、服务号普通聊天和多页/多图 fail-closed 行为不变。
+- 确定性修复默认关闭;生产验证后显式设置 `H5_WECHAT_MP_FRESH_THUMBNAIL_REPAIR=1` 才启用。
+- 如需回滚修复分支,只需将该变量设为 `0` 或移除;新缩略图硬门仍保持开启。
diff --git a/mindspace-image-generation.mjs b/mindspace-image-generation.mjs
index 612626c..46e9204 100644
--- a/mindspace-image-generation.mjs
+++ b/mindspace-image-generation.mjs
@@ -13,6 +13,29 @@ function htmlSrcForWorkspaceAsset(workspaceRelativePath) {
return normalized.slice('public/'.length) || null;
}
+const IMAGE_MAKE_IDEMPOTENCY_KEY_MAX_LENGTH = 128;
+const WECHAT_PAGE_THUMBNAIL_KEY_PATTERN = /^wechat-[a-zA-Z0-9._-]+-page-\d+-thumbnail$/;
+
+function boundedIdempotencyKey(base, suffix = '') {
+ const normalizedBase = String(base ?? '').trim();
+ const normalizedSuffix = String(suffix ?? '');
+ return `${normalizedBase.slice(0, Math.max(0, IMAGE_MAKE_IDEMPOTENCY_KEY_MAX_LENGTH - normalizedSuffix.length))}${normalizedSuffix}`;
+}
+
+function resolveImageMakeIdempotencyKey({ idempotencyKey, purpose, prompt, negativePrompt }) {
+ const normalized = String(idempotencyKey ?? '').trim();
+ if (!normalized) return `imgreq_${crypto.randomUUID()}`;
+ if (purpose !== 'hero' || !WECHAT_PAGE_THUMBNAIL_KEY_PATTERN.test(normalized)) {
+ return normalized;
+ }
+ const promptHash = crypto
+ .createHash('sha256')
+ .update([purpose, String(prompt ?? '').trim(), String(negativePrompt ?? '').trim()].join('\0'))
+ .digest('hex')
+ .slice(0, 12);
+ return boundedIdempotencyKey(normalized, `-p-${promptHash}`);
+}
+
function resolvePurposeDimensions(spec, env, logger) {
const widthKey = `IMAGE_MAKE_${spec.envPrefix}_WIDTH`;
const heightKey = `IMAGE_MAKE_${spec.envPrefix}_HEIGHT`;
@@ -65,7 +88,12 @@ export function createMindSpaceImageGenerationService({
const maxAttempts = Number.isFinite(configuredAttempts)
? Math.max(1, Math.min(3, Math.floor(configuredAttempts)))
: 3;
- const baseIdempotencyKey = idempotencyKey || `imgreq_${crypto.randomUUID()}`;
+ const baseIdempotencyKey = resolveImageMakeIdempotencyKey({
+ idempotencyKey,
+ purpose,
+ prompt,
+ negativePrompt,
+ });
let generated = null;
let acceptedReview = null;
let retryFeedback = '';
@@ -76,7 +104,7 @@ export function createMindSpaceImageGenerationService({
: prompt;
const attemptKey = attempt === 1
? baseIdempotencyKey
- : `${baseIdempotencyKey.slice(0, 160)}-review-${attempt}`;
+ : boundedIdempotencyKey(baseIdempotencyKey, `-review-${attempt}`);
generated = await imageMakeClient.generateImage({
prompt: attemptPrompt,
negativePrompt,
diff --git a/mindspace-image-generation.test.mjs b/mindspace-image-generation.test.mjs
index 9821cbd..4d88de4 100644
--- a/mindspace-image-generation.test.mjs
+++ b/mindspace-image-generation.test.mjs
@@ -214,6 +214,55 @@ test('MindSpace image generation retries a semantic mismatch and stores only a r
assert.match(generatedRequests[1].prompt, /缺失元素:竹海/);
});
+test('WeChat page thumbnail idempotency follows the prompt while other keys stay stable', async () => {
+ const generatedRequests = [];
+ let assetCount = 0;
+ const service = createMindSpaceImageGenerationService({
+ configService: { async resolveImageGenerationPurpose() { return { ok: true, presetId: 'hero' }; } },
+ assetService: {
+ async createChatAsset() {
+ assetCount += 1;
+ return {
+ id: `asset-${assetCount}`,
+ workspaceRelativePath: `public/images/hero-${assetCount}.webp`,
+ };
+ },
+ },
+ imageMakeClient: {
+ async generateImage(input) {
+ generatedRequests.push(input);
+ return {
+ jobId: `job-${generatedRequests.length}`,
+ buffer: Buffer.from(`image-${generatedRequests.length}`),
+ mimeType: 'image/webp', sha256: 'abc', width: 768, height: 432,
+ };
+ },
+ async acknowledge() {},
+ },
+ imageReviewService: passingReviewService(),
+ });
+
+ const input = {
+ userId: 'user-1',
+ purpose: 'hero',
+ prompt: 'TKMind 舌战群儒,突出安全与落地',
+ idempotencyKey: 'wechat-25550597627048917-page-1-thumbnail',
+ };
+ await service.generate(input);
+ await service.generate(input);
+ await service.generate({ ...input, prompt: 'TKMind 舌战群儒,突出场景与思维进化' });
+ await service.generate({ ...input, idempotencyKey: 'ordinary-request', prompt: '普通图片' });
+
+ assert.equal(generatedRequests[0].idempotencyKey, generatedRequests[1].idempotencyKey);
+ assert.notEqual(generatedRequests[1].idempotencyKey, generatedRequests[2].idempotencyKey);
+ assert.match(
+ generatedRequests[0].idempotencyKey,
+ /^wechat-25550597627048917-page-1-thumbnail-p-[a-f0-9]{12}$/,
+ );
+ assert.ok(generatedRequests[0].idempotencyKey.length <= 128);
+ assert.equal(generatedRequests[3].idempotencyKey, 'ordinary-request');
+});
+
test('MindSpace image generation blocks storage after all semantic review attempts fail', async () => {
let generateCount = 0;
let storeCount = 0;
diff --git a/wechat-mp-config.mjs b/wechat-mp-config.mjs
index 610ff9e..c6565db 100644
--- a/wechat-mp-config.mjs
+++ b/wechat-mp-config.mjs
@@ -88,6 +88,7 @@ export function loadWechatMpConfig(env = process.env) {
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',
+ repairFreshPageThumbnail: env.H5_WECHAT_MP_FRESH_THUMBNAIL_REPAIR === '1',
encodingAesKey: env.H5_WECHAT_MP_ENCODING_AES_KEY?.trim() ?? '',
};
}
diff --git a/wechat-mp.mjs b/wechat-mp.mjs
index 6d45803..16d8e12 100644
--- a/wechat-mp.mjs
+++ b/wechat-mp.mjs
@@ -6,7 +6,11 @@ import { developerToolsFromPolicy } from './capabilities.mjs';
import { mergeMessageContent } from './message-stream.mjs';
import { reconcileAgentSession } from './session-reconcile.mjs';
import { resolveSessionAccess } from './session-broker.mjs';
-import { isStubPublicHtmlContent, materializeMissingPublicHtmlWrites } from './mindspace-public-finish-sync.mjs';
+import {
+ extractPublicHtmlWriteArtifacts,
+ isStubPublicHtmlContent,
+ materializeMissingPublicHtmlWrites,
+} from './mindspace-public-finish-sync.mjs';
import { loadWechatMpConfig } from './wechat-mp-config.mjs';
import { buildPublicUrl, PUBLISH_ROOT_DIR } from './user-publish.mjs';
import {
@@ -41,6 +45,8 @@ import {
import { selectSendableHtmlArtifacts } from './wechat/verify/page-artifact.mjs';
import {
collectWechatGeneratedImages,
+ repairUnambiguousFreshWechatPageThumbnail,
+ summarizeFreshWechatThumbnailVerification,
verifyFreshWechatPageThumbnails,
} from './wechat/verify/generated-thumbnail.mjs';
import { resolveBillingTokenState } from './billing-token-state.mjs';
@@ -1513,6 +1519,7 @@ export function createWechatMpService({
};
}
const requireFreshPageThumbnail = config.requireFreshPageThumbnail === true;
+ const repairFreshPageThumbnail = config.repairFreshPageThumbnail === true;
config = {
...loadWechatMpConfig({}),
...config,
@@ -1531,6 +1538,7 @@ export function createWechatMpService({
? config.mediaAnalysisGrayUsers
: [],
requireFreshPageThumbnail,
+ repairFreshPageThumbnail,
asrTarget: config.asrTarget || DEFAULT_ASR_TARGET,
};
@@ -1845,7 +1853,38 @@ export function createWechatMpService({
}) => {
if (imagePolicy?.pageThumbnailMode !== WECHAT_PAGE_THUMBNAIL_MODE.REQUIRED_FRESH) return;
const images = collectWechatGeneratedImages(reply?.messages ?? []);
- const verification = verifyFreshWechatPageThumbnails(artifacts, images);
+ let verification = verifyFreshWechatPageThumbnails(artifacts, images);
+ if (!verification.ok) {
+ logger.warn?.(
+ 'WeChat MP fresh thumbnail verification failed:',
+ summarizeFreshWechatThumbnailVerification(verification, images),
+ );
+ if (config.repairFreshPageThumbnail) {
+ const repair = repairUnambiguousFreshWechatPageThumbnail({
+ artifacts,
+ images,
+ currentRunHtmlArtifacts: extractPublicHtmlWriteArtifacts(reply?.messages ?? [], {
+ publishDir,
+ }),
+ verificationReason: verification.reason,
+ });
+ if (repair.ok) {
+ verification = verifyFreshWechatPageThumbnails(artifacts, images);
+ logger.info?.('WeChat MP fresh thumbnail repaired:', {
+ relativePath: String(repair.artifact?.relativePath ?? ''),
+ jobId: String(repair.image?.jobId ?? ''),
+ cover: repair.cover,
+ verified: verification.ok,
+ });
+ } else {
+ logger.warn?.('WeChat MP fresh thumbnail repair skipped:', {
+ reason: repair.reason,
+ artifactCount: repair.artifactCount,
+ imageCount: repair.imageCount,
+ });
+ }
+ }
+ }
if (verification.ok) {
try {
for (const match of verification.matches) {
@@ -1857,8 +1896,11 @@ export function createWechatMpService({
}
return;
} catch (error) {
- verification.ok = false;
- verification.reason = `thumbnail_render_failed:${error instanceof Error ? error.message : String(error)}`;
+ verification = {
+ ...verification,
+ ok: false,
+ reason: `thumbnail_render_failed:${error instanceof Error ? error.message : String(error)}`,
+ };
}
}
const text = buildPagePublishFailureText({ missingFreshThumbnail: true });
diff --git a/wechat-mp.test.mjs b/wechat-mp.test.mjs
index 9af6740..bcd1a7a 100644
--- a/wechat-mp.test.mjs
+++ b/wechat-mp.test.mjs
@@ -864,11 +864,13 @@ test('loadWechatMpConfig requires full config and enable flag', () => {
assert.equal(config.enabled, true);
assert.equal(config.bindPath, '/auth/wechat/authorize?intent=login');
assert.equal(config.requireFreshPageThumbnail, true);
+ assert.equal(config.repairFreshPageThumbnail, false);
assert.deepEqual(config.generatedImagePublicBaseUrls, [
'https://example.com',
'https://img.example.com',
]);
assert.equal(loadWechatMpConfig({ H5_WECHAT_MP_FRESH_PAGE_THUMBNAILS: '0' }).requireFreshPageThumbnail, false);
+ assert.equal(loadWechatMpConfig({ H5_WECHAT_MP_FRESH_THUMBNAIL_REPAIR: '1' }).repairFreshPageThumbnail, true);
});
test('verifyWechatMpSignature accepts valid signature', () => {
@@ -4949,7 +4951,7 @@ test('wechat mp service exposes route status and can recreate route for bound us
assert.equal(calls.includes('upserted'), true);
});
-test('wechat mp page delivery requires and verifies a current-run fresh thumbnail', async () => {
+test('wechat mp page delivery repairs one unambiguous fresh thumbnail missing from disk metadata', async () => {
const token = 'token';
const timestamp = '1710000000';
const nonce = 'nonce';
@@ -4958,6 +4960,7 @@ test('wechat mp page delivery requires and verifies a current-run fresh thumbnai
const wechatPayloads = [];
const generated = {
ok: true,
+ purpose: 'hero',
jobId: 'job-fresh-page',
source: { mimeType: 'image/webp', width: 1280, height: 720 },
asset: {
@@ -4967,18 +4970,13 @@ test('wechat mp page delivery requires and verifies a current-run fresh thumbnai
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 },
+ config: { requireFreshPageThumbnail: true, repairFreshPageThumbnail: true },
userAuth: {
async resolveWorkingDir() {
return workspaceRoot;
@@ -5031,16 +5029,6 @@ test('wechat mp page delivery requires and verifies a current-run fresh thumbnai
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 },
- },
- },
- },
],
},
}),
@@ -5073,10 +5061,12 @@ test('wechat mp page delivery requires and verifies a current-run fresh thumbnai
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, /本轮新生成资产/);
+ assert.match(body.user_message.content[0].text, /禁止编造用户未提供的产品指标/);
+ assert.match(body.user_message.content[0].text, /Agent 层只调用一次 generate_image/);
fs.mkdirSync(path.dirname(htmlPath), { recursive: true });
fs.writeFileSync(
htmlPath,
- pageHtml,
+ previewReadyPageHtml({ title: 'Fresh', subtitle: '交付前磁盘副本' }),
'utf8',
);
fs.mkdirSync(path.join(workspaceRoot, 'public', 'images'), { recursive: true });
@@ -5116,6 +5106,10 @@ test('wechat mp page delivery requires and verifies a current-run fresh thumbnai
assert.equal(result.status, 200);
await result.task;
assert.equal(fs.existsSync(path.join(workspaceRoot, 'public', 'fresh.thumbnail.svg')), true);
+ assert.match(
+ fs.readFileSync(htmlPath, 'utf8'),
+ /"cover":"images\/fresh-page\.webp"/,
+ );
} finally {
crypto.randomUUID = originalRandomUuid;
fs.rmSync(workspaceRoot, { recursive: true, force: true });
diff --git a/wechat/image-generation-policy.mjs b/wechat/image-generation-policy.mjs
index cced31f..4642da2 100644
--- a/wechat/image-generation-policy.mjs
+++ b/wechat/image-generation-policy.mjs
@@ -86,6 +86,7 @@ export function buildWechatPageImageInstruction(policy, { sourceMessageId = '' }
'【服务号页面新缩略图硬要求】每个本轮交付给用户的正式内容页面都必须使用本轮新生成、与主题一致的位图作为缩略图源图。',
'写 HTML 前先调用 `load_skill` → `image-generation`,再为每个正式内容页面各调用一次 `sandbox-fs__generate_image`,优先 purpose=`hero`。',
`幂等键使用 ${keyPrefix}-<页面序号>-thumbnail;不同正式页面不得共用同一张图。`,
+ '同一正式页面在 Agent 层只调用一次 generate_image;工具内部会完成语义重试。返回失败后禁止自行更换提示词并重复调用,交由服务号交付层统一重试。',
'生成成功后,把返回的 asset.htmlSrc 原样写入对应页面的 mindspace-cover.cover。页面需要主视觉时复用同一张图作为 Hero,禁止再调用 card_cover/feed_cover。',
'管理后台、密码查看、重定向、下载包装和错误辅助页不属于正式内容页面,不要求单独生图;这类页面必须在 head 写 `` 供交付守卫识别。',
inlineInstruction,
diff --git a/wechat/image-generation-policy.test.mjs b/wechat/image-generation-policy.test.mjs
index 678ab13..2b1b85c 100644
--- a/wechat/image-generation-policy.test.mjs
+++ b/wechat/image-generation-policy.test.mjs
@@ -7,6 +7,10 @@ import {
WECHAT_PAGE_THUMBNAIL_MODE,
} from './image-generation-policy.mjs';
import { classifyWechatIntent } from './intent/classifier.mjs';
+import {
+ buildPageGenerateAgentPrompt,
+ buildPagePublishFailureText,
+} from './prompts/page-generate.mjs';
test('service-account page always requires a fresh thumbnail without forcing body images', () => {
const policy = resolveWechatImageGenerationPolicy({
@@ -18,7 +22,11 @@ test('service-account page always requires a fresh thumbnail without forcing bod
assert.equal(policy.imageGenerationMode, 'required');
assert.equal(policy.inlineImageMode, 'auto');
assert.equal(policy.standaloneImageMode, 'auto');
- assert.match(buildWechatPageImageInstruction(policy), /每个本轮交付给用户的正式内容页面/);
+ const instruction = buildWechatPageImageInstruction(policy);
+ assert.match(instruction, /每个本轮交付给用户的正式内容页面/);
+ assert.match(instruction, /Agent 层只调用一次 generate_image/);
+ assert.match(instruction, /工具内部会完成语义重试/);
+ assert.match(instruction, /禁止自行更换提示词并重复调用/);
});
test('no-image page request disables body images but keeps required fresh thumbnail', () => {
@@ -62,3 +70,20 @@ test('explicitly declining a page keeps standalone image generation out of page
assert.equal(policy.pageThumbnailMode, 'auto');
assert.equal(policy.standaloneImageMode, 'required');
});
+
+test('page generation prompt forbids unsupported commercial and security claims', () => {
+ const prompt = buildPageGenerateAgentPrompt({
+ agentText: '生成 TKMind 舌战群儒页面',
+ msgId: 'message-1',
+ });
+ assert.match(prompt, /禁止编造用户未提供的产品指标、客户数量、SLA、认证、案例、排名或承诺/);
+ assert.match(prompt, /不得杜撰数字/);
+});
+
+test('missing-thumbnail copy preserves the page and does not ask for the full requirement again', () => {
+ const text = buildPagePublishFailureText({ missingFreshThumbnail: true });
+ assert.match(text, /页面内容已经保留/);
+ assert.match(text, /重试上次页面/);
+ assert.match(text, /无需重新描述完整需求/);
+ assert.doesNotMatch(text, /重发一次完整页面需求/);
+});
diff --git a/wechat/prompts/page-generate.mjs b/wechat/prompts/page-generate.mjs
index f5fca1d..b8af5c3 100644
--- a/wechat/prompts/page-generate.mjs
+++ b/wechat/prompts/page-generate.mjs
@@ -41,6 +41,7 @@ export function buildPageGenerateAgentPrompt(intent, { wantsDocx = false, imageP
'6. 交付前运行 `npm run check:mindspace-cover`(或 node scripts/check-mindspace-cover.mjs --user );有 error 则补元数据后再回复链接。',
'7. 页脚加 `TKMind · 智趣
`(必须;禁止省略、不要用邮箱/tkmind.ai,也不要把该行 opacity 设太低导致看不见)。',
'8. 回复里只给一个正式域名的唯一正确链接;没有落盘或缺少上述元数据就不要发链接。',
+ '9. 禁止编造用户未提供的产品指标、客户数量、SLA、认证、案例、排名或承诺;没有可靠依据时使用定性描述,不得杜撰数字。',
'',
buildCurrentTimeAgentPrefix({ timezone: scheduleTimezone }),
`用户需求:${topic}`,
@@ -57,7 +58,7 @@ export function buildPagePublishFailureText({
if (missingFreshThumbnail) {
return [
'这次页面内容已生成,但没有完成服务号要求的本轮新缩略图,所以我先不发页面链接。',
- '请直接重发一次完整页面需求,我会重新生成主题匹配的新缩略图并完成页面交付。',
+ '页面内容已经保留。请稍后回复“重试上次页面”,无需重新描述完整需求。',
].join('\n');
}
if (missingPlatformBrand) {
diff --git a/wechat/verify/generated-thumbnail.mjs b/wechat/verify/generated-thumbnail.mjs
index 8bcf98a..9f02e78 100644
--- a/wechat/verify/generated-thumbnail.mjs
+++ b/wechat/verify/generated-thumbnail.mjs
@@ -1,5 +1,10 @@
+import crypto from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
+import {
+ parseMindspaceCoverMeta,
+ upsertMindspaceCoverMeta,
+} from '../../mindspace-cover-meta.mjs';
const RASTER_MIME_PATTERN = /^image\/(?:png|jpeg|webp)$/i;
@@ -29,6 +34,7 @@ function normalizeGeneratedImage(result) {
if (!htmlSrc && !publicUrl && !workspaceRelativePath) return null;
return {
jobId: String(result.jobId),
+ purpose: String(result.purpose ?? '').trim() || null,
mimeType,
assetId: String(asset.id ?? '').trim() || null,
htmlSrc: htmlSrc || null,
@@ -141,6 +147,7 @@ export function verifyFreshWechatPageThumbnails(artifacts = [], images = []) {
ok: false,
reason: cover ? 'cover_not_from_current_run' : 'missing_generated_cover',
artifact,
+ cover: cover || null,
matches,
skippedArtifacts,
};
@@ -150,3 +157,103 @@ export function verifyFreshWechatPageThumbnails(artifacts = [], images = []) {
}
return { ok: true, reason: null, matches, skippedArtifacts };
}
+
+function uniqueArtifactsByLocalPath(artifacts = []) {
+ const unique = new Map();
+ for (const artifact of artifacts) {
+ const localPath = String(artifact?.localPath ?? '').trim();
+ if (localPath) unique.set(localPath, artifact);
+ }
+ return [...unique.values()];
+}
+
+function atomicWriteHtml(localPath, content) {
+ const tempPath = path.join(
+ path.dirname(localPath),
+ `.${path.basename(localPath)}.wechat-thumbnail-${process.pid}-${crypto.randomUUID()}.tmp`,
+ );
+ try {
+ fs.writeFileSync(tempPath, content, 'utf8');
+ fs.renameSync(tempPath, localPath);
+ } finally {
+ try {
+ if (fs.existsSync(tempPath)) fs.unlinkSync(tempPath);
+ } catch {
+ // Best-effort cleanup only; the destination write already decided the result.
+ }
+ }
+}
+
+export function repairUnambiguousFreshWechatPageThumbnail({
+ artifacts = [],
+ images = [],
+ currentRunHtmlArtifacts = [],
+ verificationReason = '',
+} = {}) {
+ if (!['missing_generated_cover', 'cover_not_from_current_run'].includes(verificationReason)) {
+ return { ok: false, reason: 'verification_not_repairable' };
+ }
+ const eligibleArtifacts = uniqueArtifactsByLocalPath(
+ artifacts.filter((artifact) => !isWechatAuxiliaryPageArtifact(artifact)),
+ );
+ const pageImages = images.filter((image) => image?.purpose === 'hero' || !image?.purpose);
+ if (eligibleArtifacts.length !== 1 || pageImages.length !== 1) {
+ return {
+ ok: false,
+ reason: 'ambiguous_page_image_mapping',
+ artifactCount: eligibleArtifacts.length,
+ imageCount: pageImages.length,
+ };
+ }
+
+ const artifact = eligibleArtifacts[0];
+ const image = pageImages[0];
+ const cover = String(image?.htmlSrc ?? '').trim();
+ const localPath = String(artifact?.localPath ?? '').trim();
+ if (!cover || !localPath || !fs.existsSync(localPath)) {
+ return { ok: false, reason: 'repair_source_unavailable', artifact, image };
+ }
+
+ const normalizedRelativePath = String(artifact.relativePath ?? '').trim().replace(/\\/g, '/');
+ const currentRunArtifact = currentRunHtmlArtifacts.find(
+ (candidate) => String(candidate?.relativePath ?? '').trim().replace(/\\/g, '/') === normalizedRelativePath,
+ );
+ const sourceHtml = typeof currentRunArtifact?.content === 'string'
+ ? currentRunArtifact.content
+ : fs.readFileSync(localPath, 'utf8');
+ const coverMeta = parseMindspaceCoverMeta(sourceHtml);
+ if (!coverMeta) {
+ return { ok: false, reason: 'valid_cover_meta_required', artifact, image };
+ }
+
+ const repairedHtml = upsertMindspaceCoverMeta(sourceHtml, { cover });
+ atomicWriteHtml(localPath, repairedHtml);
+ return {
+ ok: true,
+ reason: null,
+ artifact,
+ image,
+ cover,
+ restoredCurrentRunHtml: Boolean(currentRunArtifact),
+ };
+}
+
+export function summarizeFreshWechatThumbnailVerification(verification, images = []) {
+ return {
+ reason: String(verification?.reason ?? 'unknown'),
+ artifact: verification?.artifact
+ ? {
+ relativePath: String(verification.artifact.relativePath ?? ''),
+ localPath: path.basename(String(verification.artifact.localPath ?? '')),
+ }
+ : null,
+ cover: String(verification?.cover ?? ''),
+ matches: Array.isArray(verification?.matches) ? verification.matches.length : 0,
+ images: images.map((image) => ({
+ jobId: String(image?.jobId ?? ''),
+ purpose: String(image?.purpose ?? ''),
+ htmlSrc: String(image?.htmlSrc ?? ''),
+ workspaceRelativePath: String(image?.workspaceRelativePath ?? ''),
+ })),
+ };
+}
diff --git a/wechat/verify/generated-thumbnail.test.mjs b/wechat/verify/generated-thumbnail.test.mjs
index e801994..ec65040 100644
--- a/wechat/verify/generated-thumbnail.test.mjs
+++ b/wechat/verify/generated-thumbnail.test.mjs
@@ -6,12 +6,14 @@ import test from 'node:test';
import {
collectWechatGeneratedImages,
extractMindspaceCoverPath,
+ repairUnambiguousFreshWechatPageThumbnail,
verifyFreshWechatPageThumbnails,
} from './generated-thumbnail.mjs';
function generatedImageMessages({ jobId = 'job-1', htmlSrc = 'images/fresh.webp' } = {}) {
const result = {
ok: true,
+ purpose: 'hero',
jobId,
source: { mimeType: 'image/webp' },
asset: {
@@ -115,3 +117,60 @@ test('auxiliary admin pages do not require a separate generated thumbnail', () =
fs.rmSync(root, { recursive: true, force: true });
}
});
+
+test('one current-run hero repairs one page from the current write snapshot and re-verifies', () => {
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'wechat-repair-cover-'));
+ try {
+ const htmlPath = path.join(root, 'page.html');
+ const diskHtml = 'disk copy';
+ const currentRunHtml = 'current run copy';
+ fs.writeFileSync(htmlPath, diskHtml, 'utf8');
+ const artifacts = [{ localPath: htmlPath, relativePath: 'public/page.html' }];
+ const images = collectWechatGeneratedImages(generatedImageMessages());
+
+ const failed = verifyFreshWechatPageThumbnails(artifacts, images);
+ assert.equal(failed.reason, 'missing_generated_cover');
+ const repaired = repairUnambiguousFreshWechatPageThumbnail({
+ artifacts,
+ images,
+ currentRunHtmlArtifacts: [{ relativePath: 'public/page.html', content: currentRunHtml }],
+ verificationReason: failed.reason,
+ });
+
+ assert.equal(repaired.ok, true);
+ assert.equal(repaired.restoredCurrentRunHtml, true);
+ const written = fs.readFileSync(htmlPath, 'utf8');
+ assert.match(written, /current run copy/);
+ assert.doesNotMatch(written, /disk copy/);
+ assert.equal(extractMindspaceCoverPath(written), 'images/fresh.webp');
+ assert.equal(verifyFreshWechatPageThumbnails(artifacts, images).ok, true);
+ } finally {
+ fs.rmSync(root, { recursive: true, force: true });
+ }
+});
+
+test('thumbnail repair refuses an ambiguous page-to-image mapping without changing the page', () => {
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'wechat-ambiguous-repair-'));
+ try {
+ const htmlPath = path.join(root, 'page.html');
+ const original = '';
+ fs.writeFileSync(htmlPath, original, 'utf8');
+ const artifacts = [{ localPath: htmlPath, relativePath: 'public/page.html' }];
+ const images = collectWechatGeneratedImages([
+ ...generatedImageMessages({ jobId: 'job-1', htmlSrc: 'images/one.webp' }),
+ ...generatedImageMessages({ jobId: 'job-2', htmlSrc: 'images/two.webp' }),
+ ]);
+
+ const repaired = repairUnambiguousFreshWechatPageThumbnail({
+ artifacts,
+ images,
+ verificationReason: 'missing_generated_cover',
+ });
+
+ assert.equal(repaired.ok, false);
+ assert.equal(repaired.reason, 'ambiguous_page_image_mapping');
+ assert.equal(fs.readFileSync(htmlPath, 'utf8'), original);
+ } finally {
+ fs.rmSync(root, { recursive: true, force: true });
+ }
+});