🚀feat: allow user mark model as favorite

This commit is contained in:
Faisal Amir 2025-07-31 15:37:06 +07:00
parent 99567a1102
commit e3ba37ba15
7 changed files with 197 additions and 8 deletions

View File

@ -19,4 +19,5 @@ export const localStorageKey = {
mcpGlobalPermissions: 'mcp-global-permissions',
lastUsedModel: 'last-used-model',
lastUsedAssistant: 'last-used-assistant',
favoriteModels: 'favorite-models',
}

View File

@ -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">

View 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>
)
}

View File

@ -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) => {

View File

@ -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}`,

View 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),
}
)
)

View File

@ -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}