import { ChangeEvent, useCallback, useEffect, useLayoutEffect, useRef, useState, type ClipboardEvent } from 'react'; import { BrainCircuit, Database, Image, ImageOff, ImagePlus } from 'lucide-react'; import { useNetworkStatus } from '../hooks/useNetworkStatus'; import { openAvatarPicker } from '../utils/userAvatar'; import { CHAT_SKILL_OPTIONS, filterChatSkills, mergeChatSkillPromptWithInput, } from '../utils/chatSkills'; import { getMessageSaveActions } from '../utils/messageSave'; import { getDisplayText } from '../utils/message'; import { IMAGE_GENERATION_MODE_LABELS, nextImageGenerationMode, type ImageGenerationMode, } from '../utils/imageGeneration'; 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 ( ); } 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; imageGenerationMode?: ImageGenerationMode; selectedChatSkill?: string; fileAttachments?: ChatFileAttachment[]; }, ) => void | Promise; onUploadImage?: ( file: File, onProgress?: (progress: number) => void, options?: { messageId?: string }, ) => Promise; onUploadFile?: ( file: File, onProgress?: (progress: number) => void, options?: { messageId?: string }, ) => Promise; onLoadOlderMessages?: () => void | Promise; onStop: () => void | Promise; onApproveTool: (allow: boolean) => void | Promise; 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(null); const [sharePreviewSource, setSharePreviewSource] = useState(null); const [plazaPublishSource, setPlazaPublishSource] = useState(null); const [voiceNotice, setVoiceNotice] = useState(null); const [forceDeepReasoning, setForceDeepReasoning] = useState(false); const [pgRequired, setPgRequired] = useState(false); const [imageGenerationMode, setImageGenerationMode] = useState('auto'); const [imageGenerationTipVisible, setImageGenerationTipVisible] = useState(false); const [chatControlOnboardingStep, setChatControlOnboardingStep] = useState<0 | 1 | 2 | null>(null); const [pendingImages, setPendingImages] = useState([]); const [pendingFiles, setPendingFiles] = useState([]); const [uploadingImage, setUploadingImage] = useState(false); const [uploadingFile, setUploadingFile] = useState(false); const [imageError, setImageError] = useState(null); const [fileError, setFileError] = useState(null); const [voiceStopSignal, setVoiceStopSignal] = useState(0); const pendingSkillRef = useRef(null); const imageGenerationTipTimerRef = useRef | null>(null); const [randomPrompt] = useState( () => CHAT_PLACEHOLDER_PROMPTS[Math.floor(Math.random() * CHAT_PLACEHOLDER_PROMPTS.length)], ); const uploadInputRef = useRef(null); const mainRef = useRef(null); const inputRef = useRef(input); const voiceBaseRef = useRef(''); const pendingImagesRef = useRef(pendingImages); const suppressVoiceUpdateRef = useRef(false); const nearBottomRef = useRef(true); const initializedSessionRef = useRef(null); const pendingOlderLoadRef = useRef(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('[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]); useEffect(() => () => { if (imageGenerationTipTimerRef.current) { clearTimeout(imageGenerationTipTimerRef.current); } }, []); const showImageGenerationSlowTip = useCallback(() => { setImageGenerationTipVisible(true); if (imageGenerationTipTimerRef.current) { clearTimeout(imageGenerationTipTimerRef.current); } imageGenerationTipTimerRef.current = setTimeout(() => { setImageGenerationTipVisible(false); imageGenerationTipTimerRef.current = null; }, 5_000); }, []); const handleImageGenerationClick = useCallback(() => { setImageGenerationMode((current) => nextImageGenerationMode(current)); showImageGenerationSlowTip(); }, [showImageGenerationSlowTip]); useLayoutEffect(() => { const container = mainRef.current; if (!container) return; const pendingOlderLoad = pendingOlderLoadRef.current; if (pendingOlderLoad) { const anchor = pendingOlderLoad.anchorId ? container.querySelector(`[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('[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, imageGenerationMode, 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, imageGenerationMode, input, onSubmit, onUploadFile, onUploadImage, pendingFiles, pendingImages, revokePendingImage, voiceDisabled, voiceRecording, ]); const handleSubmit = useCallback(async () => { await submitText(); }, [submitText]); const handleUploadInputChange = async (event: ChangeEvent) => { 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) => { 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 ( <>
{!showHomeWelcome && (historyLoadingMore || historyHasMore) && (
{historyLoadingMore ? `正在加载更早消息… 已加载 ${messages.length}${historyTotal > 0 ? ` / ${historyTotal}` : ''}` : `向上滚动加载更早消息${historyTotal > 0 ? ` · 已加载 ${messages.length} / ${historyTotal}` : ''}`}
)} void downloadDocx(message)} publishUserId={user?.id} publishUsername={user?.username} compact={compact} /> {!compact && ( )}
{pendingTool && (
允许执行工具 {pendingTool.toolName}? {pendingTool.prompt &&

{pendingTool.prompt}

}
)} {pageSource?.id && session?.id && ( setPageSource(null)} onSaved={(result) => { setPageSource(null); setSharePreviewSource(null); onPageSaved?.(result); }} /> )} {sharePreviewSource?.id && session?.id && ( setSharePreviewSource(null)} onSave={() => { setPageSource(sharePreviewSource); setSharePreviewSource(null); }} /> )} {plazaPublishSource?.id && session?.id && ( )}