import { ChangeEvent, useCallback, useEffect, useLayoutEffect, useRef, useState, type ClipboardEvent } from 'react'; import { useNetworkStatus } from '../hooks/useNetworkStatus'; import { openAvatarPicker } from '../utils/userAvatar'; import { CHAT_SKILL_OPTIONS, filterChatSkills } from '../utils/chatSkills'; import { CHAT_IMAGE_UPLOAD_MAX_COUNT, CHAT_IMAGE_UPLOAD_MAX_INPUT_BYTES, CHAT_IMAGE_UPLOAD_MAX_TOTAL_BYTES, } from '../utils/imageUpload'; import { AvatarPicker } from './AvatarPicker'; import { ChatSkillPicker } from './ChatSkillPicker'; import { DesignSkillPanel } from './DesignSkillPanel'; import { MessageList } from './MessageList'; import { PageSaveDialog } from './PageSaveDialog'; import { VoiceInputButton } from './VoiceInputButton'; import type { CapabilityMap, 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; 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() { return ( ); } export function ChatPanel({ variant, user, messages, historyLoadingMore = false, historyHasMore = false, historyTotal = 0, chatState, pendingTool, session, capabilities, grantedSkills, onSubmit, onUploadImage, 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[]) => void | Promise; onUploadImage?: (file: File, onProgress?: (progress: number) => void) => 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 [designPanelOpen, setDesignPanelOpen] = useState(false); const [voiceRecording, setVoiceRecording] = useState(false); const [pageSource, setPageSource] = useState(null); const [voiceNotice, setVoiceNotice] = useState(null); const [pendingImages, setPendingImages] = useState([]); const [uploadingImage, setUploadingImage] = useState(false); const [imageError, setImageError] = useState(null); const [showHistoryFullyLoadedNotice, setShowHistoryFullyLoadedNotice] = useState(false); const [voiceStopSignal, setVoiceStopSignal] = useState(0); const [randomPrompt] = useState( () => CHAT_PLACEHOLDER_PROMPTS[Math.floor(Math.random() * CHAT_PLACEHOLDER_PROMPTS.length)], ); const imageInputRef = 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); 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; setShowHistoryFullyLoadedNotice(false); }, [session?.id]); useEffect(() => { const isHomeWelcome = variant !== 'compact' && messages.length === 0; if (isHomeWelcome || historyTotal <= 0 || historyHasMore || historyLoadingMore) { setShowHistoryFullyLoadedNotice(false); return; } setShowHistoryFullyLoadedNotice(true); const timer = window.setTimeout(() => { setShowHistoryFullyLoadedNotice(false); }, 5000); return () => window.clearTimeout(timer); }, [historyHasMore, historyLoadingMore, historyTotal, messages.length, variant]); 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 || chatState === 'streaming') && messageCountIncreased) { container.scrollTop = container.scrollHeight; nearBottomRef.current = true; } prevMessageCountRef.current = messages.length; }, [ chatState, historyHasMore, historyLoadingMore, messages.length, onLoadOlderMessages, requestOlderMessages, session?.id, ]); 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 canPublish = Boolean(capabilities?.static_publish) || hasPublishSkill; const chatSkills = filterChatSkills(CHAT_SKILL_OPTIONS, { grantedSkills, canPublish }); const compact = variant === 'compact'; const showHomeWelcome = !compact && messages.length === 0; const placeholder = offlineBlocked ? '网络断开,恢复连接后可继续输入' : chatState === 'connecting' ? '正在连接会话…' : chatState === 'waiting' ? '消息已收到,正在连接后台…' : compact ? '随时召唤你的小助手吧' : randomPrompt; 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; const imageAttachmentDisabled = !onUploadImage || busy || uploadingImage || 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 handleSubmit = async () => { const trimmed = input.trim(); if ((!trimmed && pendingImages.length === 0) || voiceDisabled) return; if (pendingImages.length > 0 && !onUploadImage) { setImageError('当前会话暂不支持图片发送'); return; } const sentImages = [...pendingImages]; let uploadedImages = sentImages; suppressVoiceUpdateRef.current = true; if (voiceRecording) { setVoiceStopSignal((value) => value + 1); } setInput(''); voiceBaseRef.current = ''; setVoiceNotice(null); setImageError(null); setUploadingImage(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, uploadStatus: 'uploading', uploadProgress: 0 } : candidate, ), ); const uploadedUrl = await onUploadImage!(item.file, (progress) => { setPendingImages((current) => current.map((candidate) => candidate.id === item.id ? { ...candidate, uploadStatus: 'uploading', uploadProgress: Math.round(progress * 100), } : candidate, ), ); }); setPendingImages((current) => current.map((candidate) => candidate.id === item.id ? { ...candidate, 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 imagesToSend = uploadedUrls.filter(Boolean); const previewImagesToSend = imagesToSend; await onSubmit(trimmed, imagesToSend, previewImagesToSend); uploadedImages.forEach(revokePendingImage); setPendingImages([]); } catch (err) { suppressVoiceUpdateRef.current = false; setInput(trimmed); setPendingImages(uploadedImages.map((item) => ({ ...item, uploadStatus: item.uploadedUrl ? 'uploaded' : 'error', }))); setImageError(err instanceof Error ? err.message : '发送失败,请重试'); setUploadingImage(false); return; } setUploadingImage(false); suppressVoiceUpdateRef.current = false; }; const handleImageInputChange = async (event: ChangeEvent) => { if (!onUploadImage) 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('请上传图片文件'); return; } const { accepted: toQueue, message } = planImageUploads(imageFiles); if (toQueue.length === 0) { setImageError(message); return; } setImageError(message); const placeholders = toQueue.map((file, index) => ({ id: `${Date.now()}-${index}-${file.name}`, file, previewUrl: URL.createObjectURL(file), sizeBytes: file.size, uploadedUrl: null, uploadProgress: null, uploadStatus: 'queued' as const, })); setPendingImages((prev) => [...prev, ...placeholders]); }; const handlePaste = async (event: ClipboardEvent) => { if (!onUploadImage || imageAttachmentDisabled) 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, })); 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); }); }; return ( <> {!showHomeWelcome && (historyLoadingMore || historyHasMore || showHistoryFullyLoadedNotice) && ( {historyLoadingMore ? `正在加载更早消息… 已加载 ${messages.length}${historyTotal > 0 ? ` / ${historyTotal}` : ''}` : historyHasMore ? `向上滚动加载更早消息${historyTotal > 0 ? ` · 已加载 ${messages.length} / ${historyTotal}` : ''}` : `历史消息已全部加载${historyTotal > 0 ? ` · ${messages.length} / ${historyTotal}` : ''}`} )} setPageSource(message)} publishUserId={user?.id} publishUsername={user?.username} sessionId={session?.id} compact={compact} /> {!compact && ( )} {pendingTool && ( 允许执行工具 {pendingTool.toolName}? {pendingTool.prompt && {pendingTool.prompt}} void onApproveTool(true)}> 允许 void onApproveTool(false)}> 拒绝 )} {pageSource?.id && session?.id && ( setPageSource(null)} onSaved={(result) => { setPageSource(null); onPageSaved?.(result); }} /> )} > ); } function DesignBtnIcon({ className }: { className?: string }) { return ( ); }
{pendingTool.prompt}