Merge branch 'dev' into fix/enable-back-app-language-setting

This commit is contained in:
Louis 2025-08-21 12:53:21 +07:00 committed by GitHub
commit 9bc243c3f7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 315 additions and 254 deletions

View File

@ -57,7 +57,7 @@ const ChatInput = ({ model, className, initialMessage }: ChatInputProps) => {
const { prompt, setPrompt } = usePrompt() const { prompt, setPrompt } = usePrompt()
const { currentThreadId } = useThreads() const { currentThreadId } = useThreads()
const { t } = useTranslation() const { t } = useTranslation()
const { spellCheckChatInput, experimentalFeatures } = useGeneralSetting() const { spellCheckChatInput } = useGeneralSetting()
const maxRows = 10 const maxRows = 10
@ -586,8 +586,7 @@ const ChatInput = ({ model, className, initialMessage }: ChatInputProps) => {
</TooltipProvider> </TooltipProvider>
)} )}
{experimentalFeatures && {selectedModel?.capabilities?.includes('tools') &&
selectedModel?.capabilities?.includes('tools') &&
hasActiveMCPServers && ( hasActiveMCPServers && (
<TooltipProvider> <TooltipProvider>
<Tooltip <Tooltip

View File

@ -11,7 +11,6 @@ import {
import { useMatches, useNavigate } from '@tanstack/react-router' import { useMatches, useNavigate } from '@tanstack/react-router'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useGeneralSetting } from '@/hooks/useGeneralSetting'
import { useModelProvider } from '@/hooks/useModelProvider' import { useModelProvider } from '@/hooks/useModelProvider'
import { getProviderTitle } from '@/lib/utils' import { getProviderTitle } from '@/lib/utils'
import ProvidersAvatar from '@/containers/ProvidersAvatar' import ProvidersAvatar from '@/containers/ProvidersAvatar'
@ -23,7 +22,6 @@ const SettingsMenu = () => {
const matches = useMatches() const matches = useMatches()
const navigate = useNavigate() const navigate = useNavigate()
const { experimentalFeatures } = useGeneralSetting()
const { providers } = useModelProvider() const { providers } = useModelProvider()
// Filter providers that have active API keys (or are llama.cpp which doesn't need one) // Filter providers that have active API keys (or are llama.cpp which doesn't need one)
@ -79,15 +77,10 @@ const SettingsMenu = () => {
title: 'common:hardware', title: 'common:hardware',
route: route.settings.hardware, route: route.settings.hardware,
}, },
// Only show MCP Servers when experimental features are enabled
...(experimentalFeatures
? [
{ {
title: 'common:mcp-servers', title: 'common:mcp-servers',
route: route.settings.mcp_servers, route: route.settings.mcp_servers,
}, },
]
: []),
{ {
title: 'common:local_api_server', title: 'common:local_api_server',
route: route.settings.local_api_server, route: route.settings.local_api_server,

View File

@ -5,7 +5,6 @@ import SettingsMenu from '../SettingsMenu'
import { useNavigate, useMatches } from '@tanstack/react-router' import { useNavigate, useMatches } from '@tanstack/react-router'
import { useGeneralSetting } from '@/hooks/useGeneralSetting' import { useGeneralSetting } from '@/hooks/useGeneralSetting'
import { useModelProvider } from '@/hooks/useModelProvider' import { useModelProvider } from '@/hooks/useModelProvider'
import { useAppState } from '@/hooks/useAppState'
// Mock dependencies // Mock dependencies
vi.mock('@tanstack/react-router', () => ({ vi.mock('@tanstack/react-router', () => ({
@ -25,9 +24,7 @@ vi.mock('@/i18n/react-i18next-compat', () => ({
})) }))
vi.mock('@/hooks/useGeneralSetting', () => ({ vi.mock('@/hooks/useGeneralSetting', () => ({
useGeneralSetting: vi.fn(() => ({ useGeneralSetting: vi.fn(() => ({})),
experimentalFeatures: false,
})),
})) }))
vi.mock('@/hooks/useModelProvider', () => ({ vi.mock('@/hooks/useModelProvider', () => ({
@ -88,21 +85,6 @@ describe('SettingsMenu', () => {
expect(screen.getByText('common:local_api_server')).toBeInTheDocument() expect(screen.getByText('common:local_api_server')).toBeInTheDocument()
expect(screen.getByText('common:https_proxy')).toBeInTheDocument() expect(screen.getByText('common:https_proxy')).toBeInTheDocument()
expect(screen.getByText('common:extensions')).toBeInTheDocument() expect(screen.getByText('common:extensions')).toBeInTheDocument()
})
it('does not show MCP Servers when experimental features disabled', () => {
render(<SettingsMenu />)
expect(screen.queryByText('common:mcp-servers')).not.toBeInTheDocument()
})
it('shows MCP Servers when experimental features enabled', () => {
vi.mocked(useGeneralSetting).mockReturnValue({
experimentalFeatures: true,
})
render(<SettingsMenu />)
expect(screen.getByText('common:mcp-servers')).toBeInTheDocument() expect(screen.getByText('common:mcp-servers')).toBeInTheDocument()
}) })
@ -110,7 +92,7 @@ describe('SettingsMenu', () => {
render(<SettingsMenu />) render(<SettingsMenu />)
const chevronButtons = screen.getAllByRole('button') const chevronButtons = screen.getAllByRole('button')
const chevron = chevronButtons.find(button => const chevron = chevronButtons.find((button) =>
button.querySelector('svg.tabler-icon-chevron-right') button.querySelector('svg.tabler-icon-chevron-right')
) )
expect(chevron).toBeInTheDocument() expect(chevron).toBeInTheDocument()
@ -121,7 +103,7 @@ describe('SettingsMenu', () => {
render(<SettingsMenu />) render(<SettingsMenu />)
const chevronButtons = screen.getAllByRole('button') const chevronButtons = screen.getAllByRole('button')
const chevron = chevronButtons.find(button => const chevron = chevronButtons.find((button) =>
button.querySelector('svg.tabler-icon-chevron-right') button.querySelector('svg.tabler-icon-chevron-right')
) )
if (!chevron) throw new Error('Chevron button not found') if (!chevron) throw new Error('Chevron button not found')
@ -159,12 +141,14 @@ describe('SettingsMenu', () => {
// First expand the providers submenu // First expand the providers submenu
const chevronButtons = screen.getAllByRole('button') const chevronButtons = screen.getAllByRole('button')
const chevron = chevronButtons.find(button => const chevron = chevronButtons.find((button) =>
button.querySelector('svg.tabler-icon-chevron-right') button.querySelector('svg.tabler-icon-chevron-right')
) )
if (chevron) await user.click(chevron) if (chevron) await user.click(chevron)
const openaiProvider = screen.getByTestId('provider-avatar-openai').closest('div') const openaiProvider = screen
.getByTestId('provider-avatar-openai')
.closest('div')
expect(openaiProvider).toBeInTheDocument() expect(openaiProvider).toBeInTheDocument()
}) })
@ -174,14 +158,16 @@ describe('SettingsMenu', () => {
// First expand the providers // First expand the providers
const chevronButtons = screen.getAllByRole('button') const chevronButtons = screen.getAllByRole('button')
const chevron = chevronButtons.find(button => const chevron = chevronButtons.find((button) =>
button.querySelector('svg.tabler-icon-chevron-right') button.querySelector('svg.tabler-icon-chevron-right')
) )
if (!chevron) throw new Error('Chevron button not found') if (!chevron) throw new Error('Chevron button not found')
await user.click(chevron) await user.click(chevron)
// Then click on a provider // Then click on a provider
const openaiProvider = screen.getByTestId('provider-avatar-openai').closest('div') const openaiProvider = screen
.getByTestId('provider-avatar-openai')
.closest('div')
await user.click(openaiProvider!) await user.click(openaiProvider!)
expect(mockNavigate).toHaveBeenCalledWith({ expect(mockNavigate).toHaveBeenCalledWith({
@ -193,7 +179,9 @@ describe('SettingsMenu', () => {
it('shows mobile menu toggle button', () => { it('shows mobile menu toggle button', () => {
render(<SettingsMenu />) render(<SettingsMenu />)
const menuToggle = screen.getByRole('button', { name: 'Toggle settings menu' }) const menuToggle = screen.getByRole('button', {
name: 'Toggle settings menu',
})
expect(menuToggle).toBeInTheDocument() expect(menuToggle).toBeInTheDocument()
}) })
@ -201,7 +189,9 @@ describe('SettingsMenu', () => {
const user = userEvent.setup() const user = userEvent.setup()
render(<SettingsMenu />) render(<SettingsMenu />)
const menuToggle = screen.getByRole('button', { name: 'Toggle settings menu' }) const menuToggle = screen.getByRole('button', {
name: 'Toggle settings menu',
})
await user.click(menuToggle) await user.click(menuToggle)
// Menu should now be visible // Menu should now be visible
@ -214,7 +204,9 @@ describe('SettingsMenu', () => {
render(<SettingsMenu />) render(<SettingsMenu />)
// Open menu first // Open menu first
const menuToggle = screen.getByRole('button', { name: 'Toggle settings menu' }) const menuToggle = screen.getByRole('button', {
name: 'Toggle settings menu',
})
await user.click(menuToggle) await user.click(menuToggle)
// Then close it // Then close it
@ -239,7 +231,7 @@ describe('SettingsMenu', () => {
// First expand the providers submenu // First expand the providers submenu
const chevronButtons = screen.getAllByRole('button') const chevronButtons = screen.getAllByRole('button')
const chevron = chevronButtons.find(button => const chevron = chevronButtons.find((button) =>
button.querySelector('svg.tabler-icon-chevron-right') button.querySelector('svg.tabler-icon-chevron-right')
) )
if (chevron) await user.click(chevron) if (chevron) await user.click(chevron)
@ -273,12 +265,14 @@ describe('SettingsMenu', () => {
// Expand providers // Expand providers
const chevronButtons = screen.getAllByRole('button') const chevronButtons = screen.getAllByRole('button')
const chevron = chevronButtons.find(button => const chevron = chevronButtons.find((button) =>
button.querySelector('svg.tabler-icon-chevron-right') button.querySelector('svg.tabler-icon-chevron-right')
) )
if (chevron) await user.click(chevron) if (chevron) await user.click(chevron)
expect(screen.getByTestId('provider-avatar-openai')).toBeInTheDocument() expect(screen.getByTestId('provider-avatar-openai')).toBeInTheDocument()
expect(screen.queryByTestId('provider-avatar-anthropic')).not.toBeInTheDocument() expect(
screen.queryByTestId('provider-avatar-anthropic')
).not.toBeInTheDocument()
}) })
}) })

View File

@ -40,7 +40,6 @@ describe('useGeneralSetting', () => {
useGeneralSetting.setState({ useGeneralSetting.setState({
currentLanguage: 'en', currentLanguage: 'en',
spellCheckChatInput: true, spellCheckChatInput: true,
experimentalFeatures: false,
huggingfaceToken: undefined, huggingfaceToken: undefined,
}) })
@ -60,11 +59,9 @@ describe('useGeneralSetting', () => {
expect(result.current.currentLanguage).toBe('en') expect(result.current.currentLanguage).toBe('en')
expect(result.current.spellCheckChatInput).toBe(true) expect(result.current.spellCheckChatInput).toBe(true)
expect(result.current.experimentalFeatures).toBe(false)
expect(result.current.huggingfaceToken).toBeUndefined() expect(result.current.huggingfaceToken).toBeUndefined()
expect(typeof result.current.setCurrentLanguage).toBe('function') expect(typeof result.current.setCurrentLanguage).toBe('function')
expect(typeof result.current.setSpellCheckChatInput).toBe('function') expect(typeof result.current.setSpellCheckChatInput).toBe('function')
expect(typeof result.current.setExperimentalFeatures).toBe('function')
expect(typeof result.current.setHuggingfaceToken).toBe('function') expect(typeof result.current.setHuggingfaceToken).toBe('function')
}) })
@ -155,42 +152,6 @@ describe('useGeneralSetting', () => {
}) })
}) })
describe('setExperimentalFeatures', () => {
it('should enable experimental features', () => {
const { result } = renderHook(() => useGeneralSetting())
act(() => {
result.current.setExperimentalFeatures(true)
})
expect(result.current.experimentalFeatures).toBe(true)
})
it('should disable experimental features', () => {
const { result } = renderHook(() => useGeneralSetting())
act(() => {
result.current.setExperimentalFeatures(false)
})
expect(result.current.experimentalFeatures).toBe(false)
})
it('should toggle experimental features multiple times', () => {
const { result } = renderHook(() => useGeneralSetting())
act(() => {
result.current.setExperimentalFeatures(true)
})
expect(result.current.experimentalFeatures).toBe(true)
act(() => {
result.current.setExperimentalFeatures(false)
})
expect(result.current.experimentalFeatures).toBe(false)
})
})
describe('setHuggingfaceToken', () => { describe('setHuggingfaceToken', () => {
it('should set huggingface token', () => { it('should set huggingface token', () => {
const { result } = renderHook(() => useGeneralSetting()) const { result } = renderHook(() => useGeneralSetting())
@ -254,7 +215,7 @@ describe('useGeneralSetting', () => {
expect(mockGetByName).toHaveBeenCalledWith('@janhq/download-extension') expect(mockGetByName).toHaveBeenCalledWith('@janhq/download-extension')
// Wait for async operations // Wait for async operations
await new Promise(resolve => setTimeout(resolve, 0)) await new Promise((resolve) => setTimeout(resolve, 0))
expect(mockGetSettings).toHaveBeenCalled() expect(mockGetSettings).toHaveBeenCalled()
expect(mockUpdateSettings).toHaveBeenCalledWith([ expect(mockUpdateSettings).toHaveBeenCalledWith([
@ -272,13 +233,11 @@ describe('useGeneralSetting', () => {
act(() => { act(() => {
result1.current.setCurrentLanguage('id') result1.current.setCurrentLanguage('id')
result1.current.setSpellCheckChatInput(false) result1.current.setSpellCheckChatInput(false)
result1.current.setExperimentalFeatures(true)
result1.current.setHuggingfaceToken('shared-token') result1.current.setHuggingfaceToken('shared-token')
}) })
expect(result2.current.currentLanguage).toBe('id') expect(result2.current.currentLanguage).toBe('id')
expect(result2.current.spellCheckChatInput).toBe(false) expect(result2.current.spellCheckChatInput).toBe(false)
expect(result2.current.experimentalFeatures).toBe(true)
expect(result2.current.huggingfaceToken).toBe('shared-token') expect(result2.current.huggingfaceToken).toBe('shared-token')
}) })
}) })
@ -290,13 +249,11 @@ describe('useGeneralSetting', () => {
act(() => { act(() => {
result.current.setCurrentLanguage('vn') result.current.setCurrentLanguage('vn')
result.current.setSpellCheckChatInput(false) result.current.setSpellCheckChatInput(false)
result.current.setExperimentalFeatures(true)
result.current.setHuggingfaceToken('complex-token-123') result.current.setHuggingfaceToken('complex-token-123')
}) })
expect(result.current.currentLanguage).toBe('vn') expect(result.current.currentLanguage).toBe('vn')
expect(result.current.spellCheckChatInput).toBe(false) expect(result.current.spellCheckChatInput).toBe(false)
expect(result.current.experimentalFeatures).toBe(true)
expect(result.current.huggingfaceToken).toBe('complex-token-123') expect(result.current.huggingfaceToken).toBe('complex-token-123')
}) })
@ -314,11 +271,9 @@ describe('useGeneralSetting', () => {
// Second update // Second update
act(() => { act(() => {
result.current.setExperimentalFeatures(true)
result.current.setHuggingfaceToken('sequential-token') result.current.setHuggingfaceToken('sequential-token')
}) })
expect(result.current.experimentalFeatures).toBe(true)
expect(result.current.huggingfaceToken).toBe('sequential-token') expect(result.current.huggingfaceToken).toBe('sequential-token')
// Third update // Third update

View File

@ -24,7 +24,7 @@ describe('useLocalApiServer', () => {
vi.clearAllMocks() vi.clearAllMocks()
// Reset store state to defaults // Reset store state to defaults
const store = useLocalApiServer.getState() const store = useLocalApiServer.getState()
store.setRunOnStartup(true) store.setEnableOnStartup(true)
store.setServerHost('127.0.0.1') store.setServerHost('127.0.0.1')
store.setServerPort(1337) store.setServerPort(1337)
store.setApiPrefix('/v1') store.setApiPrefix('/v1')
@ -37,7 +37,7 @@ describe('useLocalApiServer', () => {
it('should initialize with default values', () => { it('should initialize with default values', () => {
const { result } = renderHook(() => useLocalApiServer()) const { result } = renderHook(() => useLocalApiServer())
expect(result.current.runOnStartup).toBe(true) expect(result.current.enableOnStartup).toBe(true)
expect(result.current.serverHost).toBe('127.0.0.1') expect(result.current.serverHost).toBe('127.0.0.1')
expect(result.current.serverPort).toBe(1337) expect(result.current.serverPort).toBe(1337)
expect(result.current.apiPrefix).toBe('/v1') expect(result.current.apiPrefix).toBe('/v1')
@ -47,21 +47,21 @@ describe('useLocalApiServer', () => {
expect(result.current.apiKey).toBe('') expect(result.current.apiKey).toBe('')
}) })
describe('runOnStartup', () => { describe('enableOnStartup', () => {
it('should set run on startup', () => { it('should set run on startup', () => {
const { result } = renderHook(() => useLocalApiServer()) const { result } = renderHook(() => useLocalApiServer())
act(() => { act(() => {
result.current.setRunOnStartup(false) result.current.setEnableOnStartup(false)
}) })
expect(result.current.runOnStartup).toBe(false) expect(result.current.enableOnStartup).toBe(false)
act(() => { act(() => {
result.current.setRunOnStartup(true) result.current.setEnableOnStartup(true)
}) })
expect(result.current.runOnStartup).toBe(true) expect(result.current.enableOnStartup).toBe(true)
}) })
}) })
@ -323,7 +323,7 @@ describe('useLocalApiServer', () => {
const { result: result2 } = renderHook(() => useLocalApiServer()) const { result: result2 } = renderHook(() => useLocalApiServer())
act(() => { act(() => {
result1.current.setRunOnStartup(false) result1.current.setEnableOnStartup(false)
result1.current.setServerHost('0.0.0.0') result1.current.setServerHost('0.0.0.0')
result1.current.setServerPort(8080) result1.current.setServerPort(8080)
result1.current.setApiPrefix('/api') result1.current.setApiPrefix('/api')
@ -333,7 +333,7 @@ describe('useLocalApiServer', () => {
result1.current.addTrustedHost('example.com') result1.current.addTrustedHost('example.com')
}) })
expect(result2.current.runOnStartup).toBe(false) expect(result2.current.enableOnStartup).toBe(false)
expect(result2.current.serverHost).toBe('0.0.0.0') expect(result2.current.serverHost).toBe('0.0.0.0')
expect(result2.current.serverPort).toBe(8080) expect(result2.current.serverPort).toBe(8080)
expect(result2.current.apiPrefix).toBe('/api') expect(result2.current.apiPrefix).toBe('/api')

View File

@ -29,7 +29,6 @@ import { OUT_OF_CONTEXT_SIZE } from '@/utils/error'
import { updateSettings } from '@/services/providers' import { updateSettings } from '@/services/providers'
import { useContextSizeApproval } from './useModelContextApproval' import { useContextSizeApproval } from './useModelContextApproval'
import { useModelLoad } from './useModelLoad' import { useModelLoad } from './useModelLoad'
import { useGeneralSetting } from './useGeneralSetting'
import { import {
ReasoningProcessor, ReasoningProcessor,
extractReasoningFromMessage, extractReasoningFromMessage,
@ -37,7 +36,6 @@ import {
export const useChat = () => { export const useChat = () => {
const { prompt, setPrompt } = usePrompt() const { prompt, setPrompt } = usePrompt()
const { experimentalFeatures } = useGeneralSetting()
const { const {
tools, tools,
updateTokenSpeed, updateTokenSpeed,
@ -247,8 +245,7 @@ export const useChat = () => {
let isCompleted = false let isCompleted = false
// Filter tools based on model capabilities and available tools for this thread // Filter tools based on model capabilities and available tools for this thread
let availableTools = let availableTools = selectedModel?.capabilities?.includes('tools')
experimentalFeatures && selectedModel?.capabilities?.includes('tools')
? tools.filter((tool) => { ? tools.filter((tool) => {
const disabledTools = getDisabledToolsForThread(activeThread.id) const disabledTools = getDisabledToolsForThread(activeThread.id)
return !disabledTools.includes(tool.name) return !disabledTools.includes(tool.name)
@ -543,7 +540,6 @@ export const useChat = () => {
setPrompt, setPrompt,
selectedModel, selectedModel,
currentAssistant, currentAssistant,
experimentalFeatures,
tools, tools,
updateLoadingModel, updateLoadingModel,
getDisabledToolsForThread, getDisabledToolsForThread,

View File

@ -6,10 +6,8 @@ import { ExtensionManager } from '@/lib/extension'
type LeftPanelStoreState = { type LeftPanelStoreState = {
currentLanguage: Language currentLanguage: Language
spellCheckChatInput: boolean spellCheckChatInput: boolean
experimentalFeatures: boolean
huggingfaceToken?: string huggingfaceToken?: string
setHuggingfaceToken: (token: string) => void setHuggingfaceToken: (token: string) => void
setExperimentalFeatures: (value: boolean) => void
setSpellCheckChatInput: (value: boolean) => void setSpellCheckChatInput: (value: boolean) => void
setCurrentLanguage: (value: Language) => void setCurrentLanguage: (value: Language) => void
} }
@ -19,9 +17,7 @@ export const useGeneralSetting = create<LeftPanelStoreState>()(
(set) => ({ (set) => ({
currentLanguage: 'en', currentLanguage: 'en',
spellCheckChatInput: true, spellCheckChatInput: true,
experimentalFeatures: false,
huggingfaceToken: undefined, huggingfaceToken: undefined,
setExperimentalFeatures: (value) => set({ experimentalFeatures: value }),
setSpellCheckChatInput: (value) => set({ spellCheckChatInput: value }), setSpellCheckChatInput: (value) => set({ spellCheckChatInput: value }),
setCurrentLanguage: (value) => set({ currentLanguage: value }), setCurrentLanguage: (value) => set({ currentLanguage: value }),
setHuggingfaceToken: (token) => { setHuggingfaceToken: (token) => {

View File

@ -4,8 +4,8 @@ import { localStorageKey } from '@/constants/localStorage'
type LocalApiServerState = { type LocalApiServerState = {
// Run local API server once app opens // Run local API server once app opens
runOnStartup: boolean enableOnStartup: boolean
setRunOnStartup: (value: boolean) => void setEnableOnStartup: (value: boolean) => void
// Server host option (127.0.0.1 or 0.0.0.0) // Server host option (127.0.0.1 or 0.0.0.0)
serverHost: '127.0.0.1' | '0.0.0.0' serverHost: '127.0.0.1' | '0.0.0.0'
setServerHost: (value: '127.0.0.1' | '0.0.0.0') => void setServerHost: (value: '127.0.0.1' | '0.0.0.0') => void
@ -33,8 +33,8 @@ type LocalApiServerState = {
export const useLocalApiServer = create<LocalApiServerState>()( export const useLocalApiServer = create<LocalApiServerState>()(
persist( persist(
(set) => ({ (set) => ({
runOnStartup: true, enableOnStartup: false,
setRunOnStartup: (value) => set({ runOnStartup: value }), setEnableOnStartup: (value) => set({ enableOnStartup: value }),
serverHost: '127.0.0.1', serverHost: '127.0.0.1',
setServerHost: (value) => set({ serverHost: value }), setServerHost: (value) => set({ serverHost: value }),
serverPort: 1337, serverPort: 1337,

View File

@ -160,6 +160,9 @@
"serverLogs": "Server Logs", "serverLogs": "Server Logs",
"serverLogsDesc": "View detailed logs of the local API server.", "serverLogsDesc": "View detailed logs of the local API server.",
"openLogs": "Open Logs", "openLogs": "Open Logs",
"startupConfiguration": "Startup Configuration",
"runOnStartup": "Enable by default on startup",
"runOnStartupDesc": "Automatically start the Local API Server when the application launches.",
"serverConfiguration": "Server Configuration", "serverConfiguration": "Server Configuration",
"serverHost": "Server Host", "serverHost": "Server Host",
"serverHostDesc": "Network address for the server.", "serverHostDesc": "Network address for the server.",

View File

@ -17,10 +17,15 @@ import {
import { useNavigate } from '@tanstack/react-router' import { useNavigate } from '@tanstack/react-router'
import { route } from '@/constants/routes' import { route } from '@/constants/routes'
import { useThreads } from '@/hooks/useThreads' import { useThreads } from '@/hooks/useThreads'
import { useLocalApiServer } from '@/hooks/useLocalApiServer'
import { useAppState } from '@/hooks/useAppState'
import { AppEvent, events } from '@janhq/core' import { AppEvent, events } from '@janhq/core'
import { startModel } from '@/services/models'
import { localStorageKey } from '@/constants/localStorage'
export function DataProvider() { export function DataProvider() {
const { setProviders } = useModelProvider() const { setProviders, selectedModel, selectedProvider, getProviderByName } =
useModelProvider()
const { setMessages } = useMessages() const { setMessages } = useMessages()
const { checkForUpdate } = useAppUpdater() const { checkForUpdate } = useAppUpdater()
@ -29,6 +34,19 @@ export function DataProvider() {
const { setThreads } = useThreads() const { setThreads } = useThreads()
const navigate = useNavigate() const navigate = useNavigate()
// Local API Server hooks
const {
enableOnStartup,
serverHost,
serverPort,
apiPrefix,
apiKey,
trustedHosts,
corsEnabled,
verboseLogs,
} = useLocalApiServer()
const { setServerStatus } = useAppState()
useEffect(() => { useEffect(() => {
console.log('Initializing DataProvider...') console.log('Initializing DataProvider...')
getProviders().then(setProviders) getProviders().then(setProviders)
@ -78,6 +96,102 @@ export function DataProvider() {
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []) }, [])
const getLastUsedModel = (): { provider: string; model: string } | null => {
try {
const stored = localStorage.getItem(localStorageKey.lastUsedModel)
return stored ? JSON.parse(stored) : null
} catch (error) {
console.debug('Failed to get last used model from localStorage:', error)
return null
}
}
// Helper function to determine which model to start
const getModelToStart = () => {
// Use last used model if available
const lastUsedModel = getLastUsedModel()
if (lastUsedModel) {
const provider = getProviderByName(lastUsedModel.provider)
if (
provider &&
provider.models.some((m) => m.id === lastUsedModel.model)
) {
return { model: lastUsedModel.model, provider }
}
}
// Use selected model if available
if (selectedModel && selectedProvider) {
const provider = getProviderByName(selectedProvider)
if (provider) {
return { model: selectedModel.id, provider }
}
}
// Use first model from llamacpp provider
const llamacppProvider = getProviderByName('llamacpp')
if (
llamacppProvider &&
llamacppProvider.models &&
llamacppProvider.models.length > 0
) {
return {
model: llamacppProvider.models[0].id,
provider: llamacppProvider,
}
}
return null
}
// Auto-start Local API Server on app startup if enabled
useEffect(() => {
if (enableOnStartup) {
// Validate API key before starting
if (!apiKey || apiKey.toString().trim().length === 0) {
console.warn('Cannot start Local API Server: API key is required')
return
}
const modelToStart = getModelToStart()
// Only start server if we have a model to load
if (!modelToStart) {
console.warn(
'Cannot start Local API Server: No model available to load'
)
return
}
setServerStatus('pending')
// Start the model first
startModel(modelToStart.provider, modelToStart.model)
.then(() => {
console.log(`Model ${modelToStart.model} started successfully`)
// Then start the server
return window.core?.api?.startServer({
host: serverHost,
port: serverPort,
prefix: apiPrefix,
apiKey,
trustedHosts,
isCorsEnabled: corsEnabled,
isVerboseEnabled: verboseLogs,
})
})
.then(() => {
setServerStatus('running')
})
.catch((error: unknown) => {
console.error('Failed to start Local API Server on startup:', error)
setServerStatus('stopped')
})
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const handleDeepLink = (urls: string[] | null) => { const handleDeepLink = (urls: string[] | null) => {
if (!urls) return if (!urls) return
console.log('Received deeplink:', urls) console.log('Received deeplink:', urls)

View File

@ -61,8 +61,6 @@ vi.mock('@/hooks/useGeneralSetting', () => ({
useGeneralSetting: () => ({ useGeneralSetting: () => ({
spellCheckChatInput: true, spellCheckChatInput: true,
setSpellCheckChatInput: vi.fn(), setSpellCheckChatInput: vi.fn(),
experimentalFeatures: false,
setExperimentalFeatures: vi.fn(),
huggingfaceToken: 'test-token', huggingfaceToken: 'test-token',
setHuggingfaceToken: vi.fn(), setHuggingfaceToken: vi.fn(),
}), }),
@ -188,7 +186,9 @@ vi.mock('@tauri-apps/plugin-opener', () => ({
})) }))
vi.mock('@tauri-apps/api/webviewWindow', () => { vi.mock('@tauri-apps/api/webviewWindow', () => {
const MockWebviewWindow = vi.fn().mockImplementation((label: string, options: any) => ({ const MockWebviewWindow = vi
.fn()
.mockImplementation((label: string, options: any) => ({
once: vi.fn(), once: vi.fn(),
setFocus: vi.fn(), setFocus: vi.fn(),
})) }))
@ -299,16 +299,6 @@ describe('General Settings Route', () => {
// expect(screen.getByTestId('language-switcher')).toBeInTheDocument() // expect(screen.getByTestId('language-switcher')).toBeInTheDocument()
// }) // })
it('should render switches for experimental features and spell check', async () => {
const Component = GeneralRoute.component as React.ComponentType
await act(async () => {
render(<Component />)
})
const switches = screen.getAllByTestId('switch')
expect(switches.length).toBeGreaterThanOrEqual(2)
})
it('should render huggingface token input', async () => { it('should render huggingface token input', async () => {
const Component = GeneralRoute.component as React.ComponentType const Component = GeneralRoute.component as React.ComponentType
await act(async () => { await act(async () => {
@ -336,24 +326,6 @@ describe('General Settings Route', () => {
expect(switches[0]).toBeInTheDocument() expect(switches[0]).toBeInTheDocument()
}) })
it('should handle experimental features toggle', async () => {
const Component = GeneralRoute.component as React.ComponentType
await act(async () => {
render(<Component />)
})
const switches = screen.getAllByTestId('switch')
expect(switches.length).toBeGreaterThan(0)
// Test that switches are interactive
if (switches.length > 1) {
await act(async () => {
fireEvent.click(switches[1])
})
expect(switches[1]).toBeInTheDocument()
}
})
it('should handle huggingface token change', async () => { it('should handle huggingface token change', async () => {
const Component = GeneralRoute.component as React.ComponentType const Component = GeneralRoute.component as React.ComponentType
await act(async () => { await act(async () => {

View File

@ -46,9 +46,6 @@ import { stopAllModels } from '@/services/models'
import { SystemEvent } from '@/types/events' import { SystemEvent } from '@/types/events'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { useHardware } from '@/hooks/useHardware' import { useHardware } from '@/hooks/useHardware'
import { getConnectedServers } from '@/services/mcp'
import { invoke } from '@tauri-apps/api/core'
import { useMCPServers } from '@/hooks/useMCPServers'
import LanguageSwitcher from '@/containers/LanguageSwitcher' import LanguageSwitcher from '@/containers/LanguageSwitcher'
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
@ -61,8 +58,6 @@ function General() {
const { const {
spellCheckChatInput, spellCheckChatInput,
setSpellCheckChatInput, setSpellCheckChatInput,
experimentalFeatures,
setExperimentalFeatures,
huggingfaceToken, huggingfaceToken,
setHuggingfaceToken, setHuggingfaceToken,
} = useGeneralSetting() } = useGeneralSetting()
@ -210,38 +205,6 @@ function General() {
} }
}, [t, checkForUpdate]) }, [t, checkForUpdate])
const handleStopAllMCPServers = async () => {
try {
const connectedServers = await getConnectedServers()
// Stop each connected server
const stopPromises = connectedServers.map((serverName) =>
invoke('deactivate_mcp_server', { name: serverName }).catch((error) => {
console.error(`Error stopping MCP server ${serverName}:`, error)
return Promise.resolve() // Continue with other servers even if one fails
})
)
await Promise.all(stopPromises)
// Update server configs to set active: false for stopped servers
const { mcpServers, editServer } = useMCPServers.getState()
connectedServers.forEach((serverName) => {
const serverConfig = mcpServers[serverName]
if (serverConfig) {
editServer(serverName, { ...serverConfig, active: false })
}
})
if (connectedServers.length > 0) {
toast.success(`Stopped ${connectedServers.length} MCP server(s)`)
}
} catch (error) {
console.error('Error stopping MCP servers:', error)
toast.error('Failed to stop MCP servers')
}
}
return ( return (
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
<HeaderPage> <HeaderPage>
@ -431,19 +394,6 @@ function General() {
</Card> </Card>
{/* Advanced */} {/* Advanced */}
<Card title="Advanced"> <Card title="Advanced">
<CardItem
title="Experimental Features"
description="Enable experimental features. They may be unstable or change at any time."
actions={
<Switch
checked={experimentalFeatures}
onCheckedChange={async (e) => {
await handleStopAllMCPServers()
setExperimentalFeatures(e)
}}
/>
}
/>
<CardItem <CardItem
title={t('settings:others.resetFactory', { title={t('settings:others.resetFactory', {
ns: 'settings', ns: 'settings',

View File

@ -13,6 +13,9 @@ import { TrustedHostsInput } from '@/containers/TrustedHostsInput'
import { useLocalApiServer } from '@/hooks/useLocalApiServer' import { useLocalApiServer } from '@/hooks/useLocalApiServer'
import { WebviewWindow } from '@tauri-apps/api/webviewWindow' import { WebviewWindow } from '@tauri-apps/api/webviewWindow'
import { useAppState } from '@/hooks/useAppState' import { useAppState } from '@/hooks/useAppState'
import { useModelProvider } from '@/hooks/useModelProvider'
import { startModel } from '@/services/models'
import { localStorageKey } from '@/constants/localStorage'
import { windowKey } from '@/constants/windows' import { windowKey } from '@/constants/windows'
import { IconLogs } from '@tabler/icons-react' import { IconLogs } from '@tabler/icons-react'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
@ -32,6 +35,8 @@ function LocalAPIServer() {
setCorsEnabled, setCorsEnabled,
verboseLogs, verboseLogs,
setVerboseLogs, setVerboseLogs,
enableOnStartup,
setEnableOnStartup,
serverHost, serverHost,
serverPort, serverPort,
apiPrefix, apiPrefix,
@ -40,6 +45,7 @@ function LocalAPIServer() {
} = useLocalApiServer() } = useLocalApiServer()
const { serverStatus, setServerStatus } = useAppState() const { serverStatus, setServerStatus } = useAppState()
const { selectedModel, selectedProvider, getProviderByName } = useModelProvider()
const [showApiKeyError, setShowApiKeyError] = useState(false) const [showApiKeyError, setShowApiKeyError] = useState(false)
const [isApiKeyEmpty, setIsApiKeyEmpty] = useState( const [isApiKeyEmpty, setIsApiKeyEmpty] = useState(
!apiKey || apiKey.toString().trim().length === 0 !apiKey || apiKey.toString().trim().length === 0
@ -60,6 +66,54 @@ function LocalAPIServer() {
setIsApiKeyEmpty(!isValid) setIsApiKeyEmpty(!isValid)
} }
const getLastUsedModel = (): { provider: string; model: string } | null => {
try {
const stored = localStorage.getItem(localStorageKey.lastUsedModel)
return stored ? JSON.parse(stored) : null
} catch (error) {
console.debug('Failed to get last used model from localStorage:', error)
return null
}
}
// Helper function to determine which model to start
const getModelToStart = () => {
// Use last used model if available
const lastUsedModel = getLastUsedModel()
if (lastUsedModel) {
const provider = getProviderByName(lastUsedModel.provider)
if (
provider &&
provider.models.some((m) => m.id === lastUsedModel.model)
) {
return { model: lastUsedModel.model, provider }
}
}
// Use selected model if available
if (selectedModel && selectedProvider) {
const provider = getProviderByName(selectedProvider)
if (provider) {
return { model: selectedModel.id, provider }
}
}
// Use first model from llamacpp provider
const llamacppProvider = getProviderByName('llamacpp')
if (
llamacppProvider &&
llamacppProvider.models &&
llamacppProvider.models.length > 0
) {
return {
model: llamacppProvider.models[0].id,
provider: llamacppProvider,
}
}
return null
}
const toggleAPIServer = async () => { const toggleAPIServer = async () => {
// Validate API key before starting server // Validate API key before starting server
if (serverStatus === 'stopped') { if (serverStatus === 'stopped') {
@ -68,12 +122,25 @@ function LocalAPIServer() {
return return
} }
setShowApiKeyError(false) setShowApiKeyError(false)
const modelToStart = getModelToStart()
// Only start server if we have a model to load
if (!modelToStart) {
console.warn(
'Cannot start Local API Server: No model available to load'
)
return
} }
setServerStatus('pending') setServerStatus('pending')
if (serverStatus === 'stopped') {
window.core?.api // Start the model first
?.startServer({ startModel(modelToStart.provider, modelToStart.model)
.then(() => {
console.log(`Model ${modelToStart.model} started successfully`)
// Then start the server
return window.core?.api?.startServer({
host: serverHost, host: serverHost,
port: serverPort, port: serverPort,
prefix: apiPrefix, prefix: apiPrefix,
@ -82,6 +149,7 @@ function LocalAPIServer() {
isCorsEnabled: corsEnabled, isCorsEnabled: corsEnabled,
isVerboseEnabled: verboseLogs, isVerboseEnabled: verboseLogs,
}) })
})
.then(() => { .then(() => {
setServerStatus('running') setServerStatus('running')
}) })
@ -90,6 +158,7 @@ function LocalAPIServer() {
setServerStatus('stopped') setServerStatus('stopped')
}) })
} else { } else {
setServerStatus('pending')
window.core?.api window.core?.api
?.stopServer() ?.stopServer()
.then(() => { .then(() => {
@ -199,6 +268,26 @@ function LocalAPIServer() {
/> />
</Card> </Card>
{/* Startup Configuration */}
<Card title={t('settings:localApiServer.startupConfiguration')}>
<CardItem
title={t('settings:localApiServer.runOnStartup')}
description={t('settings:localApiServer.runOnStartupDesc')}
actions={
<Switch
checked={enableOnStartup}
onCheckedChange={(checked) => {
if (!apiKey || apiKey.toString().trim().length === 0) {
setShowApiKeyError(true)
return
}
setEnableOnStartup(checked)
}}
/>
}
/>
</Card>
{/* Server Configuration */} {/* Server Configuration */}
<Card title={t('settings:localApiServer.serverConfiguration')}> <Card title={t('settings:localApiServer.serverConfiguration')}>
<CardItem <CardItem