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