Files
memind/src/components/ChatPanel.tsx
T
john 14a00774d9 feat(h5): web 联网能力、实时查询路由与 session Finish 对齐
- 新增 web 能力并挂载 platform/web(web_search/fetch_url)
- 实时查询强制 web skill,router fallback 与 await session Finish
- Session Broker 覆盖率/指标、stream replay 与相关单测/E2E 脚本

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-06 16:06:26 +08:00

900 lines
32 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 { 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 { 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, 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';
};
function formatUploadMegabytes(bytes: number) {
const megabytes = bytes / (1024 * 1024);
return `${Number.isInteger(megabytes) ? megabytes : megabytes.toFixed(1)}MB`;
}
function FileIcon() {
return (
<svg viewBox="0 0 24 24" width="16" height="16" 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"
/>
<path fill="currentColor" d="M9 13h6v1.5H9zm0 3h6v1.5H9zm0-6h3v1.5H9z" opacity=".72" />
</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,
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 },
) => void | Promise<void>;
onUploadImage?: (
file: File,
onProgress?: (progress: number) => void,
options?: { messageId?: string },
) => Promise<string>;
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 [pendingImages, setPendingImages] = useState<PendingChatImage[]>([]);
const [uploadingImage, setUploadingImage] = useState(false);
const [imageError, setImageError] = 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 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]);
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 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
? '网络断开,恢复连接后可继续输入'
: compact
? '随时召唤你的小助手吧'
: randomPrompt;
const connectStatusText =
uploadingImage
? '正在上传图片,发出后会自动继续…'
: chatState === 'connecting'
? '正在创建会话…'
: null;
const sendButtonLabel =
uploadingImage
? '上传中…'
: chatState === 'connecting'
? '连接中…'
: chatState === 'waiting'
? '提交中…'
: null;
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 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 (pendingImages.length > 0 && !onUploadImage) {
setImageError('当前会话暂不支持图片发送');
return;
}
const existingSourceMessageId = pendingImages.find((item) => item.sourceMessageId)?.sourceMessageId;
const outgoingMessageId = existingSourceMessageId ?? crypto.randomUUID();
const sentImages = pendingImages.map((item) => ({
...item,
sourceMessageId: item.sourceMessageId ?? outgoingMessageId,
}));
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,
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 imagesToSend = uploadedUrls.filter(Boolean);
const previewImagesToSend = imagesToSend;
await onSubmit(trimmed, imagesToSend, previewImagesToSend, {
messageId: outgoingMessageId,
forceDeepReasoning,
selectedChatSkill,
});
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;
}, [forceDeepReasoning, input, onSubmit, onUploadImage, pendingImages, revokePendingImage, voiceDisabled, voiceRecording]);
const handleSubmit = useCallback(async () => {
await submitText();
}, [submitText]);
const handleImageInputChange = async (event: ChangeEvent<HTMLInputElement>) => {
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,
sourceMessageId: null,
uploadProgress: null,
uploadStatus: 'queued' as const,
}));
setPendingImages((prev) => [...prev, ...placeholders]);
};
const handlePaste = async (event: ClipboardEvent<HTMLTextAreaElement>) => {
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,
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 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 || imageError) && (
<div className="chat-image-attachments">
{imageError && <div className="chat-image-upload-error">{imageError}</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>
)}
</div>
)}
<div className={`chat-input-row${showHomeWelcome ? ' chat-input-row-home' : ''}`}>
<div className={`chat-input-shell${showHomeWelcome ? ' chat-input-shell-home' : ''}`}>
{onUploadImage && (
<>
<button
type="button"
className="chat-image-upload-trigger"
title="上传图片"
disabled={imageAttachmentDisabled}
onClick={() => imageInputRef.current?.click()}
>
<FileIcon />
</button>
<input
ref={imageInputRef}
type="file"
accept="image/*"
multiple
hidden
onChange={handleImageInputChange}
/>
</>
)}
<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();
}
}}
/>
<VoiceInputButton
disabled={voiceDisabled}
onVoiceStart={handleVoiceStart}
onLiveTranscript={handleVoiceLiveTranscript}
onTranscript={handleVoiceTranscript}
onVoiceComplete={handleVoiceComplete}
onRecordingChange={setVoiceRecording}
onError={setVoiceNotice}
stopSignal={voiceStopSignal}
/>
</div>
{!showHomeWelcome && chatSkills.length > 0 && (
<ChatSkillPicker
skills={chatSkills}
disabled={voiceDisabled}
onSelect={submitText}
onPrefill={(prompt, skillId) => {
pendingSkillRef.current = skillId ?? null;
setInput(prompt);
}}
/>
)}
{!showHomeWelcome && (
<label
className={`chat-deep-reasoning-toggle${forceDeepReasoning ? ' is-active' : ''}`}
title="勾选后,本轮消息使用深度推理完成"
>
<input
type="checkbox"
checked={forceDeepReasoning}
disabled={voiceDisabled}
onChange={(event) => setForceDeepReasoning(event.target.checked)}
/>
<span>深度推理</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}
onClick={() => void handleSubmit()}
>
{sendButtonLabel ?? '发送'}
</button>
)}
</div>
{compact && onClose && (
<button type="button" className="space-chat-panel-dismiss" onClick={onClose}>
关闭
</button>
)}
</footer>
</>
);
}