Merge pull request #5670 from menloresearch/release/v0.6.6

Sync Release/v0.6.6 into dev
This commit is contained in:
Louis 2025-07-02 10:58:33 +07:00 committed by GitHub
commit a3fd6fcd3c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
37 changed files with 1163 additions and 689 deletions

View File

@ -150,6 +150,12 @@ jobs:
fi
- name: Build app
run: |
# Pin linuxdeploy version to prevent @tauri-apps/cli-linux-x64-gnu from pulling in an outdated version
TAURI_TOOLKIT_PATH="${XDG_CACHE_HOME:-$HOME/.cache}/tauri"
mkdir -p "$TAURI_TOOLKIT_PATH"
wget https://github.com/linuxdeploy/linuxdeploy/releases/download/1-alpha-20250213-2/linuxdeploy-x86_64.AppImage -O "$TAURI_TOOLKIT_PATH/linuxdeploy-x86_64.AppImage"
chmod +x "$TAURI_TOOLKIT_PATH/linuxdeploy-x86_64.AppImage"
make build-tauri
# Copy engines and bun to appimage
wget https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-x86_64.AppImage -O ./appimagetool

View File

@ -257,7 +257,7 @@ describe('extractInferenceParams', () => {
max_tokens: 50.3,
invalid_param: 'should_be_ignored',
}
const result = extractInferenceParams(modelParams as any)
expect(result).toEqual({
temperature: 1.5,

View File

@ -17,6 +17,8 @@
"label": "main",
"title": "Jan",
"width": 1024,
"minWidth": 375,
"minHeight": 667,
"height": 800,
"resizable": true,
"fullscreen": false,

View File

@ -66,7 +66,7 @@
"remark-math": "^6.0.0",
"sonner": "^2.0.3",
"tailwindcss": "^4.1.4",
"token.js": "npm:token.js-fork@0.7.9",
"token.js": "npm:token.js-fork@0.7.12",
"tw-animate-css": "^1.2.7",
"ulidx": "^2.4.1",
"unified": "^11.0.5",

View File

@ -67,7 +67,7 @@ function DialogContent({
data-slot="dialog-content"
aria-describedby={ariaDescribedBy}
className={cn(
'bg-main-view max-h-[calc(100%-48px)] overflow-auto border-main-view-fg/10 text-main-view-fg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg',
'bg-main-view max-h-[calc(100%-80px)] overflow-auto border-main-view-fg/10 text-main-view-fg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg',
className
)}
{...props}

View File

@ -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',

View File

@ -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 (
<div
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',
className,
align === 'start' && 'items-start',
align === 'center' && 'items-center',
align === 'end' && 'items-end',
column && 'flex-col gap-y-0 items-start'
)}
>
<div className="space-y-1.5">
<h1 className="font-medium">{title}</h1>
{description && (
<span className="text-main-view-fg/70 leading-normal">
{description}
</span>
<>
<div
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',
descriptionOutside && 'border-0',
align === 'start' && 'items-start',
align === 'center' && 'items-center',
align === 'end' && 'items-end',
column && 'flex-col gap-y-0 items-start',
className
)}
>
<div className="space-y-1.5">
<h1 className="font-medium">{title}</h1>
{description && (
<span className="text-main-view-fg/70 leading-normal">
{description}
</span>
)}
</div>
{actions && (
<div
className={cn(
'shrink-0',
classNameWrapperAction,
column && 'w-full'
)}
>
{actions}
</div>
)}
</div>
{actions && (
<div className={cn('shrink-0', column && 'w-full')}>{actions}</div>
{descriptionOutside && (
<span className="text-main-view-fg/70 leading-normal">
{descriptionOutside}
</span>
)}
</div>
</>
)
}

View File

@ -9,27 +9,31 @@ export function ChatWidthSwitcher() {
const { t } = useTranslation()
return (
<div className="flex gap-4">
<div className="flex flex-col sm:flex-row sm:gap-4">
<button
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'
)}
onClick={() => setChatWidth('compact')}
>
<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' && (
<IconCircleCheckFilled className="size-4 text-accent" />
)}
</div>
<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" />
<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">
<span className="text-main-view-fg/50">{t('common:placeholder.chatInput')}</span>
<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 line-clamp-1">
{t('common:placeholder.chatInput')}
</span>
</div>
</div>
</div>
@ -42,7 +46,9 @@ export function ChatWidthSwitcher() {
onClick={() => setChatWidth('full')}
>
<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' && (
<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" />
<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">
<span className="text-main-view-fg/50">{t('common:placeholder.chatInput')}</span>
<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>
</div>
</div>
</div>

View File

@ -26,7 +26,7 @@ import {
import { useThreads } from '@/hooks/useThreads'
import { useTranslation } from '@/i18n/react-i18next-compat'
import { useMemo, useState } from 'react'
import { useMemo, useState, useEffect, useRef } from 'react'
import {
Dialog,
DialogClose,
@ -40,6 +40,8 @@ import {
import { Button } from '@/components/ui/button'
import { toast } from 'sonner'
import { DownloadManagement } from '@/containers/DownloadManegement'
import { useSmallScreen } from '@/hooks/useMediaQuery'
import { useClickOutside } from '@/hooks/useClickOutside'
const mainMenus = [
{
@ -70,6 +72,68 @@ const LeftPanel = () => {
const navigate = useNavigate()
const [searchTerm, setSearchTerm] = useState('')
const isSmallScreen = useSmallScreen()
const prevScreenSizeRef = useRef<boolean | null>(null)
const isInitialMountRef = useRef(true)
const panelRef = useRef<HTMLElement>(null)
const searchContainerRef = useRef<HTMLDivElement>(null)
const searchContainerMacRef = useRef<HTMLDivElement>(null)
// Use click outside hook for panel with debugging
useClickOutside(
() => {
if (isSmallScreen && open) {
setLeftPanel(false)
}
},
null,
[
panelRef.current,
searchContainerRef.current,
searchContainerMacRef.current,
]
)
// Auto-collapse panel only when window is resized
useEffect(() => {
const handleResize = () => {
const currentIsSmallScreen = window.innerWidth <= 768
// Skip on initial mount
if (isInitialMountRef.current) {
isInitialMountRef.current = false
prevScreenSizeRef.current = currentIsSmallScreen
return
}
// Only trigger if the screen size actually changed
if (
prevScreenSizeRef.current !== null &&
prevScreenSizeRef.current !== currentIsSmallScreen
) {
if (currentIsSmallScreen) {
setLeftPanel(false)
} else {
setLeftPanel(true)
}
prevScreenSizeRef.current = currentIsSmallScreen
}
}
// Add resize listener
window.addEventListener('resize', handleResize)
// Initialize the previous screen size on mount
if (isInitialMountRef.current) {
prevScreenSizeRef.current = window.innerWidth <= 768
isInitialMountRef.current = false
}
return () => {
window.removeEventListener('resize', handleResize)
}
}, [setLeftPanel])
const currentPath = useRouterState({
select: (state) => state.location.pathname,
})
@ -91,50 +155,63 @@ const LeftPanel = () => {
return filteredThreads.filter((t) => !t.isFavorite)
}, [filteredThreads])
return (
<aside
className={cn(
'w-48 shrink-0 rounded-lg m-1.5 mr-0 text-left-panel-fg',
open
? 'opacity-100 visibility-visible'
: 'w-0 absolute -top-100 -left-100 visibility-hidden'
)}
>
<div className="relative h-10">
<button
className="absolute top-1/2 right-0 -translate-y-1/2 z-20"
onClick={() => setLeftPanel(!open)}
>
<div className="size-6 cursor-pointer flex items-center justify-center rounded hover:bg-left-panel-fg/10 transition-all duration-200 ease-in-out">
<IconLayoutSidebar size={18} className="text-left-panel-fg" />
</div>
</button>
{!IS_MACOS && (
<div className="relative top-1.5 mb-4 mx-1 mt-1 w-[calc(100%-32px)] z-50">
<IconSearch className="absolute size-4 top-1/2 left-2 -translate-y-1/2 text-left-panel-fg/50" />
<input
type="text"
placeholder={t('common:search')}
className="w-full pl-7 pr-8 py-1 bg-left-panel-fg/10 rounded-sm text-left-panel-fg focus:outline-none focus:ring-1 focus:ring-left-panel-fg/10"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
{searchTerm && (
<button
className="absolute right-2 top-1/2 -translate-y-1/2 text-left-panel-fg/70 hover:text-left-panel-fg"
onClick={() => setSearchTerm('')}
>
<IconX size={14} />
</button>
)}
</div>
)}
</div>
// Disable body scroll when panel is open on small screens
useEffect(() => {
if (isSmallScreen && open) {
document.body.style.overflow = 'hidden'
} else {
document.body.style.overflow = ''
}
<div className="flex flex-col justify-between h-[calc(100%-42px)] mt-0">
<div className="flex flex-col justify-between h-full">
{IS_MACOS && (
<div className="relative mb-4 mx-1 mt-1">
return () => {
document.body.style.overflow = ''
}
}, [isSmallScreen, open])
return (
<>
{/* Backdrop overlay for small screens */}
{isSmallScreen && open && (
<div
className="fixed inset-0 bg-black/50 backdrop-blur z-30"
onClick={(e) => {
// Don't close if clicking on search container or if currently searching
if (
searchContainerRef.current?.contains(e.target as Node) ||
searchContainerMacRef.current?.contains(e.target as Node)
) {
return
}
setLeftPanel(false)
}}
/>
)}
<aside
ref={panelRef}
className={cn(
'w-48 shrink-0 rounded-lg m-1.5 mr-0 text-left-panel-fg overflow-hidden',
isSmallScreen &&
'fixed h-[calc(100%-16px)] bg-main-view z-40 rounded-sm border border-left-panel-fg/10 m-2 px-1',
open
? 'opacity-100 visibility-visible'
: 'w-0 absolute -top-100 -left-100 visibility-hidden'
)}
>
<div className="relative h-10">
<button
className="absolute top-1/2 right-0 -translate-y-1/2 z-20"
onClick={() => setLeftPanel(!open)}
>
<div className="size-6 cursor-pointer flex items-center justify-center rounded hover:bg-left-panel-fg/10 transition-all duration-200 ease-in-out data-[state=open]:bg-left-panel-fg/10">
<IconLayoutSidebar size={18} className="text-left-panel-fg" />
</div>
</button>
{!IS_MACOS && (
<div
ref={searchContainerRef}
className="relative top-1.5 mb-4 mx-1 mt-1 w-[calc(100%-32px)] z-50"
data-ignore-outside-clicks
>
<IconSearch className="absolute size-4 top-1/2 left-2 -translate-y-1/2 text-left-panel-fg/50" />
<input
type="text"
@ -146,176 +223,226 @@ const LeftPanel = () => {
{searchTerm && (
<button
className="absolute right-2 top-1/2 -translate-y-1/2 text-left-panel-fg/70 hover:text-left-panel-fg"
onClick={() => setSearchTerm('')}
onClick={(e) => {
e.preventDefault()
e.stopPropagation() // prevent bubbling
setSearchTerm('')
}}
>
<IconX size={14} />
</button>
)}
</div>
)}
<div className="flex flex-col w-full h-full overflow-hidden">
<div className="h-full overflow-y-auto overflow-x-hidden">
{favoritedThreads.length > 0 && (
<>
</div>
<div className="flex flex-col justify-between overflow-hidden mt-0 !h-[calc(100%-42px)]">
<div className="flex flex-col !h-[calc(100%-140px)]">
{IS_MACOS && (
<div
ref={searchContainerMacRef}
className="relative mb-4 mx-1 mt-1"
data-ignore-outside-clicks
>
<IconSearch className="absolute size-4 top-1/2 left-2 -translate-y-1/2 text-left-panel-fg/50" />
<input
type="text"
placeholder={t('common:search')}
className="w-full pl-7 pr-8 py-1 bg-left-panel-fg/10 rounded-sm text-left-panel-fg focus:outline-none focus:ring-1 focus:ring-left-panel-fg/10"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
{searchTerm && (
<button
data-ignore-outside-clicks
className="absolute right-2 top-1/2 -translate-y-1/2 text-left-panel-fg/70 hover:text-left-panel-fg"
onClick={(e) => {
e.preventDefault()
e.stopPropagation() // prevent bubbling
setSearchTerm('')
}}
>
<IconX size={14} />
</button>
)}
</div>
)}
<div className="flex flex-col w-full overflow-y-auto overflow-x-hidden">
<div className="h-full w-full overflow-y-auto">
{favoritedThreads.length > 0 && (
<>
<div className="flex items-center justify-between mb-2">
<span className="block text-xs text-left-panel-fg/50 px-1 font-semibold sticky top-0">
{t('common:favorites')}
</span>
<div className="relative">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="size-6 flex cursor-pointer items-center justify-center rounded hover:bg-left-panel-fg/10 transition-all duration-200 ease-in-out data-[state=open]:bg-left-panel-fg/10">
<IconDots
size={18}
className="text-left-panel-fg/60"
/>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent side="bottom" align="end">
<DropdownMenuItem
onClick={() => {
unstarAllThreads()
toast.success(
t('common:toast.allThreadsUnfavorited.title'),
{
id: 'unfav-all-threads',
description: t(
'common:toast.allThreadsUnfavorited.description'
),
}
)
}}
>
<IconStar size={16} />
<span>{t('common:unstarAll')}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
<div className="flex flex-col mb-4">
<ThreadList
threads={favoritedThreads}
isFavoriteSection={true}
/>
{favoritedThreads.length === 0 && (
<p className="text-xs text-left-panel-fg/50 px-1 font-semibold">
{t('chat.status.empty', { ns: 'chat' })}
</p>
)}
</div>
</>
)}
{unFavoritedThreads.length > 0 && (
<div className="flex items-center justify-between mb-2">
<span className="block text-xs text-left-panel-fg/50 px-1 font-semibold sticky top-0">
{t('common:favorites')}
<span className="block text-xs text-left-panel-fg/50 px-1 font-semibold">
{t('common:recents')}
</span>
<div className="relative">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="size-6 flex cursor-pointer items-center justify-center rounded hover:bg-left-panel-fg/10 transition-all duration-200 ease-in-out data-[state=open]:bg-left-panel-fg/10">
<IconDots
size={18}
className="text-left-panel-fg/60"
/>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent side="bottom" align="end">
<DropdownMenuItem
onClick={() => {
unstarAllThreads()
toast.success(t('common:toast.allThreadsUnfavorited.title'), {
id: 'unfav-all-threads',
description: t('common:toast.allThreadsUnfavorited.description'),
})
}}
>
<IconStar size={16} />
<span>{t('common:unstarAll')}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Dialog>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
className="size-6 flex cursor-pointer items-center justify-center rounded hover:bg-left-panel-fg/10 transition-all duration-200 ease-in-out data-[state=open]:bg-left-panel-fg/10"
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
}}
>
<IconDots
size={18}
className="text-left-panel-fg/60"
/>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent side="bottom" align="end">
<DialogTrigger asChild>
<DropdownMenuItem
onSelect={(e) => e.preventDefault()}
>
<IconTrash size={16} />
<span>{t('common:deleteAll')}</span>
</DropdownMenuItem>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>
{t('common:dialogs.deleteAllThreads.title')}
</DialogTitle>
<DialogDescription>
{t(
'common:dialogs.deleteAllThreads.description'
)}
</DialogDescription>
<DialogFooter className="mt-2">
<DialogClose asChild>
<Button
variant="link"
size="sm"
className="hover:no-underline"
>
{t('common:cancel')}
</Button>
</DialogClose>
<Button
variant="destructive"
size="sm"
onClick={() => {
deleteAllThreads()
toast.success(
t(
'common:toast.deleteAllThreads.title'
),
{
id: 'delete-all-thread',
description: t(
'common:toast.deleteAllThreads.description'
),
}
)
setTimeout(() => {
navigate({ to: route.home })
}, 0)
}}
>
{t('common:deleteAll')}
</Button>
</DialogFooter>
</DialogHeader>
</DialogContent>
</DropdownMenuContent>
</DropdownMenu>
</Dialog>
</div>
</div>
<div className="flex flex-col mb-4">
<ThreadList
threads={favoritedThreads}
isFavoriteSection={true}
/>
{favoritedThreads.length === 0 && (
<p className="text-xs text-left-panel-fg/50 px-1 font-semibold">
{t('chat.status.empty', { ns: 'chat' })}
</p>
)}
</div>
</>
)}
)}
{unFavoritedThreads.length > 0 && (
<div className="flex items-center justify-between mb-2">
<span className="block text-xs text-left-panel-fg/50 px-1 font-semibold">
{t('common:recents')}
</span>
<div className="relative">
<Dialog>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
className="size-6 flex cursor-pointer items-center justify-center rounded hover:bg-left-panel-fg/10 transition-all duration-200 ease-in-out data-[state=open]:bg-left-panel-fg/10"
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
}}
>
<IconDots
size={18}
className="text-left-panel-fg/60"
/>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent side="bottom" align="end">
<DialogTrigger asChild>
<DropdownMenuItem
onSelect={(e) => e.preventDefault()}
>
<IconTrash size={16} />
<span>{t('common:deleteAll')}</span>
</DropdownMenuItem>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>
{t('common:dialogs.deleteAllThreads.title')}
</DialogTitle>
<DialogDescription>
{t(
'common:dialogs.deleteAllThreads.description'
)}
</DialogDescription>
<DialogFooter className="mt-2">
<DialogClose asChild>
<Button
variant="link"
size="sm"
className="hover:no-underline"
>
{t('common:cancel')}
</Button>
</DialogClose>
<Button
variant="destructive"
size="sm"
onClick={() => {
deleteAllThreads()
toast.success(t('common:toast.deleteAllThreads.title'), {
id: 'delete-all-thread',
description: t('common:toast.deleteAllThreads.description'),
})
setTimeout(() => {
navigate({ to: route.home })
}, 0)
}}
>
{t('common:deleteAll')}
</Button>
</DialogFooter>
</DialogHeader>
</DialogContent>
</DropdownMenuContent>
</DropdownMenu>
</Dialog>
</div>
</div>
)}
{filteredThreads.length === 0 && searchTerm.length > 0 && (
<div className="px-1 mt-2">
<div className="flex items-center gap-1 text-left-panel-fg/80">
<IconSearch size={18} />
<h6 className="font-medium text-base">
{t('common:noResultsFound')}
</h6>
</div>
<p className="text-left-panel-fg/60 mt-1 text-xs leading-relaxed">
{t('common:noResultsFoundDesc')}
</p>
</div>
)}
{Object.keys(threads).length === 0 && !searchTerm && (
<>
{filteredThreads.length === 0 && searchTerm.length > 0 && (
<div className="px-1 mt-2">
<div className="flex items-center gap-1 text-left-panel-fg/80">
<IconMessageFilled size={18} />
<IconSearch size={18} />
<h6 className="font-medium text-base">
{t('common:noThreadsYet')}
{t('common:noResultsFound')}
</h6>
</div>
<p className="text-left-panel-fg/60 mt-1 text-xs leading-relaxed">
{t('common:noThreadsYetDesc')}
{t('common:noResultsFoundDesc')}
</p>
</div>
</>
)}
)}
<div className="flex flex-col">
<ThreadList threads={unFavoritedThreads} />
{Object.keys(threads).length === 0 && !searchTerm && (
<>
<div className="px-1 mt-2">
<div className="flex items-center gap-1 text-left-panel-fg/80">
<IconMessageFilled size={18} />
<h6 className="font-medium text-base">
{t('common:noThreadsYet')}
</h6>
</div>
<p className="text-left-panel-fg/60 mt-1 text-xs leading-relaxed">
{t('common:noThreadsYetDesc')}
</p>
</div>
</>
)}
<div className="flex flex-col">
<ThreadList threads={unFavoritedThreads} />
</div>
</div>
</div>
</div>
<div className="space-y-1 py-1 mt-2">
<div className="space-y-1 shrink-0 py-1 mt-2">
{mainMenus.map((menu) => {
const isActive =
currentPath.includes(route.settings.index) &&
@ -324,6 +451,7 @@ const LeftPanel = () => {
<Link
key={menu.title}
to={menu.route}
onClick={() => isSmallScreen && setLeftPanel(false)}
data-test-id={`menu-${menu.title}`}
className={cn(
'flex items-center gap-1.5 cursor-pointer hover:bg-left-panel-fg/10 py-1 px-1 rounded',
@ -342,8 +470,8 @@ const LeftPanel = () => {
</div>
<DownloadManagement />
</div>
</div>
</aside>
</aside>
</>
)
}

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,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 (
<div className="flex h-full w-44 shrink-0 px-1.5 pt-3 border-r border-main-view-fg/5">
<div className="flex flex-col gap-1 w-full text-main-view-fg/90 font-medium">
{menuSettings.map((menu, index) => {
// Render the menu item
const menuItem = (
<Link
key={menu.title}
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"
>
<span className="text-main-view-fg/80">{t(menu.title)}</span>
</Link>
)
<>
<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">
{menuSettings.map((menu) => (
<div key={menu.title}>
<Link
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"
>
<div className="flex items-center justify-between">
<span className="text-main-view-fg/80">{t(menu.title)}</span>
{menu.hasSubMenu && (
<button
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
toggleProvidersExpansion()
}}
className="text-main-view-fg/60 hover:text-main-view-fg/80"
>
{expandedProviders ? (
<IconChevronDown size={16} />
) : (
<IconChevronRight size={16} />
)}
</button>
)}
</div>
</Link>
if (index === 2) {
return (
<div key={menu.title}>
<span className="mb-1 block">{menuItem}</span>
{/* Sub-menu for model providers */}
{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
)
{/* 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">
{t('common:modelProviders')}
</span>
</Link>
)}
</div>
)
}
// For other menu items, just render them normally
return menuItem
})}
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>
</>
)
}

View File

@ -20,8 +20,10 @@ import {
IconStar,
} from '@tabler/icons-react'
import { useThreads } from '@/hooks/useThreads'
import { useLeftPanel } from '@/hooks/useLeftPanel'
import { cn } from '@/lib/utils'
import { route } from '@/constants/routes'
import { useSmallScreen } from '@/hooks/useMediaQuery'
import {
DropdownMenu,
@ -55,6 +57,9 @@ const SortableItem = memo(({ thread }: { thread: Thread }) => {
isDragging,
} = useSortable({ id: thread.id, disabled: true })
const isSmallScreen = useSmallScreen()
const { setLeftPanel } = useLeftPanel()
const style = {
transform: CSS.Transform.toString(transform),
transition,
@ -75,7 +80,11 @@ const SortableItem = memo(({ thread }: { thread: Thread }) => {
const handleClick = () => {
if (!isDragging) {
navigate({ to: route.threadsDetail, params: { threadId: thread.id } })
// Only close panel and navigate if the thread is not already active
if (!isActive) {
if (isSmallScreen) setLeftPanel(false)
navigate({ to: route.threadsDetail, params: { threadId: thread.id } })
}
}
}
@ -85,7 +94,9 @@ const SortableItem = memo(({ thread }: { thread: Thread }) => {
return (thread.title || '').replace(/<span[^>]*>|<\/span>/g, '')
}, [thread.title])
const [title, setTitle] = useState(plainTitleForRename || t('common:newThread'))
const [title, setTitle] = useState(
plainTitleForRename || t('common:newThread')
)
return (
<div
@ -185,7 +196,10 @@ const SortableItem = memo(({ thread }: { thread: Thread }) => {
setOpenDropdown(false)
toast.success(t('common:toast.renameThread.title'), {
id: 'rename-thread',
description: t('common:toast.renameThread.description', { title }),
description: t(
'common:toast.renameThread.description',
{ title }
),
})
}}
>
@ -231,7 +245,9 @@ const SortableItem = memo(({ thread }: { thread: Thread }) => {
setOpenDropdown(false)
toast.success(t('common:toast.deleteThread.title'), {
id: 'delete-thread',
description: t('common:toast.deleteThread.description'),
description: t(
'common:toast.deleteThread.description'
),
})
setTimeout(() => {
navigate({ to: route.home })

View File

@ -378,73 +378,27 @@ export default function AddEditAssistant({
</div>
{paramsKeys.map((key, index) => (
<div key={index} className="flex items-center gap-2">
<Input
value={key}
onChange={(e) =>
handleParameterChange(index, e.target.value, 'key')
}
placeholder={t('assistants:key')}
className="w-24"
/>
<div key={index} className="flex items-center gap-4">
<div
key={index}
className="flex items-center flex-col sm:flex-row w-full gap-2"
>
<Input
value={key}
onChange={(e) =>
handleParameterChange(index, e.target.value, 'key')
}
placeholder={t('assistants:key')}
className="w-full sm:w-24"
/>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<div className="relative w-30">
<Input
value={
paramsTypes[index].charAt(0).toUpperCase() +
paramsTypes[index].slice(1)
}
readOnly
/>
<IconChevronDown
size={14}
className="text-main-view-fg/50 absolute right-2 top-1/2 -translate-y-1/2"
/>
</div>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-32" align="start">
<DropdownMenuItem
onClick={() =>
handleParameterChange(index, 'string', 'type')
}
>
{t('assistants:stringValue')}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() =>
handleParameterChange(index, 'number', 'type')
}
>
{t('assistants:numberValue')}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() =>
handleParameterChange(index, 'boolean', 'type')
}
>
{t('assistants:booleanValue')}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() =>
handleParameterChange(index, 'json', 'type')
}
>
{t('assistants:jsonValue')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{paramsTypes[index] === 'boolean' ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<div className="relative flex-1">
<div className="relative w-full sm:w-30">
<Input
value={
paramsValues[index]
? t('assistants:trueValue')
: t('assistants:falseValue')
paramsTypes[index].charAt(0).toUpperCase() +
paramsTypes[index].slice(1)
}
readOnly
/>
@ -454,48 +408,98 @@ export default function AddEditAssistant({
/>
</div>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-24" align="start">
<DropdownMenuContent className="w-32" align="start">
<DropdownMenuItem
onClick={() =>
handleParameterChange(index, true, 'value')
handleParameterChange(index, 'string', 'type')
}
>
{t('assistants:trueValue')}
{t('assistants:stringValue')}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() =>
handleParameterChange(index, false, 'value')
handleParameterChange(index, 'number', 'type')
}
>
{t('assistants:falseValue')}
{t('assistants:numberValue')}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() =>
handleParameterChange(index, 'boolean', 'type')
}
>
{t('assistants:booleanValue')}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() =>
handleParameterChange(index, 'json', 'type')
}
>
{t('assistants:jsonValue')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : paramsTypes[index] === 'json' ? (
<Input
value={
typeof paramsValues[index] === 'object'
? JSON.stringify(paramsValues[index], null, 2)
: paramsValues[index]?.toString() || ''
}
onChange={(e) =>
handleParameterChange(index, e.target.value, 'value')
}
placeholder={t('assistants:jsonValuePlaceholder')}
className="flex-1"
/>
) : (
<Input
value={paramsValues[index]?.toString() || ''}
onChange={(e) =>
handleParameterChange(index, e.target.value, 'value')
}
type={paramsTypes[index] === 'number' ? 'number' : 'text'}
placeholder={t('assistants:value')}
className="flex-1"
/>
)}
{paramsTypes[index] === 'boolean' ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<div className="relative sm:flex-1 w-full">
<Input
value={
paramsValues[index]
? t('assistants:trueValue')
: t('assistants:falseValue')
}
readOnly
/>
<IconChevronDown
size={14}
className="text-main-view-fg/50 absolute right-2 top-1/2 -translate-y-1/2"
/>
</div>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-24" align="start">
<DropdownMenuItem
onClick={() =>
handleParameterChange(index, true, 'value')
}
>
{t('assistants:trueValue')}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() =>
handleParameterChange(index, false, 'value')
}
>
{t('assistants:falseValue')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : paramsTypes[index] === 'json' ? (
<Input
value={
typeof paramsValues[index] === 'object'
? JSON.stringify(paramsValues[index], null, 2)
: paramsValues[index]?.toString() || ''
}
onChange={(e) =>
handleParameterChange(index, e.target.value, 'value')
}
placeholder={t('assistants:jsonValuePlaceholder')}
className="sm:flex-1 h-[36px] w-full"
/>
) : (
<Input
value={paramsValues[index]?.toString() || ''}
onChange={(e) =>
handleParameterChange(index, e.target.value, 'value')
}
type={paramsTypes[index] === 'number' ? 'number' : 'text'}
placeholder={t('assistants:value')}
className="sm:flex-1 h-[36px] w-full"
/>
)}
</div>
<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={() => handleRemoveParameter(index)}

View File

@ -1,7 +1,7 @@
import { useEffect, useState } from 'react'
import { listen } from '@tauri-apps/api/event'
import { invoke } from '@tauri-apps/api/core'
import { t } from 'i18next'
import {
Dialog,
DialogContent,
@ -11,8 +11,10 @@ import {
DialogTitle,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { useTranslation } from '@/i18n'
export function CortexFailureDialog() {
const { t } = useTranslation()
const [showDialog, setShowDialog] = useState(false)
useEffect(() => {
@ -52,15 +54,10 @@ export function CortexFailureDialog() {
<Dialog open={showDialog} onOpenChange={setShowDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle>
{t('cortexFailureDialog.title', 'Local AI Engine Issue')}
</DialogTitle>
<DialogTitle>{t('cortexFailureDialog.title')}</DialogTitle>
</DialogHeader>
<DialogDescription>
{t(
'cortexFailureDialog.description',
'The local AI engine (Cortex) failed to start after multiple attempts. This might prevent some features from working correctly.'
)}
{t('cortexFailureDialog.description')}
</DialogDescription>
<DialogFooter className="flex gap-2">
<Button
@ -77,12 +74,12 @@ export function CortexFailureDialog() {
rel="noopener noreferrer"
>
<span className="text-main-view-fg/70">
{t('cortexFailureDialog.contactSupport', 'Contact Support')}
{t('cortexFailureDialog.contactSupport')}
</span>
</a>
</Button>
<Button onClick={handleRestartJan}>
{t('cortexFailureDialog.restartJan', 'Restart Jan')}
{t('cortexFailureDialog.restartJan')}
</Button>
</DialogFooter>
</DialogContent>

View File

@ -0,0 +1,42 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { useEffect, useRef } from 'react'
const DEFAULT_EVENTS = ['mousedown', 'touchstart']
export function useClickOutside<T extends HTMLElement = any>(
handler: () => void,
events?: string[] | null,
nodes?: (HTMLElement | null)[]
) {
const ref = useRef<T>(null)
useEffect(() => {
const listener = (event: any) => {
const { target } = event ?? {}
if (Array.isArray(nodes)) {
const shouldIgnore =
target?.hasAttribute('data-ignore-outside-clicks') ||
(!document.body.contains(target) && target.tagName !== 'HTML')
const shouldTrigger = nodes.every(
(node) => !!node && !event.composedPath().includes(node)
)
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
shouldTrigger && !shouldIgnore && handler()
} else if (ref.current && !ref.current.contains(target)) {
handler()
}
}
;(events || DEFAULT_EVENTS).forEach((fn) =>
document.addEventListener(fn, listener)
)
return () => {
;(events || DEFAULT_EVENTS).forEach((fn) =>
document.removeEventListener(fn, listener)
)
}
}, [ref, handler, nodes, events])
return ref
}

View File

@ -0,0 +1,90 @@
import { useEffect, useRef, useState } from 'react'
import { create } from 'zustand'
export interface UseMediaQueryOptions {
getInitialValueInEffect: boolean
}
type MediaQueryCallback = (event: { matches: boolean; media: string }) => void
// Zustand store for small screen state
type SmallScreenState = {
isSmallScreen: boolean
setIsSmallScreen: (isSmall: boolean) => void
}
export const useSmallScreenStore = create<SmallScreenState>((set) => ({
isSmallScreen: false,
setIsSmallScreen: (isSmall) => set({ isSmallScreen: isSmall }),
}))
/**
* Older versions of Safari (shipped withCatalina and before) do not support addEventListener on matchMedia
* https://stackoverflow.com/questions/56466261/matchmedia-addlistener-marked-as-deprecated-addeventlistener-equivalent
* */
function attachMediaListener(
query: MediaQueryList,
callback: MediaQueryCallback
) {
try {
query.addEventListener('change', callback)
return () => query.removeEventListener('change', callback)
} catch (e) {
console.warn(e)
// eslint-disable @typescript-eslint/no-deprecated
query.addListener(callback)
return () => query.removeListener(callback)
// eslint-enable @typescript-eslint/no-deprecated
}
}
function getInitialValue(query: string, initialValue?: boolean) {
if (typeof initialValue === 'boolean') {
return initialValue
}
if (typeof window !== 'undefined' && 'matchMedia' in window) {
return window.matchMedia(query).matches
}
return false
}
export function useMediaQuery(
query: string,
initialValue?: boolean,
{ getInitialValueInEffect }: UseMediaQueryOptions = {
getInitialValueInEffect: true,
}
): boolean {
const [matches, setMatches] = useState(
getInitialValueInEffect ? initialValue : getInitialValue(query)
)
const queryRef = useRef<MediaQueryList>(null)
useEffect(() => {
if ('matchMedia' in window) {
queryRef.current = window.matchMedia(query)
setMatches(queryRef.current.matches)
return attachMediaListener(queryRef.current, (event) =>
setMatches(event.matches)
)
}
return undefined
}, [query])
return matches || false
}
// Specific hook for small screen detection with state management
export const useSmallScreen = (): boolean => {
const { isSmallScreen, setIsSmallScreen } = useSmallScreenStore()
const mediaQuery = useMediaQuery('(max-width: 768px)')
useEffect(() => {
setIsSmallScreen(mediaQuery)
}, [mediaQuery, setIsSmallScreen])
return isSmallScreen
}

View File

@ -13,7 +13,6 @@
"useModel": "Use this model",
"downloadModel": "Download model",
"searchPlaceholder": "Search for models on Hugging Face...",
"editTheme": "Edit Theme",
"joyride": {
"recommendedModelTitle": "Recommended Model",
"recommendedModelContent": "Browse and download powerful AI models from various providers, all in one place. We suggest starting with Jan-Nano - a model optimized for function calling, tool integration, and research capabilities. It's ideal for building interactive AI agents.",
@ -28,4 +27,4 @@
"next": "Next",
"skip": "Skip"
}
}
}

View File

@ -256,12 +256,5 @@
"description": "Cortex gagal dimulai. Silakan periksa log untuk detail lebih lanjut.",
"contactSupport": "Hubungi Dukungan",
"restartJan": "Restart Jan"
},
"outOfContextError": {
"title": "Kesalahan di luar konteks",
"description": "Obrolan ini mencapai batas memori AI, seperti papan tulis yang penuh. Kami dapat memperluas jendela memori (disebut ukuran konteks) sehingga mengingat lebih banyak, tetapi mungkin menggunakan lebih banyak memori komputer Anda. Kami juga dapat memotong input, yang berarti akan melupakan sebagian riwayat obrolan untuk memberi ruang bagi pesan baru.",
"increaseContextSizeDescription": "Apakah Anda ingin meningkatkan ukuran konteks?",
"truncateInput": "Potong Input",
"increaseContextSize": "Tingkatkan Ukuran Konteks"
}
}

View File

@ -13,7 +13,6 @@
"useModel": "Gunakan model ini",
"downloadModel": "Unduh model",
"searchPlaceholder": "Cari model di Hugging Face...",
"editTheme": "Edit Tema",
"joyride": {
"recommendedModelTitle": "Model yang Direkomendasikan",
"recommendedModelContent": "Jelajahi dan unduh model AI yang kuat dari berbagai penyedia, semuanya di satu tempat. Kami sarankan memulai dengan Jan-Nano - model yang dioptimalkan untuk pemanggilan fungsi, integrasi alat, dan kemampuan penelitian. Ini ideal untuk membangun agen AI interaktif.",
@ -28,4 +27,4 @@
"next": "Berikutnya",
"skip": "Lewati"
}
}
}

View File

@ -256,12 +256,5 @@
"description": "Cortex không khởi động được. Vui lòng kiểm tra log để biết thêm chi tiết.",
"contactSupport": "Liên hệ Hỗ trợ",
"restartJan": "Khởi động lại Jan"
},
"outOfContextError": {
"title": "Lỗi ngoài ngữ cảnh",
"description": "Cuộc trò chuyện này đang đạt đến giới hạn bộ nhớ của AI, giống như một bảng trắng đang đầy. Chúng ta có thể mở rộng cửa sổ bộ nhớ (gọi là kích thước ngữ cảnh) để nó nhớ nhiều hơn, nhưng có thể sử dụng nhiều bộ nhớ máy tính của bạn hơn. Chúng ta cũng có thể cắt bớt đầu vào, có nghĩa là nó sẽ quên một phần lịch sử trò chuyện để nhường chỗ cho tin nhắn mới.",
"increaseContextSizeDescription": "Bạn có muốn tăng kích thước ngữ cảnh không?",
"truncateInput": "Cắt bớt Đầu vào",
"increaseContextSize": "Tăng Kích thước Ngữ cảnh"
}
}

View File

@ -13,7 +13,6 @@
"useModel": "Sử dụng mô hình này",
"downloadModel": "Tải xuống mô hình",
"searchPlaceholder": "Tìm kiếm các mô hình trên Hugging Face...",
"editTheme": "Chỉnh sửa chủ đề",
"joyride": {
"recommendedModelTitle": "Mô hình được đề xuất",
"recommendedModelContent": "Duyệt và tải xuống các mô hình AI mạnh mẽ từ nhiều nhà cung cấp khác nhau, tất cả ở cùng một nơi. Chúng tôi khuyên bạn nên bắt đầu với Jan-Nano - một mô hình được tối ưu hóa cho các khả năng gọi hàm, tích hợp công cụ và nghiên cứu. Nó lý tưởng để xây dựng các tác nhân AI tương tác.",
@ -28,4 +27,4 @@
"next": "Tiếp theo",
"skip": "Bỏ qua"
}
}
}

View File

@ -256,12 +256,5 @@
"description": "Cortex 启动失败。请检查日志以获取更多详细信息。",
"contactSupport": "联系支持",
"restartJan": "重启 Jan"
},
"outOfContextError": {
"title": "超出上下文错误",
"description": "此聊天正在达到AI的内存限制就像白板填满了一样。我们可以扩展内存窗口称为上下文大小使其记住更多内容但可能会使用更多计算机内存。我们也可以截断输入这意味着它会忘记一些聊天历史记录为新消息腾出空间。",
"increaseContextSizeDescription": "您想要增加上下文大小吗?",
"truncateInput": "截断输入",
"increaseContextSize": "增加上下文大小"
}
}

View File

@ -13,7 +13,6 @@
"useModel": "使用此模型",
"downloadModel": "下载模型",
"searchPlaceholder": "在 Hugging Face 上搜索模型...",
"editTheme": "编辑主题",
"joyride": {
"recommendedModelTitle": "推荐模型",
"recommendedModelContent": "在一个地方浏览和下载来自不同提供商的强大 AI 模型。我们建议从 Jan-Nano 开始 - 这是一个针对函数调用、工具集成和研究功能进行优化的模型。它非常适合构建交互式 AI 代理。",
@ -28,4 +27,4 @@
"next": "下一步",
"skip": "跳过"
}
}
}

View File

@ -256,12 +256,5 @@
"description": "Cortex 啟動失敗。請檢查日誌以獲取更多詳細信息。",
"contactSupport": "聯繫支援",
"restartJan": "重啟 Jan"
},
"outOfContextError": {
"title": "超出上下文錯誤",
"description": "此聊天正在達到AI的記憶體限制就像白板填滿了一樣。我們可以擴展記憶體視窗稱為上下文大小使其記住更多內容但可能會使用更多電腦記憶體。我們也可以截斷輸入這意味著它會忘記一些聊天歷史記錄為新訊息騰出空間。",
"increaseContextSizeDescription": "您想要增加上下文大小嗎?",
"truncateInput": "截斷輸入",
"increaseContextSize": "增加上下文大小"
}
}

View File

@ -13,7 +13,6 @@
"useModel": "使用此模型",
"downloadModel": "下載模型",
"searchPlaceholder": "在 Hugging Face 上搜尋模型...",
"editTheme": "編輯主題",
"joyride": {
"recommendedModelTitle": "推薦模型",
"recommendedModelContent": "在一個地方瀏覽和下載來自不同提供商的強大 AI 模型。我們建議從 Jan-Nano 開始 - 這是一個針對函數調用、工具整合和研究功能進行優化的模型。它非常適合構建互動式 AI 代理。",
@ -28,4 +27,4 @@
"next": "下一步",
"skip": "略過"
}
}
}

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 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"
}
}
}

View File

@ -44,8 +44,8 @@ const AppLayout = () => {
{/* Main content panel */}
<div
className={cn(
'h-full flex w-full p-1',
isLeftPanelOpen && 'w-[calc(100%-198px)]'
'h-full flex w-full p-1 ',
isLeftPanelOpen && 'w-full md:w-[calc(100%-198px)]'
)}
>
<div className="bg-main-view text-main-view-fg border border-main-view-fg/5 w-full rounded-lg overflow-hidden">

View File

@ -62,57 +62,60 @@ function Assistant() {
<span>{t('assistants:title')}</span>
</HeaderPage>
<div className="h-full p-4 overflow-y-auto">
<div className="grid grid-cols-3 gap-4">
{assistants.map((assistant) => (
<div
className="bg-main-view-fg/3 p-3 rounded-md"
key={assistant.id}
>
<div className="flex items-center justify-between gap-2">
<h3 className="text-base font-medium text-main-view-fg/80">
<div className="flex items-center gap-1">
{assistant?.avatar && (
<span className="shrink-0 w-4 h-4 relative flex items-center justify-center">
<AvatarEmoji
avatar={assistant?.avatar}
imageClassName="object-cover"
textClassName="text-sm"
/>
</span>
)}
<span className="line-clamp-1">{assistant.name}</span>
</div>
</h3>
<div className="flex items-center gap-0.5">
<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"
title={t('assistants:editAssistant')}
onClick={() => {
setEditingKey(assistant.id)
setOpen(true)
}}
>
<IconPencil size={18} className="text-main-view-fg/50" />
</div>
<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"
title={t('assistants:deleteAssistant')}
onClick={() => handleDelete(assistant.id)}
>
<IconTrash size={18} className="text-main-view-fg/50" />
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4">
{assistants
.slice().sort((a, b) => a.created_at - b.created_at)
.map((assistant) => (
<div
className="bg-main-view-fg/3 p-3 rounded-md"
key={assistant.id}
>
<div className="flex items-center justify-between gap-2">
<h3 className="text-base font-medium text-main-view-fg/80">
<div className="flex items-center gap-1">
{assistant?.avatar && (
<span className="shrink-0 w-4 h-4 relative flex items-center justify-center">
<AvatarEmoji
avatar={assistant?.avatar}
imageClassName="object-cover"
textClassName="text-sm"
/>
</span>
)}
<span className="line-clamp-1">{assistant.name}</span>
</div>
</h3>
<div className="flex items-center gap-0.5">
<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"
title={t('assistants:editAssistant')}
onClick={() => {
setEditingKey(assistant.id)
setOpen(true)
}}
>
<IconPencil size={18} className="text-main-view-fg/50" />
</div>
<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"
title={t('assistants:deleteAssistant')}
onClick={() => handleDelete(assistant.id)}
>
<IconTrash size={18} className="text-main-view-fg/50" />
</div>
</div>
</div>
<p
className="text-main-view-fg/50 mt-1 line-clamp-2"
title={assistant.description}
>
{assistant.description}
</p>
</div>
<p
className="text-main-view-fg/50 mt-1 line-clamp-2"
title={assistant.description}
>
{assistant.description}
</p>
</div>
))}
))}
<div
className="bg-main-view p-3 rounded-md border border-dashed border-main-view-fg/10 flex items-center justify-center cursor-pointer hover:bg-main-view-fg/1 transition-all duration-200 ease-in-out"
className="bg-main-view p-3 min-h-[88px] rounded-md border border-dashed border-main-view-fg/10 flex items-center justify-center cursor-pointer hover:bg-main-view-fg/1 transition-all duration-200 ease-in-out"
key="new-assistant"
onClick={() => {
setEditingKey(null)

View File

@ -363,6 +363,46 @@ function Hub() {
// Check if we're on the last step
const isLastStep = currentStepIndex === steps.length - 1
const renderFilter = () => {
return (
<>
<DropdownMenu>
<DropdownMenuTrigger>
<span className="flex cursor-pointer items-center gap-1 px-2 py-1 rounded-sm bg-main-view-fg/15 text-sm outline-none text-main-view-fg font-medium">
{
sortOptions.find((option) => option.value === sortSelected)
?.name
}
</span>
</DropdownMenuTrigger>
<DropdownMenuContent side="bottom" align="end">
{sortOptions.map((option) => (
<DropdownMenuItem
className={cn(
'cursor-pointer my-0.5',
sortSelected === option.value && 'bg-main-view-fg/5'
)}
key={option.value}
onClick={() => setSortSelected(option.value)}
>
{option.name}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
<div className="flex items-center gap-2">
<Switch
checked={showOnlyDownloaded}
onCheckedChange={setShowOnlyDownloaded}
/>
<span className="text-xs text-main-view-fg/70 font-medium whitespace-nowrap">
{t('hub:downloaded')}
</span>
</div>
</>
)
}
return (
<>
<Joyride
@ -377,6 +417,7 @@ function Hub() {
showSkipButton={!isLastStep}
hideCloseButton={true}
spotlightClicks={true}
disableOverlay={IS_LINUX}
disableOverlayClose={true}
callback={handleJoyrideCallback}
locale={{
@ -395,9 +436,12 @@ function Hub() {
<div className="pr-4 py-3 h-10 w-full flex items-center justify-between relative z-20">
<div className="flex items-center gap-2 w-full">
{isSearching ? (
<Loader className="size-4 animate-spin text-main-view-fg/60" />
<Loader className="shrink-0 size-4 animate-spin text-main-view-fg/60" />
) : (
<IconSearch className="text-main-view-fg/60" size={14} />
<IconSearch
className="shrink-0 text-main-view-fg/60"
size={14}
/>
)}
<input
placeholder={t('hub:searchPlaceholder')}
@ -406,49 +450,13 @@ function Hub() {
className="w-full focus:outline-none"
/>
</div>
<div className="flex items-center gap-2 shrink-0">
<DropdownMenu>
<DropdownMenuTrigger>
<span
title={t('hub:editTheme')}
className="flex cursor-pointer items-center gap-1 px-2 py-1 rounded-sm bg-main-view-fg/15 text-sm outline-none text-main-view-fg font-medium"
>
{
sortOptions.find(
(option) => option.value === sortSelected
)?.name
}
</span>
</DropdownMenuTrigger>
<DropdownMenuContent side="bottom" align="end">
{sortOptions.map((option) => (
<DropdownMenuItem
className={cn(
'cursor-pointer my-0.5',
sortSelected === option.value && 'bg-main-view-fg/5'
)}
key={option.value}
onClick={() => setSortSelected(option.value)}
>
{option.name}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
<div className="flex items-center gap-2">
<Switch
checked={showOnlyDownloaded}
onCheckedChange={setShowOnlyDownloaded}
/>
<span className="text-xs text-main-view-fg/70 font-medium whitespace-nowrap">
{t('hub:downloaded')}
</span>
</div>
<div className="sm:flex items-center gap-2 shrink-0 hidden">
{renderFilter()}
</div>
</div>
</HeaderPage>
<div className="p-4 w-full h-[calc(100%-32px)] !overflow-y-auto first-step-setup-local-provider">
<div className="flex flex-col h-full justify-between gap-4 gap-y-3 w-4/5 mx-auto">
<div className="flex flex-col h-full justify-between gap-4 gap-y-3 w-full md:w-4/5 mx-auto">
{loading ? (
<div className="flex items-center justify-center">
<div className="text-center text-muted-foreground">
@ -463,6 +471,9 @@ function Hub() {
</div>
) : (
<div className="flex flex-col pb-2 mb-2 gap-2 ">
<div className="flex items-center gap-2 justify-end sm:hidden">
{renderFilter()}
</div>
{filteredModels.map((model) => (
<div key={model.id}>
<Card
@ -476,11 +487,14 @@ function Hub() {
>
<h1
className={cn(
'text-main-view-fg font-medium text-base capitalize truncate',
'text-main-view-fg font-medium text-base capitalize truncate max-w-38 sm:max-w-none',
isRecommendedModel(model.metadata?.id)
? 'hub-model-card-step'
: ''
)}
title={
extractModelName(model.metadata?.id) || ''
}
>
{extractModelName(model.metadata?.id) || ''}
</h1>

View File

@ -53,8 +53,8 @@ function Index() {
<HeaderPage>
<DropdownAssistant />
</HeaderPage>
<div className="h-full px-8 overflow-y-auto flex flex-col gap-2 justify-center">
<div className="w-4/6 mx-auto">
<div className="h-full px-4 md:px-8 overflow-y-auto flex flex-col gap-2 justify-center">
<div className="w-full md:w-4/6 mx-auto">
<div className="mb-8 text-center">
<h1 className="font-editorialnew text-main-view-fg text-4xl">
{t('chat:welcome')}

View File

@ -35,7 +35,7 @@ function Appareances() {
<HeaderPage>
<h1 className="font-medium">{t('common:settings')}</h1>
</HeaderPage>
<div className="flex h-full w-full">
<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">
@ -55,26 +55,31 @@ function Appareances() {
<CardItem
title={t('settings:appearance.windowBackground')}
description={t('settings:appearance.windowBackgroundDesc')}
className="flex-col sm:flex-row items-start sm:items-center sm:justify-between gap-y-2"
actions={<ColorPickerAppBgColor />}
/>
<CardItem
title={t('settings:appearance.appMainView')}
description={t('settings:appearance.appMainViewDesc')}
className="flex-col sm:flex-row items-start sm:items-center sm:justify-between gap-y-2"
actions={<ColorPickerAppMainView />}
/>
<CardItem
title={t('settings:appearance.primary')}
description={t('settings:appearance.primaryDesc')}
className="flex-col sm:flex-row items-start sm:items-center sm:justify-between gap-y-2"
actions={<ColorPickerAppPrimaryColor />}
/>
<CardItem
title={t('settings:appearance.accent')}
description={t('settings:appearance.accentDesc')}
className="flex-col sm:flex-row items-start sm:items-center sm:justify-between gap-y-2"
actions={<ColorPickerAppAccentColor />}
/>
<CardItem
title={t('settings:appearance.destructive')}
description={t('settings:appearance.destructiveDesc')}
className="flex-col sm:flex-row items-start sm:items-center sm:justify-between gap-y-2"
actions={<ColorPickerAppDestructiveColor />}
/>
<CardItem

View File

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

View File

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

View File

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

View File

@ -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,
@ -212,6 +211,7 @@ function ProviderDetail() {
showSkipButton={true}
hideCloseButton={true}
spotlightClicks={true}
disableOverlay={IS_LINUX}
disableOverlayClose={true}
callback={handleJoyrideCallback}
locale={{
@ -227,23 +227,13 @@ function ProviderDetail() {
<h1 className="font-medium">{t('common:settings')}</h1>
</HeaderPage>
<div className="flex h-full w-full">
<div className="flex">
<ProvidersMenu stepSetupRemoteProvider={isSetup} />
</div>
<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">
<div className="flex items-center justify-between">
<h1 className="font-medium text-base">
{getProviderTitle(providerName)}
</h1>
<Switch
checked={provider?.active}
onCheckedChange={(e) => {
if (provider) {
updateProvider(providerName, { ...provider, active: e })
}
}}
/>
</div>
<div
@ -460,7 +450,12 @@ function ProviderDetail() {
key={modelIndex}
title={
<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} />
</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>
)
}

View File

@ -19,6 +19,7 @@ import DropdownAssistant from '@/containers/DropdownAssistant'
import { useAssistant } from '@/hooks/useAssistant'
import { useAppearance } from '@/hooks/useAppearance'
import { useTranslation } from '@/i18n/react-i18next-compat'
import { useSmallScreen } from '@/hooks/useMediaQuery'
// as route.threadsDetail
export const Route = createFileRoute('/threads/$threadId')({
@ -37,6 +38,7 @@ function ThreadDetail() {
const { setMessages } = useMessages()
const { streamingContent } = useAppState()
const { appMainViewBgColor, chatWidth } = useAppearance()
const isSmallScreen = useSmallScreen()
const { messages } = useMessages(
useShallow((state) => ({
@ -213,7 +215,8 @@ function ThreadDetail() {
<div
className={cn(
'w-4/6 mx-auto flex max-w-full flex-col grow',
chatWidth === 'compact' ? 'w-4/6' : 'w-full'
chatWidth === 'compact' ? 'w-full md:w-4/6' : 'w-full',
isSmallScreen && 'w-full'
)}
>
{messages &&
@ -252,8 +255,9 @@ function ThreadDetail() {
</div>
<div
className={cn(
' mx-auto pt-2 pb-3 shrink-0 relative',
chatWidth === 'compact' ? 'w-4/6' : 'w-full px-3'
'mx-auto pt-2 pb-3 shrink-0 relative px-2',
chatWidth === 'compact' ? 'w-full md:w-4/6' : 'w-full',
isSmallScreen && 'w-full'
)}
>
<div