Merge branch 'dev' into fix/enable-back-app-language-setting
This commit is contained in:
commit
9bc243c3f7
@ -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
|
||||||
|
|||||||
@ -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',
|
||||||
? [
|
route: route.settings.mcp_servers,
|
||||||
{
|
},
|
||||||
title: 'common: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,
|
||||||
|
|||||||
@ -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', () => ({
|
||||||
@ -71,14 +68,14 @@ describe('SettingsMenu', () => {
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
|
|
||||||
vi.mocked(useNavigate).mockReturnValue(mockNavigate)
|
vi.mocked(useNavigate).mockReturnValue(mockNavigate)
|
||||||
vi.mocked(useMatches).mockReturnValue(mockMatches)
|
vi.mocked(useMatches).mockReturnValue(mockMatches)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('renders all menu items', () => {
|
it('renders all menu items', () => {
|
||||||
render(<SettingsMenu />)
|
render(<SettingsMenu />)
|
||||||
|
|
||||||
expect(screen.getByText('common:general')).toBeInTheDocument()
|
expect(screen.getByText('common:general')).toBeInTheDocument()
|
||||||
expect(screen.getByText('common:appearance')).toBeInTheDocument()
|
expect(screen.getByText('common:appearance')).toBeInTheDocument()
|
||||||
expect(screen.getByText('common:privacy')).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: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()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('shows provider expansion chevron when providers are active', () => {
|
it('shows provider expansion chevron when providers are active', () => {
|
||||||
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()
|
||||||
@ -119,14 +101,14 @@ describe('SettingsMenu', () => {
|
|||||||
it('expands providers submenu when chevron is clicked', async () => {
|
it('expands providers submenu when chevron is clicked', async () => {
|
||||||
const user = userEvent.setup()
|
const user = userEvent.setup()
|
||||||
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')
|
||||||
await user.click(chevron)
|
await user.click(chevron)
|
||||||
|
|
||||||
expect(screen.getByTestId('provider-avatar-openai')).toBeInTheDocument()
|
expect(screen.getByTestId('provider-avatar-openai')).toBeInTheDocument()
|
||||||
expect(screen.getByTestId('provider-avatar-llama.cpp')).toBeInTheDocument()
|
expect(screen.getByTestId('provider-avatar-llama.cpp')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
@ -138,52 +120,56 @@ describe('SettingsMenu', () => {
|
|||||||
params: { providerName: 'openai' },
|
params: { providerName: 'openai' },
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
|
|
||||||
render(<SettingsMenu />)
|
render(<SettingsMenu />)
|
||||||
|
|
||||||
expect(screen.getByTestId('provider-avatar-openai')).toBeInTheDocument()
|
expect(screen.getByTestId('provider-avatar-openai')).toBeInTheDocument()
|
||||||
expect(screen.getByTestId('provider-avatar-llama.cpp')).toBeInTheDocument()
|
expect(screen.getByTestId('provider-avatar-llama.cpp')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('highlights active provider in submenu', async () => {
|
it('highlights active provider in submenu', async () => {
|
||||||
const user = userEvent.setup()
|
const user = userEvent.setup()
|
||||||
|
|
||||||
vi.mocked(useMatches).mockReturnValue([
|
vi.mocked(useMatches).mockReturnValue([
|
||||||
{
|
{
|
||||||
routeId: '/settings/providers/$providerName',
|
routeId: '/settings/providers/$providerName',
|
||||||
params: { providerName: 'openai' },
|
params: { providerName: 'openai' },
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
|
|
||||||
render(<SettingsMenu />)
|
render(<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()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('navigates to provider when provider is clicked', async () => {
|
it('navigates to provider when provider is clicked', async () => {
|
||||||
const user = userEvent.setup()
|
const user = userEvent.setup()
|
||||||
render(<SettingsMenu />)
|
render(<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({
|
||||||
to: '/settings/providers/$providerName',
|
to: '/settings/providers/$providerName',
|
||||||
params: { providerName: 'openai' },
|
params: { providerName: 'openai' },
|
||||||
@ -192,18 +178,22 @@ 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()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('opens mobile menu when toggle is clicked', async () => {
|
it('opens mobile menu when toggle is clicked', async () => {
|
||||||
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
|
||||||
const menu = screen.getByText('common:general').closest('div')
|
const menu = screen.getByText('common:general').closest('div')
|
||||||
expect(menu).toHaveClass('flex')
|
expect(menu).toHaveClass('flex')
|
||||||
@ -212,21 +202,23 @@ describe('SettingsMenu', () => {
|
|||||||
it('closes mobile menu when X is clicked', async () => {
|
it('closes mobile menu when X is clicked', async () => {
|
||||||
const user = userEvent.setup()
|
const user = userEvent.setup()
|
||||||
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
|
||||||
await user.click(menuToggle)
|
await user.click(menuToggle)
|
||||||
|
|
||||||
// Just verify the toggle button is still there after clicking twice
|
// Just verify the toggle button is still there after clicking twice
|
||||||
expect(menuToggle).toBeInTheDocument()
|
expect(menuToggle).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('hides llamacpp provider during setup remote provider step', async () => {
|
it('hides llamacpp provider during setup remote provider step', async () => {
|
||||||
const user = userEvent.setup()
|
const user = userEvent.setup()
|
||||||
|
|
||||||
vi.mocked(useMatches).mockReturnValue([
|
vi.mocked(useMatches).mockReturnValue([
|
||||||
{
|
{
|
||||||
routeId: '/settings/providers/',
|
routeId: '/settings/providers/',
|
||||||
@ -234,16 +226,16 @@ describe('SettingsMenu', () => {
|
|||||||
search: { step: 'setup_remote_provider' },
|
search: { step: 'setup_remote_provider' },
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
|
|
||||||
render(<SettingsMenu />)
|
render(<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)
|
||||||
|
|
||||||
// llamacpp provider div should have hidden class
|
// llamacpp provider div should have hidden class
|
||||||
const llamacppElement = screen.getByTestId('provider-avatar-llama.cpp')
|
const llamacppElement = screen.getByTestId('provider-avatar-llama.cpp')
|
||||||
expect(llamacppElement.parentElement).toHaveClass('hidden')
|
expect(llamacppElement.parentElement).toHaveClass('hidden')
|
||||||
@ -253,7 +245,7 @@ describe('SettingsMenu', () => {
|
|||||||
|
|
||||||
it('filters out inactive providers from submenu', async () => {
|
it('filters out inactive providers from submenu', async () => {
|
||||||
const user = userEvent.setup()
|
const user = userEvent.setup()
|
||||||
|
|
||||||
vi.mocked(useModelProvider).mockReturnValue({
|
vi.mocked(useModelProvider).mockReturnValue({
|
||||||
providers: [
|
providers: [
|
||||||
{
|
{
|
||||||
@ -268,17 +260,19 @@ describe('SettingsMenu', () => {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|
||||||
render(<SettingsMenu />)
|
render(<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()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -31,16 +31,15 @@ describe('useGeneralSetting', () => {
|
|||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
|
|
||||||
// Get the mocked ExtensionManager
|
// Get the mocked ExtensionManager
|
||||||
const { ExtensionManager } = await import('@/lib/extension')
|
const { ExtensionManager } = await import('@/lib/extension')
|
||||||
mockExtensionManager = ExtensionManager
|
mockExtensionManager = ExtensionManager
|
||||||
|
|
||||||
// Reset store state to defaults
|
// Reset store state to defaults
|
||||||
useGeneralSetting.setState({
|
useGeneralSetting.setState({
|
||||||
currentLanguage: 'en',
|
currentLanguage: 'en',
|
||||||
spellCheckChatInput: true,
|
spellCheckChatInput: true,
|
||||||
experimentalFeatures: false,
|
|
||||||
huggingfaceToken: undefined,
|
huggingfaceToken: undefined,
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -49,7 +48,7 @@ describe('useGeneralSetting', () => {
|
|||||||
getSettings: vi.fn().mockResolvedValue(null),
|
getSettings: vi.fn().mockResolvedValue(null),
|
||||||
updateSettings: vi.fn(),
|
updateSettings: vi.fn(),
|
||||||
})
|
})
|
||||||
|
|
||||||
mockExtensionManager.getInstance.mockReturnValue({
|
mockExtensionManager.getInstance.mockReturnValue({
|
||||||
getByName: mockGetByName,
|
getByName: mockGetByName,
|
||||||
})
|
})
|
||||||
@ -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())
|
||||||
@ -235,7 +196,7 @@ describe('useGeneralSetting', () => {
|
|||||||
const mockGetByName = vi.fn()
|
const mockGetByName = vi.fn()
|
||||||
const mockGetSettings = vi.fn().mockResolvedValue(mockSettings)
|
const mockGetSettings = vi.fn().mockResolvedValue(mockSettings)
|
||||||
const mockUpdateSettings = vi.fn()
|
const mockUpdateSettings = vi.fn()
|
||||||
|
|
||||||
mockExtensionManager.getInstance.mockReturnValue({
|
mockExtensionManager.getInstance.mockReturnValue({
|
||||||
getByName: mockGetByName,
|
getByName: mockGetByName,
|
||||||
})
|
})
|
||||||
@ -252,9 +213,9 @@ describe('useGeneralSetting', () => {
|
|||||||
|
|
||||||
expect(mockExtensionManager.getInstance).toHaveBeenCalled()
|
expect(mockExtensionManager.getInstance).toHaveBeenCalled()
|
||||||
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
|
||||||
@ -331,4 +286,4 @@ describe('useGeneralSetting', () => {
|
|||||||
expect(result.current.spellCheckChatInput).toBe(true)
|
expect(result.current.spellCheckChatInput).toBe(true)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -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')
|
||||||
|
|||||||
@ -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,13 +245,12 @@ 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)
|
})
|
||||||
})
|
: []
|
||||||
: []
|
|
||||||
|
|
||||||
let assistantLoopSteps = 0
|
let assistantLoopSteps = 0
|
||||||
|
|
||||||
@ -543,7 +540,6 @@ export const useChat = () => {
|
|||||||
setPrompt,
|
setPrompt,
|
||||||
selectedModel,
|
selectedModel,
|
||||||
currentAssistant,
|
currentAssistant,
|
||||||
experimentalFeatures,
|
|
||||||
tools,
|
tools,
|
||||||
updateLoadingModel,
|
updateLoadingModel,
|
||||||
getDisabledToolsForThread,
|
getDisabledToolsForThread,
|
||||||
|
|||||||
@ -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) => {
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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.",
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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,12 +186,14 @@ 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
|
||||||
once: vi.fn(),
|
.fn()
|
||||||
setFocus: vi.fn(),
|
.mockImplementation((label: string, options: any) => ({
|
||||||
}))
|
once: vi.fn(),
|
||||||
|
setFocus: vi.fn(),
|
||||||
|
}))
|
||||||
MockWebviewWindow.getByLabel = vi.fn().mockReturnValue(null)
|
MockWebviewWindow.getByLabel = vi.fn().mockReturnValue(null)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
WebviewWindow: MockWebviewWindow,
|
WebviewWindow: MockWebviewWindow,
|
||||||
}
|
}
|
||||||
@ -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 () => {
|
||||||
@ -514,16 +486,16 @@ describe('General Settings Route', () => {
|
|||||||
act(() => {
|
act(() => {
|
||||||
fireEvent.click(checkUpdateButton)
|
fireEvent.click(checkUpdateButton)
|
||||||
})
|
})
|
||||||
|
|
||||||
// Now the button should be disabled while checking
|
// Now the button should be disabled while checking
|
||||||
expect(checkUpdateButton).toBeDisabled()
|
expect(checkUpdateButton).toBeDisabled()
|
||||||
|
|
||||||
// Resolve the promise to finish the update check
|
// Resolve the promise to finish the update check
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
resolveUpdate!(null)
|
resolveUpdate!(null)
|
||||||
await updatePromise
|
await updatePromise
|
||||||
})
|
})
|
||||||
|
|
||||||
// Button should be enabled again
|
// Button should be enabled again
|
||||||
expect(checkUpdateButton).not.toBeDisabled()
|
expect(checkUpdateButton).not.toBeDisabled()
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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',
|
||||||
|
|||||||
@ -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,19 +122,33 @@ function LocalAPIServer() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
setShowApiKeyError(false)
|
setShowApiKeyError(false)
|
||||||
}
|
|
||||||
|
|
||||||
setServerStatus('pending')
|
const modelToStart = getModelToStart()
|
||||||
if (serverStatus === 'stopped') {
|
// Only start server if we have a model to load
|
||||||
window.core?.api
|
if (!modelToStart) {
|
||||||
?.startServer({
|
console.warn(
|
||||||
host: serverHost,
|
'Cannot start Local API Server: No model available to load'
|
||||||
port: serverPort,
|
)
|
||||||
prefix: apiPrefix,
|
return
|
||||||
apiKey,
|
}
|
||||||
trustedHosts,
|
|
||||||
isCorsEnabled: corsEnabled,
|
setServerStatus('pending')
|
||||||
isVerboseEnabled: verboseLogs,
|
|
||||||
|
// 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(() => {
|
.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
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user