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:
john
2026-07-11 00:23:01 +08:00
parent e3063ea806
commit 32fb2cdeaf
51 changed files with 4588 additions and 186 deletions
+443 -105
View File
@@ -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 ?? '发送'}
+10 -1
View File
@@ -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}
+17 -1
View File
@@ -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"
+7 -1
View File
@@ -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);
+11 -1
View File
@@ -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}