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:
@@ -0,0 +1,94 @@
|
||||
import {
|
||||
IMAGE_GENERATION_MODE,
|
||||
resolveImageGenerationDecision,
|
||||
} from '../chat-intent-router.mjs';
|
||||
|
||||
export const WECHAT_PAGE_THUMBNAIL_MODE = {
|
||||
AUTO: 'auto',
|
||||
REQUIRED_FRESH: 'required_fresh',
|
||||
};
|
||||
|
||||
const INLINE_IMAGE_REQUIRED_PATTERNS = [
|
||||
/(?:正文|内容|段落|章节).{0,12}(?:配图|插图|图片)/u,
|
||||
/(?:每段|每章|文中|文章中).{0,12}(?:配图|插图|图片)/u,
|
||||
/(?:多张|若干张|几张).{0,12}(?:配图|插图|图片)/u,
|
||||
];
|
||||
|
||||
export function resolveWechatImageGenerationPolicy({
|
||||
text,
|
||||
isPageGenerate = false,
|
||||
requireFreshPageThumbnail = false,
|
||||
} = {}) {
|
||||
const normalizedText = String(text ?? '').trim();
|
||||
const requested = resolveImageGenerationDecision({ text: normalizedText });
|
||||
const pageThumbnailMode = isPageGenerate && requireFreshPageThumbnail
|
||||
? WECHAT_PAGE_THUMBNAIL_MODE.REQUIRED_FRESH
|
||||
: WECHAT_PAGE_THUMBNAIL_MODE.AUTO;
|
||||
const inlineImageMode = requested.mode === IMAGE_GENERATION_MODE.DISABLED
|
||||
? IMAGE_GENERATION_MODE.DISABLED
|
||||
: INLINE_IMAGE_REQUIRED_PATTERNS.some((pattern) => pattern.test(normalizedText))
|
||||
? IMAGE_GENERATION_MODE.REQUIRED
|
||||
: IMAGE_GENERATION_MODE.AUTO;
|
||||
const standaloneImageMode = isPageGenerate
|
||||
? IMAGE_GENERATION_MODE.AUTO
|
||||
: requested.mode;
|
||||
|
||||
return {
|
||||
channel: 'wechat_mp',
|
||||
imageGenerationMode: pageThumbnailMode === WECHAT_PAGE_THUMBNAIL_MODE.REQUIRED_FRESH
|
||||
? IMAGE_GENERATION_MODE.REQUIRED
|
||||
: standaloneImageMode,
|
||||
pageThumbnailMode,
|
||||
inlineImageMode,
|
||||
standaloneImageMode,
|
||||
requested,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildWechatImageRunMetadata(policy) {
|
||||
if (!policy) return { channel: 'wechat_mp' };
|
||||
return {
|
||||
channel: 'wechat_mp',
|
||||
imageGenerationMode: policy.imageGenerationMode,
|
||||
pageThumbnailMode: policy.pageThumbnailMode,
|
||||
inlineImageMode: policy.inlineImageMode,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildWechatStandaloneImageInstruction(policy, { sourceMessageId = '' } = {}) {
|
||||
if (!policy || policy.standaloneImageMode === IMAGE_GENERATION_MODE.AUTO) return '';
|
||||
if (policy.standaloneImageMode === IMAGE_GENERATION_MODE.DISABLED) {
|
||||
return '【图片策略】用户明确不要生成图片。本轮禁止调用 generate_image,只返回文字。';
|
||||
}
|
||||
const idempotencyKey = sourceMessageId
|
||||
? `wechat-${String(sourceMessageId).replace(/[^a-zA-Z0-9._-]+/g, '-').slice(0, 80)}-image`
|
||||
: '';
|
||||
return [
|
||||
'【微信服务号生图任务】用户明确要求生成一张新图片。',
|
||||
'必须先调用 `load_skill` → `image-generation`,再调用一次 `sandbox-fs__generate_image`,purpose=`inline_image`。',
|
||||
'必须使用本轮新返回的 PNG/JPEG/WebP 资产;禁止复用历史图片、SVG、CSS 绘图、占位图或网页图片。',
|
||||
idempotencyKey ? `调用 generate_image 时传入 idempotency_key=${idempotencyKey}。` : '',
|
||||
'成功后简短说明结果,不要输出内部工具步骤;失败时如实报告,禁止声称已经生成。',
|
||||
].filter(Boolean).join('\n');
|
||||
}
|
||||
|
||||
export function buildWechatPageImageInstruction(policy, { sourceMessageId = '' } = {}) {
|
||||
if (policy?.pageThumbnailMode !== WECHAT_PAGE_THUMBNAIL_MODE.REQUIRED_FRESH) return '';
|
||||
const keyPrefix = sourceMessageId
|
||||
? `wechat-${String(sourceMessageId).replace(/[^a-zA-Z0-9._-]+/g, '-').slice(0, 72)}-page`
|
||||
: 'wechat-page';
|
||||
const inlineInstruction = policy.inlineImageMode === IMAGE_GENERATION_MODE.DISABLED
|
||||
? '用户明确不要正文图片:页面正文和 Hero 不展示图片,但缩略图源图仍必须生成并只写入 mindspace-cover.cover。'
|
||||
: policy.inlineImageMode === IMAGE_GENERATION_MODE.REQUIRED
|
||||
? '用户明确要求正文配图:缩略图源图之外,只有正文确实需要不同画面时才可按需调用 inline_image。'
|
||||
: '正文独立插图按意图判断;没有明确必要时不要额外调用 inline_image。';
|
||||
return [
|
||||
'【服务号页面新缩略图硬要求】每个本轮交付给用户的正式内容页面都必须使用本轮新生成、与主题一致的位图作为缩略图源图。',
|
||||
'写 HTML 前先调用 `load_skill` → `image-generation`,再为每个正式内容页面各调用一次 `sandbox-fs__generate_image`,优先 purpose=`hero`。',
|
||||
`幂等键使用 ${keyPrefix}-<页面序号>-thumbnail;不同正式页面不得共用同一张图。`,
|
||||
'生成成功后,把返回的 asset.htmlSrc 原样写入对应页面的 mindspace-cover.cover。页面需要主视觉时复用同一张图作为 Hero,禁止再调用 card_cover/feed_cover。',
|
||||
'管理后台、密码查看、重定向、下载包装和错误辅助页不属于正式内容页面,不要求单独生图;这类页面必须在 head 写 `<meta name="mindspace-page-role" content="auxiliary">` 供交付守卫识别。',
|
||||
inlineInstruction,
|
||||
'当前轮没有新的成功 generate_image、没有新 jobId,或正式页面未引用本轮资产时,禁止交付页面链接或声称完成。',
|
||||
].join('\n');
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
buildWechatPageImageInstruction,
|
||||
buildWechatStandaloneImageInstruction,
|
||||
resolveWechatImageGenerationPolicy,
|
||||
WECHAT_PAGE_THUMBNAIL_MODE,
|
||||
} from './image-generation-policy.mjs';
|
||||
|
||||
test('service-account page always requires a fresh thumbnail without forcing body images', () => {
|
||||
const policy = resolveWechatImageGenerationPolicy({
|
||||
text: '请做一个苏州旅行页面',
|
||||
isPageGenerate: true,
|
||||
requireFreshPageThumbnail: true,
|
||||
});
|
||||
assert.equal(policy.pageThumbnailMode, WECHAT_PAGE_THUMBNAIL_MODE.REQUIRED_FRESH);
|
||||
assert.equal(policy.imageGenerationMode, 'required');
|
||||
assert.equal(policy.inlineImageMode, 'auto');
|
||||
assert.equal(policy.standaloneImageMode, 'auto');
|
||||
assert.match(buildWechatPageImageInstruction(policy), /每个本轮交付给用户的正式内容页面/);
|
||||
});
|
||||
|
||||
test('no-image page request disables body images but keeps required fresh thumbnail', () => {
|
||||
const policy = resolveWechatImageGenerationPolicy({
|
||||
text: '生成一个活动页面,只要文字,不要图片',
|
||||
isPageGenerate: true,
|
||||
requireFreshPageThumbnail: true,
|
||||
});
|
||||
assert.equal(policy.pageThumbnailMode, 'required_fresh');
|
||||
assert.equal(policy.inlineImageMode, 'disabled');
|
||||
assert.match(buildWechatPageImageInstruction(policy), /正文和 Hero 不展示图片/);
|
||||
});
|
||||
|
||||
test('explicit body illustration request is independent from the page thumbnail', () => {
|
||||
const policy = resolveWechatImageGenerationPolicy({
|
||||
text: '生成一个科普页面,每个章节都要正文插图',
|
||||
isPageGenerate: true,
|
||||
requireFreshPageThumbnail: true,
|
||||
});
|
||||
assert.equal(policy.inlineImageMode, 'required');
|
||||
});
|
||||
|
||||
test('standalone image intent gets an inline_image tool contract', () => {
|
||||
const policy = resolveWechatImageGenerationPolicy({ text: '帮我生成一张雨夜橘猫图片' });
|
||||
assert.equal(policy.standaloneImageMode, 'required');
|
||||
const instruction = buildWechatStandaloneImageInstruction(policy, { sourceMessageId: 'msg-1' });
|
||||
assert.match(instruction, /purpose=`inline_image`/);
|
||||
assert.match(instruction, /idempotency_key=wechat-msg-1-image/);
|
||||
});
|
||||
@@ -2,8 +2,9 @@ import { buildAutoChatSkillPrefix, isPageDataIntent } from '../../chat-skills.mj
|
||||
import { shouldUseScheduleAssistant } from '../../schedule-intent.mjs';
|
||||
import { buildCurrentTimeAgentPrefix } from '../../user-memory-profile.mjs';
|
||||
import { isPageGenerateText, wantsDocxDownload } from '../intent/patterns.mjs';
|
||||
import { buildWechatStandaloneImageInstruction } from '../image-generation-policy.mjs';
|
||||
|
||||
export function buildWechatAgentPrompt(intent, { grantedSkills = [] } = {}) {
|
||||
export function buildWechatAgentPrompt(intent, { grantedSkills = [], imagePolicy = null } = {}) {
|
||||
const msgType = String(intent?.msgType ?? 'text');
|
||||
const agentText = intent?.agentText ?? intent?.content ?? '';
|
||||
const autoSkillPrefix =
|
||||
@@ -54,6 +55,9 @@ export function buildWechatAgentPrompt(intent, { grantedSkills = [] } = {}) {
|
||||
'',
|
||||
].filter(Boolean).join('\n')
|
||||
: '';
|
||||
const imageGenerationHint = buildWechatStandaloneImageInstruction(imagePolicy, {
|
||||
sourceMessageId: intent?.msgId,
|
||||
});
|
||||
|
||||
if (msgType === 'voice') {
|
||||
return [
|
||||
@@ -61,6 +65,7 @@ export function buildWechatAgentPrompt(intent, { grantedSkills = [] } = {}) {
|
||||
pageDataCollectHint,
|
||||
currentTimeHint,
|
||||
scheduleAssistantHint,
|
||||
imageGenerationHint,
|
||||
'【微信服务号语音消息】用户通过语音输入,以下是微信识别结果。',
|
||||
'',
|
||||
`用户语音识别文本:${autoSkillPrefix}${String(agentText).trim()}`,
|
||||
@@ -132,6 +137,7 @@ export function buildWechatAgentPrompt(intent, { grantedSkills = [] } = {}) {
|
||||
if (pageDataCollectHint) lines.push(pageDataCollectHint);
|
||||
if (pagePublishHint) lines.push(pagePublishHint);
|
||||
if (scheduleAssistantHint) lines.push(scheduleAssistantHint);
|
||||
if (imageGenerationHint) lines.push(imageGenerationHint);
|
||||
lines.push(
|
||||
'【微信服务号新消息】请只回答下面这条用户消息,不要主动延续无关的历史话题。',
|
||||
'若用户只是在测试连通性,请一句话确认收到即可,不要展开旧任务。',
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { buildCurrentTimeAgentPrefix } from '../../user-memory-profile.mjs';
|
||||
import { buildWechatPageImageInstruction } from '../image-generation-policy.mjs';
|
||||
|
||||
/**
|
||||
* Service-account-only page generation prompt. Does not use H5 buildAutoChatSkillPrefix.
|
||||
*/
|
||||
export function buildPageGenerateAgentPrompt(intent, { wantsDocx = false } = {}) {
|
||||
export function buildPageGenerateAgentPrompt(intent, { wantsDocx = false, imagePolicy = null } = {}) {
|
||||
const topic = String(intent?.agentText ?? intent?.displayText ?? '').trim();
|
||||
const scheduleTimezone = process.env.H5_DEFAULT_TIMEZONE || 'Asia/Shanghai';
|
||||
const docxBlock = wantsDocx
|
||||
@@ -13,24 +14,33 @@ export function buildPageGenerateAgentPrompt(intent, { wantsDocx = false } = {})
|
||||
'',
|
||||
].join('\n')
|
||||
: '';
|
||||
const imageBlock = buildWechatPageImageInstruction(imagePolicy, {
|
||||
sourceMessageId: intent?.msgId,
|
||||
});
|
||||
const coverExample = imageBlock
|
||||
? '<本轮 generate_image 返回的 asset.htmlSrc>'
|
||||
: 'assets/hero.jpg';
|
||||
|
||||
return [
|
||||
'【微信服务号 · 页面生成任务】',
|
||||
'这是服务号专用页面生成,不是普通聊天。必须按步骤完成,未完成前禁止告诉用户“已生成/已发布”。',
|
||||
'',
|
||||
docxBlock,
|
||||
imageBlock,
|
||||
'步骤(必须全部完成):',
|
||||
'1. 调用 `load_skill`,参数 name=`static-page-publish`。',
|
||||
'1. 必须先调用 `load_skill`,参数 name=`static-page-publish`。',
|
||||
'2. 阅读技能说明后,用 sandbox-fs 的 `write_file` 或 `edit_file` 写入 `public/*.html`。',
|
||||
'3. 禁止 shell/cat/heredoc/cp 写 HTML(不会出现在公网 MindSpace)。',
|
||||
'4. 写完后确认目标文件已落盘,且内容是用户要的完整页面(不是占位 stub)。',
|
||||
'5. 在 `<head>` 写入微信分享卡片所需元数据(必须):',
|
||||
' - `<meta name="description" content="一句话摘要">`',
|
||||
' - `<meta name="mindspace-cover" content=\'{"tag":"主题","accent":"#主色","accent2":"#辅色","subtitle":"卖点","cover":"assets/hero.jpg"}\'>`',
|
||||
' tag/accent/subtitle 必须与页面主题一致;有 hero 图时 cover 指向同目录 raster 图(jpg/png)。',
|
||||
` - \`<meta name="mindspace-cover" content='{"tag":"主题","accent":"#主色","accent2":"#辅色","subtitle":"卖点","cover":"${coverExample}"}'>\``,
|
||||
imageBlock
|
||||
? ' tag/accent/subtitle 必须与页面主题一致;cover 必须引用本轮新生成资产,不得省略、复用旧图或填写占位路径。'
|
||||
: ' tag/accent/subtitle 必须与页面主题一致;有 hero 图时 cover 指向同目录 raster 图(jpg/png)。',
|
||||
'6. 交付前运行 `npm run check:mindspace-cover`(或 node scripts/check-mindspace-cover.mjs --user <uid>);有 error 则补元数据后再回复链接。',
|
||||
'7. 页脚加 `<p data-mindspace-page-tag="platform-brand">TKMind · 智趣</p>`(必须;禁止省略、不要用邮箱/tkmind.ai,也不要把该行 opacity 设太低导致看不见)。',
|
||||
'8. 回复里只给一个正式域名链接;没有落盘或缺少上述元数据就不要发链接。',
|
||||
'8. 回复里只给一个正式域名的唯一正确链接;没有落盘或缺少上述元数据就不要发链接。',
|
||||
'',
|
||||
buildCurrentTimeAgentPrefix({ timezone: scheduleTimezone }),
|
||||
`用户需求:${topic}`,
|
||||
@@ -39,7 +49,17 @@ export function buildPageGenerateAgentPrompt(intent, { wantsDocx = false } = {})
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
export function buildPagePublishFailureText({ missingSharePreview = false, missingPlatformBrand = false } = {}) {
|
||||
export function buildPagePublishFailureText({
|
||||
missingSharePreview = false,
|
||||
missingPlatformBrand = false,
|
||||
missingFreshThumbnail = false,
|
||||
} = {}) {
|
||||
if (missingFreshThumbnail) {
|
||||
return [
|
||||
'这次页面内容已生成,但没有完成服务号要求的本轮新缩略图,所以我先不发页面链接。',
|
||||
'请直接重发一次完整页面需求,我会重新生成主题匹配的新缩略图并完成页面交付。',
|
||||
].join('\n');
|
||||
}
|
||||
if (missingPlatformBrand) {
|
||||
return [
|
||||
'这次页面已落盘,但缺少页脚平台品牌标记(data-mindspace-page-tag="platform-brand"),所以我先不发链接。',
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const RASTER_MIME_PATTERN = /^image\/(?:png|jpeg|webp)$/i;
|
||||
|
||||
function parseToolResultJson(toolResult) {
|
||||
const content = toolResult?.value?.content;
|
||||
if (!Array.isArray(content)) return null;
|
||||
for (const item of content) {
|
||||
if (item?.type !== 'text' || !String(item.text ?? '').trim()) continue;
|
||||
try {
|
||||
return JSON.parse(String(item.text));
|
||||
} catch {
|
||||
// A non-JSON tool message cannot prove a fresh generated image.
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeGeneratedImage(result) {
|
||||
const mimeType = String(result?.source?.mimeType ?? result?.asset?.mimeType ?? '').toLowerCase();
|
||||
if (!result?.ok || !String(result?.jobId ?? '').trim() || !RASTER_MIME_PATTERN.test(mimeType)) {
|
||||
return null;
|
||||
}
|
||||
const asset = result.asset ?? {};
|
||||
const htmlSrc = String(asset.htmlSrc ?? '').trim();
|
||||
const publicUrl = String(asset.publicUrl ?? '').trim();
|
||||
const workspaceRelativePath = String(asset.workspaceRelativePath ?? '').trim();
|
||||
if (!htmlSrc && !publicUrl && !workspaceRelativePath) return null;
|
||||
return {
|
||||
jobId: String(result.jobId),
|
||||
mimeType,
|
||||
assetId: String(asset.id ?? '').trim() || null,
|
||||
htmlSrc: htmlSrc || null,
|
||||
publicUrl: publicUrl || null,
|
||||
workspaceRelativePath: workspaceRelativePath || null,
|
||||
};
|
||||
}
|
||||
|
||||
export function collectWechatGeneratedImages(messages = []) {
|
||||
const requestNames = new Map();
|
||||
const images = [];
|
||||
for (const message of messages) {
|
||||
for (const item of message?.content ?? []) {
|
||||
if (item?.type === 'toolRequest') {
|
||||
const name = String(item?.toolCall?.value?.name ?? '').trim();
|
||||
if (item.id && name) requestNames.set(String(item.id), name);
|
||||
continue;
|
||||
}
|
||||
if (item?.type !== 'toolResponse') continue;
|
||||
const name = requestNames.get(String(item.id ?? '')) ?? String(item?.toolResult?.name ?? '');
|
||||
if (!name.endsWith('generate_image')) continue;
|
||||
const toolResult = item.toolResult ?? {};
|
||||
if (toolResult.status !== 'success' || toolResult?.value?.isError === true) continue;
|
||||
const generated = normalizeGeneratedImage(parseToolResultJson(toolResult));
|
||||
if (generated && !images.some((image) => image.jobId === generated.jobId)) images.push(generated);
|
||||
}
|
||||
}
|
||||
return images;
|
||||
}
|
||||
|
||||
function decodeHtmlAttribute(value) {
|
||||
return String(value ?? '')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll(''', "'")
|
||||
.replaceAll('&', '&');
|
||||
}
|
||||
|
||||
export function extractMindspaceCoverPath(html) {
|
||||
const tag = String(html ?? '').match(/<meta[^>]*name=["']mindspace-cover["'][^>]*>/i)?.[0] ?? '';
|
||||
if (!tag) return '';
|
||||
const match = tag.match(/content=(['"])([\s\S]*?)\1/i);
|
||||
if (!match?.[2]) return '';
|
||||
try {
|
||||
const meta = JSON.parse(decodeHtmlAttribute(match[2]));
|
||||
return String(meta.cover ?? meta.image ?? '').trim();
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeRelativeAssetPath(value) {
|
||||
const clean = String(value ?? '')
|
||||
.trim()
|
||||
.split(/[?#]/, 1)[0]
|
||||
.replace(/\\/g, '/')
|
||||
.replace(/^\.\//, '')
|
||||
.replace(/^\/+/, '');
|
||||
return clean.startsWith('public/') ? clean.slice('public/'.length) : clean;
|
||||
}
|
||||
|
||||
function imageMatchesCover(image, cover, artifact) {
|
||||
if (!cover) return false;
|
||||
if (/^https?:\/\//i.test(cover)) return image.publicUrl === cover;
|
||||
const coverPath = normalizeRelativeAssetPath(cover);
|
||||
const artifactRelativePath = normalizeRelativeAssetPath(artifact?.relativePath ?? '');
|
||||
const artifactDir = path.posix.dirname(artifactRelativePath);
|
||||
const resolvedCover = normalizeRelativeAssetPath(path.posix.join(artifactDir === '.' ? '' : artifactDir, coverPath));
|
||||
const candidates = [image.htmlSrc, image.workspaceRelativePath]
|
||||
.map(normalizeRelativeAssetPath)
|
||||
.filter(Boolean);
|
||||
return candidates.includes(coverPath) || candidates.includes(resolvedCover);
|
||||
}
|
||||
|
||||
const AUXILIARY_PAGE_PATH_PATTERN = /(?:^|[-_.\/])(?:admin|manage|management|backend|password|redirect|download|error)(?:[-_.\/]|$)/i;
|
||||
|
||||
export function isWechatAuxiliaryPageArtifact(artifact) {
|
||||
const relativePath = String(artifact?.relativePath ?? '').trim().replace(/\\/g, '/');
|
||||
if (AUXILIARY_PAGE_PATH_PATTERN.test(relativePath)) return true;
|
||||
const localPath = String(artifact?.localPath ?? '').trim();
|
||||
if (!localPath || !fs.existsSync(localPath)) return false;
|
||||
const html = fs.readFileSync(localPath, 'utf8');
|
||||
return /<meta[^>]*name=["']mindspace-page-role["'][^>]*content=["'](?:admin|auxiliary)["']/i.test(html)
|
||||
|| /<[^>]+data-mindspace-page-role=["'](?:admin|auxiliary)["']/i.test(html);
|
||||
}
|
||||
|
||||
export function verifyFreshWechatPageThumbnails(artifacts = [], images = []) {
|
||||
if (!Array.isArray(artifacts) || artifacts.length === 0) {
|
||||
return { ok: false, reason: 'missing_page_artifact', matches: [] };
|
||||
}
|
||||
const eligibleArtifacts = artifacts.filter((artifact) => !isWechatAuxiliaryPageArtifact(artifact));
|
||||
const skippedArtifacts = artifacts.filter((artifact) => isWechatAuxiliaryPageArtifact(artifact));
|
||||
if (eligibleArtifacts.length === 0) {
|
||||
return { ok: true, reason: null, matches: [], skippedArtifacts };
|
||||
}
|
||||
if (!Array.isArray(images) || images.length === 0) {
|
||||
return { ok: false, reason: 'fresh_image_not_generated', matches: [], skippedArtifacts };
|
||||
}
|
||||
const usedJobs = new Set();
|
||||
const matches = [];
|
||||
for (const artifact of eligibleArtifacts) {
|
||||
const localPath = String(artifact?.localPath ?? '').trim();
|
||||
if (!localPath || !fs.existsSync(localPath)) {
|
||||
return { ok: false, reason: 'missing_page_artifact', artifact, matches, skippedArtifacts };
|
||||
}
|
||||
const cover = extractMindspaceCoverPath(fs.readFileSync(localPath, 'utf8'));
|
||||
const image = images.find((candidate) => !usedJobs.has(candidate.jobId) && imageMatchesCover(candidate, cover, artifact));
|
||||
if (!image) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: cover ? 'cover_not_from_current_run' : 'missing_generated_cover',
|
||||
artifact,
|
||||
matches,
|
||||
skippedArtifacts,
|
||||
};
|
||||
}
|
||||
usedJobs.add(image.jobId);
|
||||
matches.push({ artifact, image, cover });
|
||||
}
|
||||
return { ok: true, reason: null, matches, skippedArtifacts };
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
collectWechatGeneratedImages,
|
||||
extractMindspaceCoverPath,
|
||||
verifyFreshWechatPageThumbnails,
|
||||
} from './generated-thumbnail.mjs';
|
||||
|
||||
function generatedImageMessages({ jobId = 'job-1', htmlSrc = 'images/fresh.webp' } = {}) {
|
||||
const result = {
|
||||
ok: true,
|
||||
jobId,
|
||||
source: { mimeType: 'image/webp' },
|
||||
asset: {
|
||||
id: `asset-${jobId}`,
|
||||
htmlSrc,
|
||||
publicUrl: `https://example.com/MindSpace/user/public/${htmlSrc}`,
|
||||
workspaceRelativePath: `public/${htmlSrc}`,
|
||||
},
|
||||
};
|
||||
return [{
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
id: `call-${jobId}`,
|
||||
type: 'toolRequest',
|
||||
toolCall: { value: { name: 'sandbox-fs__generate_image', arguments: { purpose: 'hero' } } },
|
||||
},
|
||||
{
|
||||
id: `call-${jobId}`,
|
||||
type: 'toolResponse',
|
||||
toolResult: {
|
||||
status: 'success',
|
||||
value: { content: [{ type: 'text', text: JSON.stringify(result) }] },
|
||||
},
|
||||
},
|
||||
],
|
||||
}];
|
||||
}
|
||||
|
||||
test('fresh page thumbnail must reference a current-run generated image', () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'wechat-fresh-cover-'));
|
||||
try {
|
||||
const htmlPath = path.join(root, 'page.html');
|
||||
fs.writeFileSync(
|
||||
htmlPath,
|
||||
'<meta name="mindspace-cover" content=\'{"cover":"images/fresh.webp"}\'>',
|
||||
'utf8',
|
||||
);
|
||||
const images = collectWechatGeneratedImages(generatedImageMessages());
|
||||
assert.equal(images.length, 1);
|
||||
assert.equal(extractMindspaceCoverPath(fs.readFileSync(htmlPath, 'utf8')), 'images/fresh.webp');
|
||||
const result = verifyFreshWechatPageThumbnails(
|
||||
[{ localPath: htmlPath, relativePath: 'public/page.html' }],
|
||||
images,
|
||||
);
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.matches[0].image.jobId, 'job-1');
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('old cover path and sharing one generated image across two pages fail closed', () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'wechat-stale-cover-'));
|
||||
try {
|
||||
const first = path.join(root, 'one.html');
|
||||
const second = path.join(root, 'two.html');
|
||||
fs.writeFileSync(first, '<meta name="mindspace-cover" content=\'{"cover":"images/fresh.webp"}\'>');
|
||||
fs.writeFileSync(second, '<meta name="mindspace-cover" content=\'{"cover":"images/fresh.webp"}\'>');
|
||||
const images = collectWechatGeneratedImages(generatedImageMessages());
|
||||
const reused = verifyFreshWechatPageThumbnails(
|
||||
[
|
||||
{ localPath: first, relativePath: 'public/one.html' },
|
||||
{ localPath: second, relativePath: 'public/two.html' },
|
||||
],
|
||||
images,
|
||||
);
|
||||
assert.equal(reused.ok, false);
|
||||
assert.equal(reused.reason, 'cover_not_from_current_run');
|
||||
|
||||
fs.writeFileSync(first, '<meta name="mindspace-cover" content=\'{"cover":"images/old.webp"}\'>');
|
||||
const stale = verifyFreshWechatPageThumbnails(
|
||||
[{ localPath: first, relativePath: 'public/one.html' }],
|
||||
images,
|
||||
);
|
||||
assert.equal(stale.ok, false);
|
||||
assert.equal(stale.reason, 'cover_not_from_current_run');
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('auxiliary admin pages do not require a separate generated thumbnail', () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'wechat-aux-cover-'));
|
||||
try {
|
||||
const front = path.join(root, 'survey.html');
|
||||
const admin = path.join(root, 'survey-admin.html');
|
||||
fs.writeFileSync(front, '<meta name="mindspace-cover" content=\'{"cover":"images/fresh.webp"}\'>');
|
||||
fs.writeFileSync(admin, '<meta name="mindspace-page-role" content="auxiliary">');
|
||||
const result = verifyFreshWechatPageThumbnails(
|
||||
[
|
||||
{ localPath: front, relativePath: 'public/survey.html' },
|
||||
{ localPath: admin, relativePath: 'public/survey-admin.html' },
|
||||
],
|
||||
collectWechatGeneratedImages(generatedImageMessages()),
|
||||
);
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.matches.length, 1);
|
||||
assert.equal(result.skippedArtifacts.length, 1);
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user