Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 760a1760ae |
@@ -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,
|
||||||
|
};
|
||||||
@@ -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 = [
|
||||||
|
'<html><body>',
|
||||||
|
'<img src="images/2026-08-22/old-photo.jpg" alt="old">',
|
||||||
|
'</body></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 = `<html><body><img src="${materialized.relativeEmbedPath}" alt="new"></body></html>`;
|
||||||
|
const keys = extractHtmlImageSourceKeys(html);
|
||||||
|
assert.equal(verifyHtmlImageSourcesAllowed(html, allowed).ok, true);
|
||||||
|
assert.ok(keys.size > 0);
|
||||||
|
fs.rmSync(publishDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
+36
-3
@@ -39,6 +39,10 @@ import {
|
|||||||
extractCurrentTurnImageUrls,
|
extractCurrentTurnImageUrls,
|
||||||
scrubConversationHistoricalImageAttachments,
|
scrubConversationHistoricalImageAttachments,
|
||||||
} from './chat-image-turn-scope.mjs';
|
} from './chat-image-turn-scope.mjs';
|
||||||
|
import {
|
||||||
|
collectEmbedAssetKeys,
|
||||||
|
materializeWechatPublicImageForEmbed,
|
||||||
|
} from './chat-image-materialize.mjs';
|
||||||
import { repairConversationToolHistory } from './chat-tool-history-repair.mjs';
|
import { repairConversationToolHistory } from './chat-tool-history-repair.mjs';
|
||||||
import { buildVisionThumbnailBuffer } from './vision-image-thumb.mjs';
|
import { buildVisionThumbnailBuffer } from './vision-image-thumb.mjs';
|
||||||
import {
|
import {
|
||||||
@@ -864,6 +868,7 @@ export async function buildVisionPayload({
|
|||||||
userMessage,
|
userMessage,
|
||||||
userId,
|
userId,
|
||||||
publishLayout,
|
publishLayout,
|
||||||
|
publishDir = null,
|
||||||
localFetchAsset,
|
localFetchAsset,
|
||||||
llmProviderService,
|
llmProviderService,
|
||||||
imgproxySigner = null,
|
imgproxySigner = null,
|
||||||
@@ -922,6 +927,14 @@ export async function buildVisionPayload({
|
|||||||
const parsed = new URL(rawUrl);
|
const parsed = new URL(rawUrl);
|
||||||
relativePath = parsed.pathname + parsed.search;
|
relativePath = parsed.pathname + parsed.search;
|
||||||
} catch { /* keep rawUrl */ }
|
} catch { /* keep rawUrl */ }
|
||||||
|
const materialized = publishDir
|
||||||
|
? materializeWechatPublicImageForEmbed({
|
||||||
|
publishDir,
|
||||||
|
rawUrl,
|
||||||
|
buffer,
|
||||||
|
mimeType,
|
||||||
|
})
|
||||||
|
: null;
|
||||||
const publicStandardUrl = buildPublicStandardImageUrl(rawUrl, userId, publishLayout);
|
const publicStandardUrl = buildPublicStandardImageUrl(rawUrl, userId, publishLayout);
|
||||||
let visionBuffer = buffer;
|
let visionBuffer = buffer;
|
||||||
let visionMimeType = mimeType;
|
let visionMimeType = mimeType;
|
||||||
@@ -940,7 +953,12 @@ export async function buildVisionPayload({
|
|||||||
data: visionBuffer.toString('base64'),
|
data: visionBuffer.toString('base64'),
|
||||||
relativePath,
|
relativePath,
|
||||||
rawUrl,
|
rawUrl,
|
||||||
embedUrl: publicStandardUrl ?? relativePath,
|
embedUrl: materialized?.embedUrl ?? publicStandardUrl ?? relativePath,
|
||||||
|
allowedEmbedKeys: materialized?.assetKeys ?? collectEmbedAssetKeys([
|
||||||
|
publicStandardUrl,
|
||||||
|
relativePath,
|
||||||
|
rawUrl,
|
||||||
|
]),
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('Vision image fetch skipped:', err instanceof Error ? err.message : err);
|
console.warn('Vision image fetch skipped:', err instanceof Error ? err.message : err);
|
||||||
@@ -1009,6 +1027,11 @@ export async function buildVisionPayload({
|
|||||||
const canonicalImageUrls = imageItems
|
const canonicalImageUrls = imageItems
|
||||||
.map((item) => item.embedUrl ?? item.rawUrl)
|
.map((item) => item.embedUrl ?? item.rawUrl)
|
||||||
.filter((url) => typeof url === 'string' && url.trim());
|
.filter((url) => typeof url === 'string' && url.trim());
|
||||||
|
const allowedPageImageEmbedKeys = [
|
||||||
|
...new Set(
|
||||||
|
imageItems.flatMap((item) => [...(item.allowedEmbedKeys ?? [])]),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
userMessage: detachCurrentTurnImagesForTextProvider(
|
userMessage: detachCurrentTurnImagesForTextProvider(
|
||||||
@@ -1019,6 +1042,9 @@ export async function buildVisionPayload({
|
|||||||
...(userMessage.metadata ?? {}),
|
...(userMessage.metadata ?? {}),
|
||||||
...(originalDisplayText ? { displayText: originalDisplayText } : {}),
|
...(originalDisplayText ? { displayText: originalDisplayText } : {}),
|
||||||
...(canonicalImageUrls.length ? { imageUrls: canonicalImageUrls } : {}),
|
...(canonicalImageUrls.length ? { imageUrls: canonicalImageUrls } : {}),
|
||||||
|
...(allowedPageImageEmbedKeys.length
|
||||||
|
? { allowedPageImageEmbedKeys }
|
||||||
|
: {}),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
canonicalImageUrls,
|
canonicalImageUrls,
|
||||||
@@ -1618,11 +1644,12 @@ export function createTkmindProxy({
|
|||||||
// Step 2 — Inject Qwen's text description + server-relative image paths into the
|
// Step 2 — Inject Qwen's text description + server-relative image paths into the
|
||||||
// user_message that goes to DeepSeek via Goose. DeepSeek retains full
|
// user_message that goes to DeepSeek via Goose. DeepSeek retains full
|
||||||
// tool-calling capability (write_file, etc.) and creates the page properly.
|
// 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({
|
return buildVisionPayload({
|
||||||
userMessage,
|
userMessage,
|
||||||
userId,
|
userId,
|
||||||
publishLayout,
|
publishLayout,
|
||||||
|
publishDir,
|
||||||
localFetchAsset,
|
localFetchAsset,
|
||||||
llmProviderService,
|
llmProviderService,
|
||||||
imgproxySigner,
|
imgproxySigner,
|
||||||
@@ -2039,7 +2066,13 @@ export function createTkmindProxy({
|
|||||||
let finalUserMessage = userMessage;
|
let finalUserMessage = userMessage;
|
||||||
if (llmProviderService && messageHasImages(userMessage) && await llmProviderService.hasVisionKey()) {
|
if (llmProviderService && messageHasImages(userMessage) && await llmProviderService.hasVisionKey()) {
|
||||||
const publishLayout = await userAuth.getUserPublishLayout(userId).catch(() => null);
|
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) {
|
if (visionResult?.userMessage) {
|
||||||
finalUserMessage = visionResult.userMessage;
|
finalUserMessage = visionResult.userMessage;
|
||||||
}
|
}
|
||||||
|
|||||||
+36
-4
@@ -1,4 +1,7 @@
|
|||||||
import crypto from 'node:crypto';
|
import crypto from 'node:crypto';
|
||||||
|
import {
|
||||||
|
buildAllowedPageImageEmbedKeys,
|
||||||
|
} from './chat-image-materialize.mjs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { fetch as undiciFetch } from 'undici';
|
import { fetch as undiciFetch } from 'undici';
|
||||||
import { developerToolsFromPolicy } from './capabilities.mjs';
|
import { developerToolsFromPolicy } from './capabilities.mjs';
|
||||||
@@ -1121,6 +1124,20 @@ export function resolveWechatRecentMediaPublicUrl(recentMediaEntry) {
|
|||||||
return String(recentMediaEntry.items.at(-1)?.media?.publicUrl ?? '').trim();
|
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 = '' } = {}) {
|
export function prepareWechatIntentForHistoricalImageRetry(intent, { fallbackImageUrl = '' } = {}) {
|
||||||
if (!intent || typeof intent !== 'object') return intent;
|
if (!intent || typeof intent !== 'object') return intent;
|
||||||
const imageUrl = String(intent.media?.publicUrl ?? fallbackImageUrl ?? '').trim();
|
const imageUrl = String(intent.media?.publicUrl ?? fallbackImageUrl ?? '').trim();
|
||||||
@@ -2561,16 +2578,13 @@ export function createWechatMpService({
|
|||||||
pgRequired = false,
|
pgRequired = false,
|
||||||
} = {},
|
} = {},
|
||||||
) => {
|
) => {
|
||||||
|
const imageUrls = collectIntentImageUrls(intent, { mediaAnalysisEnabled });
|
||||||
const mediaPublicUrl = intent.media?.publicUrl || null;
|
const mediaPublicUrl = intent.media?.publicUrl || null;
|
||||||
const mediaItems = Array.isArray(intent.recentMediaItems) && intent.recentMediaItems.length > 0
|
const mediaItems = Array.isArray(intent.recentMediaItems) && intent.recentMediaItems.length > 0
|
||||||
? intent.recentMediaItems
|
? intent.recentMediaItems
|
||||||
: mediaPublicUrl
|
: mediaPublicUrl
|
||||||
? [{ media: intent.media, attachment: intent.attachment ?? null }]
|
? [{ 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
|
const fileAttachments = mediaItems
|
||||||
.filter((item) => item.attachment?.filename && item.media?.publicUrl)
|
.filter((item) => item.attachment?.filename && item.media?.publicUrl)
|
||||||
.map((item) => ({
|
.map((item) => ({
|
||||||
@@ -3071,6 +3085,14 @@ export function createWechatMpService({
|
|||||||
: [];
|
: [];
|
||||||
|
|
||||||
if (wechatIntent.kind === 'page.generate') {
|
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({
|
const pageOutcome = resolvePageGenerateOutcome({
|
||||||
reply,
|
reply,
|
||||||
confirmedArtifacts,
|
confirmedArtifacts,
|
||||||
@@ -3084,6 +3106,7 @@ export function createWechatMpService({
|
|||||||
publishDir: userPublishDir,
|
publishDir: userPublishDir,
|
||||||
},
|
},
|
||||||
wechatCursorChannel: isWechatCursorChannelReply(reply),
|
wechatCursorChannel: isWechatCursorChannelReply(reply),
|
||||||
|
allowedImageEmbedKeys,
|
||||||
});
|
});
|
||||||
if (pageOutcome.action === 'session_retry') {
|
if (pageOutcome.action === 'session_retry') {
|
||||||
if (sessionPageContinuation && pageAttempt < maxImmediateContextPageAttempts) {
|
if (sessionPageContinuation && pageAttempt < maxImmediateContextPageAttempts) {
|
||||||
@@ -3479,6 +3502,14 @@ export function createWechatMpService({
|
|||||||
: [];
|
: [];
|
||||||
|
|
||||||
if (wechatIntent.kind === 'page.generate') {
|
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({
|
const pageOutcome = resolvePageGenerateOutcome({
|
||||||
reply,
|
reply,
|
||||||
confirmedArtifacts,
|
confirmedArtifacts,
|
||||||
@@ -3492,6 +3523,7 @@ export function createWechatMpService({
|
|||||||
publishDir: userPublishDir,
|
publishDir: userPublishDir,
|
||||||
},
|
},
|
||||||
wechatCursorChannel: isWechatCursorChannelReply(reply),
|
wechatCursorChannel: isWechatCursorChannelReply(reply),
|
||||||
|
allowedImageEmbedKeys,
|
||||||
});
|
});
|
||||||
if (pageOutcome.action === 'session_retry' || pageOutcome.action === 'fail') {
|
if (pageOutcome.action === 'session_retry' || pageOutcome.action === 'fail') {
|
||||||
const text = pageOutcome.failureText ?? buildPagePublishFailureText();
|
const text = pageOutcome.failureText ?? buildPagePublishFailureText();
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { buildPagePublishFailureText } from '../prompts/page-generate.mjs';
|
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 { repairArtifactSharePreview } from '../verify/share-preview-repair.mjs';
|
||||||
import { filterSharePreviewReadyArtifacts, verifyArtifactSharePreview } from '../verify/share-preview.mjs';
|
import { filterSharePreviewReadyArtifacts, verifyArtifactSharePreview } from '../verify/share-preview.mjs';
|
||||||
|
|
||||||
@@ -7,6 +7,7 @@ export function evaluatePageGenerateSendableArtifacts({
|
|||||||
verifiedArtifacts = [],
|
verifiedArtifacts = [],
|
||||||
confirmedArtifacts = [],
|
confirmedArtifacts = [],
|
||||||
repairContext = {},
|
repairContext = {},
|
||||||
|
allowedImageEmbedKeys = null,
|
||||||
} = {}) {
|
} = {}) {
|
||||||
const publishDir = String(repairContext.publishDir ?? '').trim();
|
const publishDir = String(repairContext.publishDir ?? '').trim();
|
||||||
const previewOptions = { publishDir };
|
const previewOptions = { publishDir };
|
||||||
@@ -18,7 +19,13 @@ export function evaluatePageGenerateSendableArtifacts({
|
|||||||
const verified = sendable.filter((artifact) =>
|
const verified = sendable.filter((artifact) =>
|
||||||
verifyPageArtifactContent(artifact, previewOptions).ok,
|
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);
|
const ready = filterSharePreviewReadyArtifacts(candidates, previewOptions);
|
||||||
if (ready.length > 0) return ready;
|
if (ready.length > 0) return ready;
|
||||||
|
|
||||||
@@ -48,6 +55,7 @@ export function resolvePageGenerateOutcome({
|
|||||||
topic = '',
|
topic = '',
|
||||||
repairContext = {},
|
repairContext = {},
|
||||||
wechatCursorChannel = false,
|
wechatCursorChannel = false,
|
||||||
|
allowedImageEmbedKeys = null,
|
||||||
}) {
|
}) {
|
||||||
const context = {
|
const context = {
|
||||||
topic: String(topic || repairContext.topic || '').trim(),
|
topic: String(topic || repairContext.topic || '').trim(),
|
||||||
@@ -59,8 +67,30 @@ export function resolvePageGenerateOutcome({
|
|||||||
verifiedArtifacts,
|
verifiedArtifacts,
|
||||||
confirmedArtifacts,
|
confirmedArtifacts,
|
||||||
repairContext: context,
|
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) {
|
if (sendable.length > 0) {
|
||||||
return { action: 'send', artifacts: sendable };
|
return { action: 'send', artifacts: sendable };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
|
import { verifyHtmlImageSourcesAllowed } from '../../chat-image-materialize.mjs';
|
||||||
|
|
||||||
const STUB_MARKERS = ['临时补出', '服务号兜底', '服务号自动补出简版页面'];
|
const STUB_MARKERS = ['临时补出', '服务号兜底', '服务号自动补出简版页面'];
|
||||||
|
|
||||||
@@ -106,3 +107,36 @@ export function verifyPageArtifactContent(artifact, { minBytes = 512, publishDir
|
|||||||
}
|
}
|
||||||
return { ok: true, reason: null };
|
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 ?? [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
} from './verify/page-artifact.mjs';
|
} from './verify/page-artifact.mjs';
|
||||||
import { guardScheduleConfirmationReply, looksLikeScheduleConfirmation } from './handlers/schedule-guard.mjs';
|
import { guardScheduleConfirmationReply, looksLikeScheduleConfirmation } from './handlers/schedule-guard.mjs';
|
||||||
import { resolvePageGenerateOutcome } from './handlers/page-generate.mjs';
|
import { resolvePageGenerateOutcome } from './handlers/page-generate.mjs';
|
||||||
|
import { buildAllowedPageImageEmbedKeys } from '../chat-image-materialize.mjs';
|
||||||
import { buildGreetingText, resolveSyncReply } from './handlers/sync-replies.mjs';
|
import { buildGreetingText, resolveSyncReply } from './handlers/sync-replies.mjs';
|
||||||
import { buildWechatAgentPrompt } from './prompts/chat-general.mjs';
|
import { buildWechatAgentPrompt } from './prompts/chat-general.mjs';
|
||||||
import {
|
import {
|
||||||
@@ -167,6 +168,33 @@ test('schedule confirmation guard rewrites agent pseudo-confirm when ITL enabled
|
|||||||
assert.doesNotMatch(guarded, /不能算设置成功/);
|
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,
|
||||||
|
`<!doctype html><html><head><meta name="description" content="摘要"><meta name="mindspace-cover" content='{"tag":"页面","cover":"images/2026-08-22/old.jpg"}'><p data-mindspace-page-tag="platform-brand">TKMind · 智趣</p></head><body><main>${'x'.repeat(600)}<img src="images/2026-08-22/old.jpg"></main></body></html>`,
|
||||||
|
'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', () => {
|
test('resolvePageGenerateOutcome fails when only stub artifacts exist', () => {
|
||||||
const outcome = resolvePageGenerateOutcome({
|
const outcome = resolvePageGenerateOutcome({
|
||||||
reply: { text: '页面已生成 https://m.tkmind.cn/MindSpace/u/public/tang-poem.html' },
|
reply: { text: '页面已生成 https://m.tkmind.cn/MindSpace/u/public/tang-poem.html' },
|
||||||
|
|||||||
Reference in New Issue
Block a user