From 28581310da3bf42b43d902715fe8e233dab47fa8 Mon Sep 17 00:00:00 2001 From: john Date: Tue, 21 Jul 2026 23:21:44 +0800 Subject: [PATCH] feat(wechat): add image generation delivery policy --- docs/image-make-integration.md | 20 ++ package.json | 2 +- wechat-media.mjs | 90 ++++++ wechat-media.test.mjs | 38 +++ wechat-mp-config.mjs | 1 + wechat-mp.mjs | 176 +++++++++++- wechat-mp.test.mjs | 318 ++++++++++++++++++++- wechat/image-generation-policy.mjs | 94 ++++++ wechat/image-generation-policy.test.mjs | 49 ++++ wechat/prompts/chat-general.mjs | 8 +- wechat/prompts/page-generate.mjs | 32 ++- wechat/verify/generated-thumbnail.mjs | 152 ++++++++++ wechat/verify/generated-thumbnail.test.mjs | 117 ++++++++ 13 files changed, 1078 insertions(+), 19 deletions(-) create mode 100644 wechat-media.test.mjs create mode 100644 wechat/image-generation-policy.mjs create mode 100644 wechat/image-generation-policy.test.mjs create mode 100644 wechat/verify/generated-thumbnail.mjs create mode 100644 wechat/verify/generated-thumbnail.test.mjs diff --git a/docs/image-make-integration.md b/docs/image-make-integration.md index 2b07d0e..3a18a1c 100644 --- a/docs/image-make-integration.md +++ b/docs/image-make-integration.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` 服务。正式启用前必须确保: diff --git a/package.json b/package.json index a486c4f..07a9196 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/wechat-media.mjs b/wechat-media.mjs index 3fdef3b..2f8211f 100644 --- a/wechat-media.mjs +++ b/wechat-media.mjs @@ -3,12 +3,15 @@ import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { fetch as undiciFetch } from 'undici'; +import sharp from 'sharp'; import { buildPublicUrl, PUBLISH_ROOT_DIR, PUBLIC_ZONE_DIR } from './user-publish.mjs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const DEFAULT_WECHAT_MEDIA_URL = 'https://api.weixin.qq.com/cgi-bin/media/get'; +const DEFAULT_WECHAT_MEDIA_UPLOAD_URL = 'https://api.weixin.qq.com/cgi-bin/media/upload'; const DEFAULT_MAX_IMAGE_BYTES = 10 * 1024 * 1024; +const DEFAULT_MAX_OUTBOUND_IMAGE_BYTES = 2 * 1024 * 1024; const DEFAULT_MAX_ATTACHMENT_BYTES = 30 * 1024 * 1024; const ALLOWED_IMAGE_MIME_TYPES = new Map([ ['image/jpeg', 'jpg'], @@ -96,6 +99,93 @@ export async function downloadTemporaryMedia(accessToken, mediaId, { wechatFetch return { buffer, contentType }; } +async function normalizeWechatOutboundImage(buffer, maxBytes = DEFAULT_MAX_OUTBOUND_IMAGE_BYTES) { + ensureImageWithinLimit(buffer, DEFAULT_MAX_IMAGE_BYTES * 2); + const attempts = [ + { width: 2048, quality: 86 }, + { width: 1600, quality: 74 }, + { width: 1280, quality: 62 }, + ]; + for (const attempt of attempts) { + const normalized = await sharp(buffer, { sequentialRead: true }) + .rotate() + .resize({ + width: attempt.width, + height: attempt.width, + fit: 'inside', + withoutEnlargement: true, + }) + .flatten({ background: '#ffffff' }) + .jpeg({ quality: attempt.quality, progressive: true, mozjpeg: true }) + .toBuffer(); + if (normalized.length <= maxBytes) { + return { buffer: normalized, contentType: 'image/jpeg', filename: 'memind-generated.jpg' }; + } + } + throw new Error(`生成图片压缩后仍超过微信图片限制(${maxBytes} bytes)`); +} + +export async function uploadWechatGeneratedImage( + accessToken, + publicUrl, + { + wechatFetch = undiciFetch, + publicBaseUrl = '', + uploadUrl = DEFAULT_WECHAT_MEDIA_UPLOAD_URL, + maxBytes = DEFAULT_MAX_OUTBOUND_IMAGE_BYTES, + } = {}, +) { + if (!accessToken) throw new Error('缺少微信 access_token'); + if (!publicUrl) throw new Error('缺少生成图片公网地址'); + const resolvedUrl = new URL(String(publicUrl), publicBaseUrl || undefined).toString(); + if (publicBaseUrl && new URL(resolvedUrl).origin !== new URL(publicBaseUrl).origin) { + throw new Error('生成图片地址不属于当前 MindSpace 公网域名'); + } + const sourceResponse = await wechatFetch(resolvedUrl, { + method: 'GET', + headers: { Accept: 'image/*,*/*;q=0.8' }, + }); + if (!sourceResponse.ok) { + const text = await sourceResponse.text().catch(() => ''); + throw new Error(text || `生成图片下载失败 (${sourceResponse.status})`); + } + const declaredBytes = Number(sourceResponse.headers.get('content-length') ?? 0); + if (Number.isFinite(declaredBytes) && declaredBytes > DEFAULT_MAX_IMAGE_BYTES * 2) { + throw new Error('生成图片下载体积超过安全限制'); + } + const sourceBuffer = Buffer.from(await sourceResponse.arrayBuffer()); + const normalized = await normalizeWechatOutboundImage(sourceBuffer, maxBytes); + const form = new FormData(); + form.append( + 'media', + new Blob([normalized.buffer], { type: normalized.contentType }), + normalized.filename, + ); + const endpoint = new URL(uploadUrl); + endpoint.searchParams.set('access_token', accessToken); + endpoint.searchParams.set('type', 'image'); + const response = await wechatFetch(endpoint.toString(), { + method: 'POST', + body: form, + }); + const text = await response.text().catch(() => ''); + let payload = {}; + try { + payload = text ? JSON.parse(text) : {}; + } catch { + payload = {}; + } + if (!response.ok || Number(payload?.errcode ?? 0) !== 0 || !String(payload?.media_id ?? '').trim()) { + const detail = String(payload?.errmsg ?? text ?? '').trim() || `HTTP ${response.status}`; + throw new Error(`微信生成图片素材上传失败:${detail}`); + } + return { + mediaId: String(payload.media_id), + bytes: normalized.buffer.length, + contentType: normalized.contentType, + }; +} + export function buildWechatImagePublicUrl({ publicBaseUrl, publishKey, diff --git a/wechat-media.test.mjs b/wechat-media.test.mjs new file mode 100644 index 0000000..cc2c648 --- /dev/null +++ b/wechat-media.test.mjs @@ -0,0 +1,38 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import sharp from 'sharp'; +import { uploadWechatGeneratedImage } from './wechat-media.mjs'; + +test('uploadWechatGeneratedImage converts a generated asset and uploads WeChat image media', async () => { + const source = await sharp({ + create: { width: 32, height: 32, channels: 3, background: '#336699' }, + }).webp().toBuffer(); + const calls = []; + const result = await uploadWechatGeneratedImage( + 'access-1', + '/MindSpace/user/public/images/fresh.webp', + { + publicBaseUrl: 'https://example.com', + wechatFetch: async (url, init = {}) => { + calls.push({ url: String(url), init }); + if (String(url).startsWith('https://example.com/')) { + return new Response(source, { status: 200, headers: { 'Content-Type': 'image/webp' } }); + } + if (String(url).includes('/cgi-bin/media/upload')) { + assert.equal(init.method, 'POST'); + assert.ok(init.body instanceof FormData); + return new Response(JSON.stringify({ type: 'image', media_id: 'wx-media-1', created_at: 1 }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + } + throw new Error(`unexpected url: ${url}`); + }, + }, + ); + assert.equal(result.mediaId, 'wx-media-1'); + assert.equal(result.contentType, 'image/jpeg'); + assert.ok(result.bytes > 0); + assert.match(calls[1].url, /access_token=access-1/); + assert.match(calls[1].url, /type=image/); +}); diff --git a/wechat-mp-config.mjs b/wechat-mp-config.mjs index 02bb0d2..8c41b9a 100644 --- a/wechat-mp-config.mjs +++ b/wechat-mp-config.mjs @@ -81,6 +81,7 @@ export function loadWechatMpConfig(env = process.env) { acceptLocation: env.H5_WECHAT_MP_ACCEPT_LOCATION !== '0', acceptLink: env.H5_WECHAT_MP_ACCEPT_LINK !== '0', mediaAnalysisGrayUsers: parseCsvList(env.H5_WECHAT_MP_MEDIA_GRAY_USERS), + requireFreshPageThumbnail: env.H5_WECHAT_MP_FRESH_PAGE_THUMBNAILS !== '0', encodingAesKey: env.H5_WECHAT_MP_ENCODING_AES_KEY?.trim() ?? '', }; } diff --git a/wechat-mp.mjs b/wechat-mp.mjs index 5423846..6512621 100644 --- a/wechat-mp.mjs +++ b/wechat-mp.mjs @@ -13,6 +13,7 @@ import { downloadTemporaryMedia, persistWechatAttachment, persistWechatImage, + uploadWechatGeneratedImage, } from './wechat-media.mjs'; import { normalizeWechatName, resolveWechatAddressName } from './wechat/user/display-name.mjs'; import { buildAckText } from './wechat/ack/ack-provider.mjs'; @@ -27,13 +28,23 @@ import { classifyWechatIntent } from './wechat/intent/classifier.mjs'; import { isPageGenerateText, isTopicResetText } from './wechat/intent/patterns.mjs'; import { isPageDataIntent } from './chat-skills.mjs'; import { resolvePageGenerateOutcome } from './wechat/handlers/page-generate.mjs'; +import { + buildWechatImageRunMetadata, + resolveWechatImageGenerationPolicy, + WECHAT_PAGE_THUMBNAIL_MODE, +} from './wechat/image-generation-policy.mjs'; import { buildWechatAgentPrompt } from './wechat/prompts/chat-general.mjs'; import { buildPageGenerateAgentPrompt, buildPagePublishFailureText, } from './wechat/prompts/page-generate.mjs'; import { selectSendableHtmlArtifacts } from './wechat/verify/page-artifact.mjs'; +import { + collectWechatGeneratedImages, + verifyFreshWechatPageThumbnails, +} from './wechat/verify/generated-thumbnail.mjs'; import { resolveBillingTokenState } from './billing-token-state.mjs'; +import { ensureWorkspaceHtmlThumbnail } from './mindspace-workspace-thumbnails.mjs'; import { buildPageDataCollectFailureText, buildPageDataDeliveryArtifactsFromBindResult, @@ -333,6 +344,13 @@ function splitWechatText(text, maxBytes = WECHAT_CUSTOMER_TEXT_MAX_BYTES) { return chunks.length > 0 ? chunks : ['我先收到了,但这次没有生成可发送的文本结果。']; } +function appendGeneratedImageFallbackLink(text, generatedImage) { + const normalized = String(text ?? '').trim(); + const publicUrl = String(generatedImage?.publicUrl ?? '').trim(); + if (!publicUrl || normalized.includes(publicUrl)) return normalized; + return [normalized, `图片链接:${publicUrl}`].filter(Boolean).join('\n'); +} + export { splitWechatText, WECHAT_CUSTOMER_TEXT_MAX_BYTES }; function flattenSessionTools(extensions = []) { @@ -1079,6 +1097,9 @@ export function sanitizeWechatAgentOutboundText(text) { function formatWechatAgentFailureMessage(err) { const message = err instanceof Error ? err.message : String(err); + if (err?.code === 'WECHAT_IMAGE_GENERATION_REQUIRED') { + return '这次图片生成没有获得新的有效位图,所以我没有发送旧图或占位图。请稍后重试。'; + } if (isHtmlPublishFailureMessage(message)) return buildHtmlPublishFailureText(); if (isRecoverableWechatAgentSessionError(message) || isWechatAgentApiErrorText(message)) { return '刚才专属会话状态异常,我已切换到新会话。请再发一次你的需求。'; @@ -1490,6 +1511,7 @@ export function createWechatMpService({ enabled: false, }; } + const requireFreshPageThumbnail = config.requireFreshPageThumbnail === true; config = { ...loadWechatMpConfig({}), ...config, @@ -1507,6 +1529,7 @@ export function createWechatMpService({ mediaAnalysisGrayUsers: Array.isArray(config.mediaAnalysisGrayUsers) ? config.mediaAnalysisGrayUsers : [], + requireFreshPageThumbnail, asrTarget: config.asrTarget || DEFAULT_ASR_TARGET, }; @@ -1771,6 +1794,36 @@ export function createWechatMpService({ } }; + const sendCustomerServiceImage = async (openid, generatedImage) => { + const publicUrl = String(generatedImage?.publicUrl ?? '').trim(); + if (!publicUrl) throw new Error('生成图片缺少可发送的公网地址'); + const accessToken = await getStableAccessToken(); + const uploaded = await uploadWechatGeneratedImage(accessToken, publicUrl, { + wechatFetch, + publicBaseUrl: config.publicBaseUrl, + }); + const payload = await readJsonResponse( + await wechatFetch( + `${config.customerServiceUrl}?access_token=${encodeURIComponent(accessToken)}`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + touser: openid, + msgtype: 'image', + image: { media_id: uploaded.mediaId }, + }), + }, + ), + ); + if (Number(payload?.errcode ?? 0) !== 0) { + const errcode = Number(payload?.errcode ?? 0); + const errmsg = String(payload?.errmsg ?? '').trim() || 'unknown_error'; + throw new Error(`微信图片客服消息发送失败 errcode=${errcode} errmsg=${errmsg}`); + } + return uploaded; + }; + const sendTextToUser = async (userId, content) => { const openid = await userAuth.getWechatOpenidForUser(userId, config.appId); if (!openid) { @@ -1779,6 +1832,43 @@ export function createWechatMpService({ await sendCustomerServiceText(openid, content); }; + const enforceFreshPageThumbnailDelivery = async ({ + artifacts, + reply, + openid, + user, + imagePolicy, + publishDir, + }) => { + if (imagePolicy?.pageThumbnailMode !== WECHAT_PAGE_THUMBNAIL_MODE.REQUIRED_FRESH) return; + const images = collectWechatGeneratedImages(reply?.messages ?? []); + const verification = verifyFreshWechatPageThumbnails(artifacts, images); + if (verification.ok) { + try { + for (const match of verification.matches) { + const htmlRelativePath = path.relative(publishDir, match.artifact.localPath); + if (!htmlRelativePath || htmlRelativePath.startsWith('..') || path.isAbsolute(htmlRelativePath)) { + throw new Error('缩略图页面路径不在用户发布目录内'); + } + await ensureWorkspaceHtmlThumbnail(publishDir, htmlRelativePath); + } + return; + } catch (error) { + verification.ok = false; + verification.reason = `thumbnail_render_failed:${error instanceof Error ? error.message : String(error)}`; + } + } + const text = buildPagePublishFailureText({ missingFreshThumbnail: true }); + try { + await sendCustomerServiceText(openid, text, user); + } catch (sendErr) { + logger.error?.('WeChat MP fresh thumbnail failure notice failed:', sendErr); + } + const error = new Error(`wechat_page_fresh_thumbnail_required:${verification.reason}`); + error.code = 'WECHAT_PAGE_FRESH_THUMBNAIL_REQUIRED'; + throw markWechatUserNotified(error); + }; + const ensureSessionProvider = async (sessionId) => { if (!applySessionLlmProvider || !sessionId) return; const applied = await applySessionLlmProvider(sessionId); @@ -2023,7 +2113,7 @@ export function createWechatMpService({ } }; - const buildIntentMetadata = (intent, { mediaAnalysisEnabled = false } = {}) => { + const buildIntentMetadata = (intent, { mediaAnalysisEnabled = false, imagePolicy = null } = {}) => { const mediaPublicUrl = intent.media?.publicUrl || null; const mediaItems = Array.isArray(intent.recentMediaItems) && intent.recentMediaItems.length > 0 ? intent.recentMediaItems @@ -2055,6 +2145,7 @@ export function createWechatMpService({ recognition: intent.msgType === 'voice' ? intent.agentText || null : null, location: intent.location || null, link: intent.link || null, + memindRun: buildWechatImageRunMetadata(imagePolicy), }; }; @@ -2099,6 +2190,11 @@ export function createWechatMpService({ const mediaAnalysisEnabled = isWechatMediaGrayUser(user, config.mediaAnalysisGrayUsers); const resetCandidate = intent.msgType === 'text' || intent.msgType === 'voice' ? intent.agentText : ''; + const imagePolicy = resolveWechatImageGenerationPolicy({ + text: resetCandidate, + isPageGenerate: wechatIntent.kind === 'page.generate', + requireFreshPageThumbnail: config.requireFreshPageThumbnail, + }); // Page Data delivery owns persistent files, datasets and two publication // policies. Reusing a conversational route here can make a new request // inspect/retry unrelated historical pages from that session. @@ -2139,14 +2235,17 @@ export function createWechatMpService({ const requestStartedAt = Date.now(); const agentPrompt = wechatIntent.kind === 'page.generate' - ? buildPageGenerateAgentPrompt(intent, { wantsDocx: wechatIntent.wantsDocx }) - : buildWechatAgentPrompt(intent); + ? buildPageGenerateAgentPrompt(intent, { + wantsDocx: wechatIntent.wantsDocx, + imagePolicy, + }) + : buildWechatAgentPrompt(intent, { imagePolicy }); const reply = await executeSessionReply( (pathname, init) => fetchForSession(sessionId, pathname, init), sessionId, requestId, agentPrompt, - buildIntentMetadata(intent, { mediaAnalysisEnabled }), + buildIntentMetadata(intent, { mediaAnalysisEnabled, imagePolicy }), { submitReply: submitSessionReply ? ({ requestId: replyRequestId, userMessage }) => @@ -2159,6 +2258,12 @@ export function createWechatMpService({ : null, }, ); + const generatedImages = collectWechatGeneratedImages(reply?.messages ?? []); + if (imagePolicy.standaloneImageMode === 'required' && generatedImages.length === 0) { + const error = new Error('图片生成没有获得本轮新的有效位图,请稍后重试'); + error.code = 'WECHAT_IMAGE_GENERATION_REQUIRED'; + throw error; + } const workingDir = publishLayout?.publishDir ?? (await userAuth.resolveWorkingDir(user.userId)); const linkExistsForRequest = resolveLinkExistsForWorkingDir(workingDir, linkExists); const { @@ -2237,6 +2342,16 @@ export function createWechatMpService({ } else if (htmlGenerationNeedsRetry || suspiciousPublishClaim) { throw new Error('stale_session_poisoned_completion'); } + if (wechatIntent.kind === 'page.generate') { + await enforceFreshPageThumbnailDelivery({ + artifacts: publishArtifacts, + reply, + openid: inbound.fromUserName, + user, + imagePolicy, + publishDir: workingDir, + }); + } const pageDataOutcome = await enforcePageDataCollectDelivery({ reply, intent, @@ -2263,11 +2378,23 @@ export function createWechatMpService({ const tokenState = await resolveWechatBillingTokenState(sessionId, reply.tokenState); await userAuth.billSessionUsage(user.userId, sessionId, tokenState, requestId); } - const finalizedReply = await maybeAttachPublishedHtmlLink(reply, { + let finalizedReply = await maybeAttachPublishedHtmlLink(reply, { workingDir, publicBaseUrl: config.publicBaseUrl, artifacts: publishArtifacts, }); + if ( + wechatIntent.kind !== 'page.generate' + && imagePolicy.standaloneImageMode !== 'disabled' + && generatedImages.length > 0 + ) { + try { + await sendCustomerServiceImage(inbound.fromUserName, generatedImages[0]); + } catch (sendErr) { + logger.warn?.('WeChat MP generated image native delivery failed, falling back to link:', sendErr); + finalizedReply = appendGeneratedImageFallbackLink(finalizedReply, generatedImages[0]); + } + } scheduleWechatSessionSnapshotRefresh(sessionId, user.userId); await sendCustomerServiceText(inbound.fromUserName, await guardScheduleReply(finalizedReply), user, { verifiedHtmlUrls: publishArtifacts.map((artifact) => artifact.url), @@ -2309,14 +2436,17 @@ export function createWechatMpService({ const retryStartedAt = Date.now(); const retryPrompt = wechatIntent.kind === 'page.generate' - ? buildPageGenerateAgentPrompt(intent, { wantsDocx: wechatIntent.wantsDocx }) - : buildWechatAgentPrompt(intent); + ? buildPageGenerateAgentPrompt(intent, { + wantsDocx: wechatIntent.wantsDocx, + imagePolicy, + }) + : buildWechatAgentPrompt(intent, { imagePolicy }); const reply = await executeSessionReply( (pathname, init) => fetchForSession(sessionId, pathname, init), sessionId, retryId, retryPrompt, - buildIntentMetadata(intent, { mediaAnalysisEnabled }), + buildIntentMetadata(intent, { mediaAnalysisEnabled, imagePolicy }), { submitReply: submitSessionReply ? ({ requestId: replyRequestId, userMessage }) => @@ -2329,6 +2459,12 @@ export function createWechatMpService({ : null, }, ); + const generatedImages = collectWechatGeneratedImages(reply?.messages ?? []); + if (imagePolicy.standaloneImageMode === 'required' && generatedImages.length === 0) { + const error = new Error('图片生成没有获得本轮新的有效位图,请稍后重试'); + error.code = 'WECHAT_IMAGE_GENERATION_REQUIRED'; + throw error; + } const workingDir = publishLayout?.publishDir ?? (await userAuth.resolveWorkingDir(user.userId)); const linkExistsForRequest = resolveLinkExistsForWorkingDir(workingDir, linkExists); const { @@ -2404,6 +2540,16 @@ export function createWechatMpService({ } else if (htmlGenerationNeedsRetry || suspiciousPublishClaim) { throw new Error(buildHtmlPublishFailureText()); } + if (wechatIntent.kind === 'page.generate') { + await enforceFreshPageThumbnailDelivery({ + artifacts: publishArtifacts, + reply, + openid: inbound.fromUserName, + user, + imagePolicy, + publishDir: workingDir, + }); + } const pageDataOutcome = await enforcePageDataCollectDelivery({ reply, intent, @@ -2430,11 +2576,23 @@ export function createWechatMpService({ const tokenState = await resolveWechatBillingTokenState(sessionId, reply.tokenState); await userAuth.billSessionUsage(user.userId, sessionId, tokenState, retryId); } - const finalizedReply = await maybeAttachPublishedHtmlLink(reply, { + let finalizedReply = await maybeAttachPublishedHtmlLink(reply, { workingDir, publicBaseUrl: config.publicBaseUrl, artifacts: publishArtifacts, }); + if ( + wechatIntent.kind !== 'page.generate' + && imagePolicy.standaloneImageMode !== 'disabled' + && generatedImages.length > 0 + ) { + try { + await sendCustomerServiceImage(inbound.fromUserName, generatedImages[0]); + } catch (sendErr) { + logger.warn?.('WeChat MP generated image native delivery failed, falling back to link:', sendErr); + finalizedReply = appendGeneratedImageFallbackLink(finalizedReply, generatedImages[0]); + } + } scheduleWechatSessionSnapshotRefresh(sessionId, user.userId); await sendCustomerServiceText(inbound.fromUserName, await guardScheduleReply(finalizedReply), user, { verifiedHtmlUrls: publishArtifacts.map((artifact) => artifact.url), diff --git a/wechat-mp.test.mjs b/wechat-mp.test.mjs index aacc762..9be0d9a 100644 --- a/wechat-mp.test.mjs +++ b/wechat-mp.test.mjs @@ -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 `${title}
${'x'.repeat(600)}

