feat(wechat): add image generation delivery policy
Memind CI / Test, build, and release guards (pull_request) Successful in 2m45s
Memind CI / Test, build, and release guards (pull_request) Successful in 2m45s
This commit is contained in:
@@ -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` 服务。正式启用前必须确保:
|
||||
|
||||
+1
-1
@@ -58,7 +58,7 @@
|
||||
"test:scenario": "node scripts/run-scenario-test.mjs",
|
||||
"verify:children-hobby-diet-survey": "node scripts/verify-children-hobby-diet-survey.mjs",
|
||||
"test:scenario:john4-diet": "node scripts/run-scenario-test.mjs --scenario john4-children-hobby-diet-update",
|
||||
"test": "node --test auth.test.mjs asr-proxy.test.mjs billing.test.mjs billing-token-state.test.mjs billing-recharge.test.mjs wechat-pay.test.mjs wechat-oauth.test.mjs wechat-mp.test.mjs schedule-intent.test.mjs schedule-reminder-worker.test.mjs capabilities.test.mjs policies.test.mjs chat-skills.test.mjs chat-intent-router.test.mjs chat-finish-sync.test.mjs chat-agent-run-gate.test.mjs conversation-display.test.mjs user-publish.test.mjs user-memory-profile.test.mjs skills-registry.test.mjs skill-runtime-policy.test.mjs excel-analyst.test.mjs agent-run-gateway.test.mjs agent-run-routes.test.mjs session-broker.test.mjs sse-event-taxonomy.test.mjs goosed-proxy-boundary.test.mjs agent-run-stream.test.mjs mindspace-h5-html-finish-guard.test.mjs admin-routes.test.mjs image-make-admin-config.test.mjs asset-gateway.test.mjs image-make-client.test.mjs mindspace-image-generation.test.mjs mindspace-image-generation-routes.test.mjs mindspace-image-review.test.mjs mindspace-run-public-html-scope.test.mjs direct-chat-service.test.mjs tool-gateway.test.mjs mindspace.test.mjs mindspace-scan.test.mjs mindspace-assets.test.mjs mindspace-local-runtime-services.test.mjs mindspace-local-server-adapter.test.mjs mindspace-public-asset-token.test.mjs mindspace-remote-server-adapter.test.mjs mindspace-server-adapter.test.mjs mindspace-pages.test.mjs mindspace-page-sync-service.test.mjs public-site-bases.test.mjs mindspace-html-download-links.test.mjs mindspace-page-purge.test.mjs mindspace-public-delivery.test.mjs mindspace-public-page-context.test.mjs mindspace-published-page-csp.test.mjs mindspace-published-script-localize.test.mjs agent-run-deliverable-check.test.mjs mindspace-public-route.test.mjs mindspace-publications.test.mjs mindspace-public-links.test.mjs mindspace-chat-save.test.mjs mindspace-chat-docx-package.test.mjs mindspace-public-finish-sync.test.mjs mindspace-chat-context.test.mjs mindspace-canonical-url.test.mjs mindspace-conversation-package.test.mjs mindspace-conversation-package-backfill.test.mjs mindspace-conversation-package-public-html.test.mjs mindspace-conversation-package-verify.test.mjs mindspace-conversation-package-registry.test.mjs mindspace-conversation-package-routes.test.mjs mindspace-conversation-package-store.test.mjs mindspace-conversation-schema.test.mjs mindspace-runtime-config.test.mjs mindspace-config.test.mjs mindspace-analytics.test.mjs mindspace-service.test.mjs mindspace-storage-adapter.test.mjs mindspace-content-scan.test.mjs mindspace-html-localize.test.mjs mindspace-visual-editor.test.mjs mindspace-cleanup.test.mjs mindspace-thumbnails.test.mjs mindspace-workspace-thumbnails.test.mjs mindspace-workspace-sync.test.mjs mindspace-asset-preview.test.mjs mindspace-agent-jobs.test.mjs mindspace-agent-runner.test.mjs mindspace-sandbox-mcp.test.mjs mindspace-userdata-postgres.test.mjs postgres-user-data-space-service.test.mjs user-data-space-service.test.mjs page-data-routes.test.mjs page-access-policy.test.mjs page-access-visitor.test.mjs page-data-public-service.test.mjs page-data-integration.test.mjs page-data-log-store.test.mjs page-data-ops.test.mjs page-data-session-store.test.mjs page-data-browser-client.test.mjs page-data-policy-index.test.mjs message-stream.test.mjs mindspace-service/mindspace-rpc-server.test.mjs plaza-posts.test.mjs plaza-interactions.test.mjs plaza-algorithm.test.mjs plaza-seo.test.mjs plaza-ops.test.mjs user-auth.test.mjs llm-providers.test.mjs admin-guard.test.mjs user-feedback.test.mjs memory-v2.test.mjs memory-v2-admin-config.test.mjs memory-v2-lifecycle.test.mjs memory-v2-adapter-scaffold.test.mjs memory-v2-backend-contract.test.mjs memory-v2-health.test.mjs memory-v2-runtime.test.mjs memory-v2-plugin-backends.test.mjs memory-v2-pgvector.test.mjs memory-v2-pgvector-schema.test.mjs memory-v2-pgvector-backfill.test.mjs memory-v2-pgvector-smoke.test.mjs memory-v2-qdrant.test.mjs memory-v2-weaviate.test.mjs memory-v2-mem0.test.mjs memory-v2-letta.test.mjs memory-v2-external-adapters.test.mjs scripts/embed-memory-v2-local-hash.test.mjs scripts/check-memory-v2-app-canary.test.mjs scripts/check-memory-v2-config-gaps.test.mjs scripts/check-memory-v2-contracts.test.mjs scripts/check-memory-v2-health.test.mjs scripts/check-memory-v2-session-flow.test.mjs scripts/check-memory-v2-stack.test.mjs scripts/setup-memory-v2-pgvector-schema.test.mjs scripts/backfill-memory-v2-pgvector.test.mjs scripts/scaffold-memory-v2-backend.test.mjs scripts/smoke-memory-v2-pgvector.test.mjs scripts/smoke-memory-v2-qdrant.test.mjs scripts/smoke-memory-v2-external.test.mjs scripts/mock-memory-v2-services.test.mjs",
|
||||
"test": "node --test auth.test.mjs asr-proxy.test.mjs billing.test.mjs billing-token-state.test.mjs billing-recharge.test.mjs wechat-pay.test.mjs wechat-oauth.test.mjs wechat-mp.test.mjs wechat-media.test.mjs wechat/image-generation-policy.test.mjs wechat/verify/generated-thumbnail.test.mjs schedule-intent.test.mjs schedule-reminder-worker.test.mjs capabilities.test.mjs policies.test.mjs chat-skills.test.mjs chat-intent-router.test.mjs chat-finish-sync.test.mjs chat-agent-run-gate.test.mjs conversation-display.test.mjs user-publish.test.mjs user-memory-profile.test.mjs skills-registry.test.mjs skill-runtime-policy.test.mjs excel-analyst.test.mjs agent-run-gateway.test.mjs agent-run-routes.test.mjs session-broker.test.mjs sse-event-taxonomy.test.mjs goosed-proxy-boundary.test.mjs agent-run-stream.test.mjs mindspace-h5-html-finish-guard.test.mjs admin-routes.test.mjs image-make-admin-config.test.mjs asset-gateway.test.mjs image-make-client.test.mjs mindspace-image-generation.test.mjs mindspace-image-generation-routes.test.mjs mindspace-image-review.test.mjs mindspace-run-public-html-scope.test.mjs direct-chat-service.test.mjs tool-gateway.test.mjs mindspace.test.mjs mindspace-scan.test.mjs mindspace-assets.test.mjs mindspace-local-runtime-services.test.mjs mindspace-local-server-adapter.test.mjs mindspace-public-asset-token.test.mjs mindspace-remote-server-adapter.test.mjs mindspace-server-adapter.test.mjs mindspace-pages.test.mjs mindspace-page-sync-service.test.mjs public-site-bases.test.mjs mindspace-html-download-links.test.mjs mindspace-page-purge.test.mjs mindspace-public-delivery.test.mjs mindspace-public-page-context.test.mjs mindspace-published-page-csp.test.mjs mindspace-published-script-localize.test.mjs agent-run-deliverable-check.test.mjs mindspace-public-route.test.mjs mindspace-publications.test.mjs mindspace-public-links.test.mjs mindspace-chat-save.test.mjs mindspace-chat-docx-package.test.mjs mindspace-public-finish-sync.test.mjs mindspace-chat-context.test.mjs mindspace-canonical-url.test.mjs mindspace-conversation-package.test.mjs mindspace-conversation-package-backfill.test.mjs mindspace-conversation-package-public-html.test.mjs mindspace-conversation-package-verify.test.mjs mindspace-conversation-package-registry.test.mjs mindspace-conversation-package-routes.test.mjs mindspace-conversation-package-store.test.mjs mindspace-conversation-schema.test.mjs mindspace-runtime-config.test.mjs mindspace-config.test.mjs mindspace-analytics.test.mjs mindspace-service.test.mjs mindspace-storage-adapter.test.mjs mindspace-content-scan.test.mjs mindspace-html-localize.test.mjs mindspace-visual-editor.test.mjs mindspace-cleanup.test.mjs mindspace-thumbnails.test.mjs mindspace-workspace-thumbnails.test.mjs mindspace-workspace-sync.test.mjs mindspace-asset-preview.test.mjs mindspace-agent-jobs.test.mjs mindspace-agent-runner.test.mjs mindspace-sandbox-mcp.test.mjs mindspace-userdata-postgres.test.mjs postgres-user-data-space-service.test.mjs user-data-space-service.test.mjs page-data-routes.test.mjs page-access-policy.test.mjs page-access-visitor.test.mjs page-data-public-service.test.mjs page-data-integration.test.mjs page-data-log-store.test.mjs page-data-ops.test.mjs page-data-session-store.test.mjs page-data-browser-client.test.mjs page-data-policy-index.test.mjs message-stream.test.mjs mindspace-service/mindspace-rpc-server.test.mjs plaza-posts.test.mjs plaza-interactions.test.mjs plaza-algorithm.test.mjs plaza-seo.test.mjs plaza-ops.test.mjs user-auth.test.mjs llm-providers.test.mjs admin-guard.test.mjs user-feedback.test.mjs memory-v2.test.mjs memory-v2-admin-config.test.mjs memory-v2-lifecycle.test.mjs memory-v2-adapter-scaffold.test.mjs memory-v2-backend-contract.test.mjs memory-v2-health.test.mjs memory-v2-runtime.test.mjs memory-v2-plugin-backends.test.mjs memory-v2-pgvector.test.mjs memory-v2-pgvector-schema.test.mjs memory-v2-pgvector-backfill.test.mjs memory-v2-pgvector-smoke.test.mjs memory-v2-qdrant.test.mjs memory-v2-weaviate.test.mjs memory-v2-mem0.test.mjs memory-v2-letta.test.mjs memory-v2-external-adapters.test.mjs scripts/embed-memory-v2-local-hash.test.mjs scripts/check-memory-v2-app-canary.test.mjs scripts/check-memory-v2-config-gaps.test.mjs scripts/check-memory-v2-contracts.test.mjs scripts/check-memory-v2-health.test.mjs scripts/check-memory-v2-session-flow.test.mjs scripts/check-memory-v2-stack.test.mjs scripts/setup-memory-v2-pgvector-schema.test.mjs scripts/backfill-memory-v2-pgvector.test.mjs scripts/scaffold-memory-v2-backend.test.mjs scripts/smoke-memory-v2-pgvector.test.mjs scripts/smoke-memory-v2-qdrant.test.mjs scripts/smoke-memory-v2-external.test.mjs scripts/mock-memory-v2-services.test.mjs",
|
||||
"test:image-review": "node --test mindspace-image-review.test.mjs mindspace-image-generation.test.mjs",
|
||||
"test:mindspace-service": "node --test mindspace-service/mindspace-rpc-server.test.mjs",
|
||||
"verify:chat-finish-sync": "node scripts/verify-chat-finish-sync.mjs",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -81,6 +81,7 @@ export function loadWechatMpConfig(env = process.env) {
|
||||
acceptLocation: env.H5_WECHAT_MP_ACCEPT_LOCATION !== '0',
|
||||
acceptLink: env.H5_WECHAT_MP_ACCEPT_LINK !== '0',
|
||||
mediaAnalysisGrayUsers: parseCsvList(env.H5_WECHAT_MP_MEDIA_GRAY_USERS),
|
||||
requireFreshPageThumbnail: env.H5_WECHAT_MP_FRESH_PAGE_THUMBNAILS !== '0',
|
||||
encodingAesKey: env.H5_WECHAT_MP_ENCODING_AES_KEY?.trim() ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
+167
-9
@@ -13,6 +13,7 @@ import {
|
||||
downloadTemporaryMedia,
|
||||
persistWechatAttachment,
|
||||
persistWechatImage,
|
||||
uploadWechatGeneratedImage,
|
||||
} from './wechat-media.mjs';
|
||||
import { normalizeWechatName, resolveWechatAddressName } from './wechat/user/display-name.mjs';
|
||||
import { buildAckText } from './wechat/ack/ack-provider.mjs';
|
||||
@@ -27,13 +28,23 @@ import { classifyWechatIntent } from './wechat/intent/classifier.mjs';
|
||||
import { isPageGenerateText, isTopicResetText } from './wechat/intent/patterns.mjs';
|
||||
import { isPageDataIntent } from './chat-skills.mjs';
|
||||
import { resolvePageGenerateOutcome } from './wechat/handlers/page-generate.mjs';
|
||||
import {
|
||||
buildWechatImageRunMetadata,
|
||||
resolveWechatImageGenerationPolicy,
|
||||
WECHAT_PAGE_THUMBNAIL_MODE,
|
||||
} from './wechat/image-generation-policy.mjs';
|
||||
import { buildWechatAgentPrompt } from './wechat/prompts/chat-general.mjs';
|
||||
import {
|
||||
buildPageGenerateAgentPrompt,
|
||||
buildPagePublishFailureText,
|
||||
} from './wechat/prompts/page-generate.mjs';
|
||||
import { selectSendableHtmlArtifacts } from './wechat/verify/page-artifact.mjs';
|
||||
import {
|
||||
collectWechatGeneratedImages,
|
||||
verifyFreshWechatPageThumbnails,
|
||||
} from './wechat/verify/generated-thumbnail.mjs';
|
||||
import { resolveBillingTokenState } from './billing-token-state.mjs';
|
||||
import { ensureWorkspaceHtmlThumbnail } from './mindspace-workspace-thumbnails.mjs';
|
||||
import {
|
||||
buildPageDataCollectFailureText,
|
||||
buildPageDataDeliveryArtifactsFromBindResult,
|
||||
@@ -333,6 +344,13 @@ function splitWechatText(text, maxBytes = WECHAT_CUSTOMER_TEXT_MAX_BYTES) {
|
||||
return chunks.length > 0 ? chunks : ['我先收到了,但这次没有生成可发送的文本结果。'];
|
||||
}
|
||||
|
||||
function appendGeneratedImageFallbackLink(text, generatedImage) {
|
||||
const normalized = String(text ?? '').trim();
|
||||
const publicUrl = String(generatedImage?.publicUrl ?? '').trim();
|
||||
if (!publicUrl || normalized.includes(publicUrl)) return normalized;
|
||||
return [normalized, `图片链接:${publicUrl}`].filter(Boolean).join('\n');
|
||||
}
|
||||
|
||||
export { splitWechatText, WECHAT_CUSTOMER_TEXT_MAX_BYTES };
|
||||
|
||||
function flattenSessionTools(extensions = []) {
|
||||
@@ -1079,6 +1097,9 @@ export function sanitizeWechatAgentOutboundText(text) {
|
||||
|
||||
function formatWechatAgentFailureMessage(err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
if (err?.code === 'WECHAT_IMAGE_GENERATION_REQUIRED') {
|
||||
return '这次图片生成没有获得新的有效位图,所以我没有发送旧图或占位图。请稍后重试。';
|
||||
}
|
||||
if (isHtmlPublishFailureMessage(message)) return buildHtmlPublishFailureText();
|
||||
if (isRecoverableWechatAgentSessionError(message) || isWechatAgentApiErrorText(message)) {
|
||||
return '刚才专属会话状态异常,我已切换到新会话。请再发一次你的需求。';
|
||||
@@ -1490,6 +1511,7 @@ export function createWechatMpService({
|
||||
enabled: false,
|
||||
};
|
||||
}
|
||||
const requireFreshPageThumbnail = config.requireFreshPageThumbnail === true;
|
||||
config = {
|
||||
...loadWechatMpConfig({}),
|
||||
...config,
|
||||
@@ -1507,6 +1529,7 @@ export function createWechatMpService({
|
||||
mediaAnalysisGrayUsers: Array.isArray(config.mediaAnalysisGrayUsers)
|
||||
? config.mediaAnalysisGrayUsers
|
||||
: [],
|
||||
requireFreshPageThumbnail,
|
||||
asrTarget: config.asrTarget || DEFAULT_ASR_TARGET,
|
||||
};
|
||||
|
||||
@@ -1771,6 +1794,36 @@ export function createWechatMpService({
|
||||
}
|
||||
};
|
||||
|
||||
const sendCustomerServiceImage = async (openid, generatedImage) => {
|
||||
const publicUrl = String(generatedImage?.publicUrl ?? '').trim();
|
||||
if (!publicUrl) throw new Error('生成图片缺少可发送的公网地址');
|
||||
const accessToken = await getStableAccessToken();
|
||||
const uploaded = await uploadWechatGeneratedImage(accessToken, publicUrl, {
|
||||
wechatFetch,
|
||||
publicBaseUrl: config.publicBaseUrl,
|
||||
});
|
||||
const payload = await readJsonResponse(
|
||||
await wechatFetch(
|
||||
`${config.customerServiceUrl}?access_token=${encodeURIComponent(accessToken)}`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
touser: openid,
|
||||
msgtype: 'image',
|
||||
image: { media_id: uploaded.mediaId },
|
||||
}),
|
||||
},
|
||||
),
|
||||
);
|
||||
if (Number(payload?.errcode ?? 0) !== 0) {
|
||||
const errcode = Number(payload?.errcode ?? 0);
|
||||
const errmsg = String(payload?.errmsg ?? '').trim() || 'unknown_error';
|
||||
throw new Error(`微信图片客服消息发送失败 errcode=${errcode} errmsg=${errmsg}`);
|
||||
}
|
||||
return uploaded;
|
||||
};
|
||||
|
||||
const sendTextToUser = async (userId, content) => {
|
||||
const openid = await userAuth.getWechatOpenidForUser(userId, config.appId);
|
||||
if (!openid) {
|
||||
@@ -1779,6 +1832,43 @@ export function createWechatMpService({
|
||||
await sendCustomerServiceText(openid, content);
|
||||
};
|
||||
|
||||
const enforceFreshPageThumbnailDelivery = async ({
|
||||
artifacts,
|
||||
reply,
|
||||
openid,
|
||||
user,
|
||||
imagePolicy,
|
||||
publishDir,
|
||||
}) => {
|
||||
if (imagePolicy?.pageThumbnailMode !== WECHAT_PAGE_THUMBNAIL_MODE.REQUIRED_FRESH) return;
|
||||
const images = collectWechatGeneratedImages(reply?.messages ?? []);
|
||||
const verification = verifyFreshWechatPageThumbnails(artifacts, images);
|
||||
if (verification.ok) {
|
||||
try {
|
||||
for (const match of verification.matches) {
|
||||
const htmlRelativePath = path.relative(publishDir, match.artifact.localPath);
|
||||
if (!htmlRelativePath || htmlRelativePath.startsWith('..') || path.isAbsolute(htmlRelativePath)) {
|
||||
throw new Error('缩略图页面路径不在用户发布目录内');
|
||||
}
|
||||
await ensureWorkspaceHtmlThumbnail(publishDir, htmlRelativePath);
|
||||
}
|
||||
return;
|
||||
} catch (error) {
|
||||
verification.ok = false;
|
||||
verification.reason = `thumbnail_render_failed:${error instanceof Error ? error.message : String(error)}`;
|
||||
}
|
||||
}
|
||||
const text = buildPagePublishFailureText({ missingFreshThumbnail: true });
|
||||
try {
|
||||
await sendCustomerServiceText(openid, text, user);
|
||||
} catch (sendErr) {
|
||||
logger.error?.('WeChat MP fresh thumbnail failure notice failed:', sendErr);
|
||||
}
|
||||
const error = new Error(`wechat_page_fresh_thumbnail_required:${verification.reason}`);
|
||||
error.code = 'WECHAT_PAGE_FRESH_THUMBNAIL_REQUIRED';
|
||||
throw markWechatUserNotified(error);
|
||||
};
|
||||
|
||||
const ensureSessionProvider = async (sessionId) => {
|
||||
if (!applySessionLlmProvider || !sessionId) return;
|
||||
const applied = await applySessionLlmProvider(sessionId);
|
||||
@@ -2023,7 +2113,7 @@ export function createWechatMpService({
|
||||
}
|
||||
};
|
||||
|
||||
const buildIntentMetadata = (intent, { mediaAnalysisEnabled = false } = {}) => {
|
||||
const buildIntentMetadata = (intent, { mediaAnalysisEnabled = false, imagePolicy = null } = {}) => {
|
||||
const mediaPublicUrl = intent.media?.publicUrl || null;
|
||||
const mediaItems = Array.isArray(intent.recentMediaItems) && intent.recentMediaItems.length > 0
|
||||
? intent.recentMediaItems
|
||||
@@ -2055,6 +2145,7 @@ export function createWechatMpService({
|
||||
recognition: intent.msgType === 'voice' ? intent.agentText || null : null,
|
||||
location: intent.location || null,
|
||||
link: intent.link || null,
|
||||
memindRun: buildWechatImageRunMetadata(imagePolicy),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -2099,6 +2190,11 @@ export function createWechatMpService({
|
||||
const mediaAnalysisEnabled = isWechatMediaGrayUser(user, config.mediaAnalysisGrayUsers);
|
||||
const resetCandidate =
|
||||
intent.msgType === 'text' || intent.msgType === 'voice' ? intent.agentText : '';
|
||||
const imagePolicy = resolveWechatImageGenerationPolicy({
|
||||
text: resetCandidate,
|
||||
isPageGenerate: wechatIntent.kind === 'page.generate',
|
||||
requireFreshPageThumbnail: config.requireFreshPageThumbnail,
|
||||
});
|
||||
// Page Data delivery owns persistent files, datasets and two publication
|
||||
// policies. Reusing a conversational route here can make a new request
|
||||
// inspect/retry unrelated historical pages from that session.
|
||||
@@ -2139,14 +2235,17 @@ export function createWechatMpService({
|
||||
const requestStartedAt = Date.now();
|
||||
const agentPrompt =
|
||||
wechatIntent.kind === 'page.generate'
|
||||
? buildPageGenerateAgentPrompt(intent, { wantsDocx: wechatIntent.wantsDocx })
|
||||
: buildWechatAgentPrompt(intent);
|
||||
? buildPageGenerateAgentPrompt(intent, {
|
||||
wantsDocx: wechatIntent.wantsDocx,
|
||||
imagePolicy,
|
||||
})
|
||||
: buildWechatAgentPrompt(intent, { imagePolicy });
|
||||
const reply = await executeSessionReply(
|
||||
(pathname, init) => fetchForSession(sessionId, pathname, init),
|
||||
sessionId,
|
||||
requestId,
|
||||
agentPrompt,
|
||||
buildIntentMetadata(intent, { mediaAnalysisEnabled }),
|
||||
buildIntentMetadata(intent, { mediaAnalysisEnabled, imagePolicy }),
|
||||
{
|
||||
submitReply: submitSessionReply
|
||||
? ({ requestId: replyRequestId, userMessage }) =>
|
||||
@@ -2159,6 +2258,12 @@ export function createWechatMpService({
|
||||
: null,
|
||||
},
|
||||
);
|
||||
const generatedImages = collectWechatGeneratedImages(reply?.messages ?? []);
|
||||
if (imagePolicy.standaloneImageMode === 'required' && generatedImages.length === 0) {
|
||||
const error = new Error('图片生成没有获得本轮新的有效位图,请稍后重试');
|
||||
error.code = 'WECHAT_IMAGE_GENERATION_REQUIRED';
|
||||
throw error;
|
||||
}
|
||||
const workingDir = publishLayout?.publishDir ?? (await userAuth.resolveWorkingDir(user.userId));
|
||||
const linkExistsForRequest = resolveLinkExistsForWorkingDir(workingDir, linkExists);
|
||||
const {
|
||||
@@ -2237,6 +2342,16 @@ export function createWechatMpService({
|
||||
} else if (htmlGenerationNeedsRetry || suspiciousPublishClaim) {
|
||||
throw new Error('stale_session_poisoned_completion');
|
||||
}
|
||||
if (wechatIntent.kind === 'page.generate') {
|
||||
await enforceFreshPageThumbnailDelivery({
|
||||
artifacts: publishArtifacts,
|
||||
reply,
|
||||
openid: inbound.fromUserName,
|
||||
user,
|
||||
imagePolicy,
|
||||
publishDir: workingDir,
|
||||
});
|
||||
}
|
||||
const pageDataOutcome = await enforcePageDataCollectDelivery({
|
||||
reply,
|
||||
intent,
|
||||
@@ -2263,11 +2378,23 @@ export function createWechatMpService({
|
||||
const tokenState = await resolveWechatBillingTokenState(sessionId, reply.tokenState);
|
||||
await userAuth.billSessionUsage(user.userId, sessionId, tokenState, requestId);
|
||||
}
|
||||
const finalizedReply = await maybeAttachPublishedHtmlLink(reply, {
|
||||
let finalizedReply = await maybeAttachPublishedHtmlLink(reply, {
|
||||
workingDir,
|
||||
publicBaseUrl: config.publicBaseUrl,
|
||||
artifacts: publishArtifacts,
|
||||
});
|
||||
if (
|
||||
wechatIntent.kind !== 'page.generate'
|
||||
&& imagePolicy.standaloneImageMode !== 'disabled'
|
||||
&& generatedImages.length > 0
|
||||
) {
|
||||
try {
|
||||
await sendCustomerServiceImage(inbound.fromUserName, generatedImages[0]);
|
||||
} catch (sendErr) {
|
||||
logger.warn?.('WeChat MP generated image native delivery failed, falling back to link:', sendErr);
|
||||
finalizedReply = appendGeneratedImageFallbackLink(finalizedReply, generatedImages[0]);
|
||||
}
|
||||
}
|
||||
scheduleWechatSessionSnapshotRefresh(sessionId, user.userId);
|
||||
await sendCustomerServiceText(inbound.fromUserName, await guardScheduleReply(finalizedReply), user, {
|
||||
verifiedHtmlUrls: publishArtifacts.map((artifact) => artifact.url),
|
||||
@@ -2309,14 +2436,17 @@ export function createWechatMpService({
|
||||
const retryStartedAt = Date.now();
|
||||
const retryPrompt =
|
||||
wechatIntent.kind === 'page.generate'
|
||||
? buildPageGenerateAgentPrompt(intent, { wantsDocx: wechatIntent.wantsDocx })
|
||||
: buildWechatAgentPrompt(intent);
|
||||
? buildPageGenerateAgentPrompt(intent, {
|
||||
wantsDocx: wechatIntent.wantsDocx,
|
||||
imagePolicy,
|
||||
})
|
||||
: buildWechatAgentPrompt(intent, { imagePolicy });
|
||||
const reply = await executeSessionReply(
|
||||
(pathname, init) => fetchForSession(sessionId, pathname, init),
|
||||
sessionId,
|
||||
retryId,
|
||||
retryPrompt,
|
||||
buildIntentMetadata(intent, { mediaAnalysisEnabled }),
|
||||
buildIntentMetadata(intent, { mediaAnalysisEnabled, imagePolicy }),
|
||||
{
|
||||
submitReply: submitSessionReply
|
||||
? ({ requestId: replyRequestId, userMessage }) =>
|
||||
@@ -2329,6 +2459,12 @@ export function createWechatMpService({
|
||||
: null,
|
||||
},
|
||||
);
|
||||
const generatedImages = collectWechatGeneratedImages(reply?.messages ?? []);
|
||||
if (imagePolicy.standaloneImageMode === 'required' && generatedImages.length === 0) {
|
||||
const error = new Error('图片生成没有获得本轮新的有效位图,请稍后重试');
|
||||
error.code = 'WECHAT_IMAGE_GENERATION_REQUIRED';
|
||||
throw error;
|
||||
}
|
||||
const workingDir = publishLayout?.publishDir ?? (await userAuth.resolveWorkingDir(user.userId));
|
||||
const linkExistsForRequest = resolveLinkExistsForWorkingDir(workingDir, linkExists);
|
||||
const {
|
||||
@@ -2404,6 +2540,16 @@ export function createWechatMpService({
|
||||
} else if (htmlGenerationNeedsRetry || suspiciousPublishClaim) {
|
||||
throw new Error(buildHtmlPublishFailureText());
|
||||
}
|
||||
if (wechatIntent.kind === 'page.generate') {
|
||||
await enforceFreshPageThumbnailDelivery({
|
||||
artifacts: publishArtifacts,
|
||||
reply,
|
||||
openid: inbound.fromUserName,
|
||||
user,
|
||||
imagePolicy,
|
||||
publishDir: workingDir,
|
||||
});
|
||||
}
|
||||
const pageDataOutcome = await enforcePageDataCollectDelivery({
|
||||
reply,
|
||||
intent,
|
||||
@@ -2430,11 +2576,23 @@ export function createWechatMpService({
|
||||
const tokenState = await resolveWechatBillingTokenState(sessionId, reply.tokenState);
|
||||
await userAuth.billSessionUsage(user.userId, sessionId, tokenState, retryId);
|
||||
}
|
||||
const finalizedReply = await maybeAttachPublishedHtmlLink(reply, {
|
||||
let finalizedReply = await maybeAttachPublishedHtmlLink(reply, {
|
||||
workingDir,
|
||||
publicBaseUrl: config.publicBaseUrl,
|
||||
artifacts: publishArtifacts,
|
||||
});
|
||||
if (
|
||||
wechatIntent.kind !== 'page.generate'
|
||||
&& imagePolicy.standaloneImageMode !== 'disabled'
|
||||
&& generatedImages.length > 0
|
||||
) {
|
||||
try {
|
||||
await sendCustomerServiceImage(inbound.fromUserName, generatedImages[0]);
|
||||
} catch (sendErr) {
|
||||
logger.warn?.('WeChat MP generated image native delivery failed, falling back to link:', sendErr);
|
||||
finalizedReply = appendGeneratedImageFallbackLink(finalizedReply, generatedImages[0]);
|
||||
}
|
||||
}
|
||||
scheduleWechatSessionSnapshotRefresh(sessionId, user.userId);
|
||||
await sendCustomerServiceText(inbound.fromUserName, await guardScheduleReply(finalizedReply), user, {
|
||||
verifiedHtmlUrls: publishArtifacts.map((artifact) => artifact.url),
|
||||
|
||||
+316
-2
@@ -3,6 +3,7 @@ import crypto from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
import sharp from 'sharp';
|
||||
import {
|
||||
buildWechatAgentPrompt,
|
||||
buildWechatTextReply,
|
||||
@@ -62,8 +63,9 @@ function inboundXml({
|
||||
].join('');
|
||||
}
|
||||
|
||||
function previewReadyPageHtml({ title = 'Page', subtitle = '测试页面' } = {}) {
|
||||
return `<!doctype html><html><head><meta name="description" content="${subtitle}"><meta name="mindspace-cover" content='{"tag":"页面","accent":"#3366cc","accent2":"#112233","subtitle":"${subtitle}"}'><title>${title}</title></head><body><main>${'x'.repeat(600)}</main><p data-mindspace-page-tag="platform-brand">TKMind · 智趣</p></body></html>`;
|
||||
function previewReadyPageHtml({ title = 'Page', subtitle = '测试页面', cover = '' } = {}) {
|
||||
const coverField = cover ? `,"cover":"${cover}"` : '';
|
||||
return `<!doctype html><html><head><meta name="description" content="${subtitle}"><meta name="mindspace-cover" content='{"tag":"页面","accent":"#3366cc","accent2":"#112233","subtitle":"${subtitle}"${coverField}}'><title>${title}</title></head><body><main>${'x'.repeat(600)}</main><p data-mindspace-page-tag="platform-brand">TKMind · 智趣</p></body></html>`;
|
||||
}
|
||||
|
||||
function jsonEscapedPreviewReadyPageHtml(options = {}) {
|
||||
@@ -95,6 +97,7 @@ function createBoundWechatService({
|
||||
unboundTextPrefix: '请先绑定',
|
||||
progressDelayMs: 0,
|
||||
maxImageBytes: 1024 * 1024,
|
||||
requireFreshPageThumbnail: false,
|
||||
...config,
|
||||
},
|
||||
userAuth: {
|
||||
@@ -859,6 +862,8 @@ test('loadWechatMpConfig requires full config and enable flag', () => {
|
||||
});
|
||||
assert.equal(config.enabled, true);
|
||||
assert.equal(config.bindPath, '/auth/wechat/authorize?intent=login');
|
||||
assert.equal(config.requireFreshPageThumbnail, true);
|
||||
assert.equal(loadWechatMpConfig({ H5_WECHAT_MP_FRESH_PAGE_THUMBNAILS: '0' }).requireFreshPageThumbnail, false);
|
||||
});
|
||||
|
||||
test('verifyWechatMpSignature accepts valid signature', () => {
|
||||
@@ -4932,3 +4937,312 @@ test('wechat mp service exposes route status and can recreate route for bound us
|
||||
assert.equal(calls.some((item) => item === 'register:session-new'), true);
|
||||
assert.equal(calls.includes('upserted'), true);
|
||||
});
|
||||
|
||||
test('wechat mp page delivery requires and verifies a current-run fresh thumbnail', async () => {
|
||||
const token = 'token';
|
||||
const timestamp = '1710000000';
|
||||
const nonce = 'nonce';
|
||||
const workspaceRoot = fs.mkdtempSync('/tmp/wechat-mp-fresh-thumbnail-');
|
||||
const htmlPath = path.join(workspaceRoot, 'public', 'fresh.html');
|
||||
const wechatPayloads = [];
|
||||
const generated = {
|
||||
ok: true,
|
||||
jobId: 'job-fresh-page',
|
||||
source: { mimeType: 'image/webp', width: 1280, height: 720 },
|
||||
asset: {
|
||||
id: 'asset-fresh-page',
|
||||
htmlSrc: 'images/fresh-page.webp',
|
||||
publicUrl: 'https://example.com/MindSpace/user-1/public/images/fresh-page.webp',
|
||||
workspaceRelativePath: 'public/images/fresh-page.webp',
|
||||
},
|
||||
};
|
||||
const pageHtml = previewReadyPageHtml({
|
||||
title: 'Fresh',
|
||||
subtitle: '本轮新缩略图',
|
||||
cover: generated.asset.htmlSrc,
|
||||
});
|
||||
const pageImage = await sharp({
|
||||
create: { width: 64, height: 64, channels: 3, background: '#3366cc' },
|
||||
}).webp().toBuffer();
|
||||
const eventFrame = (event) => `data: ${JSON.stringify(event)}\n\n`;
|
||||
const service = createBoundWechatService({
|
||||
token,
|
||||
config: { requireFreshPageThumbnail: true },
|
||||
userAuth: {
|
||||
async resolveWorkingDir() {
|
||||
return workspaceRoot;
|
||||
},
|
||||
async getUserPublishLayout() {
|
||||
return {
|
||||
publishDir: workspaceRoot,
|
||||
displayName: 'John',
|
||||
username: 'john',
|
||||
slug: 'john',
|
||||
constraints: null,
|
||||
};
|
||||
},
|
||||
},
|
||||
sessionApiFetch: async (sessionId, pathname, init = {}) => {
|
||||
assert.equal(sessionId, 'session-1');
|
||||
if (pathname === '/sessions/session-1/events') {
|
||||
return new Response(
|
||||
[
|
||||
eventFrame({
|
||||
type: 'Message',
|
||||
request_id: 'req-fresh-page',
|
||||
message: {
|
||||
id: 'assistant-tools',
|
||||
role: 'assistant',
|
||||
metadata: { userVisible: true },
|
||||
content: [
|
||||
{
|
||||
id: 'call-page-skill',
|
||||
type: 'toolRequest',
|
||||
toolCall: {
|
||||
value: { name: 'load_skill', arguments: { name: 'static-page-publish' } },
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'call-generate',
|
||||
type: 'toolRequest',
|
||||
toolCall: {
|
||||
value: {
|
||||
name: 'sandbox-fs__generate_image',
|
||||
arguments: { purpose: 'hero' },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'call-generate',
|
||||
type: 'toolResponse',
|
||||
toolResult: {
|
||||
status: 'success',
|
||||
value: { content: [{ type: 'text', text: JSON.stringify(generated) }] },
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'call-write-page',
|
||||
type: 'toolRequest',
|
||||
toolCall: {
|
||||
value: {
|
||||
name: 'sandbox-fs__write_file',
|
||||
arguments: { path: 'public/fresh.html', content: pageHtml },
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
eventFrame({
|
||||
type: 'Message',
|
||||
request_id: 'req-fresh-page',
|
||||
message: {
|
||||
id: 'assistant-final',
|
||||
role: 'assistant',
|
||||
metadata: { userVisible: true },
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: '页面已完成:https://example.com/MindSpace/user-1/public/fresh.html',
|
||||
}],
|
||||
},
|
||||
}),
|
||||
eventFrame({
|
||||
type: 'Finish',
|
||||
request_id: 'req-fresh-page',
|
||||
token_state: { inputTokens: 1, outputTokens: 2 },
|
||||
}),
|
||||
].join(''),
|
||||
{ status: 200, headers: { 'Content-Type': 'text/event-stream' } },
|
||||
);
|
||||
}
|
||||
if (pathname === '/sessions/session-1/reply') {
|
||||
const body = JSON.parse(init.body);
|
||||
assert.equal(body.user_message.metadata.memindRun.channel, 'wechat_mp');
|
||||
assert.equal(body.user_message.metadata.memindRun.pageThumbnailMode, 'required_fresh');
|
||||
assert.equal(body.user_message.metadata.memindRun.inlineImageMode, 'auto');
|
||||
assert.match(body.user_message.content[0].text, /服务号页面新缩略图硬要求/);
|
||||
assert.match(body.user_message.content[0].text, /本轮新生成资产/);
|
||||
fs.mkdirSync(path.dirname(htmlPath), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
htmlPath,
|
||||
pageHtml,
|
||||
'utf8',
|
||||
);
|
||||
fs.mkdirSync(path.join(workspaceRoot, 'public', 'images'), { recursive: true });
|
||||
fs.writeFileSync(path.join(workspaceRoot, 'public', generated.asset.htmlSrc), pageImage);
|
||||
return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } });
|
||||
}
|
||||
if (pathname === '/agent/harness_remember' || pathname === '/agent/harness_bootstrap') {
|
||||
return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } });
|
||||
}
|
||||
throw new Error(`unexpected session path: ${pathname}`);
|
||||
},
|
||||
wechatFetch: async (url, init = {}) => {
|
||||
if (String(url).includes('/cgi-bin/stable_token')) {
|
||||
return new Response(JSON.stringify({ access_token: 'access-1', expires_in: 7200 }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
if (String(url).includes('/cgi-bin/message/custom/send')) {
|
||||
wechatPayloads.push(JSON.parse(init.body));
|
||||
return new Response(JSON.stringify({ errcode: 0, errmsg: 'ok' }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
throw new Error(`unexpected wechat url: ${url}`);
|
||||
},
|
||||
});
|
||||
|
||||
const originalRandomUuid = crypto.randomUUID;
|
||||
crypto.randomUUID = () => 'req-fresh-page';
|
||||
try {
|
||||
const result = await service.handleInboundMessage(
|
||||
inboundXml({ content: '请生成一个旅行 html 页面,文件名 public/fresh.html' }),
|
||||
{ timestamp, nonce, signature: signatureFor(token, timestamp, nonce) },
|
||||
);
|
||||
assert.equal(result.status, 200);
|
||||
await result.task;
|
||||
assert.equal(fs.existsSync(path.join(workspaceRoot, 'public', 'fresh.thumbnail.svg')), true);
|
||||
} finally {
|
||||
crypto.randomUUID = originalRandomUuid;
|
||||
fs.rmSync(workspaceRoot, { recursive: true, force: true });
|
||||
}
|
||||
assert.equal(wechatPayloads.length, 1);
|
||||
assert.equal(wechatPayloads[0].msgtype, 'text');
|
||||
assert.match(wechatPayloads[0].text.content, /fresh\.html/);
|
||||
});
|
||||
|
||||
test('wechat mp standalone image intent sends a native image message and text', async () => {
|
||||
const token = 'token';
|
||||
const timestamp = '1710000000';
|
||||
const nonce = 'nonce';
|
||||
const sourceImage = await sharp({
|
||||
create: { width: 48, height: 48, channels: 3, background: '#cc8844' },
|
||||
}).webp().toBuffer();
|
||||
const wechatPayloads = [];
|
||||
const generated = {
|
||||
ok: true,
|
||||
jobId: 'job-chat-image',
|
||||
source: { mimeType: 'image/webp', width: 1024, height: 1024 },
|
||||
asset: {
|
||||
id: 'asset-chat-image',
|
||||
htmlSrc: 'images/chat-image.webp',
|
||||
publicUrl: 'https://example.com/MindSpace/user-1/public/images/chat-image.webp',
|
||||
workspaceRelativePath: 'public/images/chat-image.webp',
|
||||
},
|
||||
};
|
||||
const eventFrame = (event) => `data: ${JSON.stringify(event)}\n\n`;
|
||||
const service = createBoundWechatService({
|
||||
token,
|
||||
sessionApiFetch: async (sessionId, pathname, init = {}) => {
|
||||
assert.equal(sessionId, 'session-1');
|
||||
if (pathname === '/sessions/session-1/events') {
|
||||
return new Response(
|
||||
[
|
||||
eventFrame({
|
||||
type: 'Message',
|
||||
request_id: 'req-chat-image',
|
||||
message: {
|
||||
id: 'assistant-tools',
|
||||
role: 'assistant',
|
||||
metadata: { userVisible: true },
|
||||
content: [
|
||||
{
|
||||
id: 'call-image',
|
||||
type: 'toolRequest',
|
||||
toolCall: {
|
||||
value: {
|
||||
name: 'sandbox-fs__generate_image',
|
||||
arguments: { purpose: 'inline_image' },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'call-image',
|
||||
type: 'toolResponse',
|
||||
toolResult: {
|
||||
status: 'success',
|
||||
value: { content: [{ type: 'text', text: JSON.stringify(generated) }] },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
eventFrame({
|
||||
type: 'Message',
|
||||
request_id: 'req-chat-image',
|
||||
message: {
|
||||
id: 'assistant-final',
|
||||
role: 'assistant',
|
||||
metadata: { userVisible: true },
|
||||
content: [{ type: 'text', text: '新图片已经生成。' }],
|
||||
},
|
||||
}),
|
||||
eventFrame({
|
||||
type: 'Finish',
|
||||
request_id: 'req-chat-image',
|
||||
token_state: { inputTokens: 1, outputTokens: 2 },
|
||||
}),
|
||||
].join(''),
|
||||
{ status: 200, headers: { 'Content-Type': 'text/event-stream' } },
|
||||
);
|
||||
}
|
||||
if (pathname === '/sessions/session-1/reply') {
|
||||
const body = JSON.parse(init.body);
|
||||
assert.equal(body.user_message.metadata.memindRun.imageGenerationMode, 'required');
|
||||
assert.equal(body.user_message.metadata.memindRun.pageThumbnailMode, 'auto');
|
||||
assert.match(body.user_message.content[0].text, /purpose=`inline_image`/);
|
||||
return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } });
|
||||
}
|
||||
if (pathname === '/agent/harness_remember' || pathname === '/agent/harness_bootstrap') {
|
||||
return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } });
|
||||
}
|
||||
throw new Error(`unexpected session path: ${pathname}`);
|
||||
},
|
||||
wechatFetch: async (url, init = {}) => {
|
||||
const target = String(url);
|
||||
if (target.includes('/cgi-bin/stable_token')) {
|
||||
return new Response(JSON.stringify({ access_token: 'access-1', expires_in: 7200 }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
if (target === generated.asset.publicUrl) {
|
||||
return new Response(sourceImage, { status: 200, headers: { 'Content-Type': 'image/webp' } });
|
||||
}
|
||||
if (target.includes('/cgi-bin/media/upload')) {
|
||||
assert.ok(init.body instanceof FormData);
|
||||
return new Response(JSON.stringify({ type: 'image', media_id: 'wx-generated-1' }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
if (target.includes('/cgi-bin/message/custom/send')) {
|
||||
wechatPayloads.push(JSON.parse(init.body));
|
||||
return new Response(JSON.stringify({ errcode: 0, errmsg: 'ok' }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
throw new Error(`unexpected wechat url: ${url}`);
|
||||
},
|
||||
});
|
||||
|
||||
const originalRandomUuid = crypto.randomUUID;
|
||||
crypto.randomUUID = () => 'req-chat-image';
|
||||
try {
|
||||
const result = await service.handleInboundMessage(
|
||||
inboundXml({ content: '帮我生成一张雨夜橘猫图片' }),
|
||||
{ timestamp, nonce, signature: signatureFor(token, timestamp, nonce) },
|
||||
);
|
||||
assert.equal(result.status, 200);
|
||||
await result.task;
|
||||
} finally {
|
||||
crypto.randomUUID = originalRandomUuid;
|
||||
}
|
||||
assert.deepEqual(wechatPayloads.map((payload) => payload.msgtype), ['image', 'text']);
|
||||
assert.equal(wechatPayloads[0].image.media_id, 'wx-generated-1');
|
||||
assert.match(wechatPayloads[1].text.content, /新图片已经生成/);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import {
|
||||
IMAGE_GENERATION_MODE,
|
||||
resolveImageGenerationDecision,
|
||||
} from '../chat-intent-router.mjs';
|
||||
|
||||
export const WECHAT_PAGE_THUMBNAIL_MODE = {
|
||||
AUTO: 'auto',
|
||||
REQUIRED_FRESH: 'required_fresh',
|
||||
};
|
||||
|
||||
const INLINE_IMAGE_REQUIRED_PATTERNS = [
|
||||
/(?:正文|内容|段落|章节).{0,12}(?:配图|插图|图片)/u,
|
||||
/(?:每段|每章|文中|文章中).{0,12}(?:配图|插图|图片)/u,
|
||||
/(?:多张|若干张|几张).{0,12}(?:配图|插图|图片)/u,
|
||||
];
|
||||
|
||||
export function resolveWechatImageGenerationPolicy({
|
||||
text,
|
||||
isPageGenerate = false,
|
||||
requireFreshPageThumbnail = false,
|
||||
} = {}) {
|
||||
const normalizedText = String(text ?? '').trim();
|
||||
const requested = resolveImageGenerationDecision({ text: normalizedText });
|
||||
const pageThumbnailMode = isPageGenerate && requireFreshPageThumbnail
|
||||
? WECHAT_PAGE_THUMBNAIL_MODE.REQUIRED_FRESH
|
||||
: WECHAT_PAGE_THUMBNAIL_MODE.AUTO;
|
||||
const inlineImageMode = requested.mode === IMAGE_GENERATION_MODE.DISABLED
|
||||
? IMAGE_GENERATION_MODE.DISABLED
|
||||
: INLINE_IMAGE_REQUIRED_PATTERNS.some((pattern) => pattern.test(normalizedText))
|
||||
? IMAGE_GENERATION_MODE.REQUIRED
|
||||
: IMAGE_GENERATION_MODE.AUTO;
|
||||
const standaloneImageMode = isPageGenerate
|
||||
? IMAGE_GENERATION_MODE.AUTO
|
||||
: requested.mode;
|
||||
|
||||
return {
|
||||
channel: 'wechat_mp',
|
||||
imageGenerationMode: pageThumbnailMode === WECHAT_PAGE_THUMBNAIL_MODE.REQUIRED_FRESH
|
||||
? IMAGE_GENERATION_MODE.REQUIRED
|
||||
: standaloneImageMode,
|
||||
pageThumbnailMode,
|
||||
inlineImageMode,
|
||||
standaloneImageMode,
|
||||
requested,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildWechatImageRunMetadata(policy) {
|
||||
if (!policy) return { channel: 'wechat_mp' };
|
||||
return {
|
||||
channel: 'wechat_mp',
|
||||
imageGenerationMode: policy.imageGenerationMode,
|
||||
pageThumbnailMode: policy.pageThumbnailMode,
|
||||
inlineImageMode: policy.inlineImageMode,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildWechatStandaloneImageInstruction(policy, { sourceMessageId = '' } = {}) {
|
||||
if (!policy || policy.standaloneImageMode === IMAGE_GENERATION_MODE.AUTO) return '';
|
||||
if (policy.standaloneImageMode === IMAGE_GENERATION_MODE.DISABLED) {
|
||||
return '【图片策略】用户明确不要生成图片。本轮禁止调用 generate_image,只返回文字。';
|
||||
}
|
||||
const idempotencyKey = sourceMessageId
|
||||
? `wechat-${String(sourceMessageId).replace(/[^a-zA-Z0-9._-]+/g, '-').slice(0, 80)}-image`
|
||||
: '';
|
||||
return [
|
||||
'【微信服务号生图任务】用户明确要求生成一张新图片。',
|
||||
'必须先调用 `load_skill` → `image-generation`,再调用一次 `sandbox-fs__generate_image`,purpose=`inline_image`。',
|
||||
'必须使用本轮新返回的 PNG/JPEG/WebP 资产;禁止复用历史图片、SVG、CSS 绘图、占位图或网页图片。',
|
||||
idempotencyKey ? `调用 generate_image 时传入 idempotency_key=${idempotencyKey}。` : '',
|
||||
'成功后简短说明结果,不要输出内部工具步骤;失败时如实报告,禁止声称已经生成。',
|
||||
].filter(Boolean).join('\n');
|
||||
}
|
||||
|
||||
export function buildWechatPageImageInstruction(policy, { sourceMessageId = '' } = {}) {
|
||||
if (policy?.pageThumbnailMode !== WECHAT_PAGE_THUMBNAIL_MODE.REQUIRED_FRESH) return '';
|
||||
const keyPrefix = sourceMessageId
|
||||
? `wechat-${String(sourceMessageId).replace(/[^a-zA-Z0-9._-]+/g, '-').slice(0, 72)}-page`
|
||||
: 'wechat-page';
|
||||
const inlineInstruction = policy.inlineImageMode === IMAGE_GENERATION_MODE.DISABLED
|
||||
? '用户明确不要正文图片:页面正文和 Hero 不展示图片,但缩略图源图仍必须生成并只写入 mindspace-cover.cover。'
|
||||
: policy.inlineImageMode === IMAGE_GENERATION_MODE.REQUIRED
|
||||
? '用户明确要求正文配图:缩略图源图之外,只有正文确实需要不同画面时才可按需调用 inline_image。'
|
||||
: '正文独立插图按意图判断;没有明确必要时不要额外调用 inline_image。';
|
||||
return [
|
||||
'【服务号页面新缩略图硬要求】每个本轮交付给用户的正式内容页面都必须使用本轮新生成、与主题一致的位图作为缩略图源图。',
|
||||
'写 HTML 前先调用 `load_skill` → `image-generation`,再为每个正式内容页面各调用一次 `sandbox-fs__generate_image`,优先 purpose=`hero`。',
|
||||
`幂等键使用 ${keyPrefix}-<页面序号>-thumbnail;不同正式页面不得共用同一张图。`,
|
||||
'生成成功后,把返回的 asset.htmlSrc 原样写入对应页面的 mindspace-cover.cover。页面需要主视觉时复用同一张图作为 Hero,禁止再调用 card_cover/feed_cover。',
|
||||
'管理后台、密码查看、重定向、下载包装和错误辅助页不属于正式内容页面,不要求单独生图;这类页面必须在 head 写 `<meta name="mindspace-page-role" content="auxiliary">` 供交付守卫识别。',
|
||||
inlineInstruction,
|
||||
'当前轮没有新的成功 generate_image、没有新 jobId,或正式页面未引用本轮资产时,禁止交付页面链接或声称完成。',
|
||||
].join('\n');
|
||||
}
|
||||
@@ -0,0 +1,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/);
|
||||
});
|
||||
@@ -2,8 +2,9 @@ import { buildAutoChatSkillPrefix, isPageDataIntent } from '../../chat-skills.mj
|
||||
import { shouldUseScheduleAssistant } from '../../schedule-intent.mjs';
|
||||
import { buildCurrentTimeAgentPrefix } from '../../user-memory-profile.mjs';
|
||||
import { isPageGenerateText, wantsDocxDownload } from '../intent/patterns.mjs';
|
||||
import { buildWechatStandaloneImageInstruction } from '../image-generation-policy.mjs';
|
||||
|
||||
export function buildWechatAgentPrompt(intent, { grantedSkills = [] } = {}) {
|
||||
export function buildWechatAgentPrompt(intent, { grantedSkills = [], imagePolicy = null } = {}) {
|
||||
const msgType = String(intent?.msgType ?? 'text');
|
||||
const agentText = intent?.agentText ?? intent?.content ?? '';
|
||||
const autoSkillPrefix =
|
||||
@@ -54,6 +55,9 @@ export function buildWechatAgentPrompt(intent, { grantedSkills = [] } = {}) {
|
||||
'',
|
||||
].filter(Boolean).join('\n')
|
||||
: '';
|
||||
const imageGenerationHint = buildWechatStandaloneImageInstruction(imagePolicy, {
|
||||
sourceMessageId: intent?.msgId,
|
||||
});
|
||||
|
||||
if (msgType === 'voice') {
|
||||
return [
|
||||
@@ -61,6 +65,7 @@ export function buildWechatAgentPrompt(intent, { grantedSkills = [] } = {}) {
|
||||
pageDataCollectHint,
|
||||
currentTimeHint,
|
||||
scheduleAssistantHint,
|
||||
imageGenerationHint,
|
||||
'【微信服务号语音消息】用户通过语音输入,以下是微信识别结果。',
|
||||
'',
|
||||
`用户语音识别文本:${autoSkillPrefix}${String(agentText).trim()}`,
|
||||
@@ -132,6 +137,7 @@ export function buildWechatAgentPrompt(intent, { grantedSkills = [] } = {}) {
|
||||
if (pageDataCollectHint) lines.push(pageDataCollectHint);
|
||||
if (pagePublishHint) lines.push(pagePublishHint);
|
||||
if (scheduleAssistantHint) lines.push(scheduleAssistantHint);
|
||||
if (imageGenerationHint) lines.push(imageGenerationHint);
|
||||
lines.push(
|
||||
'【微信服务号新消息】请只回答下面这条用户消息,不要主动延续无关的历史话题。',
|
||||
'若用户只是在测试连通性,请一句话确认收到即可,不要展开旧任务。',
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { buildCurrentTimeAgentPrefix } from '../../user-memory-profile.mjs';
|
||||
import { buildWechatPageImageInstruction } from '../image-generation-policy.mjs';
|
||||
|
||||
/**
|
||||
* Service-account-only page generation prompt. Does not use H5 buildAutoChatSkillPrefix.
|
||||
*/
|
||||
export function buildPageGenerateAgentPrompt(intent, { wantsDocx = false } = {}) {
|
||||
export function buildPageGenerateAgentPrompt(intent, { wantsDocx = false, imagePolicy = null } = {}) {
|
||||
const topic = String(intent?.agentText ?? intent?.displayText ?? '').trim();
|
||||
const scheduleTimezone = process.env.H5_DEFAULT_TIMEZONE || 'Asia/Shanghai';
|
||||
const docxBlock = wantsDocx
|
||||
@@ -13,24 +14,33 @@ export function buildPageGenerateAgentPrompt(intent, { wantsDocx = false } = {})
|
||||
'',
|
||||
].join('\n')
|
||||
: '';
|
||||
const imageBlock = buildWechatPageImageInstruction(imagePolicy, {
|
||||
sourceMessageId: intent?.msgId,
|
||||
});
|
||||
const coverExample = imageBlock
|
||||
? '<本轮 generate_image 返回的 asset.htmlSrc>'
|
||||
: 'assets/hero.jpg';
|
||||
|
||||
return [
|
||||
'【微信服务号 · 页面生成任务】',
|
||||
'这是服务号专用页面生成,不是普通聊天。必须按步骤完成,未完成前禁止告诉用户“已生成/已发布”。',
|
||||
'',
|
||||
docxBlock,
|
||||
imageBlock,
|
||||
'步骤(必须全部完成):',
|
||||
'1. 调用 `load_skill`,参数 name=`static-page-publish`。',
|
||||
'1. 必须先调用 `load_skill`,参数 name=`static-page-publish`。',
|
||||
'2. 阅读技能说明后,用 sandbox-fs 的 `write_file` 或 `edit_file` 写入 `public/*.html`。',
|
||||
'3. 禁止 shell/cat/heredoc/cp 写 HTML(不会出现在公网 MindSpace)。',
|
||||
'4. 写完后确认目标文件已落盘,且内容是用户要的完整页面(不是占位 stub)。',
|
||||
'5. 在 `<head>` 写入微信分享卡片所需元数据(必须):',
|
||||
' - `<meta name="description" content="一句话摘要">`',
|
||||
' - `<meta name="mindspace-cover" content=\'{"tag":"主题","accent":"#主色","accent2":"#辅色","subtitle":"卖点","cover":"assets/hero.jpg"}\'>`',
|
||||
' tag/accent/subtitle 必须与页面主题一致;有 hero 图时 cover 指向同目录 raster 图(jpg/png)。',
|
||||
` - \`<meta name="mindspace-cover" content='{"tag":"主题","accent":"#主色","accent2":"#辅色","subtitle":"卖点","cover":"${coverExample}"}'>\``,
|
||||
imageBlock
|
||||
? ' tag/accent/subtitle 必须与页面主题一致;cover 必须引用本轮新生成资产,不得省略、复用旧图或填写占位路径。'
|
||||
: ' tag/accent/subtitle 必须与页面主题一致;有 hero 图时 cover 指向同目录 raster 图(jpg/png)。',
|
||||
'6. 交付前运行 `npm run check:mindspace-cover`(或 node scripts/check-mindspace-cover.mjs --user <uid>);有 error 则补元数据后再回复链接。',
|
||||
'7. 页脚加 `<p data-mindspace-page-tag="platform-brand">TKMind · 智趣</p>`(必须;禁止省略、不要用邮箱/tkmind.ai,也不要把该行 opacity 设太低导致看不见)。',
|
||||
'8. 回复里只给一个正式域名链接;没有落盘或缺少上述元数据就不要发链接。',
|
||||
'8. 回复里只给一个正式域名的唯一正确链接;没有落盘或缺少上述元数据就不要发链接。',
|
||||
'',
|
||||
buildCurrentTimeAgentPrefix({ timezone: scheduleTimezone }),
|
||||
`用户需求:${topic}`,
|
||||
@@ -39,7 +49,17 @@ export function buildPageGenerateAgentPrompt(intent, { wantsDocx = false } = {})
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
export function buildPagePublishFailureText({ missingSharePreview = false, missingPlatformBrand = false } = {}) {
|
||||
export function buildPagePublishFailureText({
|
||||
missingSharePreview = false,
|
||||
missingPlatformBrand = false,
|
||||
missingFreshThumbnail = false,
|
||||
} = {}) {
|
||||
if (missingFreshThumbnail) {
|
||||
return [
|
||||
'这次页面内容已生成,但没有完成服务号要求的本轮新缩略图,所以我先不发页面链接。',
|
||||
'请直接重发一次完整页面需求,我会重新生成主题匹配的新缩略图并完成页面交付。',
|
||||
].join('\n');
|
||||
}
|
||||
if (missingPlatformBrand) {
|
||||
return [
|
||||
'这次页面已落盘,但缺少页脚平台品牌标记(data-mindspace-page-tag="platform-brand"),所以我先不发链接。',
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const RASTER_MIME_PATTERN = /^image\/(?:png|jpeg|webp)$/i;
|
||||
|
||||
function parseToolResultJson(toolResult) {
|
||||
const content = toolResult?.value?.content;
|
||||
if (!Array.isArray(content)) return null;
|
||||
for (const item of content) {
|
||||
if (item?.type !== 'text' || !String(item.text ?? '').trim()) continue;
|
||||
try {
|
||||
return JSON.parse(String(item.text));
|
||||
} catch {
|
||||
// A non-JSON tool message cannot prove a fresh generated image.
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeGeneratedImage(result) {
|
||||
const mimeType = String(result?.source?.mimeType ?? result?.asset?.mimeType ?? '').toLowerCase();
|
||||
if (!result?.ok || !String(result?.jobId ?? '').trim() || !RASTER_MIME_PATTERN.test(mimeType)) {
|
||||
return null;
|
||||
}
|
||||
const asset = result.asset ?? {};
|
||||
const htmlSrc = String(asset.htmlSrc ?? '').trim();
|
||||
const publicUrl = String(asset.publicUrl ?? '').trim();
|
||||
const workspaceRelativePath = String(asset.workspaceRelativePath ?? '').trim();
|
||||
if (!htmlSrc && !publicUrl && !workspaceRelativePath) return null;
|
||||
return {
|
||||
jobId: String(result.jobId),
|
||||
mimeType,
|
||||
assetId: String(asset.id ?? '').trim() || null,
|
||||
htmlSrc: htmlSrc || null,
|
||||
publicUrl: publicUrl || null,
|
||||
workspaceRelativePath: workspaceRelativePath || null,
|
||||
};
|
||||
}
|
||||
|
||||
export function collectWechatGeneratedImages(messages = []) {
|
||||
const requestNames = new Map();
|
||||
const images = [];
|
||||
for (const message of messages) {
|
||||
for (const item of message?.content ?? []) {
|
||||
if (item?.type === 'toolRequest') {
|
||||
const name = String(item?.toolCall?.value?.name ?? '').trim();
|
||||
if (item.id && name) requestNames.set(String(item.id), name);
|
||||
continue;
|
||||
}
|
||||
if (item?.type !== 'toolResponse') continue;
|
||||
const name = requestNames.get(String(item.id ?? '')) ?? String(item?.toolResult?.name ?? '');
|
||||
if (!name.endsWith('generate_image')) continue;
|
||||
const toolResult = item.toolResult ?? {};
|
||||
if (toolResult.status !== 'success' || toolResult?.value?.isError === true) continue;
|
||||
const generated = normalizeGeneratedImage(parseToolResultJson(toolResult));
|
||||
if (generated && !images.some((image) => image.jobId === generated.jobId)) images.push(generated);
|
||||
}
|
||||
}
|
||||
return images;
|
||||
}
|
||||
|
||||
function decodeHtmlAttribute(value) {
|
||||
return String(value ?? '')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll(''', "'")
|
||||
.replaceAll('&', '&');
|
||||
}
|
||||
|
||||
export function extractMindspaceCoverPath(html) {
|
||||
const tag = String(html ?? '').match(/<meta[^>]*name=["']mindspace-cover["'][^>]*>/i)?.[0] ?? '';
|
||||
if (!tag) return '';
|
||||
const match = tag.match(/content=(['"])([\s\S]*?)\1/i);
|
||||
if (!match?.[2]) return '';
|
||||
try {
|
||||
const meta = JSON.parse(decodeHtmlAttribute(match[2]));
|
||||
return String(meta.cover ?? meta.image ?? '').trim();
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeRelativeAssetPath(value) {
|
||||
const clean = String(value ?? '')
|
||||
.trim()
|
||||
.split(/[?#]/, 1)[0]
|
||||
.replace(/\\/g, '/')
|
||||
.replace(/^\.\//, '')
|
||||
.replace(/^\/+/, '');
|
||||
return clean.startsWith('public/') ? clean.slice('public/'.length) : clean;
|
||||
}
|
||||
|
||||
function imageMatchesCover(image, cover, artifact) {
|
||||
if (!cover) return false;
|
||||
if (/^https?:\/\//i.test(cover)) return image.publicUrl === cover;
|
||||
const coverPath = normalizeRelativeAssetPath(cover);
|
||||
const artifactRelativePath = normalizeRelativeAssetPath(artifact?.relativePath ?? '');
|
||||
const artifactDir = path.posix.dirname(artifactRelativePath);
|
||||
const resolvedCover = normalizeRelativeAssetPath(path.posix.join(artifactDir === '.' ? '' : artifactDir, coverPath));
|
||||
const candidates = [image.htmlSrc, image.workspaceRelativePath]
|
||||
.map(normalizeRelativeAssetPath)
|
||||
.filter(Boolean);
|
||||
return candidates.includes(coverPath) || candidates.includes(resolvedCover);
|
||||
}
|
||||
|
||||
const AUXILIARY_PAGE_PATH_PATTERN = /(?:^|[-_.\/])(?:admin|manage|management|backend|password|redirect|download|error)(?:[-_.\/]|$)/i;
|
||||
|
||||
export function isWechatAuxiliaryPageArtifact(artifact) {
|
||||
const relativePath = String(artifact?.relativePath ?? '').trim().replace(/\\/g, '/');
|
||||
if (AUXILIARY_PAGE_PATH_PATTERN.test(relativePath)) return true;
|
||||
const localPath = String(artifact?.localPath ?? '').trim();
|
||||
if (!localPath || !fs.existsSync(localPath)) return false;
|
||||
const html = fs.readFileSync(localPath, 'utf8');
|
||||
return /<meta[^>]*name=["']mindspace-page-role["'][^>]*content=["'](?:admin|auxiliary)["']/i.test(html)
|
||||
|| /<[^>]+data-mindspace-page-role=["'](?:admin|auxiliary)["']/i.test(html);
|
||||
}
|
||||
|
||||
export function verifyFreshWechatPageThumbnails(artifacts = [], images = []) {
|
||||
if (!Array.isArray(artifacts) || artifacts.length === 0) {
|
||||
return { ok: false, reason: 'missing_page_artifact', matches: [] };
|
||||
}
|
||||
const eligibleArtifacts = artifacts.filter((artifact) => !isWechatAuxiliaryPageArtifact(artifact));
|
||||
const skippedArtifacts = artifacts.filter((artifact) => isWechatAuxiliaryPageArtifact(artifact));
|
||||
if (eligibleArtifacts.length === 0) {
|
||||
return { ok: true, reason: null, matches: [], skippedArtifacts };
|
||||
}
|
||||
if (!Array.isArray(images) || images.length === 0) {
|
||||
return { ok: false, reason: 'fresh_image_not_generated', matches: [], skippedArtifacts };
|
||||
}
|
||||
const usedJobs = new Set();
|
||||
const matches = [];
|
||||
for (const artifact of eligibleArtifacts) {
|
||||
const localPath = String(artifact?.localPath ?? '').trim();
|
||||
if (!localPath || !fs.existsSync(localPath)) {
|
||||
return { ok: false, reason: 'missing_page_artifact', artifact, matches, skippedArtifacts };
|
||||
}
|
||||
const cover = extractMindspaceCoverPath(fs.readFileSync(localPath, 'utf8'));
|
||||
const image = images.find((candidate) => !usedJobs.has(candidate.jobId) && imageMatchesCover(candidate, cover, artifact));
|
||||
if (!image) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: cover ? 'cover_not_from_current_run' : 'missing_generated_cover',
|
||||
artifact,
|
||||
matches,
|
||||
skippedArtifacts,
|
||||
};
|
||||
}
|
||||
usedJobs.add(image.jobId);
|
||||
matches.push({ artifact, image, cover });
|
||||
}
|
||||
return { ok: true, reason: null, matches, skippedArtifacts };
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
collectWechatGeneratedImages,
|
||||
extractMindspaceCoverPath,
|
||||
verifyFreshWechatPageThumbnails,
|
||||
} from './generated-thumbnail.mjs';
|
||||
|
||||
function generatedImageMessages({ jobId = 'job-1', htmlSrc = 'images/fresh.webp' } = {}) {
|
||||
const result = {
|
||||
ok: true,
|
||||
jobId,
|
||||
source: { mimeType: 'image/webp' },
|
||||
asset: {
|
||||
id: `asset-${jobId}`,
|
||||
htmlSrc,
|
||||
publicUrl: `https://example.com/MindSpace/user/public/${htmlSrc}`,
|
||||
workspaceRelativePath: `public/${htmlSrc}`,
|
||||
},
|
||||
};
|
||||
return [{
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
id: `call-${jobId}`,
|
||||
type: 'toolRequest',
|
||||
toolCall: { value: { name: 'sandbox-fs__generate_image', arguments: { purpose: 'hero' } } },
|
||||
},
|
||||
{
|
||||
id: `call-${jobId}`,
|
||||
type: 'toolResponse',
|
||||
toolResult: {
|
||||
status: 'success',
|
||||
value: { content: [{ type: 'text', text: JSON.stringify(result) }] },
|
||||
},
|
||||
},
|
||||
],
|
||||
}];
|
||||
}
|
||||
|
||||
test('fresh page thumbnail must reference a current-run generated image', () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'wechat-fresh-cover-'));
|
||||
try {
|
||||
const htmlPath = path.join(root, 'page.html');
|
||||
fs.writeFileSync(
|
||||
htmlPath,
|
||||
'<meta name="mindspace-cover" content=\'{"cover":"images/fresh.webp"}\'>',
|
||||
'utf8',
|
||||
);
|
||||
const images = collectWechatGeneratedImages(generatedImageMessages());
|
||||
assert.equal(images.length, 1);
|
||||
assert.equal(extractMindspaceCoverPath(fs.readFileSync(htmlPath, 'utf8')), 'images/fresh.webp');
|
||||
const result = verifyFreshWechatPageThumbnails(
|
||||
[{ localPath: htmlPath, relativePath: 'public/page.html' }],
|
||||
images,
|
||||
);
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.matches[0].image.jobId, 'job-1');
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('old cover path and sharing one generated image across two pages fail closed', () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'wechat-stale-cover-'));
|
||||
try {
|
||||
const first = path.join(root, 'one.html');
|
||||
const second = path.join(root, 'two.html');
|
||||
fs.writeFileSync(first, '<meta name="mindspace-cover" content=\'{"cover":"images/fresh.webp"}\'>');
|
||||
fs.writeFileSync(second, '<meta name="mindspace-cover" content=\'{"cover":"images/fresh.webp"}\'>');
|
||||
const images = collectWechatGeneratedImages(generatedImageMessages());
|
||||
const reused = verifyFreshWechatPageThumbnails(
|
||||
[
|
||||
{ localPath: first, relativePath: 'public/one.html' },
|
||||
{ localPath: second, relativePath: 'public/two.html' },
|
||||
],
|
||||
images,
|
||||
);
|
||||
assert.equal(reused.ok, false);
|
||||
assert.equal(reused.reason, 'cover_not_from_current_run');
|
||||
|
||||
fs.writeFileSync(first, '<meta name="mindspace-cover" content=\'{"cover":"images/old.webp"}\'>');
|
||||
const stale = verifyFreshWechatPageThumbnails(
|
||||
[{ localPath: first, relativePath: 'public/one.html' }],
|
||||
images,
|
||||
);
|
||||
assert.equal(stale.ok, false);
|
||||
assert.equal(stale.reason, 'cover_not_from_current_run');
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('auxiliary admin pages do not require a separate generated thumbnail', () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'wechat-aux-cover-'));
|
||||
try {
|
||||
const front = path.join(root, 'survey.html');
|
||||
const admin = path.join(root, 'survey-admin.html');
|
||||
fs.writeFileSync(front, '<meta name="mindspace-cover" content=\'{"cover":"images/fresh.webp"}\'>');
|
||||
fs.writeFileSync(admin, '<meta name="mindspace-page-role" content="auxiliary">');
|
||||
const result = verifyFreshWechatPageThumbnails(
|
||||
[
|
||||
{ localPath: front, relativePath: 'public/survey.html' },
|
||||
{ localPath: admin, relativePath: 'public/survey-admin.html' },
|
||||
],
|
||||
collectWechatGeneratedImages(generatedImageMessages()),
|
||||
);
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.matches.length, 1);
|
||||
assert.equal(result.skippedArtifacts.length, 1);
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user