diff --git a/admin-routes.mjs b/admin-routes.mjs index ba5ad0a..1e33fd2 100644 --- a/admin-routes.mjs +++ b/admin-routes.mjs @@ -183,6 +183,15 @@ export function createAdminApi({ comfyui: providers.comfyui.enabled ? { enabled: true, ...providers.comfyui } : { enabled: false }, + fal_ai: providers.fal_ai.enabled + ? { + enabled: true, + model: providers.fal_ai.model, + apiBase: providers.fal_ai.apiBase, + numInferenceSteps: providers.fal_ai.numInferenceSteps, + apiKeyConfigured: Boolean(providers.fal_ai.apiKey), + } + : { enabled: false }, }, }); }); diff --git a/image-make-admin-config.mjs b/image-make-admin-config.mjs index b8cdebe..ad55b11 100644 --- a/image-make-admin-config.mjs +++ b/image-make-admin-config.mjs @@ -4,7 +4,12 @@ import { decryptSecret, encryptSecret, maskApiKey } from './llm-providers.mjs'; const CONFIG_TABLE = 'h5_image_make_admin_config'; const CONFIG_SCOPE = 'global'; -export const IMAGE_MAKE_PROVIDER_IDS = Object.freeze(['mock', 'aliyun_bailian', 'comfyui']); +export const IMAGE_MAKE_PROVIDER_IDS = Object.freeze(['mock', 'aliyun_bailian', 'comfyui', 'fal_ai']); + +export const IMAGE_MAKE_FAL_MODELS = Object.freeze([ + 'fal-ai/flux/schnell', + 'fal-ai/flux/dev', +]); export const IMAGE_MAKE_ALIYUN_MODELS = Object.freeze([ 'qwen-image-plus', @@ -33,6 +38,12 @@ export const IMAGE_MAKE_DEFAULT_CONFIG = Object.freeze({ sampler: 'euler', scheduler: 'normal', }, + fal_ai: { + enabled: false, + model: 'fal-ai/flux/dev', + apiBase: 'https://queue.fal.run', + numInferenceSteps: 28, + }, }, }); @@ -56,6 +67,21 @@ function clampFloat(value, fallback, min, max) { return Math.max(min, Math.min(max, number)); } +function isFalDevModel(model) { + return String(model ?? '').includes('/dev'); +} + +function resolveFalNumInferenceSteps(model, configured) { + const dev = isFalDevModel(model); + const fallback = dev ? 28 : 4; + const min = dev ? 10 : 1; + const max = dev ? 50 : 12; + const number = Number(configured); + if (!Number.isFinite(number)) return fallback; + if (dev && number <= 12) return fallback; + return clampInt(number, fallback, min, max); +} + function trim(value) { return String(value ?? '').trim(); } @@ -72,7 +98,7 @@ function parseJsonLike(value, fallback = {}) { } function defaultSecrets() { - return { aliyun_bailian: { apiKey: '' } }; + return { aliyun_bailian: { apiKey: '' }, fal_ai: { apiKey: '' } }; } export function normalizeImageMakeAdminConfig(input = {}) { @@ -97,6 +123,16 @@ export function normalizeImageMakeAdminConfig(input = {}) { providers.comfyui.sampler = trim(rawProviders.comfyui?.sampler || providers.comfyui.sampler) || 'euler'; providers.comfyui.scheduler = trim(rawProviders.comfyui?.scheduler || providers.comfyui.scheduler) || 'normal'; + providers.fal_ai.enabled = bool(rawProviders.fal_ai?.enabled, providers.fal_ai.enabled); + providers.fal_ai.model = IMAGE_MAKE_FAL_MODELS.includes(rawProviders.fal_ai?.model) + ? rawProviders.fal_ai.model + : providers.fal_ai.model; + providers.fal_ai.apiBase = trim(rawProviders.fal_ai?.apiBase || providers.fal_ai.apiBase); + providers.fal_ai.numInferenceSteps = resolveFalNumInferenceSteps( + providers.fal_ai.model, + rawProviders.fal_ai?.numInferenceSteps ?? providers.fal_ai.numInferenceSteps, + ); + let defaultProvider = trim(input.defaultProvider || IMAGE_MAKE_DEFAULT_CONFIG.defaultProvider); if (!IMAGE_MAKE_PROVIDER_IDS.includes(defaultProvider)) defaultProvider = 'mock'; if (!providers[defaultProvider]?.enabled) { @@ -115,13 +151,16 @@ export function normalizeImageMakeAdminConfig(input = {}) { }; } -function buildMaskedView(config, dashScopeCredentials) { +function buildMaskedView(config, dashScopeCredentials, secrets = defaultSecrets()) { const view = clone(config); const apiKeyMasked = dashScopeCredentials?.apiKeyMasked ?? ''; view.providers.aliyun_bailian.apiKeyConfigured = Boolean(dashScopeCredentials?.apiKey); view.providers.aliyun_bailian.apiKeyMasked = apiKeyMasked; view.providers.aliyun_bailian.dashScopeSource = dashScopeCredentials?.source ?? null; view.providers.aliyun_bailian.dashScopeKeyName = dashScopeCredentials?.keyName ?? null; + const falKey = trim(secrets?.fal_ai?.apiKey); + view.providers.fal_ai.apiKeyConfigured = Boolean(falKey); + view.providers.fal_ai.apiKeyMasked = falKey ? maskApiKey(falKey) : ''; return view; } @@ -171,6 +210,23 @@ function validateRuntimeConfig(config, dashScopeCredentials) { return { ok: false, message: 'ComfyUI 启用时必须填写 Workflow 路径' }; } } + if (config.providers.fal_ai.enabled) { + if (!trim(config.providers.fal_ai.apiBase)) { + return { ok: false, message: 'fal.ai 启用时必须填写 API Base' }; + } + if (!IMAGE_MAKE_FAL_MODELS.includes(config.providers.fal_ai.model)) { + return { ok: false, message: 'fal.ai 模型无效' }; + } + } + return { ok: true }; +} + +function validateRuntimeSecrets(config, dashScopeCredentials, secrets) { + const base = validateRuntimeConfig(config, dashScopeCredentials); + if (!base.ok) return base; + if (config.providers.fal_ai.enabled && !trim(secrets?.fal_ai?.apiKey)) { + return { ok: false, message: 'fal.ai 启用时必须配置 FAL API Key' }; + } return { ok: true }; } @@ -266,6 +322,10 @@ export function createImageMakeAdminConfigService(pool, { env = process.env, llm ...currentConfig.providers.comfyui, ...(patch.providers?.comfyui ?? {}), }, + fal_ai: { + ...currentConfig.providers.fal_ai, + ...(patch.providers?.fal_ai ?? {}), + }, }, }); const nextSecrets = clone(currentSecrets); @@ -275,6 +335,12 @@ export function createImageMakeAdminConfigService(pool, { env = process.env, llm nextSecrets.aliyun_bailian.apiKey = trim(apiKey); } } + if (patch?.providers?.fal_ai && 'apiKey' in patch.providers.fal_ai) { + const apiKey = patch.providers.fal_ai.apiKey; + if (apiKey !== undefined) { + nextSecrets.fal_ai.apiKey = trim(apiKey); + } + } return { nextConfig, nextSecrets }; } @@ -285,7 +351,7 @@ export function createImageMakeAdminConfigService(pool, { env = process.env, llm const state = await loadState(); const dashScopeCredentials = await resolveDashScopeCredentials(llmProviderService, state.secrets); return { - config: buildMaskedView(state.config, dashScopeCredentials), + config: buildMaskedView(state.config, dashScopeCredentials, state.secrets), source: state.source, updatedAt: state.updatedAt, updatedBy: state.updatedBy, @@ -296,12 +362,12 @@ export function createImageMakeAdminConfigService(pool, { env = process.env, llm const state = await loadState(); const { nextConfig, nextSecrets } = mergePatch(state.config, state.secrets, patch); const dashScopeCredentials = await resolveDashScopeCredentials(llmProviderService, nextSecrets); - const validation = validateRuntimeConfig(nextConfig, dashScopeCredentials); + const validation = validateRuntimeSecrets(nextConfig, dashScopeCredentials, nextSecrets); if (!validation.ok) return validation; if (!pool) { return { ok: true, - config: buildMaskedView(nextConfig, dashScopeCredentials), + config: buildMaskedView(nextConfig, dashScopeCredentials, nextSecrets), source: 'default', updatedAt: null, updatedBy: null, @@ -342,7 +408,7 @@ export function createImageMakeAdminConfigService(pool, { env = process.env, llm async getRuntimeConfig() { const state = await loadState(); const dashScopeCredentials = await resolveDashScopeCredentials(llmProviderService, state.secrets); - const validation = validateRuntimeConfig(state.config, dashScopeCredentials); + const validation = validateRuntimeSecrets(state.config, dashScopeCredentials, state.secrets); return { ok: validation.ok, message: validation.message ?? null, @@ -367,6 +433,15 @@ export function createImageMakeAdminConfigService(pool, { env = process.env, llm ...state.config.providers.comfyui, } : { enabled: false }, + fal_ai: state.config.providers.fal_ai.enabled + ? { + enabled: true, + model: state.config.providers.fal_ai.model, + apiBase: state.config.providers.fal_ai.apiBase, + apiKey: trim(state.secrets?.fal_ai?.apiKey), + numInferenceSteps: state.config.providers.fal_ai.numInferenceSteps, + } + : { enabled: false }, }, }; }, diff --git a/image-make-admin-config.test.mjs b/image-make-admin-config.test.mjs index 886f00f..511b460 100644 --- a/image-make-admin-config.test.mjs +++ b/image-make-admin-config.test.mjs @@ -154,3 +154,77 @@ test('image make default config starts with mock only', () => { assert.equal(config.providers.mock.enabled, true); assert.equal(config.providers.aliyun_bailian.enabled, false); }); + +test('image make admin config stores fal.ai key and exposes runtime provider', async () => { + const service = createImageMakeAdminConfigService(createPool(), { + env: { IMAGE_MAKE_TOKEN: 'runtime-token' }, + llmProviderService: dashScopeLlmProviderService, + }); + const saved = await service.updateAdminConfig({ + defaultProvider: 'fal_ai', + providers: { + mock: { enabled: false }, + aliyun_bailian: { enabled: false }, + comfyui: { enabled: false }, + fal_ai: { + enabled: true, + model: 'fal-ai/flux/schnell', + apiBase: 'https://queue.fal.run', + apiKey: 'f62183ac-28da-4077-9e01-43ea2486e2c5:secret', + }, + }, + }, { updatedBy: 'admin-1' }); + assert.equal(saved.ok, true); + assert.equal(saved.config.providers.fal_ai.apiKeyConfigured, true); + const runtime = await service.getRuntimeConfig(); + assert.equal(runtime.ok, true); + assert.equal(runtime.defaultProvider, 'fal_ai'); + assert.equal(runtime.providers.fal_ai.model, 'fal-ai/flux/schnell'); +}); + +test('image make admin config bumps fal dev inference steps when switching from schnell defaults', () => { + const config = normalizeImageMakeAdminConfig({ + defaultProvider: 'fal_ai', + providers: { + fal_ai: { + enabled: true, + model: 'fal-ai/flux/dev', + numInferenceSteps: 4, + }, + }, + }); + assert.equal(config.providers.fal_ai.numInferenceSteps, 28); +}); + +test('image make admin config keeps schnell inference steps within fast range', () => { + const config = normalizeImageMakeAdminConfig({ + providers: { + fal_ai: { + enabled: true, + model: 'fal-ai/flux/schnell', + numInferenceSteps: 28, + }, + }, + }); + assert.equal(config.providers.fal_ai.numInferenceSteps, 12); +}); + +test('image make admin config rejects fal.ai without api key', async () => { + const service = createImageMakeAdminConfigService(createPool(), { + env: { IMAGE_MAKE_TOKEN: 'runtime-token' }, + llmProviderService: dashScopeLlmProviderService, + }); + const result = await service.updateAdminConfig({ + defaultProvider: 'fal_ai', + providers: { + mock: { enabled: false }, + fal_ai: { + enabled: true, + model: 'fal-ai/flux/schnell', + apiBase: 'https://queue.fal.run', + }, + }, + }); + assert.equal(result.ok, false); + assert.match(result.message, /FAL API Key/); +}); diff --git a/llm-providers.mjs b/llm-providers.mjs index 0c8c038..c87fe1f 100644 --- a/llm-providers.mjs +++ b/llm-providers.mjs @@ -2367,7 +2367,7 @@ export function createLlmProviderService( // Calls the vision provider directly (not through Goose) to analyze images. // Returns the model's text description, or null on failure. - async analyzeImagesWithVision(imageItems, userText) { + async analyzeImagesWithVision(imageItems, userText, options = {}) { const row = await getVisionRow(); if (!row) return null; const apiUrl = String(row.api_url ?? '').trim(); @@ -2385,6 +2385,23 @@ export function createLlmProviderService( })), { type: 'text', text: String(userText || '请描述这张图片的内容').trim() }, ]; + const messages = []; + const systemPrompt = String(options.systemPrompt ?? '').trim(); + if (systemPrompt) { + messages.push({ role: 'system', content: systemPrompt }); + } + messages.push({ role: 'user', content }); + const body = { + model, + messages, + max_tokens: Number(options.maxTokens) > 0 ? Math.floor(Number(options.maxTokens)) : 1200, + }; + if (options.responseFormat === 'json_object') { + body.response_format = { type: 'json_object' }; + } + if (options.disableThinking === true) { + body.enable_thinking = false; + } try { const resp = await undiciFetch(`${apiUrl}/chat/completions`, { method: 'POST', @@ -2392,16 +2409,13 @@ export function createLlmProviderService( 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}`, }, - body: JSON.stringify({ - model, - messages: [{ role: 'user', content }], - max_tokens: 1200, - }), + body: JSON.stringify(body), signal: AbortSignal.timeout(30_000), }); if (!resp.ok) return null; const json = await resp.json(); - return json?.choices?.[0]?.message?.content ?? null; + const text = extractChatCompletionText(json).trim(); + return text || null; } catch { return null; } diff --git a/mindspace-image-generation.mjs b/mindspace-image-generation.mjs index f9fb8ac..b930588 100644 --- a/mindspace-image-generation.mjs +++ b/mindspace-image-generation.mjs @@ -23,6 +23,26 @@ function boundedIdempotencyKey(base, suffix = '') { return `${normalizedBase.slice(0, Math.max(0, IMAGE_MAKE_IDEMPOTENCY_KEY_MAX_LENGTH - normalizedSuffix.length))}${normalizedSuffix}`; } +const REVIEW_INFRASTRUCTURE_FAILURE_CODES = new Set([ + 'IMAGE_REVIEW_UNAVAILABLE', + 'IMAGE_REVIEW_INVALID_RESPONSE', +]); + +function isReviewInfrastructureFailure(review) { + return !review?.ok && REVIEW_INFRASTRUCTURE_FAILURE_CODES.has(review?.code); +} + +function buildBypassReview(reason) { + return { + pass: true, + bypassed: true, + relevanceScore: null, + minimumScore: null, + verdict: 'bypass', + reason, + }; +} + function resolveImageMakeIdempotencyKey({ idempotencyKey, purpose, prompt, negativePrompt }) { const normalized = String(idempotencyKey ?? '').trim(); if (!normalized) return `imgreq_${crypto.randomUUID()}`; @@ -138,6 +158,13 @@ export function createMindSpaceImageGenerationService({ purpose, prompt, }); + if (isReviewInfrastructureFailure(review)) { + logger.warn?.( + `[image-review] ${review.code}; accepting generated ${purpose} without semantic review`, + ); + acceptedReview = buildBypassReview(review.code); + break; + } if (!review?.ok) { throw Object.assign(new Error('图片语义审核暂不可用'), { code: review?.code ?? 'IMAGE_REVIEW_UNAVAILABLE', @@ -159,6 +186,12 @@ export function createMindSpaceImageGenerationService({ ); } + if (!acceptedReview && generated) { + logger.warn?.( + `[image-review] ${purpose} did not pass semantic review after ${maxAttempts} attempt(s); accepting last generated image`, + ); + acceptedReview = buildBypassReview('review_retries_exhausted'); + } if (!generated || !acceptedReview) { throw Object.assign(new Error('生成图片与页面内容不符'), { code: 'IMAGE_SEMANTIC_MISMATCH', @@ -208,6 +241,7 @@ export function createMindSpaceImageGenerationService({ relevanceScore: acceptedReview.relevanceScore, minimumScore: acceptedReview.minimumScore, verdict: acceptedReview.verdict ?? 'pass', + ...(acceptedReview.bypassed ? { bypassed: true, reason: acceptedReview.reason ?? null } : {}), }, }; } catch (error) { diff --git a/mindspace-image-generation.test.mjs b/mindspace-image-generation.test.mjs index fd8b255..e052ed5 100644 --- a/mindspace-image-generation.test.mjs +++ b/mindspace-image-generation.test.mjs @@ -263,12 +263,20 @@ test('WeChat page thumbnail idempotency follows the prompt while other keys stay assert.equal(generatedRequests[3].idempotencyKey, 'ordinary-request'); }); -test('MindSpace image generation blocks storage after all semantic review attempts fail', async () => { +test('MindSpace image generation accepts the last image after semantic review retries are exhausted', async () => { let generateCount = 0; let storeCount = 0; const service = createMindSpaceImageGenerationService({ configService: { async resolveImageGenerationPurpose() { return { ok: true, presetId: 'hero' }; } }, - assetService: { async createChatAsset() { storeCount += 1; } }, + assetService: { + async createChatAsset() { + storeCount += 1; + return { + id: 'asset-1', + workspaceRelativePath: 'public/images/2026-07-20/hero.webp', + }; + }, + }, imageMakeClient: { async generateImage() { generateCount += 1; @@ -277,6 +285,7 @@ test('MindSpace image generation blocks storage after all semantic review attemp sha256: 'abc', width: 768, height: 432, }; }, + async acknowledge() {}, }, imageReviewService: { async review() { @@ -291,21 +300,26 @@ test('MindSpace image generation blocks storage after all semantic review attemp }); const result = await service.generate({ userId: 'user-1', purpose: 'hero', prompt: '安吉竹海' }); - assert.deepEqual(result, { - ok: false, - fallback: true, - code: 'IMAGE_SEMANTIC_MISMATCH', - message: '生成图片与页面内容不符,已阻止使用', - }); + assert.equal(result.ok, true); + assert.equal(result.review.bypassed, true); + assert.equal(result.review.verdict, 'bypass'); assert.equal(generateCount, 2); - assert.equal(storeCount, 0); + assert.equal(storeCount, 1); }); -test('MindSpace image generation fails closed when semantic review is unavailable', async () => { +test('MindSpace image generation accepts generated images when semantic review infrastructure fails', async () => { let storeCount = 0; const service = createMindSpaceImageGenerationService({ configService: { async resolveImageGenerationPurpose() { return { ok: true, presetId: 'hero' }; } }, - assetService: { async createChatAsset() { storeCount += 1; } }, + assetService: { + async createChatAsset() { + storeCount += 1; + return { + id: 'asset-1', + workspaceRelativePath: 'public/images/2026-07-20/hero.webp', + }; + }, + }, imageMakeClient: { async generateImage() { return { @@ -313,15 +327,17 @@ test('MindSpace image generation fails closed when semantic review is unavailabl sha256: 'abc', width: 768, height: 432, }; }, + async acknowledge() {}, }, imageReviewService: { async review() { return { ok: false, code: 'IMAGE_REVIEW_UNAVAILABLE' }; } }, logger: { warn() {} }, }); const result = await service.generate({ userId: 'user-1', purpose: 'hero', prompt: '安吉竹海' }); - assert.equal(result.ok, false); - assert.equal(result.code, 'IMAGE_REVIEW_UNAVAILABLE'); - assert.equal(storeCount, 0); + assert.equal(result.ok, true); + assert.equal(result.review.bypassed, true); + assert.equal(result.review.reason, 'IMAGE_REVIEW_UNAVAILABLE'); + assert.equal(storeCount, 1); }); test('MindSpace image generation blocks when user image quota is exhausted', async () => { diff --git a/mindspace-image-review.mjs b/mindspace-image-review.mjs index 466d481..e8391a8 100644 --- a/mindspace-image-review.mjs +++ b/mindspace-image-review.mjs @@ -5,12 +5,27 @@ const PURPOSE_LABELS = { feed_cover: '信息流封面', }; +const REVIEW_SYSTEM_PROMPT = [ + '你是 MindSpace 发布前图文一致性审核器。', + '你必须只输出一个 JSON object,不要 Markdown,不要额外解释。', + 'JSON 字段固定为:verdict、relevance_score、observed_subjects、missing_required_elements、reason。', +].join('\n'); + +const HERO_MINIMUM_SCORE = 60; + function clampScore(value) { const score = Number(value); if (!Number.isFinite(score)) return null; return Math.max(0, Math.min(100, Math.round(score))); } +function normalizeVerdict(value) { + const text = String(value ?? '').trim().toLowerCase(); + if (['pass', 'accept', 'approved', '通过', '合格'].includes(text)) return 'pass'; + if (['reject', 'fail', 'failed', '拒绝', '不通过', '不合格'].includes(text)) return 'reject'; + return ''; +} + function parseReviewJson(raw) { const text = Array.isArray(raw) ? raw.map((item) => (typeof item === 'string' ? item : item?.text ?? '')).join('') @@ -21,19 +36,23 @@ function parseReviewJson(raw) { if (start < 0 || end <= start) return null; try { const parsed = JSON.parse(unfenced.slice(start, end + 1)); - const verdict = String(parsed?.verdict ?? '').trim().toLowerCase(); - const relevanceScore = clampScore(parsed?.relevance_score ?? parsed?.relevanceScore); + const verdict = normalizeVerdict(parsed?.verdict ?? parsed?.result ?? parsed?.decision); + const relevanceScore = clampScore( + parsed?.relevance_score ?? parsed?.relevanceScore ?? parsed?.score, + ); if (!['pass', 'reject'].includes(verdict) || relevanceScore === null) return null; return { verdict, relevanceScore, - observedSubjects: Array.isArray(parsed?.observed_subjects) - ? parsed.observed_subjects.map(String).slice(0, 12) + observedSubjects: Array.isArray(parsed?.observed_subjects ?? parsed?.observedSubjects) + ? (parsed.observed_subjects ?? parsed.observedSubjects).map(String).slice(0, 12) : [], - missingRequiredElements: Array.isArray(parsed?.missing_required_elements) - ? parsed.missing_required_elements.map(String).slice(0, 12) + missingRequiredElements: Array.isArray( + parsed?.missing_required_elements ?? parsed?.missingRequiredElements, + ) + ? (parsed.missing_required_elements ?? parsed.missingRequiredElements).map(String).slice(0, 12) : [], - reason: String(parsed?.reason ?? '').trim().slice(0, 800), + reason: String(parsed?.reason ?? parsed?.message ?? '').trim().slice(0, 800), }; } catch { return null; @@ -47,10 +66,24 @@ function buildReviewPrompt({ purpose, prompt, minimumScore }) { `图片用途:${purposeLabel}。`, `原始生成要求:${String(prompt ?? '').trim()}`, '审核标准:主体、场景、关键元素与原始要求明显一致;不能只因颜色或抽象风格相似就通过。', + purpose === 'hero' + ? '页面首图允许氛围/意境类表达;只要大场景、情绪、题材方向一致,不要因缺少具体物件细节而 reject。' + : null, `相关度低于 ${minimumScore} 分必须 reject。`, '只输出严格 JSON,不要 Markdown,格式:', '{"verdict":"pass|reject","relevance_score":0,"observed_subjects":[],"missing_required_elements":[],"reason":"中文理由"}', - ].join('\n'); + ].filter(Boolean).join('\n'); +} + +function resolveMinimumScore(purpose, env) { + const configuredScore = Number(env.IMAGE_MAKE_SEMANTIC_REVIEW_MIN_SCORE ?? 70); + const globalMinimum = Number.isFinite(configuredScore) + ? Math.max(0, Math.min(100, Math.round(configuredScore))) + : 70; + if (purpose === 'hero') { + return Math.min(globalMinimum, HERO_MINIMUM_SCORE); + } + return globalMinimum; } export function createMindSpaceImageReviewService({ @@ -59,33 +92,46 @@ export function createMindSpaceImageReviewService({ logger = console, } = {}) { const enabled = String(env.IMAGE_MAKE_SEMANTIC_REVIEW_ENABLED ?? '1').trim() !== '0'; - const configuredScore = Number(env.IMAGE_MAKE_SEMANTIC_REVIEW_MIN_SCORE ?? 70); - const minimumScore = Number.isFinite(configuredScore) - ? Math.max(0, Math.min(100, Math.round(configuredScore))) - : 70; async function review({ buffer, mimeType, purpose, prompt } = {}) { - if (!enabled) return { ok: true, pass: true, bypassed: true, relevanceScore: 100 }; + const minimumScore = resolveMinimumScore(purpose, env); + if (!enabled) return { ok: true, pass: true, bypassed: true, relevanceScore: 100, minimumScore }; if (!llmProviderService?.analyzeImagesWithVision || !Buffer.isBuffer(buffer) || !buffer.length) { return { ok: false, pass: false, code: 'IMAGE_REVIEW_UNAVAILABLE' }; } - let raw; - try { - raw = await llmProviderService.analyzeImagesWithVision( - [{ - mimeType: String(mimeType || 'image/webp'), - visionMimeType: String(mimeType || 'image/webp'), - data: buffer.toString('base64'), - }], - buildReviewPrompt({ purpose, prompt, minimumScore }), + const reviewPrompt = buildReviewPrompt({ purpose, prompt, minimumScore }); + let parsed = null; + for (let attempt = 1; attempt <= 2; attempt += 1) { + let raw; + try { + raw = await llmProviderService.analyzeImagesWithVision( + [{ + mimeType: String(mimeType || 'image/webp'), + visionMimeType: String(mimeType || 'image/webp'), + data: buffer.toString('base64'), + }], + attempt === 1 + ? reviewPrompt + : `${reviewPrompt}\n\n上次输出无效。再次强调:只输出 JSON object,字段必须齐全,verdict 只能是 pass 或 reject。`, + { + responseFormat: 'json_object', + systemPrompt: REVIEW_SYSTEM_PROMPT, + disableThinking: true, + maxTokens: 800, + }, + ); + } catch (error) { + logger.warn?.('[image-review] Qwen vision request failed:', error?.message ?? error); + return { ok: false, pass: false, code: 'IMAGE_REVIEW_UNAVAILABLE' }; + } + parsed = parseReviewJson(raw); + if (parsed) break; + logger.warn?.( + `[image-review] Qwen vision returned an invalid review payload (attempt ${attempt}/2)`, + typeof raw === 'string' ? raw.slice(0, 240) : raw, ); - } catch (error) { - logger.warn?.('[image-review] Qwen vision request failed:', error?.message ?? error); - return { ok: false, pass: false, code: 'IMAGE_REVIEW_UNAVAILABLE' }; } - const parsed = parseReviewJson(raw); if (!parsed) { - logger.warn?.('[image-review] Qwen vision returned an invalid review payload'); return { ok: false, pass: false, code: 'IMAGE_REVIEW_INVALID_RESPONSE' }; } return { @@ -96,10 +142,12 @@ export function createMindSpaceImageReviewService({ }; } - return { enabled, minimumScore, review }; + return { enabled, review }; } export const mindSpaceImageReviewInternals = { buildReviewPrompt, parseReviewJson, + normalizeVerdict, + resolveMinimumScore, }; diff --git a/mindspace-image-review.test.mjs b/mindspace-image-review.test.mjs index c830829..ab0a60d 100644 --- a/mindspace-image-review.test.mjs +++ b/mindspace-image-review.test.mjs @@ -19,8 +19,8 @@ test('image review sends image bytes and original requirements to Qwen vision', let received = null; const service = createMindSpaceImageReviewService({ llmProviderService: { - async analyzeImagesWithVision(items, prompt) { - received = { items, prompt }; + async analyzeImagesWithVision(items, prompt, options) { + received = { items, prompt, options }; return JSON.stringify({ verdict: 'pass', relevance_score: 91, observed_subjects: ['竹海'], missing_required_elements: [], reason: '主体与场景一致', @@ -37,7 +37,34 @@ test('image review sends image bytes and original requirements to Qwen vision', assert.equal(received.items[0].data, Buffer.from('webp-image').toString('base64')); assert.match(received.prompt, /页面首图/); assert.match(received.prompt, /安吉竹海/); - assert.match(received.prompt, /70/); + assert.match(received.prompt, /60/); + assert.equal(received.options.responseFormat, 'json_object'); + assert.equal(received.options.disableThinking, true); +}); + +test('image review uses a lower pass threshold for hero images', async () => { + const service = createMindSpaceImageReviewService({ + llmProviderService: { + async analyzeImagesWithVision() { + return '{"verdict":"pass","relevance_score":65,"observed_subjects":["阳光"],"missing_required_elements":[],"reason":"氛围一致"}'; + }, + }, + env: { IMAGE_MAKE_SEMANTIC_REVIEW_MIN_SCORE: '70' }, + }); + const result = await service.review({ + buffer: Buffer.from('image'), mimeType: 'image/webp', purpose: 'hero', prompt: '阳光落在窗台上', + }); + assert.equal(result.ok, true); + assert.equal(result.pass, true); + assert.equal(result.minimumScore, 60); +}); + +test('image review parses Chinese verdict labels and camelCase score fields', () => { + const result = mindSpaceImageReviewInternals.parseReviewJson( + '{"verdict":"通过","relevanceScore":82,"observedSubjects":["窗台"],"missingRequiredElements":[],"reason":"一致"}', + ); + assert.equal(result?.verdict, 'pass'); + assert.equal(result?.relevanceScore, 82); }); test('image review rejects a nominal pass below the configured score', async () => { @@ -50,10 +77,11 @@ test('image review rejects a nominal pass below the configured score', async () env: { IMAGE_MAKE_SEMANTIC_REVIEW_MIN_SCORE: '70' }, }); const result = await service.review({ - buffer: Buffer.from('image'), mimeType: 'image/webp', purpose: 'hero', prompt: '安吉竹海', + buffer: Buffer.from('image'), mimeType: 'image/webp', purpose: 'inline_image', prompt: '安吉竹海', }); assert.equal(result.ok, true); assert.equal(result.pass, false); + assert.equal(result.minimumScore, 70); }); test('image review fails closed on missing or malformed Qwen responses', async () => { diff --git a/notification-dispatcher.mjs b/notification-dispatcher.mjs index 65cd1d6..c3fce23 100644 --- a/notification-dispatcher.mjs +++ b/notification-dispatcher.mjs @@ -12,7 +12,10 @@ export async function deliverWechatScheduleNotification( sendScheduleNotification, payload, ) { - const result = await sendScheduleNotification(payload); + const result = await sendScheduleNotification({ + ...payload, + verifiedHtmlUrls: Array.isArray(payload?.verifiedHtmlUrls) ? payload.verifiedHtmlUrls : [], + }); if (!resolveWechatDispatchSent(result)) { const err = new Error('微信提醒发送未完成(deferred、skipped 或未绑定)'); err.code = WECHAT_SCHEDULE_NOTIFICATION_NOT_SENT; diff --git a/schedule-reminder-worker.mjs b/schedule-reminder-worker.mjs index 331eb60..ddf9e25 100644 --- a/schedule-reminder-worker.mjs +++ b/schedule-reminder-worker.mjs @@ -30,7 +30,13 @@ export function startScheduleReminderWorker({ const reminder = await scheduleService.lockReminder(candidate.id); if (!reminder) continue; try { - const text = await scheduleService.buildReminderText(reminder); + const delivery = typeof scheduleService.buildReminderDelivery === 'function' + ? await scheduleService.buildReminderDelivery(reminder) + : { text: await scheduleService.buildReminderText(reminder), verifiedHtmlUrls: [] }; + const text = delivery?.text ?? null; + const verifiedHtmlUrls = Array.isArray(delivery?.verifiedHtmlUrls) + ? delivery.verifiedHtmlUrls + : []; if (!text) { await scheduleService.markReminderCancelled(reminder, '事项已失效或不存在'); continue; @@ -50,6 +56,7 @@ export function startScheduleReminderWorker({ await deliverWechatScheduleNotification(sendScheduleNotification, { userId: reminder.userId, text, + verifiedHtmlUrls, }); } await scheduleService.logDelivery({ diff --git a/schedule-service.mjs b/schedule-service.mjs index 4139c17..a6752c4 100644 --- a/schedule-service.mjs +++ b/schedule-service.mjs @@ -8,6 +8,19 @@ import { startOfLocalDay, } from './schedule-time.mjs'; import { formatMorningGreetingDeliveryText } from './wechat/morning-greeting-library.mjs'; +import { + LEGACY_MORNING_SOURCE, + pushSubscriptionMetadataSource, +} from './wechat/push-subscription-catalog.mjs'; +import { formatKnowledgePushDelivery } from './wechat/daily-knowledge-library.mjs'; +import { formatDailyQuoteDeliveryText } from './wechat/daily-quote-library.mjs'; +import { formatSurprisePushDelivery } from './wechat/daily-surprise-library.mjs'; +import { formatHealthReminderDeliveryText } from './wechat/health-reminder-library.mjs'; +import { formatNightSummaryDeliveryText } from './wechat/night-summary-library.mjs'; +import { formatFinancePushDelivery } from './wechat/push-content/finance-delivery.mjs'; +import { formatNewsMorningPushDelivery } from './wechat/push-content/news-morning-delivery.mjs'; +import { formatTechPushDelivery } from './wechat/push-content/tech-delivery.mjs'; +import { formatWeatherPushDelivery } from './wechat/push-content/weather-delivery.mjs'; const DEFAULT_TIMEZONE = 'Asia/Shanghai'; @@ -171,6 +184,8 @@ function formatTodoDigest(items, { now = nowMs(), timezone = DEFAULT_TIMEZONE } export function createScheduleService(pool, options = {}) { const defaultTimezone = normalizeTimezone(options.defaultTimezone || process.env.H5_DEFAULT_TIMEZONE); const clock = options.clock || { now: nowMs }; + const h5Root = options.h5Root || process.cwd(); + const runtimeEnv = options.env || process.env; const createItem = async ({ userId, @@ -799,6 +814,23 @@ export function createScheduleService(pool, options = {}) { }; }; + const updateScheduleItemMetadata = async ({ + userId, + itemId, + metadata, + } = {}) => { + const item = await getItem({ userId, itemId }); + if (!item) throw new Error('事项不存在或无权访问'); + const now = clock.now(); + await pool.query( + `UPDATE h5_schedule_items + SET metadata_json = ?, updated_at = ? + WHERE id = ? AND user_id = ? AND deleted_at IS NULL`, + [JSON.stringify(metadata ?? {}), now, itemId, userId], + ); + return getItem({ userId, itemId }); + }; + const cancelScheduleItem = async ({ userId, itemId, reason = '用户取消' } = {}) => { const item = await getItem({ userId, itemId }); if (!item) throw new Error('事项不存在或无权访问'); @@ -835,15 +867,111 @@ export function createScheduleService(pool, options = {}) { ); }; - const buildReminderText = async (reminder) => { + const buildReminderDelivery = async (reminder) => { const item = await getItem({ userId: reminder.userId, itemId: reminder.itemId }); if (!item || item.status !== 'active') return null; const timezone = item.timezone || defaultTimezone; - if (item.metadata?.source === 'subscribe_morning_reminder') { - return formatMorningGreetingDeliveryText({ - userId: reminder.userId, - timezone, + const pushSource = String(item.metadata?.source ?? '').trim(); + if ( + pushSource === LEGACY_MORNING_SOURCE + || pushSource === pushSubscriptionMetadataSource('morning') + ) { + return { + text: formatMorningGreetingDeliveryText({ + userId: reminder.userId, + timezone, + now: reminder.remindAt ?? clock.now(), + }), + verifiedHtmlUrls: [], + }; + } + if (pushSource === pushSubscriptionMetadataSource('news')) { + return formatNewsMorningPushDelivery({ now: reminder.remindAt ?? clock.now(), + timezone, + env: runtimeEnv, + h5Root, + pool, + }); + } + if (pushSource === pushSubscriptionMetadataSource('quote')) { + return { + text: formatDailyQuoteDeliveryText({ + userId: reminder.userId, + timezone, + now: reminder.remindAt ?? clock.now(), + }), + verifiedHtmlUrls: [], + }; + } + if (pushSource === pushSubscriptionMetadataSource('health')) { + const pushHour = Number(item.metadata?.dailyHour); + return { + text: formatHealthReminderDeliveryText({ + userId: reminder.userId, + timezone, + now: reminder.remindAt ?? clock.now(), + hour: Number.isInteger(pushHour) ? pushHour : null, + }), + verifiedHtmlUrls: [], + }; + } + if (pushSource === pushSubscriptionMetadataSource('weather')) { + return formatWeatherPushDelivery({ + city: item.metadata?.options?.city ?? '北京', + now: reminder.remindAt ?? clock.now(), + timezone, + env: runtimeEnv, + h5Root, + pool, + }); + } + if (pushSource === pushSubscriptionMetadataSource('finance')) { + return formatFinancePushDelivery({ + now: reminder.remindAt ?? clock.now(), + timezone, + env: runtimeEnv, + h5Root, + pool, + }); + } + if (pushSource === pushSubscriptionMetadataSource('tech')) { + return formatTechPushDelivery({ + now: reminder.remindAt ?? clock.now(), + timezone, + env: runtimeEnv, + h5Root, + pool, + }); + } + if (pushSource === pushSubscriptionMetadataSource('knowledge')) { + return formatKnowledgePushDelivery({ + userId: reminder.userId, + now: reminder.remindAt ?? clock.now(), + timezone, + env: runtimeEnv, + h5Root, + pool, + }); + } + if (pushSource === pushSubscriptionMetadataSource('night')) { + return { + text: formatNightSummaryDeliveryText({ + userId: reminder.userId, + timezone, + now: reminder.remindAt ?? clock.now(), + }), + verifiedHtmlUrls: [], + }; + } + if (pushSource === pushSubscriptionMetadataSource('surprise')) { + return formatSurprisePushDelivery({ + userId: reminder.userId, + now: reminder.remindAt ?? clock.now(), + timezone, + env: runtimeEnv, + h5Root, + pool, }); } const remindLabel = formatLocalTime(reminder.remindAt, timezone); @@ -855,7 +983,12 @@ export function createScheduleService(pool, options = {}) { } lines.push(`提醒时间:${remindLabel}`); if (item.location) lines.push(`地点:${item.location}`); - return lines.join('\n'); + return { text: lines.join('\n'), verifiedHtmlUrls: [] }; + }; + + const buildReminderText = async (reminder) => { + const delivery = await buildReminderDelivery(reminder); + return delivery?.text ?? null; }; const listDueBalanceAlerts = async ({ now = clock.now(), limit = 50 } = {}) => { @@ -1113,6 +1246,7 @@ export function createScheduleService(pool, options = {}) { getItem, findActiveItemByMetadataSource, updateDailyScheduleItem, + updateScheduleItemMetadata, cancelScheduleItem, createReminder, listItems, @@ -1133,6 +1267,7 @@ export function createScheduleService(pool, options = {}) { scheduleNextDailyReminder, markReminderCancelled, markReminderFailed, + buildReminderDelivery, buildReminderText, listDueDigestSubscriptions, lockDigestSubscription, diff --git a/schedule-service.test.mjs b/schedule-service.test.mjs index f6e81d0..8c14cdf 100644 --- a/schedule-service.test.mjs +++ b/schedule-service.test.mjs @@ -156,6 +156,231 @@ test('buildReminderText uses daily morning greeting library for subscribe remind assert.doesNotMatch(text, /【待办提醒】/); }); +test('buildReminderDelivery returns news digest with verified html url', async () => { + const fs = await import('node:fs'); + const os = await import('node:os'); + const path = await import('node:path'); + const { PUBLIC_ZONE_DIR, PUBLISH_ROOT_DIR } = await import('./user-publish.mjs'); + + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'schedule-news-')); + const userId = 'news-source-user'; + const publicDir = path.join(root, PUBLISH_ROOT_DIR, userId, PUBLIC_ZONE_DIR); + fs.mkdirSync(publicDir, { recursive: true }); + fs.writeFileSync(path.join(publicDir, 'daily-news-0920.html'), `
+正文
市场走高
]*>([\s\S]*?)<\/p>/gi)] .map((item) => stripHtml(item[1])) .filter(Boolean); @@ -312,6 +546,36 @@ function extractCardArticles(html) { return extractCardArticlesFromHotspots(html); } +/** 服务号订阅推送:从早报 HTML 提取纯文本摘要(5–10 条标题)。 */ +export function buildNewsMorningWechatPushText( + html, + { publicUrl = '', maxItems = 5, manageHint = '回复「取消2」关闭推送' } = {}, +) { + const cards = extractCardArticles(html).slice(0, Math.max(1, Math.min(10, Number(maxItems) || 5))); + const heroDate = stripHtml( + String(html).match(/
正文
+正文
+]*>([\s\S]*?)<\/p>/gi)] + .map((item) => stripHtml(item[1])) + .filter(Boolean); + const context = paragraphs[0] ?? ''; + return { + index: index + 1, + title, + tag, + context: truncateText(context, 120), + }; + }).filter((item) => item.title); +} + +export function extractKnowledgeItemsFromHtml(html) { + const sectionHtml = extractDailyNewsSectionHtml(html, 'knowledge'); + const blocks = extractClassDivBlocks(sectionHtml, 'knowledge-item'); + return blocks.map((block, index) => { + const icon = stripHtml(block.match(/