feat: chat uploads, vision turn isolation, and MindSpace agent improvements
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>
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
import type { UserNotification } from '../types';
|
||||
|
||||
export const BALANCE_REFRESH_EVENT = 'tkmind:balance-refresh';
|
||||
|
||||
const RECHARGE_NOTIFICATION_TYPES = new Set(['admin_recharge', 'self_recharge', 'recharge']);
|
||||
|
||||
export function isRechargeNotification(
|
||||
notification: Pick<UserNotification, 'notificationType' | 'title' | 'body'>,
|
||||
): boolean {
|
||||
if (RECHARGE_NOTIFICATION_TYPES.has(String(notification.notificationType ?? '').trim())) {
|
||||
return true;
|
||||
}
|
||||
const title = String(notification.title ?? '');
|
||||
const body = String(notification.body ?? '');
|
||||
return /充值/.test(title) && /余额已更新/.test(body);
|
||||
}
|
||||
|
||||
export function requestBalanceRefresh() {
|
||||
if (typeof window === 'undefined') return;
|
||||
window.dispatchEvent(new CustomEvent(BALANCE_REFRESH_EVENT));
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import type { ChatFileAttachment } from '../types';
|
||||
|
||||
export type { ChatFileAttachment };
|
||||
|
||||
export const CHAT_FILE_UPLOAD_EXTENSIONS = [
|
||||
'.pdf',
|
||||
'.doc',
|
||||
'.docx',
|
||||
'.xls',
|
||||
'.xlsx',
|
||||
'.ppt',
|
||||
'.pptx',
|
||||
'.txt',
|
||||
'.md',
|
||||
'.csv',
|
||||
'.html',
|
||||
'.htm',
|
||||
] as const;
|
||||
|
||||
export const CHAT_FILE_UPLOAD_ACCEPT = CHAT_FILE_UPLOAD_EXTENSIONS.join(',');
|
||||
export const CHAT_UPLOAD_ACCEPT = `image/*,${CHAT_FILE_UPLOAD_ACCEPT}`;
|
||||
export const CHAT_FILE_UPLOAD_MAX_COUNT = 5;
|
||||
export const CHAT_FILE_UPLOAD_MAX_INPUT_BYTES = 30 * 1024 * 1024;
|
||||
export const CHAT_FILE_UPLOAD_MAX_TOTAL_BYTES = 60 * 1024 * 1024;
|
||||
export const CHAT_FILE_UPLOAD_TYPE_LABEL = 'Word、Excel、PDF、PPT、Markdown、CSV、TXT 和 HTML';
|
||||
|
||||
const EXTENSION_MIME = new Map<string, string>([
|
||||
['.txt', 'text/plain'],
|
||||
['.md', 'text/markdown'],
|
||||
['.csv', 'text/csv'],
|
||||
['.pdf', 'application/pdf'],
|
||||
['.doc', 'application/msword'],
|
||||
['.docx', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'],
|
||||
['.xls', 'application/vnd.ms-excel'],
|
||||
['.xlsx', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'],
|
||||
['.ppt', 'application/vnd.ms-powerpoint'],
|
||||
['.pptx', 'application/vnd.openxmlformats-officedocument.presentationml.presentation'],
|
||||
['.html', 'text/html'],
|
||||
['.htm', 'text/html'],
|
||||
]);
|
||||
|
||||
export function isChatImageFile(file: File): boolean {
|
||||
if (file.type.startsWith('image/')) return true;
|
||||
return /\.(png|jpe?g|webp|gif)$/i.test(file.name);
|
||||
}
|
||||
|
||||
export function isChatFileAttachment(file: File): boolean {
|
||||
if (isChatImageFile(file)) return false;
|
||||
const extension = file.name.slice(file.name.lastIndexOf('.')).toLowerCase();
|
||||
return CHAT_FILE_UPLOAD_EXTENSIONS.includes(extension as (typeof CHAT_FILE_UPLOAD_EXTENSIONS)[number]);
|
||||
}
|
||||
|
||||
export type ChatUploadKind = 'image' | 'attachment' | 'unsupported';
|
||||
|
||||
/** 统一上传入口:图片优先走图片通道,其余合法文档走附件通道。 */
|
||||
export function classifyChatUploadFile(file: File): ChatUploadKind {
|
||||
if (isChatImageFile(file)) return 'image';
|
||||
if (isChatFileAttachment(file)) return 'attachment';
|
||||
return 'unsupported';
|
||||
}
|
||||
|
||||
const IMAGE_EXTENSION_MIME = new Map<string, string>([
|
||||
['.png', 'image/png'],
|
||||
['.jpg', 'image/jpeg'],
|
||||
['.jpeg', 'image/jpeg'],
|
||||
['.webp', 'image/webp'],
|
||||
['.gif', 'image/gif'],
|
||||
['.heic', 'image/heic'],
|
||||
['.heif', 'image/heif'],
|
||||
]);
|
||||
|
||||
/** 仅用于 UI 入队:补齐 MIME,不改动 uploadChatImage 本身。 */
|
||||
export function prepareChatImageUploadFile(file: File): File | null {
|
||||
if (!isChatImageFile(file)) return null;
|
||||
if (file.type.startsWith('image/')) return file;
|
||||
const extension = file.name.slice(file.name.lastIndexOf('.')).toLowerCase();
|
||||
const mimeType = IMAGE_EXTENSION_MIME.get(extension);
|
||||
if (!mimeType) return null;
|
||||
return new File([file], file.name, { type: mimeType, lastModified: file.lastModified });
|
||||
}
|
||||
|
||||
export function resolveChatFileMimeType(file: File): string {
|
||||
if (file.type && file.type !== 'application/octet-stream') return file.type;
|
||||
const extension = file.name.slice(file.name.lastIndexOf('.')).toLowerCase();
|
||||
return EXTENSION_MIME.get(extension) ?? 'application/octet-stream';
|
||||
}
|
||||
+62
-6
@@ -1,4 +1,4 @@
|
||||
import type { Message, MessageContent } from '../types';
|
||||
import type { ChatFileAttachment, Message, MessageContent } from '../types';
|
||||
import { mergeMessageContent } from '../../message-stream.mjs';
|
||||
import { mergeConversationSnapshot as mergeConversationSnapshotCore } from '../../chat-finish-sync.mjs';
|
||||
import { deriveUserFacingText, deriveAssistantFacingText } from '../../conversation-display.mjs';
|
||||
@@ -6,8 +6,11 @@ import { stripUserAddressPrefix } from './userAddress';
|
||||
|
||||
const IMAGE_URL_LINE_RE = /^\[图片\d+]: (.+)$/;
|
||||
const IMAGE_URL_LINES_RE = /\n*\[图片\d+]: [^\n]+/g;
|
||||
const FILE_ATTACHMENT_LINE_RE = /^\[文件\d+: ([^\]]+)\]: (.+)$/;
|
||||
const FILE_ATTACHMENT_LINES_RE = /\n*\[文件\d+: [^\]]+\]: [^\n]+/g;
|
||||
const USER_IDENTITY_BLOCK_RE = /^\[用户身份\][\s\S]*?(?:\n{2,}|$)/;
|
||||
const TKMIND_VISION_NOTE_RE = /\n*【TKMind 图片分析结果[\s\S]*$/;
|
||||
const TKMIND_ATTACHMENT_NOTE_RE = /\n*【TKMind 附件分析结果[\s\S]*$/;
|
||||
|
||||
function normalizeImageUrl(url: string): string {
|
||||
const value = String(url ?? '').trim();
|
||||
@@ -41,10 +44,19 @@ function stripImageUrlLines(text: string): string {
|
||||
return String(text ?? '')
|
||||
.replace(USER_IDENTITY_BLOCK_RE, '')
|
||||
.replace(TKMIND_VISION_NOTE_RE, '')
|
||||
.replace(TKMIND_ATTACHMENT_NOTE_RE, '')
|
||||
.replace(IMAGE_URL_LINES_RE, '')
|
||||
.replace(FILE_ATTACHMENT_LINES_RE, '')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function formatFileAttachmentsForAgentText(attachments: ChatFileAttachment[]): string {
|
||||
return attachments
|
||||
.filter((item) => item?.downloadUrl?.trim())
|
||||
.map((item, index) => `[文件${index + 1}: ${item.filename}]: ${item.downloadUrl.trim()}`)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
function parseImageUrlsFromText(text: string): string[] {
|
||||
const urls: string[] = [];
|
||||
for (const line of text.split('\n')) {
|
||||
@@ -82,6 +94,7 @@ export function normalizeUserMessageForApi(message: Message): Message {
|
||||
if (message.role !== 'user') return message;
|
||||
|
||||
const imageUrls = getImageUrls(message);
|
||||
const fileAttachments = getFileAttachments(message);
|
||||
const displayText =
|
||||
message.metadata.displayText ??
|
||||
stripImageUrlLines(
|
||||
@@ -97,12 +110,17 @@ export function normalizeUserMessageForApi(message: Message): Message {
|
||||
.join('\n')
|
||||
.trim();
|
||||
|
||||
if (imageUrls.length === 0 && message.content.every((item) => item.type === 'text')) {
|
||||
if (
|
||||
imageUrls.length === 0 &&
|
||||
fileAttachments.length === 0 &&
|
||||
message.content.every((item) => item.type === 'text')
|
||||
) {
|
||||
return message;
|
||||
}
|
||||
|
||||
const imageText = formatImageUrlsForAgentText(imageUrls);
|
||||
const agentText = [stripImageUrlLines(textOnly), imageText]
|
||||
const fileText = formatFileAttachmentsForAgentText(fileAttachments);
|
||||
const agentText = [stripImageUrlLines(textOnly), imageText, fileText]
|
||||
.filter(Boolean)
|
||||
.join('\n\n');
|
||||
|
||||
@@ -113,6 +131,7 @@ export function normalizeUserMessageForApi(message: Message): Message {
|
||||
...message.metadata,
|
||||
displayText,
|
||||
...(imageUrls.length ? { imageUrls } : {}),
|
||||
...(fileAttachments.length ? { fileAttachments } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -125,6 +144,7 @@ export function buildUserMessage(
|
||||
displayText?: string;
|
||||
imageUrls?: string[];
|
||||
previewImageUrls?: string[];
|
||||
fileAttachments?: ChatFileAttachment[];
|
||||
},
|
||||
): Message {
|
||||
const displayText = stripImageUrlLines(options?.displayText ?? text);
|
||||
@@ -132,9 +152,13 @@ export function buildUserMessage(
|
||||
const previewImageUrls = (options?.previewImageUrls ?? []).filter(
|
||||
(value) => typeof value === 'string' && value.trim(),
|
||||
);
|
||||
const fileAttachments = (options?.fileAttachments ?? []).filter(
|
||||
(item) => item?.downloadUrl?.trim() && item?.filename?.trim(),
|
||||
);
|
||||
const baseAgentText = options?.agentText ?? text;
|
||||
const imageText = formatImageUrlsForAgentText(imageUrls);
|
||||
const agentText = [baseAgentText.trim(), imageText].filter(Boolean).join('\n\n');
|
||||
const fileText = formatFileAttachmentsForAgentText(fileAttachments);
|
||||
const agentText = [baseAgentText.trim(), imageText, fileText].filter(Boolean).join('\n\n');
|
||||
|
||||
return {
|
||||
id: options?.id ?? crypto.randomUUID(),
|
||||
@@ -147,6 +171,7 @@ export function buildUserMessage(
|
||||
displayText,
|
||||
...(imageUrls.length ? { imageUrls } : {}),
|
||||
...(previewImageUrls.length ? { previewImageUrls } : {}),
|
||||
...(fileAttachments.length ? { fileAttachments } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -199,6 +224,7 @@ export function getDisplayText(message: Message): string {
|
||||
export function shouldShowChatMessage(message: Message): boolean {
|
||||
if (getDisplayText(message).trim()) return true;
|
||||
if (message.role === 'user' && getImageUrls(message).length > 0) return true;
|
||||
if (message.role === 'user' && getFileAttachments(message).length > 0) return true;
|
||||
if (getThinking(message)) return true;
|
||||
if (getSystemNotificationText(message)) return true;
|
||||
if (message.role !== 'assistant') return false;
|
||||
@@ -230,7 +256,10 @@ export function getVisibleText(message: Message): string {
|
||||
}
|
||||
|
||||
export function getImageUrls(message: Message): string[] {
|
||||
const fromMetadata = message.metadata.imageUrls?.filter(
|
||||
const fromMetadata = (
|
||||
message.metadata.imageUrls
|
||||
?? message.metadata.archivedImageUrls
|
||||
)?.filter(
|
||||
(url) => typeof url === 'string' && url.trim(),
|
||||
);
|
||||
if (fromMetadata?.length) return fromMetadata.map(normalizeImageUrl);
|
||||
@@ -245,9 +274,36 @@ export function getImageUrls(message: Message): string[] {
|
||||
return parseImageUrlsFromText(text).map(normalizeImageUrl);
|
||||
}
|
||||
|
||||
export function getFileAttachments(message: Message): ChatFileAttachment[] {
|
||||
const fromMetadata = message.metadata.fileAttachments?.filter(
|
||||
(item) => item?.downloadUrl?.trim() && item?.filename?.trim(),
|
||||
);
|
||||
if (fromMetadata?.length) return fromMetadata;
|
||||
|
||||
const text = message.content
|
||||
.filter((item): item is Extract<MessageContent, { type: 'text' }> => item.type === 'text')
|
||||
.map((item) => item.text)
|
||||
.join('\n');
|
||||
const parsed: ChatFileAttachment[] = [];
|
||||
for (const line of text.split('\n')) {
|
||||
const match = line.trim().match(FILE_ATTACHMENT_LINE_RE);
|
||||
if (!match?.[1] || !match?.[2]) continue;
|
||||
parsed.push({
|
||||
assetId: '',
|
||||
filename: match[1],
|
||||
downloadUrl: match[2],
|
||||
mimeType: 'application/octet-stream',
|
||||
});
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
export function getRenderableImageUrls(message: Message): Array<{ src: string; href: string }> {
|
||||
const imageUrls = getImageUrls(message);
|
||||
const previewImageUrls = message.metadata.previewImageUrls?.filter(
|
||||
const previewImageUrls = (
|
||||
message.metadata.previewImageUrls
|
||||
?? message.metadata.archivedPreviewImageUrls
|
||||
)?.filter(
|
||||
(url) => typeof url === 'string' && url.trim(),
|
||||
) ?? [];
|
||||
return imageUrls.map((href, index) => ({
|
||||
|
||||
@@ -37,10 +37,13 @@ export function resolveMindSpaceHomeUrl(): string {
|
||||
return `${window.location.origin}/space`;
|
||||
}
|
||||
|
||||
/** 已发布页面的独立访问链接(非空间内编辑路由)。未发布时返回 null。 */
|
||||
/** 已发布页面的独立访问链接(非空间内编辑路由)。优先 MindSpace 工作区 URL。 */
|
||||
export function resolveMindSpacePagePublicViewUrl(page: {
|
||||
publicationUrl?: string | null;
|
||||
workspacePublicUrl?: string | null;
|
||||
}): string | null {
|
||||
const workspace = String(page.workspacePublicUrl ?? '').trim();
|
||||
if (workspace) return resolvePublicPageUrl(workspace);
|
||||
const value = String(page.publicationUrl ?? '').trim();
|
||||
if (!value) return null;
|
||||
return resolvePublicPageUrl(value);
|
||||
|
||||
Reference in New Issue
Block a user