feat(wechat): add image generation delivery policy
Memind CI / Test, build, and release guards (pull_request) Successful in 2m45s

This commit is contained in:
john
2026-07-21 23:21:44 +08:00
parent bfb1f6fea9
commit 28581310da
13 changed files with 1078 additions and 19 deletions
+20
View File
@@ -96,6 +96,26 @@ IMAGE_MAKE_MEMIND_CONFIG_TOKEN=<与 Portal IMAGE_MAKE_TOKEN 相同的值>
重试三次,并为每次重试使用新的幂等键。生产启用图片生成前必须同时验证生图 Provider 与视觉 重试三次,并为每次重试使用新的幂等键。生产启用图片生成前必须同时验证生图 Provider 与视觉
审核 Provider,不能只验证 `/health` 审核 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` 服务。正式启用前必须确保: Portal runtime 不包含独立 `image_make` 服务。正式启用前必须确保:
+1 -1
View File
@@ -58,7 +58,7 @@
"test:scenario": "node scripts/run-scenario-test.mjs", "test:scenario": "node scripts/run-scenario-test.mjs",
"verify:children-hobby-diet-survey": "node scripts/verify-children-hobby-diet-survey.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: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: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", "test:mindspace-service": "node --test mindspace-service/mindspace-rpc-server.test.mjs",
"verify:chat-finish-sync": "node scripts/verify-chat-finish-sync.mjs", "verify:chat-finish-sync": "node scripts/verify-chat-finish-sync.mjs",
+90
View File
@@ -3,12 +3,15 @@ import fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { fetch as undiciFetch } from 'undici'; import { fetch as undiciFetch } from 'undici';
import sharp from 'sharp';
import { buildPublicUrl, PUBLISH_ROOT_DIR, PUBLIC_ZONE_DIR } from './user-publish.mjs'; import { buildPublicUrl, PUBLISH_ROOT_DIR, PUBLIC_ZONE_DIR } from './user-publish.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url)); 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_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_IMAGE_BYTES = 10 * 1024 * 1024;
const DEFAULT_MAX_OUTBOUND_IMAGE_BYTES = 2 * 1024 * 1024;
const DEFAULT_MAX_ATTACHMENT_BYTES = 30 * 1024 * 1024; const DEFAULT_MAX_ATTACHMENT_BYTES = 30 * 1024 * 1024;
const ALLOWED_IMAGE_MIME_TYPES = new Map([ const ALLOWED_IMAGE_MIME_TYPES = new Map([
['image/jpeg', 'jpg'], ['image/jpeg', 'jpg'],
@@ -96,6 +99,93 @@ export async function downloadTemporaryMedia(accessToken, mediaId, { wechatFetch
return { buffer, contentType }; 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({ export function buildWechatImagePublicUrl({
publicBaseUrl, publicBaseUrl,
publishKey, publishKey,
+38
View File
@@ -0,0 +1,38 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import sharp from 'sharp';
import { uploadWechatGeneratedImage } from './wechat-media.mjs';
test('uploadWechatGeneratedImage converts a generated asset and uploads WeChat image media', async () => {
const source = await sharp({
create: { width: 32, height: 32, channels: 3, background: '#336699' },
}).webp().toBuffer();
const calls = [];
const result = await uploadWechatGeneratedImage(
'access-1',
'/MindSpace/user/public/images/fresh.webp',
{
publicBaseUrl: 'https://example.com',
wechatFetch: async (url, init = {}) => {
calls.push({ url: String(url), init });
if (String(url).startsWith('https://example.com/')) {
return new Response(source, { status: 200, headers: { 'Content-Type': 'image/webp' } });
}
if (String(url).includes('/cgi-bin/media/upload')) {
assert.equal(init.method, 'POST');
assert.ok(init.body instanceof FormData);
return new Response(JSON.stringify({ type: 'image', media_id: 'wx-media-1', created_at: 1 }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
throw new Error(`unexpected url: ${url}`);
},
},
);
assert.equal(result.mediaId, 'wx-media-1');
assert.equal(result.contentType, 'image/jpeg');
assert.ok(result.bytes > 0);
assert.match(calls[1].url, /access_token=access-1/);
assert.match(calls[1].url, /type=image/);
});
+1
View File
@@ -81,6 +81,7 @@ export function loadWechatMpConfig(env = process.env) {
acceptLocation: env.H5_WECHAT_MP_ACCEPT_LOCATION !== '0', acceptLocation: env.H5_WECHAT_MP_ACCEPT_LOCATION !== '0',
acceptLink: env.H5_WECHAT_MP_ACCEPT_LINK !== '0', acceptLink: env.H5_WECHAT_MP_ACCEPT_LINK !== '0',
mediaAnalysisGrayUsers: parseCsvList(env.H5_WECHAT_MP_MEDIA_GRAY_USERS), 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() ?? '', encodingAesKey: env.H5_WECHAT_MP_ENCODING_AES_KEY?.trim() ?? '',
}; };
} }
+167 -9
View File
@@ -13,6 +13,7 @@ import {
downloadTemporaryMedia, downloadTemporaryMedia,
persistWechatAttachment, persistWechatAttachment,
persistWechatImage, persistWechatImage,
uploadWechatGeneratedImage,
} from './wechat-media.mjs'; } from './wechat-media.mjs';
import { normalizeWechatName, resolveWechatAddressName } from './wechat/user/display-name.mjs'; import { normalizeWechatName, resolveWechatAddressName } from './wechat/user/display-name.mjs';
import { buildAckText } from './wechat/ack/ack-provider.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 { isPageGenerateText, isTopicResetText } from './wechat/intent/patterns.mjs';
import { isPageDataIntent } from './chat-skills.mjs'; import { isPageDataIntent } from './chat-skills.mjs';
import { resolvePageGenerateOutcome } from './wechat/handlers/page-generate.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 { buildWechatAgentPrompt } from './wechat/prompts/chat-general.mjs';
import { import {
buildPageGenerateAgentPrompt, buildPageGenerateAgentPrompt,
buildPagePublishFailureText, buildPagePublishFailureText,
} from './wechat/prompts/page-generate.mjs'; } from './wechat/prompts/page-generate.mjs';
import { selectSendableHtmlArtifacts } from './wechat/verify/page-artifact.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 { resolveBillingTokenState } from './billing-token-state.mjs';
import { ensureWorkspaceHtmlThumbnail } from './mindspace-workspace-thumbnails.mjs';
import { import {
buildPageDataCollectFailureText, buildPageDataCollectFailureText,
buildPageDataDeliveryArtifactsFromBindResult, buildPageDataDeliveryArtifactsFromBindResult,
@@ -333,6 +344,13 @@ function splitWechatText(text, maxBytes = WECHAT_CUSTOMER_TEXT_MAX_BYTES) {
return chunks.length > 0 ? chunks : ['我先收到了,但这次没有生成可发送的文本结果。']; 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 }; export { splitWechatText, WECHAT_CUSTOMER_TEXT_MAX_BYTES };
function flattenSessionTools(extensions = []) { function flattenSessionTools(extensions = []) {
@@ -1079,6 +1097,9 @@ export function sanitizeWechatAgentOutboundText(text) {
function formatWechatAgentFailureMessage(err) { function formatWechatAgentFailureMessage(err) {
const message = err instanceof Error ? err.message : String(err); const message = err instanceof Error ? err.message : String(err);
if (err?.code === 'WECHAT_IMAGE_GENERATION_REQUIRED') {
return '这次图片生成没有获得新的有效位图,所以我没有发送旧图或占位图。请稍后重试。';
}
if (isHtmlPublishFailureMessage(message)) return buildHtmlPublishFailureText(); if (isHtmlPublishFailureMessage(message)) return buildHtmlPublishFailureText();
if (isRecoverableWechatAgentSessionError(message) || isWechatAgentApiErrorText(message)) { if (isRecoverableWechatAgentSessionError(message) || isWechatAgentApiErrorText(message)) {
return '刚才专属会话状态异常,我已切换到新会话。请再发一次你的需求。'; return '刚才专属会话状态异常,我已切换到新会话。请再发一次你的需求。';
@@ -1490,6 +1511,7 @@ export function createWechatMpService({
enabled: false, enabled: false,
}; };
} }
const requireFreshPageThumbnail = config.requireFreshPageThumbnail === true;
config = { config = {
...loadWechatMpConfig({}), ...loadWechatMpConfig({}),
...config, ...config,
@@ -1507,6 +1529,7 @@ export function createWechatMpService({
mediaAnalysisGrayUsers: Array.isArray(config.mediaAnalysisGrayUsers) mediaAnalysisGrayUsers: Array.isArray(config.mediaAnalysisGrayUsers)
? config.mediaAnalysisGrayUsers ? config.mediaAnalysisGrayUsers
: [], : [],
requireFreshPageThumbnail,
asrTarget: config.asrTarget || DEFAULT_ASR_TARGET, 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 sendTextToUser = async (userId, content) => {
const openid = await userAuth.getWechatOpenidForUser(userId, config.appId); const openid = await userAuth.getWechatOpenidForUser(userId, config.appId);
if (!openid) { if (!openid) {
@@ -1779,6 +1832,43 @@ export function createWechatMpService({
await sendCustomerServiceText(openid, content); 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) => { const ensureSessionProvider = async (sessionId) => {
if (!applySessionLlmProvider || !sessionId) return; if (!applySessionLlmProvider || !sessionId) return;
const applied = await applySessionLlmProvider(sessionId); 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 mediaPublicUrl = intent.media?.publicUrl || null;
const mediaItems = Array.isArray(intent.recentMediaItems) && intent.recentMediaItems.length > 0 const mediaItems = Array.isArray(intent.recentMediaItems) && intent.recentMediaItems.length > 0
? intent.recentMediaItems ? intent.recentMediaItems
@@ -2055,6 +2145,7 @@ export function createWechatMpService({
recognition: intent.msgType === 'voice' ? intent.agentText || null : null, recognition: intent.msgType === 'voice' ? intent.agentText || null : null,
location: intent.location || null, location: intent.location || null,
link: intent.link || null, link: intent.link || null,
memindRun: buildWechatImageRunMetadata(imagePolicy),
}; };
}; };
@@ -2099,6 +2190,11 @@ export function createWechatMpService({
const mediaAnalysisEnabled = isWechatMediaGrayUser(user, config.mediaAnalysisGrayUsers); const mediaAnalysisEnabled = isWechatMediaGrayUser(user, config.mediaAnalysisGrayUsers);
const resetCandidate = const resetCandidate =
intent.msgType === 'text' || intent.msgType === 'voice' ? intent.agentText : ''; 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 // Page Data delivery owns persistent files, datasets and two publication
// policies. Reusing a conversational route here can make a new request // policies. Reusing a conversational route here can make a new request
// inspect/retry unrelated historical pages from that session. // inspect/retry unrelated historical pages from that session.
@@ -2139,14 +2235,17 @@ export function createWechatMpService({
const requestStartedAt = Date.now(); const requestStartedAt = Date.now();
const agentPrompt = const agentPrompt =
wechatIntent.kind === 'page.generate' wechatIntent.kind === 'page.generate'
? buildPageGenerateAgentPrompt(intent, { wantsDocx: wechatIntent.wantsDocx }) ? buildPageGenerateAgentPrompt(intent, {
: buildWechatAgentPrompt(intent); wantsDocx: wechatIntent.wantsDocx,
imagePolicy,
})
: buildWechatAgentPrompt(intent, { imagePolicy });
const reply = await executeSessionReply( const reply = await executeSessionReply(
(pathname, init) => fetchForSession(sessionId, pathname, init), (pathname, init) => fetchForSession(sessionId, pathname, init),
sessionId, sessionId,
requestId, requestId,
agentPrompt, agentPrompt,
buildIntentMetadata(intent, { mediaAnalysisEnabled }), buildIntentMetadata(intent, { mediaAnalysisEnabled, imagePolicy }),
{ {
submitReply: submitSessionReply submitReply: submitSessionReply
? ({ requestId: replyRequestId, userMessage }) => ? ({ requestId: replyRequestId, userMessage }) =>
@@ -2159,6 +2258,12 @@ export function createWechatMpService({
: null, : 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 workingDir = publishLayout?.publishDir ?? (await userAuth.resolveWorkingDir(user.userId));
const linkExistsForRequest = resolveLinkExistsForWorkingDir(workingDir, linkExists); const linkExistsForRequest = resolveLinkExistsForWorkingDir(workingDir, linkExists);
const { const {
@@ -2237,6 +2342,16 @@ export function createWechatMpService({
} else if (htmlGenerationNeedsRetry || suspiciousPublishClaim) { } else if (htmlGenerationNeedsRetry || suspiciousPublishClaim) {
throw new Error('stale_session_poisoned_completion'); 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({ const pageDataOutcome = await enforcePageDataCollectDelivery({
reply, reply,
intent, intent,
@@ -2263,11 +2378,23 @@ export function createWechatMpService({
const tokenState = await resolveWechatBillingTokenState(sessionId, reply.tokenState); const tokenState = await resolveWechatBillingTokenState(sessionId, reply.tokenState);
await userAuth.billSessionUsage(user.userId, sessionId, tokenState, requestId); await userAuth.billSessionUsage(user.userId, sessionId, tokenState, requestId);
} }
const finalizedReply = await maybeAttachPublishedHtmlLink(reply, { let finalizedReply = await maybeAttachPublishedHtmlLink(reply, {
workingDir, workingDir,
publicBaseUrl: config.publicBaseUrl, publicBaseUrl: config.publicBaseUrl,
artifacts: publishArtifacts, 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); scheduleWechatSessionSnapshotRefresh(sessionId, user.userId);
await sendCustomerServiceText(inbound.fromUserName, await guardScheduleReply(finalizedReply), user, { await sendCustomerServiceText(inbound.fromUserName, await guardScheduleReply(finalizedReply), user, {
verifiedHtmlUrls: publishArtifacts.map((artifact) => artifact.url), verifiedHtmlUrls: publishArtifacts.map((artifact) => artifact.url),
@@ -2309,14 +2436,17 @@ export function createWechatMpService({
const retryStartedAt = Date.now(); const retryStartedAt = Date.now();
const retryPrompt = const retryPrompt =
wechatIntent.kind === 'page.generate' wechatIntent.kind === 'page.generate'
? buildPageGenerateAgentPrompt(intent, { wantsDocx: wechatIntent.wantsDocx }) ? buildPageGenerateAgentPrompt(intent, {
: buildWechatAgentPrompt(intent); wantsDocx: wechatIntent.wantsDocx,
imagePolicy,
})
: buildWechatAgentPrompt(intent, { imagePolicy });
const reply = await executeSessionReply( const reply = await executeSessionReply(
(pathname, init) => fetchForSession(sessionId, pathname, init), (pathname, init) => fetchForSession(sessionId, pathname, init),
sessionId, sessionId,
retryId, retryId,
retryPrompt, retryPrompt,
buildIntentMetadata(intent, { mediaAnalysisEnabled }), buildIntentMetadata(intent, { mediaAnalysisEnabled, imagePolicy }),
{ {
submitReply: submitSessionReply submitReply: submitSessionReply
? ({ requestId: replyRequestId, userMessage }) => ? ({ requestId: replyRequestId, userMessage }) =>
@@ -2329,6 +2459,12 @@ export function createWechatMpService({
: null, : 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 workingDir = publishLayout?.publishDir ?? (await userAuth.resolveWorkingDir(user.userId));
const linkExistsForRequest = resolveLinkExistsForWorkingDir(workingDir, linkExists); const linkExistsForRequest = resolveLinkExistsForWorkingDir(workingDir, linkExists);
const { const {
@@ -2404,6 +2540,16 @@ export function createWechatMpService({
} else if (htmlGenerationNeedsRetry || suspiciousPublishClaim) { } else if (htmlGenerationNeedsRetry || suspiciousPublishClaim) {
throw new Error(buildHtmlPublishFailureText()); 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({ const pageDataOutcome = await enforcePageDataCollectDelivery({
reply, reply,
intent, intent,
@@ -2430,11 +2576,23 @@ export function createWechatMpService({
const tokenState = await resolveWechatBillingTokenState(sessionId, reply.tokenState); const tokenState = await resolveWechatBillingTokenState(sessionId, reply.tokenState);
await userAuth.billSessionUsage(user.userId, sessionId, tokenState, retryId); await userAuth.billSessionUsage(user.userId, sessionId, tokenState, retryId);
} }
const finalizedReply = await maybeAttachPublishedHtmlLink(reply, { let finalizedReply = await maybeAttachPublishedHtmlLink(reply, {
workingDir, workingDir,
publicBaseUrl: config.publicBaseUrl, publicBaseUrl: config.publicBaseUrl,
artifacts: publishArtifacts, 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); scheduleWechatSessionSnapshotRefresh(sessionId, user.userId);
await sendCustomerServiceText(inbound.fromUserName, await guardScheduleReply(finalizedReply), user, { await sendCustomerServiceText(inbound.fromUserName, await guardScheduleReply(finalizedReply), user, {
verifiedHtmlUrls: publishArtifacts.map((artifact) => artifact.url), verifiedHtmlUrls: publishArtifacts.map((artifact) => artifact.url),
+316 -2
View File
@@ -3,6 +3,7 @@ import crypto from 'node:crypto';
import fs from 'node:fs'; import fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import test from 'node:test'; import test from 'node:test';
import sharp from 'sharp';
import { import {
buildWechatAgentPrompt, buildWechatAgentPrompt,
buildWechatTextReply, buildWechatTextReply,
@@ -62,8 +63,9 @@ function inboundXml({
].join(''); ].join('');
} }
function previewReadyPageHtml({ title = 'Page', subtitle = '测试页面' } = {}) { function previewReadyPageHtml({ title = 'Page', subtitle = '测试页面', cover = '' } = {}) {
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>`; 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 = {}) { function jsonEscapedPreviewReadyPageHtml(options = {}) {
@@ -95,6 +97,7 @@ function createBoundWechatService({
unboundTextPrefix: '请先绑定', unboundTextPrefix: '请先绑定',
progressDelayMs: 0, progressDelayMs: 0,
maxImageBytes: 1024 * 1024, maxImageBytes: 1024 * 1024,
requireFreshPageThumbnail: false,
...config, ...config,
}, },
userAuth: { userAuth: {
@@ -859,6 +862,8 @@ test('loadWechatMpConfig requires full config and enable flag', () => {
}); });
assert.equal(config.enabled, true); assert.equal(config.enabled, true);
assert.equal(config.bindPath, '/auth/wechat/authorize?intent=login'); 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', () => { 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.some((item) => item === 'register:session-new'), true);
assert.equal(calls.includes('upserted'), true); assert.equal(calls.includes('upserted'), true);
}); });
test('wechat mp page delivery requires and verifies a current-run fresh thumbnail', async () => {
const token = 'token';
const timestamp = '1710000000';
const nonce = 'nonce';
const workspaceRoot = fs.mkdtempSync('/tmp/wechat-mp-fresh-thumbnail-');
const htmlPath = path.join(workspaceRoot, 'public', 'fresh.html');
const wechatPayloads = [];
const generated = {
ok: true,
jobId: 'job-fresh-page',
source: { mimeType: 'image/webp', width: 1280, height: 720 },
asset: {
id: 'asset-fresh-page',
htmlSrc: 'images/fresh-page.webp',
publicUrl: 'https://example.com/MindSpace/user-1/public/images/fresh-page.webp',
workspaceRelativePath: 'public/images/fresh-page.webp',
},
};
const pageHtml = previewReadyPageHtml({
title: 'Fresh',
subtitle: '本轮新缩略图',
cover: generated.asset.htmlSrc,
});
const pageImage = await sharp({
create: { width: 64, height: 64, channels: 3, background: '#3366cc' },
}).webp().toBuffer();
const eventFrame = (event) => `data: ${JSON.stringify(event)}\n\n`;
const service = createBoundWechatService({
token,
config: { requireFreshPageThumbnail: true },
userAuth: {
async resolveWorkingDir() {
return workspaceRoot;
},
async getUserPublishLayout() {
return {
publishDir: workspaceRoot,
displayName: 'John',
username: 'john',
slug: 'john',
constraints: null,
};
},
},
sessionApiFetch: async (sessionId, pathname, init = {}) => {
assert.equal(sessionId, 'session-1');
if (pathname === '/sessions/session-1/events') {
return new Response(
[
eventFrame({
type: 'Message',
request_id: 'req-fresh-page',
message: {
id: 'assistant-tools',
role: 'assistant',
metadata: { userVisible: true },
content: [
{
id: 'call-page-skill',
type: 'toolRequest',
toolCall: {
value: { name: 'load_skill', arguments: { name: 'static-page-publish' } },
},
},
{
id: 'call-generate',
type: 'toolRequest',
toolCall: {
value: {
name: 'sandbox-fs__generate_image',
arguments: { purpose: 'hero' },
},
},
},
{
id: 'call-generate',
type: 'toolResponse',
toolResult: {
status: 'success',
value: { content: [{ type: 'text', text: JSON.stringify(generated) }] },
},
},
{
id: 'call-write-page',
type: 'toolRequest',
toolCall: {
value: {
name: 'sandbox-fs__write_file',
arguments: { path: 'public/fresh.html', content: pageHtml },
},
},
},
],
},
}),
eventFrame({
type: 'Message',
request_id: 'req-fresh-page',
message: {
id: 'assistant-final',
role: 'assistant',
metadata: { userVisible: true },
content: [{
type: 'text',
text: '页面已完成:https://example.com/MindSpace/user-1/public/fresh.html',
}],
},
}),
eventFrame({
type: 'Finish',
request_id: 'req-fresh-page',
token_state: { inputTokens: 1, outputTokens: 2 },
}),
].join(''),
{ status: 200, headers: { 'Content-Type': 'text/event-stream' } },
);
}
if (pathname === '/sessions/session-1/reply') {
const body = JSON.parse(init.body);
assert.equal(body.user_message.metadata.memindRun.channel, 'wechat_mp');
assert.equal(body.user_message.metadata.memindRun.pageThumbnailMode, 'required_fresh');
assert.equal(body.user_message.metadata.memindRun.inlineImageMode, 'auto');
assert.match(body.user_message.content[0].text, /服务号页面新缩略图硬要求/);
assert.match(body.user_message.content[0].text, /本轮新生成资产/);
fs.mkdirSync(path.dirname(htmlPath), { recursive: true });
fs.writeFileSync(
htmlPath,
pageHtml,
'utf8',
);
fs.mkdirSync(path.join(workspaceRoot, 'public', 'images'), { recursive: true });
fs.writeFileSync(path.join(workspaceRoot, 'public', generated.asset.htmlSrc), pageImage);
return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } });
}
if (pathname === '/agent/harness_remember' || pathname === '/agent/harness_bootstrap') {
return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } });
}
throw new Error(`unexpected session path: ${pathname}`);
},
wechatFetch: async (url, init = {}) => {
if (String(url).includes('/cgi-bin/stable_token')) {
return new Response(JSON.stringify({ access_token: 'access-1', expires_in: 7200 }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
if (String(url).includes('/cgi-bin/message/custom/send')) {
wechatPayloads.push(JSON.parse(init.body));
return new Response(JSON.stringify({ errcode: 0, errmsg: 'ok' }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
throw new Error(`unexpected wechat url: ${url}`);
},
});
const originalRandomUuid = crypto.randomUUID;
crypto.randomUUID = () => 'req-fresh-page';
try {
const result = await service.handleInboundMessage(
inboundXml({ content: '请生成一个旅行 html 页面,文件名 public/fresh.html' }),
{ timestamp, nonce, signature: signatureFor(token, timestamp, nonce) },
);
assert.equal(result.status, 200);
await result.task;
assert.equal(fs.existsSync(path.join(workspaceRoot, 'public', 'fresh.thumbnail.svg')), true);
} finally {
crypto.randomUUID = originalRandomUuid;
fs.rmSync(workspaceRoot, { recursive: true, force: true });
}
assert.equal(wechatPayloads.length, 1);
assert.equal(wechatPayloads[0].msgtype, 'text');
assert.match(wechatPayloads[0].text.content, /fresh\.html/);
});
test('wechat mp standalone image intent sends a native image message and text', async () => {
const token = 'token';
const timestamp = '1710000000';
const nonce = 'nonce';
const sourceImage = await sharp({
create: { width: 48, height: 48, channels: 3, background: '#cc8844' },
}).webp().toBuffer();
const wechatPayloads = [];
const generated = {
ok: true,
jobId: 'job-chat-image',
source: { mimeType: 'image/webp', width: 1024, height: 1024 },
asset: {
id: 'asset-chat-image',
htmlSrc: 'images/chat-image.webp',
publicUrl: 'https://example.com/MindSpace/user-1/public/images/chat-image.webp',
workspaceRelativePath: 'public/images/chat-image.webp',
},
};
const eventFrame = (event) => `data: ${JSON.stringify(event)}\n\n`;
const service = createBoundWechatService({
token,
sessionApiFetch: async (sessionId, pathname, init = {}) => {
assert.equal(sessionId, 'session-1');
if (pathname === '/sessions/session-1/events') {
return new Response(
[
eventFrame({
type: 'Message',
request_id: 'req-chat-image',
message: {
id: 'assistant-tools',
role: 'assistant',
metadata: { userVisible: true },
content: [
{
id: 'call-image',
type: 'toolRequest',
toolCall: {
value: {
name: 'sandbox-fs__generate_image',
arguments: { purpose: 'inline_image' },
},
},
},
{
id: 'call-image',
type: 'toolResponse',
toolResult: {
status: 'success',
value: { content: [{ type: 'text', text: JSON.stringify(generated) }] },
},
},
],
},
}),
eventFrame({
type: 'Message',
request_id: 'req-chat-image',
message: {
id: 'assistant-final',
role: 'assistant',
metadata: { userVisible: true },
content: [{ type: 'text', text: '新图片已经生成。' }],
},
}),
eventFrame({
type: 'Finish',
request_id: 'req-chat-image',
token_state: { inputTokens: 1, outputTokens: 2 },
}),
].join(''),
{ status: 200, headers: { 'Content-Type': 'text/event-stream' } },
);
}
if (pathname === '/sessions/session-1/reply') {
const body = JSON.parse(init.body);
assert.equal(body.user_message.metadata.memindRun.imageGenerationMode, 'required');
assert.equal(body.user_message.metadata.memindRun.pageThumbnailMode, 'auto');
assert.match(body.user_message.content[0].text, /purpose=`inline_image`/);
return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } });
}
if (pathname === '/agent/harness_remember' || pathname === '/agent/harness_bootstrap') {
return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } });
}
throw new Error(`unexpected session path: ${pathname}`);
},
wechatFetch: async (url, init = {}) => {
const target = String(url);
if (target.includes('/cgi-bin/stable_token')) {
return new Response(JSON.stringify({ access_token: 'access-1', expires_in: 7200 }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
if (target === generated.asset.publicUrl) {
return new Response(sourceImage, { status: 200, headers: { 'Content-Type': 'image/webp' } });
}
if (target.includes('/cgi-bin/media/upload')) {
assert.ok(init.body instanceof FormData);
return new Response(JSON.stringify({ type: 'image', media_id: 'wx-generated-1' }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
if (target.includes('/cgi-bin/message/custom/send')) {
wechatPayloads.push(JSON.parse(init.body));
return new Response(JSON.stringify({ errcode: 0, errmsg: 'ok' }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
throw new Error(`unexpected wechat url: ${url}`);
},
});
const originalRandomUuid = crypto.randomUUID;
crypto.randomUUID = () => 'req-chat-image';
try {
const result = await service.handleInboundMessage(
inboundXml({ content: '帮我生成一张雨夜橘猫图片' }),
{ timestamp, nonce, signature: signatureFor(token, timestamp, nonce) },
);
assert.equal(result.status, 200);
await result.task;
} finally {
crypto.randomUUID = originalRandomUuid;
}
assert.deepEqual(wechatPayloads.map((payload) => payload.msgtype), ['image', 'text']);
assert.equal(wechatPayloads[0].image.media_id, 'wx-generated-1');
assert.match(wechatPayloads[1].text.content, /新图片已经生成/);
});
+94
View File
@@ -0,0 +1,94 @@
import {
IMAGE_GENERATION_MODE,
resolveImageGenerationDecision,
} from '../chat-intent-router.mjs';
export const WECHAT_PAGE_THUMBNAIL_MODE = {
AUTO: 'auto',
REQUIRED_FRESH: 'required_fresh',
};
const INLINE_IMAGE_REQUIRED_PATTERNS = [
/(?:正文|内容|段落|章节).{0,12}(?:配图|插图|图片)/u,
/(?:每段|每章|文中|文章中).{0,12}(?:配图|插图|图片)/u,
/(?:多张|若干张|几张).{0,12}(?:配图|插图|图片)/u,
];
export function resolveWechatImageGenerationPolicy({
text,
isPageGenerate = false,
requireFreshPageThumbnail = false,
} = {}) {
const normalizedText = String(text ?? '').trim();
const requested = resolveImageGenerationDecision({ text: normalizedText });
const pageThumbnailMode = isPageGenerate && requireFreshPageThumbnail
? WECHAT_PAGE_THUMBNAIL_MODE.REQUIRED_FRESH
: WECHAT_PAGE_THUMBNAIL_MODE.AUTO;
const inlineImageMode = requested.mode === IMAGE_GENERATION_MODE.DISABLED
? IMAGE_GENERATION_MODE.DISABLED
: INLINE_IMAGE_REQUIRED_PATTERNS.some((pattern) => pattern.test(normalizedText))
? IMAGE_GENERATION_MODE.REQUIRED
: IMAGE_GENERATION_MODE.AUTO;
const standaloneImageMode = isPageGenerate
? IMAGE_GENERATION_MODE.AUTO
: requested.mode;
return {
channel: 'wechat_mp',
imageGenerationMode: pageThumbnailMode === WECHAT_PAGE_THUMBNAIL_MODE.REQUIRED_FRESH
? IMAGE_GENERATION_MODE.REQUIRED
: standaloneImageMode,
pageThumbnailMode,
inlineImageMode,
standaloneImageMode,
requested,
};
}
export function buildWechatImageRunMetadata(policy) {
if (!policy) return { channel: 'wechat_mp' };
return {
channel: 'wechat_mp',
imageGenerationMode: policy.imageGenerationMode,
pageThumbnailMode: policy.pageThumbnailMode,
inlineImageMode: policy.inlineImageMode,
};
}
export function buildWechatStandaloneImageInstruction(policy, { sourceMessageId = '' } = {}) {
if (!policy || policy.standaloneImageMode === IMAGE_GENERATION_MODE.AUTO) return '';
if (policy.standaloneImageMode === IMAGE_GENERATION_MODE.DISABLED) {
return '【图片策略】用户明确不要生成图片。本轮禁止调用 generate_image,只返回文字。';
}
const idempotencyKey = sourceMessageId
? `wechat-${String(sourceMessageId).replace(/[^a-zA-Z0-9._-]+/g, '-').slice(0, 80)}-image`
: '';
return [
'【微信服务号生图任务】用户明确要求生成一张新图片。',
'必须先调用 `load_skill` → `image-generation`,再调用一次 `sandbox-fs__generate_image`purpose=`inline_image`。',
'必须使用本轮新返回的 PNG/JPEG/WebP 资产;禁止复用历史图片、SVG、CSS 绘图、占位图或网页图片。',
idempotencyKey ? `调用 generate_image 时传入 idempotency_key=${idempotencyKey}` : '',
'成功后简短说明结果,不要输出内部工具步骤;失败时如实报告,禁止声称已经生成。',
].filter(Boolean).join('\n');
}
export function buildWechatPageImageInstruction(policy, { sourceMessageId = '' } = {}) {
if (policy?.pageThumbnailMode !== WECHAT_PAGE_THUMBNAIL_MODE.REQUIRED_FRESH) return '';
const keyPrefix = sourceMessageId
? `wechat-${String(sourceMessageId).replace(/[^a-zA-Z0-9._-]+/g, '-').slice(0, 72)}-page`
: 'wechat-page';
const inlineInstruction = policy.inlineImageMode === IMAGE_GENERATION_MODE.DISABLED
? '用户明确不要正文图片:页面正文和 Hero 不展示图片,但缩略图源图仍必须生成并只写入 mindspace-cover.cover。'
: policy.inlineImageMode === IMAGE_GENERATION_MODE.REQUIRED
? '用户明确要求正文配图:缩略图源图之外,只有正文确实需要不同画面时才可按需调用 inline_image。'
: '正文独立插图按意图判断;没有明确必要时不要额外调用 inline_image。';
return [
'【服务号页面新缩略图硬要求】每个本轮交付给用户的正式内容页面都必须使用本轮新生成、与主题一致的位图作为缩略图源图。',
'写 HTML 前先调用 `load_skill` → `image-generation`,再为每个正式内容页面各调用一次 `sandbox-fs__generate_image`,优先 purpose=`hero`。',
`幂等键使用 ${keyPrefix}-<页面序号>-thumbnail;不同正式页面不得共用同一张图。`,
'生成成功后,把返回的 asset.htmlSrc 原样写入对应页面的 mindspace-cover.cover。页面需要主视觉时复用同一张图作为 Hero,禁止再调用 card_cover/feed_cover。',
'管理后台、密码查看、重定向、下载包装和错误辅助页不属于正式内容页面,不要求单独生图;这类页面必须在 head 写 `<meta name="mindspace-page-role" content="auxiliary">` 供交付守卫识别。',
inlineInstruction,
'当前轮没有新的成功 generate_image、没有新 jobId,或正式页面未引用本轮资产时,禁止交付页面链接或声称完成。',
].join('\n');
}
+49
View File
@@ -0,0 +1,49 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
buildWechatPageImageInstruction,
buildWechatStandaloneImageInstruction,
resolveWechatImageGenerationPolicy,
WECHAT_PAGE_THUMBNAIL_MODE,
} from './image-generation-policy.mjs';
test('service-account page always requires a fresh thumbnail without forcing body images', () => {
const policy = resolveWechatImageGenerationPolicy({
text: '请做一个苏州旅行页面',
isPageGenerate: true,
requireFreshPageThumbnail: true,
});
assert.equal(policy.pageThumbnailMode, WECHAT_PAGE_THUMBNAIL_MODE.REQUIRED_FRESH);
assert.equal(policy.imageGenerationMode, 'required');
assert.equal(policy.inlineImageMode, 'auto');
assert.equal(policy.standaloneImageMode, 'auto');
assert.match(buildWechatPageImageInstruction(policy), /每个本轮交付给用户的正式内容页面/);
});
test('no-image page request disables body images but keeps required fresh thumbnail', () => {
const policy = resolveWechatImageGenerationPolicy({
text: '生成一个活动页面,只要文字,不要图片',
isPageGenerate: true,
requireFreshPageThumbnail: true,
});
assert.equal(policy.pageThumbnailMode, 'required_fresh');
assert.equal(policy.inlineImageMode, 'disabled');
assert.match(buildWechatPageImageInstruction(policy), /正文和 Hero 不展示图片/);
});
test('explicit body illustration request is independent from the page thumbnail', () => {
const policy = resolveWechatImageGenerationPolicy({
text: '生成一个科普页面,每个章节都要正文插图',
isPageGenerate: true,
requireFreshPageThumbnail: true,
});
assert.equal(policy.inlineImageMode, 'required');
});
test('standalone image intent gets an inline_image tool contract', () => {
const policy = resolveWechatImageGenerationPolicy({ text: '帮我生成一张雨夜橘猫图片' });
assert.equal(policy.standaloneImageMode, 'required');
const instruction = buildWechatStandaloneImageInstruction(policy, { sourceMessageId: 'msg-1' });
assert.match(instruction, /purpose=`inline_image`/);
assert.match(instruction, /idempotency_key=wechat-msg-1-image/);
});
+7 -1
View File
@@ -2,8 +2,9 @@ import { buildAutoChatSkillPrefix, isPageDataIntent } from '../../chat-skills.mj
import { shouldUseScheduleAssistant } from '../../schedule-intent.mjs'; import { shouldUseScheduleAssistant } from '../../schedule-intent.mjs';
import { buildCurrentTimeAgentPrefix } from '../../user-memory-profile.mjs'; import { buildCurrentTimeAgentPrefix } from '../../user-memory-profile.mjs';
import { isPageGenerateText, wantsDocxDownload } from '../intent/patterns.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 msgType = String(intent?.msgType ?? 'text');
const agentText = intent?.agentText ?? intent?.content ?? ''; const agentText = intent?.agentText ?? intent?.content ?? '';
const autoSkillPrefix = const autoSkillPrefix =
@@ -54,6 +55,9 @@ export function buildWechatAgentPrompt(intent, { grantedSkills = [] } = {}) {
'', '',
].filter(Boolean).join('\n') ].filter(Boolean).join('\n')
: ''; : '';
const imageGenerationHint = buildWechatStandaloneImageInstruction(imagePolicy, {
sourceMessageId: intent?.msgId,
});
if (msgType === 'voice') { if (msgType === 'voice') {
return [ return [
@@ -61,6 +65,7 @@ export function buildWechatAgentPrompt(intent, { grantedSkills = [] } = {}) {
pageDataCollectHint, pageDataCollectHint,
currentTimeHint, currentTimeHint,
scheduleAssistantHint, scheduleAssistantHint,
imageGenerationHint,
'【微信服务号语音消息】用户通过语音输入,以下是微信识别结果。', '【微信服务号语音消息】用户通过语音输入,以下是微信识别结果。',
'', '',
`用户语音识别文本:${autoSkillPrefix}${String(agentText).trim()}`, `用户语音识别文本:${autoSkillPrefix}${String(agentText).trim()}`,
@@ -132,6 +137,7 @@ export function buildWechatAgentPrompt(intent, { grantedSkills = [] } = {}) {
if (pageDataCollectHint) lines.push(pageDataCollectHint); if (pageDataCollectHint) lines.push(pageDataCollectHint);
if (pagePublishHint) lines.push(pagePublishHint); if (pagePublishHint) lines.push(pagePublishHint);
if (scheduleAssistantHint) lines.push(scheduleAssistantHint); if (scheduleAssistantHint) lines.push(scheduleAssistantHint);
if (imageGenerationHint) lines.push(imageGenerationHint);
lines.push( lines.push(
'【微信服务号新消息】请只回答下面这条用户消息,不要主动延续无关的历史话题。', '【微信服务号新消息】请只回答下面这条用户消息,不要主动延续无关的历史话题。',
'若用户只是在测试连通性,请一句话确认收到即可,不要展开旧任务。', '若用户只是在测试连通性,请一句话确认收到即可,不要展开旧任务。',
+26 -6
View File
@@ -1,9 +1,10 @@
import { buildCurrentTimeAgentPrefix } from '../../user-memory-profile.mjs'; 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. * 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 topic = String(intent?.agentText ?? intent?.displayText ?? '').trim();
const scheduleTimezone = process.env.H5_DEFAULT_TIMEZONE || 'Asia/Shanghai'; const scheduleTimezone = process.env.H5_DEFAULT_TIMEZONE || 'Asia/Shanghai';
const docxBlock = wantsDocx const docxBlock = wantsDocx
@@ -13,24 +14,33 @@ export function buildPageGenerateAgentPrompt(intent, { wantsDocx = false } = {})
'', '',
].join('\n') ].join('\n')
: ''; : '';
const imageBlock = buildWechatPageImageInstruction(imagePolicy, {
sourceMessageId: intent?.msgId,
});
const coverExample = imageBlock
? '<本轮 generate_image 返回的 asset.htmlSrc>'
: 'assets/hero.jpg';
return [ return [
'【微信服务号 · 页面生成任务】', '【微信服务号 · 页面生成任务】',
'这是服务号专用页面生成,不是普通聊天。必须按步骤完成,未完成前禁止告诉用户“已生成/已发布”。', '这是服务号专用页面生成,不是普通聊天。必须按步骤完成,未完成前禁止告诉用户“已生成/已发布”。',
'', '',
docxBlock, 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`。', '2. 阅读技能说明后,用 sandbox-fs 的 `write_file` 或 `edit_file` 写入 `public/*.html`。',
'3. 禁止 shell/cat/heredoc/cp 写 HTML(不会出现在公网 MindSpace)。', '3. 禁止 shell/cat/heredoc/cp 写 HTML(不会出现在公网 MindSpace)。',
'4. 写完后确认目标文件已落盘,且内容是用户要的完整页面(不是占位 stub)。', '4. 写完后确认目标文件已落盘,且内容是用户要的完整页面(不是占位 stub)。',
'5. 在 `<head>` 写入微信分享卡片所需元数据(必须):', '5. 在 `<head>` 写入微信分享卡片所需元数据(必须):',
' - `<meta name="description" content="一句话摘要">`', ' - `<meta name="description" content="一句话摘要">`',
' - `<meta name="mindspace-cover" content=\'{"tag":"主题","accent":"#主色","accent2":"#辅色","subtitle":"卖点","cover":"assets/hero.jpg"}\'>`', ` - \`<meta name="mindspace-cover" content='{"tag":"主题","accent":"#主色","accent2":"#辅色","subtitle":"卖点","cover":"${coverExample}"}'>\``,
' 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 <uid>);有 error 则补元数据后再回复链接。', '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 设太低导致看不见)。', '7. 页脚加 `<p data-mindspace-page-tag="platform-brand">TKMind · 智趣</p>`(必须;禁止省略、不要用邮箱/tkmind.ai,也不要把该行 opacity 设太低导致看不见)。',
'8. 回复里只给一个正式域名链接;没有落盘或缺少上述元数据就不要发链接。', '8. 回复里只给一个正式域名的唯一正确链接;没有落盘或缺少上述元数据就不要发链接。',
'', '',
buildCurrentTimeAgentPrefix({ timezone: scheduleTimezone }), buildCurrentTimeAgentPrefix({ timezone: scheduleTimezone }),
`用户需求:${topic}`, `用户需求:${topic}`,
@@ -39,7 +49,17 @@ export function buildPageGenerateAgentPrompt(intent, { wantsDocx = false } = {})
.join('\n'); .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) { if (missingPlatformBrand) {
return [ return [
'这次页面已落盘,但缺少页脚平台品牌标记(data-mindspace-page-tag="platform-brand"),所以我先不发链接。', '这次页面已落盘,但缺少页脚平台品牌标记(data-mindspace-page-tag="platform-brand"),所以我先不发链接。',
+152
View File
@@ -0,0 +1,152 @@
import fs from 'node:fs';
import path from 'node:path';
const RASTER_MIME_PATTERN = /^image\/(?:png|jpeg|webp)$/i;
function parseToolResultJson(toolResult) {
const content = toolResult?.value?.content;
if (!Array.isArray(content)) return null;
for (const item of content) {
if (item?.type !== 'text' || !String(item.text ?? '').trim()) continue;
try {
return JSON.parse(String(item.text));
} catch {
// A non-JSON tool message cannot prove a fresh generated image.
}
}
return null;
}
function normalizeGeneratedImage(result) {
const mimeType = String(result?.source?.mimeType ?? result?.asset?.mimeType ?? '').toLowerCase();
if (!result?.ok || !String(result?.jobId ?? '').trim() || !RASTER_MIME_PATTERN.test(mimeType)) {
return null;
}
const asset = result.asset ?? {};
const htmlSrc = String(asset.htmlSrc ?? '').trim();
const publicUrl = String(asset.publicUrl ?? '').trim();
const workspaceRelativePath = String(asset.workspaceRelativePath ?? '').trim();
if (!htmlSrc && !publicUrl && !workspaceRelativePath) return null;
return {
jobId: String(result.jobId),
mimeType,
assetId: String(asset.id ?? '').trim() || null,
htmlSrc: htmlSrc || null,
publicUrl: publicUrl || null,
workspaceRelativePath: workspaceRelativePath || null,
};
}
export function collectWechatGeneratedImages(messages = []) {
const requestNames = new Map();
const images = [];
for (const message of messages) {
for (const item of message?.content ?? []) {
if (item?.type === 'toolRequest') {
const name = String(item?.toolCall?.value?.name ?? '').trim();
if (item.id && name) requestNames.set(String(item.id), name);
continue;
}
if (item?.type !== 'toolResponse') continue;
const name = requestNames.get(String(item.id ?? '')) ?? String(item?.toolResult?.name ?? '');
if (!name.endsWith('generate_image')) continue;
const toolResult = item.toolResult ?? {};
if (toolResult.status !== 'success' || toolResult?.value?.isError === true) continue;
const generated = normalizeGeneratedImage(parseToolResultJson(toolResult));
if (generated && !images.some((image) => image.jobId === generated.jobId)) images.push(generated);
}
}
return images;
}
function decodeHtmlAttribute(value) {
return String(value ?? '')
.replaceAll('&quot;', '"')
.replaceAll('&#34;', '"')
.replaceAll('&#39;', "'")
.replaceAll('&amp;', '&');
}
export function extractMindspaceCoverPath(html) {
const tag = String(html ?? '').match(/<meta[^>]*name=["']mindspace-cover["'][^>]*>/i)?.[0] ?? '';
if (!tag) return '';
const match = tag.match(/content=(['"])([\s\S]*?)\1/i);
if (!match?.[2]) return '';
try {
const meta = JSON.parse(decodeHtmlAttribute(match[2]));
return String(meta.cover ?? meta.image ?? '').trim();
} catch {
return '';
}
}
function normalizeRelativeAssetPath(value) {
const clean = String(value ?? '')
.trim()
.split(/[?#]/, 1)[0]
.replace(/\\/g, '/')
.replace(/^\.\//, '')
.replace(/^\/+/, '');
return clean.startsWith('public/') ? clean.slice('public/'.length) : clean;
}
function imageMatchesCover(image, cover, artifact) {
if (!cover) return false;
if (/^https?:\/\//i.test(cover)) return image.publicUrl === cover;
const coverPath = normalizeRelativeAssetPath(cover);
const artifactRelativePath = normalizeRelativeAssetPath(artifact?.relativePath ?? '');
const artifactDir = path.posix.dirname(artifactRelativePath);
const resolvedCover = normalizeRelativeAssetPath(path.posix.join(artifactDir === '.' ? '' : artifactDir, coverPath));
const candidates = [image.htmlSrc, image.workspaceRelativePath]
.map(normalizeRelativeAssetPath)
.filter(Boolean);
return candidates.includes(coverPath) || candidates.includes(resolvedCover);
}
const AUXILIARY_PAGE_PATH_PATTERN = /(?:^|[-_.\/])(?:admin|manage|management|backend|password|redirect|download|error)(?:[-_.\/]|$)/i;
export function isWechatAuxiliaryPageArtifact(artifact) {
const relativePath = String(artifact?.relativePath ?? '').trim().replace(/\\/g, '/');
if (AUXILIARY_PAGE_PATH_PATTERN.test(relativePath)) return true;
const localPath = String(artifact?.localPath ?? '').trim();
if (!localPath || !fs.existsSync(localPath)) return false;
const html = fs.readFileSync(localPath, 'utf8');
return /<meta[^>]*name=["']mindspace-page-role["'][^>]*content=["'](?:admin|auxiliary)["']/i.test(html)
|| /<[^>]+data-mindspace-page-role=["'](?:admin|auxiliary)["']/i.test(html);
}
export function verifyFreshWechatPageThumbnails(artifacts = [], images = []) {
if (!Array.isArray(artifacts) || artifacts.length === 0) {
return { ok: false, reason: 'missing_page_artifact', matches: [] };
}
const eligibleArtifacts = artifacts.filter((artifact) => !isWechatAuxiliaryPageArtifact(artifact));
const skippedArtifacts = artifacts.filter((artifact) => isWechatAuxiliaryPageArtifact(artifact));
if (eligibleArtifacts.length === 0) {
return { ok: true, reason: null, matches: [], skippedArtifacts };
}
if (!Array.isArray(images) || images.length === 0) {
return { ok: false, reason: 'fresh_image_not_generated', matches: [], skippedArtifacts };
}
const usedJobs = new Set();
const matches = [];
for (const artifact of eligibleArtifacts) {
const localPath = String(artifact?.localPath ?? '').trim();
if (!localPath || !fs.existsSync(localPath)) {
return { ok: false, reason: 'missing_page_artifact', artifact, matches, skippedArtifacts };
}
const cover = extractMindspaceCoverPath(fs.readFileSync(localPath, 'utf8'));
const image = images.find((candidate) => !usedJobs.has(candidate.jobId) && imageMatchesCover(candidate, cover, artifact));
if (!image) {
return {
ok: false,
reason: cover ? 'cover_not_from_current_run' : 'missing_generated_cover',
artifact,
matches,
skippedArtifacts,
};
}
usedJobs.add(image.jobId);
matches.push({ artifact, image, cover });
}
return { ok: true, reason: null, matches, skippedArtifacts };
}
+117
View File
@@ -0,0 +1,117 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
import {
collectWechatGeneratedImages,
extractMindspaceCoverPath,
verifyFreshWechatPageThumbnails,
} from './generated-thumbnail.mjs';
function generatedImageMessages({ jobId = 'job-1', htmlSrc = 'images/fresh.webp' } = {}) {
const result = {
ok: true,
jobId,
source: { mimeType: 'image/webp' },
asset: {
id: `asset-${jobId}`,
htmlSrc,
publicUrl: `https://example.com/MindSpace/user/public/${htmlSrc}`,
workspaceRelativePath: `public/${htmlSrc}`,
},
};
return [{
role: 'assistant',
content: [
{
id: `call-${jobId}`,
type: 'toolRequest',
toolCall: { value: { name: 'sandbox-fs__generate_image', arguments: { purpose: 'hero' } } },
},
{
id: `call-${jobId}`,
type: 'toolResponse',
toolResult: {
status: 'success',
value: { content: [{ type: 'text', text: JSON.stringify(result) }] },
},
},
],
}];
}
test('fresh page thumbnail must reference a current-run generated image', () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'wechat-fresh-cover-'));
try {
const htmlPath = path.join(root, 'page.html');
fs.writeFileSync(
htmlPath,
'<meta name="mindspace-cover" content=\'{"cover":"images/fresh.webp"}\'>',
'utf8',
);
const images = collectWechatGeneratedImages(generatedImageMessages());
assert.equal(images.length, 1);
assert.equal(extractMindspaceCoverPath(fs.readFileSync(htmlPath, 'utf8')), 'images/fresh.webp');
const result = verifyFreshWechatPageThumbnails(
[{ localPath: htmlPath, relativePath: 'public/page.html' }],
images,
);
assert.equal(result.ok, true);
assert.equal(result.matches[0].image.jobId, 'job-1');
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
});
test('old cover path and sharing one generated image across two pages fail closed', () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'wechat-stale-cover-'));
try {
const first = path.join(root, 'one.html');
const second = path.join(root, 'two.html');
fs.writeFileSync(first, '<meta name="mindspace-cover" content=\'{"cover":"images/fresh.webp"}\'>');
fs.writeFileSync(second, '<meta name="mindspace-cover" content=\'{"cover":"images/fresh.webp"}\'>');
const images = collectWechatGeneratedImages(generatedImageMessages());
const reused = verifyFreshWechatPageThumbnails(
[
{ localPath: first, relativePath: 'public/one.html' },
{ localPath: second, relativePath: 'public/two.html' },
],
images,
);
assert.equal(reused.ok, false);
assert.equal(reused.reason, 'cover_not_from_current_run');
fs.writeFileSync(first, '<meta name="mindspace-cover" content=\'{"cover":"images/old.webp"}\'>');
const stale = verifyFreshWechatPageThumbnails(
[{ localPath: first, relativePath: 'public/one.html' }],
images,
);
assert.equal(stale.ok, false);
assert.equal(stale.reason, 'cover_not_from_current_run');
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
});
test('auxiliary admin pages do not require a separate generated thumbnail', () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'wechat-aux-cover-'));
try {
const front = path.join(root, 'survey.html');
const admin = path.join(root, 'survey-admin.html');
fs.writeFileSync(front, '<meta name="mindspace-cover" content=\'{"cover":"images/fresh.webp"}\'>');
fs.writeFileSync(admin, '<meta name="mindspace-page-role" content="auxiliary">');
const result = verifyFreshWechatPageThumbnails(
[
{ localPath: front, relativePath: 'public/survey.html' },
{ localPath: admin, relativePath: 'public/survey-admin.html' },
],
collectWechatGeneratedImages(generatedImageMessages()),
);
assert.equal(result.ok, true);
assert.equal(result.matches.length, 1);
assert.equal(result.skippedArtifacts.length, 1);
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
});