Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions web-app/src/constants/localStorage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,5 +19,6 @@ export const localStorageKey = {
mcpGlobalPermissions: 'mcp-global-permissions',
lastUsedModel: 'last-used-model',
lastUsedAssistant: 'last-used-assistant',
favoriteModels: 'favorite-models',
setupCompleted: 'setup-completed',
}
95 changes: 87 additions & 8 deletions web-app/src/containers/DropdownModelProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@
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
Expand All @@ -33,25 +35,25 @@

// Helper functions for localStorage
const getLastUsedModel = (): { provider: string; model: string } | null => {
try {
const stored = localStorage.getItem(localStorageKey.lastUsedModel)
return stored ? JSON.parse(stored) : null
} catch (error) {
console.debug('Failed to get last used model from localStorage:', error)
return null
}
}

Check warning on line 45 in web-app/src/containers/DropdownModelProvider.tsx

View workflow job for this annotation

GitHub Actions / coverage-check

38-45 lines are not covered with tests

const setLastUsedModel = (provider: string, model: string) => {
try {
localStorage.setItem(
localStorageKey.lastUsedModel,
JSON.stringify({ provider, model })
)
} catch (error) {
console.debug('Failed to set last used model in localStorage:', error)
}
}

Check warning on line 56 in web-app/src/containers/DropdownModelProvider.tsx

View workflow job for this annotation

GitHub Actions / coverage-check

48-56 lines are not covered with tests

const DropdownModelProvider = ({
model,
Expand All @@ -69,6 +71,7 @@
const { updateCurrentThreadModel } = useThreads()
const navigate = useNavigate()
const { t } = useTranslation()
const { favoriteModels } = useFavoriteModel()

// Search state
const [open, setOpen] = useState(false)
Expand All @@ -79,29 +82,29 @@
useEffect(() => {
// Auto select model when existing thread is passed
if (model) {
selectModelProvider(model?.provider as string, model?.id as string)

Check warning on line 85 in web-app/src/containers/DropdownModelProvider.tsx

View workflow job for this annotation

GitHub Actions / coverage-check

85 line is not covered with tests
} else if (useLastUsedModel) {
// Try to use last used model only when explicitly requested (for new chat)
const lastUsed = getLastUsedModel()
if (lastUsed) {

Check warning on line 89 in web-app/src/containers/DropdownModelProvider.tsx

View workflow job for this annotation

GitHub Actions / coverage-check

88-89 lines are not covered with tests
// Verify the last used model still exists
const provider = providers.find(
(p) => p.provider === lastUsed.provider && p.active
)
const modelExists = provider?.models.find(
(m) => m.id === lastUsed.model
)

Check warning on line 96 in web-app/src/containers/DropdownModelProvider.tsx

View workflow job for this annotation

GitHub Actions / coverage-check

91-96 lines are not covered with tests

if (provider && modelExists) {
selectModelProvider(lastUsed.provider, lastUsed.model)
} else {

Check warning on line 100 in web-app/src/containers/DropdownModelProvider.tsx

View workflow job for this annotation

GitHub Actions / coverage-check

98-100 lines are not covered with tests
// Fallback to default model if last used model no longer exists
selectModelProvider('llamacpp', 'llama3.2:3b')
}
} else {

Check warning on line 104 in web-app/src/containers/DropdownModelProvider.tsx

View workflow job for this annotation

GitHub Actions / coverage-check

102-104 lines are not covered with tests
// default model, we should add from setting
selectModelProvider('llamacpp', 'llama3.2:3b')
}

Check warning on line 107 in web-app/src/containers/DropdownModelProvider.tsx

View workflow job for this annotation

GitHub Actions / coverage-check

106-107 lines are not covered with tests
} else {
// default model for non-new-chat contexts
selectModelProvider('llamacpp', 'llama3.2:3b')
Expand All @@ -125,15 +128,15 @@

// Reset search value when dropdown closes
const onOpenChange = useCallback((open: boolean) => {
setOpen(open)
if (!open) {
requestAnimationFrame(() => setSearchValue(''))
} else {

Check warning on line 134 in web-app/src/containers/DropdownModelProvider.tsx

View workflow job for this annotation

GitHub Actions / coverage-check

131-134 lines are not covered with tests
// Focus search input when opening
setTimeout(() => {
searchInputRef.current?.focus()
}, 100)
}

Check warning on line 139 in web-app/src/containers/DropdownModelProvider.tsx

View workflow job for this annotation

GitHub Actions / coverage-check

136-139 lines are not covered with tests
}, [])

// Clear search and focus input
Expand All @@ -151,9 +154,13 @@

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)
) && provider.provider !== 'llamacpp' && !provider.api_key?.length
)
return

const capabilities = modelItem.capabilities || []
const capabilitiesString = capabilities.join(' ')
Expand Down Expand Up @@ -182,6 +189,13 @@
})
}, [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
Expand All @@ -202,7 +216,7 @@
})
}, [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[]> = {}

Expand All @@ -221,11 +235,16 @@
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) => {
Expand Down Expand Up @@ -330,6 +349,64 @@
</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
Expand All @@ -340,7 +417,7 @@
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">
Expand Down Expand Up @@ -384,11 +461,13 @@
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">
Expand Down
25 changes: 25 additions & 0 deletions web-app/src/containers/FavoriteModelAction.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { IconStar, IconStarFilled } 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
aria-label="Toggle favorite"
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 ? (
<IconStarFilled size={18} className="text-main-view-fg" />
) : (
<IconStar size={18} className="text-main-view-fg/50" />
)}
</div>
)
}
5 changes: 5 additions & 0 deletions web-app/src/containers/dialogs/DeleteModel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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) => {
Expand Down
10 changes: 10 additions & 0 deletions web-app/src/containers/dialogs/DeleteProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,15 @@ 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
}
const DeleteProvider = ({ provider }: Props) => {
const { t } = useTranslation()
const { deleteProvider, providers } = useModelProvider()
const { favoriteModels, removeFavorite } = useFavoriteModel()
const router = useRouter()
if (
!provider ||
Expand All @@ -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}`,
Expand Down
53 changes: 53 additions & 0 deletions web-app/src/hooks/useFavoriteModel.ts
Original file line number Diff line number Diff line change
@@ -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),
}
)
)
14 changes: 13 additions & 1 deletion web-app/src/routes/settings/providers/$providerName.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,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'
Expand Down Expand Up @@ -536,7 +537,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}
Expand All @@ -547,6 +548,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}
Expand Down
Loading