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:
+167
-9
@@ -13,6 +13,7 @@ import {
|
||||
downloadTemporaryMedia,
|
||||
persistWechatAttachment,
|
||||
persistWechatImage,
|
||||
uploadWechatGeneratedImage,
|
||||
} from './wechat-media.mjs';
|
||||
import { normalizeWechatName, resolveWechatAddressName } from './wechat/user/display-name.mjs';
|
||||
import { buildAckText } from './wechat/ack/ack-provider.mjs';
|
||||
@@ -27,13 +28,23 @@ import { classifyWechatIntent } from './wechat/intent/classifier.mjs';
|
||||
import { isPageGenerateText, isTopicResetText } from './wechat/intent/patterns.mjs';
|
||||
import { isPageDataIntent } from './chat-skills.mjs';
|
||||
import { resolvePageGenerateOutcome } from './wechat/handlers/page-generate.mjs';
|
||||
import {
|
||||
buildWechatImageRunMetadata,
|
||||
resolveWechatImageGenerationPolicy,
|
||||
WECHAT_PAGE_THUMBNAIL_MODE,
|
||||
} from './wechat/image-generation-policy.mjs';
|
||||
import { buildWechatAgentPrompt } from './wechat/prompts/chat-general.mjs';
|
||||
import {
|
||||
buildPageGenerateAgentPrompt,
|
||||
buildPagePublishFailureText,
|
||||
} from './wechat/prompts/page-generate.mjs';
|
||||
import { selectSendableHtmlArtifacts } from './wechat/verify/page-artifact.mjs';
|
||||
import {
|
||||
collectWechatGeneratedImages,
|
||||
verifyFreshWechatPageThumbnails,
|
||||
} from './wechat/verify/generated-thumbnail.mjs';
|
||||
import { resolveBillingTokenState } from './billing-token-state.mjs';
|
||||
import { ensureWorkspaceHtmlThumbnail } from './mindspace-workspace-thumbnails.mjs';
|
||||
import {
|
||||
buildPageDataCollectFailureText,
|
||||
buildPageDataDeliveryArtifactsFromBindResult,
|
||||
@@ -333,6 +344,13 @@ function splitWechatText(text, maxBytes = WECHAT_CUSTOMER_TEXT_MAX_BYTES) {
|
||||
return chunks.length > 0 ? chunks : ['我先收到了,但这次没有生成可发送的文本结果。'];
|
||||
}
|
||||
|
||||
function appendGeneratedImageFallbackLink(text, generatedImage) {
|
||||
const normalized = String(text ?? '').trim();
|
||||
const publicUrl = String(generatedImage?.publicUrl ?? '').trim();
|
||||
if (!publicUrl || normalized.includes(publicUrl)) return normalized;
|
||||
return [normalized, `图片链接:${publicUrl}`].filter(Boolean).join('\n');
|
||||
}
|
||||
|
||||
export { splitWechatText, WECHAT_CUSTOMER_TEXT_MAX_BYTES };
|
||||
|
||||
function flattenSessionTools(extensions = []) {
|
||||
@@ -1079,6 +1097,9 @@ export function sanitizeWechatAgentOutboundText(text) {
|
||||
|
||||
function formatWechatAgentFailureMessage(err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
if (err?.code === 'WECHAT_IMAGE_GENERATION_REQUIRED') {
|
||||
return '这次图片生成没有获得新的有效位图,所以我没有发送旧图或占位图。请稍后重试。';
|
||||
}
|
||||
if (isHtmlPublishFailureMessage(message)) return buildHtmlPublishFailureText();
|
||||
if (isRecoverableWechatAgentSessionError(message) || isWechatAgentApiErrorText(message)) {
|
||||
return '刚才专属会话状态异常,我已切换到新会话。请再发一次你的需求。';
|
||||
@@ -1490,6 +1511,7 @@ export function createWechatMpService({
|
||||
enabled: false,
|
||||
};
|
||||
}
|
||||
const requireFreshPageThumbnail = config.requireFreshPageThumbnail === true;
|
||||
config = {
|
||||
...loadWechatMpConfig({}),
|
||||
...config,
|
||||
@@ -1507,6 +1529,7 @@ export function createWechatMpService({
|
||||
mediaAnalysisGrayUsers: Array.isArray(config.mediaAnalysisGrayUsers)
|
||||
? config.mediaAnalysisGrayUsers
|
||||
: [],
|
||||
requireFreshPageThumbnail,
|
||||
asrTarget: config.asrTarget || DEFAULT_ASR_TARGET,
|
||||
};
|
||||
|
||||
@@ -1771,6 +1794,36 @@ export function createWechatMpService({
|
||||
}
|
||||
};
|
||||
|
||||
const sendCustomerServiceImage = async (openid, generatedImage) => {
|
||||
const publicUrl = String(generatedImage?.publicUrl ?? '').trim();
|
||||
if (!publicUrl) throw new Error('生成图片缺少可发送的公网地址');
|
||||
const accessToken = await getStableAccessToken();
|
||||
const uploaded = await uploadWechatGeneratedImage(accessToken, publicUrl, {
|
||||
wechatFetch,
|
||||
publicBaseUrl: config.publicBaseUrl,
|
||||
});
|
||||
const payload = await readJsonResponse(
|
||||
await wechatFetch(
|
||||
`${config.customerServiceUrl}?access_token=${encodeURIComponent(accessToken)}`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
touser: openid,
|
||||
msgtype: 'image',
|
||||
image: { media_id: uploaded.mediaId },
|
||||
}),
|
||||
},
|
||||
),
|
||||
);
|
||||
if (Number(payload?.errcode ?? 0) !== 0) {
|
||||
const errcode = Number(payload?.errcode ?? 0);
|
||||
const errmsg = String(payload?.errmsg ?? '').trim() || 'unknown_error';
|
||||
throw new Error(`微信图片客服消息发送失败 errcode=${errcode} errmsg=${errmsg}`);
|
||||
}
|
||||
return uploaded;
|
||||
};
|
||||
|
||||
const sendTextToUser = async (userId, content) => {
|
||||
const openid = await userAuth.getWechatOpenidForUser(userId, config.appId);
|
||||
if (!openid) {
|
||||
@@ -1779,6 +1832,43 @@ export function createWechatMpService({
|
||||
await sendCustomerServiceText(openid, content);
|
||||
};
|
||||
|
||||
const enforceFreshPageThumbnailDelivery = async ({
|
||||
artifacts,
|
||||
reply,
|
||||
openid,
|
||||
user,
|
||||
imagePolicy,
|
||||
publishDir,
|
||||
}) => {
|
||||
if (imagePolicy?.pageThumbnailMode !== WECHAT_PAGE_THUMBNAIL_MODE.REQUIRED_FRESH) return;
|
||||
const images = collectWechatGeneratedImages(reply?.messages ?? []);
|
||||
const verification = verifyFreshWechatPageThumbnails(artifacts, images);
|
||||
if (verification.ok) {
|
||||
try {
|
||||
for (const match of verification.matches) {
|
||||
const htmlRelativePath = path.relative(publishDir, match.artifact.localPath);
|
||||
if (!htmlRelativePath || htmlRelativePath.startsWith('..') || path.isAbsolute(htmlRelativePath)) {
|
||||
throw new Error('缩略图页面路径不在用户发布目录内');
|
||||
}
|
||||
await ensureWorkspaceHtmlThumbnail(publishDir, htmlRelativePath);
|
||||
}
|
||||
return;
|
||||
} catch (error) {
|
||||
verification.ok = false;
|
||||
verification.reason = `thumbnail_render_failed:${error instanceof Error ? error.message : String(error)}`;
|
||||
}
|
||||
}
|
||||
const text = buildPagePublishFailureText({ missingFreshThumbnail: true });
|
||||
try {
|
||||
await sendCustomerServiceText(openid, text, user);
|
||||
} catch (sendErr) {
|
||||
logger.error?.('WeChat MP fresh thumbnail failure notice failed:', sendErr);
|
||||
}
|
||||
const error = new Error(`wechat_page_fresh_thumbnail_required:${verification.reason}`);
|
||||
error.code = 'WECHAT_PAGE_FRESH_THUMBNAIL_REQUIRED';
|
||||
throw markWechatUserNotified(error);
|
||||
};
|
||||
|
||||
const ensureSessionProvider = async (sessionId) => {
|
||||
if (!applySessionLlmProvider || !sessionId) return;
|
||||
const applied = await applySessionLlmProvider(sessionId);
|
||||
@@ -2023,7 +2113,7 @@ export function createWechatMpService({
|
||||
}
|
||||
};
|
||||
|
||||
const buildIntentMetadata = (intent, { mediaAnalysisEnabled = false } = {}) => {
|
||||
const buildIntentMetadata = (intent, { mediaAnalysisEnabled = false, imagePolicy = null } = {}) => {
|
||||
const mediaPublicUrl = intent.media?.publicUrl || null;
|
||||
const mediaItems = Array.isArray(intent.recentMediaItems) && intent.recentMediaItems.length > 0
|
||||
? intent.recentMediaItems
|
||||
@@ -2055,6 +2145,7 @@ export function createWechatMpService({
|
||||
recognition: intent.msgType === 'voice' ? intent.agentText || null : null,
|
||||
location: intent.location || null,
|
||||
link: intent.link || null,
|
||||
memindRun: buildWechatImageRunMetadata(imagePolicy),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -2099,6 +2190,11 @@ export function createWechatMpService({
|
||||
const mediaAnalysisEnabled = isWechatMediaGrayUser(user, config.mediaAnalysisGrayUsers);
|
||||
const resetCandidate =
|
||||
intent.msgType === 'text' || intent.msgType === 'voice' ? intent.agentText : '';
|
||||
const imagePolicy = resolveWechatImageGenerationPolicy({
|
||||
text: resetCandidate,
|
||||
isPageGenerate: wechatIntent.kind === 'page.generate',
|
||||
requireFreshPageThumbnail: config.requireFreshPageThumbnail,
|
||||
});
|
||||
// Page Data delivery owns persistent files, datasets and two publication
|
||||
// policies. Reusing a conversational route here can make a new request
|
||||
// inspect/retry unrelated historical pages from that session.
|
||||
@@ -2139,14 +2235,17 @@ export function createWechatMpService({
|
||||
const requestStartedAt = Date.now();
|
||||
const agentPrompt =
|
||||
wechatIntent.kind === 'page.generate'
|
||||
? buildPageGenerateAgentPrompt(intent, { wantsDocx: wechatIntent.wantsDocx })
|
||||
: buildWechatAgentPrompt(intent);
|
||||
? buildPageGenerateAgentPrompt(intent, {
|
||||
wantsDocx: wechatIntent.wantsDocx,
|
||||
imagePolicy,
|
||||
})
|
||||
: buildWechatAgentPrompt(intent, { imagePolicy });
|
||||
const reply = await executeSessionReply(
|
||||
(pathname, init) => fetchForSession(sessionId, pathname, init),
|
||||
sessionId,
|
||||
requestId,
|
||||
agentPrompt,
|
||||
buildIntentMetadata(intent, { mediaAnalysisEnabled }),
|
||||
buildIntentMetadata(intent, { mediaAnalysisEnabled, imagePolicy }),
|
||||
{
|
||||
submitReply: submitSessionReply
|
||||
? ({ requestId: replyRequestId, userMessage }) =>
|
||||
@@ -2159,6 +2258,12 @@ export function createWechatMpService({
|
||||
: null,
|
||||
},
|
||||
);
|
||||
const generatedImages = collectWechatGeneratedImages(reply?.messages ?? []);
|
||||
if (imagePolicy.standaloneImageMode === 'required' && generatedImages.length === 0) {
|
||||
const error = new Error('图片生成没有获得本轮新的有效位图,请稍后重试');
|
||||
error.code = 'WECHAT_IMAGE_GENERATION_REQUIRED';
|
||||
throw error;
|
||||
}
|
||||
const workingDir = publishLayout?.publishDir ?? (await userAuth.resolveWorkingDir(user.userId));
|
||||
const linkExistsForRequest = resolveLinkExistsForWorkingDir(workingDir, linkExists);
|
||||
const {
|
||||
@@ -2237,6 +2342,16 @@ export function createWechatMpService({
|
||||
} else if (htmlGenerationNeedsRetry || suspiciousPublishClaim) {
|
||||
throw new Error('stale_session_poisoned_completion');
|
||||
}
|
||||
if (wechatIntent.kind === 'page.generate') {
|
||||
await enforceFreshPageThumbnailDelivery({
|
||||
artifacts: publishArtifacts,
|
||||
reply,
|
||||
openid: inbound.fromUserName,
|
||||
user,
|
||||
imagePolicy,
|
||||
publishDir: workingDir,
|
||||
});
|
||||
}
|
||||
const pageDataOutcome = await enforcePageDataCollectDelivery({
|
||||
reply,
|
||||
intent,
|
||||
@@ -2263,11 +2378,23 @@ export function createWechatMpService({
|
||||
const tokenState = await resolveWechatBillingTokenState(sessionId, reply.tokenState);
|
||||
await userAuth.billSessionUsage(user.userId, sessionId, tokenState, requestId);
|
||||
}
|
||||
const finalizedReply = await maybeAttachPublishedHtmlLink(reply, {
|
||||
let finalizedReply = await maybeAttachPublishedHtmlLink(reply, {
|
||||
workingDir,
|
||||
publicBaseUrl: config.publicBaseUrl,
|
||||
artifacts: publishArtifacts,
|
||||
});
|
||||
if (
|
||||
wechatIntent.kind !== 'page.generate'
|
||||
&& imagePolicy.standaloneImageMode !== 'disabled'
|
||||
&& generatedImages.length > 0
|
||||
) {
|
||||
try {
|
||||
await sendCustomerServiceImage(inbound.fromUserName, generatedImages[0]);
|
||||
} catch (sendErr) {
|
||||
logger.warn?.('WeChat MP generated image native delivery failed, falling back to link:', sendErr);
|
||||
finalizedReply = appendGeneratedImageFallbackLink(finalizedReply, generatedImages[0]);
|
||||
}
|
||||
}
|
||||
scheduleWechatSessionSnapshotRefresh(sessionId, user.userId);
|
||||
await sendCustomerServiceText(inbound.fromUserName, await guardScheduleReply(finalizedReply), user, {
|
||||
verifiedHtmlUrls: publishArtifacts.map((artifact) => artifact.url),
|
||||
@@ -2309,14 +2436,17 @@ export function createWechatMpService({
|
||||
const retryStartedAt = Date.now();
|
||||
const retryPrompt =
|
||||
wechatIntent.kind === 'page.generate'
|
||||
? buildPageGenerateAgentPrompt(intent, { wantsDocx: wechatIntent.wantsDocx })
|
||||
: buildWechatAgentPrompt(intent);
|
||||
? buildPageGenerateAgentPrompt(intent, {
|
||||
wantsDocx: wechatIntent.wantsDocx,
|
||||
imagePolicy,
|
||||
})
|
||||
: buildWechatAgentPrompt(intent, { imagePolicy });
|
||||
const reply = await executeSessionReply(
|
||||
(pathname, init) => fetchForSession(sessionId, pathname, init),
|
||||
sessionId,
|
||||
retryId,
|
||||
retryPrompt,
|
||||
buildIntentMetadata(intent, { mediaAnalysisEnabled }),
|
||||
buildIntentMetadata(intent, { mediaAnalysisEnabled, imagePolicy }),
|
||||
{
|
||||
submitReply: submitSessionReply
|
||||
? ({ requestId: replyRequestId, userMessage }) =>
|
||||
@@ -2329,6 +2459,12 @@ export function createWechatMpService({
|
||||
: null,
|
||||
},
|
||||
);
|
||||
const generatedImages = collectWechatGeneratedImages(reply?.messages ?? []);
|
||||
if (imagePolicy.standaloneImageMode === 'required' && generatedImages.length === 0) {
|
||||
const error = new Error('图片生成没有获得本轮新的有效位图,请稍后重试');
|
||||
error.code = 'WECHAT_IMAGE_GENERATION_REQUIRED';
|
||||
throw error;
|
||||
}
|
||||
const workingDir = publishLayout?.publishDir ?? (await userAuth.resolveWorkingDir(user.userId));
|
||||
const linkExistsForRequest = resolveLinkExistsForWorkingDir(workingDir, linkExists);
|
||||
const {
|
||||
@@ -2404,6 +2540,16 @@ export function createWechatMpService({
|
||||
} else if (htmlGenerationNeedsRetry || suspiciousPublishClaim) {
|
||||
throw new Error(buildHtmlPublishFailureText());
|
||||
}
|
||||
if (wechatIntent.kind === 'page.generate') {
|
||||
await enforceFreshPageThumbnailDelivery({
|
||||
artifacts: publishArtifacts,
|
||||
reply,
|
||||
openid: inbound.fromUserName,
|
||||
user,
|
||||
imagePolicy,
|
||||
publishDir: workingDir,
|
||||
});
|
||||
}
|
||||
const pageDataOutcome = await enforcePageDataCollectDelivery({
|
||||
reply,
|
||||
intent,
|
||||
@@ -2430,11 +2576,23 @@ export function createWechatMpService({
|
||||
const tokenState = await resolveWechatBillingTokenState(sessionId, reply.tokenState);
|
||||
await userAuth.billSessionUsage(user.userId, sessionId, tokenState, retryId);
|
||||
}
|
||||
const finalizedReply = await maybeAttachPublishedHtmlLink(reply, {
|
||||
let finalizedReply = await maybeAttachPublishedHtmlLink(reply, {
|
||||
workingDir,
|
||||
publicBaseUrl: config.publicBaseUrl,
|
||||
artifacts: publishArtifacts,
|
||||
});
|
||||
if (
|
||||
wechatIntent.kind !== 'page.generate'
|
||||
&& imagePolicy.standaloneImageMode !== 'disabled'
|
||||
&& generatedImages.length > 0
|
||||
) {
|
||||
try {
|
||||
await sendCustomerServiceImage(inbound.fromUserName, generatedImages[0]);
|
||||
} catch (sendErr) {
|
||||
logger.warn?.('WeChat MP generated image native delivery failed, falling back to link:', sendErr);
|
||||
finalizedReply = appendGeneratedImageFallbackLink(finalizedReply, generatedImages[0]);
|
||||
}
|
||||
}
|
||||
scheduleWechatSessionSnapshotRefresh(sessionId, user.userId);
|
||||
await sendCustomerServiceText(inbound.fromUserName, await guardScheduleReply(finalizedReply), user, {
|
||||
verifiedHtmlUrls: publishArtifacts.map((artifact) => artifact.url),
|
||||
|
||||
Reference in New Issue
Block a user