Skip to content

Commit b1b2ca1

Browse files
authored
Merge pull request #6006 from menloresearch/feat/fav-model
🚀feat: allow user mark model as favorite
2 parents 716d516 + f58332e commit b1b2ca1

File tree

7 files changed

+194
-9
lines changed

7 files changed

+194
-9
lines changed

web-app/src/constants/localStorage.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,5 +19,6 @@ export const localStorageKey = {
1919
mcpGlobalPermissions: 'mcp-global-permissions',
2020
lastUsedModel: 'last-used-model',
2121
lastUsedAssistant: 'last-used-assistant',
22+
favoriteModels: 'favorite-models',
2223
setupCompleted: 'setup-completed',
2324
}

web-app/src/containers/DropdownModelProvider.tsx

Lines changed: 87 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ import ProvidersAvatar from '@/containers/ProvidersAvatar'
1717
import { Fzf } from 'fzf'
1818
import { localStorageKey } from '@/constants/localStorage'
1919
import { useTranslation } from '@/i18n/react-i18next-compat'
20+
import { useFavoriteModel } from '@/hooks/useFavoriteModel'
21+
import { predefinedProviders } from '@/consts/providers'
2022

2123
type DropdownModelProviderProps = {
2224
model?: ThreadModel
@@ -69,6 +71,7 @@ const DropdownModelProvider = ({
6971
const { updateCurrentThreadModel } = useThreads()
7072
const navigate = useNavigate()
7173
const { t } = useTranslation()
74+
const { favoriteModels } = useFavoriteModel()
7275

7376
// Search state
7477
const [open, setOpen] = useState(false)
@@ -151,9 +154,13 @@ const DropdownModelProvider = ({
151154

152155
provider.models.forEach((modelItem) => {
153156
// Skip models that require API key but don't have one (except llamacpp)
154-
if (provider.provider !== 'llamacpp' && !provider.api_key?.length) {
155-
return
156-
}
157+
if (
158+
provider &&
159+
predefinedProviders.some((e) =>
160+
e.provider.includes(provider.provider)
161+
) && provider.provider !== 'llamacpp' && !provider.api_key?.length
162+
)
163+
return
157164

158165
const capabilities = modelItem.capabilities || []
159166
const capabilitiesString = capabilities.join(' ')
@@ -182,6 +189,13 @@ const DropdownModelProvider = ({
182189
})
183190
}, [searchableItems])
184191

192+
// Get favorite models that are currently available
193+
const favoriteItems = useMemo(() => {
194+
return searchableItems.filter((item) =>
195+
favoriteModels.some((fav) => fav.id === item.model.id)
196+
)
197+
}, [searchableItems, favoriteModels])
198+
185199
// Filter models based on search value
186200
const filteredItems = useMemo(() => {
187201
if (!searchValue) return searchableItems
@@ -202,7 +216,7 @@ const DropdownModelProvider = ({
202216
})
203217
}, [searchableItems, searchValue, fzfInstance])
204218

205-
// Group filtered items by provider
219+
// Group filtered items by provider, excluding favorites when not searching
206220
const groupedItems = useMemo(() => {
207221
const groups: Record<string, SearchableModel[]> = {}
208222

@@ -221,11 +235,16 @@ const DropdownModelProvider = ({
221235
if (!groups[providerKey]) {
222236
groups[providerKey] = []
223237
}
238+
239+
// When not searching, exclude favorite models from regular provider sections
240+
const isFavorite = favoriteModels.some((fav) => fav.id === item.model.id)
241+
if (!searchValue && isFavorite) return // Skip adding this item to regular provider section
242+
224243
groups[providerKey].push(item)
225244
})
226245

227246
return groups
228-
}, [filteredItems, providers, searchValue])
247+
}, [filteredItems, providers, searchValue, favoriteModels])
229248

230249
const handleSelect = useCallback(
231250
(searchableModel: SearchableModel) => {
@@ -330,6 +349,64 @@ const DropdownModelProvider = ({
330349
</div>
331350
) : (
332351
<div className="py-1">
352+
{/* Favorites section - only show when not searching */}
353+
{!searchValue && favoriteItems.length > 0 && (
354+
<div className="bg-main-view-fg/2 backdrop-blur-2xl rounded-sm my-1.5 mx-1.5">
355+
{/* Favorites header */}
356+
<div className="flex items-center gap-1.5 px-2 py-1">
357+
<span className="text-sm font-medium text-main-view-fg/80">
358+
{t('common:favorites')}
359+
</span>
360+
</div>
361+
362+
{/* Favorite models */}
363+
{favoriteItems.map((searchableModel) => {
364+
const isSelected =
365+
selectedModel?.id === searchableModel.model.id &&
366+
selectedProvider === searchableModel.provider.provider
367+
const capabilities =
368+
searchableModel.model.capabilities || []
369+
370+
return (
371+
<div
372+
key={`fav-${searchableModel.value}`}
373+
title={searchableModel.model.id}
374+
onClick={() => handleSelect(searchableModel)}
375+
className={cn(
376+
'mx-1 mb-1 px-2 py-1.5 rounded-sm cursor-pointer flex items-center gap-2 transition-all duration-200',
377+
'hover:bg-main-view-fg/4',
378+
isSelected &&
379+
'bg-main-view-fg/8 hover:bg-main-view-fg/8'
380+
)}
381+
>
382+
<div className="flex items-center gap-1 flex-1 min-w-0">
383+
<div className="shrink-0 -ml-1">
384+
<ProvidersAvatar
385+
provider={searchableModel.provider}
386+
/>
387+
</div>
388+
<span className="truncate text-main-view-fg/80 text-sm">
389+
{searchableModel.model.id}
390+
</span>
391+
<div className="flex-1"></div>
392+
{capabilities.length > 0 && (
393+
<div className="flex-shrink-0 -mr-1.5">
394+
<Capabilities capabilities={capabilities} />
395+
</div>
396+
)}
397+
</div>
398+
</div>
399+
)
400+
})}
401+
</div>
402+
)}
403+
404+
{/* Divider between favorites and regular providers */}
405+
{favoriteItems.length > 0 && (
406+
<div className="border-b border-1 border-main-view-fg/8 mx-2"></div>
407+
)}
408+
409+
{/* Regular provider sections */}
333410
{Object.entries(groupedItems).map(([providerKey, models]) => {
334411
const providerInfo = providers.find(
335412
(p) => p.provider === providerKey
@@ -340,7 +417,7 @@ const DropdownModelProvider = ({
340417
return (
341418
<div
342419
key={providerKey}
343-
className="bg-main-view-fg/4 backdrop-blur-2xl first:mt-0 rounded-sm my-1.5 mx-1.5 first:mb-0"
420+
className="bg-main-view-fg/2 backdrop-blur-2xl first:mt-0 rounded-sm my-1.5 mx-1.5 first:mb-0"
344421
>
345422
{/* Provider header */}
346423
<div className="flex items-center justify-between px-2 py-1">
@@ -384,11 +461,13 @@ const DropdownModelProvider = ({
384461
return (
385462
<div
386463
key={searchableModel.value}
464+
title={searchableModel.model.id}
387465
onClick={() => handleSelect(searchableModel)}
388466
className={cn(
389467
'mx-1 mb-1 px-2 py-1.5 rounded-sm cursor-pointer flex items-center gap-2 transition-all duration-200',
390-
'hover:bg-main-view-fg/10',
391-
isSelected && 'bg-main-view-fg/15'
468+
'hover:bg-main-view-fg/4',
469+
isSelected &&
470+
'bg-main-view-fg/8 hover:bg-main-view-fg/8'
392471
)}
393472
>
394473
<div className="flex items-center gap-2 flex-1 min-w-0">
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { IconStar, IconStarFilled } from '@tabler/icons-react'
2+
import { useFavoriteModel } from '@/hooks/useFavoriteModel'
3+
4+
interface FavoriteModelActionProps {
5+
model: Model
6+
}
7+
8+
export function FavoriteModelAction({ model }: FavoriteModelActionProps) {
9+
const { isFavorite, toggleFavorite } = useFavoriteModel()
10+
const isModelFavorite = isFavorite(model.id)
11+
12+
return (
13+
<div
14+
aria-label="Toggle favorite"
15+
className="size-6 cursor-pointer flex items-center justify-center rounded hover:bg-main-view-fg/10 transition-all duration-200 ease-in-out"
16+
onClick={() => toggleFavorite(model)}
17+
>
18+
{isModelFavorite ? (
19+
<IconStarFilled size={18} className="text-main-view-fg" />
20+
) : (
21+
<IconStar size={18} className="text-main-view-fg/50" />
22+
)}
23+
</div>
24+
)
25+
}

web-app/src/containers/dialogs/DeleteModel.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { IconTrash } from '@tabler/icons-react'
1818
import { useState, useEffect } from 'react'
1919
import { toast } from 'sonner'
2020
import { useTranslation } from '@/i18n/react-i18next-compat'
21+
import { useFavoriteModel } from '@/hooks/useFavoriteModel'
2122

2223
type DialogDeleteModelProps = {
2324
provider: ModelProvider
@@ -31,8 +32,12 @@ export const DialogDeleteModel = ({
3132
const { t } = useTranslation()
3233
const [selectedModelId, setSelectedModelId] = useState<string>('')
3334
const { setProviders, deleteModel: deleteModelCache } = useModelProvider()
35+
const { removeFavorite } = useFavoriteModel()
3436

3537
const removeModel = async () => {
38+
// Remove model from favorites if it exists
39+
removeFavorite(selectedModelId)
40+
3641
deleteModelCache(selectedModelId)
3742
deleteModel(selectedModelId).then(() => {
3843
getProviders().then((providers) => {

web-app/src/containers/dialogs/DeleteProvider.tsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,15 @@ import { useRouter } from '@tanstack/react-router'
1818
import { route } from '@/constants/routes'
1919
import { useTranslation } from '@/i18n/react-i18next-compat'
2020
import { predefinedProviders } from '@/consts/providers'
21+
import { useFavoriteModel } from '@/hooks/useFavoriteModel'
2122

2223
type Props = {
2324
provider?: ProviderObject
2425
}
2526
const DeleteProvider = ({ provider }: Props) => {
2627
const { t } = useTranslation()
2728
const { deleteProvider, providers } = useModelProvider()
29+
const { favoriteModels, removeFavorite } = useFavoriteModel()
2830
const router = useRouter()
2931
if (
3032
!provider ||
@@ -34,6 +36,14 @@ const DeleteProvider = ({ provider }: Props) => {
3436
return null
3537

3638
const removeProvider = async () => {
39+
// Remove favorite models that belong to this provider
40+
const providerModelIds = provider.models.map((model) => model.id)
41+
favoriteModels.forEach((favoriteModel) => {
42+
if (providerModelIds.includes(favoriteModel.id)) {
43+
removeFavorite(favoriteModel.id)
44+
}
45+
})
46+
3747
deleteProvider(provider.provider)
3848
toast.success(t('providers:deleteProvider.title'), {
3949
id: `delete-provider-${provider.provider}`,
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { create } from 'zustand'
2+
import { persist, createJSONStorage } from 'zustand/middleware'
3+
import { localStorageKey } from '@/constants/localStorage'
4+
5+
interface FavoriteModelState {
6+
favoriteModels: Model[]
7+
addFavorite: (model: Model) => void
8+
removeFavorite: (modelId: string) => void
9+
isFavorite: (modelId: string) => boolean
10+
toggleFavorite: (model: Model) => void
11+
}
12+
13+
export const useFavoriteModel = create<FavoriteModelState>()(
14+
persist(
15+
(set, get) => ({
16+
favoriteModels: [],
17+
18+
addFavorite: (model: Model) => {
19+
set((state) => {
20+
if (!state.favoriteModels.some((fav) => fav.id === model.id)) {
21+
return {
22+
favoriteModels: [...state.favoriteModels, model],
23+
}
24+
}
25+
return state
26+
})
27+
},
28+
29+
removeFavorite: (modelId: string) => {
30+
set((state) => ({
31+
favoriteModels: state.favoriteModels.filter((model) => model.id !== modelId),
32+
}))
33+
},
34+
35+
isFavorite: (modelId: string) => {
36+
return get().favoriteModels.some((model) => model.id === modelId)
37+
},
38+
39+
toggleFavorite: (model: Model) => {
40+
const { isFavorite, addFavorite, removeFavorite } = get()
41+
if (isFavorite(model.id)) {
42+
removeFavorite(model.id)
43+
} else {
44+
addFavorite(model)
45+
}
46+
},
47+
}),
48+
{
49+
name: localStorageKey.favoriteModels,
50+
storage: createJSONStorage(() => localStorage),
51+
}
52+
)
53+
)

web-app/src/routes/settings/providers/$providerName.tsx

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import { DialogEditModel } from '@/containers/dialogs/EditModel'
2626
import { DialogAddModel } from '@/containers/dialogs/AddModel'
2727
import { ModelSetting } from '@/containers/ModelSetting'
2828
import { DialogDeleteModel } from '@/containers/dialogs/DeleteModel'
29+
import { FavoriteModelAction } from '@/containers/FavoriteModelAction'
2930
import Joyride, { CallBackProps, STATUS } from 'react-joyride'
3031
import { CustomTooltipJoyRide } from '@/containers/CustomeTooltipJoyRide'
3132
import { route } from '@/constants/routes'
@@ -554,7 +555,7 @@ function ProviderDetail() {
554555
</div>
555556
}
556557
actions={
557-
<div className="flex items-center gap-1">
558+
<div className="flex items-center gap-0.5">
558559
<DialogEditModel
559560
provider={provider}
560561
modelId={model.id}
@@ -565,6 +566,17 @@ function ProviderDetail() {
565566
model={model}
566567
/>
567568
)}
569+
{((provider &&
570+
!predefinedProviders.some(
571+
(p) => p.provider === provider.provider
572+
)) ||
573+
(provider &&
574+
predefinedProviders.some(
575+
(p) => p.provider === provider.provider
576+
) &&
577+
Boolean(provider.api_key?.length))) && (
578+
<FavoriteModelAction model={model} />
579+
)}
568580
<DialogDeleteModel
569581
provider={provider}
570582
modelId={model.id}

0 commit comments

Comments
 (0)