Files
memind/chat-image-turn-scope.mjs
john db225b784e
Memind CI / Test, build, and release guards (pull_request) Successful in 2m36s
fix(wechat): isolate stale images and page links
2026-07-22 14:08:18 +08:00

175 lines
6.0 KiB
JavaScript

const MINDSPACE_ASSET_ID_RE = /\/mindspace\/v1\/assets\/([^/?#]+)/i;
const PUBLIC_IMAGE_PATH_RE = /\/public\/images\/([^?#\s"'<>]+)/i;
const IMAGE_URL_LINE_RE = /^\[图片\d+]: (.+)$/;
const IMAGE_URL_LINES_RE = /\n*\[图片\d+]: [^\n]+/g;
const TKMIND_VISION_NOTE_RE = /\n*【TKMind 图片分析结果[\s\S]*$/;
export function extractImageAssetKey(url) {
const value = String(url ?? '').trim();
if (!value) return '';
const assetMatch = value.match(MINDSPACE_ASSET_ID_RE);
if (assetMatch?.[1]) return `asset:${decodeURIComponent(assetMatch[1])}`;
const publicMatch = value.match(PUBLIC_IMAGE_PATH_RE);
if (publicMatch?.[1]) return `public:${decodeURIComponent(publicMatch[1])}`;
try {
const parsed = value.startsWith('http://') || value.startsWith('https://')
? new URL(value)
: new URL(value, 'http://local');
return `path:${parsed.pathname}${parsed.search}`;
} catch {
return `raw:${value}`;
}
}
export function dedupeImageUrlsByAssetKey(urls) {
const seen = new Set();
const result = [];
for (const raw of urls) {
const url = String(raw ?? '').trim();
if (!url) continue;
const key = extractImageAssetKey(url);
if (seen.has(key)) continue;
seen.add(key);
result.push(url);
}
return result;
}
export function collectImageUrlsFromMessageContent(content) {
const urls = [];
if (!Array.isArray(content)) return urls;
for (const item of content) {
if (item?.type === 'image_url' && item.image_url?.url) {
urls.push(String(item.image_url.url).trim());
continue;
}
if (item?.type !== 'text' || typeof item.text !== 'string') continue;
for (const line of item.text.split('\n')) {
const match = line.trim().match(IMAGE_URL_LINE_RE);
if (match?.[1]) urls.push(match[1].trim());
}
}
return urls;
}
/**
* Current-turn image URLs: metadata.imageUrls is authoritative when present.
*/
export function extractCurrentTurnImageUrls(userMessage) {
const fromMetadata = Array.isArray(userMessage?.metadata?.imageUrls)
? userMessage.metadata.imageUrls
.filter((url) => typeof url === 'string' && url.trim())
.map((url) => url.trim())
: [];
if (fromMetadata.length > 0) {
return dedupeImageUrlsByAssetKey(fromMetadata);
}
return dedupeImageUrlsByAssetKey(collectImageUrlsFromMessageContent(userMessage?.content));
}
function stripAgentImageText(text) {
return String(text ?? '')
.replace(TKMIND_VISION_NOTE_RE, '')
.replace(IMAGE_URL_LINES_RE, '')
.trim();
}
/**
* Remove image attachments from a persisted user message so later turns cannot reuse them.
* UI displayText / previewImageUrls are preserved for chat history rendering.
*/
export function scrubUserMessageImageAttachments(message) {
if (!message || message.role !== 'user') return { message, changed: false };
const metadata =
message.metadata && typeof message.metadata === 'object' && !Array.isArray(message.metadata)
? { ...message.metadata }
: {};
const hadImageMetadata =
Array.isArray(metadata.imageUrls) && metadata.imageUrls.length > 0
|| Array.isArray(metadata.previewImageUrls) && metadata.previewImageUrls.length > 0;
if (Array.isArray(metadata.imageUrls) && metadata.imageUrls.length > 0) {
metadata.archivedImageUrls = metadata.imageUrls;
}
if (Array.isArray(metadata.previewImageUrls) && metadata.previewImageUrls.length > 0) {
metadata.archivedPreviewImageUrls = metadata.previewImageUrls;
}
delete metadata.imageUrls;
delete metadata.previewImageUrls;
let contentChanged = false;
const content = Array.isArray(message.content)
? message.content.map((item) => {
if (item?.type === 'image_url') {
contentChanged = true;
return null;
}
if (item?.type !== 'text' || typeof item.text !== 'string') return item;
const nextText = stripAgentImageText(item.text);
if (nextText === item.text) return item;
contentChanged = true;
return nextText ? { ...item, text: nextText } : null;
}).filter(Boolean)
: message.content;
const displayText =
typeof metadata.displayText === 'string' ? metadata.displayText : null;
const changed = hadImageMetadata || contentChanged;
if (!changed) return { message, changed: false };
return {
message: {
...message,
content,
metadata,
...(displayText != null ? {} : {}),
},
changed: true,
};
}
export function scrubConversationHistoricalImageAttachments(conversation, activeMessageId) {
const activeId = String(activeMessageId ?? '').trim();
if (!Array.isArray(conversation) || !activeId) {
return { conversation, changed: false };
}
let changed = false;
const nextConversation = conversation.map((message) => {
if (message?.role !== 'user') return message;
if (String(message?.id ?? '').trim() === activeId) return message;
const scrubbed = scrubUserMessageImageAttachments(message);
if (scrubbed.changed) changed = true;
return scrubbed.message;
});
return {
conversation: nextConversation,
changed,
};
}
export function buildCurrentTurnImageScopeNote(imageItems) {
if (!Array.isArray(imageItems) || imageItems.length === 0) return '';
const lines = imageItems.map((item, index) => {
const assetKey = extractImageAssetKey(item.rawUrl ?? item.embedUrl ?? '');
const assetId = assetKey.startsWith('asset:') ? assetKey.slice('asset:'.length) : `图片${index + 1}`;
return ` ${index + 1}. asset=${assetId} 页面嵌入=${item.embedUrl ?? item.rawUrl ?? ''}`;
});
return (
'【本轮图片主题(硬性)】\n' +
`- 本轮用户仅上传 ${imageItems.length} 张图片;每次上传都是独立主题,不得与历史轮次混用。\n` +
`- 用户说「这个图片 / 根据这张图 / 这张」时,只能使用下列本轮附件:\n${lines.join('\n')}\n` +
'- 对话历史中更早消息里出现过的图片、asset、<img> 路径均已作废,禁止用于本轮任务。\n\n'
);
}
export const chatImageTurnScopeInternals = {
IMAGE_URL_LINE_RE,
TKMIND_VISION_NOTE_RE,
};