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 与视觉
|
||||||
审核 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
@@ -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",
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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',
|
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
@@ -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
@@ -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, /新图片已经生成/);
|
||||||
|
});
|
||||||
|
|||||||
@@ -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 { 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(
|
||||||
'【微信服务号新消息】请只回答下面这条用户消息,不要主动延续无关的历史话题。',
|
'【微信服务号新消息】请只回答下面这条用户消息,不要主动延续无关的历史话题。',
|
||||||
'若用户只是在测试连通性,请一句话确认收到即可,不要展开旧任务。',
|
'若用户只是在测试连通性,请一句话确认收到即可,不要展开旧任务。',
|
||||||
|
|||||||
@@ -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"),所以我先不发链接。',
|
||||||
|
|||||||
@@ -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