enhancement: setting responsive (#5615)

* feat: setting responsive

* 🧹cleanup: feeback PR

* 🧹cleanup: unused className

* 🧹cleanup: unused props
This commit is contained in:
Faisal Amir 2025-07-01 09:44:32 +07:00 committed by GitHub
parent 5918c9cd6f
commit 662879bb5d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 456 additions and 256 deletions

View File

@ -5,6 +5,7 @@ export const route = {
assistant: '/assistant', assistant: '/assistant',
settings: { settings: {
index: '/settings', index: '/settings',
model_providers: '/settings/providers',
providers: '/settings/providers/$providerName', providers: '/settings/providers/$providerName',
general: '/settings/general', general: '/settings/general',
appearance: '/settings/appearance', appearance: '/settings/appearance',

View File

@ -10,29 +10,35 @@ type CardProps = {
type CardItemProps = { type CardItemProps = {
title?: string | ReactNode title?: string | ReactNode
description?: string | ReactNode description?: string | ReactNode
descriptionOutside?: string | ReactNode
align?: 'start' | 'center' | 'end' align?: 'start' | 'center' | 'end'
actions?: ReactNode actions?: ReactNode
column?: boolean column?: boolean
className?: string className?: string
classNameWrapperAction?: string
} }
export function CardItem({ export function CardItem({
title, title,
description, description,
descriptionOutside,
className, className,
classNameWrapperAction,
align = 'center', align = 'center',
column, column,
actions, actions,
}: CardItemProps) { }: CardItemProps) {
return ( return (
<>
<div <div
className={cn( className={cn(
'flex justify-between mt-2 first:mt-0 border-b border-main-view-fg/5 pb-3 last:border-none last:pb-0 gap-8', 'flex justify-between mt-2 first:mt-0 border-b border-main-view-fg/5 pb-3 last:border-none last:pb-0 gap-8',
className, descriptionOutside && 'border-0',
align === 'start' && 'items-start', align === 'start' && 'items-start',
align === 'center' && 'items-center', align === 'center' && 'items-center',
align === 'end' && 'items-end', align === 'end' && 'items-end',
column && 'flex-col gap-y-0 items-start' column && 'flex-col gap-y-0 items-start',
className
)} )}
> >
<div className="space-y-1.5"> <div className="space-y-1.5">
@ -44,9 +50,23 @@ export function CardItem({
)} )}
</div> </div>
{actions && ( {actions && (
<div className={cn('shrink-0', column && 'w-full')}>{actions}</div> <div
className={cn(
'shrink-0',
classNameWrapperAction,
column && 'w-full'
)}
>
{actions}
</div>
)} )}
</div> </div>
{descriptionOutside && (
<span className="text-main-view-fg/70 leading-normal">
{descriptionOutside}
</span>
)}
</>
) )
} }

View File

@ -9,27 +9,31 @@ export function ChatWidthSwitcher() {
const { t } = useTranslation() const { t } = useTranslation()
return ( return (
<div className="flex gap-4"> <div className="flex flex-col sm:flex-row sm:gap-4">
<button <button
className={cn( className={cn(
'w-full overflow-hidden border border-main-view-fg/10 rounded-md my-2 pb-2 cursor-pointer', 'w-full overflow-hidden border border-main-view-fg/10 rounded-md my-2 pb-2 cursor-pointer ',
chatWidth === 'compact' && 'border-accent' chatWidth === 'compact' && 'border-accent'
)} )}
onClick={() => setChatWidth('compact')} onClick={() => setChatWidth('compact')}
> >
<div className="flex items-center justify-between px-4 py-2 bg-main-view-fg/10"> <div className="flex items-center justify-between px-4 py-2 bg-main-view-fg/10">
<span className="font-medium text-xs font-sans">{t('common:compactWidth')}</span> <span className="font-medium text-xs font-sans">
{t('common:compactWidth')}
</span>
{chatWidth === 'compact' && ( {chatWidth === 'compact' && (
<IconCircleCheckFilled className="size-4 text-accent" /> <IconCircleCheckFilled className="size-4 text-accent" />
)} )}
</div> </div>
<div className="overflow-auto p-2"> <div className="overflow-auto p-2">
<div className="flex flex-col px-10 gap-2 mt-2"> <div className="flex flex-col px-6 gap-2 mt-2">
<Skeleton className="h-2 w-full rounded-full" /> <Skeleton className="h-2 w-full rounded-full" />
<Skeleton className="h-2 w-full rounded-full" /> <Skeleton className="h-2 w-full rounded-full" />
<Skeleton className="h-2 w-full rounded-full" /> <Skeleton className="h-2 w-full rounded-full" />
<div className="bg-main-view-fg/10 h-8 px-4 w-full flex-shrink-0 border-none resize-none outline-0 rounded-2xl flex items-center"> <div className="bg-main-view-fg/10 h-8 px-4 w-full flex-shrink-0 border-none resize-none outline-0 rounded-sm flex items-center truncate">
<span className="text-main-view-fg/50">{t('common:placeholder.chatInput')}</span> <span className="text-main-view-fg/50 line-clamp-1">
{t('common:placeholder.chatInput')}
</span>
</div> </div>
</div> </div>
</div> </div>
@ -42,7 +46,9 @@ export function ChatWidthSwitcher() {
onClick={() => setChatWidth('full')} onClick={() => setChatWidth('full')}
> >
<div className="flex items-center justify-between px-4 py-2 bg-main-view-fg/10"> <div className="flex items-center justify-between px-4 py-2 bg-main-view-fg/10">
<span className="font-medium text-xs font-sans">{t('common:fullWidth')}</span> <span className="font-medium text-xs font-sans">
{t('common:fullWidth')}
</span>
{chatWidth === 'full' && ( {chatWidth === 'full' && (
<IconCircleCheckFilled className="size-4 text-accent" /> <IconCircleCheckFilled className="size-4 text-accent" />
)} )}
@ -52,8 +58,10 @@ export function ChatWidthSwitcher() {
<Skeleton className="h-2 w-full rounded-full" /> <Skeleton className="h-2 w-full rounded-full" />
<Skeleton className="h-2 w-full rounded-full" /> <Skeleton className="h-2 w-full rounded-full" />
<Skeleton className="h-2 w-full rounded-full" /> <Skeleton className="h-2 w-full rounded-full" />
<div className="bg-main-view-fg/10 h-8 px-4 w-full flex-shrink-0 border-none resize-none outline-0 rounded-2xl flex items-center"> <div className="bg-main-view-fg/10 h-8 px-4 w-full flex-shrink-0 border-none resize-none outline-0 rounded-sm flex items-center">
<span className="text-main-view-fg/50">{t('common:placeholder.chatInput')}</span> <span className="text-main-view-fg/50">
{t('common:placeholder.chatInput')}
</span>
</div> </div>
</div> </div>
</div> </div>

View File

@ -1,152 +0,0 @@
import { route } from '@/constants/routes'
import { useModelProvider } from '@/hooks/useModelProvider'
import { cn, getProviderTitle } from '@/lib/utils'
import { useNavigate, useMatches, Link } from '@tanstack/react-router'
import { IconArrowLeft, IconCirclePlus } from '@tabler/icons-react'
import {
Dialog,
DialogClose,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
import { useCallback, useState } from 'react'
import { openAIProviderSettings } from '@/mock/data'
import ProvidersAvatar from '@/containers/ProvidersAvatar'
import cloneDeep from 'lodash/cloneDeep'
import { toast } from 'sonner'
import { useTranslation } from '@/i18n/react-i18next-compat'
const ProvidersMenu = ({
stepSetupRemoteProvider,
}: {
stepSetupRemoteProvider: boolean
}) => {
const { providers, addProvider } = useModelProvider()
const navigate = useNavigate()
const matches = useMatches()
const [name, setName] = useState('')
const { t } = useTranslation()
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 (
<div className="w-44 py-2 border-r border-main-view-fg/5 pb-10 overflow-y-auto">
<Link to={route.settings.general}>
<div className="flex items-center gap-0.5 ml-3 mb-4 mt-1">
<IconArrowLeft size={16} className="text-main-view-fg/70" />
<span className="text-main-view-fg/80">{t('common:back')}</span>
</div>
</Link>
<div className="first-step-setup-remote-provider">
{providers.map((provider, index) => {
const isActive = matches.some(
(match) =>
match.routeId === '/settings/providers/$providerName' &&
'providerName' in match.params &&
match.params.providerName === provider.provider
)
return (
<div key={index} className="flex flex-col px-2 my-1.5 ">
<div
className={cn(
'flex px-2 items-center gap-1.5 cursor-pointer hover:bg-main-view-fg/5 py-1 w-full rounded [&.active]:bg-main-view-fg/5 text-main-view-fg/80',
isActive && 'bg-main-view-fg/5',
// hidden for llama.cpp provider for setup remote provider
provider.provider === 'llama.cpp' &&
stepSetupRemoteProvider &&
'hidden'
)}
onClick={() =>
navigate({
to: route.settings.providers,
params: {
providerName: provider.provider,
},
...(stepSetupRemoteProvider
? { search: { step: 'setup_remote_provider' } }
: {}),
})
}
>
<ProvidersAvatar provider={provider} />
<div className="truncate">
<span>{getProviderTitle(provider.provider)}</span>
</div>
</div>
</div>
)
})}
<Dialog>
<DialogTrigger asChild>
<div className="flex cursor-pointer px-4 my-1.5 items-center gap-1.5 text-main-view-fg/80">
<IconCirclePlus size={18} />
<span>{t('provider:addProvider')}</span>
</div>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('provider:addOpenAIProvider')}</DialogTitle>
<Input
value={name}
onChange={(e) => setName(e.target.value)}
className="mt-2"
placeholder={t('provider:enterNameForProvider')}
onKeyDown={(e) => {
// Prevent key from being captured by parent components
e.stopPropagation()
}}
/>
<DialogFooter className="mt-2 flex items-center">
<DialogClose asChild>
<Button
variant="link"
size="sm"
className="hover:no-underline"
>
{t('common:cancel')}
</Button>
</DialogClose>
<DialogClose asChild>
<Button disabled={!name} onClick={createProvider}>
{t('common:create')}
</Button>
</DialogClose>
</DialogFooter>
</DialogHeader>
</DialogContent>
</Dialog>
</div>
</div>
)
}
export default ProvidersMenu

View File

@ -1,21 +1,57 @@
import { Link, useMatches } from '@tanstack/react-router' import { Link } 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 { 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 { useGeneralSetting } from '@/hooks/useGeneralSetting'
import { useModelProvider } from '@/hooks/useModelProvider'
import { getProviderTitle } from '@/lib/utils'
import ProvidersAvatar from '@/containers/ProvidersAvatar'
const SettingsMenu = () => { const SettingsMenu = () => {
const { t } = useTranslation() const { t } = useTranslation()
const { providers } = useModelProvider() const [expandedProviders, setExpandedProviders] = useState(false)
const { experimentalFeatures } = useGeneralSetting() const [isMenuOpen, setIsMenuOpen] = useState(false)
const firstItemProvider =
providers.length > 0 ? providers[0].provider : 'llama.cpp'
const matches = useMatches() 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) =>
match.routeId === '/settings/providers/$providerName' && match.routeId === '/settings/providers/$providerName' &&
'providerName' in match.params '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.search &&
typeof match.search === 'object' &&
'step' in match.search &&
match.search.step === 'setup_remote_provider'
)
const menuSettings = [ const menuSettings = [
{ {
@ -30,6 +66,11 @@ const SettingsMenu = () => {
title: 'common:privacy', title: 'common:privacy',
route: route.settings.privacy, route: route.settings.privacy,
}, },
{
title: 'common:modelProviders',
route: route.settings.model_providers,
hasSubMenu: activeProviders.length > 0,
},
{ {
title: 'common:keyboardShortcuts', title: 'common:keyboardShortcuts',
route: route.settings.shortcuts, route: route.settings.shortcuts,
@ -61,52 +102,113 @@ const SettingsMenu = () => {
}, },
] ]
const toggleProvidersExpansion = () => {
setExpandedProviders(!expandedProviders)
}
const toggleMenu = () => {
setIsMenuOpen(!isMenuOpen)
}
return ( return (
<div className="flex h-full w-44 shrink-0 px-1.5 pt-3 border-r border-main-view-fg/5"> <>
<button
className="fixed top-4 right-4 sm:hidden size-5 cursor-pointer items-center justify-center rounded hover:bg-main-view-fg/10 transition-all duration-200 ease-in-out data-[state=open]:bg-main-view-fg/10 z-20"
onClick={toggleMenu}
aria-label="Toggle settings menu"
>
{isMenuOpen ? (
<IconX size={18} className="text-main-view-fg relative z-20" />
) : (
<IconMenu2 size={18} className="text-main-view-fg relative z-20" />
)}
</button>
<div
className={cn(
'h-full w-44 shrink-0 px-1.5 pt-3 border-r border-main-view-fg/5 bg-main-view',
'sm:flex',
isMenuOpen
? 'flex fixed sm:hidden top-0 z-10 m-1 h-[calc(100%-8px)] border-r-0 border-l bg-main-view right-0 py-8 rounded-tr-lg rounded-br-lg'
: 'hidden'
)}
>
<div className="flex flex-col gap-1 w-full text-main-view-fg/90 font-medium"> <div className="flex flex-col gap-1 w-full text-main-view-fg/90 font-medium">
{menuSettings.map((menu, index) => { {menuSettings.map((menu) => (
// Render the menu item <div key={menu.title}>
const menuItem = (
<Link <Link
key={menu.title}
to={menu.route} to={menu.route}
className="block px-2 gap-1.5 cursor-pointer hover:bg-main-view-fg/5 py-1 w-full rounded [&.active]:bg-main-view-fg/5" className="block px-2 gap-1.5 cursor-pointer hover:bg-main-view-fg/5 py-1 w-full rounded [&.active]:bg-main-view-fg/5"
> >
<div className="flex items-center justify-between">
<span className="text-main-view-fg/80">{t(menu.title)}</span> <span className="text-main-view-fg/80">{t(menu.title)}</span>
</Link> {menu.hasSubMenu && (
) <button
onClick={(e) => {
if (index === 2) { e.preventDefault()
return ( e.stopPropagation()
<div key={menu.title}> toggleProvidersExpansion()
<span className="mb-1 block">{menuItem}</span> }}
className="text-main-view-fg/60 hover:text-main-view-fg/80"
{/* Model Providers Link with default parameter */}
{isActive ? (
<div className="block px-2 mt-1 gap-1.5 py-1 w-full rounded bg-main-view-fg/5 cursor-pointer">
<span>{t('common:modelProviders')}</span>
</div>
) : (
<Link
key="common.modelProviders"
to={route.settings.providers}
params={{ providerName: firstItemProvider }}
className="block px-2 gap-1.5 cursor-pointer hover:bg-main-view-fg/5 py-1 w-full rounded"
> >
<span className="text-main-view-fg/80"> {expandedProviders ? (
{t('common:modelProviders')} <IconChevronDown size={16} />
</span> ) : (
</Link> <IconChevronRight size={16} />
)}
</button>
)} )}
</div> </div>
) </Link>
}
// For other menu items, just render them normally {/* Sub-menu for model providers */}
return menuItem {menu.hasSubMenu && expandedProviders && (
<div className="ml-2 mt-1 space-y-1 first-step-setup-remote-provider">
{activeProviders.map((provider) => {
const isActive = matches.some(
(match) =>
match.routeId === '/settings/providers/$providerName' &&
'providerName' in match.params &&
match.params.providerName === provider.provider
)
return (
<div key={provider.provider}>
<div
className={cn(
'flex px-2 items-center gap-1.5 cursor-pointer hover:bg-main-view-fg/5 py-1 w-full rounded [&.active]:bg-main-view-fg/5 text-main-view-fg/80',
isActive && 'bg-main-view-fg/5',
// hidden for llama.cpp provider for setup remote provider
provider.provider === 'llama.cpp' &&
stepSetupRemoteProvider &&
'hidden'
)}
onClick={() =>
navigate({
to: route.settings.providers,
params: {
providerName: provider.provider,
},
...(stepSetupRemoteProvider
? { search: { step: 'setup_remote_provider' } }
: {}),
})
}
>
<ProvidersAvatar provider={provider} />
<div className="truncate">
<span>{getProviderTitle(provider.provider)}</span>
</div>
</div>
</div>
)
})} })}
</div> </div>
)}
</div> </div>
))}
</div>
</div>
</>
) )
} }

View File

@ -27,6 +27,7 @@ import { Route as SettingsGeneralImport } from './routes/settings/general'
import { Route as SettingsExtensionsImport } from './routes/settings/extensions' import { Route as SettingsExtensionsImport } from './routes/settings/extensions'
import { Route as SettingsAppearanceImport } from './routes/settings/appearance' import { Route as SettingsAppearanceImport } from './routes/settings/appearance'
import { Route as LocalApiServerLogsImport } from './routes/local-api-server/logs' 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' import { Route as SettingsProvidersProviderNameImport } from './routes/settings/providers/$providerName'
// Create/Update Routes // Create/Update Routes
@ -127,6 +128,12 @@ const LocalApiServerLogsRoute = LocalApiServerLogsImport.update({
getParentRoute: () => rootRoute, getParentRoute: () => rootRoute,
} as any) } as any)
const SettingsProvidersIndexRoute = SettingsProvidersIndexImport.update({
id: '/settings/providers/',
path: '/settings/providers/',
getParentRoute: () => rootRoute,
} as any)
const SettingsProvidersProviderNameRoute = const SettingsProvidersProviderNameRoute =
SettingsProvidersProviderNameImport.update({ SettingsProvidersProviderNameImport.update({
id: '/settings/providers/$providerName', id: '/settings/providers/$providerName',
@ -257,6 +264,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof SettingsProvidersProviderNameImport preLoaderRoute: typeof SettingsProvidersProviderNameImport
parentRoute: typeof rootRoute 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 '/settings/shortcuts': typeof SettingsShortcutsRoute
'/threads/$threadId': typeof ThreadsThreadIdRoute '/threads/$threadId': typeof ThreadsThreadIdRoute
'/settings/providers/$providerName': typeof SettingsProvidersProviderNameRoute '/settings/providers/$providerName': typeof SettingsProvidersProviderNameRoute
'/settings/providers': typeof SettingsProvidersIndexRoute
} }
export interface FileRoutesByTo { export interface FileRoutesByTo {
@ -300,6 +315,7 @@ export interface FileRoutesByTo {
'/settings/shortcuts': typeof SettingsShortcutsRoute '/settings/shortcuts': typeof SettingsShortcutsRoute
'/threads/$threadId': typeof ThreadsThreadIdRoute '/threads/$threadId': typeof ThreadsThreadIdRoute
'/settings/providers/$providerName': typeof SettingsProvidersProviderNameRoute '/settings/providers/$providerName': typeof SettingsProvidersProviderNameRoute
'/settings/providers': typeof SettingsProvidersIndexRoute
} }
export interface FileRoutesById { export interface FileRoutesById {
@ -321,6 +337,7 @@ export interface FileRoutesById {
'/settings/shortcuts': typeof SettingsShortcutsRoute '/settings/shortcuts': typeof SettingsShortcutsRoute
'/threads/$threadId': typeof ThreadsThreadIdRoute '/threads/$threadId': typeof ThreadsThreadIdRoute
'/settings/providers/$providerName': typeof SettingsProvidersProviderNameRoute '/settings/providers/$providerName': typeof SettingsProvidersProviderNameRoute
'/settings/providers/': typeof SettingsProvidersIndexRoute
} }
export interface FileRouteTypes { export interface FileRouteTypes {
@ -343,6 +360,7 @@ export interface FileRouteTypes {
| '/settings/shortcuts' | '/settings/shortcuts'
| '/threads/$threadId' | '/threads/$threadId'
| '/settings/providers/$providerName' | '/settings/providers/$providerName'
| '/settings/providers'
fileRoutesByTo: FileRoutesByTo fileRoutesByTo: FileRoutesByTo
to: to:
| '/' | '/'
@ -362,6 +380,7 @@ export interface FileRouteTypes {
| '/settings/shortcuts' | '/settings/shortcuts'
| '/threads/$threadId' | '/threads/$threadId'
| '/settings/providers/$providerName' | '/settings/providers/$providerName'
| '/settings/providers'
id: id:
| '__root__' | '__root__'
| '/' | '/'
@ -381,6 +400,7 @@ export interface FileRouteTypes {
| '/settings/shortcuts' | '/settings/shortcuts'
| '/threads/$threadId' | '/threads/$threadId'
| '/settings/providers/$providerName' | '/settings/providers/$providerName'
| '/settings/providers/'
fileRoutesById: FileRoutesById fileRoutesById: FileRoutesById
} }
@ -402,6 +422,7 @@ export interface RootRouteChildren {
SettingsShortcutsRoute: typeof SettingsShortcutsRoute SettingsShortcutsRoute: typeof SettingsShortcutsRoute
ThreadsThreadIdRoute: typeof ThreadsThreadIdRoute ThreadsThreadIdRoute: typeof ThreadsThreadIdRoute
SettingsProvidersProviderNameRoute: typeof SettingsProvidersProviderNameRoute SettingsProvidersProviderNameRoute: typeof SettingsProvidersProviderNameRoute
SettingsProvidersIndexRoute: typeof SettingsProvidersIndexRoute
} }
const rootRouteChildren: RootRouteChildren = { const rootRouteChildren: RootRouteChildren = {
@ -422,6 +443,7 @@ const rootRouteChildren: RootRouteChildren = {
SettingsShortcutsRoute: SettingsShortcutsRoute, SettingsShortcutsRoute: SettingsShortcutsRoute,
ThreadsThreadIdRoute: ThreadsThreadIdRoute, ThreadsThreadIdRoute: ThreadsThreadIdRoute,
SettingsProvidersProviderNameRoute: SettingsProvidersProviderNameRoute, SettingsProvidersProviderNameRoute: SettingsProvidersProviderNameRoute,
SettingsProvidersIndexRoute: SettingsProvidersIndexRoute,
} }
export const routeTree = rootRoute export const routeTree = rootRoute
@ -450,7 +472,8 @@ export const routeTree = rootRoute
"/settings/privacy", "/settings/privacy",
"/settings/shortcuts", "/settings/shortcuts",
"/threads/$threadId", "/threads/$threadId",
"/settings/providers/$providerName" "/settings/providers/$providerName",
"/settings/providers/"
] ]
}, },
"/": { "/": {
@ -503,6 +526,9 @@ export const routeTree = rootRoute
}, },
"/settings/providers/$providerName": { "/settings/providers/$providerName": {
"filePath": "settings/providers/$providerName.tsx" "filePath": "settings/providers/$providerName.tsx"
},
"/settings/providers/": {
"filePath": "settings/providers/index.tsx"
} }
} }
} }

View File

@ -35,7 +35,7 @@ function Appareances() {
<HeaderPage> <HeaderPage>
<h1 className="font-medium">{t('common:settings')}</h1> <h1 className="font-medium">{t('common:settings')}</h1>
</HeaderPage> </HeaderPage>
<div className="flex h-full w-full"> <div className="flex h-full w-full flex-col sm:flex-row">
<SettingsMenu /> <SettingsMenu />
<div className="p-4 w-full h-[calc(100%-32px)] overflow-y-auto"> <div className="p-4 w-full h-[calc(100%-32px)] overflow-y-auto">
<div className="flex flex-col justify-between gap-4 gap-y-3 w-full"> <div className="flex flex-col justify-between gap-4 gap-y-3 w-full">
@ -55,26 +55,31 @@ function Appareances() {
<CardItem <CardItem
title={t('settings:appearance.windowBackground')} title={t('settings:appearance.windowBackground')}
description={t('settings:appearance.windowBackgroundDesc')} description={t('settings:appearance.windowBackgroundDesc')}
className="flex-col sm:flex-row items-start sm:items-center sm:justify-between gap-y-2"
actions={<ColorPickerAppBgColor />} actions={<ColorPickerAppBgColor />}
/> />
<CardItem <CardItem
title={t('settings:appearance.appMainView')} title={t('settings:appearance.appMainView')}
description={t('settings:appearance.appMainViewDesc')} description={t('settings:appearance.appMainViewDesc')}
className="flex-col sm:flex-row items-start sm:items-center sm:justify-between gap-y-2"
actions={<ColorPickerAppMainView />} actions={<ColorPickerAppMainView />}
/> />
<CardItem <CardItem
title={t('settings:appearance.primary')} title={t('settings:appearance.primary')}
description={t('settings:appearance.primaryDesc')} description={t('settings:appearance.primaryDesc')}
className="flex-col sm:flex-row items-start sm:items-center sm:justify-between gap-y-2"
actions={<ColorPickerAppPrimaryColor />} actions={<ColorPickerAppPrimaryColor />}
/> />
<CardItem <CardItem
title={t('settings:appearance.accent')} title={t('settings:appearance.accent')}
description={t('settings:appearance.accentDesc')} description={t('settings:appearance.accentDesc')}
className="flex-col sm:flex-row items-start sm:items-center sm:justify-between gap-y-2"
actions={<ColorPickerAppAccentColor />} actions={<ColorPickerAppAccentColor />}
/> />
<CardItem <CardItem
title={t('settings:appearance.destructive')} title={t('settings:appearance.destructive')}
description={t('settings:appearance.destructiveDesc')} description={t('settings:appearance.destructiveDesc')}
className="flex-col sm:flex-row items-start sm:items-center sm:justify-between gap-y-2"
actions={<ColorPickerAppDestructiveColor />} actions={<ColorPickerAppDestructiveColor />}
/> />
<CardItem <CardItem

View File

@ -205,7 +205,7 @@ function General() {
<HeaderPage> <HeaderPage>
<h1 className="font-medium">{t('common:settings')}</h1> <h1 className="font-medium">{t('common:settings')}</h1>
</HeaderPage> </HeaderPage>
<div className="flex h-full w-full"> <div className="flex h-full w-full flex-col sm:flex-row">
<SettingsMenu /> <SettingsMenu />
<div className="p-4 w-full h-[calc(100%-32px)] overflow-y-auto"> <div className="p-4 w-full h-[calc(100%-32px)] overflow-y-auto">
<div className="flex flex-col justify-between gap-4 gap-y-3 w-full"> <div className="flex flex-col justify-between gap-4 gap-y-3 w-full">
@ -222,6 +222,7 @@ function General() {
<CardItem <CardItem
title={t('settings:general.checkForUpdates')} title={t('settings:general.checkForUpdates')}
description={t('settings:general.checkForUpdatesDesc')} description={t('settings:general.checkForUpdatesDesc')}
className="flex-col sm:flex-row items-start sm:items-center sm:justify-between gap-y-2"
actions={ actions={
<Button <Button
variant="link" variant="link"
@ -265,6 +266,7 @@ function General() {
ns: 'settings', ns: 'settings',
})} })}
align="start" align="start"
className="flex-col sm:flex-row items-start sm:items-center sm:justify-between gap-y-2"
description={ description={
<> <>
<span> <span>
@ -273,13 +275,15 @@ function General() {
})} })}
&nbsp; &nbsp;
</span> </span>
<div className="flex items-center gap-2 mt-1"> <div className="flex items-center gap-2 mt-1 ">
<div className="">
<span <span
title={janDataFolder} title={janDataFolder}
className="bg-main-view-fg/10 text-xs px-1 py-0.5 rounded-sm text-main-view-fg/80" className="bg-main-view-fg/10 text-xs px-1 py-0.5 rounded-sm text-main-view-fg/80 line-clamp-1 w-fit"
> >
{janDataFolder} {janDataFolder}
</span> </span>
</div>
<button <button
onClick={() => onClick={() =>
janDataFolder && copyToClipboard(janDataFolder) janDataFolder && copyToClipboard(janDataFolder)
@ -349,6 +353,7 @@ function General() {
ns: 'settings', ns: 'settings',
})} })}
description={t('settings:dataFolder.appLogsDesc')} description={t('settings:dataFolder.appLogsDesc')}
className="flex-col sm:flex-row items-start sm:items-center sm:justify-between gap-y-2"
actions={ actions={
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button <Button

View File

@ -229,9 +229,11 @@ function LocalAPIServer() {
title={t('settings:localApiServer.apiKey')} title={t('settings:localApiServer.apiKey')}
description={t('settings:localApiServer.apiKeyDesc')} description={t('settings:localApiServer.apiKeyDesc')}
className={cn( className={cn(
'flex-col sm:flex-row items-start sm:items-center sm:justify-between gap-y-2',
isServerRunning && 'opacity-50 pointer-events-none', isServerRunning && 'opacity-50 pointer-events-none',
isApiKeyEmpty && showApiKeyError && 'pb-6' isApiKeyEmpty && showApiKeyError && 'pb-6'
)} )}
classNameWrapperAction="w-full sm:w-auto"
actions={ actions={
<ApiKeyInput <ApiKeyInput
showError={showApiKeyError} showError={showApiKeyError}
@ -243,8 +245,10 @@ function LocalAPIServer() {
title={t('settings:localApiServer.trustedHosts')} title={t('settings:localApiServer.trustedHosts')}
description={t('settings:localApiServer.trustedHostsDesc')} description={t('settings:localApiServer.trustedHostsDesc')}
className={cn( className={cn(
'flex-col sm:flex-row items-start sm:items-center sm:justify-between gap-y-2',
isServerRunning && 'opacity-50 pointer-events-none' isServerRunning && 'opacity-50 pointer-events-none'
)} )}
classNameWrapperAction="w-full sm:w-auto"
actions={<TrustedHostsInput />} actions={<TrustedHostsInput />}
/> />
</Card> </Card>

View File

@ -320,7 +320,7 @@ function MCPServers() {
</h1> </h1>
</div> </div>
} }
description={ descriptionOutside={
<div className="text-sm text-main-view-fg/70"> <div className="text-sm text-main-view-fg/70">
<div> <div>
{t('mcp-servers:command')}: {config.command} {t('mcp-servers:command')}: {config.command}

View File

@ -1,9 +1,8 @@
import { Card, CardItem } from '@/containers/Card' import { Card, CardItem } from '@/containers/Card'
import HeaderPage from '@/containers/HeaderPage' import HeaderPage from '@/containers/HeaderPage'
import ProvidersMenu from '@/containers/ProvidersMenu' import SettingsMenu from '@/containers/SettingsMenu'
import { useModelProvider } from '@/hooks/useModelProvider' import { useModelProvider } from '@/hooks/useModelProvider'
import { cn, getProviderTitle } from '@/lib/utils' import { cn, getProviderTitle } from '@/lib/utils'
import { Switch } from '@/components/ui/switch'
import { open } from '@tauri-apps/plugin-dialog' import { open } from '@tauri-apps/plugin-dialog'
import { import {
getActiveModels, getActiveModels,
@ -228,23 +227,13 @@ function ProviderDetail() {
<h1 className="font-medium">{t('common:settings')}</h1> <h1 className="font-medium">{t('common:settings')}</h1>
</HeaderPage> </HeaderPage>
<div className="flex h-full w-full"> <div className="flex h-full w-full">
<div className="flex"> <SettingsMenu />
<ProvidersMenu stepSetupRemoteProvider={isSetup} />
</div>
<div className="p-4 w-full h-[calc(100%-32px)] overflow-y-auto"> <div className="p-4 w-full h-[calc(100%-32px)] overflow-y-auto">
<div className="flex flex-col justify-between gap-4 gap-y-3 w-full"> <div className="flex flex-col justify-between gap-4 gap-y-3 w-full">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h1 className="font-medium text-base"> <h1 className="font-medium text-base">
{getProviderTitle(providerName)} {getProviderTitle(providerName)}
</h1> </h1>
<Switch
checked={provider?.active}
onCheckedChange={(e) => {
if (provider) {
updateProvider(providerName, { ...provider, active: e })
}
}}
/>
</div> </div>
<div <div
@ -461,7 +450,12 @@ function ProviderDetail() {
key={modelIndex} key={modelIndex}
title={ title={
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<h1 className="font-medium">{model.id}</h1> <h1
className="font-medium line-clamp-1"
title={model.id}
>
{model.id}
</h1>
<Capabilities capabilities={capabilities} /> <Capabilities capabilities={capabilities} />
</div> </div>
} }

View File

@ -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 (
<div className="flex flex-col h-full">
<HeaderPage>
<h1 className="font-medium">{t('common:settings')}</h1>
</HeaderPage>
<div className="flex h-full w-full flex-col sm:flex-row">
<SettingsMenu />
<div className="p-4 w-full h-[calc(100%-32px)] overflow-y-auto">
<div className="flex flex-col justify-between gap-4 gap-y-3 w-full">
{/* Model Providers */}
<Card
header={
<div className="flex items-center justify-between w-full mb-6">
<span className="text-main-view-fg font-medium text-base">
{t('common:modelProviders')}
</span>
<Dialog>
<DialogTrigger asChild>
<Button
variant="link"
size="sm"
className="flex items-center gap-2"
>
<div className="cursor-pointer flex items-center justify-center rounded hover:bg-main-view-fg/15 bg-main-view-fg/10 transition-all duration-200 ease-in-out p-1.5 py-1 gap-1 -mr-2">
<IconCirclePlus size={16} />
<span>{t('provider:addProvider')}</span>
</div>
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>
{t('provider:addOpenAIProvider')}
</DialogTitle>
<Input
value={name}
onChange={(e) => setName(e.target.value)}
className="mt-2"
placeholder={t('provider:enterNameForProvider')}
onKeyDown={(e) => {
// Prevent key from being captured by parent components
e.stopPropagation()
}}
/>
<DialogFooter className="mt-2 flex items-center">
<DialogClose asChild>
<Button
variant="link"
size="sm"
className="hover:no-underline"
>
{t('common:cancel')}
</Button>
</DialogClose>
<DialogClose asChild>
<Button disabled={!name} onClick={createProvider}>
{t('common:create')}
</Button>
</DialogClose>
</DialogFooter>
</DialogHeader>
</DialogContent>
</Dialog>
</div>
}
>
{providers.map((provider, index) => (
<CardItem
key={index}
title={
<div className="flex items-center gap-3">
<ProvidersAvatar provider={provider} />
<div>
<h3 className="font-medium">
{getProviderTitle(provider.provider)}
</h3>
<p className="text-xs text-main-view-fg/70">
{provider.models.length} Models
</p>
</div>
</div>
}
actions={
<div className="flex items-center gap-2">
{provider.active && (
<Button
variant="default"
size="sm"
className="h-6 w-6 p-0 bg-transparent hover:bg-main-view-fg/10 border-none shadow-none"
onClick={() => {
navigate({
to: route.settings.providers,
params: {
providerName: provider.provider,
},
})
}}
>
<IconSettings
className="text-main-view-fg/60"
size={16}
/>
</Button>
)}
<Switch
checked={provider.active}
onCheckedChange={(e) => {
updateProvider(provider.provider, {
...provider,
active: e,
})
}}
/>
</div>
}
/>
))}
</Card>
</div>
</div>
</div>
</div>
)
}