2026-07-19 18:34:50 +08:00
|
|
|
|
import crypto from 'node:crypto';
|
2026-08-03 14:58:38 +08:00
|
|
|
|
import { IMAGE_MAKE_PURPOSE_CATALOG } from './asset-gateway.mjs';
|
2026-07-19 18:34:50 +08:00
|
|
|
|
|
|
|
|
|
|
const PURPOSES = {
|
2026-07-20 20:04:44 +08:00
|
|
|
|
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' },
|
2026-07-19 18:34:50 +08:00
|
|
|
|
};
|
|
|
|
|
|
|
2026-07-20 20:04:44 +08:00
|
|
|
|
function htmlSrcForWorkspaceAsset(workspaceRelativePath) {
|
|
|
|
|
|
const normalized = String(workspaceRelativePath ?? '').trim().replace(/\\/g, '/').replace(/^\/+/, '');
|
|
|
|
|
|
if (!normalized.startsWith('public/')) return null;
|
|
|
|
|
|
return normalized.slice('public/'.length) || null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-22 11:51:15 +08:00
|
|
|
|
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}`);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-20 20:04:44 +08:00
|
|
|
|
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 };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-19 18:34:50 +08:00
|
|
|
|
export function createMindSpaceImageGenerationService({
|
|
|
|
|
|
configService,
|
|
|
|
|
|
assetService,
|
|
|
|
|
|
imageMakeClient,
|
2026-07-20 20:04:44 +08:00
|
|
|
|
imageReviewService,
|
2026-08-03 14:58:38 +08:00
|
|
|
|
subscriptionService = null,
|
2026-07-20 20:04:44 +08:00
|
|
|
|
env = process.env,
|
2026-07-19 18:34:50 +08:00
|
|
|
|
logger = console,
|
|
|
|
|
|
} = {}) {
|
2026-08-03 14:58:38 +08:00
|
|
|
|
function shouldBillImageGeneration(purpose) {
|
|
|
|
|
|
const purposeConfig = IMAGE_MAKE_PURPOSE_CATALOG.find((item) => item.id === purpose);
|
|
|
|
|
|
return purposeConfig?.strategy !== 'derive';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-19 18:34:50 +08:00
|
|
|
|
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 };
|
2026-08-03 14:58:38 +08:00
|
|
|
|
if (shouldBillImageGeneration(purpose) && subscriptionService?.checkImageQuota) {
|
|
|
|
|
|
const quotaCheck = await subscriptionService.checkImageQuota(userId, 1);
|
|
|
|
|
|
if (!quotaCheck.ok) {
|
|
|
|
|
|
return {
|
|
|
|
|
|
ok: false,
|
|
|
|
|
|
fallback: false,
|
|
|
|
|
|
code: quotaCheck.code ?? 'image_quota_exceeded',
|
|
|
|
|
|
message: quotaCheck.message ?? '图片生成额度不足',
|
|
|
|
|
|
quota: quotaCheck.quota ?? null,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-07-19 18:34:50 +08:00
|
|
|
|
if (!imageMakeClient || !assetService?.createChatAsset) {
|
|
|
|
|
|
return { ok: false, fallback: true, code: 'runtime_unavailable', message: '图片生成服务未配置' };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
2026-07-20 20:04:44 +08:00
|
|
|
|
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;
|
2026-07-22 11:51:15 +08:00
|
|
|
|
const baseIdempotencyKey = resolveImageMakeIdempotencyKey({
|
|
|
|
|
|
idempotencyKey,
|
|
|
|
|
|
purpose,
|
|
|
|
|
|
prompt,
|
|
|
|
|
|
negativePrompt,
|
|
|
|
|
|
});
|
2026-07-20 20:04:44 +08:00
|
|
|
|
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
|
2026-07-22 11:51:15 +08:00
|
|
|
|
: boundedIdempotencyKey(baseIdempotencyKey, `-review-${attempt}`);
|
2026-07-20 20:04:44 +08:00
|
|
|
|
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, {
|
2026-07-19 18:34:50 +08:00
|
|
|
|
categoryCode: 'public',
|
|
|
|
|
|
buffer: generated.buffer,
|
|
|
|
|
|
filename: spec.filename,
|
|
|
|
|
|
displayName: spec.filename,
|
|
|
|
|
|
sourceType: 'image_make',
|
|
|
|
|
|
});
|
2026-07-20 20:04:44 +08:00
|
|
|
|
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 };
|
2026-07-19 18:34:50 +08:00
|
|
|
|
await imageMakeClient.acknowledge(generated.jobId, asset.id).catch((error) => {
|
|
|
|
|
|
logger.warn?.('[image_make] acknowledge failed; TTL cleanup will apply:', error?.message ?? error);
|
|
|
|
|
|
});
|
2026-08-03 14:58:38 +08:00
|
|
|
|
if (shouldBillImageGeneration(purpose) && subscriptionService?.consumeImageQuota) {
|
|
|
|
|
|
const consumed = await subscriptionService.consumeImageQuota(userId, 1, null, {
|
|
|
|
|
|
refId: generated.jobId,
|
|
|
|
|
|
note: `image_make:${purpose}`,
|
|
|
|
|
|
});
|
|
|
|
|
|
if (!consumed?.fullyCovers) {
|
|
|
|
|
|
logger.warn?.('[image_make] quota consume failed after successful generation', {
|
|
|
|
|
|
userId,
|
|
|
|
|
|
jobId: generated.jobId,
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-07-19 18:34:50 +08:00
|
|
|
|
return {
|
|
|
|
|
|
ok: true,
|
|
|
|
|
|
purpose,
|
|
|
|
|
|
jobId: generated.jobId,
|
|
|
|
|
|
asset,
|
|
|
|
|
|
source: {
|
|
|
|
|
|
mimeType: generated.mimeType,
|
|
|
|
|
|
width: generated.width,
|
|
|
|
|
|
height: generated.height,
|
|
|
|
|
|
sha256: generated.sha256,
|
|
|
|
|
|
},
|
2026-07-20 20:04:44 +08:00
|
|
|
|
review: {
|
|
|
|
|
|
relevanceScore: acceptedReview.relevanceScore,
|
|
|
|
|
|
minimumScore: acceptedReview.minimumScore,
|
|
|
|
|
|
verdict: acceptedReview.verdict ?? 'pass',
|
|
|
|
|
|
},
|
2026-07-19 18:34:50 +08:00
|
|
|
|
};
|
|
|
|
|
|
} 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',
|
2026-07-20 20:04:44 +08:00
|
|
|
|
message: error?.code === 'IMAGE_SEMANTIC_MISMATCH'
|
|
|
|
|
|
? '生成图片与页面内容不符,已阻止使用'
|
|
|
|
|
|
: '图片生成暂不可用,已保留原有流程',
|
2026-07-19 18:34:50 +08:00
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return { purposes: Object.keys(PURPOSES), generate };
|
|
|
|
|
|
}
|