From fbc5bef636d8b7ac4a7940b09a73cdd1044abbfc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BB=8E=E4=BD=95=E5=BC=80=E5=A7=8B123?= <64304674+yeahhe365@users.noreply.github.com> Date: Fri, 3 Apr 2026 17:56:53 +0800 Subject: [PATCH 1/2] =?UTF-8?q?refactor:=20=E5=AE=8C=E6=88=90=20Zustand=20?= =?UTF-8?q?=E8=BF=81=E7=A7=BB=20Phase=202=20=E2=80=94=20=E7=BB=84=E4=BB=B6?= =?UTF-8?q?=E7=9B=B4=E6=8E=A5=E8=AF=BB=20store=EF=BC=8C=E6=B6=88=E9=99=A4?= =?UTF-8?q?=20props=20drilling?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 组件层迁移: - ChatArea、ChatInput、MessageList、Header、HistorySidebar、AppModals 内部直接从 useChatStore/useSettingsStore/useUIStore 读取数据 - 使用 "store first, props fallback" 模式保持向后兼容 中间层清理: - ChatAreaProps 接口大幅缩减(移除 appSettings/themeId/language 等状态 props) - useChatAreaProps 从 270 行缩减到 185 行 - useChatState 从 98 行缩减到 40 行(只保留 activeChat/currentChatSettings/isLoading 计算值) - useChat 直接通过 store selectors 读取状态 修复: - 扩展 thinkingLevel 类型支持 MINIMAL/MEDIUM - 修复 t 翻译函数类型兼容性 Co-Authored-By: Claude Opus 4.6 --- README.md | 2 +- index.html | 2 +- src/App.tsx | 80 +++ src/components/chat/MessageList.tsx | 217 +++++++ src/components/chat/input/AttachmentMenu.tsx | 149 +++++ src/components/chat/input/ChatInput.tsx | 306 ++++++++++ .../chat/input/ChatInputActions.tsx | 145 +++++ src/components/chat/input/ChatInputArea.tsx | 212 +++++++ .../chat/input/ChatInputFileModals.tsx | 88 +++ src/components/chat/input/ChatInputModals.tsx | 107 ++++ .../chat/input/ChatInputToolbar.tsx | 114 ++++ .../chat/input/LiveStatusBanner.tsx | 67 +++ .../chat/input/SelectedFileDisplay.tsx | 162 ++++++ .../chat/input/SlashCommandMenu.tsx | 130 +++++ src/components/chat/input/ToolsMenu.tsx | 219 +++++++ .../chat/input/actions/LiveControls.tsx | 61 ++ .../chat/input/actions/RecordControls.tsx | 64 +++ .../chat/input/actions/SendControls.tsx | 186 ++++++ .../chat/input/actions/UtilityControls.tsx | 57 ++ .../chat/input/actions/WebSearchToggle.tsx | 28 + .../chat/input/area/ChatFilePreviewList.tsx | 45 ++ .../chat/input/area/ChatQuoteDisplay.tsx | 60 ++ .../chat/input/area/ChatSuggestions.tsx | 160 ++++++ .../chat/input/area/ChatTextArea.tsx | 107 ++++ .../chat/input/area/SuggestionIcon.tsx | 18 + .../chat/input/toolbar/AddFileByIdInput.tsx | 47 ++ .../chat/input/toolbar/AddUrlInput.tsx | 40 ++ .../chat/input/toolbar/ImageSizeSelector.tsx | 34 ++ .../toolbar/ImagenAspectRatioSelector.tsx | 59 ++ .../chat/input/toolbar/InputBar.tsx | 105 ++++ .../input/toolbar/MediaResolutionSelector.tsx | 50 ++ .../chat/input/toolbar/QuadImageToggle.tsx | 29 + .../chat/input/toolbar/TtsVoiceSelector.tsx | 36 ++ .../chat/message-list/MessageListFooter.tsx | 23 + .../message-list/MessageListPlaceholder.tsx | 38 ++ .../chat/message-list/ScrollNavigation.tsx | 72 +++ .../message-list/TextSelectionToolbar.tsx | 123 ++++ .../chat/message-list/WelcomeScreen.tsx | 156 +++++ .../hooks/useMessageListScroll.ts | 205 +++++++ .../text-selection/AudioPlayerView.tsx | 56 ++ .../text-selection/StandardActionsView.tsx | 92 +++ .../text-selection/ToolbarContainer.tsx | 29 + .../chat/overlays/DragDropOverlay.tsx | 28 + .../chat/overlays/ModelsErrorDisplay.tsx | 16 + src/components/header/Header.tsx | 175 ++++++ src/components/header/HeaderModelSelector.tsx | 105 ++++ src/components/icons/AppLogo.tsx | 36 ++ src/components/icons/CommandIcon.tsx | 29 + src/components/icons/CustomIcons.tsx | 9 + src/components/icons/GoogleSpinner.tsx | 34 ++ .../icons/groups/AttachmentIcons.tsx | 85 +++ src/components/icons/groups/GeneralIcons.tsx | 79 +++ src/components/icons/groups/SettingsIcons.tsx | 60 ++ src/components/icons/groups/ThemeIcons.tsx | 30 + src/components/icons/iconUtils.ts | 13 + src/components/layout/ChatArea.tsx | 219 +++++++ src/components/layout/MainContent.tsx | 53 ++ src/components/layout/PiPPlaceholder.tsx | 23 + src/components/layout/SidePanel.tsx | 246 ++++++++ .../layout/chat-area/ChatAreaProps.ts | 106 ++++ .../layout/chat-area/useChatArea.ts | 36 ++ src/components/log-viewer/ApiUsageTab.tsx | 66 +++ src/components/log-viewer/ConsoleTab.tsx | 120 ++++ src/components/log-viewer/LogRow.tsx | 51 ++ src/components/log-viewer/LogViewer.tsx | 155 +++++ .../log-viewer/ObfuscatedApiKey.tsx | 17 + src/components/log-viewer/TokenUsageTab.tsx | 74 +++ src/components/log-viewer/constants.ts | 18 + src/components/message/FileDisplay.tsx | 213 +++++++ src/components/message/GroundedResponse.tsx | 78 +++ src/components/message/MarkdownRenderer.tsx | 241 ++++++++ src/components/message/Message.tsx | 88 +++ src/components/message/MessageActions.tsx | 186 ++++++ src/components/message/MessageContent.tsx | 62 ++ src/components/message/PerformanceMetrics.tsx | 106 ++++ src/components/message/ThinkingTimer.tsx | 30 + src/components/message/blocks/CodeBlock.tsx | 221 ++++++++ .../message/blocks/GraphvizBlock.tsx | 228 ++++++++ .../message/blocks/MermaidBlock.tsx | 111 ++++ src/components/message/blocks/TableBlock.tsx | 209 +++++++ .../message/blocks/ToolResultBlock.tsx | 90 +++ .../message/blocks/parts/CodeHeader.tsx | 90 +++ .../message/blocks/parts/DiagramWrapper.tsx | 122 ++++ .../message/buttons/ExportMessageButton.tsx | 58 ++ .../message/buttons/MessageCopyButton.tsx | 22 + .../message/buttons/export/ExportModal.tsx | 56 ++ .../message/buttons/export/ExportOptions.tsx | 61 ++ .../message/code-block/InlineCode.tsx | 30 + .../message/code-block/LanguageIcon.tsx | 71 +++ .../message/content/MessageFiles.tsx | 116 ++++ .../message/content/MessageFooter.tsx | 68 +++ .../message/content/MessageText.tsx | 132 +++++ .../message/content/MessageThoughts.tsx | 164 ++++++ .../content/thoughts/ThinkingActions.tsx | 61 ++ .../content/thoughts/ThinkingHeader.tsx | 95 ++++ .../content/thoughts/ThoughtContent.tsx | 71 +++ .../message/grounded-response/ContextUrls.tsx | 67 +++ .../grounded-response/SearchQueries.tsx | 33 ++ .../grounded-response/SearchSources.tsx | 62 ++ .../message/grounded-response/utils.ts | 109 ++++ src/components/modals/AppModals.tsx | 145 +++++ src/components/modals/AudioRecorder.tsx | 149 +++++ src/components/modals/ConfirmationModal.tsx | 75 +++ .../modals/CreateTextFileEditor.tsx | 107 ++++ src/components/modals/ExportChatModal.tsx | 56 ++ .../modals/FileConfigurationModal.tsx | 119 ++++ src/components/modals/FilePreviewModal.tsx | 250 ++++++++ src/components/modals/HelpModal.tsx | 124 ++++ src/components/modals/HtmlPreviewModal.tsx | 101 ++++ src/components/modals/TextEditorModal.tsx | 88 +++ src/components/modals/TokenCountModal.tsx | 119 ++++ .../modals/create-file/CreateFileBody.tsx | 132 +++++ .../modals/create-file/CreateFileFooter.tsx | 82 +++ .../modals/create-file/CreateFileHeader.tsx | 73 +++ .../modals/file-config/FileConfigFooter.tsx | 21 + .../modals/file-config/FileConfigHeader.tsx | 22 + .../modals/file-config/ResolutionConfig.tsx | 38 ++ .../modals/file-config/VideoConfig.tsx | 76 +++ .../html-preview/HtmlPreviewContent.tsx | 46 ++ .../modals/html-preview/HtmlPreviewHeader.tsx | 105 ++++ .../modals/token-count/TokenCountFiles.tsx | 66 +++ .../modals/token-count/TokenCountFooter.tsx | 56 ++ .../modals/token-count/TokenCountInput.tsx | 24 + src/components/recorder/AudioVisualizer.tsx | 99 ++++ src/components/recorder/RecorderControls.tsx | 86 +++ .../scenarios/PreloadedMessagesModal.tsx | 195 +++++++ src/components/scenarios/ScenarioEditor.tsx | 126 +++++ src/components/scenarios/ScenarioItem.tsx | 117 ++++ src/components/scenarios/ScenarioList.tsx | 153 +++++ .../scenarios/editor/ScenarioEditorHeader.tsx | 70 +++ .../scenarios/editor/ScenarioMessageInput.tsx | 78 +++ .../scenarios/editor/ScenarioMessageList.tsx | 122 ++++ .../scenarios/editor/ScenarioSystemPrompt.tsx | 45 ++ .../settings/ModelVoiceSettings.tsx | 237 ++++++++ src/components/settings/SettingsContent.tsx | 155 +++++ src/components/settings/SettingsModal.tsx | 144 +++++ src/components/settings/SettingsSidebar.tsx | 68 +++ .../settings/controls/ModelSelector.tsx | 45 ++ .../settings/controls/VoiceControl.tsx | 76 +++ .../model-selector/ModelListEditor.tsx | 96 ++++ .../model-selector/ModelListEditorRow.tsx | 40 ++ .../controls/model-selector/ModelListView.tsx | 65 +++ .../model-selector/ModelSelectorHeader.tsx | 23 + .../controls/thinking/LevelButton.tsx | 25 + .../controls/thinking/SparklesIcon.tsx | 8 + .../thinking/ThinkingBudgetSlider.tsx | 56 ++ .../controls/thinking/ThinkingControl.tsx | 206 +++++++ .../thinking/ThinkingLevelSelector.tsx | 66 +++ .../thinking/ThinkingModeSelector.tsx | 62 ++ .../settings/sections/AboutSection.tsx | 160 ++++++ .../settings/sections/ApiConfigSection.tsx | 175 ++++++ .../settings/sections/AppearanceSection.tsx | 44 ++ .../settings/sections/ChatBehaviorSection.tsx | 124 ++++ .../sections/DataManagementSection.tsx | 149 +++++ .../settings/sections/SafetySection.tsx | 194 +++++++ .../settings/sections/ShortcutsSection.tsx | 85 +++ .../sections/api-config/ApiConfigToggle.tsx | 54 ++ .../api-config/ApiConnectionTester.tsx | 90 +++ .../sections/api-config/ApiKeyInput.tsx | 54 ++ .../sections/api-config/ApiProxySettings.tsx | 118 ++++ .../appearance/FileStrategyControl.tsx | 43 ++ .../sections/appearance/FontSizeControl.tsx | 34 ++ .../sections/appearance/InterfaceToggles.tsx | 76 +++ .../appearance/ThemeLanguageSelector.tsx | 73 +++ .../sections/shortcuts/ShortcutRecorder.tsx | 154 +++++ src/components/shared/AudioPlayer.tsx | 165 ++++++ src/components/shared/CodeEditor.tsx | 65 +++ src/components/shared/ErrorBoundary.tsx | 52 ++ src/components/shared/LoadingDots.tsx | 9 + src/components/shared/Modal.tsx | 80 +++ src/components/shared/ModelPicker.tsx | 128 +++++ src/components/shared/Select.tsx | 136 +++++ src/components/shared/Toggle.tsx | 29 + src/components/shared/ToggleItem.tsx | 36 ++ src/components/shared/Tooltip.tsx | 9 + .../shared/file-preview/FilePreviewHeader.tsx | 179 ++++++ .../shared/file-preview/FloatingToolbar.tsx | 52 ++ .../shared/file-preview/ImageViewer.tsx | 245 ++++++++ .../shared/file-preview/PdfViewer.tsx | 83 +++ .../shared/file-preview/TextFileViewer.tsx | 150 +++++ .../pdf-viewer/PdfMainContent.tsx | 181 ++++++ .../file-preview/pdf-viewer/PdfSidebar.tsx | 62 ++ .../file-preview/pdf-viewer/PdfToolbar.tsx | 106 ++++ src/components/sidebar/GroupItem.tsx | 94 +++ src/components/sidebar/GroupItemMenu.tsx | 20 + src/components/sidebar/HistorySidebar.tsx | 274 +++++++++ src/components/sidebar/SessionItem.tsx | 102 ++++ src/components/sidebar/SessionItemMenu.tsx | 26 + src/components/sidebar/SidebarActions.tsx | 76 +++ src/components/sidebar/SidebarHeader.tsx | 22 + src/constants/appConstants.ts | 124 ++++ src/constants/defaultScenarios.ts | 20 + src/constants/fileConstants.ts | 203 +++++++ src/constants/modelConstants.ts | 140 +++++ src/constants/promptConstants.ts | 90 +++ src/constants/prompts/canvas.ts | 533 ++++++++++++++++++ src/constants/prompts/deepSearch.ts | 21 + src/constants/scenarios/adventure.ts | 43 ++ src/constants/scenarios/demo.ts | 72 +++ src/constants/scenarios/jailbreak.ts | 149 +++++ src/constants/scenarios/utility.ts | 72 +++ src/constants/shortcuts.ts | 35 ++ src/constants/specialPrompts.ts | 11 + src/constants/themeConstants.ts | 123 ++++ src/contexts/WindowContext.tsx | 37 ++ src/hooks/app/logic/useAppHandlers.ts | 157 ++++++ src/hooks/app/logic/useAppInitialization.ts | 22 + src/hooks/app/logic/useAppSidePanel.ts | 21 + src/hooks/app/logic/useAppTitle.ts | 71 +++ src/hooks/app/props/useChatAreaProps.ts | 203 ++----- src/hooks/app/useAppProps.ts | 13 + .../handlers/useFileManagementHandlers.ts | 86 +++ .../handlers/useFileSelectionHandlers.ts | 83 +++ .../handlers/useInputAndPasteHandlers.ts | 131 +++++ .../handlers/useKeyboardHandlers.ts | 213 +++++++ .../handlers/useSubmissionHandlers.ts | 152 +++++ src/hooks/chat-input/useChatInputEffects.ts | 210 +++++++ src/hooks/chat-input/useChatInputHeight.ts | 25 + .../chat-input/useChatInputLocalState.ts | 88 +++ src/hooks/chat-input/useChatInputLogic.ts | 277 +++++++++ src/hooks/chat-input/useChatInputModals.ts | 129 +++++ src/hooks/chat-input/useChatInputState.ts | 164 ++++++ src/hooks/chat-stream/processors.ts | 255 +++++++++ src/hooks/chat-stream/utils.ts | 11 + src/hooks/chat/actions/useAudioActions.ts | 59 ++ .../chat/actions/useChatSessionActions.ts | 109 ++++ src/hooks/chat/actions/useMessageUpdates.ts | 213 +++++++ src/hooks/chat/actions/useModelSelection.ts | 100 ++++ src/hooks/chat/history/useGroupActions.ts | 57 ++ src/hooks/chat/history/useHistoryClearer.ts | 72 +++ src/hooks/chat/history/useSessionActions.ts | 89 +++ src/hooks/chat/history/useSessionLoader.ts | 279 +++++++++ src/hooks/chat/messages/useMessageActions.ts | 226 ++++++++ .../chat/messages/useTextToSpeechHandler.ts | 85 +++ src/hooks/chat/useAutoTitling.ts | 128 +++++ src/hooks/chat/useChat.ts | 395 +++++++++++++ src/hooks/chat/useChatActions.ts | 118 ++++ src/hooks/chat/useChatEffects.ts | 144 +++++ src/hooks/chat/useChatHistory.ts | 120 ++++ src/hooks/chat/useChatScroll.ts | 72 +++ src/hooks/chat/useChatState.ts | 78 +-- src/hooks/chat/useSuggestions.ts | 112 ++++ src/hooks/core/useAppEvents.ts | 160 ++++++ src/hooks/core/useBackgroundKeepAlive.ts | 102 ++++ src/hooks/core/useModels.ts | 32 ++ src/hooks/core/useMultiTabSync.ts | 70 +++ src/hooks/core/usePictureInPicture.ts | 109 ++++ src/hooks/core/useRecorder.ts | 159 ++++++ .../data-management/useChatSessionExport.ts | 127 +++++ src/hooks/data-management/useDataExport.ts | 78 +++ src/hooks/data-management/useDataImport.ts | 110 ++++ src/hooks/features/useLocalPythonAgent.ts | 119 ++++ src/hooks/features/useScenarioManager.ts | 194 +++++++ src/hooks/features/useSettingsLogic.ts | 210 +++++++ src/hooks/features/useTokenCountLogic.ts | 131 +++++ src/hooks/file-upload/uploadFileItem.ts | 181 ++++++ src/hooks/file-upload/useFileIdAdder.ts | 108 ++++ src/hooks/file-upload/useFilePreProcessing.ts | 168 ++++++ src/hooks/file-upload/useFileUploader.ts | 100 ++++ src/hooks/file-upload/utils.ts | 60 ++ src/hooks/files/useFileDragDrop.ts | 157 ++++++ src/hooks/files/useFileHandling.ts | 47 ++ src/hooks/files/useFilePolling.ts | 91 +++ src/hooks/files/useFileUpload.ts | 63 +++ src/hooks/live-api/useLiveAudio.ts | 199 +++++++ src/hooks/live-api/useLiveConfig.ts | 72 +++ src/hooks/live-api/useLiveConnection.ts | 276 +++++++++ src/hooks/live-api/useLiveFrameCapture.ts | 51 ++ .../live-api/useLiveMessageProcessing.ts | 131 +++++ src/hooks/live-api/useLiveTools.ts | 55 ++ src/hooks/live-api/useLiveVideo.ts | 110 ++++ .../standard/useApiInteraction.ts | 185 ++++++ .../standard/useSessionUpdate.ts | 115 ++++ src/hooks/message-sender/types.ts | 55 ++ .../message-sender/useApiErrorHandler.ts | 64 +++ .../message-sender/useCanvasGenerator.ts | 124 ++++ .../message-sender/useChatStreamHandler.ts | 254 +++++++++ .../message-sender/useImageEditSender.ts | 161 ++++++ src/hooks/message-sender/useStandardChat.ts | 159 ++++++ .../message-sender/useTtsImagenSender.ts | 128 +++++ src/hooks/text-selection/useSelectionAudio.ts | 36 ++ src/hooks/text-selection/useSelectionDrag.ts | 99 ++++ .../text-selection/useSelectionPosition.ts | 127 +++++ src/hooks/ui/useCodeBlock.ts | 249 ++++++++ src/hooks/ui/useFullscreen.ts | 47 ++ src/hooks/ui/useHtmlPreviewModal.ts | 177 ++++++ src/hooks/ui/useImageNavigation.ts | 44 ++ src/hooks/ui/useMessageStream.ts | 31 + src/hooks/ui/usePdfViewer.ts | 167 ++++++ src/hooks/ui/useSmoothStreaming.ts | 109 ++++ src/hooks/useAudioRecorder.ts | 70 +++ src/hooks/useClickOutside.ts | 34 ++ src/hooks/useCopyToClipboard.ts | 38 ++ src/hooks/useCreateFileEditor.ts | 377 +++++++++++++ src/hooks/useDataManagement.ts | 69 +++ src/hooks/useDevice.ts | 77 +++ src/hooks/useHistorySidebarLogic.ts | 293 ++++++++++ src/hooks/useLiveAPI.ts | 130 +++++ src/hooks/useMessageExport.ts | 147 +++++ src/hooks/useMessageHandler.ts | 86 +++ src/hooks/useMessageListUI.ts | 81 +++ src/hooks/useMessageSender.ts | 170 ++++++ src/hooks/useModelCapabilities.ts | 40 ++ src/hooks/usePreloadedScenarios.ts | 133 +++++ src/hooks/usePyodide.ts | 93 +++ src/hooks/useSlashCommands.ts | 205 +++++++ src/hooks/useTextAreaInsert.ts | 51 ++ src/hooks/useVoiceInput.ts | 83 +++ src/index.tsx | 21 + src/services/api/__tests__/baseApi.test.ts | 282 +++++++++ src/services/api/baseApi.ts | 230 ++++++++ src/services/api/chatApi.ts | 186 ++++++ src/services/api/fileApi.ts | 77 +++ src/services/api/generation/audioApi.ts | 126 +++++ src/services/api/generation/imageApi.ts | 52 ++ src/services/api/generation/textApi.ts | 142 +++++ src/services/api/generation/tokenApi.ts | 30 + src/services/api/generationApi.ts | 6 + src/services/geminiService.ts | 132 +++++ src/services/logService.ts | 304 ++++++++++ src/services/networkInterceptor.ts | 150 +++++ src/services/pyodideService.ts | 312 ++++++++++ src/services/streamingStore.ts | 46 ++ src/stores/__tests__/chatStore.test.ts | 342 +++++++++++ src/stores/__tests__/settingsStore.test.ts | 191 +++++++ src/styles/animations.css | 181 ++++++ src/styles/main.css | 150 +++++ src/styles/markdown.css | 260 +++++++++ src/test/setup.ts | 18 + src/types/api.ts | 54 ++ src/types/chat.ts | 288 ++++++++++ src/types/index.ts | 9 + src/types/settings.ts | 97 ++++ src/types/theme.ts | 63 +++ src/utils/__tests__/modelHelpers.test.ts | 173 ++++++ src/utils/apiUtils.ts | 114 ++++ src/utils/appUtils.ts | 12 + src/utils/audio/audioProcessing.ts | 181 ++++++ src/utils/audio/audioWorklet.ts | 29 + src/utils/audioCompression.ts | 150 +++++ src/utils/chat/__tests__/builder.test.ts | 220 ++++++++ src/utils/chat/__tests__/parsing.test.ts | 89 +++ src/utils/chat/__tests__/session.test.ts | 328 +++++++++++ src/utils/chat/builder.ts | 215 +++++++ src/utils/chat/ids.ts | 2 + src/utils/chat/parsing.ts | 77 +++ src/utils/chat/session.ts | 238 ++++++++ src/utils/chatHelpers.ts | 6 + src/utils/clipboardUtils.ts | 68 +++ src/utils/codeUtils.ts | 6 + src/utils/dateHelpers.ts | 7 + src/utils/db.ts | 285 ++++++++++ src/utils/domainUtils.ts | 6 + src/utils/export/core.ts | 37 ++ src/utils/export/dom.ts | 292 ++++++++++ src/utils/export/files.ts | 21 + src/utils/export/image.ts | 248 ++++++++ src/utils/export/templates.ts | 171 ++++++ src/utils/exportUtils.ts | 6 + src/utils/fileHelpers.ts | 97 ++++ src/utils/folderImportUtils.ts | 347 ++++++++++++ src/utils/htmlToMarkdown.ts | 45 ++ src/utils/markdownConfig.ts | 65 +++ src/utils/mediaUtils.ts | 100 ++++ src/utils/modelHelpers.ts | 147 +++++ src/utils/shortcutUtils.ts | 121 ++++ src/utils/translations.ts | 43 ++ src/utils/translations/app.ts | 9 + src/utils/translations/chatInput.ts | 120 ++++ src/utils/translations/common.ts | 25 + src/utils/translations/header.ts | 24 + src/utils/translations/history.ts | 23 + src/utils/translations/messages.ts | 80 +++ src/utils/translations/scenarios.ts | 67 +++ src/utils/translations/settings/about.ts | 13 + src/utils/translations/settings/api.ts | 15 + src/utils/translations/settings/appearance.ts | 50 ++ src/utils/translations/settings/data.ts | 34 ++ src/utils/translations/settings/general.ts | 10 + src/utils/translations/settings/model.ts | 72 +++ src/utils/translations/settings/safety.ts | 14 + src/utils/translations/settings/shortcuts.ts | 26 + src/utils/uiUtils.ts | 198 +++++++ tsconfig.json | 2 +- vite.config.ts | 2 +- vitest.config.ts | 2 +- 386 files changed, 40441 insertions(+), 217 deletions(-) create mode 100644 src/App.tsx create mode 100644 src/components/chat/MessageList.tsx create mode 100644 src/components/chat/input/AttachmentMenu.tsx create mode 100644 src/components/chat/input/ChatInput.tsx create mode 100644 src/components/chat/input/ChatInputActions.tsx create mode 100644 src/components/chat/input/ChatInputArea.tsx create mode 100644 src/components/chat/input/ChatInputFileModals.tsx create mode 100644 src/components/chat/input/ChatInputModals.tsx create mode 100644 src/components/chat/input/ChatInputToolbar.tsx create mode 100644 src/components/chat/input/LiveStatusBanner.tsx create mode 100644 src/components/chat/input/SelectedFileDisplay.tsx create mode 100644 src/components/chat/input/SlashCommandMenu.tsx create mode 100644 src/components/chat/input/ToolsMenu.tsx create mode 100644 src/components/chat/input/actions/LiveControls.tsx create mode 100644 src/components/chat/input/actions/RecordControls.tsx create mode 100644 src/components/chat/input/actions/SendControls.tsx create mode 100644 src/components/chat/input/actions/UtilityControls.tsx create mode 100644 src/components/chat/input/actions/WebSearchToggle.tsx create mode 100644 src/components/chat/input/area/ChatFilePreviewList.tsx create mode 100644 src/components/chat/input/area/ChatQuoteDisplay.tsx create mode 100644 src/components/chat/input/area/ChatSuggestions.tsx create mode 100644 src/components/chat/input/area/ChatTextArea.tsx create mode 100644 src/components/chat/input/area/SuggestionIcon.tsx create mode 100644 src/components/chat/input/toolbar/AddFileByIdInput.tsx create mode 100644 src/components/chat/input/toolbar/AddUrlInput.tsx create mode 100644 src/components/chat/input/toolbar/ImageSizeSelector.tsx create mode 100644 src/components/chat/input/toolbar/ImagenAspectRatioSelector.tsx create mode 100644 src/components/chat/input/toolbar/InputBar.tsx create mode 100644 src/components/chat/input/toolbar/MediaResolutionSelector.tsx create mode 100644 src/components/chat/input/toolbar/QuadImageToggle.tsx create mode 100644 src/components/chat/input/toolbar/TtsVoiceSelector.tsx create mode 100644 src/components/chat/message-list/MessageListFooter.tsx create mode 100644 src/components/chat/message-list/MessageListPlaceholder.tsx create mode 100644 src/components/chat/message-list/ScrollNavigation.tsx create mode 100644 src/components/chat/message-list/TextSelectionToolbar.tsx create mode 100644 src/components/chat/message-list/WelcomeScreen.tsx create mode 100644 src/components/chat/message-list/hooks/useMessageListScroll.ts create mode 100644 src/components/chat/message-list/text-selection/AudioPlayerView.tsx create mode 100644 src/components/chat/message-list/text-selection/StandardActionsView.tsx create mode 100644 src/components/chat/message-list/text-selection/ToolbarContainer.tsx create mode 100644 src/components/chat/overlays/DragDropOverlay.tsx create mode 100644 src/components/chat/overlays/ModelsErrorDisplay.tsx create mode 100644 src/components/header/Header.tsx create mode 100644 src/components/header/HeaderModelSelector.tsx create mode 100644 src/components/icons/AppLogo.tsx create mode 100644 src/components/icons/CommandIcon.tsx create mode 100644 src/components/icons/CustomIcons.tsx create mode 100644 src/components/icons/GoogleSpinner.tsx create mode 100644 src/components/icons/groups/AttachmentIcons.tsx create mode 100644 src/components/icons/groups/GeneralIcons.tsx create mode 100644 src/components/icons/groups/SettingsIcons.tsx create mode 100644 src/components/icons/groups/ThemeIcons.tsx create mode 100644 src/components/icons/iconUtils.ts create mode 100644 src/components/layout/ChatArea.tsx create mode 100644 src/components/layout/MainContent.tsx create mode 100644 src/components/layout/PiPPlaceholder.tsx create mode 100644 src/components/layout/SidePanel.tsx create mode 100644 src/components/layout/chat-area/ChatAreaProps.ts create mode 100644 src/components/layout/chat-area/useChatArea.ts create mode 100644 src/components/log-viewer/ApiUsageTab.tsx create mode 100644 src/components/log-viewer/ConsoleTab.tsx create mode 100644 src/components/log-viewer/LogRow.tsx create mode 100644 src/components/log-viewer/LogViewer.tsx create mode 100644 src/components/log-viewer/ObfuscatedApiKey.tsx create mode 100644 src/components/log-viewer/TokenUsageTab.tsx create mode 100644 src/components/log-viewer/constants.ts create mode 100644 src/components/message/FileDisplay.tsx create mode 100644 src/components/message/GroundedResponse.tsx create mode 100644 src/components/message/MarkdownRenderer.tsx create mode 100644 src/components/message/Message.tsx create mode 100644 src/components/message/MessageActions.tsx create mode 100644 src/components/message/MessageContent.tsx create mode 100644 src/components/message/PerformanceMetrics.tsx create mode 100644 src/components/message/ThinkingTimer.tsx create mode 100644 src/components/message/blocks/CodeBlock.tsx create mode 100644 src/components/message/blocks/GraphvizBlock.tsx create mode 100644 src/components/message/blocks/MermaidBlock.tsx create mode 100644 src/components/message/blocks/TableBlock.tsx create mode 100644 src/components/message/blocks/ToolResultBlock.tsx create mode 100644 src/components/message/blocks/parts/CodeHeader.tsx create mode 100644 src/components/message/blocks/parts/DiagramWrapper.tsx create mode 100644 src/components/message/buttons/ExportMessageButton.tsx create mode 100644 src/components/message/buttons/MessageCopyButton.tsx create mode 100644 src/components/message/buttons/export/ExportModal.tsx create mode 100644 src/components/message/buttons/export/ExportOptions.tsx create mode 100644 src/components/message/code-block/InlineCode.tsx create mode 100644 src/components/message/code-block/LanguageIcon.tsx create mode 100644 src/components/message/content/MessageFiles.tsx create mode 100644 src/components/message/content/MessageFooter.tsx create mode 100644 src/components/message/content/MessageText.tsx create mode 100644 src/components/message/content/MessageThoughts.tsx create mode 100644 src/components/message/content/thoughts/ThinkingActions.tsx create mode 100644 src/components/message/content/thoughts/ThinkingHeader.tsx create mode 100644 src/components/message/content/thoughts/ThoughtContent.tsx create mode 100644 src/components/message/grounded-response/ContextUrls.tsx create mode 100644 src/components/message/grounded-response/SearchQueries.tsx create mode 100644 src/components/message/grounded-response/SearchSources.tsx create mode 100644 src/components/message/grounded-response/utils.ts create mode 100644 src/components/modals/AppModals.tsx create mode 100644 src/components/modals/AudioRecorder.tsx create mode 100644 src/components/modals/ConfirmationModal.tsx create mode 100644 src/components/modals/CreateTextFileEditor.tsx create mode 100644 src/components/modals/ExportChatModal.tsx create mode 100644 src/components/modals/FileConfigurationModal.tsx create mode 100644 src/components/modals/FilePreviewModal.tsx create mode 100644 src/components/modals/HelpModal.tsx create mode 100644 src/components/modals/HtmlPreviewModal.tsx create mode 100644 src/components/modals/TextEditorModal.tsx create mode 100644 src/components/modals/TokenCountModal.tsx create mode 100644 src/components/modals/create-file/CreateFileBody.tsx create mode 100644 src/components/modals/create-file/CreateFileFooter.tsx create mode 100644 src/components/modals/create-file/CreateFileHeader.tsx create mode 100644 src/components/modals/file-config/FileConfigFooter.tsx create mode 100644 src/components/modals/file-config/FileConfigHeader.tsx create mode 100644 src/components/modals/file-config/ResolutionConfig.tsx create mode 100644 src/components/modals/file-config/VideoConfig.tsx create mode 100644 src/components/modals/html-preview/HtmlPreviewContent.tsx create mode 100644 src/components/modals/html-preview/HtmlPreviewHeader.tsx create mode 100644 src/components/modals/token-count/TokenCountFiles.tsx create mode 100644 src/components/modals/token-count/TokenCountFooter.tsx create mode 100644 src/components/modals/token-count/TokenCountInput.tsx create mode 100644 src/components/recorder/AudioVisualizer.tsx create mode 100644 src/components/recorder/RecorderControls.tsx create mode 100644 src/components/scenarios/PreloadedMessagesModal.tsx create mode 100644 src/components/scenarios/ScenarioEditor.tsx create mode 100644 src/components/scenarios/ScenarioItem.tsx create mode 100644 src/components/scenarios/ScenarioList.tsx create mode 100644 src/components/scenarios/editor/ScenarioEditorHeader.tsx create mode 100644 src/components/scenarios/editor/ScenarioMessageInput.tsx create mode 100644 src/components/scenarios/editor/ScenarioMessageList.tsx create mode 100644 src/components/scenarios/editor/ScenarioSystemPrompt.tsx create mode 100644 src/components/settings/ModelVoiceSettings.tsx create mode 100644 src/components/settings/SettingsContent.tsx create mode 100644 src/components/settings/SettingsModal.tsx create mode 100644 src/components/settings/SettingsSidebar.tsx create mode 100644 src/components/settings/controls/ModelSelector.tsx create mode 100644 src/components/settings/controls/VoiceControl.tsx create mode 100644 src/components/settings/controls/model-selector/ModelListEditor.tsx create mode 100644 src/components/settings/controls/model-selector/ModelListEditorRow.tsx create mode 100644 src/components/settings/controls/model-selector/ModelListView.tsx create mode 100644 src/components/settings/controls/model-selector/ModelSelectorHeader.tsx create mode 100644 src/components/settings/controls/thinking/LevelButton.tsx create mode 100644 src/components/settings/controls/thinking/SparklesIcon.tsx create mode 100644 src/components/settings/controls/thinking/ThinkingBudgetSlider.tsx create mode 100644 src/components/settings/controls/thinking/ThinkingControl.tsx create mode 100644 src/components/settings/controls/thinking/ThinkingLevelSelector.tsx create mode 100644 src/components/settings/controls/thinking/ThinkingModeSelector.tsx create mode 100644 src/components/settings/sections/AboutSection.tsx create mode 100644 src/components/settings/sections/ApiConfigSection.tsx create mode 100644 src/components/settings/sections/AppearanceSection.tsx create mode 100644 src/components/settings/sections/ChatBehaviorSection.tsx create mode 100644 src/components/settings/sections/DataManagementSection.tsx create mode 100644 src/components/settings/sections/SafetySection.tsx create mode 100644 src/components/settings/sections/ShortcutsSection.tsx create mode 100644 src/components/settings/sections/api-config/ApiConfigToggle.tsx create mode 100644 src/components/settings/sections/api-config/ApiConnectionTester.tsx create mode 100644 src/components/settings/sections/api-config/ApiKeyInput.tsx create mode 100644 src/components/settings/sections/api-config/ApiProxySettings.tsx create mode 100644 src/components/settings/sections/appearance/FileStrategyControl.tsx create mode 100644 src/components/settings/sections/appearance/FontSizeControl.tsx create mode 100644 src/components/settings/sections/appearance/InterfaceToggles.tsx create mode 100644 src/components/settings/sections/appearance/ThemeLanguageSelector.tsx create mode 100644 src/components/settings/sections/shortcuts/ShortcutRecorder.tsx create mode 100644 src/components/shared/AudioPlayer.tsx create mode 100644 src/components/shared/CodeEditor.tsx create mode 100644 src/components/shared/ErrorBoundary.tsx create mode 100644 src/components/shared/LoadingDots.tsx create mode 100644 src/components/shared/Modal.tsx create mode 100644 src/components/shared/ModelPicker.tsx create mode 100644 src/components/shared/Select.tsx create mode 100644 src/components/shared/Toggle.tsx create mode 100644 src/components/shared/ToggleItem.tsx create mode 100644 src/components/shared/Tooltip.tsx create mode 100644 src/components/shared/file-preview/FilePreviewHeader.tsx create mode 100644 src/components/shared/file-preview/FloatingToolbar.tsx create mode 100644 src/components/shared/file-preview/ImageViewer.tsx create mode 100644 src/components/shared/file-preview/PdfViewer.tsx create mode 100644 src/components/shared/file-preview/TextFileViewer.tsx create mode 100644 src/components/shared/file-preview/pdf-viewer/PdfMainContent.tsx create mode 100644 src/components/shared/file-preview/pdf-viewer/PdfSidebar.tsx create mode 100644 src/components/shared/file-preview/pdf-viewer/PdfToolbar.tsx create mode 100644 src/components/sidebar/GroupItem.tsx create mode 100644 src/components/sidebar/GroupItemMenu.tsx create mode 100644 src/components/sidebar/HistorySidebar.tsx create mode 100644 src/components/sidebar/SessionItem.tsx create mode 100644 src/components/sidebar/SessionItemMenu.tsx create mode 100644 src/components/sidebar/SidebarActions.tsx create mode 100644 src/components/sidebar/SidebarHeader.tsx create mode 100644 src/constants/appConstants.ts create mode 100644 src/constants/defaultScenarios.ts create mode 100644 src/constants/fileConstants.ts create mode 100644 src/constants/modelConstants.ts create mode 100644 src/constants/promptConstants.ts create mode 100644 src/constants/prompts/canvas.ts create mode 100644 src/constants/prompts/deepSearch.ts create mode 100644 src/constants/scenarios/adventure.ts create mode 100644 src/constants/scenarios/demo.ts create mode 100644 src/constants/scenarios/jailbreak.ts create mode 100644 src/constants/scenarios/utility.ts create mode 100644 src/constants/shortcuts.ts create mode 100644 src/constants/specialPrompts.ts create mode 100644 src/constants/themeConstants.ts create mode 100644 src/contexts/WindowContext.tsx create mode 100644 src/hooks/app/logic/useAppHandlers.ts create mode 100644 src/hooks/app/logic/useAppInitialization.ts create mode 100644 src/hooks/app/logic/useAppSidePanel.ts create mode 100644 src/hooks/app/logic/useAppTitle.ts create mode 100644 src/hooks/app/useAppProps.ts create mode 100644 src/hooks/chat-input/handlers/useFileManagementHandlers.ts create mode 100644 src/hooks/chat-input/handlers/useFileSelectionHandlers.ts create mode 100644 src/hooks/chat-input/handlers/useInputAndPasteHandlers.ts create mode 100644 src/hooks/chat-input/handlers/useKeyboardHandlers.ts create mode 100644 src/hooks/chat-input/handlers/useSubmissionHandlers.ts create mode 100644 src/hooks/chat-input/useChatInputEffects.ts create mode 100644 src/hooks/chat-input/useChatInputHeight.ts create mode 100644 src/hooks/chat-input/useChatInputLocalState.ts create mode 100644 src/hooks/chat-input/useChatInputLogic.ts create mode 100644 src/hooks/chat-input/useChatInputModals.ts create mode 100644 src/hooks/chat-input/useChatInputState.ts create mode 100644 src/hooks/chat-stream/processors.ts create mode 100644 src/hooks/chat-stream/utils.ts create mode 100644 src/hooks/chat/actions/useAudioActions.ts create mode 100644 src/hooks/chat/actions/useChatSessionActions.ts create mode 100644 src/hooks/chat/actions/useMessageUpdates.ts create mode 100644 src/hooks/chat/actions/useModelSelection.ts create mode 100644 src/hooks/chat/history/useGroupActions.ts create mode 100644 src/hooks/chat/history/useHistoryClearer.ts create mode 100644 src/hooks/chat/history/useSessionActions.ts create mode 100644 src/hooks/chat/history/useSessionLoader.ts create mode 100644 src/hooks/chat/messages/useMessageActions.ts create mode 100644 src/hooks/chat/messages/useTextToSpeechHandler.ts create mode 100644 src/hooks/chat/useAutoTitling.ts create mode 100644 src/hooks/chat/useChat.ts create mode 100644 src/hooks/chat/useChatActions.ts create mode 100644 src/hooks/chat/useChatEffects.ts create mode 100644 src/hooks/chat/useChatHistory.ts create mode 100644 src/hooks/chat/useChatScroll.ts create mode 100644 src/hooks/chat/useSuggestions.ts create mode 100644 src/hooks/core/useAppEvents.ts create mode 100644 src/hooks/core/useBackgroundKeepAlive.ts create mode 100644 src/hooks/core/useModels.ts create mode 100644 src/hooks/core/useMultiTabSync.ts create mode 100644 src/hooks/core/usePictureInPicture.ts create mode 100644 src/hooks/core/useRecorder.ts create mode 100644 src/hooks/data-management/useChatSessionExport.ts create mode 100644 src/hooks/data-management/useDataExport.ts create mode 100644 src/hooks/data-management/useDataImport.ts create mode 100644 src/hooks/features/useLocalPythonAgent.ts create mode 100644 src/hooks/features/useScenarioManager.ts create mode 100644 src/hooks/features/useSettingsLogic.ts create mode 100644 src/hooks/features/useTokenCountLogic.ts create mode 100644 src/hooks/file-upload/uploadFileItem.ts create mode 100644 src/hooks/file-upload/useFileIdAdder.ts create mode 100644 src/hooks/file-upload/useFilePreProcessing.ts create mode 100644 src/hooks/file-upload/useFileUploader.ts create mode 100644 src/hooks/file-upload/utils.ts create mode 100644 src/hooks/files/useFileDragDrop.ts create mode 100644 src/hooks/files/useFileHandling.ts create mode 100644 src/hooks/files/useFilePolling.ts create mode 100644 src/hooks/files/useFileUpload.ts create mode 100644 src/hooks/live-api/useLiveAudio.ts create mode 100644 src/hooks/live-api/useLiveConfig.ts create mode 100644 src/hooks/live-api/useLiveConnection.ts create mode 100644 src/hooks/live-api/useLiveFrameCapture.ts create mode 100644 src/hooks/live-api/useLiveMessageProcessing.ts create mode 100644 src/hooks/live-api/useLiveTools.ts create mode 100644 src/hooks/live-api/useLiveVideo.ts create mode 100644 src/hooks/message-sender/standard/useApiInteraction.ts create mode 100644 src/hooks/message-sender/standard/useSessionUpdate.ts create mode 100644 src/hooks/message-sender/types.ts create mode 100644 src/hooks/message-sender/useApiErrorHandler.ts create mode 100644 src/hooks/message-sender/useCanvasGenerator.ts create mode 100644 src/hooks/message-sender/useChatStreamHandler.ts create mode 100644 src/hooks/message-sender/useImageEditSender.ts create mode 100644 src/hooks/message-sender/useStandardChat.ts create mode 100644 src/hooks/message-sender/useTtsImagenSender.ts create mode 100644 src/hooks/text-selection/useSelectionAudio.ts create mode 100644 src/hooks/text-selection/useSelectionDrag.ts create mode 100644 src/hooks/text-selection/useSelectionPosition.ts create mode 100644 src/hooks/ui/useCodeBlock.ts create mode 100644 src/hooks/ui/useFullscreen.ts create mode 100644 src/hooks/ui/useHtmlPreviewModal.ts create mode 100644 src/hooks/ui/useImageNavigation.ts create mode 100644 src/hooks/ui/useMessageStream.ts create mode 100644 src/hooks/ui/usePdfViewer.ts create mode 100644 src/hooks/ui/useSmoothStreaming.ts create mode 100644 src/hooks/useAudioRecorder.ts create mode 100644 src/hooks/useClickOutside.ts create mode 100644 src/hooks/useCopyToClipboard.ts create mode 100644 src/hooks/useCreateFileEditor.ts create mode 100644 src/hooks/useDataManagement.ts create mode 100644 src/hooks/useDevice.ts create mode 100644 src/hooks/useHistorySidebarLogic.ts create mode 100644 src/hooks/useLiveAPI.ts create mode 100644 src/hooks/useMessageExport.ts create mode 100644 src/hooks/useMessageHandler.ts create mode 100644 src/hooks/useMessageListUI.ts create mode 100644 src/hooks/useMessageSender.ts create mode 100644 src/hooks/useModelCapabilities.ts create mode 100644 src/hooks/usePreloadedScenarios.ts create mode 100644 src/hooks/usePyodide.ts create mode 100644 src/hooks/useSlashCommands.ts create mode 100644 src/hooks/useTextAreaInsert.ts create mode 100644 src/hooks/useVoiceInput.ts create mode 100644 src/index.tsx create mode 100644 src/services/api/__tests__/baseApi.test.ts create mode 100644 src/services/api/baseApi.ts create mode 100644 src/services/api/chatApi.ts create mode 100644 src/services/api/fileApi.ts create mode 100644 src/services/api/generation/audioApi.ts create mode 100644 src/services/api/generation/imageApi.ts create mode 100644 src/services/api/generation/textApi.ts create mode 100644 src/services/api/generation/tokenApi.ts create mode 100644 src/services/api/generationApi.ts create mode 100644 src/services/geminiService.ts create mode 100644 src/services/logService.ts create mode 100644 src/services/networkInterceptor.ts create mode 100644 src/services/pyodideService.ts create mode 100644 src/services/streamingStore.ts create mode 100644 src/stores/__tests__/chatStore.test.ts create mode 100644 src/stores/__tests__/settingsStore.test.ts create mode 100644 src/styles/animations.css create mode 100644 src/styles/main.css create mode 100644 src/styles/markdown.css create mode 100644 src/types/api.ts create mode 100644 src/types/chat.ts create mode 100644 src/types/index.ts create mode 100644 src/types/settings.ts create mode 100644 src/types/theme.ts create mode 100644 src/utils/__tests__/modelHelpers.test.ts create mode 100644 src/utils/apiUtils.ts create mode 100644 src/utils/appUtils.ts create mode 100644 src/utils/audio/audioProcessing.ts create mode 100644 src/utils/audio/audioWorklet.ts create mode 100644 src/utils/audioCompression.ts create mode 100644 src/utils/chat/__tests__/builder.test.ts create mode 100644 src/utils/chat/__tests__/parsing.test.ts create mode 100644 src/utils/chat/__tests__/session.test.ts create mode 100644 src/utils/chat/builder.ts create mode 100644 src/utils/chat/ids.ts create mode 100644 src/utils/chat/parsing.ts create mode 100644 src/utils/chat/session.ts create mode 100644 src/utils/chatHelpers.ts create mode 100644 src/utils/clipboardUtils.ts create mode 100644 src/utils/codeUtils.ts create mode 100644 src/utils/dateHelpers.ts create mode 100644 src/utils/db.ts create mode 100644 src/utils/domainUtils.ts create mode 100644 src/utils/export/core.ts create mode 100644 src/utils/export/dom.ts create mode 100644 src/utils/export/files.ts create mode 100644 src/utils/export/image.ts create mode 100644 src/utils/export/templates.ts create mode 100644 src/utils/exportUtils.ts create mode 100644 src/utils/fileHelpers.ts create mode 100644 src/utils/folderImportUtils.ts create mode 100644 src/utils/htmlToMarkdown.ts create mode 100644 src/utils/markdownConfig.ts create mode 100644 src/utils/mediaUtils.ts create mode 100644 src/utils/modelHelpers.ts create mode 100644 src/utils/shortcutUtils.ts create mode 100644 src/utils/translations.ts create mode 100644 src/utils/translations/app.ts create mode 100644 src/utils/translations/chatInput.ts create mode 100644 src/utils/translations/common.ts create mode 100644 src/utils/translations/header.ts create mode 100644 src/utils/translations/history.ts create mode 100644 src/utils/translations/messages.ts create mode 100644 src/utils/translations/scenarios.ts create mode 100644 src/utils/translations/settings/about.ts create mode 100644 src/utils/translations/settings/api.ts create mode 100644 src/utils/translations/settings/appearance.ts create mode 100644 src/utils/translations/settings/data.ts create mode 100644 src/utils/translations/settings/general.ts create mode 100644 src/utils/translations/settings/model.ts create mode 100644 src/utils/translations/settings/safety.ts create mode 100644 src/utils/translations/settings/shortcuts.ts create mode 100644 src/utils/uiUtils.ts diff --git a/README.md b/README.md index 49b75ba6..7b449e13 100644 --- a/README.md +++ b/README.md @@ -289,7 +289,7 @@ All-Model-Chat/ | 类型 | 模型 | | :--- | :--- | -| **Gemini 3.x** | gemini-3-flash-preview, gemini-3-pro-preview, gemini-3.1-flash-lite-preview, gemini-3.1-pro-preview | +| **Gemini 3.x** | gemini-3-flash-preview, gemini-3.1-flash-lite-preview, gemini-3.1-pro-preview | | **Gemini 2.5** | gemini-2.5-pro, gemini-2.5-flash-preview, gemini-2.5-flash-lite-preview, gemini-2.5-flash-native-audio-preview | | **Gemma 4** | gemma-4-31b-it, gemma-4-26b-a4b-it | | **Imagen 4.0** | imagen-4.0-fast-generate, imagen-4.0-generate, imagen-4.0-ultra-generate | diff --git a/index.html b/index.html index 5705dc02..fc47ebf3 100644 --- a/index.html +++ b/index.html @@ -79,6 +79,6 @@
- + \ No newline at end of file diff --git a/src/App.tsx b/src/App.tsx new file mode 100644 index 00000000..4c3294a1 --- /dev/null +++ b/src/App.tsx @@ -0,0 +1,80 @@ + +import React from 'react'; +import { createPortal } from 'react-dom'; +import { useAppLogic } from './hooks/app/useAppLogic'; +import { useAppProps } from './hooks/app/useAppProps'; +import { WindowProvider } from './contexts/WindowContext'; +import { MainContent } from './components/layout/MainContent'; +import { PiPPlaceholder } from './components/layout/PiPPlaceholder'; +import { ErrorBoundary } from './components/shared/ErrorBoundary'; + +const App: React.FC = () => { + return ( + + + + ); +}; + +const AppContent: React.FC = () => { + const logic = useAppLogic(); + const { + currentTheme, + pipState, + sidePanelContent, + handleCloseSidePanel, + uiState, + } = logic; + + const { sidebarProps, chatAreaProps, appModalsProps } = useAppProps(logic); + + return ( +
+ {pipState.isPipActive && pipState.pipContainer && pipState.pipWindow ? ( + <> + {createPortal( + +
+ +
+
, + pipState.pipContainer + )} + + + ) : ( + + + + )} +
+ ); +}; + +export default App; \ No newline at end of file diff --git a/src/components/chat/MessageList.tsx b/src/components/chat/MessageList.tsx new file mode 100644 index 00000000..3d0707d4 --- /dev/null +++ b/src/components/chat/MessageList.tsx @@ -0,0 +1,217 @@ + +import React, { useMemo } from 'react'; +import { Virtuoso } from 'react-virtuoso'; +import { ChatMessage, AppSettings, SideViewContent, VideoMetadata } from '../../types'; +import { Message } from '../message/Message'; +import { translations } from '../../utils/appUtils'; +import { HtmlPreviewModal } from '../modals/HtmlPreviewModal'; +import { FilePreviewModal } from '../modals/FilePreviewModal'; +import { WelcomeScreen } from './message-list/WelcomeScreen'; +import { ScrollNavigation } from './message-list/ScrollNavigation'; +import { FileConfigurationModal } from '../modals/FileConfigurationModal'; +import { MediaResolution } from '../../types/settings'; +import { isGemini3Model } from '../../utils/appUtils'; +import { TextSelectionToolbar } from './message-list/TextSelectionToolbar'; +import { useMessageListUI } from '../../hooks/useMessageListUI'; +import { useMessageListScroll } from './message-list/hooks/useMessageListScroll'; +import { MessageListFooter } from './message-list/MessageListFooter'; +import { useChatStore } from '../../stores/chatStore'; +import { useSettingsStore } from '../../stores/settingsStore'; + +export interface MessageListProps { + messages: ChatMessage[]; + sessionTitle?: string; + scrollContainerRef: React.RefObject; + setScrollContainerRef: (node: HTMLDivElement | null) => void; + onScrollContainerScroll: () => void; + onEditMessage: (messageId: string, mode?: 'update' | 'resend') => void; + onDeleteMessage: (messageId: string) => void; + onRetryMessage: (messageId: string) => void; + onUpdateMessageFile: (messageId: string, fileId: string, updates: { videoMetadata?: VideoMetadata, mediaResolution?: MediaResolution }) => void; + showThoughts: boolean; + themeId: string; + baseFontSize: number; + expandCodeBlocksByDefault: boolean; + isMermaidRenderingEnabled: boolean; + isGraphvizRenderingEnabled: boolean; + onSuggestionClick?: (suggestion: string) => void; + onOrganizeInfoClick?: (suggestion: string) => void; + onFollowUpSuggestionClick?: (suggestion: string) => void; + onTextToSpeech: (messageId: string, text: string) => void; + onGenerateCanvas: (messageId: string, text: string) => void; + onContinueGeneration: (messageId: string) => void; + ttsMessageId: string | null; + onQuickTTS: (text: string) => Promise; + t: (key: keyof typeof translations, fallback?: string) => string; + language: 'en' | 'zh'; + chatInputHeight: number; + appSettings: AppSettings; + currentModelId: string; + onOpenSidePanel: (content: SideViewContent) => void; + onQuote: (text: string) => void; + onInsert?: (text: string) => void; + activeSessionId: string | null; +} + +export const MessageList: React.FC = ({ + messages, sessionTitle, setScrollContainerRef, onScrollContainerScroll, + onEditMessage, onDeleteMessage, onRetryMessage, onUpdateMessageFile, showThoughts, baseFontSize, + expandCodeBlocksByDefault, isMermaidRenderingEnabled, isGraphvizRenderingEnabled, onSuggestionClick, onOrganizeInfoClick, onFollowUpSuggestionClick, onTextToSpeech, onGenerateCanvas, onContinueGeneration, ttsMessageId: propsTtsMessageId, onQuickTTS, t, themeId: propsThemeId, + chatInputHeight, appSettings: propsAppSettings, currentModelId, onOpenSidePanel, onQuote, onInsert, activeSessionId +}) => { + // Read directly from stores + const storeTtsMessageId = useChatStore(s => s.ttsMessageId); + const storeAppSettings = useSettingsStore(s => s.appSettings); + const storeThemeId = useSettingsStore(s => s.currentTheme.id); + const storeScrollContainerRef = useChatStore(s => s.scrollContainerRef); + + // Use store values with fallback to props + const ttsMessageId = storeTtsMessageId ?? propsTtsMessageId; + const appSettings = storeAppSettings ?? propsAppSettings; + const themeId = storeThemeId ?? propsThemeId; + + // UI Logic (Modals, Previews, Configuration) + const { + previewFile, + isHtmlPreviewModalOpen, + htmlToPreview, + initialTrueFullscreenRequest, + configuringFile, + setConfiguringFile, + handleFileClick, + closeFilePreviewModal, + allImages, + currentImageIndex, + handlePrevImage, + handleNextImage, + handleOpenHtmlPreview, + handleCloseHtmlPreview, + handleConfigureFile, + handleSaveFileConfig, + } = useMessageListUI({ messages, onUpdateMessageFile }); + + // Scroll Logic + const { + virtuosoRef, + setInternalScrollerRef, + setAtBottom, + onRangeChanged, + scrollToPrevTurn, + scrollToNextTurn, + showScrollDown, + showScrollUp, + scrollerRef + } = useMessageListScroll({ messages, setScrollContainerRef, activeSessionId }); + + // Determine if current model is Gemini 3 to enable per-part resolution + const isGemini3 = useMemo(() => isGemini3Model(currentModelId), [currentModelId]); + + return ( + <> +
+ {messages.length === 0 ? ( + + ) : ( + + }} + itemContent={(index, msg) => ( +
+ 0 ? messages[index - 1] : undefined} + messageIndex={index} + onEditMessage={onEditMessage} + onDeleteMessage={onDeleteMessage} + onRetryMessage={onRetryMessage} + onImageClick={handleFileClick} + onOpenHtmlPreview={handleOpenHtmlPreview} + showThoughts={showThoughts} + themeId={themeId} + baseFontSize={baseFontSize} + expandCodeBlocksByDefault={expandCodeBlocksByDefault} + isMermaidRenderingEnabled={isMermaidRenderingEnabled} + isGraphvizRenderingEnabled={isGraphvizRenderingEnabled} + onTextToSpeech={onTextToSpeech} + onGenerateCanvas={onGenerateCanvas} + onContinueGeneration={onContinueGeneration} + ttsMessageId={ttsMessageId} + onSuggestionClick={onFollowUpSuggestionClick} + t={t} + appSettings={appSettings} + onOpenSidePanel={onOpenSidePanel} + onConfigureFile={msg.role === 'user' ? handleConfigureFile : undefined} + isGemini3={isGemini3} + /> +
+ )} + /> + )} + + {/* Floating Toolbars & Navigation */} + + + +
+ + {/* Modals */} + 0} + hasNext={currentImageIndex !== -1 && currentImageIndex < allImages.length - 1} + /> + + {isHtmlPreviewModalOpen && htmlToPreview !== null && ( + + )} + + setConfiguringFile(null)} + file={configuringFile?.file || null} + onSave={handleSaveFileConfig} + t={t} + isGemini3={isGemini3} + /> + + ); +}; diff --git a/src/components/chat/input/AttachmentMenu.tsx b/src/components/chat/input/AttachmentMenu.tsx new file mode 100644 index 00000000..7ae3ffbf --- /dev/null +++ b/src/components/chat/input/AttachmentMenu.tsx @@ -0,0 +1,149 @@ + +import React, { useState, useRef, useLayoutEffect, useEffect } from 'react'; +import { createPortal } from 'react-dom'; +import { Plus, FolderUp } from 'lucide-react'; +import { translations } from '../../../utils/appUtils'; +import { + IconUpload, + IconGallery, + IconCamera, + IconScreenshot, + IconMicrophone, + IconLink, + IconFileEdit, + IconZip +} from '../../icons/CustomIcons'; +import { useWindowContext } from '../../../contexts/WindowContext'; +import { useClickOutside } from '../../../hooks/useClickOutside'; +import { CHAT_INPUT_BUTTON_CLASS } from '../../../constants/appConstants'; + +export type AttachmentAction = 'upload' | 'gallery' | 'camera' | 'recorder' | 'id' | 'url' | 'text' | 'screenshot' | 'folder' | 'zip'; + +interface AttachmentMenuProps { + onAction: (action: AttachmentAction) => void; + disabled: boolean; + t: (key: keyof typeof translations) => string; +} + +const attachIconSize = 20; +const menuIconSize = 18; // Consistent icon size for menu items + +export const AttachmentMenu: React.FC = ({ onAction, disabled, t }) => { + const [isOpen, setIsOpen] = useState(false); + const [menuPosition, setMenuPosition] = useState({}); + const containerRef = useRef(null); + const buttonRef = useRef(null); + const menuRef = useRef(null); + + const { window: targetWindow } = useWindowContext(); + + useClickOutside(containerRef, () => setIsOpen(false), isOpen); + + // Prevent click-outside logic from firing when interacting with the portaled menu + useEffect(() => { + if (!isOpen || !menuRef.current) return; + + const stopProp = (e: Event) => e.stopPropagation(); + const menuEl = menuRef.current; + + // Stop bubbling to document so useClickOutside doesn't see it + menuEl.addEventListener('mousedown', stopProp); + menuEl.addEventListener('touchstart', stopProp); + + return () => { + menuEl.removeEventListener('mousedown', stopProp); + menuEl.removeEventListener('touchstart', stopProp); + }; + }, [isOpen]); + + // Dynamic fixed positioning + useLayoutEffect(() => { + if (isOpen && buttonRef.current && targetWindow) { + const buttonRect = buttonRef.current.getBoundingClientRect(); + const viewportWidth = targetWindow.innerWidth; + const viewportHeight = targetWindow.innerHeight; + + const MENU_WIDTH = 240; + const BUTTON_MARGIN = 10; + const GAP = 8; + + const newStyle: React.CSSProperties = { + position: 'fixed', + zIndex: 9999, // Ensure it sits on top of everything including toolbar + }; + + // Horizontal Alignment + if (buttonRect.left + MENU_WIDTH > viewportWidth - BUTTON_MARGIN) { + // Align right edge of menu with right edge of button + newStyle.left = buttonRect.right - MENU_WIDTH; + newStyle.transformOrigin = 'bottom right'; + } else { + // Align left + newStyle.left = buttonRect.left; + newStyle.transformOrigin = 'bottom left'; + } + + // Vertical Alignment (Anchored to bottom of viewport relative to button top) + newStyle.bottom = viewportHeight - buttonRect.top + GAP; + + // Height Constraint + const availableHeight = buttonRect.top - BUTTON_MARGIN; + newStyle.maxHeight = `${Math.max(150, availableHeight)}px`; + newStyle.overflowY = 'auto'; // Allow scrolling if constrained + + setMenuPosition(newStyle); + } + }, [isOpen, targetWindow]); + + const handleAction = (action: AttachmentAction) => { + setIsOpen(false); + onAction(action); + }; + + const menuItems: { labelKey: keyof typeof translations, icon: React.ReactNode, action: AttachmentAction }[] = [ + { labelKey: 'attachMenu_upload', icon: , action: 'upload' }, + { labelKey: 'attachMenu_importFolder', icon: , action: 'folder' }, + { labelKey: 'attachMenu_importZip', icon: , action: 'zip' }, + { labelKey: 'attachMenu_gallery', icon: , action: 'gallery' }, + { labelKey: 'attachMenu_takePhoto', icon: , action: 'camera' }, + { labelKey: 'attachMenu_screenshot', icon: , action: 'screenshot' }, + { labelKey: 'attachMenu_recordAudio', icon: , action: 'recorder' }, + { labelKey: 'attachMenu_addById', icon: , action: 'id' }, + { labelKey: 'attachMenu_createText', icon: , action: 'text' } + ]; + + return ( +
+ + + {isOpen && targetWindow && createPortal( +
+ {menuItems.map(item => ( + + ))} +
, + targetWindow.document.body + )} +
+ ); +}; diff --git a/src/components/chat/input/ChatInput.tsx b/src/components/chat/input/ChatInput.tsx new file mode 100644 index 00000000..04356cd1 --- /dev/null +++ b/src/components/chat/input/ChatInput.tsx @@ -0,0 +1,306 @@ +import React from 'react'; +import { createPortal } from 'react-dom'; +import { ChatInputProps, ChatInputToolbarProps, ChatInputActionsProps } from '../../../types'; +import { useChatInputLogic } from '../../../hooks/chat-input/useChatInputLogic'; +import { ChatInputModals } from './ChatInputModals'; +import { ChatInputFileModals } from './ChatInputFileModals'; +import { ChatInputArea, ChatInputAreaProps } from './ChatInputArea'; +import { INITIAL_TEXTAREA_HEIGHT_PX } from '../../../hooks/chat-input/useChatInputState'; +import { useChatStore } from '../../../stores/chatStore'; +import { useSettingsStore } from '../../../stores/settingsStore'; + +export type { ChatInputProps }; + +export const ChatInput: React.FC = (props) => { + // Read state directly from stores instead of through props + const storeSelectedFiles = useChatStore(s => s.selectedFiles); + const storeSetSelectedFiles = useChatStore(s => s.setSelectedFiles); + const storeEditingMessageId = useChatStore(s => s.editingMessageId); + const storeSetEditingMessageId = useChatStore(s => s.setEditingMessageId); + const storeEditMode = useChatStore(s => s.editMode); + const storeCommandedInput = useChatStore(s => s.commandedInput); + const storeSetCommandedInput = useChatStore(s => s.setCommandedInput); + const storeAspectRatio = useChatStore(s => s.aspectRatio); + const storeSetAspectRatio = useChatStore(s => s.setAspectRatio); + const storeImageSize = useChatStore(s => s.imageSize); + const storeSetImageSize = useChatStore(s => s.setImageSize); + const storeIsAppProcessingFile = useChatStore(s => s.isAppProcessingFile); + const storeAppFileError = useChatStore(s => s.appFileError); + const storeSetAppFileError = useChatStore(s => s.setAppFileError); + const storeAppSettings = useSettingsStore(s => s.appSettings); + + // Use store values, falling back to props for backward compatibility + const selectedFiles = storeSelectedFiles ?? props.selectedFiles; + const setSelectedFiles = storeSetSelectedFiles ?? props.setSelectedFiles; + const editingMessageId = storeEditingMessageId ?? props.editingMessageId; + const setEditingMessageId = storeSetEditingMessageId ?? props.setEditingMessageId; + const editMode = storeEditMode ?? props.editMode; + const commandedInput = storeCommandedInput ?? props.commandedInput; + const setCommandedInput = storeSetCommandedInput ?? props.setCommandedInput; + const aspectRatio = storeAspectRatio ?? props.aspectRatio; + const setAspectRatio = storeSetAspectRatio ?? props.setAspectRatio; + const imageSize = storeImageSize ?? props.imageSize; + const setImageSize = storeSetImageSize ?? props.setImageSize; + const isAppProcessingFile = storeIsAppProcessingFile ?? props.isProcessingFile; + const appFileError = storeAppFileError ?? props.fileError; + const setAppFileError = storeSetAppFileError ?? props.setAppFileError; + const appSettings = storeAppSettings ?? props.appSettings; + + // Build effective props with store values + const effectiveProps = { + ...props, + selectedFiles, + setSelectedFiles, + editingMessageId, + setEditingMessageId, + editMode, + commandedInput, + setCommandedInput, + aspectRatio, + setAspectRatio, + imageSize, + setImageSize, + isProcessingFile: isAppProcessingFile, + fileError: appFileError, + setAppFileError, + appSettings, + }; + + // 1. 获取所有核心逻辑和状态 + const { + inputState, + capabilities, + liveAPI, + modalsState, + localFileState, + voiceState, + slashCommandState, + handlers, + targetDocument, + canSend, + isAnyModalOpen, + handleSmartSendMessage + } = useChatInputLogic(effectiveProps); + + // 2. 组装 Toolbar 参数 + const toolbarProps: ChatInputToolbarProps = { + isImagenModel: capabilities.isImagenModel || false, + isGemini3ImageModel: capabilities.isGemini3ImageModel, + isTtsModel: capabilities.isTtsModel, + ttsVoice: effectiveProps.currentChatSettings.ttsVoice, + setTtsVoice: (voice) => effectiveProps.setCurrentChatSettings(prev => ({ ...prev, ttsVoice: voice })), + aspectRatio, + setAspectRatio, + imageSize, + setImageSize, + fileError: appFileError, + showAddByIdInput: modalsState.showAddByIdInput, + fileIdInput: inputState.fileIdInput, + setFileIdInput: inputState.setFileIdInput, + onAddFileByIdSubmit: handlers.handleAddFileByIdSubmit, + onCancelAddById: () => { + modalsState.setShowAddByIdInput(false); + inputState.setFileIdInput(''); + inputState.textareaRef.current?.focus(); + }, + isAddingById: inputState.isAddingById, + showAddByUrlInput: modalsState.showAddByUrlInput, + urlInput: inputState.urlInput, + setUrlInput: inputState.setUrlInput, + onAddUrlSubmit: () => handlers.handleAddUrl(inputState.urlInput), + onCancelAddUrl: () => { + modalsState.setShowAddByUrlInput(false); + inputState.setUrlInput(''); + inputState.textareaRef.current?.focus(); + }, + isAddingByUrl: inputState.isAddingByUrl, + isLoading: effectiveProps.isLoading, + t: effectiveProps.t, + generateQuadImages: effectiveProps.generateQuadImages, + onToggleQuadImages: effectiveProps.onToggleQuadImages, + supportedAspectRatios: capabilities.supportedAspectRatios, + supportedImageSizes: capabilities.supportedImageSizes, + isNativeAudioModel: capabilities.isNativeAudioModel || false, + mediaResolution: effectiveProps.currentChatSettings.mediaResolution, + setMediaResolution: (res) => effectiveProps.setCurrentChatSettings(prev => ({ ...prev, mediaResolution: res })), + ttsContext: inputState.ttsContext, + onEditTtsContext: () => modalsState.setShowTtsContextEditor(true) + }; + + // 3. 组装操作按钮参数 + const actionsProps: ChatInputActionsProps = { + onAttachmentAction: modalsState.handleAttachmentAction, + disabled: inputState.isAddingById || isAnyModalOpen || inputState.isWaitingForUpload || localFileState.isConverting, + isGoogleSearchEnabled: effectiveProps.isGoogleSearchEnabled, + onToggleGoogleSearch: () => handlers.handleToggleToolAndFocus(effectiveProps.onToggleGoogleSearch), + isCodeExecutionEnabled: effectiveProps.isCodeExecutionEnabled, + onToggleCodeExecution: () => handlers.handleToggleToolAndFocus(effectiveProps.onToggleCodeExecution), + isLocalPythonEnabled: effectiveProps.isLocalPythonEnabled, + onToggleLocalPython: effectiveProps.onToggleLocalPython ? () => handlers.handleToggleToolAndFocus(effectiveProps.onToggleLocalPython!) : undefined, + isUrlContextEnabled: effectiveProps.isUrlContextEnabled, + onToggleUrlContext: () => handlers.handleToggleToolAndFocus(effectiveProps.onToggleUrlContext), + isDeepSearchEnabled: effectiveProps.isDeepSearchEnabled, + onToggleDeepSearch: () => handlers.handleToggleToolAndFocus(effectiveProps.onToggleDeepSearch), + onAddYouTubeVideo: () => { + modalsState.setShowAddByUrlInput(true); + inputState.textareaRef.current?.focus(); + }, + onCountTokens: () => localFileState.setShowTokenModal(true), + onRecordButtonClick: voiceState.handleVoiceInputClick, + onCancelRecording: voiceState.handleCancelRecording, + isRecording: voiceState.isRecording, + isMicInitializing: voiceState.isMicInitializing, + isTranscribing: voiceState.isTranscribing, + isLoading: effectiveProps.isLoading, + onStopGenerating: effectiveProps.onStopGenerating, + isEditing: effectiveProps.isEditing, + onCancelEdit: effectiveProps.onCancelEdit, + canSend: canSend, + isWaitingForUpload: inputState.isWaitingForUpload, + t: effectiveProps.t as any, + onTranslate: handlers.handleTranslate, + isTranslating: inputState.isTranslating, + inputText: inputState.inputText, + onToggleFullscreen: inputState.handleToggleFullscreen, + isFullscreen: inputState.isFullscreen, + editMode, + isNativeAudioModel: capabilities.isNativeAudioModel || false, + onStartLiveSession: liveAPI.connect, + isLiveConnected: liveAPI.isConnected, + isLiveMuted: liveAPI.isMuted, + onToggleLiveMute: liveAPI.toggleMute, + onFastSendMessage: handlers.handleFastSubmit + }; + + // 4. 组装输入区域核心参数 + const areaProps: ChatInputAreaProps = { + toolbarProps, + actionsProps, + slashCommandProps: { + isOpen: slashCommandState.slashCommandState.isOpen, + commands: slashCommandState.slashCommandState.filteredCommands, + onSelect: handlers.handleCommandSelect, + selectedIndex: slashCommandState.slashCommandState.selectedIndex, + }, + fileDisplayProps: { + selectedFiles, + onRemove: handlers.removeSelectedFile, + onCancelUpload: effectiveProps.onCancelUpload, + onConfigure: localFileState.handleConfigureFile, + onPreview: localFileState.handlePreviewFile, + isGemini3: capabilities.isGemini3, + }, + inputProps: { + value: inputState.inputText, + onChange: handlers.handleInputChange, + onKeyDown: handlers.handleKeyDown, + onPaste: handlers.handlePaste, + textareaRef: inputState.textareaRef, + placeholder: effectiveProps.t('chatInputPlaceholder'), + disabled: isAnyModalOpen || voiceState.isTranscribing || inputState.isWaitingForUpload || voiceState.isRecording || localFileState.isConverting, + onCompositionStart: handlers.onCompositionStart, + onCompositionEnd: handlers.onCompositionEnd, + }, + quoteProps: { + quotes: inputState.quotes, + onRemoveQuote: (index: number) => inputState.setQuotes(prev => prev.filter((_, i) => i !== index)) + }, + layoutProps: { + isFullscreen: inputState.isFullscreen, + isPipActive: effectiveProps.isPipActive, + isAnimatingSend: inputState.isAnimatingSend, + isMobile: inputState.isMobile, + initialTextareaHeight: INITIAL_TEXTAREA_HEIGHT_PX, + isConverting: localFileState.isConverting, + }, + fileInputRefs: { + fileInputRef: modalsState.fileInputRef, + imageInputRef: modalsState.imageInputRef, + folderInputRef: modalsState.folderInputRef, + zipInputRef: modalsState.zipInputRef, + cameraInputRef: modalsState.cameraInputRef, + handleFileChange: handlers.handleFileChange, + handleFolderChange: handlers.handleFolderChange, + handleZipChange: handlers.handleZipChange, + }, + formProps: { + onSubmit: handlers.handleSubmit, + }, + suggestionsProps: (effectiveProps.showEmptyStateSuggestions && !capabilities.isImagenModel && !capabilities.isTtsModel && !capabilities.isNativeAudioModel && effectiveProps.onSuggestionClick && effectiveProps.onOrganizeInfoClick) ? { + show: effectiveProps.showEmptyStateSuggestions, + onSuggestionClick: effectiveProps.onSuggestionClick, + onOrganizeInfoClick: effectiveProps.onOrganizeInfoClick, + onToggleBBox: effectiveProps.onToggleBBox, + isBBoxModeActive: effectiveProps.isBBoxModeActive, + onToggleGuide: effectiveProps.onToggleGuide, + isGuideModeActive: effectiveProps.isGuideModeActive + } : undefined, + liveStatusProps: { + isConnected: liveAPI.isConnected, + isSpeaking: liveAPI.isSpeaking, + volume: liveAPI.volume, + error: liveAPI.error, + onDisconnect: liveAPI.disconnect, + }, + t: effectiveProps.t as any, + themeId: effectiveProps.themeId + }; + + // 5. 渲染 UI + const chatInputContent = ; + + return ( + <> + { modalsState.setShowRecorder(false); inputState.textareaRef.current?.focus(); }} + showCreateTextFileEditor={modalsState.showCreateTextFileEditor} + onConfirmCreateTextFile={localFileState.handleSaveTextFile} + onCreateTextFileCancel={() => { modalsState.setShowCreateTextFileEditor(false); modalsState.setEditingFile(null); inputState.textareaRef.current?.focus(); }} + isHelpModalOpen={modalsState.isHelpModalOpen} + onHelpModalClose={() => modalsState.setIsHelpModalOpen(false)} + allCommandsForHelp={slashCommandState.allCommandsForHelp} + isProcessingFile={isAppProcessingFile} + isLoading={effectiveProps.isLoading} + t={effectiveProps.t} + initialContent={modalsState.editingFile?.textContent || ''} + initialFilename={modalsState.editingFile?.name || ''} + isSystemAudioRecordingEnabled={appSettings.isSystemAudioRecordingEnabled} + themeId={effectiveProps.themeId} + isPasteRichTextAsMarkdownEnabled={appSettings.isPasteRichTextAsMarkdownEnabled ?? true} + showTtsContextEditor={modalsState.showTtsContextEditor} + onCloseTtsContextEditor={() => modalsState.setShowTtsContextEditor(false)} + ttsContext={inputState.ttsContext} + setTtsContext={inputState.setTtsContext} + /> + + + + {inputState.isFullscreen ? createPortal(chatInputContent, targetDocument.body) : chatInputContent} + + ); +}; diff --git a/src/components/chat/input/ChatInputActions.tsx b/src/components/chat/input/ChatInputActions.tsx new file mode 100644 index 00000000..7676550c --- /dev/null +++ b/src/components/chat/input/ChatInputActions.tsx @@ -0,0 +1,145 @@ + +import React from 'react'; +import { AttachmentMenu } from './AttachmentMenu'; +import { ToolsMenu } from './ToolsMenu'; +import { ChatInputActionsProps } from '../../../types'; +import { WebSearchToggle } from './actions/WebSearchToggle'; +import { LiveControls } from './actions/LiveControls'; +import { RecordControls } from './actions/RecordControls'; +import { UtilityControls } from './actions/UtilityControls'; +import { SendControls } from './actions/SendControls'; + +export interface ExtendedChatInputActionsProps extends ChatInputActionsProps { + editMode?: 'update' | 'resend'; + isNativeAudioModel?: boolean; + onStartLiveSession?: () => void; + isLiveConnected?: boolean; + isLiveMuted?: boolean; + onToggleLiveMute?: () => void; +} + +export const ChatInputActions: React.FC = ({ + onAttachmentAction, + disabled, + isGoogleSearchEnabled, + onToggleGoogleSearch, + isCodeExecutionEnabled, + onToggleCodeExecution, + isLocalPythonEnabled, + onToggleLocalPython, + isUrlContextEnabled, + onToggleUrlContext, + isDeepSearchEnabled, + onToggleDeepSearch, + onAddYouTubeVideo, + onCountTokens, + onRecordButtonClick, + isRecording, + isMicInitializing, + isTranscribing, + isLoading, + onStopGenerating, + isEditing, + onCancelEdit, + canSend, + isWaitingForUpload, + t, + onCancelRecording, + onTranslate, + isTranslating, + inputText, + onToggleFullscreen, + isFullscreen, + editMode, + isNativeAudioModel, + onStartLiveSession, + isLiveConnected, + isLiveMuted, + onToggleLiveMute, + onFastSendMessage, +}) => { + return ( +
+
+ + + {isNativeAudioModel && ( + + )} + + +
+ +
+ {!isLiveConnected && !isNativeAudioModel && ( + + )} + + {!isNativeAudioModel && ( + + )} + + {isNativeAudioModel && onStartLiveSession && ( + + )} + + +
+
+ ); +}; diff --git a/src/components/chat/input/ChatInputArea.tsx b/src/components/chat/input/ChatInputArea.tsx new file mode 100644 index 00000000..b4c9c3a3 --- /dev/null +++ b/src/components/chat/input/ChatInputArea.tsx @@ -0,0 +1,212 @@ + +import React from 'react'; +import { ChatInputToolbar } from './ChatInputToolbar'; +import { ChatInputActions } from './ChatInputActions'; +import { SlashCommandMenu, Command } from './SlashCommandMenu'; +import { UploadedFile, ChatInputToolbarProps, ChatInputActionsProps } from '../../../types'; +import { ALL_SUPPORTED_MIME_TYPES, SUPPORTED_IMAGE_MIME_TYPES } from '../../../constants/fileConstants'; +import { translations } from '../../../utils/appUtils'; +import { ChatSuggestions } from './area/ChatSuggestions'; +import { ChatQuoteDisplay } from './area/ChatQuoteDisplay'; +import { ChatFilePreviewList } from './area/ChatFilePreviewList'; +import { ChatTextArea } from './area/ChatTextArea'; +import { LiveStatusBanner } from './LiveStatusBanner'; + +export interface ChatInputAreaProps { + toolbarProps: ChatInputToolbarProps; + actionsProps: ChatInputActionsProps; + slashCommandProps: { + isOpen: boolean; + commands: Command[]; + onSelect: (command: Command) => void; + selectedIndex: number; + }; + fileDisplayProps: { + selectedFiles: UploadedFile[]; + onRemove: (id: string) => void; + onCancelUpload: (id: string) => void; + onConfigure: (file: UploadedFile) => void; + onPreview: (file: UploadedFile) => void; + isGemini3?: boolean; + }; + inputProps: { + value: string; + onChange: (e: React.ChangeEvent) => void; + onKeyDown: (e: React.KeyboardEvent) => void; + onPaste: (e: React.ClipboardEvent) => void; + textareaRef: React.RefObject; + placeholder: string; + disabled: boolean; + onCompositionStart: () => void; + onCompositionEnd: () => void; + onFocus?: () => void; + }; + quoteProps?: { + quotes: string[]; + onRemoveQuote: (index: number) => void; + }; + layoutProps: { + isFullscreen: boolean; + isPipActive?: boolean; + isAnimatingSend: boolean; + isMobile: boolean; + initialTextareaHeight: number; + isConverting: boolean; + }; + fileInputRefs: { + fileInputRef: React.RefObject; + imageInputRef: React.RefObject; + folderInputRef: React.RefObject; + zipInputRef: React.RefObject; + cameraInputRef: React.RefObject; + handleFileChange: (e: React.ChangeEvent) => void; + handleFolderChange: (e: React.ChangeEvent) => void; + handleZipChange?: (e: React.ChangeEvent) => void; + }; + formProps: { + onSubmit: (e: React.FormEvent) => void; + }; + suggestionsProps?: { + show: boolean; + onSuggestionClick: (suggestion: string) => void; + onOrganizeInfoClick: (suggestion: string) => void; + onToggleBBox?: () => void; + isBBoxModeActive?: boolean; + onToggleGuide?: () => void; + isGuideModeActive?: boolean; + }; + liveStatusProps?: { + isConnected: boolean; + isSpeaking: boolean; + volume: number; + onDisconnect: () => void; + error: string | null; + }; + t: (key: keyof typeof translations) => string; + themeId: string; +} + +export const ChatInputArea: React.FC = ({ + toolbarProps, + actionsProps, + slashCommandProps, + fileDisplayProps, + inputProps, + quoteProps, + layoutProps, + fileInputRefs, + formProps, + suggestionsProps, + liveStatusProps, + t, + themeId, +}) => { + const { isFullscreen, isPipActive, isAnimatingSend, isMobile, initialTextareaHeight, isConverting } = layoutProps; + const { isRecording } = actionsProps; + + const isUIBlocked = inputProps.disabled && !isAnimatingSend && !isRecording; + + const wrapperClass = isFullscreen + ? "fixed inset-0 z-[2000] bg-[var(--theme-bg-secondary)] text-[var(--theme-text-primary)] p-4 sm:p-6 flex flex-col fullscreen-enter-animation" + : `bg-transparent ${isUIBlocked ? 'opacity-30 pointer-events-none' : ''}`; + + const innerContainerClass = isFullscreen + ? "w-full max-w-6xl mx-auto flex flex-col h-full" + : `mx-auto w-full ${!isPipActive ? 'max-w-4xl' : ''} px-2 sm:px-3 pt-0 pb-[calc(env(safe-area-inset-bottom,0px)+0.5rem)]`; + + const formClass = isFullscreen + ? "flex-grow flex flex-col relative min-h-0" + : `relative ${isAnimatingSend ? 'form-send-animate' : ''}`; + + const inputContainerClass = isFullscreen + ? "flex flex-col gap-2 rounded-none sm:rounded-[26px] border-0 sm:border border-[var(--theme-border-secondary)] bg-[var(--theme-bg-input)] px-4 py-4 shadow-none h-full transition-all duration-200 relative" + : "flex flex-col gap-2 rounded-[26px] border border-[var(--theme-border-secondary)] bg-[var(--theme-bg-input)] p-3 sm:p-4 shadow-lg transition-all duration-300 focus-within:border-[var(--theme-border-focus)] relative"; + + return ( +
+
+ {suggestionsProps && !isFullscreen && ( + + )} +
+ +
+ {/* Wrap toolbar in z-indexed container to ensure dropdowns render above status banner */} +
+ +
+ + {liveStatusProps && ( + + )} + +
+ +
+ + + {quoteProps && ( + + )} + + + +
+ + + {/* Hidden inputs */} + + + + + +
+
+ +
+
+ ); +}; diff --git a/src/components/chat/input/ChatInputFileModals.tsx b/src/components/chat/input/ChatInputFileModals.tsx new file mode 100644 index 00000000..08759de7 --- /dev/null +++ b/src/components/chat/input/ChatInputFileModals.tsx @@ -0,0 +1,88 @@ + +import React from 'react'; +import { UploadedFile, AppSettings, ModelOption } from '../../../types'; +import { FileConfigurationModal } from '../../modals/FileConfigurationModal'; +import { TokenCountModal } from '../../modals/TokenCountModal'; +import { FilePreviewModal } from '../../modals/FilePreviewModal'; +import { VideoMetadata } from '../../../types'; +import { MediaResolution } from '../../../types/settings'; + +interface ChatInputFileModalsProps { + configuringFile: UploadedFile | null; + setConfiguringFile: (file: UploadedFile | null) => void; + showTokenModal: boolean; + setShowTokenModal: (show: boolean) => void; + previewFile: UploadedFile | null; + setPreviewFile: (file: UploadedFile | null) => void; + inputText: string; + selectedFiles: UploadedFile[]; + appSettings: AppSettings; + availableModels: ModelOption[]; + currentModelId: string; + t: (key: string) => string; + isGemini3: boolean; + isPreviewEditable?: boolean; + onSaveTextFile?: (fileId: string, content: string, newName: string) => void; + handlers: { + handleSaveFileConfig: (fileId: string, updates: { videoMetadata?: VideoMetadata, mediaResolution?: MediaResolution }) => void; + handlePrevImage: () => void; + handleNextImage: () => void; + currentImageIndex: number; + inputImages: UploadedFile[]; + }; +} + +export const ChatInputFileModals: React.FC = ({ + configuringFile, + setConfiguringFile, + showTokenModal, + setShowTokenModal, + previewFile, + setPreviewFile, + inputText, + selectedFiles, + appSettings, + availableModels, + currentModelId, + t, + isGemini3, + isPreviewEditable, + onSaveTextFile, + handlers +}) => { + return ( + <> + setConfiguringFile(null)} + file={configuringFile} + onSave={handlers.handleSaveFileConfig} + t={t} + isGemini3={isGemini3} + /> + + setShowTokenModal(false)} + initialText={inputText} + initialFiles={selectedFiles} + appSettings={appSettings} + availableModels={availableModels} + currentModelId={currentModelId} + t={t} + /> + + setPreviewFile(null)} + t={t as any} + onPrev={handlers.handlePrevImage} + onNext={handlers.handleNextImage} + hasPrev={handlers.currentImageIndex > 0} + hasNext={handlers.currentImageIndex !== -1 && handlers.currentImageIndex < handlers.inputImages.length - 1} + onSaveText={onSaveTextFile} + initialEditMode={isPreviewEditable} + /> + + ); +}; diff --git a/src/components/chat/input/ChatInputModals.tsx b/src/components/chat/input/ChatInputModals.tsx new file mode 100644 index 00000000..06439e7c --- /dev/null +++ b/src/components/chat/input/ChatInputModals.tsx @@ -0,0 +1,107 @@ + + + +import React from 'react'; +import { AudioRecorder } from '../../modals/AudioRecorder'; +import { CreateTextFileEditor } from '../../modals/CreateTextFileEditor'; +import { HelpModal } from '../../modals/HelpModal'; +import { TextEditorModal } from '../../modals/TextEditorModal'; +import { translations } from '../../../utils/appUtils'; +import { CommandInfo, UploadedFile } from '../../../types'; + +export interface ChatInputModalsProps { + showRecorder: boolean; + onAudioRecord: (file: File) => Promise; + onRecorderCancel: () => void; + showCreateTextFileEditor: boolean; + onConfirmCreateTextFile: (content: string, filename: string) => Promise; + onCreateTextFileCancel: () => void; + isHelpModalOpen: boolean; + onHelpModalClose: () => void; + allCommandsForHelp: CommandInfo[]; + isProcessingFile: boolean; + isLoading: boolean; + t: (key: keyof typeof translations) => string; + initialContent?: string; + initialFilename?: string; + editingFile?: UploadedFile | null; + isSystemAudioRecordingEnabled?: boolean; + themeId: string; + isPasteRichTextAsMarkdownEnabled?: boolean; + showTtsContextEditor?: boolean; + onCloseTtsContextEditor?: () => void; + ttsContext?: string; + setTtsContext?: (val: string) => void; +} + +const DEFAULT_TTS_CONTEXT_TEMPLATE = `# AUDIO PROFILE: [Name] +## THE SCENE: [Description] +### DIRECTOR'S NOTES +Style: [e.g. Happy] +Pace: [e.g. Fast]`; + +export const ChatInputModals: React.FC = ({ + showRecorder, + onAudioRecord, + onRecorderCancel, + showCreateTextFileEditor, + onConfirmCreateTextFile, + onCreateTextFileCancel, + isHelpModalOpen, + onHelpModalClose, + allCommandsForHelp, + isProcessingFile, + isLoading, + t, + initialContent, + initialFilename, + isSystemAudioRecordingEnabled, + themeId, + isPasteRichTextAsMarkdownEnabled, + showTtsContextEditor, + onCloseTtsContextEditor, + ttsContext, + setTtsContext +}) => { + if (!showRecorder && !showCreateTextFileEditor && !isHelpModalOpen && !showTtsContextEditor) { + return null; + } + + return ( + <> + {showRecorder && ( + + )} + {showCreateTextFileEditor && ( + string} + initialContent={initialContent} + initialFilename={initialFilename} + themeId={themeId} + isPasteRichTextAsMarkdownEnabled={isPasteRichTextAsMarkdownEnabled} + /> + )} + {isHelpModalOpen && } + + {showTtsContextEditor && onCloseTtsContextEditor && setTtsContext && ( + string} + /> + )} + + ); +}; \ No newline at end of file diff --git a/src/components/chat/input/ChatInputToolbar.tsx b/src/components/chat/input/ChatInputToolbar.tsx new file mode 100644 index 00000000..139db864 --- /dev/null +++ b/src/components/chat/input/ChatInputToolbar.tsx @@ -0,0 +1,114 @@ + + +import React from 'react'; +import { AddFileByIdInput } from './toolbar/AddFileByIdInput'; +import { AddUrlInput } from './toolbar/AddUrlInput'; +import { ImagenAspectRatioSelector } from './toolbar/ImagenAspectRatioSelector'; +import { ImageSizeSelector } from './toolbar/ImageSizeSelector'; +import { QuadImageToggle } from './toolbar/QuadImageToggle'; +import { TtsVoiceSelector } from './toolbar/TtsVoiceSelector'; +import { MediaResolutionSelector } from './toolbar/MediaResolutionSelector'; +import { ChatInputToolbarProps } from '../../../types'; +import { Clapperboard } from 'lucide-react'; + +export const ChatInputToolbar: React.FC = ({ + isImagenModel, + isGemini3ImageModel, + isTtsModel, + ttsVoice, + setTtsVoice, + aspectRatio, + setAspectRatio, + imageSize, + setImageSize, + fileError, + showAddByIdInput, + fileIdInput, + setFileIdInput, + onAddFileByIdSubmit, + onCancelAddById, + isAddingById, + showAddByUrlInput, + urlInput, + setUrlInput, + onAddUrlSubmit, + onCancelAddUrl, + isAddingByUrl, + isLoading, + t, + generateQuadImages, + onToggleQuadImages, + supportedAspectRatios, + supportedImageSizes, + isNativeAudioModel, + mediaResolution, + setMediaResolution, + ttsContext, + onEditTtsContext +}) => { + const showAspectRatio = (isImagenModel || isGemini3ImageModel) && setAspectRatio && aspectRatio; + const showImageSize = supportedImageSizes && supportedImageSizes.length > 0 && setImageSize && imageSize; + const showQuadToggle = (isImagenModel || isGemini3ImageModel) && onToggleQuadImages && generateQuadImages !== undefined; + + // Allow voice selection for both explicit TTS models and Native Audio (Live) models + const showTtsVoice = (isTtsModel || isNativeAudioModel) && ttsVoice && setTtsVoice; + + // Show Media Resolution selector for Native Audio (Live API) to control stream quality + const showMediaResolution = isNativeAudioModel && mediaResolution && setMediaResolution; + + const hasVisibleContent = showAspectRatio || showImageSize || showQuadToggle || showTtsVoice || showMediaResolution || fileError || showAddByIdInput || showAddByUrlInput; + + return ( +
+ {(showAspectRatio || showImageSize || showQuadToggle || showTtsVoice || showMediaResolution) && ( +
+ {showTtsVoice && string} />} + {isTtsModel && onEditTtsContext && ( + + )} + {showMediaResolution && string} isNativeAudioModel={isNativeAudioModel} />} + {showAspectRatio && string} supportedRatios={supportedAspectRatios} />} + {showImageSize && string} supportedSizes={supportedImageSizes} />} + {showQuadToggle && string} />} +
+ )} + {fileError &&
{fileError}
} + {showAddByIdInput && ( + string} + /> + )} + {showAddByUrlInput && ( + string} + /> + )} +
+ ); +}; diff --git a/src/components/chat/input/LiveStatusBanner.tsx b/src/components/chat/input/LiveStatusBanner.tsx new file mode 100644 index 00000000..15b47e0a --- /dev/null +++ b/src/components/chat/input/LiveStatusBanner.tsx @@ -0,0 +1,67 @@ + +import React from 'react'; +import { Mic, Activity, X } from 'lucide-react'; + +interface LiveStatusBannerProps { + isConnected: boolean; + isSpeaking: boolean; + volume: number; + onDisconnect: () => void; + error: string | null; +} + +export const LiveStatusBanner: React.FC = ({ + isConnected, + isSpeaking, + volume, + onDisconnect, + error +}) => { + if (error) { + return ( +
+ {error} + +
+ ); + } + + if (!isConnected) return null; + + return ( +
+
+
+ {isSpeaking ? ( + + ) : ( + 0.05 ? 'animate-pulse' : ''} /> + )} + {/* Visualizer Ring */} +
+
+ +
+ + {isSpeaking ? "Gemini is speaking..." : "Listening..."} + + + Live Session Active • Type to chat + +
+
+ + +
+ ); +}; diff --git a/src/components/chat/input/SelectedFileDisplay.tsx b/src/components/chat/input/SelectedFileDisplay.tsx new file mode 100644 index 00000000..521500a3 --- /dev/null +++ b/src/components/chat/input/SelectedFileDisplay.tsx @@ -0,0 +1,162 @@ + +import React, { useState, useEffect, useRef } from 'react'; +import { UploadedFile } from '../../../types'; +import { Ban, X, Loader2, CheckCircle, Copy, Check, Scissors, SlidersHorizontal, Settings2, Edit3 } from 'lucide-react'; +import { getFileTypeCategory, CATEGORY_STYLES, getResolutionColor } from '../../../utils/uiUtils'; +import { useCopyToClipboard } from '../../../hooks/useCopyToClipboard'; +import { SUPPORTED_IMAGE_MIME_TYPES } from '../../../constants/fileConstants'; +import { formatFileSize } from '../../../utils/domainUtils'; + +interface SelectedFileDisplayProps { + file: UploadedFile; + onRemove: (fileId: string) => void; + onCancelUpload: (fileId: string) => void; + onConfigure?: (file: UploadedFile) => void; + onPreview?: (file: UploadedFile) => void; + isGemini3?: boolean; +} + +export const SelectedFileDisplay: React.FC = ({ file, onRemove, onCancelUpload, onConfigure, onPreview, isGemini3 }) => { + const [isNewlyActive, setIsNewlyActive] = useState(false); + const prevUploadState = useRef(file.uploadState); + const { isCopied: idCopied, copyToClipboard } = useCopyToClipboard(); + + useEffect(() => { + if (prevUploadState.current !== 'active' && file.uploadState === 'active') { + setIsNewlyActive(true); + setTimeout(() => setIsNewlyActive(false), 800); + } + prevUploadState.current = file.uploadState; + }, [file.uploadState]); + + const handleCopyId = (event: React.MouseEvent) => { + event.stopPropagation(); + if (file.fileApiName) { + copyToClipboard(file.fileApiName); + } + }; + + const isUploading = file.uploadState === 'uploading'; + const isProcessing = file.uploadState === 'processing_api' || file.isProcessing; + const isFailed = file.uploadState === 'failed' || !!file.error; + const isActive = file.uploadState === 'active'; + const isCancelled = file.uploadState === 'cancelled'; + + const isCancellable = isUploading || (isProcessing && file.uploadState !== 'processing_api'); + + const category = getFileTypeCategory(file.type, file.error); + const { Icon, colorClass, bgClass } = CATEGORY_STYLES[category] || CATEGORY_STYLES['code']; + + const isVideo = category === 'video' || category === 'youtube'; + const isImage = category === 'image'; + const isPdf = category === 'pdf'; + const isText = category === 'code'; // Based on getFileTypeCategory logic + + // Determine if this file supports configuration (Video Clipping OR Gemini 3 Resolution OR Text Editing) + const canConfigure = onConfigure && isActive && !file.error && ( + isVideo || (isGemini3 && (isImage || isPdf)) || isText + ); + + const ErrorIcon = CATEGORY_STYLES['error'].Icon; + + // Icon Selection Logic: + // If it's a text file, use Edit icon + // If it's Gemini 3, we support resolution settings. Use Sliders icon. + // If it's NOT Gemini 3 but is Video, we only support clipping. Use Scissors. + const ConfigIcon = isText ? Edit3 : (isGemini3 ? SlidersHorizontal : (isVideo ? Scissors : Settings2)); + + return ( +
+ + + +
isActive && onPreview && onPreview(file)} + className={`relative w-full aspect-square rounded-xl border border-[var(--theme-border-secondary)] bg-[var(--theme-bg-tertiary)]/30 overflow-hidden flex items-center justify-center transition-colors group-hover:border-[var(--theme-border-focus)]/50 ${isActive && onPreview ? 'cursor-pointer hover:opacity-90' : ''}`} + > + +
+ {file.dataUrl && SUPPORTED_IMAGE_MIME_TYPES.includes(file.type) ? ( + {file.name} + ) : ( +
+ +
+ )} +
+ + {(isUploading || isProcessing) && ( +
+
+ +
+
+ )} + + {isFailed && !isCancelled && ( +
+ +
+ )} + + {isNewlyActive && ( +
+ +
+ )} + + {canConfigure && ( + + )} + + {file.fileApiName && isActive && !file.error && ( + + )} +
+ +
+

