* call jan api * fix lint * ci: add jan server web * chore: add Dockerfile * clean up ui ux and support for reasoning fields, make app spa * add logo * chore: update tag for preview image * chore: update k8s service name * chore: update image tag and image name * fixed test --------- Co-authored-by: Minh141120 <minh.itptit@gmail.com> Co-authored-by: Nguyen Ngoc Minh <91668012+Minh141120@users.noreply.github.com>
566 lines
21 KiB
TypeScript
566 lines
21 KiB
TypeScript
import { createFileRoute } from '@tanstack/react-router'
|
|
import { route } from '@/constants/routes'
|
|
import SettingsMenu from '@/containers/SettingsMenu'
|
|
import HeaderPage from '@/containers/HeaderPage'
|
|
import { Switch } from '@/components/ui/switch'
|
|
import { Button } from '@/components/ui/button'
|
|
import { Card, CardItem } from '@/containers/Card'
|
|
import { useTranslation } from '@/i18n/react-i18next-compat'
|
|
import { useGeneralSetting } from '@/hooks/useGeneralSetting'
|
|
import { useAppUpdater } from '@/hooks/useAppUpdater'
|
|
import { useEffect, useState, useCallback } from 'react'
|
|
import ChangeDataFolderLocation from '@/containers/dialogs/ChangeDataFolderLocation'
|
|
|
|
import {
|
|
Dialog,
|
|
DialogClose,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
DialogTrigger,
|
|
} from '@/components/ui/dialog'
|
|
import { useServiceHub } from '@/hooks/useServiceHub'
|
|
import {
|
|
IconBrandDiscord,
|
|
IconBrandGithub,
|
|
IconExternalLink,
|
|
IconFolder,
|
|
IconLogs,
|
|
IconCopy,
|
|
IconCopyCheck,
|
|
} from '@tabler/icons-react'
|
|
// import { windowKey } from '@/constants/windows'
|
|
import { toast } from 'sonner'
|
|
import { isDev } from '@/lib/utils'
|
|
import { SystemEvent } from '@/types/events'
|
|
import { Input } from '@/components/ui/input'
|
|
import { useHardware } from '@/hooks/useHardware'
|
|
import LanguageSwitcher from '@/containers/LanguageSwitcher'
|
|
import { PlatformFeatures } from '@/lib/platform/const'
|
|
import { PlatformFeature } from '@/lib/platform/types'
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
export const Route = createFileRoute(route.settings.general as any)({
|
|
component: General,
|
|
})
|
|
|
|
function General() {
|
|
const { t } = useTranslation()
|
|
const {
|
|
spellCheckChatInput,
|
|
setSpellCheckChatInput,
|
|
huggingfaceToken,
|
|
setHuggingfaceToken,
|
|
} = useGeneralSetting()
|
|
const serviceHub = useServiceHub()
|
|
|
|
const openFileTitle = (): string => {
|
|
if (IS_MACOS) {
|
|
return t('settings:general.showInFinder')
|
|
} else if (IS_WINDOWS) {
|
|
return t('settings:general.showInFileExplorer')
|
|
} else {
|
|
return t('settings:general.openContainingFolder')
|
|
}
|
|
}
|
|
const { checkForUpdate } = useAppUpdater()
|
|
const { pausePolling } = useHardware()
|
|
const [janDataFolder, setJanDataFolder] = useState<string | undefined>()
|
|
const [isCopied, setIsCopied] = useState(false)
|
|
const [selectedNewPath, setSelectedNewPath] = useState<string | null>(null)
|
|
const [isDialogOpen, setIsDialogOpen] = useState(false)
|
|
const [isCheckingUpdate, setIsCheckingUpdate] = useState(false)
|
|
|
|
useEffect(() => {
|
|
const fetchDataFolder = async () => {
|
|
const path = await serviceHub.app().getJanDataFolder()
|
|
setJanDataFolder(path)
|
|
}
|
|
|
|
fetchDataFolder()
|
|
}, [serviceHub])
|
|
|
|
const resetApp = async () => {
|
|
pausePolling()
|
|
// TODO: Loading indicator
|
|
await serviceHub.app().factoryReset()
|
|
}
|
|
|
|
const handleOpenLogs = async () => {
|
|
try {
|
|
await serviceHub.window().openLogsWindow()
|
|
} catch (error) {
|
|
console.error('Failed to open logs window:', error)
|
|
}
|
|
}
|
|
|
|
const copyToClipboard = async (text: string) => {
|
|
try {
|
|
await navigator.clipboard.writeText(text)
|
|
setIsCopied(true)
|
|
setTimeout(() => setIsCopied(false), 2000) // Reset after 2 seconds
|
|
} catch (error) {
|
|
console.error('Failed to copy to clipboard:', error)
|
|
}
|
|
}
|
|
|
|
const handleDataFolderChange = async () => {
|
|
const selectedPath = await serviceHub.dialog().open({
|
|
multiple: false,
|
|
directory: true,
|
|
defaultPath: janDataFolder,
|
|
})
|
|
|
|
if (selectedPath === janDataFolder) return
|
|
if (selectedPath !== null) {
|
|
setSelectedNewPath(selectedPath as string)
|
|
setIsDialogOpen(true)
|
|
}
|
|
}
|
|
|
|
const confirmDataFolderChange = async () => {
|
|
if (selectedNewPath) {
|
|
try {
|
|
await serviceHub.models().stopAllModels()
|
|
serviceHub.events().emit(SystemEvent.KILL_SIDECAR)
|
|
setTimeout(async () => {
|
|
try {
|
|
await serviceHub.app().relocateJanDataFolder(selectedNewPath)
|
|
setJanDataFolder(selectedNewPath)
|
|
// Only relaunch if relocation was successful
|
|
window.core?.api?.relaunch()
|
|
setSelectedNewPath(null)
|
|
setIsDialogOpen(false)
|
|
} catch (error) {
|
|
console.error(error)
|
|
toast.error(
|
|
error instanceof Error
|
|
? error.message
|
|
: t('settings:general.failedToRelocateDataFolder')
|
|
)
|
|
}
|
|
}, 1000)
|
|
} catch (error) {
|
|
console.error('Failed to relocate data folder:', error)
|
|
// Revert the data folder path on error
|
|
const originalPath = await serviceHub.app().getJanDataFolder()
|
|
setJanDataFolder(originalPath)
|
|
|
|
toast.error(t('settings:general.failedToRelocateDataFolderDesc'))
|
|
}
|
|
}
|
|
}
|
|
|
|
const handleCheckForUpdate = useCallback(async () => {
|
|
setIsCheckingUpdate(true)
|
|
try {
|
|
if (isDev()) return toast.info(t('settings:general.devVersion'))
|
|
const update = await checkForUpdate(true)
|
|
if (!update) {
|
|
toast.info(t('settings:general.noUpdateAvailable'))
|
|
}
|
|
// If update is available, the AppUpdater dialog will automatically show
|
|
} catch (error) {
|
|
console.error('Failed to check for updates:', error)
|
|
toast.error(t('settings:general.updateError'))
|
|
} finally {
|
|
setIsCheckingUpdate(false)
|
|
}
|
|
}, [t, checkForUpdate])
|
|
|
|
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">
|
|
{/* General */}
|
|
<Card title={t('common:general')}>
|
|
{PlatformFeatures[PlatformFeature.SYSTEM_INTEGRATIONS] && (
|
|
<CardItem
|
|
title={t('settings:general.appVersion')}
|
|
actions={
|
|
<span className="text-main-view-fg/80 font-medium">
|
|
v{VERSION}
|
|
</span>
|
|
}
|
|
/>
|
|
)}
|
|
{!AUTO_UPDATER_DISABLED && PlatformFeatures[PlatformFeature.SYSTEM_INTEGRATIONS] && (
|
|
<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"
|
|
size="sm"
|
|
className="p-0"
|
|
onClick={handleCheckForUpdate}
|
|
disabled={isCheckingUpdate}
|
|
>
|
|
<div className="cursor-pointer rounded-sm hover:bg-main-view-fg/15 bg-main-view-fg/10 transition-all duration-200 ease-in-out px-2 py-1 gap-1">
|
|
{isCheckingUpdate
|
|
? t('settings:general.checkingForUpdates')
|
|
: t('settings:general.checkForUpdates')}
|
|
</div>
|
|
</Button>
|
|
}
|
|
/>
|
|
)}
|
|
<CardItem
|
|
title={t('common:language')}
|
|
actions={<LanguageSwitcher />}
|
|
/>
|
|
</Card>
|
|
|
|
{/* Data folder - Desktop only */}
|
|
{PlatformFeatures[PlatformFeature.SYSTEM_INTEGRATIONS] && (
|
|
<Card title={t('common:dataFolder')}>
|
|
<CardItem
|
|
title={t('settings:dataFolder.appData', {
|
|
ns: 'settings',
|
|
})}
|
|
align="start"
|
|
className="flex-col sm:flex-row items-start sm:items-center sm:justify-between gap-y-2"
|
|
description={
|
|
<>
|
|
<span>
|
|
{t('settings:dataFolder.appDataDesc', {
|
|
ns: 'settings',
|
|
})}
|
|
|
|
</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)
|
|
}
|
|
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"
|
|
title={
|
|
isCopied
|
|
? t('settings:general.copied')
|
|
: t('settings:general.copyPath')
|
|
}
|
|
>
|
|
{isCopied ? (
|
|
<div className="flex items-center gap-1">
|
|
<IconCopyCheck size={12} className="text-accent" />
|
|
<span className="text-xs leading-0">
|
|
{t('settings:general.copied')}
|
|
</span>
|
|
</div>
|
|
) : (
|
|
<IconCopy
|
|
size={12}
|
|
className="text-main-view-fg/50"
|
|
/>
|
|
)}
|
|
</button>
|
|
</div>
|
|
</>
|
|
}
|
|
actions={
|
|
<>
|
|
<Button
|
|
variant="link"
|
|
size="sm"
|
|
className="p-0"
|
|
title={t('settings:dataFolder.appData')}
|
|
onClick={handleDataFolderChange}
|
|
>
|
|
<div className="cursor-pointer flex items-center justify-center rounded-sm hover:bg-main-view-fg/15 bg-main-view-fg/10 transition-all duration-200 ease-in-out px-2 py-1 gap-1">
|
|
<IconFolder
|
|
size={12}
|
|
className="text-main-view-fg/50"
|
|
/>
|
|
<span>{t('settings:general.changeLocation')}</span>
|
|
</div>
|
|
</Button>
|
|
{selectedNewPath && (
|
|
<ChangeDataFolderLocation
|
|
currentPath={janDataFolder || ''}
|
|
newPath={selectedNewPath}
|
|
onConfirm={confirmDataFolderChange}
|
|
open={isDialogOpen}
|
|
onOpenChange={(open) => {
|
|
setIsDialogOpen(open)
|
|
if (!open) {
|
|
setSelectedNewPath(null)
|
|
}
|
|
}}
|
|
>
|
|
<div />
|
|
</ChangeDataFolderLocation>
|
|
)}
|
|
</>
|
|
}
|
|
/>
|
|
<CardItem
|
|
title={t('settings:dataFolder.appLogs', {
|
|
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
|
|
variant="link"
|
|
size="sm"
|
|
className="p-0"
|
|
onClick={handleOpenLogs}
|
|
title={t('settings:dataFolder.appLogs')}
|
|
>
|
|
<div className="cursor-pointer flex items-center justify-center rounded-sm hover:bg-main-view-fg/15 bg-main-view-fg/10 transition-all duration-200 ease-in-out px-2 py-1 gap-1">
|
|
<IconLogs size={12} className="text-main-view-fg/50" />
|
|
<span>{t('settings:general.openLogs')}</span>
|
|
</div>
|
|
</Button>
|
|
<Button
|
|
variant="link"
|
|
size="sm"
|
|
className="p-0"
|
|
onClick={async () => {
|
|
if (janDataFolder) {
|
|
try {
|
|
const logsPath = `${janDataFolder}/logs`
|
|
await serviceHub.opener().revealItemInDir(logsPath)
|
|
} catch (error) {
|
|
console.error(
|
|
'Failed to reveal logs folder:',
|
|
error
|
|
)
|
|
}
|
|
}
|
|
}}
|
|
title={t('settings:general.revealLogs')}
|
|
>
|
|
<div className="cursor-pointer flex items-center justify-center rounded-sm hover:bg-main-view-fg/15 bg-main-view-fg/10 transition-all duration-200 ease-in-out px-2 py-1 gap-1">
|
|
<IconFolder
|
|
size={12}
|
|
className="text-main-view-fg/50"
|
|
/>
|
|
<span>{openFileTitle()}</span>
|
|
</div>
|
|
</Button>
|
|
</div>
|
|
}
|
|
/>
|
|
</Card>
|
|
)}
|
|
{/* Advanced - Desktop only */}
|
|
{PlatformFeatures[PlatformFeature.SYSTEM_INTEGRATIONS] && (
|
|
<Card title="Advanced">
|
|
<CardItem
|
|
title={t('settings:others.resetFactory', {
|
|
ns: 'settings',
|
|
})}
|
|
description={t('settings:others.resetFactoryDesc', {
|
|
ns: 'settings',
|
|
})}
|
|
actions={
|
|
<Dialog>
|
|
<DialogTrigger asChild>
|
|
<Button variant="destructive" size="sm">
|
|
{t('common:reset')}
|
|
</Button>
|
|
</DialogTrigger>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>
|
|
{t('settings:general.factoryResetTitle')}
|
|
</DialogTitle>
|
|
<DialogDescription>
|
|
{t('settings:general.factoryResetDesc')}
|
|
</DialogDescription>
|
|
<DialogFooter className="mt-2 flex items-center">
|
|
<DialogClose asChild>
|
|
<Button
|
|
variant="link"
|
|
size="sm"
|
|
className="hover:no-underline"
|
|
>
|
|
{t('settings:general.cancel')}
|
|
</Button>
|
|
</DialogClose>
|
|
<DialogClose asChild>
|
|
<Button
|
|
variant="destructive"
|
|
onClick={() => resetApp()}
|
|
>
|
|
{t('settings:general.reset')}
|
|
</Button>
|
|
</DialogClose>
|
|
</DialogFooter>
|
|
</DialogHeader>
|
|
</DialogContent>
|
|
</Dialog>
|
|
}
|
|
/>
|
|
</Card>
|
|
)}
|
|
|
|
{/* Other */}
|
|
<Card title={t('common:others')}>
|
|
<CardItem
|
|
title={t('settings:others.spellCheck', {
|
|
ns: 'settings',
|
|
})}
|
|
description={t('settings:others.spellCheckDesc', {
|
|
ns: 'settings',
|
|
})}
|
|
actions={
|
|
<Switch
|
|
checked={spellCheckChatInput}
|
|
onCheckedChange={(e) => setSpellCheckChatInput(e)}
|
|
/>
|
|
}
|
|
/>
|
|
{PlatformFeatures[PlatformFeature.MODEL_HUB] && (
|
|
<CardItem
|
|
title={t('settings:general.huggingfaceToken', {
|
|
ns: 'settings',
|
|
})}
|
|
description={t('settings:general.huggingfaceTokenDesc', {
|
|
ns: 'settings',
|
|
})}
|
|
actions={
|
|
<Input
|
|
id="hf-token"
|
|
value={huggingfaceToken || ''}
|
|
onChange={(e) => setHuggingfaceToken(e.target.value)}
|
|
placeholder={'hf_xxx'}
|
|
required
|
|
/>
|
|
}
|
|
/>
|
|
)}
|
|
</Card>
|
|
|
|
{/* Resources */}
|
|
<Card title={t('settings:general.resources')}>
|
|
<CardItem
|
|
title={t('settings:general.documentation')}
|
|
description={t('settings:general.documentationDesc')}
|
|
actions={
|
|
<a
|
|
href="https://jan.ai/docs"
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
>
|
|
<div className="flex items-center gap-1">
|
|
<span>{t('settings:general.viewDocs')}</span>
|
|
<IconExternalLink size={14} />
|
|
</div>
|
|
</a>
|
|
}
|
|
/>
|
|
<CardItem
|
|
title={t('settings:general.releaseNotes')}
|
|
description={t('settings:general.releaseNotesDesc')}
|
|
actions={
|
|
<a
|
|
href="https://github.com/menloresearch/jan/releases"
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
>
|
|
<div className="flex items-center gap-1">
|
|
<span>{t('settings:general.viewReleases')}</span>
|
|
<IconExternalLink size={14} />
|
|
</div>
|
|
</a>
|
|
}
|
|
/>
|
|
</Card>
|
|
|
|
{/* Community */}
|
|
<Card title={t('settings:general.community')}>
|
|
<CardItem
|
|
title={t('settings:general.github')}
|
|
description={t('settings:general.githubDesc')}
|
|
actions={
|
|
<a
|
|
href="https://github.com/menloresearch/jan"
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
>
|
|
<div className="size-6 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">
|
|
<IconBrandGithub
|
|
size={18}
|
|
className="text-main-view-fg/50"
|
|
/>
|
|
</div>
|
|
</a>
|
|
}
|
|
/>
|
|
<CardItem
|
|
title={t('settings:general.discord')}
|
|
description={t('settings:general.discordDesc')}
|
|
actions={
|
|
<a
|
|
href="https://discord.com/invite/FTk2MvZwJH"
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
>
|
|
<div className="size-6 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">
|
|
<IconBrandDiscord
|
|
size={18}
|
|
className="text-main-view-fg/50"
|
|
/>
|
|
</div>
|
|
</a>
|
|
}
|
|
/>
|
|
</Card>
|
|
|
|
{/* Support */}
|
|
<Card title={t('settings:general.support')}>
|
|
<CardItem
|
|
title={t('settings:general.reportAnIssue')}
|
|
description={t('settings:general.reportAnIssueDesc')}
|
|
actions={
|
|
<a
|
|
href="https://github.com/menloresearch/jan/issues/new"
|
|
target="_blank"
|
|
>
|
|
<div className="flex items-center gap-1">
|
|
<span>{t('settings:general.reportIssue')}</span>
|
|
<IconExternalLink size={14} />
|
|
</div>
|
|
</a>
|
|
}
|
|
/>
|
|
</Card>
|
|
|
|
{/* Credits */}
|
|
<Card title={t('settings:general.credits')}>
|
|
<CardItem
|
|
align="start"
|
|
description={
|
|
<div className="text-main-view-fg/70 -mt-2">
|
|
<p>{t('settings:general.creditsDesc1')}</p>
|
|
<p className="mt-2">{t('settings:general.creditsDesc2')}</p>
|
|
</div>
|
|
}
|
|
/>
|
|
</Card>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|