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 }; }