Files
memind/mindspace-image-generation.mjs
T
john 946d8756c8
Memind CI / Test, build, and release guards (push) Failing after 3s
feat(billing): enforce image generation quota with admin API and user display
Add period_images_bonus and ledger tracking, gate image_make on remaining quota,
expose admin image-quota routes, show remaining image quota in the balance popover,
and document that admin UI must live in memind_adm (5174) not ops.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 14:58:38 +08:00

228 lines
8.6 KiB
JavaScript
Raw 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';
import { IMAGE_MAKE_PURPOSE_CATALOG } from './asset-gateway.mjs';
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,
subscriptionService = null,
env = process.env,
logger = console,
} = {}) {
function shouldBillImageGeneration(purpose) {
const purposeConfig = IMAGE_MAKE_PURPOSE_CATALOG.find((item) => item.id === purpose);
return purposeConfig?.strategy !== 'derive';
}
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 (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,
};
}
}
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);
});
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,
});
}
}
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 };
}