Files
memind/src/components/ChatPanel.tsx
T

1272 lines
45 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { ChangeEvent, useCallback, useEffect, useLayoutEffect, useRef, useState, type ClipboardEvent } from 'react';
import { BrainCircuit, Database } from 'lucide-react';
import { useNetworkStatus } from '../hooks/useNetworkStatus';
import { openAvatarPicker } from '../utils/userAvatar';
import { CHAT_SKILL_OPTIONS, filterChatSkills } from '../utils/chatSkills';
import { getMessageSaveActions } from '../utils/messageSave';
import { getDisplayText } from '../utils/message';
import {
downloadChatMessageDocx,
} from '../api/client';
import {
CHAT_IMAGE_UPLOAD_MAX_COUNT,
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';
import { ChatSharePreviewModal } from './ChatSharePreviewModal';
import { ChatPlazaPublishModal } from './ChatPlazaPublishModal';
import { MessageList } from './MessageList';
import { PageSaveDialog } from './PageSaveDialog';
import { VoiceInputButton } from './VoiceInputButton';
import type { CapabilityMap, ChatFileAttachment, ChatState, Message, PortalUser, Session, ToolConfirmation } from '../types';
import type { MindSpaceSaveCategory } from '../types';
const CHAT_PLACEHOLDER_PROMPTS = [
'帮我写一篇温柔一点的小短文',
'讲个轻松的笑话,让我换换脑子',
'帮我看看今天的天气和出门建议',
'规划一份周末城市漫游攻略',
'把这组数据整理成一段分析结论',
'帮我总结一份会议纪要或文档',
'写一个有记忆点的产品介绍',
'设计一个简单好玩的小游戏',
'帮我做一份旅行行程和预算',
'把一段想法改成更专业的表达',
'给我起几个品牌名和一句 slogan',
'帮我写一封礼貌但坚定的邮件',
'整理一份学习计划和每日任务',
'分析一个产品页面哪里可以优化',
'把复杂概念讲得像聊天一样简单',
'帮我生成一份短视频脚本',
'写一段适合发朋友圈的文案',
'帮我拆解一个商业想法是否可行',
'把长文压缩成三条重点',
'做一个活动策划和执行清单',
'帮我准备一次面试的回答思路',
'写一个网页或小工具的需求说明',
'帮我把表格数据变成洞察报告',
'给孩子讲一个睡前故事',
'帮我规划一周健康饮食',
'把这段话翻译得自然一点',
'帮我做一份竞品对比',
'给一个新功能设计使用流程',
'帮我写一段代码并解释思路',
'整理一个今天就能开始的行动计划',
];
type PendingChatImage = {
id: string;
file: File;
previewUrl: string;
sizeBytes: number;
uploadedUrl: string | null;
sourceMessageId: string | null;
uploadProgress: number | null;
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 UploadIcon() {
return (
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path
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"
/>
</svg>
);
}
function appendLongImageDownloadParam(url: string) {
const next = new URL(url, window.location.href);
next.searchParams.set('download', 'long-image');
return next.toString();
}
function triggerUrlDownload(url: string) {
const link = document.createElement('a');
link.href = url;
link.download = '';
document.body.appendChild(link);
link.click();
link.remove();
}
function downloadBlob(blob: Blob, filename: string) {
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
link.remove();
window.setTimeout(() => URL.revokeObjectURL(url), 60_000);
}
export function ChatPanel({
variant,
user,
messages,
historyLoadingMore = false,
historyHasMore = false,
historyTotal = 0,
chatState,
pendingTool,
session,
capabilities,
grantedSkills,
onSubmit,
onUploadImage,
onUploadFile,
onLoadOlderMessages,
onStop,
onApproveTool,
onPageSaved,
onClose,
}: {
variant: 'full' | 'compact';
user?: PortalUser | null;
messages: Message[];
historyLoadingMore?: boolean;
historyHasMore?: boolean;
historyTotal?: number;
chatState: ChatState;
pendingTool: ToolConfirmation | null;
session: Session | null;
capabilities?: CapabilityMap;
grantedSkills?: string[];
onSubmit: (
text: string,
imageUrls?: string[],
previewImageUrls?: string[],
options?: {
messageId?: string;
forceDeepReasoning?: boolean;
pgRequired?: 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>;
onPageSaved?: (result: {
kind: 'page' | 'asset' | 'category';
pageId?: string;
categoryCode?: MindSpaceSaveCategory;
}) => void;
onClose?: () => void;
}) {
const online = useNetworkStatus();
const [input, setInput] = useState('');
const [voiceRecording, setVoiceRecording] = useState(false);
const [pageSource, setPageSource] = useState<Message | null>(null);
const [sharePreviewSource, setSharePreviewSource] = useState<Message | null>(null);
const [plazaPublishSource, setPlazaPublishSource] = useState<Message | null>(null);
const [voiceNotice, setVoiceNotice] = useState<string | null>(null);
const [forceDeepReasoning, setForceDeepReasoning] = useState(false);
const [pgRequired, setPgRequired] = useState(false);
const [chatControlOnboardingStep, setChatControlOnboardingStep] = useState<0 | 1 | 2 | null>(null);
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 uploadInputRef = useRef<HTMLInputElement>(null);
const mainRef = useRef<HTMLElement>(null);
const inputRef = useRef(input);
const voiceBaseRef = useRef('');
const pendingImagesRef = useRef(pendingImages);
const suppressVoiceUpdateRef = useRef(false);
const nearBottomRef = useRef(true);
const initializedSessionRef = useRef<string | null>(null);
const pendingOlderLoadRef = useRef<null | {
anchorId: string | null;
anchorTop: number;
remainingHeight: number;
}>(null);
const prevMessageCountRef = useRef(messages.length);
const showAssistantTyping = chatState === 'waiting' || chatState === 'streaming';
inputRef.current = input;
pendingImagesRef.current = pendingImages;
useEffect(() => {
return () => {
pendingImagesRef.current.forEach((item) => {
if (item.previewUrl.startsWith('blob:')) URL.revokeObjectURL(item.previewUrl);
});
};
}, []);
const requestOlderMessages = useCallback(() => {
const container = mainRef.current;
if (!container || !onLoadOlderMessages || historyLoadingMore || !historyHasMore) return;
const blocks = Array.from(container.querySelectorAll<HTMLElement>('[data-message-anchor]'));
const anchor =
blocks.find((block) => {
const rect = block.getBoundingClientRect();
return rect.bottom >= container.getBoundingClientRect().top + 8;
}) ?? null;
pendingOlderLoadRef.current = {
anchorId: anchor?.dataset.messageAnchor ?? null,
anchorTop: anchor?.getBoundingClientRect().top ?? container.getBoundingClientRect().top,
remainingHeight: Math.max(container.clientHeight * 2, 1),
};
void onLoadOlderMessages();
}, [historyHasMore, historyLoadingMore, onLoadOlderMessages]);
const handleMainScroll = useCallback(() => {
const container = mainRef.current;
if (!container) return;
nearBottomRef.current =
container.scrollHeight - container.scrollTop - container.clientHeight < 120;
if (
container.scrollTop < 120 &&
historyHasMore &&
!historyLoadingMore &&
onLoadOlderMessages &&
!pendingOlderLoadRef.current
) {
requestOlderMessages();
}
}, [historyHasMore, historyLoadingMore, onLoadOlderMessages, requestOlderMessages]);
useEffect(() => {
initializedSessionRef.current = null;
pendingOlderLoadRef.current = null;
nearBottomRef.current = true;
}, [session?.id]);
useEffect(() => {
setChatControlOnboardingStep(0);
const timers = [
window.setTimeout(() => setChatControlOnboardingStep(1), 2_200),
window.setTimeout(() => setChatControlOnboardingStep(2), 4_400),
window.setTimeout(() => setChatControlOnboardingStep(null), 6_600),
];
return () => timers.forEach((timer) => window.clearTimeout(timer));
}, [session?.id]);
useLayoutEffect(() => {
const container = mainRef.current;
if (!container) return;
const pendingOlderLoad = pendingOlderLoadRef.current;
if (pendingOlderLoad) {
const anchor = pendingOlderLoad.anchorId
? container.querySelector<HTMLElement>(`[data-message-anchor="${pendingOlderLoad.anchorId}"]`)
: null;
const anchorTop = anchor?.getBoundingClientRect().top ?? container.getBoundingClientRect().top;
const addedHeight = Math.max(0, anchorTop - pendingOlderLoad.anchorTop);
container.scrollTop += addedHeight;
if (pendingOlderLoad.remainingHeight - addedHeight > 0 && historyHasMore && !historyLoadingMore) {
const nextAnchor =
anchor ??
container.querySelector<HTMLElement>('[data-message-anchor]');
pendingOlderLoadRef.current = {
anchorId: nextAnchor?.dataset.messageAnchor ?? null,
anchorTop: nextAnchor?.getBoundingClientRect().top ?? container.getBoundingClientRect().top,
remainingHeight: pendingOlderLoad.remainingHeight - addedHeight,
};
requestOlderMessages();
return;
}
pendingOlderLoadRef.current = null;
nearBottomRef.current =
container.scrollHeight - container.scrollTop - container.clientHeight < 120;
return;
}
if (session?.id && initializedSessionRef.current !== session.id) {
container.scrollTop = container.scrollHeight;
nearBottomRef.current = true;
if (
container.scrollHeight < container.clientHeight * 2 &&
historyHasMore &&
!historyLoadingMore &&
onLoadOlderMessages
) {
requestOlderMessages();
return;
}
initializedSessionRef.current = session.id;
prevMessageCountRef.current = messages.length;
return;
}
const messageCountIncreased = messages.length > prevMessageCountRef.current;
if ((nearBottomRef.current || showAssistantTyping) && messageCountIncreased) {
container.scrollTop = container.scrollHeight;
nearBottomRef.current = true;
}
prevMessageCountRef.current = messages.length;
}, [
chatState,
showAssistantTyping,
historyHasMore,
historyLoadingMore,
messages.length,
onLoadOlderMessages,
requestOlderMessages,
session?.id,
]);
useLayoutEffect(() => {
if (!showAssistantTyping) return;
const container = mainRef.current;
if (!container || !nearBottomRef.current) return;
container.scrollTop = container.scrollHeight;
}, [showAssistantTyping, messages.length]);
const busy =
chatState === 'streaming' ||
chatState === 'loading' ||
chatState === 'connecting' ||
chatState === 'waiting';
const offlineBlocked = !online;
const publishSkillName = user?.publishSkillName ?? 'static-page-publish';
const hasPublishSkill = grantedSkills?.includes(publishSkillName) ?? false;
const hasPageDataCollectSkill = grantedSkills?.includes('page-data-collect') ?? false;
const canPublish =
Boolean(capabilities?.static_publish) || hasPublishSkill || hasPageDataCollectSkill;
const chatSkills = filterChatSkills(CHAT_SKILL_OPTIONS, { grantedSkills, canPublish });
const compact = variant === 'compact';
const showHomeWelcome = !compact && messages.length === 0;
const placeholder = offlineBlocked
? '网络断开,恢复连接后可继续输入'
: compact
? '随时召唤你的小助手吧'
: randomPrompt;
const connectStatusText =
uploadingImage || uploadingFile
? '正在上传,发出后会自动继续…'
: chatState === 'connecting'
? '正在创建会话…'
: null;
const taskInputStatusText =
chatState === 'streaming'
? '正在执行任务…'
: chatState === 'waiting'
? '请求已提交…'
: null;
const sendButtonLabel =
uploadingImage || uploadingFile
? '上传中…'
: chatState === 'connecting'
? '连接中…'
: chatState === 'waiting'
? '提交中…'
: 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();
if (!chunk) return base;
return base ? `${base} ${chunk}` : chunk;
};
const handleVoiceStart = () => {
suppressVoiceUpdateRef.current = false;
voiceBaseRef.current = inputRef.current;
setVoiceNotice(null);
};
const handleVoiceLiveTranscript = (text: string) => {
if (suppressVoiceUpdateRef.current) return;
setInput(mergeVoiceText(text));
};
const handleVoiceTranscript = (text: string) => {
if (suppressVoiceUpdateRef.current) return;
setInput(mergeVoiceText(text));
setVoiceNotice('已识别,可编辑后发送');
};
const handleVoiceComplete = () => {
setVoiceNotice('已识别,可编辑后发送');
};
const voiceDisabled = busy || !!pendingTool || chatState === 'error' || offlineBlocked;
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);
};
const pendingImageBytes = pendingImages.reduce((sum, item) => sum + item.sizeBytes, 0);
const planImageUploads = useCallback((files: File[]) => {
const remainingCount = CHAT_IMAGE_UPLOAD_MAX_COUNT - pendingImages.length;
if (remainingCount <= 0) {
return {
accepted: [],
message: `最多支持一次性附加 ${CHAT_IMAGE_UPLOAD_MAX_COUNT} 张图片`,
};
}
const accepted: File[] = [];
let nextTotalBytes = pendingImageBytes;
let oversizedRejected = false;
let totalRejected = false;
let countRejected = false;
for (const file of files) {
if (accepted.length >= remainingCount) {
countRejected = true;
continue;
}
if (file.size > CHAT_IMAGE_UPLOAD_MAX_INPUT_BYTES) {
oversizedRejected = true;
continue;
}
if (nextTotalBytes + file.size > CHAT_IMAGE_UPLOAD_MAX_TOTAL_BYTES) {
totalRejected = true;
continue;
}
accepted.push(file);
nextTotalBytes += file.size;
}
if (accepted.length === 0) {
if (oversizedRejected) {
return {
accepted,
message: `单张图片不能超过 ${formatUploadMegabytes(CHAT_IMAGE_UPLOAD_MAX_INPUT_BYTES)}`,
};
}
return {
accepted,
message: `图片总大小不能超过 ${formatUploadMegabytes(CHAT_IMAGE_UPLOAD_MAX_TOTAL_BYTES)}`,
};
}
let message = null;
if (countRejected) {
message = `最多支持 ${CHAT_IMAGE_UPLOAD_MAX_COUNT} 张图片,本次仅保留前 ${accepted.length}`;
} else if (oversizedRejected || totalRejected) {
message = `已按单张 ${formatUploadMegabytes(CHAT_IMAGE_UPLOAD_MAX_INPUT_BYTES)}、总计 ${formatUploadMegabytes(CHAT_IMAGE_UPLOAD_MAX_TOTAL_BYTES)} 的限制筛选图片`;
}
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 && pendingFiles.length === 0) || voiceDisabled) return;
if (pendingImages.length > 0 && !onUploadImage) {
setImageError('当前会话暂不支持图片发送');
return;
}
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) {
setVoiceStopSignal((value) => value + 1);
}
setInput('');
voiceBaseRef.current = '';
setVoiceNotice(null);
setImageError(null);
setFileError(null);
if (sentImages.length > 0) setUploadingImage(true);
if (sentFiles.length > 0) setUploadingFile(true);
try {
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
? {
...candidate,
sourceMessageId: item.sourceMessageId,
uploadStatus: 'uploading',
uploadProgress: 0,
}
: candidate,
),
);
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,
pgRequired,
selectedChatSkill,
fileAttachments: fileAttachmentsToSend,
});
uploadedImages.forEach(revokePendingImage);
setPendingImages([]);
setPendingFiles([]);
} catch (err) {
suppressVoiceUpdateRef.current = false;
setInput(trimmed);
setPendingImages(uploadedImages.map((item) => ({
...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,
pgRequired,
input,
onSubmit,
onUploadFile,
onUploadImage,
pendingFiles,
pendingImages,
revokePendingImage,
voiceDisabled,
voiceRecording,
]);
const handleSubmit = useCallback(async () => {
await submitText();
}, [submitText]);
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: 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;
}
setImageError(null);
setFileError(null);
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 || 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;
event.preventDefault();
const files = imageItems
.map((item) => item.getAsFile())
.filter((file): file is File => Boolean(file));
if (files.length === 0) return;
const { accepted: toQueue, message } = planImageUploads(files);
if (toQueue.length === 0) {
setImageError(message);
return;
}
setImageError(message);
const placeholders = toQueue.map((file, index) => ({
id: `${Date.now()}-${index}-${file.name || 'pasted-image'}`,
file,
previewUrl: URL.createObjectURL(file),
sizeBytes: file.size,
uploadedUrl: null,
sourceMessageId: null,
uploadProgress: null,
uploadStatus: 'queued' as const,
}));
setPendingImages((prev) => [...prev, ...placeholders]);
};
const handleRemoveImage = (idToRemove: string) => {
setPendingImages((prev) => {
const target = prev.find((item) => item.id === idToRemove);
if (target) revokePendingImage(target);
return prev.filter((item) => item.id !== idToRemove);
});
};
const handleRemoveFile = (idToRemove: string) => {
setPendingFiles((prev) => prev.filter((item) => item.id !== idToRemove));
};
const openSaveActions = (message: Message) => {
const actions = getMessageSaveActions(getDisplayText(message), {
userId: user?.id,
username: user?.username,
});
if (actions.kind === 'page') {
setSharePreviewSource(message);
return;
}
setPageSource(message);
};
const openPlazaPublish = (message: Message) => {
setPlazaPublishSource(message);
};
const closePlazaPublish = useCallback(() => {
setPlazaPublishSource(null);
}, []);
const downloadLongImage = (_message: Message, publicUrl: string) => {
triggerUrlDownload(appendLongImageDownloadParam(publicUrl));
};
const downloadDocx = async (message: Message) => {
if (!session?.id || !message.id) return;
try {
const result = await downloadChatMessageDocx({
sessionId: session.id,
messageId: message.id,
});
downloadBlob(result.blob, result.filename);
} catch (err) {
setVoiceNotice(err instanceof Error ? err.message : '文档下载失败,请重试');
}
};
return (
<>
<main
ref={mainRef}
className={compact ? 'space-chat-panel-body' : `main${showHomeWelcome ? ' main-home' : ''}`}
onScroll={handleMainScroll}
>
{!showHomeWelcome && (historyLoadingMore || historyHasMore) && (
<div className="chat-history-loader" role="status">
{historyLoadingMore
? `正在加载更早消息… 已加载 ${messages.length}${historyTotal > 0 ? ` / ${historyTotal}` : ''}`
: `向上滚动加载更早消息${historyTotal > 0 ? ` · 已加载 ${messages.length} / ${historyTotal}` : ''}`}
</div>
)}
<MessageList
messages={messages}
streaming={showAssistantTyping}
onAvatarClick={compact ? undefined : openAvatarPicker}
onSaveAsPage={openSaveActions}
onShareToPlaza={openPlazaPublish}
onDownloadLongImage={downloadLongImage}
onDownloadDocx={(message) => void downloadDocx(message)}
publishUserId={user?.id}
publishUsername={user?.username}
compact={compact}
/>
{!compact && (
<div className="user-avatar-input-host" aria-hidden="true">
<AvatarPicker variant="compact" />
</div>
)}
</main>
{pendingTool && (
<div className={`tool-confirm${compact ? ' tool-confirm-compact' : ''}`}>
<div className="tool-confirm-text">
<strong>{pendingTool.toolName}</strong>
{pendingTool.prompt && <p>{pendingTool.prompt}</p>}
</div>
<div className="tool-confirm-actions">
<button type="button" onClick={() => void onApproveTool(true)}>
</button>
<button type="button" className="danger" onClick={() => void onApproveTool(false)}>
</button>
</div>
</div>
)}
{pageSource?.id && session?.id && (
<PageSaveDialog
sessionId={session.id}
messageId={pageSource.id}
compact={compact}
onClose={() => setPageSource(null)}
onSaved={(result) => {
setPageSource(null);
setSharePreviewSource(null);
onPageSaved?.(result);
}}
/>
)}
{sharePreviewSource?.id && session?.id && (
<ChatSharePreviewModal
sessionId={session.id}
messageId={sharePreviewSource.id}
onClose={() => setSharePreviewSource(null)}
onSave={() => {
setPageSource(sharePreviewSource);
setSharePreviewSource(null);
}}
/>
)}
{plazaPublishSource?.id && session?.id && (
<ChatPlazaPublishModal
sessionId={session.id}
messageId={plazaPublishSource.id}
onClose={closePlazaPublish}
/>
)}
<footer className={compact ? 'space-chat-panel-footer' : `footer${showHomeWelcome ? ' footer-home' : ''}`}>
{connectStatusText && (
<div className="chat-connect-status" role="status" aria-live="polite">
<ChatLoadingSpinner />
<span>{connectStatusText}</span>
</div>
)}
{voiceNotice && (
<div className="voice-notice" role="status">
{voiceNotice}
</div>
)}
{(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) => (
<div
key={item.id}
className={`chat-image-attachment-item chat-image-attachment-item-${item.uploadStatus}`}
>
<a href={item.uploadedUrl || item.previewUrl} target="_blank" rel="noreferrer">
<img src={item.previewUrl} alt={`上传图片 ${index + 1}`} className="chat-image-attachment-thumb" />
</a>
{item.uploadStatus === 'uploading' && (
<div className="chat-image-upload-progress" aria-label={`上传进度 ${item.uploadProgress ?? 0}%`}>
<div className="chat-image-upload-progress-fill" style={{ width: `${item.uploadProgress ?? 0}%` }} />
<span>{item.uploadProgress ?? 0}%</span>
</div>
)}
{item.uploadStatus === 'uploaded' && (
<div className="chat-image-upload-complete" aria-label="上传完成">
</div>
)}
<button
type="button"
className="chat-image-attachment-remove"
aria-label="移除图片"
disabled={item.uploadStatus === 'uploading'}
onClick={() => handleRemoveImage(item.id)}
>
×
</button>
</div>
))}
</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' : ''}`}>
{canUpload && (
<>
<button
type="button"
className="chat-image-upload-trigger"
title="上传图片或附件"
disabled={uploadDisabled}
onClick={() => uploadInputRef.current?.click()}
>
<UploadIcon />
</button>
<input
ref={uploadInputRef}
type="file"
accept={CHAT_UPLOAD_ACCEPT}
multiple
hidden
onChange={handleUploadInputChange}
/>
</>
)}
{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}
onLiveTranscript={handleVoiceLiveTranscript}
onTranscript={handleVoiceTranscript}
onVoiceComplete={handleVoiceComplete}
onRecordingChange={setVoiceRecording}
onError={setVoiceNotice}
stopSignal={voiceStopSignal}
/>
</div>
{chatSkills.length > 0 && (
<ChatSkillPicker
skills={chatSkills}
disabled={voiceDisabled}
onboardingActive={chatControlOnboardingStep === 0}
onSelect={submitText}
onPrefill={(prompt, skillId) => {
pendingSkillRef.current = skillId ?? null;
setInput(prompt);
}}
/>
)}
<label
className={`chat-deep-reasoning-toggle${pgRequired ? ' is-active' : ''}${chatControlOnboardingStep === 1 ? ' chat-control-onboarding-active' : ''}`}
title="勾选后,本轮生成的可保存数据必须使用你的专属 PostgreSQL 数据空间"
>
<input
type="checkbox"
checked={pgRequired}
disabled={voiceDisabled}
aria-label="使用 PostgreSQL 数据空间"
onChange={(event) => setPgRequired(event.target.checked)}
/>
<Database className="chat-deep-reasoning-toggle-icon" aria-hidden="true" />
{chatControlOnboardingStep === 1 && (
<span className="chat-control-onboarding-tip" role="status">使</span>
)}
</label>
<label
className={`chat-deep-reasoning-toggle${forceDeepReasoning ? ' is-active' : ''}${chatControlOnboardingStep === 2 ? ' chat-control-onboarding-active' : ''}`}
title="勾选后,本轮消息使用深度推理完成"
>
<input
type="checkbox"
checked={forceDeepReasoning}
disabled={voiceDisabled}
aria-label="使用深度推理"
onChange={(event) => setForceDeepReasoning(event.target.checked)}
/>
<BrainCircuit className="chat-deep-reasoning-toggle-icon" aria-hidden="true" />
{chatControlOnboardingStep === 2 && (
<span className="chat-control-onboarding-tip" role="status"></span>
)}
</label>
{chatState === 'streaming' ? (
<button type="button" className="danger-btn chat-input-action" onClick={() => void onStop()}>
</button>
) : (
<button
type="button"
className={`send-btn chat-input-action${showHomeWelcome ? ' chat-input-action-home' : ''}`}
disabled={!canSubmit || voiceDisabled || uploadingImage || uploadingFile}
onClick={() => void handleSubmit()}
>
{sendButtonLabel ?? '发送'}
</button>
)}
</div>
{compact && onClose && (
<button type="button" className="space-chat-panel-dismiss" onClick={onClose}>
</button>
)}
</footer>
</>
);
}