jan/web-app/src/routes/settings/general.tsx
Dinh Long Nguyen d490174544
feat: Web use jan model (#6374)
* 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>
2025-09-05 16:18:30 +07:00

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',
})}
&nbsp;
</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>
)
}