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

This commit is contained in:
john
2026-07-21 23:21:44 +08:00
parent bfb1f6fea9
commit 28581310da
13 changed files with 1078 additions and 19 deletions
+167 -9
View File
@@ -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),