Files
memind/mindspace-image-generation.mjs
john cb62b36cd5
Memind CI / Test, build, and release guards (pull_request) Successful in 5m1s
fix: recover WeChat page thumbnail delivery
2026-07-22 11:51:48 +08:00

197 lines
7.4 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import crypto from 'node:crypto';
const PURPOSES = {
inline_image: { presetId: 'memind_square_illustration', filename: 'inline-image.webp', envPrefix: 'INLINE_IMAGE' },
hero: { presetId: 'memind_dark_hero', filename: 'hero.webp', envPrefix: 'HERO' },
card_cover: { presetId: 'memind_card_cover', filename: 'card-cover.webp', envPrefix: 'CARD_COVER' },
feed_cover: { presetId: 'memind_feed_cover_source', filename: 'feed-cover.webp', envPrefix: 'FEED_COVER' },
};
function htmlSrcForWorkspaceAsset(workspaceRelativePath) {
const normalized = String(workspaceRelativePath ?? '').trim().replace(/\\/g, '/').replace(/^\/+/, '');
if (!normalized.startsWith('public/')) return null;
return normalized.slice('public/'.length) || null;
}
const IMAGE_MAKE_IDEMPOTENCY_KEY_MAX_LENGTH = 128;
const WECHAT_PAGE_THUMBNAIL_KEY_PATTERN = /^wechat-[a-zA-Z0-9._-]+-page-\d+-thumbnail$/;
function boundedIdempotencyKey(base, suffix = '') {
const normalizedBase = String(base ?? '').trim();
const normalizedSuffix = String(suffix ?? '');
return `${normalizedBase.slice(0, Math.max(0, IMAGE_MAKE_IDEMPOTENCY_KEY_MAX_LENGTH - normalizedSuffix.length))}${normalizedSuffix}`;
}
function resolveImageMakeIdempotencyKey({ idempotencyKey, purpose, prompt, negativePrompt }) {
const normalized = String(idempotencyKey ?? '').trim();
if (!normalized) return `imgreq_${crypto.randomUUID()}`;
if (purpose !== 'hero' || !WECHAT_PAGE_THUMBNAIL_KEY_PATTERN.test(normalized)) {
return normalized;
}
const promptHash = crypto
.createHash('sha256')
.update([purpose, String(prompt ?? '').trim(), String(negativePrompt ?? '').trim()].join('\0'))
.digest('hex')
.slice(0, 12);
return boundedIdempotencyKey(normalized, `-p-${promptHash}`);
}
function resolvePurposeDimensions(spec, env, logger) {
const widthKey = `IMAGE_MAKE_${spec.envPrefix}_WIDTH`;
const heightKey = `IMAGE_MAKE_${spec.envPrefix}_HEIGHT`;
const rawWidth = String(env?.[widthKey] ?? '').trim();
const rawHeight = String(env?.[heightKey] ?? '').trim();
if (!rawWidth && !rawHeight) return {};
const width = Number(rawWidth);
const height = Number(rawHeight);
const valid = Number.isInteger(width) && width >= 64 && width <= 4096
&& Number.isInteger(height) && height >= 64 && height <= 4096;
if (!valid) {
logger.warn?.(`[image_make] ignoring invalid ${widthKey}/${heightKey} override`);
return {};
}
return { width, height };
}
export function createMindSpaceImageGenerationService({
configService,
assetService,
imageMakeClient,
imageReviewService,
env = process.env,
logger = console,
} = {}) {
async function generate({
userId,
purpose,
prompt,
negativePrompt = '',
consumerRef = '',
idempotencyKey,
} = {}) {
const spec = PURPOSES[purpose];
if (!spec) {
return { ok: false, fallback: true, code: 'unsupported_purpose', message: '不支持的图片生成用途' };
}
if (!configService?.resolveImageGenerationPurpose) {
return { ok: false, fallback: true, code: 'gateway_unavailable', message: '图片生成能力未启用' };
}
const gate = await configService.resolveImageGenerationPurpose(purpose);
if (!gate.ok) return { ...gate, fallback: true };
if (!imageMakeClient || !assetService?.createChatAsset) {
return { ok: false, fallback: true, code: 'runtime_unavailable', message: '图片生成服务未配置' };
}
try {
const dimensions = resolvePurposeDimensions(spec, env, logger);
const configuredAttempts = Number(env.IMAGE_MAKE_SEMANTIC_REVIEW_MAX_ATTEMPTS ?? 3);
const maxAttempts = Number.isFinite(configuredAttempts)
? Math.max(1, Math.min(3, Math.floor(configuredAttempts)))
: 3;
const baseIdempotencyKey = resolveImageMakeIdempotencyKey({
idempotencyKey,
purpose,
prompt,
negativePrompt,
});
let generated = null;
let acceptedReview = null;
let retryFeedback = '';
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
const attemptPrompt = retryFeedback
? `${String(prompt).slice(0, 1100)}\n\n必须修正上次错误:${retryFeedback.slice(0, 400)}。画面必须以原始要求的主体和场景为核心。`
: prompt;
const attemptKey = attempt === 1
? baseIdempotencyKey
: boundedIdempotencyKey(baseIdempotencyKey, `-review-${attempt}`);
generated = await imageMakeClient.generateImage({
prompt: attemptPrompt,
negativePrompt,
presetId: gate.presetId ?? spec.presetId,
...dimensions,
consumerRef,
idempotencyKey: attemptKey,
});
const review = await imageReviewService?.review?.({
buffer: generated.buffer,
mimeType: generated.mimeType,
purpose,
prompt,
});
if (!review?.ok) {
throw Object.assign(new Error('图片语义审核暂不可用'), {
code: review?.code ?? 'IMAGE_REVIEW_UNAVAILABLE',
});
}
if (review.pass) {
acceptedReview = review;
break;
}
retryFeedback = [
review.reason,
review.observedSubjects?.length ? `错误主体:${review.observedSubjects.join('、')}` : '',
review.missingRequiredElements?.length
? `缺失元素:${review.missingRequiredElements.join('、')}`
: '',
].filter(Boolean).join('');
logger.warn?.(
`[image-review] rejected ${purpose} attempt ${attempt}/${maxAttempts}: ${review.relevanceScore}`,
);
}
if (!generated || !acceptedReview) {
throw Object.assign(new Error('生成图片与页面内容不符'), {
code: 'IMAGE_SEMANTIC_MISMATCH',
});
}
const storedAsset = await assetService.createChatAsset(userId, {
categoryCode: 'public',
buffer: generated.buffer,
filename: spec.filename,
displayName: spec.filename,
sourceType: 'image_make',
});
const htmlSrc = htmlSrcForWorkspaceAsset(storedAsset?.workspaceRelativePath);
if (!htmlSrc) {
throw Object.assign(new Error('generated asset has no public HTML source path'), {
code: 'IMAGE_ASSET_PATH_UNAVAILABLE',
});
}
const asset = { ...storedAsset, htmlSrc };
await imageMakeClient.acknowledge(generated.jobId, asset.id).catch((error) => {
logger.warn?.('[image_make] acknowledge failed; TTL cleanup will apply:', error?.message ?? error);
});
return {
ok: true,
purpose,
jobId: generated.jobId,
asset,
source: {
mimeType: generated.mimeType,
width: generated.width,
height: generated.height,
sha256: generated.sha256,
},
review: {
relevanceScore: acceptedReview.relevanceScore,
minimumScore: acceptedReview.minimumScore,
verdict: acceptedReview.verdict ?? 'pass',
},
};
} catch (error) {
logger.warn?.('[image_make] generation failed; existing flow remains active:', error?.message ?? error);
return {
ok: false,
fallback: true,
code: error?.code ?? 'image_make_unavailable',
message: error?.code === 'IMAGE_SEMANTIC_MISMATCH'
? '生成图片与页面内容不符,已阻止使用'
: '图片生成暂不可用,已保留原有流程',
};
}
}
return { purposes: Object.keys(PURPOSES), generate };
}