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 = [
+ '
',
+ '
',
+ '',
+ ].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 = `
`;
+ 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' },