32fb2cdeaf
Add chat file/image upload UX, attachment proxying, vision thumbnails, and per-turn image scoping so agents only use the current upload. Extend MindSpace asset context, billing token state, OA/scenario verify scripts, and related runtime config. Co-authored-by: Cursor <cursoragent@cursor.com>
171 lines
5.9 KiB
JavaScript
171 lines
5.9 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 !== '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,
|
|
};
|