import { Fragment, useRef, useMemo, useState, useEffect } from "react"; import { IconSend, IconPlayerStop, IconChevronDown, IconPlus } from "@tabler/icons-react"; import { Button } from "@/components/ui/button"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { ActionMenu } from "../ActionMenu"; import { SlashCommandMenu } from "../SlashCommandMenu"; import { FilePickerMenu } from "../FilePickerMenu"; import { MediaThumbnail } from "../MediaThumbnail"; import { MediaPreviewModal } from "../MediaPreviewModal"; import { BottomToolbar } from "../BottomToolbar"; import { StreamingConfirmDialog } from "../StreamingConfirmDialog"; import { ThinkingButton } from "../ThinkingButton"; import { PlanModeButton } from "../PlanModeButton"; import { getModelById, getMediaFallbackModel, getModelsForMedia, groupModelsByProvider, providerDisplayName, useChatStore, useSettingsStore, } from "@/stores"; import { bridge, Events } from "@/services"; import { Content } from "@/lib/content"; import { cn } from "@/lib/utils"; import { useSlashMenu, findActiveToken } from "./hooks/useSlashMenu"; import { useFilePicker } from "./hooks/useFilePicker"; import { useMediaUpload } from "./hooks/useMediaUpload"; import { useClickOutside } from "./hooks/useClickOutside"; import { useInputHistory } from "./hooks/useInputHistory"; import { computeMentionInsert } from "./utils"; interface InputAreaProps { onAuthAction?: () => void; } const SWITCH_CACHE_NOTE = "Note: Switching models or thinking effort invalidates the existing prompt cache. Start a new conversation to avoid extra token costs."; function adjustHeight(textarea: HTMLTextAreaElement | null) { if (!textarea) return; textarea.style.height = "auto"; textarea.style.height = `${Math.min(textarea.scrollHeight, 140)}px`; } export function InputArea({ onAuthAction }: InputAreaProps) { const textareaRef = useRef(null); const menuRef = useRef(null); const [text, setText] = useState(""); const [cursorPos, setCursorPos] = useState(0); const [previewMedia, setPreviewMedia] = useState(null); const { isStreaming, sendMessage, abort, draftMedia, removeDraftMedia, hasProcessingMedia, getMediaInConversation, pendingInput, planMode, messages } = useChatStore(); const { currentModel, thinkingEffort, updateModel, toggleThinking, selectThinkingEffort, models, extensionConfig, getCurrentThinkingMode } = useSettingsStore(); const isProcessing = hasProcessingMedia(); const thinkingMode = getCurrentThinkingMode(); // A switch from a non-empty conversation resends the accumulated context, // losing the prompt cache — surface the cost note in the switcher dropdowns. const hasConversationHistory = messages.some((message) => message.role === "user"); const [showPlanModeConfirm, setShowPlanModeConfirm] = useState(false); const handleTogglePlanMode = () => { // Turning OFF during streaming needs confirmation — user may want next turn, not current if (planMode && isStreaming) { setShowPlanModeConfirm(true); return; } const newState = !planMode; useChatStore.setState({ planMode: newState }); // optimistic void bridge.setPlanMode(newState); }; const handleConfirmExitPlanMode = () => { useChatStore.setState({ planMode: false }); void bridge.setPlanMode(false); setShowPlanModeConfirm(false); }; const mediaReq = useMemo(() => { const media = getMediaInConversation(); return { image: media.hasImage, video: media.hasVideo }; }, [getMediaInConversation, draftMedia]); const availableModels = useMemo(() => getModelsForMedia(models, mediaReq), [models, mediaReq]); const currentModelConfig = getModelById(models, currentModel); const modelGroups = useMemo(() => groupModelsByProvider(availableModels), [availableModels]); const showProviderGroups = modelGroups.length > 1; const currentModelLabel = currentModelConfig === undefined ? "No models available" : showProviderGroups ? `${currentModelConfig.name} · ${providerDisplayName(currentModelConfig.provider)}` : currentModelConfig.name; // Auto-switch model if current model doesn't support required media useEffect(() => { if (!mediaReq.image && !mediaReq.video) { return; } const isCurrentModelValid = availableModels.some((m) => m.id === currentModel); if (isCurrentModelValid) { return; } const fallbackModel = getMediaFallbackModel(availableModels, currentModelConfig); if (fallbackModel !== undefined) { updateModel(fallbackModel.id); } }, [mediaReq.image, mediaReq.video, currentModel, currentModelConfig, availableModels, updateModel]); // Restore pending input useEffect(() => { if (!pendingInput || isStreaming) { return; } // 只在输入框为空时恢复 if (text.trim()) { return; } const textContent = Content.getText(pendingInput.content); if (textContent) { setText(textContent); setTimeout(() => { textareaRef.current?.focus(); adjustHeight(textareaRef.current); }, 0); } }, [pendingInput, isStreaming]); const activeToken = useMemo(() => findActiveToken(text, cursorPos), [text, cursorPos]); const { handlePaste, handlePickMedia } = useMediaUpload(); const { handleKey: handleHistoryKey, add: addToHistory, reset: resetHistoryIndex, } = useInputHistory({ text, setText, onHeightChange: () => setTimeout(() => { adjustHeight(textareaRef.current); }, 0), }); function clearInput() { setText(""); setCursorPos(0); setTimeout(() => { adjustHeight(textareaRef.current); }, 0); } function removeActiveToken() { if (!activeToken) return; const newText = text.slice(0, activeToken.start) + text.slice(cursorPos); const newCursorPos = activeToken.start; setText(newText); setCursorPos(newCursorPos); setTimeout(() => { textareaRef.current?.setSelectionRange(newCursorPos, newCursorPos); adjustHeight(textareaRef.current); }, 0); } function handleSend() { if (isProcessing || (!text.trim() && draftMedia.length === 0)) { return; } addToHistory(text); sendMessage(text); clearInput(); } function handleSlashCommand(name: string) { sendMessage(`/${name}`); clearInput(); } function applyMention(filePath: string) { const { newText, newCursorPos } = computeMentionInsert({ text, cursorPos, filePath, activeToken, isAppend: false, }); setText(newText); setCursorPos(newCursorPos); setTimeout(() => { textareaRef.current?.setSelectionRange(newCursorPos, newCursorPos); textareaRef.current?.focus(); adjustHeight(textareaRef.current); }, 0); } const { showSlashMenu, filteredCommands, selectedIndex: slashSelectedIndex, setSelectedIndex: setSlashSelectedIndex, handleSlashMenuKey, resetSlashMenu, } = useSlashMenu(activeToken, handleSlashCommand, removeActiveToken); const { showFileMenu, fileItems, selectedIndex: fileSelectedIndex, isLoading: isFileLoading, isStale: isFileStale, showMediaOption, setSelectedIndex: setFileSelectedIndex, handleSelectItem: handleSelectFileItem, handleFileMenuKey, resetFilePicker, } = useFilePicker( activeToken, applyMention, () => { void handlePickMedia(); }, removeActiveToken, ); const closeMenus = () => { if (showSlashMenu || showFileMenu) { removeActiveToken(); } }; useClickOutside([textareaRef, menuRef], showSlashMenu || showFileMenu, closeMenus); useEffect(() => { resetSlashMenu(); }, [showSlashMenu, resetSlashMenu]); useEffect(() => { if (!showFileMenu) { resetFilePicker(); } }, [showFileMenu, resetFilePicker]); useEffect(() => { const unsub = bridge.on<{ mention: string }>(Events.InsertMention, ({ mention }) => { setText((prev) => prev + mention + " "); setTimeout(() => { textareaRef.current?.focus(); adjustHeight(textareaRef.current); }, 0); }); return unsub; }, []); function handleKeyDown(e: React.KeyboardEvent) { if (e.nativeEvent.isComposing) { return; } if (handleSlashMenuKey(e)) { return; } if (handleFileMenuKey(e)) { return; } if (handleHistoryKey(e)) { return; } if (extensionConfig.useCtrlEnterToSend) { if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) { e.preventDefault(); handleSend(); } } else { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); handleSend(); } } } const handleChange = (e: React.ChangeEvent) => { setText(e.target.value); setCursorPos(e.target.selectionStart); resetHistoryIndex(); setTimeout(() => { adjustHeight(textareaRef.current); }, 0); }; const handleSelect = () => { setCursorPos(textareaRef.current?.selectionStart ?? 0); }; function handleAddButtonClick() { const newText = text + "@"; setText(newText); setCursorPos(newText.length); setTimeout(() => { textareaRef.current?.focus(); textareaRef.current?.setSelectionRange(newText.length, newText.length); adjustHeight(textareaRef.current); }, 0); } const hasModels = availableModels.length > 0; const canSend = (text.trim() || draftMedia.length > 0) && !isProcessing; return (
{showSlashMenu && filteredCommands.length > 0 && (
)} {showFileMenu && (
{ void handlePickMedia(); }} onSelectItem={handleSelectFileItem} onHover={setFileSelectedIndex} />
)}
{draftMedia.length > 0 && (
{draftMedia.map((item) => ( setPreviewMedia(item.dataUri!) : undefined} onRemove={() => removeDraftMedia(item.id)} /> ))}
)}