diff --git a/src/components/Option/Settings/general-settings.tsx b/src/components/Option/Settings/general-settings.tsx
index 7ceba76..f615236 100644
--- a/src/components/Option/Settings/general-settings.tsx
+++ b/src/components/Option/Settings/general-settings.tsx
@@ -16,13 +16,12 @@ import {
import { useStorage } from "@plasmohq/storage/hook"
export const GeneralSettings = () => {
- const { clearChat } =
- useMessageOption()
+ const { clearChat } = useMessageOption()
- const [ speechToTextLanguage, setSpeechToTextLanguage ] = useStorage(
- "speechToTextLanguage",
- "en-US"
- )
+ const [speechToTextLanguage, setSpeechToTextLanguage] = useStorage(
+ "speechToTextLanguage",
+ "en-US"
+ )
const [copilotResumeLastChat, setCopilotResumeLastChat] = useStorage(
"copilotResumeLastChat",
false
@@ -41,6 +40,11 @@ export const GeneralSettings = () => {
const [sendNotificationAfterIndexing, setSendNotificationAfterIndexing] =
useStorage("sendNotificationAfterIndexing", false)
+ const [checkOllamaStatus, setCheckOllamaStatus] = useStorage(
+ "checkOllamaStatus",
+ true
+ )
+
const queryClient = useQueryClient()
const { mode, toggleDarkMode } = useDarkMode()
@@ -160,6 +164,19 @@ export const GeneralSettings = () => {
/>
+
+
+
+ {t("generalSettings.settings.ollamaStatus.label")}
+
+
+
+
setCheckOllamaStatus(checked)}
+ />
+
+
{t("generalSettings.settings.darkMode.label")}
diff --git a/src/components/Select/LoadingIndicator.tsx b/src/components/Select/LoadingIndicator.tsx
new file mode 100644
index 0000000..dc06722
--- /dev/null
+++ b/src/components/Select/LoadingIndicator.tsx
@@ -0,0 +1,27 @@
+import React from "react"
+
+export const LoadingIndicator: React.FC<{ className?: string }> = ({
+ className = ""
+}) => (
+
+)
diff --git a/src/components/Select/index.tsx b/src/components/Select/index.tsx
new file mode 100644
index 0000000..34d205f
--- /dev/null
+++ b/src/components/Select/index.tsx
@@ -0,0 +1,333 @@
+import React, { useState, useRef, useEffect, useMemo } from "react"
+import { Search, RotateCw, ChevronDown } from "lucide-react"
+import { LoadingIndicator } from "./LoadingIndicator"
+import { Empty } from "antd"
+
+export interface SelectOption {
+ label: string | JSX.Element
+ value: string
+}
+
+interface SelectProps {
+ options: SelectOption[]
+ value?: string
+ onChange: (option: SelectOption) => void
+ placeholder?: string
+ onRefresh?: () => void
+ className?: string
+ dropdownClassName?: string
+ optionClassName?: string
+ searchClassName?: string
+ disabled?: boolean
+ isLoading?: boolean
+ loadingText?: string
+ filterOption?: (input: string, option: SelectOption) => boolean
+}
+
+export const PageAssistSelect: React.FC = ({
+ options = [],
+ value,
+ onChange,
+ placeholder = "Select an option",
+ onRefresh,
+ className = "",
+ dropdownClassName = "",
+ optionClassName = "",
+ searchClassName = "",
+ disabled = false,
+ isLoading = false,
+ loadingText = "Loading...",
+ filterOption
+}) => {
+ const [isOpen, setIsOpen] = useState(false)
+ const [searchTerm, setSearchTerm] = useState("")
+ const [filteredOptions, setFilteredOptions] = useState([])
+ const containerRef = useRef(null)
+ const optionsContainerRef = useRef(null)
+ const [activeIndex, setActiveIndex] = useState(-1)
+
+ useEffect(() => {
+ const handleClickOutside = (event: MouseEvent) => {
+ if (
+ containerRef.current &&
+ !containerRef.current.contains(event.target as Node)
+ ) {
+ setIsOpen(false)
+ }
+ }
+
+ document.addEventListener("mousedown", handleClickOutside)
+ return () => document.removeEventListener("mousedown", handleClickOutside)
+ }, [])
+
+ useEffect(() => {
+ try {
+ if (isOpen && optionsContainerRef.current && value) {
+ const selectedOptionElement = optionsContainerRef.current.querySelector(
+ `[data-value="${value}"]`
+ )
+ if (selectedOptionElement) {
+ selectedOptionElement.scrollIntoView({ block: "nearest" })
+ }
+ }
+ } catch (error) {
+ console.error("Error scrolling to selected option:", error)
+ }
+ }, [isOpen, value])
+
+ useEffect(() => {
+ if (!options) return
+
+ const filtered = options.filter((option) => {
+ if (!searchTerm) return true
+
+ if (filterOption) {
+ return filterOption(searchTerm, option)
+ }
+
+ if (typeof option.label === "string") {
+ return option.label.toLowerCase().includes(searchTerm.toLowerCase())
+ }
+
+ if (React.isValidElement(option.label)) {
+ const textContent = extractTextFromJSX(option.label)
+ return textContent.toLowerCase().includes(searchTerm.toLowerCase())
+ }
+
+ return false
+ })
+ setFilteredOptions(filtered)
+ setActiveIndex(-1)
+ }, [searchTerm, options, filterOption])
+
+ const extractTextFromJSX = (element: React.ReactElement): string => {
+ if (typeof element.props.children === "string") {
+ return element.props.children
+ }
+
+ if (Array.isArray(element.props.children)) {
+ return element.props.children
+ .map((child) => {
+ if (typeof child === "string") return child
+ if (React.isValidElement(child)) return extractTextFromJSX(child)
+ return ""
+ })
+ .join(" ")
+ }
+
+ if (React.isValidElement(element.props.children)) {
+ return extractTextFromJSX(element.props.children)
+ }
+
+ return ""
+ }
+
+ const handleRefresh = (e: React.MouseEvent) => {
+ e.stopPropagation()
+ onRefresh?.()
+ }
+
+ const handleKeyDown = (e: React.KeyboardEvent) => {
+ if (disabled || isLoading) return
+
+ switch (e.key) {
+ case "Enter":
+ if (isOpen && activeIndex >= 0) {
+ e.preventDefault()
+ const selectedOption = filteredOptions[activeIndex]
+ if (selectedOption) {
+ onChange(selectedOption)
+ setIsOpen(false)
+ setSearchTerm("")
+ }
+ } else {
+ setIsOpen(!isOpen)
+ }
+ break
+ case " ":
+ if (!isOpen) {
+ e.preventDefault()
+ setIsOpen(true)
+ }
+ break
+ case "Escape":
+ setIsOpen(false)
+ break
+ case "ArrowDown":
+ e.preventDefault()
+ if (!isOpen) {
+ setIsOpen(true)
+ } else {
+ setActiveIndex((prev) =>
+ prev < filteredOptions.length - 1 ? prev + 1 : prev
+ )
+ }
+ break
+ case "ArrowUp":
+ e.preventDefault()
+ setActiveIndex((prev) => (prev > 0 ? prev - 1 : prev))
+ break
+ }
+ }
+
+ const defaultSelectClass = `
+ flex items-center justify-between p-2.5 rounded-lg border
+ ${disabled || isLoading ? "cursor-not-allowed opacity-50" : "cursor-pointer"}
+ ${isOpen ? "ring-2 ring-blue-500" : ""}
+ bg-transparent border-gray-200 text-gray-900
+ transition-all duration-200
+ dark:text-white
+ dark:border-[#353534]
+ `
+
+ const defaultDropdownClass = `
+ absolute z-50 w-full mt-1 bg-white dark:bg-[#1e1e1f] dark:text-white rounded-lg shadow-lg
+ border border-gray-200 dark:border-[#353534]
+ `
+
+ const defaultSearchClass = `
+ w-full pl-8 pr-8 py-1.5 rounded-md
+ bg-gray-50 border border-gray-200
+ focus:outline-none focus:ring-2 focus:ring-gray-100
+ text-gray-900
+ dark:bg-[#1e1e1f] dark:text-white
+ dark:border-[#353534]
+ dark:focus:ring-gray-700
+ dark:focus:border-gray-700
+ dark:placeholder-gray-400
+ dark:bg-opacity-90
+ dark:hover:bg-opacity-100
+ dark:focus:bg-opacity-100
+ dark:hover:border-gray-700
+ dark:hover:bg-[#2a2a2b]
+ dark:focus:bg-[#2a2a2b]
+ `
+
+ const defaultOptionClass = `
+ p-2 cursor-pointer transition-colors duration-150
+ `
+
+ const selectedOption = useMemo(() => {
+ if (!value || !options) return null
+ return options?.find((opt) => opt.value === value)
+ }, [value, options])
+
+ if (!options) {
+ return (
+
+ )
+ }
+
+ return (
+
+
!disabled && !isLoading && setIsOpen(!isOpen)}
+ onKeyDown={handleKeyDown}
+ className={`${defaultSelectClass} ${className}`}>
+
+ {isLoading && }
+ {isLoading ? (
+ loadingText
+ ) : selectedOption ? (
+ selectedOption.label
+ ) : (
+
+ {placeholder}
+
+ )}
+
+
+
+
+ {isOpen && (
+
+
+
+ setSearchTerm(e.target.value)}
+ placeholder="Search..."
+ className={`${defaultSearchClass} ${searchClassName}`}
+ disabled={isLoading}
+ aria-label="Search options"
+ />
+
+ {onRefresh && (
+
+ )}{" "}
+
+
+
+
+ {isLoading ? (
+
+
+ {loadingText}
+
+ ) : filteredOptions.length === 0 ? (
+
+
+
+ ) : (
+ filteredOptions.map((option, index) => (
+
{
+ onChange(option)
+ setIsOpen(false)
+ setSearchTerm("")
+ }}
+ className={`
+ ${defaultOptionClass}
+ ${value === option.value ? "bg-blue-50 dark:bg-[#262627]" : "hover:bg-gray-100 dark:hover:bg-[#272728]"}
+ ${activeIndex === index ? "bg-gray-100 dark:bg-[#272728]" : ""}
+ ${optionClassName}`}>
+ {option.label}
+
+ ))
+ )}
+
+
+ )}
+
+ )
+}
diff --git a/src/components/Sidepanel/Chat/empty.tsx b/src/components/Sidepanel/Chat/empty.tsx
index f94304b..cf38d1a 100644
--- a/src/components/Sidepanel/Chat/empty.tsx
+++ b/src/components/Sidepanel/Chat/empty.tsx
@@ -1,4 +1,5 @@
import { cleanUrl } from "@/libs/clean-url"
+import { useStorage } from "@plasmohq/storage/hook"
import { useQuery, useQueryClient } from "@tanstack/react-query"
import { Select } from "antd"
import { RotateCcw } from "lucide-react"
@@ -17,13 +18,15 @@ export const EmptySidePanel = () => {
const [ollamaURL, setOllamaURL] = useState("")
const { t } = useTranslation(["playground", "common"])
const queryClient = useQueryClient()
+ const [checkOllamaStatus] = useStorage("checkOllamaStatus", true)
+
const {
data: ollamaInfo,
status: ollamaStatus,
refetch,
isRefetching
} = useQuery({
- queryKey: ["ollamaStatus"],
+ queryKey: ["ollamaStatus", checkOllamaStatus],
queryFn: async () => {
const ollamaURL = await getOllamaURL()
const isOk = await isOllamaRunning()
@@ -32,7 +35,7 @@ export const EmptySidePanel = () => {
queryKey: ["getAllModelsForSelect"]
})
return {
- isOk,
+ isOk: checkOllamaStatus ? isOk : true,
models,
ollamaURL
}
@@ -59,7 +62,7 @@ export const EmptySidePanel = () => {
)}
- {!isRefetching && ollamaStatus === "success" ? (
+ {!isRefetching && ollamaStatus === "success" && checkOllamaStatus ? (
ollamaInfo.isOk ? (
diff --git a/src/components/Sidepanel/Chat/form.tsx b/src/components/Sidepanel/Chat/form.tsx
index 618431d..a402680 100644
--- a/src/components/Sidepanel/Chat/form.tsx
+++ b/src/components/Sidepanel/Chat/form.tsx
@@ -7,7 +7,14 @@ import { toBase64 } from "~/libs/to-base64"
import { Checkbox, Dropdown, Image, Switch, Tooltip } from "antd"
import { useWebUI } from "~/store/webui"
import { defaultEmbeddingModelForRag } from "~/services/ollama"
-import { ImageIcon, MicIcon, StopCircleIcon, X } from "lucide-react"
+import {
+ ImageIcon,
+ MicIcon,
+ StopCircleIcon,
+ X,
+ EyeIcon,
+ EyeOffIcon
+} from "lucide-react"
import { useTranslation } from "react-i18next"
import { ModelSelect } from "@/components/Common/ModelSelect"
import { useSpeechRecognition } from "@/hooks/useSpeechRecognition"
@@ -36,7 +43,7 @@ export const SidepanelForm = ({ dropedFile }: Props) => {
resetTranscript,
start: startListening,
stop: stopSpeechRecognition,
- supported: browserSupportsSpeechRecognition,
+ supported: browserSupportsSpeechRecognition
} = useSpeechRecognition()
const stopListening = async () => {
@@ -237,7 +244,10 @@ export const SidepanelForm = ({ dropedFile }: Props) => {
}
}
await stopListening()
- if (value.message.trim().length === 0 && value.image.length === 0) {
+ if (
+ value.message.trim().length === 0 &&
+ value.image.length === 0
+ ) {
return
}
form.reset()
@@ -281,20 +291,22 @@ export const SidepanelForm = ({ dropedFile }: Props) => {
{...form.getInputProps("message")}
/>
-
-
-
+ {chatMode !== "vision" && (
+
+
+
+ )}
{browserSupportsSpeechRecognition && (
@@ -323,13 +335,35 @@ export const SidepanelForm = ({ dropedFile }: Props) => {
)}
+
+
+