TKMind · 智趣

`; +function previewReadyPageHtml({ title = 'Page', subtitle = '测试页面', cover = '' } = {}) { + const coverField = cover ? `,"cover":"${cover}"` : ''; + return `${title}
${'x'.repeat(600)}

TKMind · 智趣

`; } function jsonEscapedPreviewReadyPageHtml(options = {}) { @@ -95,6 +97,7 @@ function createBoundWechatService({ unboundTextPrefix: '请先绑定', progressDelayMs: 0, maxImageBytes: 1024 * 1024, + requireFreshPageThumbnail: false, ...config, }, userAuth: { @@ -859,6 +862,8 @@ test('loadWechatMpConfig requires full config and enable flag', () => { }); assert.equal(config.enabled, true); assert.equal(config.bindPath, '/auth/wechat/authorize?intent=login'); + assert.equal(config.requireFreshPageThumbnail, true); + assert.equal(loadWechatMpConfig({ H5_WECHAT_MP_FRESH_PAGE_THUMBNAILS: '0' }).requireFreshPageThumbnail, false); }); test('verifyWechatMpSignature accepts valid signature', () => { @@ -4932,3 +4937,312 @@ test('wechat mp service exposes route status and can recreate route for bound us assert.equal(calls.some((item) => item === 'register:session-new'), true); assert.equal(calls.includes('upserted'), true); }); + +test('wechat mp page delivery requires and verifies a current-run fresh thumbnail', async () => { + const token = 'token'; + const timestamp = '1710000000'; + const nonce = 'nonce'; + const workspaceRoot = fs.mkdtempSync('/tmp/wechat-mp-fresh-thumbnail-'); + const htmlPath = path.join(workspaceRoot, 'public', 'fresh.html'); + const wechatPayloads = []; + const generated = { + ok: true, + jobId: 'job-fresh-page', + source: { mimeType: 'image/webp', width: 1280, height: 720 }, + asset: { + id: 'asset-fresh-page', + htmlSrc: 'images/fresh-page.webp', + publicUrl: 'https://example.com/MindSpace/user-1/public/images/fresh-page.webp', + workspaceRelativePath: 'public/images/fresh-page.webp', + }, + }; + const pageHtml = previewReadyPageHtml({ + title: 'Fresh', + subtitle: '本轮新缩略图', + cover: generated.asset.htmlSrc, + }); + const pageImage = await sharp({ + create: { width: 64, height: 64, channels: 3, background: '#3366cc' }, + }).webp().toBuffer(); + const eventFrame = (event) => `data: ${JSON.stringify(event)}\n\n`; + const service = createBoundWechatService({ + token, + config: { requireFreshPageThumbnail: true }, + userAuth: { + async resolveWorkingDir() { + return workspaceRoot; + }, + async getUserPublishLayout() { + return { + publishDir: workspaceRoot, + displayName: 'John', + username: 'john', + slug: 'john', + constraints: null, + }; + }, + }, + sessionApiFetch: async (sessionId, pathname, init = {}) => { + assert.equal(sessionId, 'session-1'); + if (pathname === '/sessions/session-1/events') { + return new Response( + [ + eventFrame({ + type: 'Message', + request_id: 'req-fresh-page', + message: { + id: 'assistant-tools', + role: 'assistant', + metadata: { userVisible: true }, + content: [ + { + id: 'call-page-skill', + type: 'toolRequest', + toolCall: { + value: { name: 'load_skill', arguments: { name: 'static-page-publish' } }, + }, + }, + { + id: 'call-generate', + type: 'toolRequest', + toolCall: { + value: { + name: 'sandbox-fs__generate_image', + arguments: { purpose: 'hero' }, + }, + }, + }, + { + id: 'call-generate', + type: 'toolResponse', + toolResult: { + status: 'success', + value: { content: [{ type: 'text', text: JSON.stringify(generated) }] }, + }, + }, + { + id: 'call-write-page', + type: 'toolRequest', + toolCall: { + value: { + name: 'sandbox-fs__write_file', + arguments: { path: 'public/fresh.html', content: pageHtml }, + }, + }, + }, + ], + }, + }), + eventFrame({ + type: 'Message', + request_id: 'req-fresh-page', + message: { + id: 'assistant-final', + role: 'assistant', + metadata: { userVisible: true }, + content: [{ + type: 'text', + text: '页面已完成:https://example.com/MindSpace/user-1/public/fresh.html', + }], + }, + }), + eventFrame({ + type: 'Finish', + request_id: 'req-fresh-page', + token_state: { inputTokens: 1, outputTokens: 2 }, + }), + ].join(''), + { status: 200, headers: { 'Content-Type': 'text/event-stream' } }, + ); + } + if (pathname === '/sessions/session-1/reply') { + const body = JSON.parse(init.body); + assert.equal(body.user_message.metadata.memindRun.channel, 'wechat_mp'); + assert.equal(body.user_message.metadata.memindRun.pageThumbnailMode, 'required_fresh'); + assert.equal(body.user_message.metadata.memindRun.inlineImageMode, 'auto'); + assert.match(body.user_message.content[0].text, /服务号页面新缩略图硬要求/); + assert.match(body.user_message.content[0].text, /本轮新生成资产/); + fs.mkdirSync(path.dirname(htmlPath), { recursive: true }); + fs.writeFileSync( + htmlPath, + pageHtml, + 'utf8', + ); + fs.mkdirSync(path.join(workspaceRoot, 'public', 'images'), { recursive: true }); + fs.writeFileSync(path.join(workspaceRoot, 'public', generated.asset.htmlSrc), pageImage); + return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } }); + } + if (pathname === '/agent/harness_remember' || pathname === '/agent/harness_bootstrap') { + return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } }); + } + throw new Error(`unexpected session path: ${pathname}`); + }, + wechatFetch: async (url, init = {}) => { + if (String(url).includes('/cgi-bin/stable_token')) { + return new Response(JSON.stringify({ access_token: 'access-1', expires_in: 7200 }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + } + if (String(url).includes('/cgi-bin/message/custom/send')) { + wechatPayloads.push(JSON.parse(init.body)); + return new Response(JSON.stringify({ errcode: 0, errmsg: 'ok' }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + } + throw new Error(`unexpected wechat url: ${url}`); + }, + }); + + const originalRandomUuid = crypto.randomUUID; + crypto.randomUUID = () => 'req-fresh-page'; + try { + const result = await service.handleInboundMessage( + inboundXml({ content: '请生成一个旅行 html 页面,文件名 public/fresh.html' }), + { timestamp, nonce, signature: signatureFor(token, timestamp, nonce) }, + ); + assert.equal(result.status, 200); + await result.task; + assert.equal(fs.existsSync(path.join(workspaceRoot, 'public', 'fresh.thumbnail.svg')), true); + } finally { + crypto.randomUUID = originalRandomUuid; + fs.rmSync(workspaceRoot, { recursive: true, force: true }); + } + assert.equal(wechatPayloads.length, 1); + assert.equal(wechatPayloads[0].msgtype, 'text'); + assert.match(wechatPayloads[0].text.content, /fresh\.html/); +}); + +test('wechat mp standalone image intent sends a native image message and text', async () => { + const token = 'token'; + const timestamp = '1710000000'; + const nonce = 'nonce'; + const sourceImage = await sharp({ + create: { width: 48, height: 48, channels: 3, background: '#cc8844' }, + }).webp().toBuffer(); + const wechatPayloads = []; + const generated = { + ok: true, + jobId: 'job-chat-image', + source: { mimeType: 'image/webp', width: 1024, height: 1024 }, + asset: { + id: 'asset-chat-image', + htmlSrc: 'images/chat-image.webp', + publicUrl: 'https://example.com/MindSpace/user-1/public/images/chat-image.webp', + workspaceRelativePath: 'public/images/chat-image.webp', + }, + }; + const eventFrame = (event) => `data: ${JSON.stringify(event)}\n\n`; + const service = createBoundWechatService({ + token, + sessionApiFetch: async (sessionId, pathname, init = {}) => { + assert.equal(sessionId, 'session-1'); + if (pathname === '/sessions/session-1/events') { + return new Response( + [ + eventFrame({ + type: 'Message', + request_id: 'req-chat-image', + message: { + id: 'assistant-tools', + role: 'assistant', + metadata: { userVisible: true }, + content: [ + { + id: 'call-image', + type: 'toolRequest', + toolCall: { + value: { + name: 'sandbox-fs__generate_image', + arguments: { purpose: 'inline_image' }, + }, + }, + }, + { + id: 'call-image', + type: 'toolResponse', + toolResult: { + status: 'success', + value: { content: [{ type: 'text', text: JSON.stringify(generated) }] }, + }, + }, + ], + }, + }), + eventFrame({ + type: 'Message', + request_id: 'req-chat-image', + message: { + id: 'assistant-final', + role: 'assistant', + metadata: { userVisible: true }, + content: [{ type: 'text', text: '新图片已经生成。' }], + }, + }), + eventFrame({ + type: 'Finish', + request_id: 'req-chat-image', + token_state: { inputTokens: 1, outputTokens: 2 }, + }), + ].join(''), + { status: 200, headers: { 'Content-Type': 'text/event-stream' } }, + ); + } + if (pathname === '/sessions/session-1/reply') { + const body = JSON.parse(init.body); + assert.equal(body.user_message.metadata.memindRun.imageGenerationMode, 'required'); + assert.equal(body.user_message.metadata.memindRun.pageThumbnailMode, 'auto'); + assert.match(body.user_message.content[0].text, /purpose=`inline_image`/); + return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } }); + } + if (pathname === '/agent/harness_remember' || pathname === '/agent/harness_bootstrap') { + return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } }); + } + throw new Error(`unexpected session path: ${pathname}`); + }, + wechatFetch: async (url, init = {}) => { + const target = String(url); + if (target.includes('/cgi-bin/stable_token')) { + return new Response(JSON.stringify({ access_token: 'access-1', expires_in: 7200 }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + } + if (target === generated.asset.publicUrl) { + return new Response(sourceImage, { status: 200, headers: { 'Content-Type': 'image/webp' } }); + } + if (target.includes('/cgi-bin/media/upload')) { + assert.ok(init.body instanceof FormData); + return new Response(JSON.stringify({ type: 'image', media_id: 'wx-generated-1' }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + } + if (target.includes('/cgi-bin/message/custom/send')) { + wechatPayloads.push(JSON.parse(init.body)); + return new Response(JSON.stringify({ errcode: 0, errmsg: 'ok' }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + } + throw new Error(`unexpected wechat url: ${url}`); + }, + }); + + const originalRandomUuid = crypto.randomUUID; + crypto.randomUUID = () => 'req-chat-image'; + try { + const result = await service.handleInboundMessage( + inboundXml({ content: '帮我生成一张雨夜橘猫图片' }), + { timestamp, nonce, signature: signatureFor(token, timestamp, nonce) }, + ); + assert.equal(result.status, 200); + await result.task; + } finally { + crypto.randomUUID = originalRandomUuid; + } + assert.deepEqual(wechatPayloads.map((payload) => payload.msgtype), ['image', 'text']); + assert.equal(wechatPayloads[0].image.media_id, 'wx-generated-1'); + assert.match(wechatPayloads[1].text.content, /新图片已经生成/); +}); diff --git a/wechat/image-generation-policy.mjs b/wechat/image-generation-policy.mjs new file mode 100644 index 0000000..cced31f --- /dev/null +++ b/wechat/image-generation-policy.mjs @@ -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 写 `` 供交付守卫识别。', + inlineInstruction, + '当前轮没有新的成功 generate_image、没有新 jobId,或正式页面未引用本轮资产时,禁止交付页面链接或声称完成。', + ].join('\n'); +} diff --git a/wechat/image-generation-policy.test.mjs b/wechat/image-generation-policy.test.mjs new file mode 100644 index 0000000..ca1cf6c --- /dev/null +++ b/wechat/image-generation-policy.test.mjs @@ -0,0 +1,49 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + buildWechatPageImageInstruction, + buildWechatStandaloneImageInstruction, + resolveWechatImageGenerationPolicy, + WECHAT_PAGE_THUMBNAIL_MODE, +} from './image-generation-policy.mjs'; + +test('service-account page always requires a fresh thumbnail without forcing body images', () => { + const policy = resolveWechatImageGenerationPolicy({ + text: '请做一个苏州旅行页面', + isPageGenerate: true, + requireFreshPageThumbnail: true, + }); + assert.equal(policy.pageThumbnailMode, WECHAT_PAGE_THUMBNAIL_MODE.REQUIRED_FRESH); + assert.equal(policy.imageGenerationMode, 'required'); + assert.equal(policy.inlineImageMode, 'auto'); + assert.equal(policy.standaloneImageMode, 'auto'); + assert.match(buildWechatPageImageInstruction(policy), /每个本轮交付给用户的正式内容页面/); +}); + +test('no-image page request disables body images but keeps required fresh thumbnail', () => { + const policy = resolveWechatImageGenerationPolicy({ + text: '生成一个活动页面,只要文字,不要图片', + isPageGenerate: true, + requireFreshPageThumbnail: true, + }); + assert.equal(policy.pageThumbnailMode, 'required_fresh'); + assert.equal(policy.inlineImageMode, 'disabled'); + assert.match(buildWechatPageImageInstruction(policy), /正文和 Hero 不展示图片/); +}); + +test('explicit body illustration request is independent from the page thumbnail', () => { + const policy = resolveWechatImageGenerationPolicy({ + text: '生成一个科普页面,每个章节都要正文插图', + isPageGenerate: true, + requireFreshPageThumbnail: true, + }); + assert.equal(policy.inlineImageMode, 'required'); +}); + +test('standalone image intent gets an inline_image tool contract', () => { + const policy = resolveWechatImageGenerationPolicy({ text: '帮我生成一张雨夜橘猫图片' }); + assert.equal(policy.standaloneImageMode, 'required'); + const instruction = buildWechatStandaloneImageInstruction(policy, { sourceMessageId: 'msg-1' }); + assert.match(instruction, /purpose=`inline_image`/); + assert.match(instruction, /idempotency_key=wechat-msg-1-image/); +}); diff --git a/wechat/prompts/chat-general.mjs b/wechat/prompts/chat-general.mjs index 89a4295..7322b57 100644 --- a/wechat/prompts/chat-general.mjs +++ b/wechat/prompts/chat-general.mjs @@ -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( '【微信服务号新消息】请只回答下面这条用户消息,不要主动延续无关的历史话题。', '若用户只是在测试连通性,请一句话确认收到即可,不要展开旧任务。', diff --git a/wechat/prompts/page-generate.mjs b/wechat/prompts/page-generate.mjs index e0f3185..f5fca1d 100644 --- a/wechat/prompts/page-generate.mjs +++ b/wechat/prompts/page-generate.mjs @@ -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. 在 `` 写入微信分享卡片所需元数据(必须):', ' - ``', - ' - ``', - ' tag/accent/subtitle 必须与页面主题一致;有 hero 图时 cover 指向同目录 raster 图(jpg/png)。', + ` - \`\``, + 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 );有 error 则补元数据后再回复链接。', '7. 页脚加 `

TKMind · 智趣

`(必须;禁止省略、不要用邮箱/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"),所以我先不发链接。', diff --git a/wechat/verify/generated-thumbnail.mjs b/wechat/verify/generated-thumbnail.mjs new file mode 100644 index 0000000..8bcf98a --- /dev/null +++ b/wechat/verify/generated-thumbnail.mjs @@ -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(/]*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 /]*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 }; +} diff --git a/wechat/verify/generated-thumbnail.test.mjs b/wechat/verify/generated-thumbnail.test.mjs new file mode 100644 index 0000000..e801994 --- /dev/null +++ b/wechat/verify/generated-thumbnail.test.mjs @@ -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, + '', + '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, ''); + fs.writeFileSync(second, ''); + 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, ''); + 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, ''); + fs.writeFileSync(admin, ''); + 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 }); + } +});