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,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