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:
+443
-105
@@ -12,6 +12,16 @@ import {
|
||||
CHAT_IMAGE_UPLOAD_MAX_INPUT_BYTES,
|
||||
CHAT_IMAGE_UPLOAD_MAX_TOTAL_BYTES,
|
||||
} from '../utils/imageUpload';
|
||||
import {
|
||||
CHAT_FILE_UPLOAD_MAX_COUNT,
|
||||
CHAT_FILE_UPLOAD_MAX_INPUT_BYTES,
|
||||
CHAT_FILE_UPLOAD_MAX_TOTAL_BYTES,
|
||||
CHAT_FILE_UPLOAD_TYPE_LABEL,
|
||||
CHAT_UPLOAD_ACCEPT,
|
||||
classifyChatUploadFile,
|
||||
isChatFileAttachment,
|
||||
prepareChatImageUploadFile,
|
||||
} from '../utils/chatAttachment';
|
||||
import { AvatarPicker } from './AvatarPicker';
|
||||
import { ChatSkillPicker } from './ChatSkillPicker';
|
||||
import { ChatLoadingSpinner } from './ChatLoadingSpinner';
|
||||
@@ -20,7 +30,7 @@ import { ChatPlazaPublishModal } from './ChatPlazaPublishModal';
|
||||
import { MessageList } from './MessageList';
|
||||
import { PageSaveDialog } from './PageSaveDialog';
|
||||
import { VoiceInputButton } from './VoiceInputButton';
|
||||
import type { CapabilityMap, ChatState, Message, PortalUser, Session, ToolConfirmation } from '../types';
|
||||
import type { CapabilityMap, ChatFileAttachment, ChatState, Message, PortalUser, Session, ToolConfirmation } from '../types';
|
||||
import type { MindSpaceSaveCategory } from '../types';
|
||||
|
||||
const CHAT_PLACEHOLDER_PROMPTS = [
|
||||
@@ -67,19 +77,44 @@ type PendingChatImage = {
|
||||
uploadStatus: 'queued' | 'uploading' | 'uploaded' | 'error';
|
||||
};
|
||||
|
||||
type PendingChatFile = {
|
||||
id: string;
|
||||
file: File;
|
||||
filename: string;
|
||||
sizeBytes: number;
|
||||
uploadedAttachment: ChatFileAttachment | null;
|
||||
sourceMessageId: string | null;
|
||||
uploadProgress: number | null;
|
||||
uploadStatus: 'queued' | 'uploading' | 'uploaded' | 'error';
|
||||
};
|
||||
|
||||
function formatUploadMegabytes(bytes: number) {
|
||||
const megabytes = bytes / (1024 * 1024);
|
||||
return `${Number.isInteger(megabytes) ? megabytes : megabytes.toFixed(1)}MB`;
|
||||
}
|
||||
|
||||
function FileIcon() {
|
||||
function UploadIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M14 2H7a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V7zm0 0v5h5"
|
||||
d="M8.5 12.5 12 9l3.5 3.5"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.8"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M12 9v10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.8"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M7 5.5h10a2 2 0 0 1 2 2v11a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2v-11a2 2 0 0 1 2-2Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.8"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path fill="currentColor" d="M9 13h6v1.5H9zm0 3h6v1.5H9zm0-6h3v1.5H9z" opacity=".72" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -124,6 +159,7 @@ export function ChatPanel({
|
||||
grantedSkills,
|
||||
onSubmit,
|
||||
onUploadImage,
|
||||
onUploadFile,
|
||||
onLoadOlderMessages,
|
||||
onStop,
|
||||
onApproveTool,
|
||||
@@ -145,13 +181,23 @@ export function ChatPanel({
|
||||
text: string,
|
||||
imageUrls?: string[],
|
||||
previewImageUrls?: string[],
|
||||
options?: { messageId?: string; forceDeepReasoning?: boolean },
|
||||
options?: {
|
||||
messageId?: string;
|
||||
forceDeepReasoning?: boolean;
|
||||
selectedChatSkill?: string;
|
||||
fileAttachments?: ChatFileAttachment[];
|
||||
},
|
||||
) => void | Promise<void>;
|
||||
onUploadImage?: (
|
||||
file: File,
|
||||
onProgress?: (progress: number) => void,
|
||||
options?: { messageId?: string },
|
||||
) => Promise<string>;
|
||||
onUploadFile?: (
|
||||
file: File,
|
||||
onProgress?: (progress: number) => void,
|
||||
options?: { messageId?: string },
|
||||
) => Promise<ChatFileAttachment>;
|
||||
onLoadOlderMessages?: () => void | Promise<void>;
|
||||
onStop: () => void | Promise<void>;
|
||||
onApproveTool: (allow: boolean) => void | Promise<void>;
|
||||
@@ -171,14 +217,17 @@ export function ChatPanel({
|
||||
const [voiceNotice, setVoiceNotice] = useState<string | null>(null);
|
||||
const [forceDeepReasoning, setForceDeepReasoning] = useState(false);
|
||||
const [pendingImages, setPendingImages] = useState<PendingChatImage[]>([]);
|
||||
const [pendingFiles, setPendingFiles] = useState<PendingChatFile[]>([]);
|
||||
const [uploadingImage, setUploadingImage] = useState(false);
|
||||
const [uploadingFile, setUploadingFile] = useState(false);
|
||||
const [imageError, setImageError] = useState<string | null>(null);
|
||||
const [fileError, setFileError] = useState<string | null>(null);
|
||||
const [voiceStopSignal, setVoiceStopSignal] = useState(0);
|
||||
const pendingSkillRef = useRef<string | null>(null);
|
||||
const [randomPrompt] = useState(
|
||||
() => CHAT_PLACEHOLDER_PROMPTS[Math.floor(Math.random() * CHAT_PLACEHOLDER_PROMPTS.length)],
|
||||
);
|
||||
const imageInputRef = useRef<HTMLInputElement>(null);
|
||||
const uploadInputRef = useRef<HTMLInputElement>(null);
|
||||
const mainRef = useRef<HTMLElement>(null);
|
||||
const inputRef = useRef(input);
|
||||
const voiceBaseRef = useRef('');
|
||||
@@ -338,13 +387,19 @@ export function ChatPanel({
|
||||
? '随时召唤你的小助手吧'
|
||||
: randomPrompt;
|
||||
const connectStatusText =
|
||||
uploadingImage
|
||||
? '正在上传图片,发出后会自动继续…'
|
||||
uploadingImage || uploadingFile
|
||||
? '正在上传,发出后会自动继续…'
|
||||
: chatState === 'connecting'
|
||||
? '正在创建会话…'
|
||||
: null;
|
||||
const taskInputStatusText =
|
||||
chatState === 'streaming'
|
||||
? '正在执行任务…'
|
||||
: chatState === 'waiting'
|
||||
? '请求已提交…'
|
||||
: null;
|
||||
const sendButtonLabel =
|
||||
uploadingImage
|
||||
uploadingImage || uploadingFile
|
||||
? '上传中…'
|
||||
: chatState === 'connecting'
|
||||
? '连接中…'
|
||||
@@ -352,6 +407,9 @@ export function ChatPanel({
|
||||
? '提交中…'
|
||||
: null;
|
||||
|
||||
const canUpload = Boolean(onUploadImage || onUploadFile);
|
||||
const uploadFieldClass = canUpload ? ' chat-input-field-with-image-upload' : '';
|
||||
|
||||
const mergeVoiceText = (spoken: string) => {
|
||||
const base = voiceBaseRef.current.trimEnd();
|
||||
const chunk = spoken.trim();
|
||||
@@ -381,8 +439,8 @@ export function ChatPanel({
|
||||
};
|
||||
|
||||
const voiceDisabled = busy || !!pendingTool || chatState === 'error' || offlineBlocked;
|
||||
const canSubmit = input.trim() || pendingImages.length > 0;
|
||||
const imageAttachmentDisabled = !onUploadImage || busy || uploadingImage || offlineBlocked || !!pendingTool;
|
||||
const canSubmit = input.trim() || pendingImages.length > 0 || pendingFiles.length > 0;
|
||||
const uploadDisabled = !canUpload || busy || uploadingImage || uploadingFile || offlineBlocked || !!pendingTool;
|
||||
|
||||
const revokePendingImage = (item: PendingChatImage) => {
|
||||
if (item.previewUrl.startsWith('blob:')) URL.revokeObjectURL(item.previewUrl);
|
||||
@@ -444,23 +502,101 @@ export function ChatPanel({
|
||||
return { accepted, message };
|
||||
}, [pendingImageBytes, pendingImages.length]);
|
||||
|
||||
const pendingFileBytes = pendingFiles.reduce((sum, item) => sum + item.sizeBytes, 0);
|
||||
|
||||
const planFileUploads = useCallback((files: File[]) => {
|
||||
const remainingCount = CHAT_FILE_UPLOAD_MAX_COUNT - pendingFiles.length;
|
||||
if (remainingCount <= 0) {
|
||||
return {
|
||||
accepted: [],
|
||||
message: `最多支持一次性附加 ${CHAT_FILE_UPLOAD_MAX_COUNT} 个文件`,
|
||||
};
|
||||
}
|
||||
|
||||
const accepted: File[] = [];
|
||||
let nextTotalBytes = pendingFileBytes;
|
||||
let oversizedRejected = false;
|
||||
let totalRejected = false;
|
||||
let countRejected = false;
|
||||
let unsupportedRejected = false;
|
||||
|
||||
for (const file of files) {
|
||||
if (!isChatFileAttachment(file)) {
|
||||
unsupportedRejected = true;
|
||||
continue;
|
||||
}
|
||||
if (accepted.length >= remainingCount) {
|
||||
countRejected = true;
|
||||
continue;
|
||||
}
|
||||
if (file.size > CHAT_FILE_UPLOAD_MAX_INPUT_BYTES) {
|
||||
oversizedRejected = true;
|
||||
continue;
|
||||
}
|
||||
if (nextTotalBytes + file.size > CHAT_FILE_UPLOAD_MAX_TOTAL_BYTES) {
|
||||
totalRejected = true;
|
||||
continue;
|
||||
}
|
||||
accepted.push(file);
|
||||
nextTotalBytes += file.size;
|
||||
}
|
||||
|
||||
if (accepted.length === 0) {
|
||||
if (unsupportedRejected) {
|
||||
return {
|
||||
accepted,
|
||||
message: `仅支持 ${CHAT_FILE_UPLOAD_TYPE_LABEL}`,
|
||||
};
|
||||
}
|
||||
if (oversizedRejected) {
|
||||
return {
|
||||
accepted,
|
||||
message: `单个附件不能超过 ${formatUploadMegabytes(CHAT_FILE_UPLOAD_MAX_INPUT_BYTES)}`,
|
||||
};
|
||||
}
|
||||
return {
|
||||
accepted,
|
||||
message: `附件总大小不能超过 ${formatUploadMegabytes(CHAT_FILE_UPLOAD_MAX_TOTAL_BYTES)}`,
|
||||
};
|
||||
}
|
||||
|
||||
let message = null;
|
||||
if (countRejected) {
|
||||
message = `最多支持 ${CHAT_FILE_UPLOAD_MAX_COUNT} 个附件,本次仅保留前 ${accepted.length} 个`;
|
||||
} else if (unsupportedRejected || oversizedRejected || totalRejected) {
|
||||
message = `已按类型、单文件 ${formatUploadMegabytes(CHAT_FILE_UPLOAD_MAX_INPUT_BYTES)}、总计 ${formatUploadMegabytes(CHAT_FILE_UPLOAD_MAX_TOTAL_BYTES)} 的限制筛选附件`;
|
||||
}
|
||||
return { accepted, message };
|
||||
}, [pendingFileBytes, pendingFiles.length]);
|
||||
|
||||
const submitText = useCallback(async (textOverride?: string, skillIdOverride?: string) => {
|
||||
const baseText = typeof textOverride === 'string' ? textOverride : input;
|
||||
const trimmed = baseText.trim();
|
||||
const selectedChatSkill = skillIdOverride ?? pendingSkillRef.current ?? undefined;
|
||||
pendingSkillRef.current = null;
|
||||
if ((!trimmed && pendingImages.length === 0) || voiceDisabled) return;
|
||||
if ((!trimmed && pendingImages.length === 0 && pendingFiles.length === 0) || voiceDisabled) return;
|
||||
if (pendingImages.length > 0 && !onUploadImage) {
|
||||
setImageError('当前会话暂不支持图片发送');
|
||||
return;
|
||||
}
|
||||
const existingSourceMessageId = pendingImages.find((item) => item.sourceMessageId)?.sourceMessageId;
|
||||
if (pendingFiles.length > 0 && !onUploadFile) {
|
||||
setFileError('当前会话暂不支持附件发送');
|
||||
return;
|
||||
}
|
||||
const existingSourceMessageId =
|
||||
pendingImages.find((item) => item.sourceMessageId)?.sourceMessageId ??
|
||||
pendingFiles.find((item) => item.sourceMessageId)?.sourceMessageId;
|
||||
const outgoingMessageId = existingSourceMessageId ?? crypto.randomUUID();
|
||||
const sentImages = pendingImages.map((item) => ({
|
||||
...item,
|
||||
sourceMessageId: item.sourceMessageId ?? outgoingMessageId,
|
||||
}));
|
||||
let uploadedImages = sentImages;
|
||||
const sentFiles = pendingFiles.map((item) => ({
|
||||
...item,
|
||||
sourceMessageId: item.sourceMessageId ?? outgoingMessageId,
|
||||
}));
|
||||
let uploadedFiles = sentFiles;
|
||||
|
||||
suppressVoiceUpdateRef.current = true;
|
||||
if (voiceRecording) {
|
||||
@@ -470,26 +606,14 @@ export function ChatPanel({
|
||||
voiceBaseRef.current = '';
|
||||
setVoiceNotice(null);
|
||||
setImageError(null);
|
||||
setUploadingImage(true);
|
||||
setFileError(null);
|
||||
if (sentImages.length > 0) setUploadingImage(true);
|
||||
if (sentFiles.length > 0) setUploadingFile(true);
|
||||
try {
|
||||
const uploadedUrls = await Promise.all(
|
||||
sentImages.map(async (item) => {
|
||||
if (item.uploadedUrl) return item.uploadedUrl;
|
||||
setPendingImages((current) =>
|
||||
current.map((candidate) =>
|
||||
candidate.id === item.id
|
||||
? {
|
||||
...candidate,
|
||||
sourceMessageId: item.sourceMessageId,
|
||||
uploadStatus: 'uploading',
|
||||
uploadProgress: 0,
|
||||
}
|
||||
: candidate,
|
||||
),
|
||||
);
|
||||
const uploadedUrl = await onUploadImage!(
|
||||
item.file,
|
||||
(progress) => {
|
||||
const uploadedUrls = sentImages.length > 0
|
||||
? await Promise.all(
|
||||
sentImages.map(async (item) => {
|
||||
if (item.uploadedUrl) return item.uploadedUrl;
|
||||
setPendingImages((current) =>
|
||||
current.map((candidate) =>
|
||||
candidate.id === item.id
|
||||
@@ -497,45 +621,121 @@ export function ChatPanel({
|
||||
...candidate,
|
||||
sourceMessageId: item.sourceMessageId,
|
||||
uploadStatus: 'uploading',
|
||||
uploadProgress: Math.round(progress * 100),
|
||||
uploadProgress: 0,
|
||||
}
|
||||
: candidate,
|
||||
),
|
||||
);
|
||||
},
|
||||
{ messageId: item.sourceMessageId ?? outgoingMessageId },
|
||||
);
|
||||
setPendingImages((current) =>
|
||||
current.map((candidate) =>
|
||||
candidate.id === item.id
|
||||
? {
|
||||
...candidate,
|
||||
sourceMessageId: item.sourceMessageId,
|
||||
uploadedUrl,
|
||||
uploadStatus: 'uploaded',
|
||||
uploadProgress: 100,
|
||||
}
|
||||
: candidate,
|
||||
),
|
||||
);
|
||||
return uploadedUrl;
|
||||
}),
|
||||
);
|
||||
const uploadedUrl = await onUploadImage!(
|
||||
item.file,
|
||||
(progress) => {
|
||||
setPendingImages((current) =>
|
||||
current.map((candidate) =>
|
||||
candidate.id === item.id
|
||||
? {
|
||||
...candidate,
|
||||
sourceMessageId: item.sourceMessageId,
|
||||
uploadStatus: 'uploading',
|
||||
uploadProgress: Math.round(progress * 100),
|
||||
}
|
||||
: candidate,
|
||||
),
|
||||
);
|
||||
},
|
||||
{ messageId: item.sourceMessageId ?? outgoingMessageId },
|
||||
);
|
||||
setPendingImages((current) =>
|
||||
current.map((candidate) =>
|
||||
candidate.id === item.id
|
||||
? {
|
||||
...candidate,
|
||||
sourceMessageId: item.sourceMessageId,
|
||||
uploadedUrl,
|
||||
uploadStatus: 'uploaded',
|
||||
uploadProgress: 100,
|
||||
}
|
||||
: candidate,
|
||||
),
|
||||
);
|
||||
return uploadedUrl;
|
||||
}),
|
||||
)
|
||||
: [];
|
||||
uploadedImages = sentImages.map((item, index) => ({
|
||||
...item,
|
||||
uploadedUrl: uploadedUrls[index] ?? item.uploadedUrl,
|
||||
uploadProgress: uploadedUrls[index] ? 100 : item.uploadProgress,
|
||||
uploadStatus: uploadedUrls[index] ? 'uploaded' : item.uploadStatus,
|
||||
}));
|
||||
const uploadedAttachments = sentFiles.length > 0
|
||||
? await Promise.all(
|
||||
sentFiles.map(async (item) => {
|
||||
if (item.uploadedAttachment) return item.uploadedAttachment;
|
||||
setPendingFiles((current) =>
|
||||
current.map((candidate) =>
|
||||
candidate.id === item.id
|
||||
? {
|
||||
...candidate,
|
||||
sourceMessageId: item.sourceMessageId,
|
||||
uploadStatus: 'uploading',
|
||||
uploadProgress: 0,
|
||||
}
|
||||
: candidate,
|
||||
),
|
||||
);
|
||||
const uploadedAttachment = await onUploadFile!(
|
||||
item.file,
|
||||
(progress) => {
|
||||
setPendingFiles((current) =>
|
||||
current.map((candidate) =>
|
||||
candidate.id === item.id
|
||||
? {
|
||||
...candidate,
|
||||
sourceMessageId: item.sourceMessageId,
|
||||
uploadStatus: 'uploading',
|
||||
uploadProgress: Math.round(progress * 100),
|
||||
}
|
||||
: candidate,
|
||||
),
|
||||
);
|
||||
},
|
||||
{ messageId: item.sourceMessageId ?? outgoingMessageId },
|
||||
);
|
||||
setPendingFiles((current) =>
|
||||
current.map((candidate) =>
|
||||
candidate.id === item.id
|
||||
? {
|
||||
...candidate,
|
||||
sourceMessageId: item.sourceMessageId,
|
||||
uploadedAttachment,
|
||||
uploadStatus: 'uploaded',
|
||||
uploadProgress: 100,
|
||||
}
|
||||
: candidate,
|
||||
),
|
||||
);
|
||||
return uploadedAttachment;
|
||||
}),
|
||||
)
|
||||
: [];
|
||||
uploadedFiles = sentFiles.map((item, index) => ({
|
||||
...item,
|
||||
uploadedAttachment: uploadedAttachments[index] ?? item.uploadedAttachment,
|
||||
uploadProgress: uploadedAttachments[index] ? 100 : item.uploadProgress,
|
||||
uploadStatus: uploadedAttachments[index] ? 'uploaded' : item.uploadStatus,
|
||||
}));
|
||||
const imagesToSend = uploadedUrls.filter(Boolean);
|
||||
const previewImagesToSend = imagesToSend;
|
||||
const fileAttachmentsToSend = uploadedAttachments.filter(Boolean);
|
||||
await onSubmit(trimmed, imagesToSend, previewImagesToSend, {
|
||||
messageId: outgoingMessageId,
|
||||
forceDeepReasoning,
|
||||
selectedChatSkill,
|
||||
fileAttachments: fileAttachmentsToSend,
|
||||
});
|
||||
uploadedImages.forEach(revokePendingImage);
|
||||
setPendingImages([]);
|
||||
setPendingFiles([]);
|
||||
} catch (err) {
|
||||
suppressVoiceUpdateRef.current = false;
|
||||
setInput(trimmed);
|
||||
@@ -543,52 +743,133 @@ export function ChatPanel({
|
||||
...item,
|
||||
uploadStatus: item.uploadedUrl ? 'uploaded' : 'error',
|
||||
})));
|
||||
setPendingFiles(uploadedFiles.map((item) => ({
|
||||
...item,
|
||||
uploadStatus: item.uploadedAttachment ? 'uploaded' : 'error',
|
||||
})));
|
||||
setImageError(err instanceof Error ? err.message : '发送失败,请重试');
|
||||
setFileError(err instanceof Error ? err.message : '发送失败,请重试');
|
||||
setUploadingImage(false);
|
||||
setUploadingFile(false);
|
||||
return;
|
||||
}
|
||||
setUploadingImage(false);
|
||||
setUploadingFile(false);
|
||||
suppressVoiceUpdateRef.current = false;
|
||||
}, [forceDeepReasoning, input, onSubmit, onUploadImage, pendingImages, revokePendingImage, voiceDisabled, voiceRecording]);
|
||||
}, [
|
||||
forceDeepReasoning,
|
||||
input,
|
||||
onSubmit,
|
||||
onUploadFile,
|
||||
onUploadImage,
|
||||
pendingFiles,
|
||||
pendingImages,
|
||||
revokePendingImage,
|
||||
voiceDisabled,
|
||||
voiceRecording,
|
||||
]);
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
await submitText();
|
||||
}, [submitText]);
|
||||
|
||||
const handleImageInputChange = async (event: ChangeEvent<HTMLInputElement>) => {
|
||||
if (!onUploadImage) return;
|
||||
const handleUploadInputChange = async (event: ChangeEvent<HTMLInputElement>) => {
|
||||
if (!canUpload) return;
|
||||
const selected = Array.from(event.currentTarget.files ?? []);
|
||||
event.currentTarget.value = '';
|
||||
|
||||
if (selected.length === 0) return;
|
||||
const imageFiles = selected.filter((file) => file.type.startsWith('image/'));
|
||||
if (imageFiles.length === 0) {
|
||||
setImageError('请上传图片文件');
|
||||
|
||||
const imageFiles: File[] = [];
|
||||
const attachmentFiles: File[] = [];
|
||||
let unsupportedCount = 0;
|
||||
|
||||
for (const file of selected) {
|
||||
const kind = classifyChatUploadFile(file);
|
||||
if (kind === 'image') {
|
||||
const prepared = prepareChatImageUploadFile(file);
|
||||
if (prepared) imageFiles.push(prepared);
|
||||
else unsupportedCount += 1;
|
||||
continue;
|
||||
}
|
||||
if (kind === 'attachment') {
|
||||
attachmentFiles.push(file);
|
||||
continue;
|
||||
}
|
||||
unsupportedCount += 1;
|
||||
}
|
||||
|
||||
if (imageFiles.length === 0 && attachmentFiles.length === 0) {
|
||||
setImageError(null);
|
||||
setFileError(`仅支持图片,或 ${CHAT_FILE_UPLOAD_TYPE_LABEL}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const { accepted: toQueue, message } = planImageUploads(imageFiles);
|
||||
if (toQueue.length === 0) {
|
||||
setImageError(message);
|
||||
return;
|
||||
}
|
||||
setImageError(message);
|
||||
setImageError(null);
|
||||
setFileError(null);
|
||||
|
||||
const placeholders = toQueue.map((file, index) => ({
|
||||
id: `${Date.now()}-${index}-${file.name}`,
|
||||
file,
|
||||
previewUrl: URL.createObjectURL(file),
|
||||
sizeBytes: file.size,
|
||||
uploadedUrl: null,
|
||||
sourceMessageId: null,
|
||||
uploadProgress: null,
|
||||
uploadStatus: 'queued' as const,
|
||||
}));
|
||||
setPendingImages((prev) => [...prev, ...placeholders]);
|
||||
if (imageFiles.length > 0) {
|
||||
if (!onUploadImage) {
|
||||
setImageError('当前会话暂不支持图片发送');
|
||||
} else {
|
||||
const { accepted: imageQueue, message: imageMessage } = planImageUploads(imageFiles);
|
||||
if (imageQueue.length === 0) {
|
||||
setImageError(imageMessage);
|
||||
} else {
|
||||
if (imageMessage) setImageError(imageMessage);
|
||||
const placeholders = imageQueue.map((file, index) => ({
|
||||
id: `${Date.now()}-image-${index}-${file.name}`,
|
||||
file,
|
||||
previewUrl: URL.createObjectURL(file),
|
||||
sizeBytes: file.size,
|
||||
uploadedUrl: null,
|
||||
sourceMessageId: null,
|
||||
uploadProgress: null,
|
||||
uploadStatus: 'queued' as const,
|
||||
}));
|
||||
setPendingImages((prev) => [...prev, ...placeholders]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (attachmentFiles.length > 0) {
|
||||
if (!onUploadFile) {
|
||||
setFileError('当前会话暂不支持附件发送');
|
||||
} else {
|
||||
const { accepted: fileQueue, message: fileMessage } = planFileUploads(attachmentFiles);
|
||||
if (fileQueue.length === 0) {
|
||||
setFileError(fileMessage);
|
||||
} else {
|
||||
if (fileMessage) setFileError(fileMessage);
|
||||
const placeholders = fileQueue.map((file, index) => ({
|
||||
id: `${Date.now()}-file-${index}-${file.name}`,
|
||||
file,
|
||||
filename: file.name,
|
||||
sizeBytes: file.size,
|
||||
uploadedAttachment: null,
|
||||
sourceMessageId: null,
|
||||
uploadProgress: null,
|
||||
uploadStatus: 'queued' as const,
|
||||
}));
|
||||
setPendingFiles((prev) => [...prev, ...placeholders]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (unsupportedCount > 0) {
|
||||
const notice = `已忽略 ${unsupportedCount} 个不支持的文件`;
|
||||
if (attachmentFiles.length > 0) {
|
||||
setFileError((current) => (current ? `${current};${notice}` : notice));
|
||||
} else if (imageFiles.length > 0) {
|
||||
setImageError((current) => (current ? `${current};${notice}` : notice));
|
||||
} else {
|
||||
setFileError(notice);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handlePaste = async (event: ClipboardEvent<HTMLTextAreaElement>) => {
|
||||
if (!onUploadImage || imageAttachmentDisabled) return;
|
||||
if (!onUploadImage || uploadDisabled) return;
|
||||
const items = Array.from(event.clipboardData.items ?? []);
|
||||
const imageItems = items.filter((item) => item.kind === 'file' && item.type.startsWith('image/'));
|
||||
if (imageItems.length === 0) return;
|
||||
@@ -628,6 +909,10 @@ export function ChatPanel({
|
||||
});
|
||||
};
|
||||
|
||||
const handleRemoveFile = (idToRemove: string) => {
|
||||
setPendingFiles((prev) => prev.filter((item) => item.id !== idToRemove));
|
||||
};
|
||||
|
||||
const openSaveActions = (message: Message) => {
|
||||
const actions = getMessageSaveActions(getDisplayText(message), {
|
||||
userId: user?.id,
|
||||
@@ -761,9 +1046,10 @@ export function ChatPanel({
|
||||
{voiceNotice}
|
||||
</div>
|
||||
)}
|
||||
{(pendingImages.length > 0 || imageError) && (
|
||||
{(pendingImages.length > 0 || pendingFiles.length > 0 || imageError || fileError) && (
|
||||
<div className="chat-image-attachments">
|
||||
{imageError && <div className="chat-image-upload-error">{imageError}</div>}
|
||||
{fileError && <div className="chat-image-upload-error">{fileError}</div>}
|
||||
{pendingImages.length > 0 && (
|
||||
<div className="chat-image-attachment-grid">
|
||||
{pendingImages.map((item, index) => (
|
||||
@@ -798,47 +1084,96 @@ export function ChatPanel({
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{pendingFiles.length > 0 && (
|
||||
<div className="chat-file-attachment-list">
|
||||
{pendingFiles.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className={`chat-file-attachment-item chat-file-attachment-item-${item.uploadStatus}`}
|
||||
>
|
||||
<div className="chat-file-attachment-meta">
|
||||
<span className="chat-file-attachment-name" title={item.filename}>
|
||||
{item.filename}
|
||||
</span>
|
||||
{item.uploadStatus === 'uploading' && (
|
||||
<span className="chat-file-attachment-progress">
|
||||
{item.uploadProgress ?? 0}%
|
||||
</span>
|
||||
)}
|
||||
{item.uploadStatus === 'uploaded' && (
|
||||
<span className="chat-file-attachment-complete">已完成</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="chat-image-attachment-remove"
|
||||
aria-label="移除附件"
|
||||
disabled={item.uploadStatus === 'uploading'}
|
||||
onClick={() => handleRemoveFile(item.id)}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className={`chat-input-row${showHomeWelcome ? ' chat-input-row-home' : ''}`}>
|
||||
<div className={`chat-input-shell${showHomeWelcome ? ' chat-input-shell-home' : ''}`}>
|
||||
{onUploadImage && (
|
||||
{canUpload && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="chat-image-upload-trigger"
|
||||
title="上传图片"
|
||||
disabled={imageAttachmentDisabled}
|
||||
onClick={() => imageInputRef.current?.click()}
|
||||
title="上传图片或附件"
|
||||
disabled={uploadDisabled}
|
||||
onClick={() => uploadInputRef.current?.click()}
|
||||
>
|
||||
<FileIcon />
|
||||
<UploadIcon />
|
||||
</button>
|
||||
<input
|
||||
ref={imageInputRef}
|
||||
ref={uploadInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
accept={CHAT_UPLOAD_ACCEPT}
|
||||
multiple
|
||||
hidden
|
||||
onChange={handleImageInputChange}
|
||||
onChange={handleUploadInputChange}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<textarea
|
||||
className={`input chat-input-field${onUploadImage ? ' chat-input-field-with-image-upload' : ''}`}
|
||||
rows={1}
|
||||
placeholder={placeholder}
|
||||
value={input}
|
||||
disabled={voiceDisabled}
|
||||
readOnly={voiceRecording}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onPaste={handlePaste}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
void handleSubmit();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{taskInputStatusText ? (
|
||||
<div
|
||||
className={`input chat-input-field chat-input-task-status${uploadFieldClass}`}
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-label={taskInputStatusText}
|
||||
>
|
||||
<span className="chat-input-task-status-text">{taskInputStatusText}</span>
|
||||
<span className="typing-dots chat-input-task-status-dots" aria-hidden="true">
|
||||
<span />
|
||||
<span />
|
||||
<span />
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<textarea
|
||||
className={`input chat-input-field${uploadFieldClass}`}
|
||||
rows={1}
|
||||
placeholder={placeholder}
|
||||
value={input}
|
||||
disabled={voiceDisabled}
|
||||
readOnly={voiceRecording}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onPaste={handlePaste}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
void handleSubmit();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<VoiceInputButton
|
||||
disabled={voiceDisabled}
|
||||
onVoiceStart={handleVoiceStart}
|
||||
@@ -872,7 +1207,10 @@ export function ChatPanel({
|
||||
disabled={voiceDisabled}
|
||||
onChange={(event) => setForceDeepReasoning(event.target.checked)}
|
||||
/>
|
||||
<span>深度推理</span>
|
||||
<span className="chat-deep-reasoning-toggle-label" aria-hidden="true">
|
||||
<span>深度</span>
|
||||
<span>推理</span>
|
||||
</span>
|
||||
</label>
|
||||
)}
|
||||
{chatState === 'streaming' ? (
|
||||
@@ -883,7 +1221,7 @@ export function ChatPanel({
|
||||
<button
|
||||
type="button"
|
||||
className={`send-btn chat-input-action${showHomeWelcome ? ' chat-input-action-home' : ''}`}
|
||||
disabled={!canSubmit || voiceDisabled || uploadingImage}
|
||||
disabled={!canSubmit || voiceDisabled || uploadingImage || uploadingFile}
|
||||
onClick={() => void handleSubmit()}
|
||||
>
|
||||
{sendButtonLabel ?? '发送'}
|
||||
|
||||
@@ -211,6 +211,7 @@ export function ChatView({
|
||||
openRecharge,
|
||||
openSubscribe,
|
||||
uploadChatImage,
|
||||
uploadChatAttachment,
|
||||
} = useChat();
|
||||
const online = useNetworkStatus();
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
@@ -543,7 +544,12 @@ export function ChatView({
|
||||
onSubmit={(text, imageUrls, previewImageUrls, options) =>
|
||||
void submit(
|
||||
text,
|
||||
{ messageId: options?.messageId, forceDeepReasoning: options?.forceDeepReasoning },
|
||||
{
|
||||
messageId: options?.messageId,
|
||||
forceDeepReasoning: options?.forceDeepReasoning,
|
||||
selectedChatSkill: options?.selectedChatSkill,
|
||||
fileAttachments: options?.fileAttachments,
|
||||
},
|
||||
imageUrls,
|
||||
previewImageUrls,
|
||||
)
|
||||
@@ -551,6 +557,9 @@ export function ChatView({
|
||||
onUploadImage={(file, onProgress, options) =>
|
||||
uploadChatImage(file, onProgress, { messageId: options?.messageId })
|
||||
}
|
||||
onUploadFile={(file, onProgress, options) =>
|
||||
uploadChatAttachment(file, onProgress, { messageId: options?.messageId })
|
||||
}
|
||||
onLoadOlderMessages={loadOlderMessages}
|
||||
onStop={stop}
|
||||
onApproveTool={approveTool}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import { useUserAvatar } from '../hooks/useUserAvatar';
|
||||
import type { Message } from '../types';
|
||||
import { getDisplayText, getImageUrls, getRenderableImageUrls, getThinking, shouldShowChatMessage } from '../utils/message';
|
||||
import { getDisplayText, getFileAttachments, getImageUrls, getRenderableImageUrls, getThinking, shouldShowChatMessage } from '../utils/message';
|
||||
import { getMessageSaveActions } from '../utils/messageSave';
|
||||
import { renderMarkdown } from '../utils/markdown';
|
||||
import { filterText } from '../utils/wordFilter';
|
||||
@@ -314,6 +314,7 @@ function MessageRow({
|
||||
const thinking = getThinking(message);
|
||||
const isUser = message.role === 'user';
|
||||
const imageUrls = getImageUrls(message);
|
||||
const fileAttachments = getFileAttachments(message);
|
||||
const renderableImageUrls = getRenderableImageUrls(message);
|
||||
const copyText = [thinking ? `【思考】\n${thinking}` : '', text].filter(Boolean).join('\n\n');
|
||||
|
||||
@@ -421,6 +422,21 @@ function MessageRow({
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{fileAttachments.length > 0 && (
|
||||
<div className="msg-file-attachments">
|
||||
{fileAttachments.map((file, index) => (
|
||||
<a
|
||||
key={`${file.downloadUrl}-${index}`}
|
||||
href={file.downloadUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="msg-file-attachment-link"
|
||||
>
|
||||
{file.filename}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{text && (
|
||||
<div
|
||||
className="bubble-text"
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
subscribeNotificationEvents,
|
||||
} from '../api/client';
|
||||
import type { UserNotification } from '../types';
|
||||
import { isRechargeNotification, requestBalanceRefresh } from '../utils/balanceRefresh';
|
||||
|
||||
const POPOVER_WIDTH = 340;
|
||||
const HEADER_POPOVER_OPEN_EVENT = 'tkmind:header-popover-open';
|
||||
@@ -67,7 +68,12 @@ export function NotificationCenter({
|
||||
};
|
||||
void load('all');
|
||||
const unsubscribe = subscribeNotificationEvents({
|
||||
onNotification: () => void load('all'),
|
||||
onNotification: (notification) => {
|
||||
if (isRechargeNotification(notification)) {
|
||||
requestBalanceRefresh();
|
||||
}
|
||||
void load('all');
|
||||
},
|
||||
onSync: () => void load('all'),
|
||||
});
|
||||
window.addEventListener('focus', syncAll);
|
||||
|
||||
@@ -50,6 +50,7 @@ export function SpaceChatPanel({
|
||||
dismissNotice,
|
||||
openRecharge,
|
||||
uploadChatImage,
|
||||
uploadChatAttachment,
|
||||
retryConnect,
|
||||
} = chat;
|
||||
const { capabilities, grantedSkills } = mainChat;
|
||||
@@ -142,7 +143,12 @@ export function SpaceChatPanel({
|
||||
chatBridge
|
||||
? void submit(
|
||||
text,
|
||||
{ ...context, messageId: options?.messageId, forceDeepReasoning: options?.forceDeepReasoning },
|
||||
{
|
||||
...context,
|
||||
messageId: options?.messageId,
|
||||
forceDeepReasoning: options?.forceDeepReasoning,
|
||||
fileAttachments: options?.fileAttachments,
|
||||
},
|
||||
imageUrls,
|
||||
previewImageUrls,
|
||||
)
|
||||
@@ -152,6 +158,7 @@ export function SpaceChatPanel({
|
||||
mindspaceContext: context,
|
||||
messageId: options?.messageId,
|
||||
forceDeepReasoning: options?.forceDeepReasoning,
|
||||
fileAttachments: options?.fileAttachments,
|
||||
},
|
||||
imageUrls,
|
||||
previewImageUrls,
|
||||
@@ -160,6 +167,9 @@ export function SpaceChatPanel({
|
||||
onUploadImage={(file, onProgress, options) =>
|
||||
uploadChatImage(file, onProgress, { messageId: options?.messageId })
|
||||
}
|
||||
onUploadFile={(file, onProgress, options) =>
|
||||
uploadChatAttachment(file, onProgress, { messageId: options?.messageId })
|
||||
}
|
||||
onStop={stop}
|
||||
onApproveTool={approveTool}
|
||||
onPageSaved={onPageSaved}
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
} from '../api/client';
|
||||
import type { AgentRun } from '../api/client';
|
||||
import { appConfig } from '../config';
|
||||
import { BALANCE_REFRESH_EVENT, isRechargeNotification, requestBalanceRefresh } from '../utils/balanceRefresh';
|
||||
import {
|
||||
clearStoredSessionId,
|
||||
readStoredSessionId,
|
||||
@@ -36,6 +37,7 @@ import {
|
||||
} from '../utils/sessionStorage';
|
||||
import type {
|
||||
CapabilityMap,
|
||||
ChatFileAttachment,
|
||||
ChatState,
|
||||
Message,
|
||||
MindSpaceChatContext,
|
||||
@@ -340,6 +342,7 @@ export function useTKMindChat(
|
||||
const userRef = useRef(user);
|
||||
const onUserUpdateRef = useRef(onUserUpdate);
|
||||
const chatImageCategoryIdRef = useRef<string | null>(null);
|
||||
const chatFileCategoryIdRef = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
chatStateRef.current = chatState;
|
||||
@@ -422,6 +425,19 @@ export function useTKMindChat(
|
||||
onUserUpdateRef.current = onUserUpdate;
|
||||
}, [onUserUpdate]);
|
||||
|
||||
const refreshUserBalance = useCallback(() => {
|
||||
if (!onUserUpdateRef.current) return;
|
||||
void getMe()
|
||||
.then(({ user: fresh }) => onUserUpdateRef.current?.(fresh))
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handleBalanceRefresh = () => refreshUserBalance();
|
||||
window.addEventListener(BALANCE_REFRESH_EVENT, handleBalanceRefresh);
|
||||
return () => window.removeEventListener(BALANCE_REFRESH_EVENT, handleBalanceRefresh);
|
||||
}, [refreshUserBalance]);
|
||||
|
||||
useEffect(() => {
|
||||
messagesRef.current = messages;
|
||||
}, [messages]);
|
||||
@@ -453,6 +469,9 @@ export function useTKMindChat(
|
||||
seenNotificationIdsRef.current.add(notification.id);
|
||||
setActiveNotification(notification);
|
||||
setNotice(`${notification.title}\n${notification.body}`.trim());
|
||||
if (isRechargeNotification(notification)) {
|
||||
requestBalanceRefresh();
|
||||
}
|
||||
};
|
||||
|
||||
const pullNotifications = async () => {
|
||||
@@ -715,6 +734,46 @@ export function useTKMindChat(
|
||||
});
|
||||
}, [resolveChatImageUploadCategoryId]);
|
||||
|
||||
const resolveChatFileUploadCategoryId = useCallback(async () => {
|
||||
if (chatFileCategoryIdRef.current) return chatFileCategoryIdRef.current;
|
||||
|
||||
const space = await getMindSpace();
|
||||
const category =
|
||||
space.categories.find((item) => item.code === 'oa') ??
|
||||
space.categories[0];
|
||||
if (!category) {
|
||||
throw new Error('当前空间暂无可用的附件上传分类');
|
||||
}
|
||||
|
||||
chatFileCategoryIdRef.current = category.id;
|
||||
return category.id;
|
||||
}, []);
|
||||
|
||||
const uploadChatAttachment = useCallback(async (
|
||||
file: File,
|
||||
onProgress?: (progress: number) => void,
|
||||
options: { messageId?: string | null } = {},
|
||||
) => {
|
||||
if (file.type.startsWith('image/')) {
|
||||
throw new Error('请使用图片上传入口发送图片');
|
||||
}
|
||||
const categoryId = await resolveChatFileUploadCategoryId();
|
||||
const asset = await uploadMindSpaceAsset(categoryId, file, {
|
||||
onProgress,
|
||||
sessionId: sessionRef.current?.id ?? null,
|
||||
messageId: options.messageId ?? null,
|
||||
});
|
||||
return {
|
||||
assetId: asset.id,
|
||||
downloadUrl: buildAbsoluteAssetDownloadUrl({
|
||||
id: asset.id,
|
||||
updatedAt: asset.updatedAt,
|
||||
}),
|
||||
filename: asset.displayName || asset.filename || file.name,
|
||||
mimeType: asset.mimeType || file.type || 'application/octet-stream',
|
||||
};
|
||||
}, [resolveChatFileUploadCategoryId]);
|
||||
|
||||
const syncSessionMessages = useCallback(async (sessionId: string) => {
|
||||
// REGRESSION GUARD: Finish may arrive before the backend persists assistant turns.
|
||||
// Never blind-replace with server snapshot — merge + retry (see chat-finish-sync.mjs).
|
||||
@@ -1306,7 +1365,13 @@ export function useTKMindChat(
|
||||
const submit = useCallback(
|
||||
async (
|
||||
text: string,
|
||||
options?: { mindspaceContext?: MindSpaceChatContext; messageId?: string; forceDeepReasoning?: boolean; selectedChatSkill?: string },
|
||||
options?: {
|
||||
mindspaceContext?: MindSpaceChatContext;
|
||||
messageId?: string;
|
||||
forceDeepReasoning?: boolean;
|
||||
selectedChatSkill?: string;
|
||||
fileAttachments?: ChatFileAttachment[];
|
||||
},
|
||||
imageUrls?: string[],
|
||||
previewImageUrls?: string[],
|
||||
) => {
|
||||
@@ -1314,7 +1379,10 @@ export function useTKMindChat(
|
||||
const normalizedPreviewImageUrls = (previewImageUrls ?? []).filter(
|
||||
(value) => typeof value === 'string' && value.trim(),
|
||||
);
|
||||
if (!text.trim() && normalizedImageUrls.length === 0) return;
|
||||
const normalizedFileAttachments = (options?.fileAttachments ?? []).filter(
|
||||
(item) => item?.downloadUrl?.trim() && item?.filename?.trim(),
|
||||
);
|
||||
if (!text.trim() && normalizedImageUrls.length === 0 && normalizedFileAttachments.length === 0) return;
|
||||
// Use the ref here (not the React state) so that rapid back-to-back calls in the
|
||||
// same render cycle are blocked even before the state update has been re-rendered.
|
||||
if (
|
||||
@@ -1338,6 +1406,7 @@ export function useTKMindChat(
|
||||
displayText: trimmed,
|
||||
imageUrls: normalizedImageUrls,
|
||||
previewImageUrls: normalizedPreviewImageUrls,
|
||||
fileAttachments: normalizedFileAttachments,
|
||||
});
|
||||
userMessage.metadata = {
|
||||
...userMessage.metadata,
|
||||
@@ -1437,7 +1506,7 @@ export function useTKMindChat(
|
||||
if (!activeSessionId) {
|
||||
throw new Error('后台任务已提交,但未返回会话');
|
||||
}
|
||||
if (normalizedImageUrls.length > 0) {
|
||||
if (normalizedImageUrls.length > 0 || normalizedFileAttachments.length > 0) {
|
||||
void claimMindSpaceConversationUploads(activeSessionId, userMessage.id).catch(() => {});
|
||||
}
|
||||
if (!session?.id || session.id !== activeSessionId) {
|
||||
@@ -1778,6 +1847,7 @@ export function useTKMindChat(
|
||||
dismissNotice,
|
||||
onSidebarOpen,
|
||||
uploadChatImage,
|
||||
uploadChatAttachment,
|
||||
workingDir: session?.working_dir ?? user?.workspaceRoot ?? '',
|
||||
balanceCents: user?.balanceCents,
|
||||
totalCreditCents: user?.totalCreditCents,
|
||||
|
||||
+331
-10
@@ -695,6 +695,101 @@ body,
|
||||
padding: 6px 8px;
|
||||
}
|
||||
|
||||
.app-shell-h5 .chat-input-row .chat-skill-trigger,
|
||||
.app-shell-h5 .chat-input-row .chat-input-action {
|
||||
box-sizing: border-box;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: auto;
|
||||
min-width: 0;
|
||||
height: 30px;
|
||||
padding: 2px 5px;
|
||||
border-radius: 6px;
|
||||
font-size: 10px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.app-shell-h5 .chat-input-row .chat-deep-reasoning-toggle {
|
||||
box-sizing: border-box;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: auto;
|
||||
min-width: 0;
|
||||
height: 30px;
|
||||
padding: 2px 4px;
|
||||
border-radius: 6px;
|
||||
font-size: 10px;
|
||||
flex-direction: row;
|
||||
gap: 3px;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.app-shell-h5 .chat-deep-reasoning-toggle input {
|
||||
width: 11px;
|
||||
height: 11px;
|
||||
}
|
||||
|
||||
.app-shell-h5 .chat-deep-reasoning-toggle-label {
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
font-size: 10px;
|
||||
line-height: 1.05;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.app-shell-h5 .chat-skill-trigger {
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.app-shell-h5 .chat-skill-trigger-icon {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
.app-shell-h5 .chat-skill-trigger-label {
|
||||
font-size: 10px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.app-shell-h5 .chat-input-row .chat-input-action-home {
|
||||
box-shadow: 0 8px 18px rgba(121, 146, 255, 0.28);
|
||||
}
|
||||
|
||||
.app-shell-h5 .chat-input-row textarea.input.chat-input-field,
|
||||
.app-shell-h5 .chat-input-row .chat-input-task-status {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.app-shell-h5 .chat-input-row-home textarea.input.chat-input-field,
|
||||
.app-shell-h5 .chat-input-row-home .chat-input-task-status {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.app-shell-h5 .chat-image-upload-trigger {
|
||||
left: 8px;
|
||||
top: 50%;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 7px;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.app-shell-h5 .chat-image-upload-trigger svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.app-shell-h5 .chat-input-row textarea.input.chat-input-field.chat-input-field-with-image-upload,
|
||||
.app-shell-h5 .chat-input-row .chat-input-field.chat-input-field-with-image-upload {
|
||||
padding-left: 42px;
|
||||
}
|
||||
|
||||
.header-icon-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -2748,6 +2843,7 @@ body,
|
||||
top: 7px;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
padding: 0;
|
||||
border: 1px solid rgba(24, 33, 29, 0.18);
|
||||
border-radius: 7px;
|
||||
background: rgba(24, 33, 29, 0.08);
|
||||
@@ -2758,6 +2854,11 @@ body,
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.chat-image-upload-trigger svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.chat-image-upload-trigger:hover:not(:disabled) {
|
||||
background: rgba(24, 33, 29, 0.12);
|
||||
color: #4b5651;
|
||||
@@ -2816,6 +2917,131 @@ body,
|
||||
padding-left: 42px;
|
||||
}
|
||||
|
||||
.chat-input-field-with-dual-upload {
|
||||
padding-left: 72px;
|
||||
}
|
||||
|
||||
.chat-file-upload-trigger {
|
||||
position: absolute;
|
||||
left: 42px;
|
||||
top: 7px;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
padding: 0;
|
||||
border: 1px solid rgba(24, 33, 29, 0.18);
|
||||
border-radius: 7px;
|
||||
background: rgba(24, 33, 29, 0.08);
|
||||
color: #5d6963;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.chat-file-upload-trigger svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.chat-file-upload-trigger:hover:not(:disabled) {
|
||||
background: rgba(24, 33, 29, 0.12);
|
||||
color: #4b5651;
|
||||
}
|
||||
|
||||
.chat-file-upload-trigger:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.chat-file-attachment-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.chat-file-attachment-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 10px;
|
||||
background: rgba(24, 33, 29, 0.06);
|
||||
}
|
||||
|
||||
.chat-file-attachment-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.chat-file-attachment-name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.chat-file-attachment-progress,
|
||||
.chat-file-attachment-complete {
|
||||
font-size: 12px;
|
||||
color: #5d6963;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.msg-file-attachments {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.msg-file-attachment-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 6px 10px;
|
||||
border-radius: 8px;
|
||||
background: rgba(24, 33, 29, 0.08);
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
font-size: 13px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.chat-input-task-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: default;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.chat-input-task-status-text {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.chat-input-task-status-dots span {
|
||||
background: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.chat-input-row-home .chat-input-task-status-text {
|
||||
color: rgba(175, 191, 216, 0.72);
|
||||
}
|
||||
|
||||
.chat-input-row-home .chat-input-task-status-dots span {
|
||||
background: rgba(175, 191, 216, 0.72);
|
||||
}
|
||||
|
||||
.chat-input-row-home .chat-input-task-status {
|
||||
min-height: 52px;
|
||||
padding-top: 14px;
|
||||
padding-bottom: 14px;
|
||||
}
|
||||
|
||||
.chat-input-action {
|
||||
flex: 0 0 auto;
|
||||
white-space: nowrap;
|
||||
@@ -2864,9 +3090,17 @@ body,
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
margin: 0;
|
||||
flex-shrink: 0;
|
||||
accent-color: #79b7ff;
|
||||
}
|
||||
|
||||
.chat-deep-reasoning-toggle-label {
|
||||
display: inline-flex;
|
||||
flex-direction: row;
|
||||
gap: 0;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.chat-deep-reasoning-toggle.is-active {
|
||||
color: #eff6ff;
|
||||
border-color: rgba(121, 183, 255, 0.6);
|
||||
@@ -9647,29 +9881,107 @@ body,
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.chat-input-row textarea.input.chat-input-field,
|
||||
.chat-input-row .chat-input-task-status {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.chat-input-row-home textarea.input.chat-input-field {
|
||||
min-height: 48px;
|
||||
padding-top: 12px;
|
||||
padding-bottom: 12px;
|
||||
font-size: 13px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.chat-input-row-home .chat-input-task-status {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.chat-image-upload-trigger {
|
||||
left: 8px;
|
||||
top: 50%;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 7px;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.chat-image-upload-trigger svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.chat-input-row textarea.input.chat-input-field.chat-input-field-with-image-upload,
|
||||
.chat-input-row .chat-input-field.chat-input-field-with-image-upload {
|
||||
padding-left: 42px;
|
||||
}
|
||||
|
||||
.chat-input-row .chat-skill-trigger,
|
||||
.chat-input-row .chat-input-action {
|
||||
box-sizing: border-box;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: auto;
|
||||
min-width: 0;
|
||||
height: 30px;
|
||||
padding: 2px 5px;
|
||||
border-radius: 6px;
|
||||
font-size: 10px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.chat-input-row .chat-deep-reasoning-toggle {
|
||||
box-sizing: border-box;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: auto;
|
||||
min-width: 0;
|
||||
height: 30px;
|
||||
padding: 2px 4px;
|
||||
border-radius: 6px;
|
||||
font-size: 10px;
|
||||
flex-direction: row;
|
||||
gap: 3px;
|
||||
font-weight: 600;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.chat-input-action-home {
|
||||
min-width: 68px;
|
||||
height: 44px;
|
||||
padding-inline: 12px;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 8px 18px rgba(121, 146, 255, 0.28);
|
||||
}
|
||||
|
||||
.chat-deep-reasoning-toggle {
|
||||
min-height: 36px;
|
||||
padding-inline: 8px;
|
||||
.chat-deep-reasoning-toggle input {
|
||||
width: 11px;
|
||||
height: 11px;
|
||||
}
|
||||
|
||||
.chat-deep-reasoning-toggle span {
|
||||
max-width: 56px;
|
||||
.chat-deep-reasoning-toggle-label {
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
font-size: 10px;
|
||||
line-height: 1.05;
|
||||
text-align: left;
|
||||
max-width: none;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.chat-skill-trigger {
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.chat-skill-trigger-icon {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
.chat-skill-trigger-label {
|
||||
font-size: 10px;
|
||||
line-height: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
@@ -10134,6 +10446,15 @@ body,
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.space-chat-panel-footer .chat-input-task-status-text {
|
||||
color: rgba(109, 119, 113, 0.85);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.space-chat-panel-footer .chat-input-task-status-dots span {
|
||||
background: rgba(109, 119, 113, 0.72);
|
||||
}
|
||||
|
||||
.space-chat-panel-footer .chat-image-upload-trigger {
|
||||
color: var(--ms-soft);
|
||||
}
|
||||
|
||||
@@ -22,6 +22,13 @@ export type MessageContent =
|
||||
};
|
||||
};
|
||||
|
||||
export type ChatFileAttachment = {
|
||||
assetId: string;
|
||||
downloadUrl: string;
|
||||
filename: string;
|
||||
mimeType: string;
|
||||
};
|
||||
|
||||
export type Message = {
|
||||
id?: string | null;
|
||||
role: Role;
|
||||
@@ -33,6 +40,9 @@ export type Message = {
|
||||
displayText?: string;
|
||||
imageUrls?: string[];
|
||||
previewImageUrls?: string[];
|
||||
archivedImageUrls?: string[];
|
||||
archivedPreviewImageUrls?: string[];
|
||||
fileAttachments?: ChatFileAttachment[];
|
||||
};
|
||||
};
|
||||
|
||||
@@ -424,6 +434,7 @@ export type MindSpaceAsset = {
|
||||
hasThumbnail?: boolean;
|
||||
publicUrl?: string | null;
|
||||
sourcePageId?: string | null;
|
||||
workspaceRelativePath?: string | null;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
};
|
||||
@@ -796,6 +807,8 @@ export type MindSpacePage = {
|
||||
visibility: 'private' | 'internal' | 'public';
|
||||
publicationAccessMode?: 'public' | 'password' | 'private_link' | 'time_limited' | 'login_required' | 'owner_only' | null;
|
||||
publicationUrl?: string | null;
|
||||
workspaceRelativePath?: string | null;
|
||||
workspacePublicUrl?: string | null;
|
||||
currentVersionId: string;
|
||||
versionNo: number;
|
||||
content?: string;
|
||||
|
||||
@@ -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