+ {file.name} +

+

+ {file.videoMetadata ? : null} + {file.mediaResolution && } + {isFailed ? (file.error || 'Error') : + isUploading ? 'Uploading...' : + isProcessing ? 'Processing...' : + isCancelled ? 'Cancelled' : + formatFileSize(file.size)} +

+
+
+ ); +}; diff --git a/src/components/chat/input/SlashCommandMenu.tsx b/src/components/chat/input/SlashCommandMenu.tsx new file mode 100644 index 00000000..ba95f730 --- /dev/null +++ b/src/components/chat/input/SlashCommandMenu.tsx @@ -0,0 +1,130 @@ + +import React, { useRef, useEffect } from 'react'; +import { CornerDownLeft } from 'lucide-react'; +import { CommandIcon } from '../../icons/CommandIcon'; + +export interface Command { + name: string; + description: string; + icon: string; + action: () => void; +} + +interface SlashCommandMenuProps { + isOpen: boolean; + commands: Command[]; + onSelect: (command: Command) => void; + selectedIndex: number; + className?: string; +} + +export const SlashCommandMenu: React.FC = ({ isOpen, commands, onSelect, selectedIndex, className }) => { + const scrollContainerRef = useRef(null); + const selectedItemRef = useRef(null); + + useEffect(() => { + if (isOpen && selectedItemRef.current && scrollContainerRef.current) { + selectedItemRef.current.scrollIntoView({ + block: 'nearest', + inline: 'start' + }); + } + }, [selectedIndex, isOpen]); + + if (!isOpen || commands.length === 0) { + return null; + } + + const defaultClasses = "absolute bottom-full left-0 right-0 mb-2 w-full max-w-3xl mx-auto px-2 sm:px-4 z-30"; + const finalClassName = className || defaultClasses; + + return ( +
+
+ {/* Header Strip */} +
+ + Commands + +
+ + ↑↓ to navigate + + + Tab to select + +
+
+ + {/* Command List */} +
    + {commands.map((command, index) => { + const isSelected = selectedIndex === index; + return ( +
  • + +
  • + ); + })} +
+
+
+ ); +}; diff --git a/src/components/chat/input/ToolsMenu.tsx b/src/components/chat/input/ToolsMenu.tsx new file mode 100644 index 00000000..0c340787 --- /dev/null +++ b/src/components/chat/input/ToolsMenu.tsx @@ -0,0 +1,219 @@ + + +import React, { useState, useRef, useLayoutEffect, useEffect } from 'react'; +import { createPortal } from 'react-dom'; +import { SlidersHorizontal, Globe, Check, Terminal, Link, X, Telescope, Calculator } from 'lucide-react'; +import { translations } from '../../../utils/appUtils'; +import { useClickOutside } from '../../../hooks/useClickOutside'; +import { IconYoutube, IconPython } from '../../icons/CustomIcons'; +import { CHAT_INPUT_BUTTON_CLASS } from '../../../constants/appConstants'; +import { useWindowContext } from '../../../contexts/WindowContext'; + +interface ToolsMenuProps { + isGoogleSearchEnabled: boolean; + onToggleGoogleSearch: () => void; + isCodeExecutionEnabled: boolean; + onToggleCodeExecution: () => void; + isLocalPythonEnabled?: boolean; + onToggleLocalPython?: () => void; + isUrlContextEnabled: boolean; + onToggleUrlContext: () => void; + isDeepSearchEnabled: boolean; + onToggleDeepSearch: () => void; + onAddYouTubeVideo: () => void; + onCountTokens: () => void; + disabled: boolean; + t: (key: keyof typeof translations) => string; + isNativeAudioModel?: boolean; +} + +const ActiveToolBadge: React.FC<{ + label: string; + onRemove: () => void; + removeAriaLabel: string; + icon: React.ReactNode; +}> = ({ label, onRemove, removeAriaLabel, icon }) => ( + <> +
+
+
+ + {icon} + + + + +
+ {label} +
+ +); + +export const ToolsMenu: React.FC = ({ + isGoogleSearchEnabled, onToggleGoogleSearch, + isCodeExecutionEnabled, onToggleCodeExecution, + isLocalPythonEnabled, onToggleLocalPython, + isUrlContextEnabled, onToggleUrlContext, + isDeepSearchEnabled, onToggleDeepSearch, + onAddYouTubeVideo, onCountTokens, + disabled, t, isNativeAudioModel +}) => { + const [isOpen, setIsOpen] = useState(false); + const [menuPosition, setMenuPosition] = useState({}); + const containerRef = useRef(null); + const buttonRef = useRef(null); + const menuRef = useRef(null); + + const { window: targetWindow } = useWindowContext(); + + useClickOutside(containerRef, () => setIsOpen(false), isOpen); + + // Prevent click-outside logic from firing when interacting with the portaled menu + useEffect(() => { + if (!isOpen || !menuRef.current) return; + + const stopProp = (e: Event) => e.stopPropagation(); + const menuEl = menuRef.current; + + // Stop bubbling to document so useClickOutside doesn't see it + menuEl.addEventListener('mousedown', stopProp); + menuEl.addEventListener('touchstart', stopProp); + + return () => { + menuEl.removeEventListener('mousedown', stopProp); + menuEl.removeEventListener('touchstart', stopProp); + }; + }, [isOpen]); + + // Dynamic fixed positioning + useLayoutEffect(() => { + if (isOpen && buttonRef.current && targetWindow) { + const buttonRect = buttonRef.current.getBoundingClientRect(); + const viewportWidth = targetWindow.innerWidth; + const viewportHeight = targetWindow.innerHeight; + + const MENU_WIDTH = 240; // w-60 approx 240px + const BUTTON_MARGIN = 10; + const GAP = 8; + + const newStyle: React.CSSProperties = { + position: 'fixed', + zIndex: 9999, // Ensure it sits on top of everything including toolbar + }; + + // Horizontal Alignment + if (buttonRect.left + MENU_WIDTH > viewportWidth - BUTTON_MARGIN) { + // Align right edge of menu with right edge of button + newStyle.left = buttonRect.right - MENU_WIDTH; + newStyle.transformOrigin = 'bottom right'; + } else { + // Align left + newStyle.left = buttonRect.left; + newStyle.transformOrigin = 'bottom left'; + } + + // Vertical Alignment (Anchored to bottom of viewport relative to button top) + newStyle.bottom = viewportHeight - buttonRect.top + GAP; + + setMenuPosition(newStyle); + } + }, [isOpen, targetWindow]); + + const handleToggle = (toggleFunc?: () => void) => { + if (toggleFunc) { + toggleFunc(); + setIsOpen(false); + } + }; + + // Matched icon size to other toolbar buttons (Attachment, Mic, etc.) + const menuIconSize = 20; + + const menuItems = [ + { labelKey: 'deep_search_label', icon: , isEnabled: isDeepSearchEnabled, action: () => handleToggle(onToggleDeepSearch) }, + { labelKey: 'web_search_label', icon: , isEnabled: isGoogleSearchEnabled, action: () => handleToggle(onToggleGoogleSearch) }, + { labelKey: 'code_execution_label', icon: , isEnabled: isCodeExecutionEnabled, action: () => handleToggle(onToggleCodeExecution) }, + { labelKey: 'local_python_label', icon: , isEnabled: !!isLocalPythonEnabled, action: () => handleToggle(onToggleLocalPython) }, + { labelKey: 'url_context_label', icon: , isEnabled: isUrlContextEnabled, action: () => handleToggle(onToggleUrlContext) }, + { labelKey: 'attachMenu_addByUrl', icon: , isEnabled: false, action: () => { onAddYouTubeVideo(); setIsOpen(false); } }, + { labelKey: 'tools_token_count_label', icon: , isEnabled: false, action: () => { onCountTokens(); setIsOpen(false); } } + ]; + + const filteredItems = menuItems.filter(item => { + if (isNativeAudioModel) { + // For Live API: + // 1. Code Execution is NOT supported. + // 2. Web Search is supported but moved to the main toolbar. + // 3. Other tools are not explicitly supported/tested in this mode yet. + return false; + } + // Only show Local Python if handler is provided (it's new feature) + if (item.labelKey === 'local_python_label' && !onToggleLocalPython) { + return false; + } + return true; + }); + + if (filteredItems.length === 0) return null; + + return ( +
+
+ + {isOpen && targetWindow && createPortal( +
+ {filteredItems.map(item => ( + + ))} +
, + targetWindow.document.body + )} +
+ {/* Only show badges for tools that are relevant to the current mode */} + {!isNativeAudioModel && isDeepSearchEnabled && } />} + + {/* In Live Mode, Web Search is a toggle button, so badge is redundant/confusing if inside tools menu logic, but let's hide it from here if the button shows status */} + {!isNativeAudioModel && isGoogleSearchEnabled && } />} + + {!isNativeAudioModel && isCodeExecutionEnabled && } />} + + {!isNativeAudioModel && isLocalPythonEnabled && onToggleLocalPython && } />} + + {!isNativeAudioModel && isUrlContextEnabled && } />} +
+ ); +}; \ No newline at end of file diff --git a/src/components/chat/input/actions/LiveControls.tsx b/src/components/chat/input/actions/LiveControls.tsx new file mode 100644 index 00000000..d8c36709 --- /dev/null +++ b/src/components/chat/input/actions/LiveControls.tsx @@ -0,0 +1,61 @@ +import React from 'react'; +import { PhoneOff, AudioWaveform, Mic, MicOff } from 'lucide-react'; +import { CHAT_INPUT_BUTTON_CLASS } from '../../../../constants/appConstants'; + +interface LiveControlsProps { + isLiveConnected: boolean; + isLiveMuted?: boolean; + onStartLiveSession: () => void; + onToggleLiveMute?: () => void; + disabled: boolean; + isRecording: boolean; + isTranscribing: boolean; +} + +export const LiveControls: React.FC = ({ + isLiveConnected, + isLiveMuted, + onStartLiveSession, + onToggleLiveMute, + disabled, + isRecording, + isTranscribing +}) => { + const micIconSize = 20; + + return ( + <> + {/* Live Session Mute Button */} + {isLiveConnected && onToggleLiveMute && ( + + )} + + {/* Live Session Button */} + {!isRecording && !isTranscribing && ( + + )} + + ); +}; \ No newline at end of file diff --git a/src/components/chat/input/actions/RecordControls.tsx b/src/components/chat/input/actions/RecordControls.tsx new file mode 100644 index 00000000..0a814bc4 --- /dev/null +++ b/src/components/chat/input/actions/RecordControls.tsx @@ -0,0 +1,64 @@ +import React from 'react'; +import { Loader2, Mic } from 'lucide-react'; +import { CHAT_INPUT_BUTTON_CLASS } from '../../../../constants/appConstants'; + +interface RecordControlsProps { + isRecording: boolean; + isTranscribing: boolean; + isMicInitializing: boolean; + onRecordButtonClick: () => void; + onCancelRecording: () => void; + disabled: boolean; + t: (key: string) => string; +} + +export const RecordControls: React.FC = ({ + isRecording, + isTranscribing, + isMicInitializing, + onRecordButtonClick, + onCancelRecording, + disabled, + t +}) => { + const micIconSize = 20; + + return ( + <> + {isRecording && ( + + )} + + + + ); +}; \ No newline at end of file diff --git a/src/components/chat/input/actions/SendControls.tsx b/src/components/chat/input/actions/SendControls.tsx new file mode 100644 index 00000000..952e82e2 --- /dev/null +++ b/src/components/chat/input/actions/SendControls.tsx @@ -0,0 +1,186 @@ + +import React, { useState, useEffect } from 'react'; +import { X, Save, Edit2, Loader2, ArrowUp } from 'lucide-react'; +import { IconStop } from '../../../icons/CustomIcons'; +import { CHAT_INPUT_BUTTON_CLASS } from '../../../../constants/appConstants'; + +interface SendControlsProps { + isLoading: boolean; + isEditing: boolean; + canSend: boolean; + isWaitingForUpload: boolean; + editMode?: 'update' | 'resend'; + onStopGenerating: () => void; + onCancelEdit: () => void; + onFastSendMessage?: () => void; + t: (key: string, fallback?: string) => string; +} + +interface Ripple { + x: number; + y: number; + id: number; + size: number; +} + +export const SendControls: React.FC = ({ + isLoading, + isEditing, + canSend, + isWaitingForUpload, + editMode, + onStopGenerating, + onCancelEdit, + onFastSendMessage, + t +}) => { + const iconSize = 20; + const [ripples, setRipples] = useState([]); + + useEffect(() => { + if (ripples.length > 0) { + const timeout = setTimeout(() => setRipples([]), 600); + return () => clearTimeout(timeout); + } + }, [ripples]); + + const createRipple = (e: React.MouseEvent) => { + const button = e.currentTarget; + const rect = button.getBoundingClientRect(); + const size = Math.max(rect.width, rect.height); + const x = e.clientX - rect.left - size / 2; + const y = e.clientY - rect.top - size / 2; + setRipples(prev => [...prev, { x, y, id: Date.now(), size }]); + }; + + // Determine state priorities + const isStop = isLoading; + const isUpload = !isLoading && isWaitingForUpload; + const isEdit = !isLoading && isEditing; + const isSend = !isLoading && !isEditing && !isWaitingForUpload; + + // Determine disabled state + // Note: Stop button is never disabled by canSend. + const isDisabled = !isLoading && (!canSend || isWaitingForUpload); + + // Determine background class + let bgClass = "bg-[var(--theme-bg-accent)] hover:bg-[var(--theme-bg-accent-hover)] text-[var(--theme-text-accent)]"; + + if (isDisabled && !isUpload) { + bgClass = "bg-[var(--theme-bg-tertiary)] text-[var(--theme-text-tertiary)] cursor-not-allowed"; + } else if (isStop) { + bgClass = "bg-[var(--theme-bg-danger)] hover:bg-[var(--theme-bg-danger-hover)] text-[var(--theme-icon-stop)]"; + } else if (isEdit) { + bgClass = "bg-amber-500 hover:bg-amber-600 text-white"; + } else if (isUpload) { + // Active processing state uses accent color with progress stripes + bgClass = "bg-[var(--theme-bg-accent)] text-[var(--theme-text-accent)] cursor-wait bg-progress-stripe"; + } + + // Determine shape class for morphing + // Stop button is squarer (rounded-xl) to match stop icon metaphor + // Others are circular (rounded-full) + // Using explicit pixel radius or consistent scale ensures smoother transition than mixed units + const shapeClass = isStop ? '!rounded-[12px]' : '!rounded-full'; + + // Handlers + const handleClick = (e: React.MouseEvent) => { + // Create ripple if button is interactive + if (!isDisabled) { + createRipple(e); + } + + if (isStop) { + e.preventDefault(); + e.stopPropagation(); + onStopGenerating(); + } else if (isDisabled) { + e.preventDefault(); + } + // For submit (send/edit), we let the form handler take over unless blocked + }; + + const handleContextMenu = (e: React.MouseEvent) => { + if (isSend && onFastSendMessage && !isDisabled) { + e.preventDefault(); + createRipple(e); + onFastSendMessage(); + } + }; + + // Text & Tooltips + let label = t('sendMessage_aria'); + let title = t('sendMessage_title'); + + if (isStop) { + label = t('stopGenerating_aria'); + title = t('stopGenerating_title'); + } else if (isEdit) { + label = t('updateMessage_aria'); + title = t('updateMessage_title'); + } else if (isUpload) { + label = "Waiting for upload..."; + title = "Waiting for upload to complete before sending"; + } else if (isSend && onFastSendMessage && !isDisabled) { + title = t('sendMessage_title') + t('sendMessage_fast_suffix', " (Right-click for Fast Mode ⚡)"); + } + + const renderIcon = (active: boolean, Icon: React.ElementType, props: any = {}) => ( +
+ +
+ ); + + return ( +
+ {/* Cancel Edit Button - Animates in/out */} +
+ +
+ + {/* Main Action Button */} + +
+ ); +}; diff --git a/src/components/chat/input/actions/UtilityControls.tsx b/src/components/chat/input/actions/UtilityControls.tsx new file mode 100644 index 00000000..d47898c2 --- /dev/null +++ b/src/components/chat/input/actions/UtilityControls.tsx @@ -0,0 +1,57 @@ +import React from 'react'; +import { Maximize2, Minimize2, Languages, Loader2 } from 'lucide-react'; +import { CHAT_INPUT_BUTTON_CLASS } from '../../../../constants/appConstants'; + +interface UtilityControlsProps { + isFullscreen?: boolean; + onToggleFullscreen?: () => void; + isTranslating: boolean; + onTranslate: () => void; + disabled: boolean; + canTranslate: boolean; + t: (key: string) => string; +} + +export const UtilityControls: React.FC = ({ + isFullscreen, + onToggleFullscreen, + isTranslating, + onTranslate, + disabled, + canTranslate, + t +}) => { + const iconSize = 20; + + return ( + <> + {onToggleFullscreen && ( + + )} + + + + ); +}; \ No newline at end of file diff --git a/src/components/chat/input/actions/WebSearchToggle.tsx b/src/components/chat/input/actions/WebSearchToggle.tsx new file mode 100644 index 00000000..2825836a --- /dev/null +++ b/src/components/chat/input/actions/WebSearchToggle.tsx @@ -0,0 +1,28 @@ +import React from 'react'; +import { Globe } from 'lucide-react'; +import { CHAT_INPUT_BUTTON_CLASS } from '../../../../constants/appConstants'; + +interface WebSearchToggleProps { + isGoogleSearchEnabled: boolean; + onToggleGoogleSearch: () => void; + disabled: boolean; + t: (key: string) => string; +} + +export const WebSearchToggle: React.FC = ({ + isGoogleSearchEnabled, + onToggleGoogleSearch, + disabled, + t +}) => ( + +); \ No newline at end of file diff --git a/src/components/chat/input/area/ChatFilePreviewList.tsx b/src/components/chat/input/area/ChatFilePreviewList.tsx new file mode 100644 index 00000000..f5e66ba0 --- /dev/null +++ b/src/components/chat/input/area/ChatFilePreviewList.tsx @@ -0,0 +1,45 @@ +import React from 'react'; +import { UploadedFile } from '../../../../types'; +import { SelectedFileDisplay } from '../SelectedFileDisplay'; +import { useAutoAnimate } from '@formkit/auto-animate/react'; + +interface ChatFilePreviewListProps { + selectedFiles: UploadedFile[]; + onRemove: (id: string) => void; + onCancelUpload: (id: string) => void; + onConfigure: (file: UploadedFile) => void; + onPreview: (file: UploadedFile) => void; + isGemini3?: boolean; +} + +export const ChatFilePreviewList: React.FC = ({ + selectedFiles, + onRemove, + onCancelUpload, + onConfigure, + onPreview, + isGemini3 +}) => { + const [parent] = useAutoAnimate({ + duration: 200, + easing: 'ease-in-out' + }); + + if (selectedFiles.length === 0) return null; + + return ( +
+ {selectedFiles.map(file => ( + + ))} +
+ ); +}; \ No newline at end of file diff --git a/src/components/chat/input/area/ChatQuoteDisplay.tsx b/src/components/chat/input/area/ChatQuoteDisplay.tsx new file mode 100644 index 00000000..948fe69a --- /dev/null +++ b/src/components/chat/input/area/ChatQuoteDisplay.tsx @@ -0,0 +1,60 @@ + +import React from 'react'; +import { Quote, Trash2 } from 'lucide-react'; +import { MarkdownRenderer } from '../../../message/MarkdownRenderer'; +import { translations } from '../../../../utils/appUtils'; + +interface ChatQuoteDisplayProps { + quotes: string[]; + onRemoveQuote: (index: number) => void; + themeId: string; + t: (key: keyof typeof translations) => string; +} + +export const ChatQuoteDisplay: React.FC = ({ quotes, onRemoveQuote, themeId, t }) => { + if (!quotes || quotes.length === 0) return null; + + return ( +
+ {quotes.map((quote, index) => ( +
+
+
+ +
+
+ {quotes.length > 1 && ( +
+ Quote {index + 1} +
+ )} +
+
+ {}} + onOpenHtmlPreview={() => {}} + expandCodeBlocksByDefault={false} + isMermaidRenderingEnabled={true} + isGraphvizRenderingEnabled={false} + t={t} + themeId={themeId} + onOpenSidePanel={() => {}} + /> +
+
+
+ +
+ ))} +
+ ); +}; diff --git a/src/components/chat/input/area/ChatSuggestions.tsx b/src/components/chat/input/area/ChatSuggestions.tsx new file mode 100644 index 00000000..ca1cb4d0 --- /dev/null +++ b/src/components/chat/input/area/ChatSuggestions.tsx @@ -0,0 +1,160 @@ + +import React, { useRef, useState, useEffect, useCallback } from 'react'; +import { ChevronLeft, ChevronRight, MousePointer2 } from 'lucide-react'; +import { SUGGESTIONS_KEYS } from '../../../../constants/appConstants'; +import { SuggestionIcon } from './SuggestionIcon'; +import { translations } from '../../../../utils/appUtils'; + +interface ChatSuggestionsProps { + show: boolean; + onSuggestionClick?: (suggestion: string) => void; + onOrganizeInfoClick?: (suggestion: string) => void; + onToggleBBox?: () => void; + isBBoxModeActive?: boolean; + onToggleGuide?: () => void; + isGuideModeActive?: boolean; + t: (key: keyof typeof translations) => string; + isFullscreen: boolean; +} + +export const ChatSuggestions: React.FC = ({ show, onSuggestionClick, onOrganizeInfoClick, onToggleBBox, isBBoxModeActive, onToggleGuide, isGuideModeActive, t, isFullscreen }) => { + const suggestionsRef = useRef(null); + const [showLeftArrow, setShowLeftArrow] = useState(false); + const [showRightArrow, setShowRightArrow] = useState(false); + const [isSuggestionsHovered, setIsSuggestionsHovered] = useState(false); + + const checkScroll = useCallback(() => { + if (suggestionsRef.current) { + const { scrollLeft, scrollWidth, clientWidth } = suggestionsRef.current; + setShowLeftArrow(scrollLeft > 5); // Small threshold + setShowRightArrow(scrollLeft < scrollWidth - clientWidth - 5); + } + }, []); + + useEffect(() => { + checkScroll(); + window.addEventListener('resize', checkScroll); + return () => window.removeEventListener('resize', checkScroll); + }, [checkScroll, show]); + + const handleScroll = (direction: 'left' | 'right') => { + if (suggestionsRef.current) { + const scrollAmount = suggestionsRef.current.clientWidth * 0.6; + suggestionsRef.current.scrollBy({ + left: direction === 'left' ? -scrollAmount : scrollAmount, + behavior: 'smooth' + }); + } + }; + + if (!show || isFullscreen) return null; + + return ( +
setIsSuggestionsHovered(true)} + onMouseLeave={() => setIsSuggestionsHovered(false)} + > +
+ {SUGGESTIONS_KEYS.map((s, i) => ( + + + + {/* Insert BBox and Guide Buttons after "Smart Board" (organize action) if available */} + {(s as any).specialAction === 'organize' && ( + <> + {onToggleBBox && ( + + )} + {onToggleGuide && ( + + )} + + )} + + ))} +
+ + {/* Navigation Arrows (Visible on Hover) */} + {showLeftArrow && ( + + )} + {showRightArrow && ( + + )} +
+ ); +}; diff --git a/src/components/chat/input/area/ChatTextArea.tsx b/src/components/chat/input/area/ChatTextArea.tsx new file mode 100644 index 00000000..e3b72727 --- /dev/null +++ b/src/components/chat/input/area/ChatTextArea.tsx @@ -0,0 +1,107 @@ + +import React, { useRef, useLayoutEffect } from 'react'; +import { MAX_TEXTAREA_HEIGHT_PX } from '../../../../hooks/chat-input/useChatInputState'; + +interface ChatTextAreaProps { + textareaRef: React.RefObject; + value: string; + onChange: (e: React.ChangeEvent) => void; + onKeyDown: (e: React.KeyboardEvent) => void; + onPaste: (e: React.ClipboardEvent) => void; + onCompositionStart: () => void; + onCompositionEnd: () => void; + onFocus?: () => void; + placeholder: string; + disabled: boolean; + isFullscreen: boolean; + isMobile: boolean; + initialTextareaHeight: number; + isConverting: boolean; +} + +export const ChatTextArea: React.FC = ({ + textareaRef, + value, + onChange, + onKeyDown, + onPaste, + onCompositionStart, + onCompositionEnd, + onFocus, + placeholder, + disabled, + isFullscreen, + isMobile, + initialTextareaHeight, + isConverting, +}) => { + const shadowRef = useRef(null); + + useLayoutEffect(() => { + const target = textareaRef.current; + const shadow = shadowRef.current; + if (!target || !shadow) return; + + // Reset shadow height to allow accurate shrinking measurement + shadow.style.height = '0px'; + shadow.value = value; + + if (isFullscreen) { + target.style.height = '100%'; + target.style.overflowY = 'auto'; + } else { + const scrollHeight = shadow.scrollHeight; + const baseHeight = isMobile ? 24 : initialTextareaHeight; + const maxHeight = isMobile ? 120 : MAX_TEXTAREA_HEIGHT_PX; + const newHeight = Math.max(baseHeight, Math.min(scrollHeight, maxHeight)); + target.style.height = `${newHeight}px`; + + // Only show scrollbar if content exceeds max height + if (scrollHeight > maxHeight) { + target.style.overflowY = 'auto'; + } else { + target.style.overflowY = 'hidden'; + } + } + }, [value, isFullscreen, isMobile, initialTextareaHeight, textareaRef]); + + return ( +
+ {/* Shadow Textarea for Height Calculation */} +