diff --git a/src/components/Menu/ModelSelectionMenuList.tsx b/src/components/Menu/ModelSelectionMenuList.tsx new file mode 100644 index 00000000..100c5c1a --- /dev/null +++ b/src/components/Menu/ModelSelectionMenuList.tsx @@ -0,0 +1,254 @@ +import { useState, useMemo } from "react"; +import { useDebounce } from "react-use"; +import { MenuItem, MenuDivider } from "../Menu"; +import { MenuGroup, MenuHeader } from "@szhsin/react-menu"; +import { useSettings } from "../../hooks/use-settings"; +import { useModels } from "../../hooks/use-models"; +import { IoMdCheckmark } from "react-icons/io"; +import { TbSearch } from "react-icons/tb"; +import { Input, InputGroup, InputLeftElement } from "@chakra-ui/react"; +import { FreeModelProvider } from "../../lib/providers/DefaultProvider/FreeModelProvider"; +import { isChatModel } from "../../lib/ai"; +import useMobileBreakpoint from "../../hooks/use-mobile-breakpoint"; +import { useTextToSpeech } from "../../hooks/use-text-to-speech"; +import { MdVolumeOff, MdVolumeUp } from "react-icons/md"; +import useAudioPlayer from "../../hooks/use-audio-player"; + +interface ModelSelectionMenuListProps { + onItemSelect: (modelId: string) => void; +} +function MobileModelSelectionMenuList({ onItemSelect }: ModelSelectionMenuListProps) { + const { settings, setSettings } = useSettings(); + const { models } = useModels(); + const [searchQuery, setSearchQuery] = useState(""); + const [debouncedSearchQuery, setDebouncedSearchQuery] = useState(""); + const { isTextToSpeechSupported } = useTextToSpeech(); + const { clearAudioQueue } = useAudioPlayer(); + const providersList = useMemo( + () => ({ + ...settings.providers, + "Free AI Models": new FreeModelProvider(), + }), + [settings.providers] + ); + + useDebounce(() => setDebouncedSearchQuery(searchQuery), 250, [searchQuery]); + return ( + <> + {/* Providers Section */} + Providers + + {Object.entries(providersList).map(([providerName, providerObject]) => ( + { + setSettings({ ...settings, currentProvider: providerObject }); + }} + > + {settings.currentProvider.name === providerName ? ( + + ) : ( + + )} + {providerName} + + ))} + + + {/* Models Section */} + Models + +
+ {models + .filter((model) => isChatModel(model.id)) + .filter((model) => + model.name.toLowerCase().includes(debouncedSearchQuery.toLowerCase()) + ) + .map((model) => ( + { + onItemSelect(model.id); + }} + style={{ + paddingInline: "16px", + }} + > + {settings.model.id === model.id ? ( + + ) : ( + + )} + {model.name} + + ))} +
+
+ + + + + { + e.preventDefault(); + setSearchQuery(e.target.value); + }} + /> + +
+
+ + {/* Text-to-Speech Section */} + {isTextToSpeechSupported && ( + <> + + + ) : ( + + ) + } + onClick={() => { + if (settings.textToSpeech.announceMessages) { + // Flush any remaining audio clips being announced + clearAudioQueue(); + } + setSettings({ + ...settings, + textToSpeech: { + ...settings.textToSpeech, + announceMessages: !settings.textToSpeech.announceMessages, + }, + }); + }} + > + {settings.textToSpeech.announceMessages + ? "Text-to-Speech Enabled" + : "Text-to-Speech Disabled"} + + + )} + + ); +} + +function DesktopModelSelectionMenuList({ onItemSelect }: ModelSelectionMenuListProps) { + const { settings, setSettings } = useSettings(); + const { models } = useModels(); + const [searchQuery, setSearchQuery] = useState(""); + const [debouncedSearchQuery, setDebouncedSearchQuery] = useState(""); + const providersList = useMemo( + () => ({ + ...settings.providers, + "Free AI Models": new FreeModelProvider(), + }), + [settings.providers] + ); + + useDebounce(() => setDebouncedSearchQuery(searchQuery), 250, [searchQuery]); + + return ( + <> + + Providers + + + {Object.entries(providersList).map(([providerName, providerObject]) => ( + setSettings({ ...settings, currentProvider: providerObject })} + > + {settings.currentProvider.name === providerName ? ( + + ) : ( + + )} + {providerName} + + ))} + + + + Models + + + + + + + setSearchQuery(e.target.value)} + /> + +
+ {models + .filter((model) => isChatModel(model.id)) + .filter((model) => + model.name.toLowerCase().includes(debouncedSearchQuery.toLowerCase()) + ) + .map((model) => ( + { + onItemSelect(model.id); + }} + > + {settings.model.id === model.id ? ( + + ) : ( + + )} + {model.name} + + ))} +
+
+ + ); +} + +export default function ModelSelectionMenuList(props: ModelSelectionMenuListProps) { + const isMobile = useMobileBreakpoint(); + + return isMobile ? ( + + ) : ( + + ); +} diff --git a/src/components/Message/MessageBase.tsx b/src/components/Message/MessageBase.tsx index 3e7379d4..750343e1 100644 --- a/src/components/Message/MessageBase.tsx +++ b/src/components/Message/MessageBase.tsx @@ -63,10 +63,10 @@ import useAudioPlayer from "../../hooks/use-audio-player"; import useMobileBreakpoint from "../../hooks/use-mobile-breakpoint"; import { useUser } from "../../hooks/use-user"; import { ChatCraftChat } from "../../lib/ChatCraftChat"; -import { isChatModel } from "../../lib/ai"; import { getSentenceChunksFrom } from "../../lib/summarize"; import "./Message.css"; import { useTextToSpeech } from "../../hooks/use-text-to-speech"; +import ModelSelectionMenuList from "../Menu/ModelSelectionMenuList"; export interface MessageBaseProps { message: ChatCraftMessage; @@ -556,13 +556,12 @@ function MessageBase({ <> - {models - .filter((model) => isChatModel(model.id)) - .map((model) => ( - onRetryClick(model)}> - {model.prettyModel} - - ))} + { + const model = models.find((m) => m.id === modelId); + if (model) onRetryClick(model); + }} + /> )} diff --git a/src/components/PromptForm/PromptSendButton.tsx b/src/components/PromptForm/PromptSendButton.tsx index a85c2a66..dbc30281 100644 --- a/src/components/PromptForm/PromptSendButton.tsx +++ b/src/components/PromptForm/PromptSendButton.tsx @@ -1,273 +1,134 @@ -import { - Box, - Button, - ButtonGroup, - IconButton, - Input, - InputGroup, - InputLeftElement, - Menu, - MenuButton, - MenuDivider, - MenuGroup, - MenuItem, - MenuList, - Tooltip, -} from "@chakra-ui/react"; -import { TbChevronUp, TbSearch, TbSend } from "react-icons/tb"; -import { FreeModelProvider } from "../../lib/providers/DefaultProvider/FreeModelProvider"; - +import { Button, ButtonGroup, IconButton, Tooltip } from "@chakra-ui/react"; +import { TbChevronUp, TbSend } from "react-icons/tb"; import useMobileBreakpoint from "../../hooks/use-mobile-breakpoint"; import { useSettings } from "../../hooks/use-settings"; import { useModels } from "../../hooks/use-models"; import theme from "../../theme"; import { MdVolumeOff, MdVolumeUp } from "react-icons/md"; -import { IoMdCheckmark } from "react-icons/io"; -import { type KeyboardEvent, useRef, useState } from "react"; import useAudioPlayer from "../../hooks/use-audio-player"; -import { useDebounce } from "react-use"; -import { isChatModel } from "../../lib/ai"; import InterruptSpeechButton from "../InterruptSpeechButton"; import { useTextToSpeech } from "../../hooks/use-text-to-speech"; +import ModelSelectionMenuList from "../Menu/ModelSelectionMenuList"; +import { Menu, MenuDivider } from "../Menu"; type PromptSendButtonProps = { isLoading: boolean; }; function MobilePromptSendButton({ isLoading }: PromptSendButtonProps) { - const [searchQuery, setSearchQuery] = useState(""); - const [debouncedSearchQuery, setDebouncedSearchQuery] = useState(""); const { settings, setSettings } = useSettings(); const { models } = useModels(); - const inputRef = useRef(null); - const { clearAudioQueue, isAudioQueueEmpty } = useAudioPlayer(); const { isTextToSpeechSupported } = useTextToSpeech(); - - useDebounce( - () => { - setDebouncedSearchQuery(searchQuery); - }, - 600, - [searchQuery] - ); - - const providersList = { - ...settings.providers, - "Free AI Models": new FreeModelProvider(), - }; - return ( - - } - /> - {isTextToSpeechSupported && isAudioQueueEmpty ? ( - } + /> + {isTextToSpeechSupported && isAudioQueueEmpty ? ( + + - - ) : ( - - ) + icon={ + settings.textToSpeech.announceMessages ? ( + + ) : ( + + ) + } + onClick={() => { + if (settings.textToSpeech.announceMessages) { + // Flush any remaining audio clips being announced + clearAudioQueue(); } - onClick={() => { - if (settings.textToSpeech.announceMessages) { - // Flush any remaining audio clips being announced - clearAudioQueue(); - } - setSettings({ - ...settings, - textToSpeech: { - ...settings.textToSpeech, - announceMessages: !settings.textToSpeech.announceMessages, - }, - }); - }} - /> - - ) : isTextToSpeechSupported ? ( - - ) : null} - } + setSettings({ + ...settings, + textToSpeech: { + ...settings.textToSpeech, + announceMessages: !settings.textToSpeech.announceMessages, + }, + }); + }} + /> + + ) : isTextToSpeechSupported ? ( + + ) : null} + { + e.keepOpen = false; // Prevents the menu from closing automatically + }} + menuStyle={{ + maxHeight: "85dvh", // Sets the maximum height + overflowY: "auto", // Enables vertical scrolling + zIndex: theme.zIndices.dropdown, + marginTop: "-90px", + }} + menuButton={({ open }) => ( + + )} + > + { + const model = models.find((m) => m.id === modelId); + if (model) setSettings({ ...settings, model }); + }} /> - - - {Object.entries(providersList).map(([providerName, providerObject]) => ( - { - setSettings({ ...settings, currentProvider: providerObject }); - }} - > - {settings.currentProvider.name === providerName ? ( - - ) : ( - - )} - {providerName} - - ))} - - - - - {models - .filter((model) => isChatModel(model.id)) - .filter((model) => - model.name.toLowerCase().includes(debouncedSearchQuery.toLowerCase()) - ) - .map((model) => ( - setSettings({ ...settings, model })} - > - {settings.model.id === model.id ? ( - - ) : ( - - )} - {model.name} - - ))} - - - - - - { - e.preventDefault(); - setSearchQuery(e.target.value); - }} - /> - - - {isTextToSpeechSupported && ( - <> - - - ) : ( - - ) - } - onClick={() => { - if (settings.textToSpeech.announceMessages) { - // Flush any remaining audio clips being announced - clearAudioQueue(); - } - setSettings({ - ...settings, - textToSpeech: { - ...settings.textToSpeech, - announceMessages: !settings.textToSpeech.announceMessages, - }, - }); - }} - > - {settings.textToSpeech.announceMessages - ? "Text-to-Speech Enabled" - : "Text-to-Speech Disabled"} - - - )} - ); } function DesktopPromptSendButton({ isLoading }: PromptSendButtonProps) { - const [searchQuery, setSearchQuery] = useState(""); - const [debouncedSearchQuery, setDebouncedSearchQuery] = useState(""); const { settings, setSettings } = useSettings(); const { models } = useModels(); - const inputRef = useRef(null); - - useDebounce( - () => { - setDebouncedSearchQuery(searchQuery); - }, - 250, - [searchQuery] - ); - - const onStartTyping = (e: KeyboardEvent) => { - // Check if the inputRef is current and the input is not already focused - if (inputRef.current && document.activeElement !== inputRef.current) { - if (e.key === "ArrowUp" || e.key === "ArrowDown" || e.key === "Enter") { - return; - } - // Don't handle the keydown event more than once - e.preventDefault(); - // Ignore control keys - const char = e.key.length === 1 ? e.key : ""; - // Set the initial character in the input so we don't lose it - setSearchQuery(searchQuery + char); - // Make sure we are focused on the input element - inputRef.current.focus(); - } - }; const { clearAudioQueue, isAudioQueueEmpty } = useAudioPlayer(); const { isTextToSpeechSupported } = useTextToSpeech(); - const providersList = { - ...settings.providers, - "Free AI Models": new FreeModelProvider(), - }; - return ( + )} + > + { + const model = models.find((m) => m.id === modelId); + if (model) setSettings({ ...settings, model }); + }} /> - - - {Object.entries(providersList).map(([providerName, providerObject]) => ( - { - setSettings({ ...settings, currentProvider: providerObject }); - }} - > - {settings.currentProvider.name === providerName ? ( - - ) : ( - - )} - {providerName} - - ))} - - - - - - - - { - e.preventDefault(); - setSearchQuery(e.target.value); - }} - /> - - - {models - .filter((model) => isChatModel(model.id)) - .filter((model) => - model.name.toLowerCase().includes(debouncedSearchQuery.toLowerCase()) - ) - .map((model) => ( - setSettings({ ...settings, model })} - > - {settings.model.id === model.id ? ( - - ) : ( - - )} - {model.name} - - ))} - - - + );