diff --git a/chat-image-materialize.mjs b/chat-image-materialize.mjs new file mode 100644 index 0000000..72790c2 --- /dev/null +++ b/chat-image-materialize.mjs @@ -0,0 +1,205 @@ +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; +import { extractImageAssetKey } from './chat-image-turn-scope.mjs'; + +const WECHAT_MP_PUBLIC_PATH_RE = /\/public\/(wechat-mp\/[^?#\s"'<>]+)/i; +const MINDSPACE_PUBLIC_PATH_RE = + /\/MindSpace\/[0-9a-f-]{36}\/public\/(wechat-mp\/[^?#\s"'<>]+|images\/[^?#\s"'<>]+)/i; +const HTML_IMG_SRC_RE = /\bsrc\s*=\s*["']([^"']+)["']/gi; +const HTML_COVER_JSON_RE = /name=["']mindspace-cover["']\s+content=['"]([^'"]+)['"]/i; + +function formatUtcDateStamp(date = new Date()) { + return date.toISOString().slice(0, 10); +} + +export function extractPublicZoneRelativePath(rawUrl, userId = '') { + const value = String(rawUrl ?? '').trim(); + if (!value) return null; + const mindspaceMatch = value.match(MINDSPACE_PUBLIC_PATH_RE); + if (mindspaceMatch?.[1]) { + return `public/${mindspaceMatch[1]}`; + } + const wechatMatch = value.match(WECHAT_MP_PUBLIC_PATH_RE); + if (wechatMatch?.[1]) { + return `public/${wechatMatch[1]}`; + } + if (value.startsWith('public/wechat-mp/') || value.startsWith('public/images/')) { + return value; + } + if (value.startsWith('wechat-mp/') || value.startsWith('images/')) { + return `public/${value}`; + } + try { + const parsed = new URL( + value, + value.startsWith('/') ? 'http://local' : undefined, + ); + const pathname = parsed.pathname; + const publicIndex = pathname.indexOf('/public/'); + if (publicIndex >= 0) { + const tail = pathname.slice(publicIndex + '/public/'.length); + if (tail.startsWith('wechat-mp/') || tail.startsWith('images/')) { + return `public/${tail}`; + } + } + } catch { + return null; + } + void userId; + return null; +} + +export function materializeWechatPublicImageForEmbed({ + publishDir, + rawUrl, + buffer = null, + mimeType = 'image/jpeg', + now = new Date(), +} = {}) { + const publicRelativePath = extractPublicZoneRelativePath(rawUrl); + if (!publicRelativePath || !String(publishDir ?? '').trim()) { + return null; + } + const sourceAbs = path.join(publishDir, publicRelativePath); + let sourceBuffer = buffer; + if (!sourceBuffer) { + try { + if (!fs.existsSync(sourceAbs) || !fs.statSync(sourceAbs).isFile()) { + return null; + } + sourceBuffer = fs.readFileSync(sourceAbs); + if (!mimeType) mimeType = 'image/jpeg'; + } catch { + return null; + } + } + if (!Buffer.isBuffer(sourceBuffer) || sourceBuffer.length === 0) { + return null; + } + + if (publicRelativePath.startsWith('public/images/')) { + const embedPath = publicRelativePath.slice('public/'.length); + return { + publicRelativePath, + relativeEmbedPath: embedPath, + embedUrl: embedPath, + assetKeys: collectEmbedAssetKeys([embedPath, publicRelativePath, rawUrl]), + materialized: false, + }; + } + + if (!publicRelativePath.startsWith('public/wechat-mp/')) { + return null; + } + + const dateDir = formatUtcDateStamp(now); + const hash = crypto.createHash('md5').update(sourceBuffer).digest('hex').slice(0, 8); + const basename = path.posix.basename(publicRelativePath); + const destPublicRelativePath = `public/images/${dateDir}/${hash}-${basename}`; + const destAbs = path.join(publishDir, destPublicRelativePath); + try { + fs.mkdirSync(path.dirname(destAbs), { recursive: true }); + if (!fs.existsSync(destAbs)) { + fs.writeFileSync(destAbs, sourceBuffer); + } + } catch { + return null; + } + const embedPath = destPublicRelativePath.slice('public/'.length); + return { + publicRelativePath: destPublicRelativePath, + relativeEmbedPath: embedPath, + embedUrl: embedPath, + assetKeys: collectEmbedAssetKeys([embedPath, destPublicRelativePath, rawUrl]), + materialized: true, + mimeType, + }; +} + +export function collectEmbedAssetKeys(values = []) { + const keys = new Set(); + for (const value of values) { + const key = extractImageAssetKey(value); + if (key) keys.add(key); + } + return keys; +} + +export function buildAllowedPageImageEmbedKeys({ + publishDir, + imageUrls = [], + userId = '', +} = {}) { + const allowed = new Set(); + for (const rawUrl of imageUrls) { + const materialized = materializeWechatPublicImageForEmbed({ + publishDir, + rawUrl, + userId, + }); + if (materialized?.assetKeys) { + for (const key of materialized.assetKeys) allowed.add(key); + } else { + for (const key of collectEmbedAssetKeys([rawUrl])) allowed.add(key); + } + } + return allowed; +} + +export function extractHtmlImageSourceKeys(html) { + const keys = new Set(); + const value = String(html ?? ''); + if (!value) return keys; + + for (const match of value.matchAll(HTML_IMG_SRC_RE)) { + for (const key of collectEmbedAssetKeys([match[1]])) keys.add(key); + } + + const coverMatch = value.match(HTML_COVER_JSON_RE); + if (coverMatch?.[1]) { + try { + const parsed = JSON.parse( + coverMatch[1] + .replace(/"/g, '"') + .replace(/'/g, "'"), + ); + for (const key of collectEmbedAssetKeys([parsed?.cover, parsed?.image])) { + if (key) keys.add(key); + } + } catch { + for (const key of collectEmbedAssetKeys([coverMatch[1]])) keys.add(key); + } + } + + return keys; +} + +export function verifyHtmlImageSourcesAllowed(html, allowedKeys) { + const allowed = allowedKeys instanceof Set ? allowedKeys : new Set(allowedKeys); + if (allowed.size === 0) { + return { ok: true, reason: null, offendingKeys: [] }; + } + const found = extractHtmlImageSourceKeys(html); + if (found.size === 0) { + return { + ok: false, + reason: 'missing_required_images', + offendingKeys: [], + }; + } + const offendingKeys = [...found].filter((key) => !allowed.has(key)); + if (offendingKeys.length > 0) { + return { + ok: false, + reason: 'stale_image_source', + offendingKeys, + }; + } + return { ok: true, reason: null, offendingKeys: [] }; +} + +export const chatImageMaterializeInternals = { + formatUtcDateStamp, + HTML_IMG_SRC_RE, +}; diff --git a/chat-image-materialize.test.mjs b/chat-image-materialize.test.mjs new file mode 100644 index 0000000..73c1e21 --- /dev/null +++ b/chat-image-materialize.test.mjs @@ -0,0 +1,71 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; +import { + buildAllowedPageImageEmbedKeys, + extractHtmlImageSourceKeys, + materializeWechatPublicImageForEmbed, + verifyHtmlImageSourcesAllowed, +} from './chat-image-materialize.mjs'; + +test('materializeWechatPublicImageForEmbed copies wechat-mp images into public/images date dir', (t) => { + const publishDir = fs.mkdtempSync(path.join(os.tmpdir(), 'chat-image-materialize-')); + t.after(() => fs.rmSync(publishDir, { recursive: true, force: true })); + const sourceRel = 'public/wechat-mp/sample-photo.jpg'; + const sourceAbs = path.join(publishDir, sourceRel); + fs.mkdirSync(path.dirname(sourceAbs), { recursive: true }); + fs.writeFileSync(sourceAbs, Buffer.from('fresh-image-bytes')); + + const result = materializeWechatPublicImageForEmbed({ + publishDir, + rawUrl: 'https://m.tkmind.cn/MindSpace/user-1/public/wechat-mp/sample-photo.jpg', + now: new Date('2026-08-27T04:00:00.000Z'), + }); + + assert.ok(result?.materialized); + assert.match(result.relativeEmbedPath, /^images\/2026-08-27\/[a-f0-9]{8}-sample-photo\.jpg$/); + assert.equal(fs.existsSync(path.join(publishDir, result.publicRelativePath)), true); +}); + +test('verifyHtmlImageSourcesAllowed rejects stale workspace images for the current turn', () => { + const publishDir = fs.mkdtempSync(path.join(os.tmpdir(), 'chat-image-verify-')); + const sourceAbs = path.join(publishDir, 'public/wechat-mp/new.jpg'); + fs.mkdirSync(path.dirname(sourceAbs), { recursive: true }); + fs.writeFileSync(sourceAbs, Buffer.from('new-turn-image')); + const allowed = buildAllowedPageImageEmbedKeys({ + publishDir, + imageUrls: ['https://m.tkmind.cn/MindSpace/user-1/public/wechat-mp/new.jpg'], + }); + const html = [ + '', + 'old', + '', + ].join(''); + const verification = verifyHtmlImageSourcesAllowed(html, allowed); + assert.equal(verification.ok, false); + assert.equal(verification.reason, 'stale_image_source'); + fs.rmSync(publishDir, { recursive: true, force: true }); +}); + +test('verifyHtmlImageSourcesAllowed accepts materialized current-turn image paths', () => { + const publishDir = fs.mkdtempSync(path.join(os.tmpdir(), 'chat-image-verify-ok-')); + const sourceAbs = path.join(publishDir, 'public/wechat-mp/new.jpg'); + fs.mkdirSync(path.dirname(sourceAbs), { recursive: true }); + fs.writeFileSync(sourceAbs, Buffer.from('new-turn-image')); + const materialized = materializeWechatPublicImageForEmbed({ + publishDir, + rawUrl: 'https://m.tkmind.cn/MindSpace/user-1/public/wechat-mp/new.jpg', + now: new Date('2026-08-27T04:00:00.000Z'), + }); + const allowed = buildAllowedPageImageEmbedKeys({ + publishDir, + imageUrls: ['https://m.tkmind.cn/MindSpace/user-1/public/wechat-mp/new.jpg'], + }); + const html = `new`; + const keys = extractHtmlImageSourceKeys(html); + assert.equal(verifyHtmlImageSourcesAllowed(html, allowed).ok, true); + assert.ok(keys.size > 0); + fs.rmSync(publishDir, { recursive: true, force: true }); +}); diff --git a/tkmind-proxy.mjs b/tkmind-proxy.mjs index 82dc22c..d446627 100644 --- a/tkmind-proxy.mjs +++ b/tkmind-proxy.mjs @@ -39,6 +39,10 @@ import { extractCurrentTurnImageUrls, scrubConversationHistoricalImageAttachments, } from './chat-image-turn-scope.mjs'; +import { + collectEmbedAssetKeys, + materializeWechatPublicImageForEmbed, +} from './chat-image-materialize.mjs'; import { repairConversationToolHistory } from './chat-tool-history-repair.mjs'; import { buildVisionThumbnailBuffer } from './vision-image-thumb.mjs'; import { @@ -864,6 +868,7 @@ export async function buildVisionPayload({ userMessage, userId, publishLayout, + publishDir = null, localFetchAsset, llmProviderService, imgproxySigner = null, @@ -922,6 +927,14 @@ export async function buildVisionPayload({ const parsed = new URL(rawUrl); relativePath = parsed.pathname + parsed.search; } catch { /* keep rawUrl */ } + const materialized = publishDir + ? materializeWechatPublicImageForEmbed({ + publishDir, + rawUrl, + buffer, + mimeType, + }) + : null; const publicStandardUrl = buildPublicStandardImageUrl(rawUrl, userId, publishLayout); let visionBuffer = buffer; let visionMimeType = mimeType; @@ -940,7 +953,12 @@ export async function buildVisionPayload({ data: visionBuffer.toString('base64'), relativePath, rawUrl, - embedUrl: publicStandardUrl ?? relativePath, + embedUrl: materialized?.embedUrl ?? publicStandardUrl ?? relativePath, + allowedEmbedKeys: materialized?.assetKeys ?? collectEmbedAssetKeys([ + publicStandardUrl, + relativePath, + rawUrl, + ]), }); } catch (err) { console.warn('Vision image fetch skipped:', err instanceof Error ? err.message : err); @@ -1009,6 +1027,11 @@ export async function buildVisionPayload({ const canonicalImageUrls = imageItems .map((item) => item.embedUrl ?? item.rawUrl) .filter((url) => typeof url === 'string' && url.trim()); + const allowedPageImageEmbedKeys = [ + ...new Set( + imageItems.flatMap((item) => [...(item.allowedEmbedKeys ?? [])]), + ), + ]; return { userMessage: detachCurrentTurnImagesForTextProvider( @@ -1019,6 +1042,9 @@ export async function buildVisionPayload({ ...(userMessage.metadata ?? {}), ...(originalDisplayText ? { displayText: originalDisplayText } : {}), ...(canonicalImageUrls.length ? { imageUrls: canonicalImageUrls } : {}), + ...(allowedPageImageEmbedKeys.length + ? { allowedPageImageEmbedKeys } + : {}), }, }, canonicalImageUrls, @@ -1618,11 +1644,12 @@ export function createTkmindProxy({ // Step 2 — Inject Qwen's text description + server-relative image paths into the // user_message that goes to DeepSeek via Goose. DeepSeek retains full // tool-calling capability (write_file, etc.) and creates the page properly. - async function buildVisionBody(userMessage, userId, publishLayout) { + async function buildVisionBody(userMessage, userId, publishLayout, publishDir = null) { return buildVisionPayload({ userMessage, userId, publishLayout, + publishDir, localFetchAsset, llmProviderService, imgproxySigner, @@ -2039,7 +2066,13 @@ export function createTkmindProxy({ let finalUserMessage = userMessage; if (llmProviderService && messageHasImages(userMessage) && await llmProviderService.hasVisionKey()) { const publishLayout = await userAuth.getUserPublishLayout(userId).catch(() => null); - const visionResult = await buildVisionBody(userMessage, userId, publishLayout).catch(() => null); + const publishDir = await userAuth.resolveWorkingDir(userId).catch(() => null); + const visionResult = await buildVisionBody( + userMessage, + userId, + publishLayout, + publishDir, + ).catch(() => null); if (visionResult?.userMessage) { finalUserMessage = visionResult.userMessage; } diff --git a/wechat-mp.mjs b/wechat-mp.mjs index ddf801b..039a8ca 100644 --- a/wechat-mp.mjs +++ b/wechat-mp.mjs @@ -1,4 +1,7 @@ import crypto from 'node:crypto'; +import { + buildAllowedPageImageEmbedKeys, +} from './chat-image-materialize.mjs'; import path from 'node:path'; import { fetch as undiciFetch } from 'undici'; import { developerToolsFromPolicy } from './capabilities.mjs'; @@ -1121,6 +1124,20 @@ export function resolveWechatRecentMediaPublicUrl(recentMediaEntry) { return String(recentMediaEntry.items.at(-1)?.media?.publicUrl ?? '').trim(); } +export function collectIntentImageUrls(intent, { mediaAnalysisEnabled = false } = {}) { + if (!mediaAnalysisEnabled) return []; + const mediaPublicUrl = intent?.media?.publicUrl || null; + const mediaItems = Array.isArray(intent?.recentMediaItems) && intent.recentMediaItems.length > 0 + ? intent.recentMediaItems + : mediaPublicUrl + ? [{ media: intent.media, attachment: intent.attachment ?? null }] + : []; + return mediaItems + .filter((item) => !item.attachment) + .map((item) => String(item?.media?.publicUrl ?? '').trim()) + .filter((url, index, values) => url && values.indexOf(url) === index); +} + export function prepareWechatIntentForHistoricalImageRetry(intent, { fallbackImageUrl = '' } = {}) { if (!intent || typeof intent !== 'object') return intent; const imageUrl = String(intent.media?.publicUrl ?? fallbackImageUrl ?? '').trim(); @@ -2561,16 +2578,13 @@ export function createWechatMpService({ pgRequired = false, } = {}, ) => { + const imageUrls = collectIntentImageUrls(intent, { mediaAnalysisEnabled }); const mediaPublicUrl = intent.media?.publicUrl || null; const mediaItems = Array.isArray(intent.recentMediaItems) && intent.recentMediaItems.length > 0 ? intent.recentMediaItems : mediaPublicUrl ? [{ media: intent.media, attachment: intent.attachment ?? null }] : []; - const imageUrls = mediaItems - .filter((item) => !item.attachment) - .map((item) => String(item?.media?.publicUrl ?? '').trim()) - .filter((url, index, values) => url && values.indexOf(url) === index); const fileAttachments = mediaItems .filter((item) => item.attachment?.filename && item.media?.publicUrl) .map((item) => ({ @@ -3071,6 +3085,14 @@ export function createWechatMpService({ : []; if (wechatIntent.kind === 'page.generate') { + const intentImageUrls = collectIntentImageUrls(intent, { mediaAnalysisEnabled }); + const allowedImageEmbedKeys = intentImageUrls.length > 0 + ? buildAllowedPageImageEmbedKeys({ + publishDir: userPublishDir, + imageUrls: intentImageUrls, + userId: user.userId, + }) + : null; const pageOutcome = resolvePageGenerateOutcome({ reply, confirmedArtifacts, @@ -3084,6 +3106,7 @@ export function createWechatMpService({ publishDir: userPublishDir, }, wechatCursorChannel: isWechatCursorChannelReply(reply), + allowedImageEmbedKeys, }); if (pageOutcome.action === 'session_retry') { if (sessionPageContinuation && pageAttempt < maxImmediateContextPageAttempts) { @@ -3479,6 +3502,14 @@ export function createWechatMpService({ : []; if (wechatIntent.kind === 'page.generate') { + const intentImageUrls = collectIntentImageUrls(intent, { mediaAnalysisEnabled }); + const allowedImageEmbedKeys = intentImageUrls.length > 0 + ? buildAllowedPageImageEmbedKeys({ + publishDir: userPublishDir, + imageUrls: intentImageUrls, + userId: user.userId, + }) + : null; const pageOutcome = resolvePageGenerateOutcome({ reply, confirmedArtifacts, @@ -3492,6 +3523,7 @@ export function createWechatMpService({ publishDir: userPublishDir, }, wechatCursorChannel: isWechatCursorChannelReply(reply), + allowedImageEmbedKeys, }); if (pageOutcome.action === 'session_retry' || pageOutcome.action === 'fail') { const text = pageOutcome.failureText ?? buildPagePublishFailureText(); diff --git a/wechat/handlers/page-generate.mjs b/wechat/handlers/page-generate.mjs index 3941b83..562c773 100644 --- a/wechat/handlers/page-generate.mjs +++ b/wechat/handlers/page-generate.mjs @@ -1,5 +1,5 @@ import { buildPagePublishFailureText } from '../prompts/page-generate.mjs'; -import { selectSendableHtmlArtifacts, verifyPageArtifactContent } from '../verify/page-artifact.mjs'; +import { selectSendableHtmlArtifacts, verifyPageArtifactContent, verifyPageArtifactImageSources } from '../verify/page-artifact.mjs'; import { repairArtifactSharePreview } from '../verify/share-preview-repair.mjs'; import { filterSharePreviewReadyArtifacts, verifyArtifactSharePreview } from '../verify/share-preview.mjs'; @@ -7,6 +7,7 @@ export function evaluatePageGenerateSendableArtifacts({ verifiedArtifacts = [], confirmedArtifacts = [], repairContext = {}, + allowedImageEmbedKeys = null, } = {}) { const publishDir = String(repairContext.publishDir ?? '').trim(); const previewOptions = { publishDir }; @@ -18,7 +19,13 @@ export function evaluatePageGenerateSendableArtifacts({ const verified = sendable.filter((artifact) => verifyPageArtifactContent(artifact, previewOptions).ok, ); - const candidates = verified.length > 0 ? verified : sendable; + const imageVerified = (verified.length > 0 ? verified : sendable).filter((artifact) => + verifyPageArtifactImageSources(artifact, { + publishDir, + allowedImageEmbedKeys, + }).ok, + ); + const candidates = imageVerified.length > 0 ? imageVerified : []; const ready = filterSharePreviewReadyArtifacts(candidates, previewOptions); if (ready.length > 0) return ready; @@ -48,6 +55,7 @@ export function resolvePageGenerateOutcome({ topic = '', repairContext = {}, wechatCursorChannel = false, + allowedImageEmbedKeys = null, }) { const context = { topic: String(topic || repairContext.topic || '').trim(), @@ -59,8 +67,30 @@ export function resolvePageGenerateOutcome({ verifiedArtifacts, confirmedArtifacts, repairContext: context, + allowedImageEmbedKeys, }); + if (sendable.length === 0 && allowedImageEmbedKeys instanceof Set && allowedImageEmbedKeys.size > 0) { + const staleCandidate = selectSendableHtmlArtifacts({ + verifiedArtifacts, + confirmedArtifacts, + publishDir: context.publishDir, + }).find((artifact) => + verifyPageArtifactContent(artifact, previewOptions).ok + && !verifyPageArtifactImageSources(artifact, { + publishDir: context.publishDir, + allowedImageEmbedKeys, + }).ok, + ); + if (staleCandidate) { + return { + action: 'fail', + failureText: buildPagePublishFailureText(), + reason: 'stale_image_source', + }; + } + } + if (sendable.length > 0) { return { action: 'send', artifacts: sendable }; } diff --git a/wechat/verify/page-artifact.mjs b/wechat/verify/page-artifact.mjs index 74ac5ef..b9547d0 100644 --- a/wechat/verify/page-artifact.mjs +++ b/wechat/verify/page-artifact.mjs @@ -1,5 +1,6 @@ import fs from 'node:fs'; import path from 'node:path'; +import { verifyHtmlImageSourcesAllowed } from '../../chat-image-materialize.mjs'; const STUB_MARKERS = ['临时补出', '服务号兜底', '服务号自动补出简版页面']; @@ -106,3 +107,36 @@ export function verifyPageArtifactContent(artifact, { minBytes = 512, publishDir } return { ok: true, reason: null }; } + +export function verifyPageArtifactImageSources( + artifact, + { + publishDir = '', + allowedImageEmbedKeys = null, + } = {}, +) { + const allowed = allowedImageEmbedKeys instanceof Set + ? allowedImageEmbedKeys + : Array.isArray(allowedImageEmbedKeys) + ? new Set(allowedImageEmbedKeys) + : null; + if (!allowed || allowed.size === 0) { + return { ok: true, reason: null, offendingKeys: [] }; + } + const localPath = resolveArtifactLocalPath(artifact, publishDir); + if (!localPath) { + return { ok: false, reason: 'missing_file', offendingKeys: [] }; + } + let content = ''; + try { + content = fs.readFileSync(localPath, 'utf8'); + } catch { + return { ok: false, reason: 'missing_file', offendingKeys: [] }; + } + const verification = verifyHtmlImageSourcesAllowed(content, allowed); + return { + ok: verification.ok, + reason: verification.reason, + offendingKeys: verification.offendingKeys ?? [], + }; +} diff --git a/wechat/wechat-channel.test.mjs b/wechat/wechat-channel.test.mjs index f847aff..88ec557 100644 --- a/wechat/wechat-channel.test.mjs +++ b/wechat/wechat-channel.test.mjs @@ -13,6 +13,7 @@ import { } from './verify/page-artifact.mjs'; import { guardScheduleConfirmationReply, looksLikeScheduleConfirmation } from './handlers/schedule-guard.mjs'; import { resolvePageGenerateOutcome } from './handlers/page-generate.mjs'; +import { buildAllowedPageImageEmbedKeys } from '../chat-image-materialize.mjs'; import { buildGreetingText, resolveSyncReply } from './handlers/sync-replies.mjs'; import { buildWechatAgentPrompt } from './prompts/chat-general.mjs'; import { @@ -167,6 +168,33 @@ test('schedule confirmation guard rewrites agent pseudo-confirm when ITL enabled assert.doesNotMatch(guarded, /不能算设置成功/); }); +test('resolvePageGenerateOutcome fails closed when html reuses stale image paths', () => { + const publishDir = fs.mkdtempSync(path.join(os.tmpdir(), 'wechat-stale-image-')); + const wechatMp = path.join(publishDir, 'public/wechat-mp/new.jpg'); + fs.mkdirSync(path.dirname(wechatMp), { recursive: true }); + fs.writeFileSync(wechatMp, Buffer.from('fresh-image')); + const htmlPath = path.join(publishDir, 'public/page.html'); + fs.mkdirSync(path.dirname(htmlPath), { recursive: true }); + fs.writeFileSync( + htmlPath, + `

TKMind · 智趣

${'x'.repeat(600)}
`, + 'utf8', + ); + const allowedImageEmbedKeys = buildAllowedPageImageEmbedKeys({ + publishDir, + imageUrls: ['https://m.tkmind.cn/MindSpace/user-1/public/wechat-mp/new.jpg'], + }); + const outcome = resolvePageGenerateOutcome({ + reply: { text: '页面完成' }, + confirmedArtifacts: [{ localPath: htmlPath, relativePath: 'public/page.html' }], + repairContext: { publishDir }, + allowedImageEmbedKeys, + }); + assert.equal(outcome.action, 'fail'); + assert.equal(outcome.reason, 'stale_image_source'); + fs.rmSync(publishDir, { recursive: true, force: true }); +}); + test('resolvePageGenerateOutcome fails when only stub artifacts exist', () => { const outcome = resolvePageGenerateOutcome({ reply: { text: '页面已生成 https://m.tkmind.cn/MindSpace/u/public/tang-poem.html' },