diff --git a/web-app/src/constants/routes.ts b/web-app/src/constants/routes.ts index db741e9e6..e9997590a 100644 --- a/web-app/src/constants/routes.ts +++ b/web-app/src/constants/routes.ts @@ -5,6 +5,7 @@ export const route = { assistant: '/assistant', settings: { index: '/settings', + model_providers: '/settings/providers', providers: '/settings/providers/$providerName', general: '/settings/general', appearance: '/settings/appearance', diff --git a/web-app/src/containers/Card.tsx b/web-app/src/containers/Card.tsx index 324b98b2d..7d68550db 100644 --- a/web-app/src/containers/Card.tsx +++ b/web-app/src/containers/Card.tsx @@ -10,43 +10,63 @@ type CardProps = { type CardItemProps = { title?: string | ReactNode description?: string | ReactNode + descriptionOutside?: string | ReactNode align?: 'start' | 'center' | 'end' actions?: ReactNode column?: boolean className?: string + classNameWrapperAction?: string } export function CardItem({ title, description, + descriptionOutside, className, + classNameWrapperAction, align = 'center', column, actions, }: CardItemProps) { return ( -
-
-

{title}

- {description && ( - - {description} - + <> +
+
+

{title}

+ {description && ( + + {description} + + )} +
+ {actions && ( +
+ {actions} +
)}
- {actions && ( -
{actions}
+ {descriptionOutside && ( + + {descriptionOutside} + )} -
+ ) } diff --git a/web-app/src/containers/ChatWidthSwitcher.tsx b/web-app/src/containers/ChatWidthSwitcher.tsx index 27cc3c69d..10417200e 100644 --- a/web-app/src/containers/ChatWidthSwitcher.tsx +++ b/web-app/src/containers/ChatWidthSwitcher.tsx @@ -9,27 +9,31 @@ export function ChatWidthSwitcher() { const { t } = useTranslation() return ( -
+
- - - - - - - - -
-
- ) -} - -export default ProvidersMenu diff --git a/web-app/src/containers/SettingsMenu.tsx b/web-app/src/containers/SettingsMenu.tsx index 5d5e3ef5b..8aea3b501 100644 --- a/web-app/src/containers/SettingsMenu.tsx +++ b/web-app/src/containers/SettingsMenu.tsx @@ -1,20 +1,56 @@ -import { Link, useMatches } from '@tanstack/react-router' +import { Link } from '@tanstack/react-router' import { route } from '@/constants/routes' import { useTranslation } from '@/i18n/react-i18next-compat' -import { useModelProvider } from '@/hooks/useModelProvider' +import { useState, useEffect } from 'react' +import { + IconChevronDown, + IconChevronRight, + IconMenu2, + IconX, +} from '@tabler/icons-react' +import { useMatches, useNavigate } from '@tanstack/react-router' +import { cn } from '@/lib/utils' + import { useGeneralSetting } from '@/hooks/useGeneralSetting' +import { useModelProvider } from '@/hooks/useModelProvider' +import { getProviderTitle } from '@/lib/utils' +import ProvidersAvatar from '@/containers/ProvidersAvatar' const SettingsMenu = () => { const { t } = useTranslation() - const { providers } = useModelProvider() - const { experimentalFeatures } = useGeneralSetting() - const firstItemProvider = - providers.length > 0 ? providers[0].provider : 'llama.cpp' + const [expandedProviders, setExpandedProviders] = useState(false) + const [isMenuOpen, setIsMenuOpen] = useState(false) const matches = useMatches() - const isActive = matches.some( + const navigate = useNavigate() + + const { experimentalFeatures } = useGeneralSetting() + const { providers } = useModelProvider() + + // Filter providers that have active API keys (or are llama.cpp which doesn't need one) + const activeProviders = providers.filter((provider) => provider.active) + + // Check if current route has a providerName parameter and expand providers submenu + useEffect(() => { + const hasProviderName = matches.some( + (match) => + match.routeId === '/settings/providers/$providerName' && + 'providerName' in match.params + ) + const isProvidersRoute = matches.some( + (match) => match.routeId === '/settings/providers/' + ) + if (hasProviderName || isProvidersRoute) { + setExpandedProviders(true) + } + }, [matches]) + + // Check if we're in the setup remote provider step + const stepSetupRemoteProvider = matches.some( (match) => - match.routeId === '/settings/providers/$providerName' && - 'providerName' in match.params + match.search && + typeof match.search === 'object' && + 'step' in match.search && + match.search.step === 'setup_remote_provider' ) const menuSettings = [ @@ -30,6 +66,11 @@ const SettingsMenu = () => { title: 'common:privacy', route: route.settings.privacy, }, + { + title: 'common:modelProviders', + route: route.settings.model_providers, + hasSubMenu: activeProviders.length > 0, + }, { title: 'common:keyboardShortcuts', route: route.settings.shortcuts, @@ -61,52 +102,113 @@ const SettingsMenu = () => { }, ] + const toggleProvidersExpansion = () => { + setExpandedProviders(!expandedProviders) + } + + const toggleMenu = () => { + setIsMenuOpen(!isMenuOpen) + } + return ( -
-
- {menuSettings.map((menu, index) => { - // Render the menu item - const menuItem = ( - - {t(menu.title)} - - ) + <> + +
+
+ {menuSettings.map((menu) => ( +
+ +
+ {t(menu.title)} + {menu.hasSubMenu && ( + + )} +
+ - if (index === 2) { - return ( -
- {menuItem} + {/* Sub-menu for model providers */} + {menu.hasSubMenu && expandedProviders && ( +
+ {activeProviders.map((provider) => { + const isActive = matches.some( + (match) => + match.routeId === '/settings/providers/$providerName' && + 'providerName' in match.params && + match.params.providerName === provider.provider + ) - {/* Model Providers Link with default parameter */} - {isActive ? ( -
- {t('common:modelProviders')} -
- ) : ( - - - {t('common:modelProviders')} - - - )} -
- ) - } - - // For other menu items, just render them normally - return menuItem - })} + return ( +
+ +
+ ) + })} +
+ )} +
+ ))} +
-
+ ) } diff --git a/web-app/src/routeTree.gen.ts b/web-app/src/routeTree.gen.ts index 52782cb2e..bbd3db391 100644 --- a/web-app/src/routeTree.gen.ts +++ b/web-app/src/routeTree.gen.ts @@ -27,6 +27,7 @@ import { Route as SettingsGeneralImport } from './routes/settings/general' import { Route as SettingsExtensionsImport } from './routes/settings/extensions' import { Route as SettingsAppearanceImport } from './routes/settings/appearance' import { Route as LocalApiServerLogsImport } from './routes/local-api-server/logs' +import { Route as SettingsProvidersIndexImport } from './routes/settings/providers/index' import { Route as SettingsProvidersProviderNameImport } from './routes/settings/providers/$providerName' // Create/Update Routes @@ -127,6 +128,12 @@ const LocalApiServerLogsRoute = LocalApiServerLogsImport.update({ getParentRoute: () => rootRoute, } as any) +const SettingsProvidersIndexRoute = SettingsProvidersIndexImport.update({ + id: '/settings/providers/', + path: '/settings/providers/', + getParentRoute: () => rootRoute, +} as any) + const SettingsProvidersProviderNameRoute = SettingsProvidersProviderNameImport.update({ id: '/settings/providers/$providerName', @@ -257,6 +264,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof SettingsProvidersProviderNameImport parentRoute: typeof rootRoute } + '/settings/providers/': { + id: '/settings/providers/' + path: '/settings/providers' + fullPath: '/settings/providers' + preLoaderRoute: typeof SettingsProvidersIndexImport + parentRoute: typeof rootRoute + } } } @@ -280,6 +294,7 @@ export interface FileRoutesByFullPath { '/settings/shortcuts': typeof SettingsShortcutsRoute '/threads/$threadId': typeof ThreadsThreadIdRoute '/settings/providers/$providerName': typeof SettingsProvidersProviderNameRoute + '/settings/providers': typeof SettingsProvidersIndexRoute } export interface FileRoutesByTo { @@ -300,6 +315,7 @@ export interface FileRoutesByTo { '/settings/shortcuts': typeof SettingsShortcutsRoute '/threads/$threadId': typeof ThreadsThreadIdRoute '/settings/providers/$providerName': typeof SettingsProvidersProviderNameRoute + '/settings/providers': typeof SettingsProvidersIndexRoute } export interface FileRoutesById { @@ -321,6 +337,7 @@ export interface FileRoutesById { '/settings/shortcuts': typeof SettingsShortcutsRoute '/threads/$threadId': typeof ThreadsThreadIdRoute '/settings/providers/$providerName': typeof SettingsProvidersProviderNameRoute + '/settings/providers/': typeof SettingsProvidersIndexRoute } export interface FileRouteTypes { @@ -343,6 +360,7 @@ export interface FileRouteTypes { | '/settings/shortcuts' | '/threads/$threadId' | '/settings/providers/$providerName' + | '/settings/providers' fileRoutesByTo: FileRoutesByTo to: | '/' @@ -362,6 +380,7 @@ export interface FileRouteTypes { | '/settings/shortcuts' | '/threads/$threadId' | '/settings/providers/$providerName' + | '/settings/providers' id: | '__root__' | '/' @@ -381,6 +400,7 @@ export interface FileRouteTypes { | '/settings/shortcuts' | '/threads/$threadId' | '/settings/providers/$providerName' + | '/settings/providers/' fileRoutesById: FileRoutesById } @@ -402,6 +422,7 @@ export interface RootRouteChildren { SettingsShortcutsRoute: typeof SettingsShortcutsRoute ThreadsThreadIdRoute: typeof ThreadsThreadIdRoute SettingsProvidersProviderNameRoute: typeof SettingsProvidersProviderNameRoute + SettingsProvidersIndexRoute: typeof SettingsProvidersIndexRoute } const rootRouteChildren: RootRouteChildren = { @@ -422,6 +443,7 @@ const rootRouteChildren: RootRouteChildren = { SettingsShortcutsRoute: SettingsShortcutsRoute, ThreadsThreadIdRoute: ThreadsThreadIdRoute, SettingsProvidersProviderNameRoute: SettingsProvidersProviderNameRoute, + SettingsProvidersIndexRoute: SettingsProvidersIndexRoute, } export const routeTree = rootRoute @@ -450,7 +472,8 @@ export const routeTree = rootRoute "/settings/privacy", "/settings/shortcuts", "/threads/$threadId", - "/settings/providers/$providerName" + "/settings/providers/$providerName", + "/settings/providers/" ] }, "/": { @@ -503,6 +526,9 @@ export const routeTree = rootRoute }, "/settings/providers/$providerName": { "filePath": "settings/providers/$providerName.tsx" + }, + "/settings/providers/": { + "filePath": "settings/providers/index.tsx" } } } diff --git a/web-app/src/routes/settings/appearance.tsx b/web-app/src/routes/settings/appearance.tsx index df0da98fa..3cba3eed5 100644 --- a/web-app/src/routes/settings/appearance.tsx +++ b/web-app/src/routes/settings/appearance.tsx @@ -35,7 +35,7 @@ function Appareances() {

{t('common:settings')}

-
+
@@ -55,26 +55,31 @@ function Appareances() { } /> } /> } /> } /> } />

{t('common:settings')}

-
+
@@ -222,6 +222,7 @@ function General() { @@ -273,13 +275,15 @@ function General() { })}   -
- - {janDataFolder} - +
+
+ + {janDataFolder} + +
} - description={ + descriptionOutside={
{t('mcp-servers:command')}: {config.command} diff --git a/web-app/src/routes/settings/providers/$providerName.tsx b/web-app/src/routes/settings/providers/$providerName.tsx index 60254cf65..d15260908 100644 --- a/web-app/src/routes/settings/providers/$providerName.tsx +++ b/web-app/src/routes/settings/providers/$providerName.tsx @@ -1,9 +1,8 @@ import { Card, CardItem } from '@/containers/Card' import HeaderPage from '@/containers/HeaderPage' -import ProvidersMenu from '@/containers/ProvidersMenu' +import SettingsMenu from '@/containers/SettingsMenu' import { useModelProvider } from '@/hooks/useModelProvider' import { cn, getProviderTitle } from '@/lib/utils' -import { Switch } from '@/components/ui/switch' import { open } from '@tauri-apps/plugin-dialog' import { getActiveModels, @@ -228,23 +227,13 @@ function ProviderDetail() {

{t('common:settings')}

-
- -
+

{getProviderTitle(providerName)}

- { - if (provider) { - updateProvider(providerName, { ...provider, active: e }) - } - }} - />
-

{model.id}

+

+ {model.id} +

} diff --git a/web-app/src/routes/settings/providers/index.tsx b/web-app/src/routes/settings/providers/index.tsx new file mode 100644 index 000000000..94c01865b --- /dev/null +++ b/web-app/src/routes/settings/providers/index.tsx @@ -0,0 +1,187 @@ +import { createFileRoute } from '@tanstack/react-router' +import { route } from '@/constants/routes' +import SettingsMenu from '@/containers/SettingsMenu' +import HeaderPage from '@/containers/HeaderPage' +import { Button } from '@/components/ui/button' +import { Card, CardItem } from '@/containers/Card' +import { useTranslation } from '@/i18n/react-i18next-compat' +import { useModelProvider } from '@/hooks/useModelProvider' +import { useNavigate } from '@tanstack/react-router' +import { IconCirclePlus, IconSettings } from '@tabler/icons-react' +import { getProviderTitle } from '@/lib/utils' +import ProvidersAvatar from '@/containers/ProvidersAvatar' +import { + Dialog, + DialogClose, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@/components/ui/dialog' +import { Input } from '@/components/ui/input' +import { Switch } from '@/components/ui/switch' +import { useCallback, useState } from 'react' +import { openAIProviderSettings } from '@/mock/data' +import cloneDeep from 'lodash/cloneDeep' +import { toast } from 'sonner' + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export const Route = createFileRoute(route.settings.model_providers as any)({ + component: ModelProviders, +}) + +function ModelProviders() { + const { t } = useTranslation() + const { providers, addProvider, updateProvider } = useModelProvider() + const navigate = useNavigate() + const [name, setName] = useState('') + + const createProvider = useCallback(() => { + if (providers.some((e) => e.provider === name)) { + toast.error(t('providerAlreadyExists', { name })) + return + } + const newProvider = { + provider: name, + active: true, + models: [], + settings: cloneDeep(openAIProviderSettings) as ProviderSetting[], + api_key: '', + base_url: 'https://api.openai.com/v1', + } + addProvider(newProvider) + setTimeout(() => { + navigate({ + to: route.settings.providers, + params: { + providerName: name, + }, + }) + }, 0) + }, [providers, name, addProvider, t, navigate]) + + return ( +
+ +

{t('common:settings')}

+
+
+ +
+
+ {/* Model Providers */} + + + {t('common:modelProviders')} + + + + + + + + + {t('provider:addOpenAIProvider')} + + setName(e.target.value)} + className="mt-2" + placeholder={t('provider:enterNameForProvider')} + onKeyDown={(e) => { + // Prevent key from being captured by parent components + e.stopPropagation() + }} + /> + + + + + + + + + + + +
+ } + > + {providers.map((provider, index) => ( + + +
+

+ {getProviderTitle(provider.provider)} +

+

+ {provider.models.length} Models +

+
+
+ } + actions={ +
+ {provider.active && ( + + )} + { + updateProvider(provider.provider, { + ...provider, + active: e, + }) + }} + /> +
+ } + /> + ))} + +
+
+
+
+ ) +}