1272 lines
45 KiB
TypeScript
1272 lines
45 KiB
TypeScript
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>
|
||
</>
|
||
);
|
||
}
|