feat: Add page assist select component to header
This commit introduces a new `PageAssistSelect` component to the header, which replaces the previous `Select` component for selecting the active chat model. The new component provides improved functionality, including: - Ability to display provider icons alongside the model name - Truncation of long model names to ensure they fit within the available space - Improved loading state handling - Ability to refresh the model list on demand These changes enhance the user experience and make it easier for users to quickly select the desired chat model.
This commit is contained in:
parent
4292dc45ea
commit
c4d9e3aeed
@ -22,6 +22,7 @@ import { getAllPrompts } from "@/db"
|
||||
import { ShareBtn } from "~/components/Common/ShareBtn"
|
||||
import { ProviderIcons } from "../Common/ProviderIcon"
|
||||
import { NewChat } from "./NewChat"
|
||||
import { PageAssistSelect } from "../Select"
|
||||
type Props = {
|
||||
setSidebarOpen: (open: boolean) => void
|
||||
setOpenModelSettings: (open: boolean) => void
|
||||
@ -49,14 +50,10 @@ export const Header: React.FC<Props> = ({
|
||||
historyId,
|
||||
temporaryChat
|
||||
} = useMessageOption()
|
||||
const {
|
||||
data: models,
|
||||
isLoading: isModelsLoading,
|
||||
} = useQuery({
|
||||
const { data: models, isLoading: isModelsLoading, refetch } = useQuery({
|
||||
queryKey: ["fetchModel"],
|
||||
queryFn: () => fetchChatModels({ returnEmpty: true }),
|
||||
refetchInterval: 15_000,
|
||||
refetchIntervalInBackground: true,
|
||||
refetchIntervalInBackground: false,
|
||||
placeholderData: (prev) => prev
|
||||
})
|
||||
|
||||
@ -87,9 +84,10 @@ export const Header: React.FC<Props> = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`sticky top-0 z-[999] flex h-16 p-3 bg-gray-50 border-b dark:bg-[#171717] dark:border-gray-600 ${
|
||||
temporaryChat && "!bg-gray-200 dark:!bg-black"
|
||||
}`}>
|
||||
<div
|
||||
className={`sticky top-0 z-[999] flex h-16 p-3 bg-gray-50 border-b dark:bg-[#171717] dark:border-gray-600 ${
|
||||
temporaryChat && "!bg-gray-200 dark:!bg-black"
|
||||
}`}>
|
||||
<div className="flex gap-2 items-center">
|
||||
{pathname !== "/" && (
|
||||
<div>
|
||||
@ -107,41 +105,38 @@ export const Header: React.FC<Props> = ({
|
||||
<PanelLeftIcon className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
<NewChat
|
||||
clearChat={clearChat}
|
||||
/>
|
||||
<NewChat clearChat={clearChat} />
|
||||
<span className="text-lg font-thin text-zinc-300 dark:text-zinc-600">
|
||||
{"/"}
|
||||
</span>
|
||||
<div className="hidden lg:block">
|
||||
<Select
|
||||
<PageAssistSelect
|
||||
className="w-80"
|
||||
placeholder={t("common:selectAModel")}
|
||||
value={selectedModel}
|
||||
onChange={(e) => {
|
||||
setSelectedModel(e)
|
||||
localStorage.setItem("selectedModel", e)
|
||||
setSelectedModel(e.value)
|
||||
localStorage.setItem("selectedModel", e.value)
|
||||
}}
|
||||
size="large"
|
||||
loading={isModelsLoading}
|
||||
filterOption={(input, option) =>
|
||||
option.label.key.toLowerCase().indexOf(input.toLowerCase()) >= 0
|
||||
}
|
||||
showSearch
|
||||
placeholder={t("common:selectAModel")}
|
||||
className="w-72"
|
||||
isLoading={isModelsLoading}
|
||||
options={models?.map((model) => ({
|
||||
label: (
|
||||
<span
|
||||
key={model.model}
|
||||
className="flex flex-row gap-3 items-center truncate">
|
||||
className="flex flex-row gap-3 items-center ">
|
||||
<ProviderIcons
|
||||
provider={model?.provider}
|
||||
className="w-5 h-5"
|
||||
/>
|
||||
<span className="truncate">{model.name}</span>
|
||||
<span className="line-clamp-2">{model.name}</span>
|
||||
</span>
|
||||
),
|
||||
value: model.model
|
||||
}))}
|
||||
|
||||
onRefresh={() => {
|
||||
refetch()
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="lg:hidden">
|
||||
|
27
src/components/Select/LoadingIndicator.tsx
Normal file
27
src/components/Select/LoadingIndicator.tsx
Normal file
@ -0,0 +1,27 @@
|
||||
import React from "react"
|
||||
|
||||
export const LoadingIndicator: React.FC<{ className?: string }> = ({
|
||||
className = ""
|
||||
}) => (
|
||||
<div className={`animate-spin ${className}`}>
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
)
|
303
src/components/Select/index.tsx
Normal file
303
src/components/Select/index.tsx
Normal file
@ -0,0 +1,303 @@
|
||||
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<SelectProps> = ({
|
||||
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<SelectOption[]>([])
|
||||
const containerRef = useRef<HTMLDivElement>(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(() => {
|
||||
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 (
|
||||
<div className={`relative w-full ${className}`}>
|
||||
<div className={`${defaultSelectClass} ${className}`}>
|
||||
<LoadingIndicator />
|
||||
<span>{loadingText}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className={`relative w-full ${className}`}>
|
||||
<div
|
||||
role="combobox"
|
||||
aria-expanded={isOpen}
|
||||
aria-haspopup="listbox"
|
||||
aria-controls="select-dropdown"
|
||||
aria-label={placeholder}
|
||||
tabIndex={disabled ? -1 : 0}
|
||||
onClick={() => !disabled && !isLoading && setIsOpen(!isOpen)}
|
||||
onKeyDown={handleKeyDown}
|
||||
className={`${defaultSelectClass} ${className}`}>
|
||||
<span className="!truncate flex items-center gap-2 ">
|
||||
{isLoading && <LoadingIndicator />}
|
||||
{isLoading ? loadingText : selectedOption ? selectedOption.label : <span className="dark:text-gray-400 text-sm">{placeholder}</span>}
|
||||
</span>
|
||||
<ChevronDown
|
||||
aria-hidden="true"
|
||||
className={`w-4 h-4 transition-transform duration-200 ${
|
||||
isOpen ? "transform rotate-180" : ""
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isOpen && (
|
||||
<div
|
||||
id="select-dropdown"
|
||||
role="listbox"
|
||||
className={`${defaultDropdownClass} ${dropdownClassName}`}>
|
||||
<div className="p-2 border-b border-gray-200 dark:border-[#353534]">
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
placeholder="Search..."
|
||||
className={`${defaultSearchClass} ${searchClassName}`}
|
||||
disabled={isLoading}
|
||||
aria-label="Search options"
|
||||
/>
|
||||
<Search aria-hidden="true" className="absolute left-2 top-1/2 transform -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
{onRefresh && (
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
disabled={isLoading}
|
||||
aria-label="Refresh options"
|
||||
className={`absolute right-2 top-1/2 transform -translate-y-1/2
|
||||
hover:text-blue-500 dark:hover:text-blue-400 transition-colors duration-200
|
||||
${isLoading ? "cursor-not-allowed opacity-50" : ""}`}>
|
||||
<RotateCw
|
||||
aria-hidden="true"
|
||||
className={`w-4 h-4 dark:text-gray-400 ${isLoading ? "animate-spin" : ""}`}
|
||||
/>
|
||||
</button>
|
||||
)}{" "}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-h-60 overflow-y-auto custom-scrollbar">
|
||||
{isLoading ? (
|
||||
<div className="p-4 text-center text-gray-500 flex items-center justify-center gap-2">
|
||||
<LoadingIndicator />
|
||||
<span>{loadingText}</span>
|
||||
</div>
|
||||
) : filteredOptions.length === 0 ? (
|
||||
<div className="p-6">
|
||||
<Empty />
|
||||
</div>
|
||||
) : (
|
||||
filteredOptions.map((option, index) => (
|
||||
<div
|
||||
key={option.value}
|
||||
role="option"
|
||||
aria-selected={value === option.value}
|
||||
onClick={() => {
|
||||
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}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user