260 lines
9.7 KiB
JavaScript
260 lines
9.7 KiB
JavaScript
import crypto from 'node:crypto';
|
|
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
import {
|
|
parseMindspaceCoverMeta,
|
|
upsertMindspaceCoverMeta,
|
|
} from '../../mindspace-cover-meta.mjs';
|
|
|
|
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),
|
|
purpose: String(result.purpose ?? '').trim() || null,
|
|
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,
|
|
cover: cover || null,
|
|
matches,
|
|
skippedArtifacts,
|
|
};
|
|
}
|
|
usedJobs.add(image.jobId);
|
|
matches.push({ artifact, image, cover });
|
|
}
|
|
return { ok: true, reason: null, matches, skippedArtifacts };
|
|
}
|
|
|
|
function uniqueArtifactsByLocalPath(artifacts = []) {
|
|
const unique = new Map();
|
|
for (const artifact of artifacts) {
|
|
const localPath = String(artifact?.localPath ?? '').trim();
|
|
if (localPath) unique.set(localPath, artifact);
|
|
}
|
|
return [...unique.values()];
|
|
}
|
|
|
|
function atomicWriteHtml(localPath, content) {
|
|
const tempPath = path.join(
|
|
path.dirname(localPath),
|
|
`.${path.basename(localPath)}.wechat-thumbnail-${process.pid}-${crypto.randomUUID()}.tmp`,
|
|
);
|
|
try {
|
|
fs.writeFileSync(tempPath, content, 'utf8');
|
|
fs.renameSync(tempPath, localPath);
|
|
} finally {
|
|
try {
|
|
if (fs.existsSync(tempPath)) fs.unlinkSync(tempPath);
|
|
} catch {
|
|
// Best-effort cleanup only; the destination write already decided the result.
|
|
}
|
|
}
|
|
}
|
|
|
|
export function repairUnambiguousFreshWechatPageThumbnail({
|
|
artifacts = [],
|
|
images = [],
|
|
currentRunHtmlArtifacts = [],
|
|
verificationReason = '',
|
|
} = {}) {
|
|
if (!['missing_generated_cover', 'cover_not_from_current_run'].includes(verificationReason)) {
|
|
return { ok: false, reason: 'verification_not_repairable' };
|
|
}
|
|
const eligibleArtifacts = uniqueArtifactsByLocalPath(
|
|
artifacts.filter((artifact) => !isWechatAuxiliaryPageArtifact(artifact)),
|
|
);
|
|
const pageImages = images.filter((image) => image?.purpose === 'hero' || !image?.purpose);
|
|
if (eligibleArtifacts.length !== 1 || pageImages.length !== 1) {
|
|
return {
|
|
ok: false,
|
|
reason: 'ambiguous_page_image_mapping',
|
|
artifactCount: eligibleArtifacts.length,
|
|
imageCount: pageImages.length,
|
|
};
|
|
}
|
|
|
|
const artifact = eligibleArtifacts[0];
|
|
const image = pageImages[0];
|
|
const cover = String(image?.htmlSrc ?? '').trim();
|
|
const localPath = String(artifact?.localPath ?? '').trim();
|
|
if (!cover || !localPath || !fs.existsSync(localPath)) {
|
|
return { ok: false, reason: 'repair_source_unavailable', artifact, image };
|
|
}
|
|
|
|
const normalizedRelativePath = String(artifact.relativePath ?? '').trim().replace(/\\/g, '/');
|
|
const currentRunArtifact = currentRunHtmlArtifacts.find(
|
|
(candidate) => String(candidate?.relativePath ?? '').trim().replace(/\\/g, '/') === normalizedRelativePath,
|
|
);
|
|
const sourceHtml = typeof currentRunArtifact?.content === 'string'
|
|
? currentRunArtifact.content
|
|
: fs.readFileSync(localPath, 'utf8');
|
|
const coverMeta = parseMindspaceCoverMeta(sourceHtml);
|
|
if (!coverMeta) {
|
|
return { ok: false, reason: 'valid_cover_meta_required', artifact, image };
|
|
}
|
|
|
|
const repairedHtml = upsertMindspaceCoverMeta(sourceHtml, { cover });
|
|
atomicWriteHtml(localPath, repairedHtml);
|
|
return {
|
|
ok: true,
|
|
reason: null,
|
|
artifact,
|
|
image,
|
|
cover,
|
|
restoredCurrentRunHtml: Boolean(currentRunArtifact),
|
|
};
|
|
}
|
|
|
|
export function summarizeFreshWechatThumbnailVerification(verification, images = []) {
|
|
return {
|
|
reason: String(verification?.reason ?? 'unknown'),
|
|
artifact: verification?.artifact
|
|
? {
|
|
relativePath: String(verification.artifact.relativePath ?? ''),
|
|
localPath: path.basename(String(verification.artifact.localPath ?? '')),
|
|
}
|
|
: null,
|
|
cover: String(verification?.cover ?? ''),
|
|
matches: Array.isArray(verification?.matches) ? verification.matches.length : 0,
|
|
images: images.map((image) => ({
|
|
jobId: String(image?.jobId ?? ''),
|
|
purpose: String(image?.purpose ?? ''),
|
|
htmlSrc: String(image?.htmlSrc ?? ''),
|
|
workspaceRelativePath: String(image?.workspaceRelativePath ?? ''),
|
|
})),
|
|
};
|
|
}
|