diff --git a/web-app/src/containers/ChatInput.tsx b/web-app/src/containers/ChatInput.tsx index 5383a170e..53e47db51 100644 --- a/web-app/src/containers/ChatInput.tsx +++ b/web-app/src/containers/ChatInput.tsx @@ -57,7 +57,7 @@ const ChatInput = ({ model, className, initialMessage }: ChatInputProps) => { const { prompt, setPrompt } = usePrompt() const { currentThreadId } = useThreads() const { t } = useTranslation() - const { spellCheckChatInput, experimentalFeatures } = useGeneralSetting() + const { spellCheckChatInput } = useGeneralSetting() const maxRows = 10 @@ -586,8 +586,7 @@ const ChatInput = ({ model, className, initialMessage }: ChatInputProps) => { )} - {experimentalFeatures && - selectedModel?.capabilities?.includes('tools') && + {selectedModel?.capabilities?.includes('tools') && hasActiveMCPServers && ( { const matches = useMatches() 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) @@ -79,15 +77,10 @@ const SettingsMenu = () => { title: 'common:hardware', route: route.settings.hardware, }, - // Only show MCP Servers when experimental features are enabled - ...(experimentalFeatures - ? [ - { - title: 'common:mcp-servers', - route: route.settings.mcp_servers, - }, - ] - : []), + { + title: 'common:mcp-servers', + route: route.settings.mcp_servers, + }, { title: 'common:local_api_server', route: route.settings.local_api_server, diff --git a/web-app/src/containers/__tests__/SettingsMenu.test.tsx b/web-app/src/containers/__tests__/SettingsMenu.test.tsx index 14b7bfca7..56a73fbb8 100644 --- a/web-app/src/containers/__tests__/SettingsMenu.test.tsx +++ b/web-app/src/containers/__tests__/SettingsMenu.test.tsx @@ -5,7 +5,6 @@ import SettingsMenu from '../SettingsMenu' import { useNavigate, useMatches } from '@tanstack/react-router' import { useGeneralSetting } from '@/hooks/useGeneralSetting' import { useModelProvider } from '@/hooks/useModelProvider' -import { useAppState } from '@/hooks/useAppState' // Mock dependencies vi.mock('@tanstack/react-router', () => ({ @@ -25,9 +24,7 @@ vi.mock('@/i18n/react-i18next-compat', () => ({ })) vi.mock('@/hooks/useGeneralSetting', () => ({ - useGeneralSetting: vi.fn(() => ({ - experimentalFeatures: false, - })), + useGeneralSetting: vi.fn(() => ({})), })) vi.mock('@/hooks/useModelProvider', () => ({ @@ -71,14 +68,14 @@ describe('SettingsMenu', () => { beforeEach(() => { vi.clearAllMocks() - + vi.mocked(useNavigate).mockReturnValue(mockNavigate) vi.mocked(useMatches).mockReturnValue(mockMatches) }) it('renders all menu items', () => { render() - + expect(screen.getByText('common:general')).toBeInTheDocument() expect(screen.getByText('common:appearance')).toBeInTheDocument() expect(screen.getByText('common:privacy')).toBeInTheDocument() @@ -88,29 +85,14 @@ describe('SettingsMenu', () => { expect(screen.getByText('common:local_api_server')).toBeInTheDocument() expect(screen.getByText('common:https_proxy')).toBeInTheDocument() expect(screen.getByText('common:extensions')).toBeInTheDocument() - }) - - it('does not show MCP Servers when experimental features disabled', () => { - render() - - expect(screen.queryByText('common:mcp-servers')).not.toBeInTheDocument() - }) - - it('shows MCP Servers when experimental features enabled', () => { - vi.mocked(useGeneralSetting).mockReturnValue({ - experimentalFeatures: true, - }) - - render() - expect(screen.getByText('common:mcp-servers')).toBeInTheDocument() }) it('shows provider expansion chevron when providers are active', () => { render() - + const chevronButtons = screen.getAllByRole('button') - const chevron = chevronButtons.find(button => + const chevron = chevronButtons.find((button) => button.querySelector('svg.tabler-icon-chevron-right') ) expect(chevron).toBeInTheDocument() @@ -119,14 +101,14 @@ describe('SettingsMenu', () => { it('expands providers submenu when chevron is clicked', async () => { const user = userEvent.setup() render() - + const chevronButtons = screen.getAllByRole('button') - const chevron = chevronButtons.find(button => + const chevron = chevronButtons.find((button) => button.querySelector('svg.tabler-icon-chevron-right') ) if (!chevron) throw new Error('Chevron button not found') await user.click(chevron) - + expect(screen.getByTestId('provider-avatar-openai')).toBeInTheDocument() expect(screen.getByTestId('provider-avatar-llama.cpp')).toBeInTheDocument() }) @@ -138,52 +120,56 @@ describe('SettingsMenu', () => { params: { providerName: 'openai' }, }, ]) - + render() - + expect(screen.getByTestId('provider-avatar-openai')).toBeInTheDocument() expect(screen.getByTestId('provider-avatar-llama.cpp')).toBeInTheDocument() }) it('highlights active provider in submenu', async () => { const user = userEvent.setup() - + vi.mocked(useMatches).mockReturnValue([ { routeId: '/settings/providers/$providerName', params: { providerName: 'openai' }, }, ]) - + render() - + // First expand the providers submenu const chevronButtons = screen.getAllByRole('button') - const chevron = chevronButtons.find(button => + const chevron = chevronButtons.find((button) => button.querySelector('svg.tabler-icon-chevron-right') ) 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() }) it('navigates to provider when provider is clicked', async () => { const user = userEvent.setup() render() - + // First expand the providers const chevronButtons = screen.getAllByRole('button') - const chevron = chevronButtons.find(button => + const chevron = chevronButtons.find((button) => button.querySelector('svg.tabler-icon-chevron-right') ) if (!chevron) throw new Error('Chevron button not found') await user.click(chevron) - + // 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!) - + expect(mockNavigate).toHaveBeenCalledWith({ to: '/settings/providers/$providerName', params: { providerName: 'openai' }, @@ -192,18 +178,22 @@ describe('SettingsMenu', () => { it('shows mobile menu toggle button', () => { render() - - const menuToggle = screen.getByRole('button', { name: 'Toggle settings menu' }) + + const menuToggle = screen.getByRole('button', { + name: 'Toggle settings menu', + }) expect(menuToggle).toBeInTheDocument() }) it('opens mobile menu when toggle is clicked', async () => { const user = userEvent.setup() render() - - const menuToggle = screen.getByRole('button', { name: 'Toggle settings menu' }) + + const menuToggle = screen.getByRole('button', { + name: 'Toggle settings menu', + }) await user.click(menuToggle) - + // Menu should now be visible const menu = screen.getByText('common:general').closest('div') expect(menu).toHaveClass('flex') @@ -212,21 +202,23 @@ describe('SettingsMenu', () => { it('closes mobile menu when X is clicked', async () => { const user = userEvent.setup() render() - + // 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) - + // Then close it await user.click(menuToggle) - + // Just verify the toggle button is still there after clicking twice expect(menuToggle).toBeInTheDocument() }) it('hides llamacpp provider during setup remote provider step', async () => { const user = userEvent.setup() - + vi.mocked(useMatches).mockReturnValue([ { routeId: '/settings/providers/', @@ -234,16 +226,16 @@ describe('SettingsMenu', () => { search: { step: 'setup_remote_provider' }, }, ]) - + render() - + // First expand the providers submenu const chevronButtons = screen.getAllByRole('button') - const chevron = chevronButtons.find(button => + const chevron = chevronButtons.find((button) => button.querySelector('svg.tabler-icon-chevron-right') ) if (chevron) await user.click(chevron) - + // llamacpp provider div should have hidden class const llamacppElement = screen.getByTestId('provider-avatar-llama.cpp') expect(llamacppElement.parentElement).toHaveClass('hidden') @@ -253,7 +245,7 @@ describe('SettingsMenu', () => { it('filters out inactive providers from submenu', async () => { const user = userEvent.setup() - + vi.mocked(useModelProvider).mockReturnValue({ providers: [ { @@ -268,17 +260,19 @@ describe('SettingsMenu', () => { }, ], }) - + render() - + // Expand providers const chevronButtons = screen.getAllByRole('button') - const chevron = chevronButtons.find(button => + const chevron = chevronButtons.find((button) => button.querySelector('svg.tabler-icon-chevron-right') ) if (chevron) await user.click(chevron) - + expect(screen.getByTestId('provider-avatar-openai')).toBeInTheDocument() - expect(screen.queryByTestId('provider-avatar-anthropic')).not.toBeInTheDocument() + expect( + screen.queryByTestId('provider-avatar-anthropic') + ).not.toBeInTheDocument() }) -}) \ No newline at end of file +}) diff --git a/web-app/src/hooks/__tests__/useGeneralSetting.test.ts b/web-app/src/hooks/__tests__/useGeneralSetting.test.ts index 19e71c4fa..f55835c0f 100644 --- a/web-app/src/hooks/__tests__/useGeneralSetting.test.ts +++ b/web-app/src/hooks/__tests__/useGeneralSetting.test.ts @@ -31,16 +31,15 @@ describe('useGeneralSetting', () => { beforeEach(async () => { vi.clearAllMocks() - + // Get the mocked ExtensionManager const { ExtensionManager } = await import('@/lib/extension') mockExtensionManager = ExtensionManager - + // Reset store state to defaults useGeneralSetting.setState({ currentLanguage: 'en', spellCheckChatInput: true, - experimentalFeatures: false, huggingfaceToken: undefined, }) @@ -49,7 +48,7 @@ describe('useGeneralSetting', () => { getSettings: vi.fn().mockResolvedValue(null), updateSettings: vi.fn(), }) - + mockExtensionManager.getInstance.mockReturnValue({ getByName: mockGetByName, }) @@ -60,11 +59,9 @@ describe('useGeneralSetting', () => { expect(result.current.currentLanguage).toBe('en') expect(result.current.spellCheckChatInput).toBe(true) - expect(result.current.experimentalFeatures).toBe(false) expect(result.current.huggingfaceToken).toBeUndefined() expect(typeof result.current.setCurrentLanguage).toBe('function') expect(typeof result.current.setSpellCheckChatInput).toBe('function') - expect(typeof result.current.setExperimentalFeatures).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', () => { it('should set huggingface token', () => { const { result } = renderHook(() => useGeneralSetting()) @@ -235,7 +196,7 @@ describe('useGeneralSetting', () => { const mockGetByName = vi.fn() const mockGetSettings = vi.fn().mockResolvedValue(mockSettings) const mockUpdateSettings = vi.fn() - + mockExtensionManager.getInstance.mockReturnValue({ getByName: mockGetByName, }) @@ -252,9 +213,9 @@ describe('useGeneralSetting', () => { expect(mockExtensionManager.getInstance).toHaveBeenCalled() expect(mockGetByName).toHaveBeenCalledWith('@janhq/download-extension') - + // Wait for async operations - await new Promise(resolve => setTimeout(resolve, 0)) + await new Promise((resolve) => setTimeout(resolve, 0)) expect(mockGetSettings).toHaveBeenCalled() expect(mockUpdateSettings).toHaveBeenCalledWith([ @@ -272,13 +233,11 @@ describe('useGeneralSetting', () => { act(() => { result1.current.setCurrentLanguage('id') result1.current.setSpellCheckChatInput(false) - result1.current.setExperimentalFeatures(true) result1.current.setHuggingfaceToken('shared-token') }) expect(result2.current.currentLanguage).toBe('id') expect(result2.current.spellCheckChatInput).toBe(false) - expect(result2.current.experimentalFeatures).toBe(true) expect(result2.current.huggingfaceToken).toBe('shared-token') }) }) @@ -290,13 +249,11 @@ describe('useGeneralSetting', () => { act(() => { result.current.setCurrentLanguage('vn') result.current.setSpellCheckChatInput(false) - result.current.setExperimentalFeatures(true) result.current.setHuggingfaceToken('complex-token-123') }) expect(result.current.currentLanguage).toBe('vn') expect(result.current.spellCheckChatInput).toBe(false) - expect(result.current.experimentalFeatures).toBe(true) expect(result.current.huggingfaceToken).toBe('complex-token-123') }) @@ -314,11 +271,9 @@ describe('useGeneralSetting', () => { // Second update act(() => { - result.current.setExperimentalFeatures(true) result.current.setHuggingfaceToken('sequential-token') }) - expect(result.current.experimentalFeatures).toBe(true) expect(result.current.huggingfaceToken).toBe('sequential-token') // Third update @@ -331,4 +286,4 @@ describe('useGeneralSetting', () => { expect(result.current.spellCheckChatInput).toBe(true) }) }) -}) \ No newline at end of file +}) diff --git a/web-app/src/hooks/__tests__/useLocalApiServer.test.ts b/web-app/src/hooks/__tests__/useLocalApiServer.test.ts index 4cc15ef39..e347d2307 100644 --- a/web-app/src/hooks/__tests__/useLocalApiServer.test.ts +++ b/web-app/src/hooks/__tests__/useLocalApiServer.test.ts @@ -24,7 +24,7 @@ describe('useLocalApiServer', () => { vi.clearAllMocks() // Reset store state to defaults const store = useLocalApiServer.getState() - store.setRunOnStartup(true) + store.setEnableOnStartup(true) store.setServerHost('127.0.0.1') store.setServerPort(1337) store.setApiPrefix('/v1') @@ -37,7 +37,7 @@ describe('useLocalApiServer', () => { it('should initialize with default values', () => { 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.serverPort).toBe(1337) expect(result.current.apiPrefix).toBe('/v1') @@ -47,21 +47,21 @@ describe('useLocalApiServer', () => { expect(result.current.apiKey).toBe('') }) - describe('runOnStartup', () => { + describe('enableOnStartup', () => { it('should set run on startup', () => { const { result } = renderHook(() => useLocalApiServer()) act(() => { - result.current.setRunOnStartup(false) + result.current.setEnableOnStartup(false) }) - expect(result.current.runOnStartup).toBe(false) + expect(result.current.enableOnStartup).toBe(false) 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()) act(() => { - result1.current.setRunOnStartup(false) + result1.current.setEnableOnStartup(false) result1.current.setServerHost('0.0.0.0') result1.current.setServerPort(8080) result1.current.setApiPrefix('/api') @@ -333,7 +333,7 @@ describe('useLocalApiServer', () => { 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.serverPort).toBe(8080) expect(result2.current.apiPrefix).toBe('/api') diff --git a/web-app/src/hooks/useChat.ts b/web-app/src/hooks/useChat.ts index e1511ee51..134dc1ae1 100644 --- a/web-app/src/hooks/useChat.ts +++ b/web-app/src/hooks/useChat.ts @@ -29,7 +29,6 @@ import { OUT_OF_CONTEXT_SIZE } from '@/utils/error' import { updateSettings } from '@/services/providers' import { useContextSizeApproval } from './useModelContextApproval' import { useModelLoad } from './useModelLoad' -import { useGeneralSetting } from './useGeneralSetting' import { ReasoningProcessor, extractReasoningFromMessage, @@ -37,7 +36,6 @@ import { export const useChat = () => { const { prompt, setPrompt } = usePrompt() - const { experimentalFeatures } = useGeneralSetting() const { tools, updateTokenSpeed, @@ -247,13 +245,12 @@ export const useChat = () => { let isCompleted = false // Filter tools based on model capabilities and available tools for this thread - let availableTools = - experimentalFeatures && selectedModel?.capabilities?.includes('tools') - ? tools.filter((tool) => { - const disabledTools = getDisabledToolsForThread(activeThread.id) - return !disabledTools.includes(tool.name) - }) - : [] + let availableTools = selectedModel?.capabilities?.includes('tools') + ? tools.filter((tool) => { + const disabledTools = getDisabledToolsForThread(activeThread.id) + return !disabledTools.includes(tool.name) + }) + : [] let assistantLoopSteps = 0 @@ -543,7 +540,6 @@ export const useChat = () => { setPrompt, selectedModel, currentAssistant, - experimentalFeatures, tools, updateLoadingModel, getDisabledToolsForThread, diff --git a/web-app/src/hooks/useGeneralSetting.ts b/web-app/src/hooks/useGeneralSetting.ts index 6d8a9e22e..b356ca8a3 100644 --- a/web-app/src/hooks/useGeneralSetting.ts +++ b/web-app/src/hooks/useGeneralSetting.ts @@ -6,10 +6,8 @@ import { ExtensionManager } from '@/lib/extension' type LeftPanelStoreState = { currentLanguage: Language spellCheckChatInput: boolean - experimentalFeatures: boolean huggingfaceToken?: string setHuggingfaceToken: (token: string) => void - setExperimentalFeatures: (value: boolean) => void setSpellCheckChatInput: (value: boolean) => void setCurrentLanguage: (value: Language) => void } @@ -19,9 +17,7 @@ export const useGeneralSetting = create()( (set) => ({ currentLanguage: 'en', spellCheckChatInput: true, - experimentalFeatures: false, huggingfaceToken: undefined, - setExperimentalFeatures: (value) => set({ experimentalFeatures: value }), setSpellCheckChatInput: (value) => set({ spellCheckChatInput: value }), setCurrentLanguage: (value) => set({ currentLanguage: value }), setHuggingfaceToken: (token) => { diff --git a/web-app/src/hooks/useLocalApiServer.ts b/web-app/src/hooks/useLocalApiServer.ts index 8080d4d2d..a353df75c 100644 --- a/web-app/src/hooks/useLocalApiServer.ts +++ b/web-app/src/hooks/useLocalApiServer.ts @@ -4,8 +4,8 @@ import { localStorageKey } from '@/constants/localStorage' type LocalApiServerState = { // Run local API server once app opens - runOnStartup: boolean - setRunOnStartup: (value: boolean) => void + enableOnStartup: boolean + setEnableOnStartup: (value: boolean) => void // Server host option (127.0.0.1 or 0.0.0.0) serverHost: '127.0.0.1' | '0.0.0.0' setServerHost: (value: '127.0.0.1' | '0.0.0.0') => void @@ -33,8 +33,8 @@ type LocalApiServerState = { export const useLocalApiServer = create()( persist( (set) => ({ - runOnStartup: true, - setRunOnStartup: (value) => set({ runOnStartup: value }), + enableOnStartup: false, + setEnableOnStartup: (value) => set({ enableOnStartup: value }), serverHost: '127.0.0.1', setServerHost: (value) => set({ serverHost: value }), serverPort: 1337, diff --git a/web-app/src/locales/en/settings.json b/web-app/src/locales/en/settings.json index 4eeb825d5..17e59d3bb 100644 --- a/web-app/src/locales/en/settings.json +++ b/web-app/src/locales/en/settings.json @@ -160,6 +160,9 @@ "serverLogs": "Server Logs", "serverLogsDesc": "View detailed logs of the local API server.", "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", "serverHost": "Server Host", "serverHostDesc": "Network address for the server.", diff --git a/web-app/src/providers/DataProvider.tsx b/web-app/src/providers/DataProvider.tsx index 32c6a374c..baca6e213 100644 --- a/web-app/src/providers/DataProvider.tsx +++ b/web-app/src/providers/DataProvider.tsx @@ -17,10 +17,15 @@ import { import { useNavigate } from '@tanstack/react-router' import { route } from '@/constants/routes' import { useThreads } from '@/hooks/useThreads' +import { useLocalApiServer } from '@/hooks/useLocalApiServer' +import { useAppState } from '@/hooks/useAppState' import { AppEvent, events } from '@janhq/core' +import { startModel } from '@/services/models' +import { localStorageKey } from '@/constants/localStorage' export function DataProvider() { - const { setProviders } = useModelProvider() + const { setProviders, selectedModel, selectedProvider, getProviderByName } = + useModelProvider() const { setMessages } = useMessages() const { checkForUpdate } = useAppUpdater() @@ -29,6 +34,19 @@ export function DataProvider() { const { setThreads } = useThreads() const navigate = useNavigate() + // Local API Server hooks + const { + enableOnStartup, + serverHost, + serverPort, + apiPrefix, + apiKey, + trustedHosts, + corsEnabled, + verboseLogs, + } = useLocalApiServer() + const { setServerStatus } = useAppState() + useEffect(() => { console.log('Initializing DataProvider...') getProviders().then(setProviders) @@ -78,6 +96,102 @@ export function DataProvider() { // 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) => { if (!urls) return console.log('Received deeplink:', urls) diff --git a/web-app/src/routes/settings/__tests__/general.test.tsx b/web-app/src/routes/settings/__tests__/general.test.tsx index 96388b0fb..e21a28dcf 100644 --- a/web-app/src/routes/settings/__tests__/general.test.tsx +++ b/web-app/src/routes/settings/__tests__/general.test.tsx @@ -61,8 +61,6 @@ vi.mock('@/hooks/useGeneralSetting', () => ({ useGeneralSetting: () => ({ spellCheckChatInput: true, setSpellCheckChatInput: vi.fn(), - experimentalFeatures: false, - setExperimentalFeatures: vi.fn(), huggingfaceToken: 'test-token', setHuggingfaceToken: vi.fn(), }), @@ -188,12 +186,14 @@ vi.mock('@tauri-apps/plugin-opener', () => ({ })) vi.mock('@tauri-apps/api/webviewWindow', () => { - const MockWebviewWindow = vi.fn().mockImplementation((label: string, options: any) => ({ - once: vi.fn(), - setFocus: vi.fn(), - })) + const MockWebviewWindow = vi + .fn() + .mockImplementation((label: string, options: any) => ({ + once: vi.fn(), + setFocus: vi.fn(), + })) MockWebviewWindow.getByLabel = vi.fn().mockReturnValue(null) - + return { WebviewWindow: MockWebviewWindow, } @@ -299,16 +299,6 @@ describe('General Settings Route', () => { // 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() - }) - - const switches = screen.getAllByTestId('switch') - expect(switches.length).toBeGreaterThanOrEqual(2) - }) - it('should render huggingface token input', async () => { const Component = GeneralRoute.component as React.ComponentType await act(async () => { @@ -336,24 +326,6 @@ describe('General Settings Route', () => { expect(switches[0]).toBeInTheDocument() }) - it('should handle experimental features toggle', async () => { - const Component = GeneralRoute.component as React.ComponentType - await act(async () => { - render() - }) - - 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 () => { const Component = GeneralRoute.component as React.ComponentType await act(async () => { @@ -514,16 +486,16 @@ describe('General Settings Route', () => { act(() => { fireEvent.click(checkUpdateButton) }) - + // Now the button should be disabled while checking expect(checkUpdateButton).toBeDisabled() - + // Resolve the promise to finish the update check await act(async () => { resolveUpdate!(null) await updatePromise }) - + // Button should be enabled again expect(checkUpdateButton).not.toBeDisabled() } diff --git a/web-app/src/routes/settings/general.tsx b/web-app/src/routes/settings/general.tsx index 8900db0fb..aa93f985a 100644 --- a/web-app/src/routes/settings/general.tsx +++ b/web-app/src/routes/settings/general.tsx @@ -46,9 +46,6 @@ import { stopAllModels } from '@/services/models' import { SystemEvent } from '@/types/events' import { Input } from '@/components/ui/input' 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' // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -61,8 +58,6 @@ function General() { const { spellCheckChatInput, setSpellCheckChatInput, - experimentalFeatures, - setExperimentalFeatures, huggingfaceToken, setHuggingfaceToken, } = useGeneralSetting() @@ -210,38 +205,6 @@ function General() { } }, [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 (
@@ -431,19 +394,6 @@ function General() { {/* Advanced */} - { - await handleStopAllMCPServers() - setExperimentalFeatures(e) - }} - /> - } - /> { + 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 () => { // Validate API key before starting server if (serverStatus === 'stopped') { @@ -68,19 +122,33 @@ function LocalAPIServer() { return } setShowApiKeyError(false) - } - setServerStatus('pending') - if (serverStatus === 'stopped') { - window.core?.api - ?.startServer({ - host: serverHost, - port: serverPort, - prefix: apiPrefix, - apiKey, - trustedHosts, - isCorsEnabled: corsEnabled, - isVerboseEnabled: verboseLogs, + 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') @@ -90,6 +158,7 @@ function LocalAPIServer() { setServerStatus('stopped') }) } else { + setServerStatus('pending') window.core?.api ?.stopServer() .then(() => { @@ -199,6 +268,26 @@ function LocalAPIServer() { /> + {/* Startup Configuration */} + + { + if (!apiKey || apiKey.toString().trim().length === 0) { + setShowApiKeyError(true) + return + } + setEnableOnStartup(checked) + }} + /> + } + /> + + {/* Server Configuration */}