🚀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',
|
mcpGlobalPermissions: 'mcp-global-permissions',
|
||||||
lastUsedModel: 'last-used-model',
|
lastUsedModel: 'last-used-model',
|
||||||
lastUsedAssistant: 'last-used-assistant',
|
lastUsedAssistant: 'last-used-assistant',
|
||||||
|
favoriteModels: 'favorite-models',
|
||||||
}
|
}
|
||||||
|
|||||||
@ -17,6 +17,8 @@ import ProvidersAvatar from '@/containers/ProvidersAvatar'
|
|||||||
import { Fzf } from 'fzf'
|
import { Fzf } from 'fzf'
|
||||||
import { localStorageKey } from '@/constants/localStorage'
|
import { localStorageKey } from '@/constants/localStorage'
|
||||||
import { useTranslation } from '@/i18n/react-i18next-compat'
|
import { useTranslation } from '@/i18n/react-i18next-compat'
|
||||||
|
import { useFavoriteModel } from '@/hooks/useFavoriteModel'
|
||||||
|
import { predefinedProviders } from '@/consts/providers'
|
||||||
|
|
||||||
type DropdownModelProviderProps = {
|
type DropdownModelProviderProps = {
|
||||||
model?: ThreadModel
|
model?: ThreadModel
|
||||||
@ -69,6 +71,7 @@ const DropdownModelProvider = ({
|
|||||||
const { updateCurrentThreadModel } = useThreads()
|
const { updateCurrentThreadModel } = useThreads()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
const { favoriteModels } = useFavoriteModel()
|
||||||
|
|
||||||
// Search state
|
// Search state
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
@ -151,8 +154,15 @@ const DropdownModelProvider = ({
|
|||||||
|
|
||||||
provider.models.forEach((modelItem) => {
|
provider.models.forEach((modelItem) => {
|
||||||
// Skip models that require API key but don't have one (except llamacpp)
|
// Skip models that require API key but don't have one (except llamacpp)
|
||||||
if (provider.provider !== 'llamacpp' && !provider.api_key?.length) {
|
if (
|
||||||
return
|
provider &&
|
||||||
|
predefinedProviders.some((e) =>
|
||||||
|
e.provider.includes(provider.provider)
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
if (provider.provider !== 'llamacpp' && !provider.api_key?.length) {
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const capabilities = modelItem.capabilities || []
|
const capabilities = modelItem.capabilities || []
|
||||||
@ -182,6 +192,13 @@ const DropdownModelProvider = ({
|
|||||||
})
|
})
|
||||||
}, [searchableItems])
|
}, [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
|
// Filter models based on search value
|
||||||
const filteredItems = useMemo(() => {
|
const filteredItems = useMemo(() => {
|
||||||
if (!searchValue) return searchableItems
|
if (!searchValue) return searchableItems
|
||||||
@ -202,7 +219,7 @@ const DropdownModelProvider = ({
|
|||||||
})
|
})
|
||||||
}, [searchableItems, searchValue, fzfInstance])
|
}, [searchableItems, searchValue, fzfInstance])
|
||||||
|
|
||||||
// Group filtered items by provider
|
// Group filtered items by provider, excluding favorites when not searching
|
||||||
const groupedItems = useMemo(() => {
|
const groupedItems = useMemo(() => {
|
||||||
const groups: Record<string, SearchableModel[]> = {}
|
const groups: Record<string, SearchableModel[]> = {}
|
||||||
|
|
||||||
@ -221,11 +238,18 @@ const DropdownModelProvider = ({
|
|||||||
if (!groups[providerKey]) {
|
if (!groups[providerKey]) {
|
||||||
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)
|
groups[providerKey].push(item)
|
||||||
})
|
})
|
||||||
|
|
||||||
return groups
|
return groups
|
||||||
}, [filteredItems, providers, searchValue])
|
}, [filteredItems, providers, searchValue, favoriteModels])
|
||||||
|
|
||||||
const handleSelect = useCallback(
|
const handleSelect = useCallback(
|
||||||
(searchableModel: SearchableModel) => {
|
(searchableModel: SearchableModel) => {
|
||||||
@ -330,6 +354,64 @@ const DropdownModelProvider = ({
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="py-1">
|
<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]) => {
|
{Object.entries(groupedItems).map(([providerKey, models]) => {
|
||||||
const providerInfo = providers.find(
|
const providerInfo = providers.find(
|
||||||
(p) => p.provider === providerKey
|
(p) => p.provider === providerKey
|
||||||
@ -340,7 +422,7 @@ const DropdownModelProvider = ({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={providerKey}
|
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 */}
|
{/* Provider header */}
|
||||||
<div className="flex items-center justify-between px-2 py-1">
|
<div className="flex items-center justify-between px-2 py-1">
|
||||||
@ -384,11 +466,13 @@ const DropdownModelProvider = ({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={searchableModel.value}
|
key={searchableModel.value}
|
||||||
|
title={searchableModel.model.id}
|
||||||
onClick={() => handleSelect(searchableModel)}
|
onClick={() => handleSelect(searchableModel)}
|
||||||
className={cn(
|
className={cn(
|
||||||
'mx-1 mb-1 px-2 py-1.5 rounded-sm cursor-pointer flex items-center gap-2 transition-all duration-200',
|
'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',
|
'hover:bg-main-view-fg/4',
|
||||||
isSelected && 'bg-main-view-fg/15'
|
isSelected &&
|
||||||
|
'bg-main-view-fg/8 hover:bg-main-view-fg/8'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
<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 { useState, useEffect } from 'react'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { useTranslation } from '@/i18n/react-i18next-compat'
|
import { useTranslation } from '@/i18n/react-i18next-compat'
|
||||||
|
import { useFavoriteModel } from '@/hooks/useFavoriteModel'
|
||||||
|
|
||||||
type DialogDeleteModelProps = {
|
type DialogDeleteModelProps = {
|
||||||
provider: ModelProvider
|
provider: ModelProvider
|
||||||
@ -31,8 +32,12 @@ export const DialogDeleteModel = ({
|
|||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const [selectedModelId, setSelectedModelId] = useState<string>('')
|
const [selectedModelId, setSelectedModelId] = useState<string>('')
|
||||||
const { setProviders, deleteModel: deleteModelCache } = useModelProvider()
|
const { setProviders, deleteModel: deleteModelCache } = useModelProvider()
|
||||||
|
const { removeFavorite } = useFavoriteModel()
|
||||||
|
|
||||||
const removeModel = async () => {
|
const removeModel = async () => {
|
||||||
|
// Remove model from favorites if it exists
|
||||||
|
removeFavorite(selectedModelId)
|
||||||
|
|
||||||
deleteModelCache(selectedModelId)
|
deleteModelCache(selectedModelId)
|
||||||
deleteModel(selectedModelId).then(() => {
|
deleteModel(selectedModelId).then(() => {
|
||||||
getProviders().then((providers) => {
|
getProviders().then((providers) => {
|
||||||
|
|||||||
@ -18,6 +18,7 @@ import { useRouter } from '@tanstack/react-router'
|
|||||||
import { route } from '@/constants/routes'
|
import { route } from '@/constants/routes'
|
||||||
import { useTranslation } from '@/i18n/react-i18next-compat'
|
import { useTranslation } from '@/i18n/react-i18next-compat'
|
||||||
import { predefinedProviders } from '@/consts/providers'
|
import { predefinedProviders } from '@/consts/providers'
|
||||||
|
import { useFavoriteModel } from '@/hooks/useFavoriteModel'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
provider?: ProviderObject
|
provider?: ProviderObject
|
||||||
@ -25,6 +26,7 @@ type Props = {
|
|||||||
const DeleteProvider = ({ provider }: Props) => {
|
const DeleteProvider = ({ provider }: Props) => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { deleteProvider, providers } = useModelProvider()
|
const { deleteProvider, providers } = useModelProvider()
|
||||||
|
const { favoriteModels, removeFavorite } = useFavoriteModel()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
if (
|
if (
|
||||||
!provider ||
|
!provider ||
|
||||||
@ -34,6 +36,14 @@ const DeleteProvider = ({ provider }: Props) => {
|
|||||||
return null
|
return null
|
||||||
|
|
||||||
const removeProvider = async () => {
|
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)
|
deleteProvider(provider.provider)
|
||||||
toast.success(t('providers:deleteProvider.title'), {
|
toast.success(t('providers:deleteProvider.title'), {
|
||||||
id: `delete-provider-${provider.provider}`,
|
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 { DialogAddModel } from '@/containers/dialogs/AddModel'
|
||||||
import { ModelSetting } from '@/containers/ModelSetting'
|
import { ModelSetting } from '@/containers/ModelSetting'
|
||||||
import { DialogDeleteModel } from '@/containers/dialogs/DeleteModel'
|
import { DialogDeleteModel } from '@/containers/dialogs/DeleteModel'
|
||||||
|
import { FavoriteModelAction } from '@/containers/FavoriteModelAction'
|
||||||
import Joyride, { CallBackProps, STATUS } from 'react-joyride'
|
import Joyride, { CallBackProps, STATUS } from 'react-joyride'
|
||||||
import { CustomTooltipJoyRide } from '@/containers/CustomeTooltipJoyRide'
|
import { CustomTooltipJoyRide } from '@/containers/CustomeTooltipJoyRide'
|
||||||
import { route } from '@/constants/routes'
|
import { route } from '@/constants/routes'
|
||||||
@ -539,7 +540,7 @@ function ProviderDetail() {
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
actions={
|
actions={
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-0.5">
|
||||||
<DialogEditModel
|
<DialogEditModel
|
||||||
provider={provider}
|
provider={provider}
|
||||||
modelId={model.id}
|
modelId={model.id}
|
||||||
@ -550,6 +551,17 @@ function ProviderDetail() {
|
|||||||
model={model}
|
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
|
<DialogDeleteModel
|
||||||
provider={provider}
|
provider={provider}
|
||||||
modelId={model.id}
|
modelId={model.id}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user