feat(image): complete reviewed generation delivery
Memind CI / Test, build, and release guards (pull_request) Successful in 2m47s

This commit is contained in:
john
2026-07-20 20:04:44 +08:00
parent fc3d27aae0
commit 4b15610aa8
42 changed files with 1764 additions and 97 deletions
+102 -13
View File
@@ -1,16 +1,41 @@
import crypto from 'node:crypto';
const PURPOSES = {
inline_image: { presetId: 'memind_square_illustration', filename: 'inline-image.webp' },
hero: { presetId: 'memind_dark_hero', filename: 'hero.webp' },
card_cover: { presetId: 'memind_card_cover', filename: 'card-cover.webp' },
feed_cover: { presetId: 'memind_feed_cover_source', filename: 'feed-cover.webp' },
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;
}
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({
@@ -35,20 +60,77 @@ export function createMindSpaceImageGenerationService({
}
try {
const generated = await imageMakeClient.generateImage({
prompt,
negativePrompt,
presetId: gate.presetId ?? spec.presetId,
consumerRef,
idempotencyKey: idempotencyKey || `imgreq_${crypto.randomUUID()}`,
});
const asset = await assetService.createChatAsset(userId, {
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 = idempotencyKey || `imgreq_${crypto.randomUUID()}`;
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
: `${baseIdempotencyKey.slice(0, 160)}-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);
});
@@ -63,6 +145,11 @@ export function createMindSpaceImageGenerationService({
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);
@@ -70,7 +157,9 @@ export function createMindSpaceImageGenerationService({
ok: false,
fallback: true,
code: error?.code ?? 'image_make_unavailable',
message: '图片生成暂不可用,已保留原有流程',
message: error?.code === 'IMAGE_SEMANTIC_MISMATCH'
? '生成图片与页面内容不符,已阻止使用'
: '图片生成暂不可用,已保留原有流程',
};
}
}