🚀feat: allow user mark model as favorite
This commit is contained in:
parent
99567a1102
commit
e3ba37ba15
@ -19,4 +19,5 @@ export const localStorageKey = {
|
||||
mcpGlobalPermissions: 'mcp-global-permissions',
|
||||
lastUsedModel: 'last-used-model',
|
||||
lastUsedAssistant: 'last-used-assistant',
|
||||
favoriteModels: 'favorite-models',
|
||||
}
|
||||
|
||||
@ -17,6 +17,8 @@ import ProvidersAvatar from '@/containers/ProvidersAvatar'
|
||||
import { Fzf } from 'fzf'
|
||||
import { localStorageKey } from '@/constants/localStorage'
|
||||
import { useTranslation } from '@/i18n/react-i18next-compat'
|
||||
import { useFavoriteModel } from '@/hooks/useFavoriteModel'
|
||||
import { predefinedProviders } from '@/consts/providers'
|
||||
|
||||
type DropdownModelProviderProps = {
|
||||
model?: ThreadModel
|
||||
@ -69,6 +71,7 @@ const DropdownModelProvider = ({
|
||||
const { updateCurrentThreadModel } = useThreads()
|
||||
const navigate = useNavigate()
|
||||
const { t } = useTranslation()
|
||||
const { favoriteModels } = useFavoriteModel()
|
||||
|
||||
// Search state
|
||||
const [open, setOpen] = useState(false)
|
||||
@ -151,8 +154,15 @@ const DropdownModelProvider = ({
|
||||
|
||||
provider.models.forEach((modelItem) => {
|
||||
// Skip models that require API key but don't have one (except llamacpp)
|
||||
if (provider.provider !== 'llamacpp' && !provider.api_key?.length) {
|
||||
return
|
||||
if (
|
||||
provider &&
|
||||
predefinedProviders.some((e) =>
|
||||
e.provider.includes(provider.provider)
|
||||
)
|
||||
) {
|
||||
if (provider.provider !== 'llamacpp' && !provider.api_key?.length) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const capabilities = modelItem.capabilities || []
|
||||
@ -182,6 +192,13 @@ const DropdownModelProvider = ({
|
||||
})
|
||||
}, [searchableItems])
|
||||
|
||||
// Get favorite models that are currently available
|
||||
const favoriteItems = useMemo(() => {
|
||||
return searchableItems.filter((item) =>
|
||||
favoriteModels.some((fav) => fav.id === item.model.id)
|
||||
)
|
||||
}, [searchableItems, favoriteModels])
|
||||
|
||||
// Filter models based on search value
|
||||
const filteredItems = useMemo(() => {
|
||||
if (!searchValue) return searchableItems
|
||||
@ -202,7 +219,7 @@ const DropdownModelProvider = ({
|
||||
})
|
||||
}, [searchableItems, searchValue, fzfInstance])
|
||||
|
||||
// Group filtered items by provider
|
||||
// Group filtered items by provider, excluding favorites when not searching
|
||||
const groupedItems = useMemo(() => {
|
||||
const groups: Record<string, SearchableModel[]> = {}
|
||||
|
||||
@ -221,11 +238,18 @@ const DropdownModelProvider = ({
|
||||
if (!groups[providerKey]) {
|
||||
groups[providerKey] = []
|
||||
}
|
||||
|
||||
// When not searching, exclude favorite models from regular provider sections
|
||||
const isFavorite = favoriteModels.some((fav) => fav.id === item.model.id)
|
||||
if (!searchValue && isFavorite) {
|
||||
return // Skip adding this item to regular provider section
|
||||
}
|
||||
|
||||
groups[providerKey].push(item)
|
||||
})
|
||||
|
||||
return groups
|
||||
}, [filteredItems, providers, searchValue])
|
||||
}, [filteredItems, providers, searchValue, favoriteModels])
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(searchableModel: SearchableModel) => {
|
||||
@ -330,6 +354,64 @@ const DropdownModelProvider = ({
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-1">
|
||||
{/* Favorites section - only show when not searching */}
|
||||
{!searchValue && favoriteItems.length > 0 && (
|
||||
<div className="bg-main-view-fg/2 backdrop-blur-2xl rounded-sm my-1.5 mx-1.5">
|
||||
{/* Favorites header */}
|
||||
<div className="flex items-center gap-1.5 px-2 py-1">
|
||||
<span className="text-sm font-medium text-main-view-fg/80">
|
||||
{t('common:favorites')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Favorite models */}
|
||||
{favoriteItems.map((searchableModel) => {
|
||||
const isSelected =
|
||||
selectedModel?.id === searchableModel.model.id &&
|
||||
selectedProvider === searchableModel.provider.provider
|
||||
const capabilities =
|
||||
searchableModel.model.capabilities || []
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`fav-${searchableModel.value}`}
|
||||
title={searchableModel.model.id}
|
||||
onClick={() => handleSelect(searchableModel)}
|
||||
className={cn(
|
||||
'mx-1 mb-1 px-2 py-1.5 rounded-sm cursor-pointer flex items-center gap-2 transition-all duration-200',
|
||||
'hover:bg-main-view-fg/4',
|
||||
isSelected &&
|
||||
'bg-main-view-fg/8 hover:bg-main-view-fg/8'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-1 flex-1 min-w-0">
|
||||
<div className="shrink-0 -ml-1">
|
||||
<ProvidersAvatar
|
||||
provider={searchableModel.provider}
|
||||
/>
|
||||
</div>
|
||||
<span className="truncate text-main-view-fg/80 text-sm">
|
||||
{searchableModel.model.id}
|
||||
</span>
|
||||
<div className="flex-1"></div>
|
||||
{capabilities.length > 0 && (
|
||||
<div className="flex-shrink-0 -mr-1.5">
|
||||
<Capabilities capabilities={capabilities} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Divider between favorites and regular providers */}
|
||||
{favoriteItems.length > 0 && (
|
||||
<div className="border-b border-1 border-main-view-fg/8 mx-2"></div>
|
||||
)}
|
||||
|
||||
{/* Regular provider sections */}
|
||||
{Object.entries(groupedItems).map(([providerKey, models]) => {
|
||||
const providerInfo = providers.find(
|
||||
(p) => p.provider === providerKey
|
||||
@ -340,7 +422,7 @@ const DropdownModelProvider = ({
|
||||
return (
|
||||
<div
|
||||
key={providerKey}
|
||||
className="bg-main-view-fg/4 backdrop-blur-2xl first:mt-0 rounded-sm my-1.5 mx-1.5 first:mb-0"
|
||||
className="bg-main-view-fg/2 backdrop-blur-2xl first:mt-0 rounded-sm my-1.5 mx-1.5 first:mb-0"
|
||||
>
|
||||
{/* Provider header */}
|
||||
<div className="flex items-center justify-between px-2 py-1">
|
||||
@ -384,11 +466,13 @@ const DropdownModelProvider = ({
|
||||
return (
|
||||
<div
|
||||
key={searchableModel.value}
|
||||
title={searchableModel.model.id}
|
||||
onClick={() => handleSelect(searchableModel)}
|
||||
className={cn(
|
||||
'mx-1 mb-1 px-2 py-1.5 rounded-sm cursor-pointer flex items-center gap-2 transition-all duration-200',
|
||||
'hover:bg-main-view-fg/10',
|
||||
isSelected && 'bg-main-view-fg/15'
|
||||
'hover:bg-main-view-fg/4',
|
||||
isSelected &&
|
||||
'bg-main-view-fg/8 hover:bg-main-view-fg/8'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
|
||||
24
web-app/src/containers/FavoriteModelAction.tsx
Normal file
24
web-app/src/containers/FavoriteModelAction.tsx
Normal file
@ -0,0 +1,24 @@
|
||||
import { IconHeart, IconHeartFilled } from '@tabler/icons-react'
|
||||
import { useFavoriteModel } from '@/hooks/useFavoriteModel'
|
||||
|
||||
interface FavoriteModelActionProps {
|
||||
model: Model
|
||||
}
|
||||
|
||||
export function FavoriteModelAction({ model }: FavoriteModelActionProps) {
|
||||
const { isFavorite, toggleFavorite } = useFavoriteModel()
|
||||
const isModelFavorite = isFavorite(model.id)
|
||||
|
||||
return (
|
||||
<div
|
||||
className="size-6 cursor-pointer flex items-center justify-center rounded hover:bg-main-view-fg/10 transition-all duration-200 ease-in-out"
|
||||
onClick={() => toggleFavorite(model)}
|
||||
>
|
||||
{isModelFavorite ? (
|
||||
<IconHeartFilled size={18} className="text-main-view-fg" />
|
||||
) : (
|
||||
<IconHeart size={18} className="text-main-view-fg/50" />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -18,6 +18,7 @@ import { IconTrash } from '@tabler/icons-react'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { useTranslation } from '@/i18n/react-i18next-compat'
|
||||
import { useFavoriteModel } from '@/hooks/useFavoriteModel'
|
||||
|
||||
type DialogDeleteModelProps = {
|
||||
provider: ModelProvider
|
||||
@ -31,8 +32,12 @@ export const DialogDeleteModel = ({
|
||||
const { t } = useTranslation()
|
||||
const [selectedModelId, setSelectedModelId] = useState<string>('')
|
||||
const { setProviders, deleteModel: deleteModelCache } = useModelProvider()
|
||||
const { removeFavorite } = useFavoriteModel()
|
||||
|
||||
const removeModel = async () => {
|
||||
// Remove model from favorites if it exists
|
||||
removeFavorite(selectedModelId)
|
||||
|
||||
deleteModelCache(selectedModelId)
|
||||
deleteModel(selectedModelId).then(() => {
|
||||
getProviders().then((providers) => {
|
||||
|
||||
@ -18,6 +18,7 @@ import { useRouter } from '@tanstack/react-router'
|
||||
import { route } from '@/constants/routes'
|
||||
import { useTranslation } from '@/i18n/react-i18next-compat'
|
||||
import { predefinedProviders } from '@/consts/providers'
|
||||
import { useFavoriteModel } from '@/hooks/useFavoriteModel'
|
||||
|
||||
type Props = {
|
||||
provider?: ProviderObject
|
||||
@ -25,6 +26,7 @@ type Props = {
|
||||
const DeleteProvider = ({ provider }: Props) => {
|
||||
const { t } = useTranslation()
|
||||
const { deleteProvider, providers } = useModelProvider()
|
||||
const { favoriteModels, removeFavorite } = useFavoriteModel()
|
||||
const router = useRouter()
|
||||
if (
|
||||
!provider ||
|
||||
@ -34,6 +36,14 @@ const DeleteProvider = ({ provider }: Props) => {
|
||||
return null
|
||||
|
||||
const removeProvider = async () => {
|
||||
// Remove favorite models that belong to this provider
|
||||
const providerModelIds = provider.models.map((model) => model.id)
|
||||
favoriteModels.forEach((favoriteModel) => {
|
||||
if (providerModelIds.includes(favoriteModel.id)) {
|
||||
removeFavorite(favoriteModel.id)
|
||||
}
|
||||
})
|
||||
|
||||
deleteProvider(provider.provider)
|
||||
toast.success(t('providers:deleteProvider.title'), {
|
||||
id: `delete-provider-${provider.provider}`,
|
||||
|
||||
53
web-app/src/hooks/useFavoriteModel.ts
Normal file
53
web-app/src/hooks/useFavoriteModel.ts
Normal file
@ -0,0 +1,53 @@
|
||||
import { create } from 'zustand'
|
||||
import { persist, createJSONStorage } from 'zustand/middleware'
|
||||
import { localStorageKey } from '@/constants/localStorage'
|
||||
|
||||
interface FavoriteModelState {
|
||||
favoriteModels: Model[]
|
||||
addFavorite: (model: Model) => void
|
||||
removeFavorite: (modelId: string) => void
|
||||
isFavorite: (modelId: string) => boolean
|
||||
toggleFavorite: (model: Model) => void
|
||||
}
|
||||
|
||||
export const useFavoriteModel = create<FavoriteModelState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
favoriteModels: [],
|
||||
|
||||
addFavorite: (model: Model) => {
|
||||
set((state) => {
|
||||
if (!state.favoriteModels.some((fav) => fav.id === model.id)) {
|
||||
return {
|
||||
favoriteModels: [...state.favoriteModels, model],
|
||||
}
|
||||
}
|
||||
return state
|
||||
})
|
||||
},
|
||||
|
||||
removeFavorite: (modelId: string) => {
|
||||
set((state) => ({
|
||||
favoriteModels: state.favoriteModels.filter((model) => model.id !== modelId),
|
||||
}))
|
||||
},
|
||||
|
||||
isFavorite: (modelId: string) => {
|
||||
return get().favoriteModels.some((model) => model.id === modelId)
|
||||
},
|
||||
|
||||
toggleFavorite: (model: Model) => {
|
||||
const { isFavorite, addFavorite, removeFavorite } = get()
|
||||
if (isFavorite(model.id)) {
|
||||
removeFavorite(model.id)
|
||||
} else {
|
||||
addFavorite(model)
|
||||
}
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: localStorageKey.favoriteModels,
|
||||
storage: createJSONStorage(() => localStorage),
|
||||
}
|
||||
)
|
||||
)
|
||||
@ -27,6 +27,7 @@ import { DialogEditModel } from '@/containers/dialogs/EditModel'
|
||||
import { DialogAddModel } from '@/containers/dialogs/AddModel'
|
||||
import { ModelSetting } from '@/containers/ModelSetting'
|
||||
import { DialogDeleteModel } from '@/containers/dialogs/DeleteModel'
|
||||
import { FavoriteModelAction } from '@/containers/FavoriteModelAction'
|
||||
import Joyride, { CallBackProps, STATUS } from 'react-joyride'
|
||||
import { CustomTooltipJoyRide } from '@/containers/CustomeTooltipJoyRide'
|
||||
import { route } from '@/constants/routes'
|
||||
@ -539,7 +540,7 @@ function ProviderDetail() {
|
||||
</div>
|
||||
}
|
||||
actions={
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="flex items-center gap-0.5">
|
||||
<DialogEditModel
|
||||
provider={provider}
|
||||
modelId={model.id}
|
||||
@ -550,6 +551,17 @@ function ProviderDetail() {
|
||||
model={model}
|
||||
/>
|
||||
)}
|
||||
{((provider &&
|
||||
!predefinedProviders.some(
|
||||
(p) => p.provider === provider.provider
|
||||
)) ||
|
||||
(provider &&
|
||||
predefinedProviders.some(
|
||||
(p) => p.provider === provider.provider
|
||||
) &&
|
||||
Boolean(provider.api_key?.length))) && (
|
||||
<FavoriteModelAction model={model} />
|
||||
)}
|
||||
<DialogDeleteModel
|
||||
provider={provider}
|
||||
modelId={model.id}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user