From 5d9c3ab46291056f382307a8b68fd21b1d2f2344 Mon Sep 17 00:00:00 2001 From: lugnicca Date: Wed, 20 Aug 2025 13:35:02 +0200 Subject: [PATCH 01/24] feat: add model selector with fetching from /v1/models endpoints when adding models --- web-app/src/containers/ModelCombobox.tsx | 367 ++++++++++++++++++++ web-app/src/containers/dialogs/AddModel.tsx | 20 +- web-app/src/hooks/useProviderModels.ts | 89 +++++ web-app/src/locales/en/common.json | 2 + web-app/src/services/providers.ts | 37 +- 5 files changed, 504 insertions(+), 11 deletions(-) create mode 100644 web-app/src/containers/ModelCombobox.tsx create mode 100644 web-app/src/hooks/useProviderModels.ts diff --git a/web-app/src/containers/ModelCombobox.tsx b/web-app/src/containers/ModelCombobox.tsx new file mode 100644 index 000000000..34c95bf6d --- /dev/null +++ b/web-app/src/containers/ModelCombobox.tsx @@ -0,0 +1,367 @@ +import { useState, useMemo, useRef, useEffect, useCallback } from 'react' +import { createPortal } from 'react-dom' +import { Input } from '@/components/ui/input' +import { Button } from '@/components/ui/button' +import { IconChevronDown, IconLoader2, IconRefresh } from '@tabler/icons-react' +import { cn } from '@/lib/utils' +import { useTranslation } from '@/i18n/react-i18next-compat' + +type ModelComboboxProps = { + value: string + onChange: (value: string) => void + models: string[] + loading?: boolean + error?: string | null + onRefresh?: () => void + placeholder?: string + disabled?: boolean + className?: string +} + +export function ModelCombobox({ + value, + onChange, + models, + loading = false, + error = null, + onRefresh, + placeholder = 'Type or select a model...', + disabled = false, + className, +}: ModelComboboxProps) { + const [open, setOpen] = useState(false) + const [inputValue, setInputValue] = useState(value) + const [dropdownPosition, setDropdownPosition] = useState({ top: 0, left: 0, width: 0 }) + const [highlightedIndex, setHighlightedIndex] = useState(-1) + const inputRef = useRef(null) + const containerRef = useRef(null) + const dropdownRef = useRef(null) + const keyRepeatTimeoutRef = useRef(null) + const { t } = useTranslation() + + // Sync input value with prop value + useEffect(() => { + setInputValue(value) + }, [value]) + + // Simple position calculation + const updateDropdownPosition = useCallback(() => { + if (containerRef.current) { + const rect = containerRef.current.getBoundingClientRect() + setDropdownPosition({ + top: rect.bottom + window.scrollY + 4, + left: rect.left + window.scrollX, + width: rect.width, + }) + } + }, []) + + // Update position when opening + useEffect(() => { + if (open) { + // Use requestAnimationFrame to ensure DOM is ready + requestAnimationFrame(() => { + updateDropdownPosition() + }) + } + }, [open, updateDropdownPosition]) + + // Close dropdown when clicking outside + useEffect(() => { + if (!open) return + + const handleClickOutside = (event: Event) => { + const target = event.target as Node + // Check if click is inside our container or dropdown + const isInsideContainer = containerRef.current?.contains(target) + const isInsideDropdown = dropdownRef.current?.contains(target) + + // Only close if click is outside both container and dropdown + if (!isInsideContainer && !isInsideDropdown) { + setOpen(false) + setDropdownPosition({ top: 0, left: 0, width: 0 }) + setHighlightedIndex(-1) + } + } + + // Use multiple event types to ensure we catch all interactions + const events = ['mousedown', 'touchstart'] + events.forEach(eventType => { + document.addEventListener(eventType, handleClickOutside, { capture: true, passive: true }) + }) + + return () => { + events.forEach(eventType => { + document.removeEventListener(eventType, handleClickOutside, { capture: true }) + }) + } + }, [open]) + + // Cleanup: close dropdown when component unmounts + useEffect(() => { + const timeoutId = keyRepeatTimeoutRef.current + return () => { + setOpen(false) + setDropdownPosition({ top: 0, left: 0, width: 0 }) + setHighlightedIndex(-1) + if (timeoutId) { + clearTimeout(timeoutId) + } + } + }, []) + + // Filter models based on input + const filteredModels = useMemo(() => { + if (!inputValue.trim()) return models + + return models.filter((model) => + model.toLowerCase().includes(inputValue.toLowerCase()) + ) + }, [models, inputValue]) + + // Reset highlighted index when filtered models change + useEffect(() => { + setHighlightedIndex(-1) + }, [filteredModels]) + + // Scroll to highlighted item with debouncing to handle key repeat + useEffect(() => { + if (highlightedIndex >= 0 && dropdownRef.current && !loading && !error) { + // Use requestAnimationFrame to ensure smooth scrolling and avoid conflicts + requestAnimationFrame(() => { + // Find all model elements (they have the data-model attribute) + const modelElements = dropdownRef.current?.querySelectorAll('[data-model]') + const highlightedElement = modelElements?.[highlightedIndex] as HTMLElement + if (highlightedElement) { + highlightedElement.scrollIntoView({ + block: 'nearest', + behavior: 'auto' + }) + } + }) + } + }, [highlightedIndex, error, loading]) + + // Handle input change + const handleInputChange = (newValue: string) => { + setInputValue(newValue) + onChange(newValue) + + // Only open dropdown if user is actively typing and there are models + if (newValue.trim() && models.length > 0) { + setOpen(true) + } else { + // Don't auto-open on empty input - wait for user interaction + setOpen(false) + } + } + + // Handle keyboard navigation + const handleKeyDown = (e: React.KeyboardEvent) => { + if (!open && (e.key === 'ArrowDown' || e.key === 'ArrowUp')) { + // Open dropdown on arrow keys if there are models + if (models.length > 0) { + e.preventDefault() + setOpen(true) + setHighlightedIndex(0) + } + return + } + + if (!open) return + + switch (e.key) { + case 'ArrowDown': + e.preventDefault() + if (keyRepeatTimeoutRef.current) { + clearTimeout(keyRepeatTimeoutRef.current) + } + setHighlightedIndex((prev) => + filteredModels.length === 0 ? 0 : (prev < filteredModels.length - 1 ? prev + 1 : 0) + ) + break + case 'ArrowUp': + e.preventDefault() + if (keyRepeatTimeoutRef.current) { + clearTimeout(keyRepeatTimeoutRef.current) + } + setHighlightedIndex((prev) => + filteredModels.length === 0 ? 0 : (prev > 0 ? prev - 1 : filteredModels.length - 1) + ) + break + case 'Enter': + e.preventDefault() + if (highlightedIndex >= 0 && highlightedIndex < filteredModels.length) { + handleModelSelect(filteredModels[highlightedIndex]) + } + break + case 'ArrowRight': + case 'ArrowLeft': + setOpen(false) + setHighlightedIndex(-1) + break + case 'PageUp': + setHighlightedIndex(0) + break + case 'PageDown': + setHighlightedIndex(filteredModels.length - 1) + break + } + } + + // Handle model selection from dropdown + const handleModelSelect = (model: string) => { + setInputValue(model) + onChange(model) + setOpen(false) + setDropdownPosition({ top: 0, left: 0, width: 0 }) + setHighlightedIndex(-1) + inputRef.current?.focus() + } + + return ( +
+
+ handleInputChange(e.target.value)} + onKeyDown={handleKeyDown} + onClick={() => { + // Open dropdown on click if models are available + if (models.length > 0) { + setOpen(true) + } + }} + placeholder={placeholder} + disabled={disabled} + className="pr-8" + /> + + {/* Dropdown trigger button */} + + + {/* Custom dropdown rendered as portal */} + {open && dropdownPosition.width > 0 && createPortal( +
{ + // Prevent interaction with underlying elements + e.stopPropagation() + }} + onClick={(e) => { + // Prevent click from bubbling up and closing modal + e.stopPropagation() + }} + onMouseDown={(e) => { + // Allow default behavior for scrolling and selection + e.stopPropagation() + }} + onWheel={(e) => { + // Allow wheel events for scrolling + e.stopPropagation() + }} + > + {/* Error state */} + {error && ( +
+
+ {t('common:failedToLoadModels')} + {onRefresh && ( + + )} +
+
{error}
+
+ )} + + {/* Loading state */} + {loading && ( +
+ + {t('common:loading')} +
+ )} + + {/* Models list */} + {!loading && !error && ( + <> + {filteredModels.length === 0 ? ( +
+ {inputValue.trim() ? ( + {t('common:noModelsFoundFor', { searchValue: inputValue })} + ) : ( + {t('common:noModels')} + )} +
+ ) : ( + <> + {/* Available models */} + {filteredModels.map((model, index) => ( +
{ + e.stopPropagation() + handleModelSelect(model) + }} + onMouseEnter={() => setHighlightedIndex(index)} + className={cn( + 'cursor-pointer px-3 py-2 hover:bg-main-view-fg/15 hover:shadow-sm transition-all duration-200 text-main-view-fg', + value === model && 'bg-main-view-fg/12 shadow-sm', + highlightedIndex === index && 'bg-main-view-fg/20 shadow-md' + )} + > + {model} +
+ ))} + + )} + + )} +
, + document.body + )} +
+
+ ) +} diff --git a/web-app/src/containers/dialogs/AddModel.tsx b/web-app/src/containers/dialogs/AddModel.tsx index 248600212..dc0eac244 100644 --- a/web-app/src/containers/dialogs/AddModel.tsx +++ b/web-app/src/containers/dialogs/AddModel.tsx @@ -8,8 +8,9 @@ import { DialogFooter, } from '@/components/ui/dialog' import { Button } from '@/components/ui/button' -import { Input } from '@/components/ui/input' import { useModelProvider } from '@/hooks/useModelProvider' +import { useProviderModels } from '@/hooks/useProviderModels' +import { ModelCombobox } from '@/containers/ModelCombobox' import { IconPlus } from '@tabler/icons-react' import { useState } from 'react' import { getProviderTitle } from '@/lib/utils' @@ -26,6 +27,11 @@ export const DialogAddModel = ({ provider, trigger }: DialogAddModelProps) => { const [modelId, setModelId] = useState('') const [open, setOpen] = useState(false) + // Fetch models from provider API (API key is optional) + const { models, loading, error, refetch } = useProviderModels( + provider.base_url ? provider : undefined + ) + // Handle form submission const handleSubmit = () => { if (!modelId.trim()) { @@ -72,7 +78,7 @@ export const DialogAddModel = ({ provider, trigger }: DialogAddModelProps) => { - {/* Model ID field - required */} + {/* Model selection field - required */}
- setModelId(e.target.value)} + onChange={setModelId} + models={models} + loading={loading} + error={error} + onRefresh={refetch} placeholder={t('providers:addModel.enterModelId')} - required />
diff --git a/web-app/src/hooks/useProviderModels.ts b/web-app/src/hooks/useProviderModels.ts new file mode 100644 index 000000000..5c984d1ca --- /dev/null +++ b/web-app/src/hooks/useProviderModels.ts @@ -0,0 +1,89 @@ +import { useState, useEffect, useCallback, useRef } from 'react' +import { fetchModelsFromProvider } from '@/services/providers' +import type { ModelProvider } from '@/types/providers' + +type UseProviderModelsState = { + models: string[] + loading: boolean + error: string | null + refetch: () => void +} + +const modelsCache = new Map() +const CACHE_DURATION = 5 * 60 * 1000 // 5 minutes + +export const useProviderModels = (provider?: ModelProvider): UseProviderModelsState => { + const [models, setModels] = useState([]) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + const prevProviderKey = useRef('') + + const fetchModels = useCallback(async () => { + if (!provider || !provider.base_url) { + // Clear models if provider is invalid (base_url is required, api_key is optional) + setModels([]) + setError(null) + setLoading(false) + return + } + + // Clear any previous state when starting a new fetch for a different provider + const currentProviderKey = `${provider.provider}-${provider.base_url}` + if (currentProviderKey !== prevProviderKey.current) { + setModels([]) + setError(null) + setLoading(false) + prevProviderKey.current = currentProviderKey + } + + const cacheKey = `${provider.provider}-${provider.base_url}` + const cached = modelsCache.get(cacheKey) + + // Check cache first + if (cached && Date.now() - cached.timestamp < CACHE_DURATION) { + setModels(cached.models) + return + } + + setLoading(true) + setError(null) + + try { + const fetchedModels = await fetchModelsFromProvider(provider) + const sortedModels = fetchedModels.sort((a, b) => a.localeCompare(b)) + + setModels(sortedModels) + + // Cache the results + modelsCache.set(cacheKey, { + models: sortedModels, + timestamp: Date.now(), + }) + } catch (err) { + const errorMessage = err instanceof Error ? err.message : 'Failed to fetch models' + setError(errorMessage) + console.error(`Error fetching models from ${provider.provider}:`, err) + } finally { + setLoading(false) + } + }, [provider]) + + const refetch = useCallback(() => { + if (provider) { + const cacheKey = `${provider.provider}-${provider.base_url}` + modelsCache.delete(cacheKey) + fetchModels() + } + }, [provider, fetchModels]) + + useEffect(() => { + fetchModels() + }, [fetchModels]) + + return { + models, + loading, + error, + refetch, + } +} diff --git a/web-app/src/locales/en/common.json b/web-app/src/locales/en/common.json index 46f2d5a8a..e5f5aa9f7 100644 --- a/web-app/src/locales/en/common.json +++ b/web-app/src/locales/en/common.json @@ -75,6 +75,8 @@ "selectAModel": "Select a model", "noToolsAvailable": "No tools available", "noModelsFoundFor": "No models found for \"{{searchValue}}\"", + "failedToLoadModels": "Failed to load models", + "noModels": "No models found", "customAvatar": "Custom avatar", "editAssistant": "Edit Assistant", "jan": "Jan", diff --git a/web-app/src/services/providers.ts b/web-app/src/services/providers.ts index e9f05fd09..748aed322 100644 --- a/web-app/src/services/providers.ts +++ b/web-app/src/services/providers.ts @@ -135,9 +135,24 @@ export const fetchModelsFromProvider = async ( }) if (!response.ok) { - throw new Error( - `Failed to fetch models: ${response.status} ${response.statusText}` - ) + // Provide more specific error messages based on status code + if (response.status === 401) { + throw new Error( + `Authentication failed: API key is required or invalid for ${provider.provider}` + ) + } else if (response.status === 403) { + throw new Error( + `Access forbidden: Check your API key permissions for ${provider.provider}` + ) + } else if (response.status === 404) { + throw new Error( + `Models endpoint not found for ${provider.provider}. Check the base URL configuration.` + ) + } else { + throw new Error( + `Failed to fetch models from ${provider.provider}: ${response.status} ${response.statusText}` + ) + } } const data = await response.json() @@ -167,14 +182,26 @@ export const fetchModelsFromProvider = async ( } catch (error) { console.error('Error fetching models from provider:', error) - // Provide helpful error message + if (error instanceof Error && ( + error.message.includes('Authentication failed') || + error.message.includes('Access forbidden') || + error.message.includes('Models endpoint not found') || + error.message.includes('Failed to fetch models from') + )) { + throw error + } + + // Provide helpful error message for network issues if (error instanceof Error && error.message.includes('fetch')) { throw new Error( `Cannot connect to ${provider.provider} at ${provider.base_url}. Please check that the service is running and accessible.` ) } - throw error + // Generic fallback + throw new Error( + `Unexpected error while fetching models from ${provider.provider}: ${error instanceof Error ? error.message : 'Unknown error'}` + ) } } From 3339629747a891c0cd20d46ab50b2e64310c4a16 Mon Sep 17 00:00:00 2001 From: lugnicca Date: Wed, 20 Aug 2025 16:22:21 +0200 Subject: [PATCH 02/24] test: add unit tests for ModelCombobox, useProviderModels and providers --- .../__tests__/ModelCombobox.test.tsx | 197 ++++++++++++++++++ .../hooks/__tests__/useProviderModels.test.ts | 105 ++++++++++ .../src/services/__tests__/providers.test.ts | 40 +++- 3 files changed, 340 insertions(+), 2 deletions(-) create mode 100644 web-app/src/containers/__tests__/ModelCombobox.test.tsx create mode 100644 web-app/src/hooks/__tests__/useProviderModels.test.ts diff --git a/web-app/src/containers/__tests__/ModelCombobox.test.tsx b/web-app/src/containers/__tests__/ModelCombobox.test.tsx new file mode 100644 index 000000000..88158f268 --- /dev/null +++ b/web-app/src/containers/__tests__/ModelCombobox.test.tsx @@ -0,0 +1,197 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { render, screen, fireEvent, waitFor, act } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import '@testing-library/jest-dom/vitest' +import { ModelCombobox } from '../ModelCombobox' +import React from 'react' + +describe('ModelCombobox', () => { + const defaultProps = { + value: '', + onChange: vi.fn(), + models: ['gpt-3.5-turbo', 'gpt-4', 'claude-3-haiku'], + } + + const mockUser = userEvent.setup() + + beforeEach(() => { + vi.clearAllMocks() + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + describe('Basic Rendering', () => { + it('should render input field with placeholder', () => { + render() + + const input = screen.getByRole('textbox') + expect(input).toBeInTheDocument() + expect(input).toHaveAttribute('placeholder', 'Type or select a model...') + }) + + it('should render custom placeholder', () => { + render() + + const input = screen.getByRole('textbox') + expect(input).toHaveAttribute('placeholder', 'Choose a model') + }) + + it('should render dropdown trigger button', () => { + render() + const button = screen.getByRole('button') + expect(button).toBeInTheDocument() + }) + + it('should display current value in input', () => { + render() + + const input = screen.getByDisplayValue('gpt-4') + expect(input).toBeInTheDocument() + }) + + it('should apply custom className', () => { + const { container } = render( + + ) + + const wrapper = container.firstChild as HTMLElement + expect(wrapper).toHaveClass('custom-class') + }) + }) + + describe('Disabled State', () => { + it('should disable input when disabled prop is true', () => { + render() + + const input = screen.getByRole('textbox') + const button = screen.getByRole('button') + + expect(input).toBeDisabled() + expect(button).toBeDisabled() + }) + + it('should not open dropdown when disabled', async () => { + render() + + const input = screen.getByRole('textbox') + await mockUser.click(input) + + expect(screen.queryByTestId('dropdown')).not.toBeInTheDocument() + }) + }) + + describe('Loading State', () => { + it('should show loading spinner in trigger button', () => { + render() + + const button = screen.getByRole('button') + const spinner = button.querySelector('.animate-spin') + expect(spinner).toBeInTheDocument() + }) + + it('should show loading spinner when loading prop is true', () => { + render() + + const spinner = screen.getByRole('button').querySelector('.animate-spin') + expect(spinner).toBeInTheDocument() + }) + }) + + describe('Input Interactions', () => { + it('should call onChange when typing', async () => { + const mockOnChange = vi.fn() + render() + + const input = screen.getByRole('textbox') + await mockUser.type(input, 'g') + + expect(mockOnChange).toHaveBeenCalledWith('g') + }) + + it('should update input value when typing', async () => { + const mockOnChange = vi.fn() + render() + + const input = screen.getByRole('textbox') + await mockUser.type(input, 'test') + + expect(input).toHaveValue('test') + }) + + it('should handle input focus', async () => { + render() + + const input = screen.getByRole('textbox') + await mockUser.click(input) + + expect(input).toHaveFocus() + }) + }) + + describe('Props Validation', () => { + it('should render with empty models array', () => { + render() + + const input = screen.getByRole('textbox') + expect(input).toBeInTheDocument() + }) + + it('should render with models array', () => { + render() + + const input = screen.getByRole('textbox') + expect(input).toBeInTheDocument() + }) + + it('should render with all props', () => { + render( + + ) + + const input = screen.getByRole('textbox') + expect(input).toBeInTheDocument() + expect(input).toBeDisabled() + }) + }) + + describe('Component Lifecycle', () => { + it('should handle mount and unmount without errors', () => { + const { unmount } = render() + + expect(screen.getByRole('textbox')).toBeInTheDocument() + + unmount() + + expect(screen.queryByRole('textbox')).not.toBeInTheDocument() + }) + + it('should handle props changes', () => { + const { rerender } = render() + + expect(screen.getByDisplayValue('')).toBeInTheDocument() + + rerender() + + expect(screen.getByDisplayValue('gpt-4')).toBeInTheDocument() + }) + + it('should handle models array changes', () => { + const { rerender } = render() + + expect(screen.getByRole('textbox')).toBeInTheDocument() + + rerender() + + expect(screen.getByRole('textbox')).toBeInTheDocument() + }) + }) +}) diff --git a/web-app/src/hooks/__tests__/useProviderModels.test.ts b/web-app/src/hooks/__tests__/useProviderModels.test.ts new file mode 100644 index 000000000..3d107b9f8 --- /dev/null +++ b/web-app/src/hooks/__tests__/useProviderModels.test.ts @@ -0,0 +1,105 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { renderHook, waitFor } from '@testing-library/react' +import { useProviderModels } from '../useProviderModels' + +// Mock the providers service +vi.mock('@/services/providers', () => ({ + fetchModelsFromProvider: vi.fn(), +})) + +import { fetchModelsFromProvider } from '@/services/providers' +const mockFetchModelsFromProvider = vi.mocked(fetchModelsFromProvider) + +// Mock ModelProvider type +type MockModelProvider = { + active: boolean + provider: string + base_url?: string + api_key?: string + settings: any[] + models: any[] +} + +describe('useProviderModels', () => { + const mockProvider: MockModelProvider = { + active: true, + provider: 'openai', + base_url: 'https://api.openai.com/v1', + api_key: 'test-api-key', + settings: [], + models: [], + } + + const mockModels = ['gpt-4', 'gpt-3.5-turbo', 'gpt-4-turbo'] + + beforeEach(() => { + vi.clearAllMocks() + // Reset the cache by clearing any previous state + mockFetchModelsFromProvider.mockClear() + }) + + it('should initialize with empty state', () => { + const { result } = renderHook(() => useProviderModels()) + + expect(result.current.models).toEqual([]) + expect(result.current.loading).toBe(false) + expect(result.current.error).toBe(null) + expect(typeof result.current.refetch).toBe('function') + }) + + it('should not fetch models when provider is undefined', () => { + renderHook(() => useProviderModels(undefined)) + expect(mockFetchModelsFromProvider).not.toHaveBeenCalled() + }) + + it('should not fetch models when provider has no base_url', () => { + const providerWithoutUrl = { ...mockProvider, base_url: undefined } + renderHook(() => useProviderModels(providerWithoutUrl)) + expect(mockFetchModelsFromProvider).not.toHaveBeenCalled() + }) + + it('should fetch and sort models', async () => { + mockFetchModelsFromProvider.mockResolvedValueOnce(mockModels) + + const { result } = renderHook(() => useProviderModels(mockProvider)) + + await waitFor(() => { + expect(result.current.loading).toBe(false) + }) + + // Should be sorted alphabetically + expect(result.current.models).toEqual(['gpt-3.5-turbo', 'gpt-4', 'gpt-4-turbo']) + expect(result.current.error).toBe(null) + expect(mockFetchModelsFromProvider).toHaveBeenCalledWith(mockProvider) + }) + + it('should clear models when switching to invalid provider', async () => { + mockFetchModelsFromProvider.mockResolvedValueOnce(mockModels) + + const { result, rerender } = renderHook( + ({ provider }) => useProviderModels(provider), + { initialProps: { provider: mockProvider } } + ) + + await waitFor(() => { + expect(result.current.loading).toBe(false) + }) + + expect(result.current.models).toEqual(['gpt-3.5-turbo', 'gpt-4', 'gpt-4-turbo']) + + // Switch to invalid provider + rerender({ provider: { ...mockProvider, base_url: undefined } }) + + expect(result.current.models).toEqual([]) + expect(result.current.error).toBe(null) + expect(result.current.loading).toBe(false) + }) + + it('should not refetch when provider is undefined', () => { + const { result } = renderHook(() => useProviderModels(undefined)) + + result.current.refetch() + + expect(mockFetchModelsFromProvider).not.toHaveBeenCalled() + }) +}) \ No newline at end of file diff --git a/web-app/src/services/__tests__/providers.test.ts b/web-app/src/services/__tests__/providers.test.ts index 6660ffa30..c7b041cd5 100644 --- a/web-app/src/services/__tests__/providers.test.ts +++ b/web-app/src/services/__tests__/providers.test.ts @@ -222,7 +222,7 @@ describe('providers service', () => { ) }) - it('should throw error when API response is not ok', async () => { + it('should throw error when API response is not ok (404)', async () => { const mockResponse = { ok: false, status: 404, @@ -236,7 +236,43 @@ describe('providers service', () => { } await expect(fetchModelsFromProvider(provider)).rejects.toThrow( - 'Cannot connect to custom at https://api.custom.com. Please check that the service is running and accessible.' + 'Models endpoint not found for custom. Check the base URL configuration.' + ) + }) + + it('should throw error when API response is not ok (403)', async () => { + const mockResponse = { + ok: false, + status: 403, + statusText: 'Forbidden', + } + vi.mocked(fetchTauri).mockResolvedValue(mockResponse as any) + + const provider = { + provider: 'custom', + base_url: 'https://api.custom.com', + } as ModelProvider + + await expect(fetchModelsFromProvider(provider)).rejects.toThrow( + 'Access forbidden: Check your API key permissions for custom' + ) + }) + + it('should throw error when API response is not ok (401)', async () => { + const mockResponse = { + ok: false, + status: 401, + statusText: 'Unauthorized', + } + vi.mocked(fetchTauri).mockResolvedValue(mockResponse as any) + + const provider = { + provider: 'custom', + base_url: 'https://api.custom.com', + } as ModelProvider + + await expect(fetchModelsFromProvider(provider)).rejects.toThrow( + 'Authentication failed: API key is required or invalid for custom' ) }) From f35e6cdae86973b7534c710cb3ae73bad807ae2d Mon Sep 17 00:00:00 2001 From: lugnicca Date: Fri, 22 Aug 2025 02:20:47 +0200 Subject: [PATCH 03/24] refactor: clean model selector and add more tests --- web-app/src/containers/ModelCombobox.tsx | 496 +++++++++------- .../__tests__/ModelCombobox.test.tsx | 548 ++++++++++++++---- 2 files changed, 708 insertions(+), 336 deletions(-) diff --git a/web-app/src/containers/ModelCombobox.tsx b/web-app/src/containers/ModelCombobox.tsx index 34c95bf6d..80fe0157c 100644 --- a/web-app/src/containers/ModelCombobox.tsx +++ b/web-app/src/containers/ModelCombobox.tsx @@ -6,6 +6,216 @@ import { IconChevronDown, IconLoader2, IconRefresh } from '@tabler/icons-react' import { cn } from '@/lib/utils' import { useTranslation } from '@/i18n/react-i18next-compat' +// Hook for the dropdown position +function useDropdownPosition(open: boolean, containerRef: React.RefObject) { + const [dropdownPosition, setDropdownPosition] = useState({ top: 0, left: 0, width: 0 }) + + const updateDropdownPosition = useCallback(() => { + if (containerRef.current) { + const rect = containerRef.current.getBoundingClientRect() + setDropdownPosition({ + top: rect.bottom + window.scrollY + 4, + left: rect.left + window.scrollX, + width: rect.width, + }) + } + }, [containerRef]) + + // Update the position when the dropdown opens + useEffect(() => { + if (open) { + requestAnimationFrame(() => { + updateDropdownPosition() + }) + } + }, [open, updateDropdownPosition]) + + // Update the position when the window is resized + useEffect(() => { + if (!open) return + + const handleResize = () => { + updateDropdownPosition() + } + + window.addEventListener('resize', handleResize) + window.addEventListener('scroll', handleResize) + + return () => { + window.removeEventListener('resize', handleResize) + window.removeEventListener('scroll', handleResize) + } + }, [open, updateDropdownPosition]) + + return { dropdownPosition, updateDropdownPosition } +} + +// Components for the different sections of the dropdown +const ErrorSection = ({ error, onRefresh, t }: { error: string; onRefresh?: () => void; t: (key: string) => string }) => ( +
+
+ {t('common:failedToLoadModels')} + {onRefresh && ( + + )} +
+
{error}
+
+) + +const LoadingSection = ({ t }: { t: (key: string) => string }) => ( +
+ + {t('common:loading')} +
+) + +const EmptySection = ({ inputValue, t }: { inputValue: string; t: (key: string, options?: Record) => string }) => ( +
+ {inputValue.trim() ? ( + {t('common:noModelsFoundFor', { searchValue: inputValue })} + ) : ( + {t('common:noModels')} + )} +
+) + +const ModelsList = ({ + filteredModels, + value, + highlightedIndex, + onModelSelect, + onHighlight +}: { + filteredModels: string[] + value: string + highlightedIndex: number + onModelSelect: (model: string) => void + onHighlight: (index: number) => void +}) => ( + <> + {filteredModels.map((model, index) => ( +
{ + e.stopPropagation() + onModelSelect(model) + }} + onMouseEnter={() => onHighlight(index)} + className={cn( + 'cursor-pointer px-3 py-2 hover:bg-main-view-fg/15 hover:shadow-sm transition-all duration-200 text-main-view-fg', + value === model && 'bg-main-view-fg/12 shadow-sm', + highlightedIndex === index && 'bg-main-view-fg/20 shadow-md' + )} + > + {model} +
+ ))} + +) + +// Custom hook for keyboard navigation +function useKeyboardNavigation( + open: boolean, + setOpen: React.Dispatch>, + models: string[], + filteredModels: string[], + highlightedIndex: number, + setHighlightedIndex: React.Dispatch>, + onModelSelect: (model: string) => void, + dropdownRef: React.RefObject +) { + const keyRepeatTimeoutRef = useRef(null) + + // Scroll to the highlighted element + useEffect(() => { + if (highlightedIndex >= 0 && dropdownRef.current) { + requestAnimationFrame(() => { + const modelElements = dropdownRef.current?.querySelectorAll('[data-model]') + const highlightedElement = modelElements?.[highlightedIndex] as HTMLElement + if (highlightedElement) { + highlightedElement.scrollIntoView({ + block: 'nearest', + behavior: 'auto' + }) + } + }) + } + }, [highlightedIndex, dropdownRef]) + + const handleKeyDown = useCallback((e: React.KeyboardEvent) => { + // Open the dropdown with the arrows if closed + if (!open && (e.key === 'ArrowDown' || e.key === 'ArrowUp')) { + if (models.length > 0) { + e.preventDefault() + setOpen(true) + setHighlightedIndex(0) + } + return + } + + if (!open) return + + switch (e.key) { + case 'ArrowDown': + e.preventDefault() + if (keyRepeatTimeoutRef.current) { + clearTimeout(keyRepeatTimeoutRef.current) + } + setHighlightedIndex((prev: number) => filteredModels.length === 0 ? 0 : (prev < filteredModels.length - 1 ? prev + 1 : 0)) + break + case 'ArrowUp': + e.preventDefault() + if (keyRepeatTimeoutRef.current) { + clearTimeout(keyRepeatTimeoutRef.current) + } + setHighlightedIndex((prev: number) => filteredModels.length === 0 ? 0 : (prev > 0 ? prev - 1 : filteredModels.length - 1)) + break + case 'Enter': + e.preventDefault() + if (highlightedIndex >= 0 && highlightedIndex < filteredModels.length) { + onModelSelect(filteredModels[highlightedIndex]) + } + break + case 'ArrowRight': + case 'ArrowLeft': + setOpen(false) + setHighlightedIndex(-1) + break + case 'PageUp': + setHighlightedIndex(0) + break + case 'PageDown': + setHighlightedIndex(filteredModels.length - 1) + break + } + }, [open, setOpen, models.length, filteredModels, highlightedIndex, setHighlightedIndex, onModelSelect]) + + // Cleanup the timeout + useEffect(() => { + const timeoutId = keyRepeatTimeoutRef.current + return () => { + if (timeoutId) { + clearTimeout(timeoutId) + } + } + }, []) + + return { handleKeyDown } +} + type ModelComboboxProps = { value: string onChange: (value: string) => void @@ -31,12 +241,10 @@ export function ModelCombobox({ }: ModelComboboxProps) { const [open, setOpen] = useState(false) const [inputValue, setInputValue] = useState(value) - const [dropdownPosition, setDropdownPosition] = useState({ top: 0, left: 0, width: 0 }) const [highlightedIndex, setHighlightedIndex] = useState(-1) const inputRef = useRef(null) const containerRef = useRef(null) - const dropdownRef = useRef(null) - const keyRepeatTimeoutRef = useRef(null) + const dropdownRef = useRef(null) const { t } = useTranslation() // Sync input value with prop value @@ -44,47 +252,36 @@ export function ModelCombobox({ setInputValue(value) }, [value]) - // Simple position calculation - const updateDropdownPosition = useCallback(() => { - if (containerRef.current) { - const rect = containerRef.current.getBoundingClientRect() - setDropdownPosition({ - top: rect.bottom + window.scrollY + 4, - left: rect.left + window.scrollX, - width: rect.width, - }) - } - }, []) + // Hook for the dropdown position + const { dropdownPosition } = useDropdownPosition(open, containerRef) - // Update position when opening + // Optimized model filtering + const filteredModels = useMemo(() => { + if (!inputValue.trim()) return models + const searchValue = inputValue.toLowerCase() + return models.filter((model) => model.toLowerCase().includes(searchValue)) + }, [models, inputValue]) + + // Reset highlighted index when filtered models change useEffect(() => { - if (open) { - // Use requestAnimationFrame to ensure DOM is ready - requestAnimationFrame(() => { - updateDropdownPosition() - }) - } - }, [open, updateDropdownPosition]) + setHighlightedIndex(-1) + }, [filteredModels]) - // Close dropdown when clicking outside + // Close the dropdown when clicking outside useEffect(() => { if (!open) return const handleClickOutside = (event: Event) => { const target = event.target as Node - // Check if click is inside our container or dropdown const isInsideContainer = containerRef.current?.contains(target) const isInsideDropdown = dropdownRef.current?.contains(target) - // Only close if click is outside both container and dropdown if (!isInsideContainer && !isInsideDropdown) { setOpen(false) - setDropdownPosition({ top: 0, left: 0, width: 0 }) setHighlightedIndex(-1) } } - // Use multiple event types to ensure we catch all interactions const events = ['mousedown', 'touchstart'] events.forEach(eventType => { document.addEventListener(eventType, handleClickOutside, { capture: true, passive: true }) @@ -97,127 +294,60 @@ export function ModelCombobox({ } }, [open]) - // Cleanup: close dropdown when component unmounts + // Cleanup: close the dropdown when the component is unmounted useEffect(() => { - const timeoutId = keyRepeatTimeoutRef.current return () => { setOpen(false) - setDropdownPosition({ top: 0, left: 0, width: 0 }) setHighlightedIndex(-1) - if (timeoutId) { - clearTimeout(timeoutId) - } } }, []) - // Filter models based on input - const filteredModels = useMemo(() => { - if (!inputValue.trim()) return models - - return models.filter((model) => - model.toLowerCase().includes(inputValue.toLowerCase()) - ) - }, [models, inputValue]) - - // Reset highlighted index when filtered models change - useEffect(() => { - setHighlightedIndex(-1) - }, [filteredModels]) - - // Scroll to highlighted item with debouncing to handle key repeat - useEffect(() => { - if (highlightedIndex >= 0 && dropdownRef.current && !loading && !error) { - // Use requestAnimationFrame to ensure smooth scrolling and avoid conflicts - requestAnimationFrame(() => { - // Find all model elements (they have the data-model attribute) - const modelElements = dropdownRef.current?.querySelectorAll('[data-model]') - const highlightedElement = modelElements?.[highlightedIndex] as HTMLElement - if (highlightedElement) { - highlightedElement.scrollIntoView({ - block: 'nearest', - behavior: 'auto' - }) - } - }) - } - }, [highlightedIndex, error, loading]) - - // Handle input change - const handleInputChange = (newValue: string) => { + // Handler for the input change + const handleInputChange = useCallback((newValue: string) => { setInputValue(newValue) onChange(newValue) - - // Only open dropdown if user is actively typing and there are models + + // Open the dropdown if the user types and there are models if (newValue.trim() && models.length > 0) { setOpen(true) } else { - // Don't auto-open on empty input - wait for user interaction setOpen(false) } - } + }, [onChange, models.length]) - // Handle keyboard navigation - const handleKeyDown = (e: React.KeyboardEvent) => { - if (!open && (e.key === 'ArrowDown' || e.key === 'ArrowUp')) { - // Open dropdown on arrow keys if there are models - if (models.length > 0) { - e.preventDefault() - setOpen(true) - setHighlightedIndex(0) - } - return - } - - if (!open) return - - switch (e.key) { - case 'ArrowDown': - e.preventDefault() - if (keyRepeatTimeoutRef.current) { - clearTimeout(keyRepeatTimeoutRef.current) - } - setHighlightedIndex((prev) => - filteredModels.length === 0 ? 0 : (prev < filteredModels.length - 1 ? prev + 1 : 0) - ) - break - case 'ArrowUp': - e.preventDefault() - if (keyRepeatTimeoutRef.current) { - clearTimeout(keyRepeatTimeoutRef.current) - } - setHighlightedIndex((prev) => - filteredModels.length === 0 ? 0 : (prev > 0 ? prev - 1 : filteredModels.length - 1) - ) - break - case 'Enter': - e.preventDefault() - if (highlightedIndex >= 0 && highlightedIndex < filteredModels.length) { - handleModelSelect(filteredModels[highlightedIndex]) - } - break - case 'ArrowRight': - case 'ArrowLeft': - setOpen(false) - setHighlightedIndex(-1) - break - case 'PageUp': - setHighlightedIndex(0) - break - case 'PageDown': - setHighlightedIndex(filteredModels.length - 1) - break - } - } - - // Handle model selection from dropdown - const handleModelSelect = (model: string) => { + // Handler for the model selection + const handleModelSelect = useCallback((model: string) => { setInputValue(model) onChange(model) setOpen(false) - setDropdownPosition({ top: 0, left: 0, width: 0 }) setHighlightedIndex(-1) inputRef.current?.focus() - } + }, [onChange]) + + // Hook for the keyboard navigation + const { handleKeyDown } = useKeyboardNavigation( + open, + setOpen, + models, + filteredModels, + highlightedIndex, + setHighlightedIndex, + handleModelSelect, + dropdownRef + ) + + // Handler for the dropdown opening + const handleDropdownToggle = useCallback(() => { + inputRef.current?.focus() + setOpen(!open) + }, [open]) + + // Handler for the input click + const handleInputClick = useCallback(() => { + if (models.length > 0) { + setOpen(true) + } + }, [models.length]) return (
@@ -227,12 +357,7 @@ export function ModelCombobox({ value={inputValue} onChange={(e) => handleInputChange(e.target.value)} onKeyDown={handleKeyDown} - onClick={() => { - // Open dropdown on click if models are available - if (models.length > 0) { - setOpen(true) - } - }} + onClick={handleInputClick} placeholder={placeholder} disabled={disabled} className="pr-8" @@ -243,14 +368,8 @@ export function ModelCombobox({ variant="link" size="sm" disabled={disabled} - onMouseDown={(e) => { - // Prevent losing focus from input - e.preventDefault() - }} - onClick={() => { - inputRef.current?.focus() - setOpen(!open) - }} + onMouseDown={(e) => e.preventDefault()} + onClick={handleDropdownToggle} className="absolute right-1 top-1/2 h-6 w-6 p-0 -translate-y-1/2 no-underline hover:bg-main-view-fg/10" > {loading ? ( @@ -274,89 +393,30 @@ export function ModelCombobox({ pointerEvents: 'auto', }} data-dropdown="model-combobox" - onPointerDown={(e) => { - // Prevent interaction with underlying elements - e.stopPropagation() - }} - onClick={(e) => { - // Prevent click from bubbling up and closing modal - e.stopPropagation() - }} - onMouseDown={(e) => { - // Allow default behavior for scrolling and selection - e.stopPropagation() - }} - onWheel={(e) => { - // Allow wheel events for scrolling - e.stopPropagation() - }} + onPointerDown={(e) => e.stopPropagation()} + onClick={(e) => e.stopPropagation()} + onMouseDown={(e) => e.stopPropagation()} + onWheel={(e) => e.stopPropagation()} > {/* Error state */} - {error && ( -
-
- {t('common:failedToLoadModels')} - {onRefresh && ( - - )} -
-
{error}
-
- )} + {error && } {/* Loading state */} - {loading && ( -
- - {t('common:loading')} -
- )} + {loading && } {/* Models list */} {!loading && !error && ( - <> - {filteredModels.length === 0 ? ( -
- {inputValue.trim() ? ( - {t('common:noModelsFoundFor', { searchValue: inputValue })} - ) : ( - {t('common:noModels')} - )} -
- ) : ( - <> - {/* Available models */} - {filteredModels.map((model, index) => ( -
{ - e.stopPropagation() - handleModelSelect(model) - }} - onMouseEnter={() => setHighlightedIndex(index)} - className={cn( - 'cursor-pointer px-3 py-2 hover:bg-main-view-fg/15 hover:shadow-sm transition-all duration-200 text-main-view-fg', - value === model && 'bg-main-view-fg/12 shadow-sm', - highlightedIndex === index && 'bg-main-view-fg/20 shadow-md' - )} - > - {model} -
- ))} - - )} - + filteredModels.length === 0 ? ( + + ) : ( + + ) )}
, document.body diff --git a/web-app/src/containers/__tests__/ModelCombobox.test.tsx b/web-app/src/containers/__tests__/ModelCombobox.test.tsx index 88158f268..29496a6aa 100644 --- a/web-app/src/containers/__tests__/ModelCombobox.test.tsx +++ b/web-app/src/containers/__tests__/ModelCombobox.test.tsx @@ -1,197 +1,509 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { describe, it, expect, vi, beforeEach } from 'vitest' import { render, screen, fireEvent, waitFor, act } from '@testing-library/react' import userEvent from '@testing-library/user-event' import '@testing-library/jest-dom/vitest' -import { ModelCombobox } from '../ModelCombobox' import React from 'react' +import { ModelCombobox } from '../ModelCombobox' + +// Mock translation hook +vi.mock('@/i18n/react-i18next-compat', () => ({ + useTranslation: () => ({ + t: (key: string, options?: Record) => { + if (key === 'common:failedToLoadModels') return 'Failed to load models' + if (key === 'common:loading') return 'Loading' + if (key === 'common:noModelsFoundFor') return `No models found for "${options?.searchValue}"` + if (key === 'common:noModels') return 'No models available' + return key + }, + }), +})) describe('ModelCombobox', () => { + const mockOnChange = vi.fn() + const mockOnRefresh = vi.fn() + const defaultProps = { value: '', - onChange: vi.fn(), + onChange: mockOnChange, models: ['gpt-3.5-turbo', 'gpt-4', 'claude-3-haiku'], } - const mockUser = userEvent.setup() - beforeEach(() => { vi.clearAllMocks() + + Element.prototype.getBoundingClientRect = vi.fn(() => ({ + width: 300, + height: 40, + top: 100, + left: 50, + bottom: 140, + right: 350, + x: 50, + y: 100, + toJSON: () => {}, + })) + + Element.prototype.scrollIntoView = vi.fn() }) - afterEach(() => { - vi.restoreAllMocks() - }) - - describe('Basic Rendering', () => { - it('should render input field with placeholder', () => { + it('renders input field with default placeholder', () => { + act(() => { render() - - const input = screen.getByRole('textbox') - expect(input).toBeInTheDocument() - expect(input).toHaveAttribute('placeholder', 'Type or select a model...') }) + + const input = screen.getByRole('textbox') + expect(input).toBeInTheDocument() + expect(input).toHaveAttribute('placeholder', 'Type or select a model...') + }) - it('should render custom placeholder', () => { + it('renders custom placeholder', () => { + act(() => { render() - - const input = screen.getByRole('textbox') - expect(input).toHaveAttribute('placeholder', 'Choose a model') }) + + const input = screen.getByRole('textbox') + expect(input).toHaveAttribute('placeholder', 'Choose a model') + }) - it('should render dropdown trigger button', () => { + it('renders dropdown trigger button', () => { + act(() => { render() - const button = screen.getByRole('button') - expect(button).toBeInTheDocument() }) + + const button = screen.getByRole('button') + expect(button).toBeInTheDocument() + }) - it('should display current value in input', () => { + it('displays current value in input', () => { + act(() => { render() - - const input = screen.getByDisplayValue('gpt-4') - expect(input).toBeInTheDocument() - }) - - it('should apply custom className', () => { - const { container } = render( - - ) - - const wrapper = container.firstChild as HTMLElement - expect(wrapper).toHaveClass('custom-class') }) + + const input = screen.getByDisplayValue('gpt-4') + expect(input).toBeInTheDocument() }) - describe('Disabled State', () => { - it('should disable input when disabled prop is true', () => { + it('applies custom className', () => { + const { container } = render( + + ) + + const wrapper = container.firstChild as HTMLElement + expect(wrapper).toHaveClass('custom-class') + }) + + it('disables input when disabled prop is true', () => { + act(() => { render() - - const input = screen.getByRole('textbox') - const button = screen.getByRole('button') - - expect(input).toBeDisabled() - expect(button).toBeDisabled() }) + + const input = screen.getByRole('textbox') + const button = screen.getByRole('button') - it('should not open dropdown when disabled', async () => { - render() - - const input = screen.getByRole('textbox') - await mockUser.click(input) - - expect(screen.queryByTestId('dropdown')).not.toBeInTheDocument() - }) + expect(input).toBeDisabled() + expect(button).toBeDisabled() }) - describe('Loading State', () => { - it('should show loading spinner in trigger button', () => { + it('shows loading spinner in trigger button', () => { + act(() => { render() - - const button = screen.getByRole('button') - const spinner = button.querySelector('.animate-spin') - expect(spinner).toBeInTheDocument() }) + + const button = screen.getByRole('button') + const spinner = button.querySelector('.animate-spin') + expect(spinner).toBeInTheDocument() + }) - it('should show loading spinner when loading prop is true', () => { - render() - - const spinner = screen.getByRole('button').querySelector('.animate-spin') - expect(spinner).toBeInTheDocument() + it('shows loading section when dropdown is opened during loading', async () => { + const user = userEvent.setup() + render() + + // Click input to trigger dropdown opening + const input = screen.getByRole('textbox') + await user.click(input) + + // Wait for dropdown to appear and check loading section + await waitFor(() => { + const dropdown = document.querySelector('[data-dropdown="model-combobox"]') + expect(dropdown).toBeInTheDocument() + expect(screen.getByText('Loading')).toBeInTheDocument() }) }) - describe('Input Interactions', () => { - it('should call onChange when typing', async () => { - const mockOnChange = vi.fn() - render() + it('calls onChange when typing', async () => { + const user = userEvent.setup() + const localMockOnChange = vi.fn() + render() - const input = screen.getByRole('textbox') - await mockUser.type(input, 'g') + const input = screen.getByRole('textbox') + await user.type(input, 'g') - expect(mockOnChange).toHaveBeenCalledWith('g') - }) - - it('should update input value when typing', async () => { - const mockOnChange = vi.fn() - render() - - const input = screen.getByRole('textbox') - await mockUser.type(input, 'test') - - expect(input).toHaveValue('test') - }) - - it('should handle input focus', async () => { - render() - - const input = screen.getByRole('textbox') - await mockUser.click(input) - - expect(input).toHaveFocus() - }) + expect(localMockOnChange).toHaveBeenCalledWith('g') }) - describe('Props Validation', () => { - it('should render with empty models array', () => { + it('updates input value when typing', async () => { + const user = userEvent.setup() + render() + + const input = screen.getByRole('textbox') + await user.type(input, 'test') + + expect(input).toHaveValue('test') + }) + + it('handles input focus', async () => { + const user = userEvent.setup() + render() + + const input = screen.getByRole('textbox') + await user.click(input) + + expect(input).toHaveFocus() + }) + + it('renders with empty models array', () => { + act(() => { render() - - const input = screen.getByRole('textbox') - expect(input).toBeInTheDocument() }) + + const input = screen.getByRole('textbox') + expect(input).toBeInTheDocument() + }) - it('should render with models array', () => { + it('renders with models array', () => { + act(() => { render() + }) + + const input = screen.getByRole('textbox') + expect(input).toBeInTheDocument() + }) - const input = screen.getByRole('textbox') - expect(input).toBeInTheDocument() + it('handles mount and unmount without errors', () => { + const { unmount } = render() + + expect(screen.getByRole('textbox')).toBeInTheDocument() + + unmount() + + expect(screen.queryByRole('textbox')).not.toBeInTheDocument() + }) + + it('handles props changes', () => { + const { rerender } = render() + + expect(screen.getByDisplayValue('')).toBeInTheDocument() + + rerender() + + expect(screen.getByDisplayValue('gpt-4')).toBeInTheDocument() + }) + + it('handles models array changes', () => { + const { rerender } = render() + + expect(screen.getByRole('textbox')).toBeInTheDocument() + + rerender() + + expect(screen.getByRole('textbox')).toBeInTheDocument() + }) + + it('does not open dropdown when clicking input with no models', async () => { + const user = userEvent.setup() + render() + + const input = screen.getByRole('textbox') + await user.click(input) + + // Should focus but not open dropdown + expect(input).toHaveFocus() + const dropdown = document.querySelector('[data-dropdown="model-combobox"]') + expect(dropdown).not.toBeInTheDocument() + }) + + it('accepts error prop without crashing', () => { + act(() => { + render() }) - it('should render with all props', () => { + const input = screen.getByRole('textbox') + expect(input).toBeInTheDocument() + expect(input).toHaveAttribute('placeholder', 'Type or select a model...') + }) + + it('renders with all props', () => { + act(() => { render( ) + }) + + const input = screen.getByRole('textbox') + expect(input).toBeInTheDocument() + expect(input).toBeDisabled() + }) - const input = screen.getByRole('textbox') - expect(input).toBeInTheDocument() - expect(input).toBeDisabled() + it('opens dropdown when clicking trigger button', async () => { + const user = userEvent.setup() + render() + + const button = screen.getByRole('button') + await user.click(button) + + await waitFor(() => { + const dropdown = document.querySelector('[data-dropdown="model-combobox"]') + expect(dropdown).toBeInTheDocument() }) }) - describe('Component Lifecycle', () => { - it('should handle mount and unmount without errors', () => { - const { unmount } = render() + it('opens dropdown when clicking input', async () => { + const user = userEvent.setup() + render() - expect(screen.getByRole('textbox')).toBeInTheDocument() + const input = screen.getByRole('textbox') + await user.click(input) - unmount() + expect(input).toHaveFocus() + await waitFor(() => { + const dropdown = document.querySelector('[data-dropdown="model-combobox"]') + expect(dropdown).toBeInTheDocument() + }) + }) - expect(screen.queryByRole('textbox')).not.toBeInTheDocument() + it('filters models based on input value', async () => { + const user = userEvent.setup() + const localMockOnChange = vi.fn() + render() + + const input = screen.getByRole('textbox') + await user.type(input, 'gpt-4') + + expect(localMockOnChange).toHaveBeenCalledWith('gpt-4') + }) + + it('shows filtered models in dropdown when typing', async () => { + const user = userEvent.setup() + render() + + const input = screen.getByRole('textbox') + // Type 'gpt' to trigger dropdown opening + await user.type(input, 'gpt') + + await waitFor(() => { + // Dropdown should be open + const dropdown = document.querySelector('[data-dropdown="model-combobox"]') + expect(dropdown).toBeInTheDocument() + + // Should show GPT models + expect(screen.getByText('gpt-3.5-turbo')).toBeInTheDocument() + expect(screen.getByText('gpt-4')).toBeInTheDocument() + // Should not show Claude + expect(screen.queryByText('claude-3-haiku')).not.toBeInTheDocument() + }) + }) + + it('handles case insensitive filtering', async () => { + const user = userEvent.setup() + render() + + const input = screen.getByRole('textbox') + await user.type(input, 'GPT') + + expect(mockOnChange).toHaveBeenCalledWith('GPT') + }) + + it('shows empty state when no models match filter', async () => { + const user = userEvent.setup() + render() + + const input = screen.getByRole('textbox') + // Type something that doesn't match any model to trigger dropdown + empty state + await user.type(input, 'nonexistent') + + await waitFor(() => { + // Dropdown should be open + const dropdown = document.querySelector('[data-dropdown="model-combobox"]') + expect(dropdown).toBeInTheDocument() + // Should show empty state message + expect(screen.getByText('No models found for "nonexistent"')).toBeInTheDocument() + }) + }) + + it('selects model from dropdown when clicked', async () => { + const user = userEvent.setup() + const localMockOnChange = vi.fn() + render() + + const input = screen.getByRole('textbox') + await user.click(input) + + await waitFor(() => { + const modelOption = screen.getByText('gpt-4') + expect(modelOption).toBeInTheDocument() + }) + + const modelOption = screen.getByText('gpt-4') + await user.click(modelOption) + + expect(localMockOnChange).toHaveBeenCalledWith('gpt-4') + expect(input).toHaveValue('gpt-4') + }) + + it('submits input value with Enter key', async () => { + const user = userEvent.setup() + const localMockOnChange = vi.fn() + render() + + const input = screen.getByRole('textbox') + await user.type(input, 'gpt') + await user.keyboard('{Enter}') + + expect(localMockOnChange).toHaveBeenCalledWith('gpt') + }) + + it('updates input value when typing', () => { + render() + + const input = screen.getByRole('textbox') + fireEvent.change(input, { target: { value: 'gpt-4' } }) + + expect(input).toHaveValue('gpt-4') + }) + + it('displays error message in dropdown', async () => { + const user = userEvent.setup() + render() + + const input = screen.getByRole('textbox') + // Click input to open dropdown + await user.click(input) + + await waitFor(() => { + // Dropdown should be open + const dropdown = document.querySelector('[data-dropdown="model-combobox"]') + expect(dropdown).toBeInTheDocument() + // Error messages should be displayed + expect(screen.getByText('Failed to load models')).toBeInTheDocument() + expect(screen.getByText('Network connection failed')).toBeInTheDocument() + }) + }) + + it('calls onRefresh when refresh button is clicked', async () => { + const user = userEvent.setup() + const localMockOnRefresh = vi.fn() + render() + + const input = screen.getByRole('textbox') + // Click input to open dropdown + await user.click(input) + + await waitFor(() => { + // Dropdown should be open with error section + const dropdown = document.querySelector('[data-dropdown="model-combobox"]') + expect(dropdown).toBeInTheDocument() + const refreshButton = document.querySelector('[aria-label="Refresh models"]') + expect(refreshButton).toBeInTheDocument() }) - it('should handle props changes', () => { - const { rerender } = render() + const refreshButton = document.querySelector('[aria-label="Refresh models"]') + if (refreshButton) { + await user.click(refreshButton) + expect(localMockOnRefresh).toHaveBeenCalledTimes(1) + } + }) - expect(screen.getByDisplayValue('')).toBeInTheDocument() + it('opens dropdown when pressing ArrowDown', async () => { + const user = userEvent.setup() + render() - rerender() + const input = screen.getByRole('textbox') + input.focus() + await user.keyboard('{ArrowDown}') - expect(screen.getByDisplayValue('gpt-4')).toBeInTheDocument() + expect(input).toHaveFocus() + await waitFor(() => { + const dropdown = document.querySelector('[data-dropdown="model-combobox"]') + expect(dropdown).toBeInTheDocument() }) + }) - it('should handle models array changes', () => { - const { rerender } = render() + it('navigates through models with arrow keys', async () => { + const user = userEvent.setup() + render() - expect(screen.getByRole('textbox')).toBeInTheDocument() + const input = screen.getByRole('textbox') + input.focus() + + // ArrowDown should open dropdown + await user.keyboard('{ArrowDown}') + + await waitFor(() => { + // Dropdown should be open + const dropdown = document.querySelector('[data-dropdown="model-combobox"]') + expect(dropdown).toBeInTheDocument() + }) + + // Navigate to second item + await user.keyboard('{ArrowDown}') - rerender() + await waitFor(() => { + const secondModel = screen.getByText('gpt-4') + const modelElement = secondModel.closest('[data-model]') + expect(modelElement).toHaveClass('bg-main-view-fg/20') + }) + }) - expect(screen.getByRole('textbox')).toBeInTheDocument() + it('handles Enter key to select highlighted model', async () => { + const user = userEvent.setup() + const localMockOnChange = vi.fn() + render() + + const input = screen.getByRole('textbox') + // Type 'gpt' to open dropdown and filter models + await user.type(input, 'gpt') + + await waitFor(() => { + // Dropdown should be open with filtered models + const dropdown = document.querySelector('[data-dropdown="model-combobox"]') + expect(dropdown).toBeInTheDocument() + }) + + // Navigate to highlight first model and select it + await user.keyboard('{ArrowDown}') + await user.keyboard('{Enter}') + + expect(localMockOnChange).toHaveBeenCalledWith('gpt-3.5-turbo') + }) + + it('closes dropdown with ArrowLeft key', async () => { + const user = userEvent.setup() + render() + + const input = screen.getByRole('textbox') + input.focus() + + // ArrowDown should open dropdown + await user.keyboard('{ArrowDown}') + + await waitFor(() => { + const dropdown = document.querySelector('[data-dropdown="model-combobox"]') + expect(dropdown).toBeInTheDocument() + }) + + // ArrowLeft should close dropdown + await user.keyboard('{ArrowLeft}') + + await waitFor(() => { + const dropdown = document.querySelector('[data-dropdown="model-combobox"]') + expect(dropdown).not.toBeInTheDocument() }) }) }) From 9a68631d39ff2448ddafb4af68d0f15f6a85dee6 Mon Sep 17 00:00:00 2001 From: lugnicca Date: Fri, 22 Aug 2025 03:12:37 +0200 Subject: [PATCH 04/24] refactor: more modular error handling in fetchModelsFromProvider function --- web-app/src/services/providers.ts | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/web-app/src/services/providers.ts b/web-app/src/services/providers.ts index 748aed322..a036c9148 100644 --- a/web-app/src/services/providers.ts +++ b/web-app/src/services/providers.ts @@ -182,13 +182,16 @@ export const fetchModelsFromProvider = async ( } catch (error) { console.error('Error fetching models from provider:', error) - if (error instanceof Error && ( - error.message.includes('Authentication failed') || - error.message.includes('Access forbidden') || - error.message.includes('Models endpoint not found') || - error.message.includes('Failed to fetch models from') - )) { - throw error + const structuredErrorPrefixes = [ + 'Authentication failed', + 'Access forbidden', + 'Models endpoint not found', + 'Failed to fetch models from' + ] + + if (error instanceof Error && + structuredErrorPrefixes.some(prefix => (error as Error).message.startsWith(prefix))) { + throw new Error(error.message) } // Provide helpful error message for network issues From 4e8dd9281fadb2b5e65b7ab754957eb6c1ea1449 Mon Sep 17 00:00:00 2001 From: lugnicca Date: Fri, 22 Aug 2025 03:12:58 +0200 Subject: [PATCH 05/24] refactor: simplify event handling and fix test setup in ModelCombobox --- web-app/src/containers/ModelCombobox.tsx | 12 ++---- .../__tests__/ModelCombobox.test.tsx | 37 +++++++++++-------- 2 files changed, 24 insertions(+), 25 deletions(-) diff --git a/web-app/src/containers/ModelCombobox.tsx b/web-app/src/containers/ModelCombobox.tsx index 80fe0157c..ae1af0900 100644 --- a/web-app/src/containers/ModelCombobox.tsx +++ b/web-app/src/containers/ModelCombobox.tsx @@ -61,7 +61,7 @@ const ErrorSection = ({ error, onRefresh, t }: { error: string; onRefresh?: () = size="sm" onClick={(e) => { e.stopPropagation() - onRefresh?.() + onRefresh() }} className="h-6 w-6 p-0 no-underline hover:bg-main-view-fg/10 text-main-view-fg" aria-label="Refresh models" @@ -171,16 +171,10 @@ function useKeyboardNavigation( switch (e.key) { case 'ArrowDown': e.preventDefault() - if (keyRepeatTimeoutRef.current) { - clearTimeout(keyRepeatTimeoutRef.current) - } setHighlightedIndex((prev: number) => filteredModels.length === 0 ? 0 : (prev < filteredModels.length - 1 ? prev + 1 : 0)) break case 'ArrowUp': e.preventDefault() - if (keyRepeatTimeoutRef.current) { - clearTimeout(keyRepeatTimeoutRef.current) - } setHighlightedIndex((prev: number) => filteredModels.length === 0 ? 0 : (prev > 0 ? prev - 1 : filteredModels.length - 1)) break case 'Enter': @@ -195,9 +189,11 @@ function useKeyboardNavigation( setHighlightedIndex(-1) break case 'PageUp': + e.preventDefault() setHighlightedIndex(0) break case 'PageDown': + e.preventDefault() setHighlightedIndex(filteredModels.length - 1) break } @@ -394,8 +390,6 @@ export function ModelCombobox({ }} data-dropdown="model-combobox" onPointerDown={(e) => e.stopPropagation()} - onClick={(e) => e.stopPropagation()} - onMouseDown={(e) => e.stopPropagation()} onWheel={(e) => e.stopPropagation()} > {/* Error state */} diff --git a/web-app/src/containers/__tests__/ModelCombobox.test.tsx b/web-app/src/containers/__tests__/ModelCombobox.test.tsx index 29496a6aa..1b91c3712 100644 --- a/web-app/src/containers/__tests__/ModelCombobox.test.tsx +++ b/web-app/src/containers/__tests__/ModelCombobox.test.tsx @@ -1,4 +1,4 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' +import { describe, it, expect, vi, beforeEach, beforeAll, afterAll } from 'vitest' import { render, screen, fireEvent, waitFor, act } from '@testing-library/react' import userEvent from '@testing-library/user-event' import '@testing-library/jest-dom/vitest' @@ -28,10 +28,11 @@ describe('ModelCombobox', () => { models: ['gpt-3.5-turbo', 'gpt-4', 'claude-3-haiku'], } - beforeEach(() => { - vi.clearAllMocks() + let bcrSpy: ReturnType + let scrollSpy: ReturnType - Element.prototype.getBoundingClientRect = vi.fn(() => ({ + beforeAll(() => { + const mockRect = { width: 300, height: 40, top: 100, @@ -41,9 +42,22 @@ describe('ModelCombobox', () => { x: 50, y: 100, toJSON: () => {}, - })) - - Element.prototype.scrollIntoView = vi.fn() + } as unknown as DOMRect + + bcrSpy = vi + .spyOn(Element.prototype as any, 'getBoundingClientRect') + .mockReturnValue(mockRect) + + Element.prototype.scrollIntoView = () => {} + }) + + beforeEach(() => { + vi.clearAllMocks() + }) + + afterAll(() => { + bcrSpy?.mockRestore() + scrollSpy?.mockRestore() }) it('renders input field with default placeholder', () => { @@ -369,15 +383,6 @@ describe('ModelCombobox', () => { expect(localMockOnChange).toHaveBeenCalledWith('gpt') }) - it('updates input value when typing', () => { - render() - - const input = screen.getByRole('textbox') - fireEvent.change(input, { target: { value: 'gpt-4' } }) - - expect(input).toHaveValue('gpt-4') - }) - it('displays error message in dropdown', async () => { const user = userEvent.setup() render() From 1bf5802a68549e748a0aea9264bb293ea75317a8 Mon Sep 17 00:00:00 2001 From: lugnicca Date: Fri, 22 Aug 2025 03:13:34 +0200 Subject: [PATCH 06/24] refactor: update MockModelProvider type to use ModelProvider and clean up test setup --- .../hooks/__tests__/useProviderModels.test.ts | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/web-app/src/hooks/__tests__/useProviderModels.test.ts b/web-app/src/hooks/__tests__/useProviderModels.test.ts index 3d107b9f8..89bc9e26a 100644 --- a/web-app/src/hooks/__tests__/useProviderModels.test.ts +++ b/web-app/src/hooks/__tests__/useProviderModels.test.ts @@ -10,15 +10,13 @@ vi.mock('@/services/providers', () => ({ import { fetchModelsFromProvider } from '@/services/providers' const mockFetchModelsFromProvider = vi.mocked(fetchModelsFromProvider) +import type { ModelProvider } from '@/types/modelProviders' + // Mock ModelProvider type -type MockModelProvider = { - active: boolean - provider: string - base_url?: string - api_key?: string - settings: any[] - models: any[] -} +type MockModelProvider = Pick< + ModelProvider, + 'active' | 'provider' | 'base_url' | 'api_key' | 'settings' | 'models' +> describe('useProviderModels', () => { const mockProvider: MockModelProvider = { @@ -34,8 +32,6 @@ describe('useProviderModels', () => { beforeEach(() => { vi.clearAllMocks() - // Reset the cache by clearing any previous state - mockFetchModelsFromProvider.mockClear() }) it('should initialize with empty state', () => { From aa568e62902cdddf00c46a26c36e21a2a26fa63e Mon Sep 17 00:00:00 2001 From: lugnicca Date: Sat, 23 Aug 2025 15:07:42 +0200 Subject: [PATCH 07/24] fix: remove ModelProvider type --- web-app/src/hooks/useProviderModels.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/web-app/src/hooks/useProviderModels.ts b/web-app/src/hooks/useProviderModels.ts index 5c984d1ca..7e5ee6650 100644 --- a/web-app/src/hooks/useProviderModels.ts +++ b/web-app/src/hooks/useProviderModels.ts @@ -1,6 +1,5 @@ import { useState, useEffect, useCallback, useRef } from 'react' import { fetchModelsFromProvider } from '@/services/providers' -import type { ModelProvider } from '@/types/providers' type UseProviderModelsState = { models: string[] From 639bd5fb276e5a1afb6d957085519f04216f0490 Mon Sep 17 00:00:00 2001 From: lugnicca Date: Sat, 23 Aug 2025 18:08:29 +0200 Subject: [PATCH 08/24] fix: set Escape in keyboard navigation --- web-app/src/containers/ModelCombobox.tsx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/web-app/src/containers/ModelCombobox.tsx b/web-app/src/containers/ModelCombobox.tsx index ae1af0900..29fc3ee50 100644 --- a/web-app/src/containers/ModelCombobox.tsx +++ b/web-app/src/containers/ModelCombobox.tsx @@ -196,6 +196,11 @@ function useKeyboardNavigation( e.preventDefault() setHighlightedIndex(filteredModels.length - 1) break + case 'Escape': + e.preventDefault() + setOpen(false) + setHighlightedIndex(-1) + break } }, [open, setOpen, models.length, filteredModels, highlightedIndex, setHighlightedIndex, onModelSelect]) From 6c0e6dce0671d4ee5b62177657a9dcb58d36bed6 Mon Sep 17 00:00:00 2001 From: lugnicca Date: Sat, 23 Aug 2025 18:32:12 +0200 Subject: [PATCH 09/24] fix: remove unused keyRepeatTimeoutRef --- web-app/src/containers/ModelCombobox.tsx | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/web-app/src/containers/ModelCombobox.tsx b/web-app/src/containers/ModelCombobox.tsx index 29fc3ee50..7bd6672e7 100644 --- a/web-app/src/containers/ModelCombobox.tsx +++ b/web-app/src/containers/ModelCombobox.tsx @@ -137,7 +137,6 @@ function useKeyboardNavigation( onModelSelect: (model: string) => void, dropdownRef: React.RefObject ) { - const keyRepeatTimeoutRef = useRef(null) // Scroll to the highlighted element useEffect(() => { @@ -204,16 +203,6 @@ function useKeyboardNavigation( } }, [open, setOpen, models.length, filteredModels, highlightedIndex, setHighlightedIndex, onModelSelect]) - // Cleanup the timeout - useEffect(() => { - const timeoutId = keyRepeatTimeoutRef.current - return () => { - if (timeoutId) { - clearTimeout(timeoutId) - } - } - }, []) - return { handleKeyDown } } From 1a6a37c0037a65fd6a92f2052066cf5bfa44c908 Mon Sep 17 00:00:00 2001 From: lugnicca Date: Sun, 24 Aug 2025 00:40:02 +0200 Subject: [PATCH 10/24] fix: escape key was closing modal instead of only combobox and remove arrow left/righ closing combobox --- web-app/src/containers/ModelCombobox.tsx | 17 +++++++------ .../__tests__/ModelCombobox.test.tsx | 24 ------------------- web-app/src/containers/dialogs/AddModel.tsx | 10 +++++++- 3 files changed, 19 insertions(+), 32 deletions(-) diff --git a/web-app/src/containers/ModelCombobox.tsx b/web-app/src/containers/ModelCombobox.tsx index 7bd6672e7..9b8144ed3 100644 --- a/web-app/src/containers/ModelCombobox.tsx +++ b/web-app/src/containers/ModelCombobox.tsx @@ -182,8 +182,9 @@ function useKeyboardNavigation( onModelSelect(filteredModels[highlightedIndex]) } break - case 'ArrowRight': - case 'ArrowLeft': + case 'Escape': + e.preventDefault() + e.stopPropagation() setOpen(false) setHighlightedIndex(-1) break @@ -195,11 +196,6 @@ function useKeyboardNavigation( e.preventDefault() setHighlightedIndex(filteredModels.length - 1) break - case 'Escape': - e.preventDefault() - setOpen(false) - setHighlightedIndex(-1) - break } }, [open, setOpen, models.length, filteredModels, highlightedIndex, setHighlightedIndex, onModelSelect]) @@ -216,6 +212,7 @@ type ModelComboboxProps = { placeholder?: string disabled?: boolean className?: string + onOpenChange?: (open: boolean) => void } export function ModelCombobox({ @@ -228,6 +225,7 @@ export function ModelCombobox({ placeholder = 'Type or select a model...', disabled = false, className, + onOpenChange, }: ModelComboboxProps) { const [open, setOpen] = useState(false) const [inputValue, setInputValue] = useState(value) @@ -242,6 +240,11 @@ export function ModelCombobox({ setInputValue(value) }, [value]) + // Notify parent when open state changes + useEffect(() => { + onOpenChange?.(open) + }, [open, onOpenChange]) + // Hook for the dropdown position const { dropdownPosition } = useDropdownPosition(open, containerRef) diff --git a/web-app/src/containers/__tests__/ModelCombobox.test.tsx b/web-app/src/containers/__tests__/ModelCombobox.test.tsx index 1b91c3712..38f9b97c8 100644 --- a/web-app/src/containers/__tests__/ModelCombobox.test.tsx +++ b/web-app/src/containers/__tests__/ModelCombobox.test.tsx @@ -487,28 +487,4 @@ describe('ModelCombobox', () => { expect(localMockOnChange).toHaveBeenCalledWith('gpt-3.5-turbo') }) - - it('closes dropdown with ArrowLeft key', async () => { - const user = userEvent.setup() - render() - - const input = screen.getByRole('textbox') - input.focus() - - // ArrowDown should open dropdown - await user.keyboard('{ArrowDown}') - - await waitFor(() => { - const dropdown = document.querySelector('[data-dropdown="model-combobox"]') - expect(dropdown).toBeInTheDocument() - }) - - // ArrowLeft should close dropdown - await user.keyboard('{ArrowLeft}') - - await waitFor(() => { - const dropdown = document.querySelector('[data-dropdown="model-combobox"]') - expect(dropdown).not.toBeInTheDocument() - }) - }) }) diff --git a/web-app/src/containers/dialogs/AddModel.tsx b/web-app/src/containers/dialogs/AddModel.tsx index dc0eac244..a633867f6 100644 --- a/web-app/src/containers/dialogs/AddModel.tsx +++ b/web-app/src/containers/dialogs/AddModel.tsx @@ -26,6 +26,7 @@ export const DialogAddModel = ({ provider, trigger }: DialogAddModelProps) => { const { updateProvider } = useModelProvider() const [modelId, setModelId] = useState('') const [open, setOpen] = useState(false) + const [isComboboxOpen, setIsComboboxOpen] = useState(false) // Fetch models from provider API (API key is optional) const { models, loading, error, refetch } = useProviderModels( @@ -68,7 +69,13 @@ export const DialogAddModel = ({ provider, trigger }: DialogAddModelProps) => { )} - + { + if (isComboboxOpen) { + e.preventDefault() + } + }} + > {t('providers:addModel.title')} @@ -95,6 +102,7 @@ export const DialogAddModel = ({ provider, trigger }: DialogAddModelProps) => { error={error} onRefresh={refetch} placeholder={t('providers:addModel.enterModelId')} + onOpenChange={setIsComboboxOpen} /> From 70bf257e751b6d09ca4988fe5577d65bfb2c43dd Mon Sep 17 00:00:00 2001 From: lugnicca Date: Tue, 2 Sep 2025 18:18:05 +0200 Subject: [PATCH 11/24] fix: put refresh button directly in input instead of in dropdown --- web-app/src/containers/ModelCombobox.tsx | 84 ++++++++++++++---------- 1 file changed, 48 insertions(+), 36 deletions(-) diff --git a/web-app/src/containers/ModelCombobox.tsx b/web-app/src/containers/ModelCombobox.tsx index 9b8144ed3..ea5b3d670 100644 --- a/web-app/src/containers/ModelCombobox.tsx +++ b/web-app/src/containers/ModelCombobox.tsx @@ -51,24 +51,10 @@ function useDropdownPosition(open: boolean, containerRef: React.RefObject void; t: (key: string) => string }) => ( +const ErrorSection = ({ error, t }: { error: string; t: (key: string) => string }) => (
{t('common:failedToLoadModels')} - {onRefresh && ( - - )}
{error}
@@ -83,11 +69,15 @@ const LoadingSection = ({ t }: { t: (key: string) => string }) => ( const EmptySection = ({ inputValue, t }: { inputValue: string; t: (key: string, options?: Record) => string }) => (
- {inputValue.trim() ? ( - {t('common:noModelsFoundFor', { searchValue: inputValue })} - ) : ( - {t('common:noModels')} - )} +
+
+ {inputValue.trim() ? ( + {t('common:noModelsFoundFor', { searchValue: inputValue })} + ) : ( + {t('common:noModels')} + )} +
+
) @@ -353,24 +343,46 @@ export function ModelCombobox({ onClick={handleInputClick} placeholder={placeholder} disabled={disabled} - className="pr-8" + className="pr-16" /> - {/* Dropdown trigger button */} - )} - + + {/* Custom dropdown rendered as portal */} {open && dropdownPosition.width > 0 && createPortal( @@ -390,7 +402,7 @@ export function ModelCombobox({ onWheel={(e) => e.stopPropagation()} > {/* Error state */} - {error && } + {error && } {/* Loading state */} {loading && } From 3d0ce15fe85bd9f044b00f5f1b3ef44e3ea95ee2 Mon Sep 17 00:00:00 2001 From: lugnicca Date: Tue, 2 Sep 2025 18:19:12 +0200 Subject: [PATCH 12/24] fix: prevent stale provider model requests from polluting UI state --- web-app/src/containers/dialogs/AddModel.tsx | 1 + web-app/src/hooks/useProviderModels.ts | 6 +++++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/web-app/src/containers/dialogs/AddModel.tsx b/web-app/src/containers/dialogs/AddModel.tsx index a633867f6..3ccdc6d65 100644 --- a/web-app/src/containers/dialogs/AddModel.tsx +++ b/web-app/src/containers/dialogs/AddModel.tsx @@ -95,6 +95,7 @@ export const DialogAddModel = ({ provider, trigger }: DialogAddModelProps) => { * (null) const prevProviderKey = useRef('') + const requestIdRef = useRef(0) const fetchModels = useCallback(async () => { if (!provider || !provider.base_url) { @@ -44,11 +45,13 @@ export const useProviderModels = (provider?: ModelProvider): UseProviderModelsSt return } + const currentRequestId = ++requestIdRef.current setLoading(true) setError(null) try { const fetchedModels = await fetchModelsFromProvider(provider) + if (currentRequestId !== requestIdRef.current) return const sortedModels = fetchedModels.sort((a, b) => a.localeCompare(b)) setModels(sortedModels) @@ -59,11 +62,12 @@ export const useProviderModels = (provider?: ModelProvider): UseProviderModelsSt timestamp: Date.now(), }) } catch (err) { + if (currentRequestId !== requestIdRef.current) return const errorMessage = err instanceof Error ? err.message : 'Failed to fetch models' setError(errorMessage) console.error(`Error fetching models from ${provider.provider}:`, err) } finally { - setLoading(false) + if (currentRequestId === requestIdRef.current) setLoading(false) } }, [provider]) From 66af5c7386a12f2efcbbe8837c48628dc71f75a8 Mon Sep 17 00:00:00 2001 From: lugnicca Date: Fri, 5 Sep 2025 19:56:28 +0200 Subject: [PATCH 13/24] fix: use webprovider services to fetch models --- .../hooks/__tests__/useProviderModels.test.ts | 42 ++++++++++--------- web-app/src/hooks/useProviderModels.ts | 9 ++-- web-app/src/services/providers/web.ts | 42 ++++++++++++++++--- 3 files changed, 63 insertions(+), 30 deletions(-) diff --git a/web-app/src/hooks/__tests__/useProviderModels.test.ts b/web-app/src/hooks/__tests__/useProviderModels.test.ts index 89bc9e26a..e6ca301f7 100644 --- a/web-app/src/hooks/__tests__/useProviderModels.test.ts +++ b/web-app/src/hooks/__tests__/useProviderModels.test.ts @@ -1,22 +1,19 @@ import { describe, it, expect, beforeEach, vi } from 'vitest' import { renderHook, waitFor } from '@testing-library/react' import { useProviderModels } from '../useProviderModels' +import { WebProvidersService } from '../../services/providers/web' -// Mock the providers service -vi.mock('@/services/providers', () => ({ - fetchModelsFromProvider: vi.fn(), -})) +let fetchModelsSpy: ReturnType -import { fetchModelsFromProvider } from '@/services/providers' -const mockFetchModelsFromProvider = vi.mocked(fetchModelsFromProvider) - -import type { ModelProvider } from '@/types/modelProviders' - -// Mock ModelProvider type -type MockModelProvider = Pick< - ModelProvider, - 'active' | 'provider' | 'base_url' | 'api_key' | 'settings' | 'models' -> +// Local minimal provider type for tests +type MockModelProvider = { + active: boolean + provider: string + base_url?: string + api_key?: string + settings: any[] + models: any[] +} describe('useProviderModels', () => { const mockProvider: MockModelProvider = { @@ -31,7 +28,12 @@ describe('useProviderModels', () => { const mockModels = ['gpt-4', 'gpt-3.5-turbo', 'gpt-4-turbo'] beforeEach(() => { + vi.restoreAllMocks() vi.clearAllMocks() + fetchModelsSpy = vi.spyOn( + WebProvidersService.prototype, + 'fetchModelsFromProvider' + ) }) it('should initialize with empty state', () => { @@ -45,17 +47,17 @@ describe('useProviderModels', () => { it('should not fetch models when provider is undefined', () => { renderHook(() => useProviderModels(undefined)) - expect(mockFetchModelsFromProvider).not.toHaveBeenCalled() + expect(fetchModelsSpy).not.toHaveBeenCalled() }) it('should not fetch models when provider has no base_url', () => { const providerWithoutUrl = { ...mockProvider, base_url: undefined } renderHook(() => useProviderModels(providerWithoutUrl)) - expect(mockFetchModelsFromProvider).not.toHaveBeenCalled() + expect(fetchModelsSpy).not.toHaveBeenCalled() }) it('should fetch and sort models', async () => { - mockFetchModelsFromProvider.mockResolvedValueOnce(mockModels) + fetchModelsSpy.mockResolvedValueOnce(mockModels) const { result } = renderHook(() => useProviderModels(mockProvider)) @@ -66,11 +68,11 @@ describe('useProviderModels', () => { // Should be sorted alphabetically expect(result.current.models).toEqual(['gpt-3.5-turbo', 'gpt-4', 'gpt-4-turbo']) expect(result.current.error).toBe(null) - expect(mockFetchModelsFromProvider).toHaveBeenCalledWith(mockProvider) + expect(fetchModelsSpy).toHaveBeenCalledWith(mockProvider) }) it('should clear models when switching to invalid provider', async () => { - mockFetchModelsFromProvider.mockResolvedValueOnce(mockModels) + fetchModelsSpy.mockResolvedValueOnce(mockModels) const { result, rerender } = renderHook( ({ provider }) => useProviderModels(provider), @@ -96,6 +98,6 @@ describe('useProviderModels', () => { result.current.refetch() - expect(mockFetchModelsFromProvider).not.toHaveBeenCalled() + expect(fetchModelsSpy).not.toHaveBeenCalled() }) }) \ No newline at end of file diff --git a/web-app/src/hooks/useProviderModels.ts b/web-app/src/hooks/useProviderModels.ts index ee5104133..1be17b706 100644 --- a/web-app/src/hooks/useProviderModels.ts +++ b/web-app/src/hooks/useProviderModels.ts @@ -1,5 +1,5 @@ -import { useState, useEffect, useCallback, useRef } from 'react' -import { fetchModelsFromProvider } from '@/services/providers' +import { useState, useEffect, useCallback, useRef, useMemo } from 'react' +import { WebProvidersService } from '../services/providers/web' type UseProviderModelsState = { models: string[] @@ -12,6 +12,7 @@ const modelsCache = new Map() const CACHE_DURATION = 5 * 60 * 1000 // 5 minutes export const useProviderModels = (provider?: ModelProvider): UseProviderModelsState => { + const providersService = useMemo(() => new WebProvidersService(), []) const [models, setModels] = useState([]) const [loading, setLoading] = useState(false) const [error, setError] = useState(null) @@ -50,7 +51,7 @@ export const useProviderModels = (provider?: ModelProvider): UseProviderModelsSt setError(null) try { - const fetchedModels = await fetchModelsFromProvider(provider) + const fetchedModels = await providersService.fetchModelsFromProvider(provider) if (currentRequestId !== requestIdRef.current) return const sortedModels = fetchedModels.sort((a, b) => a.localeCompare(b)) @@ -69,7 +70,7 @@ export const useProviderModels = (provider?: ModelProvider): UseProviderModelsSt } finally { if (currentRequestId === requestIdRef.current) setLoading(false) } - }, [provider]) + }, [provider, providersService]) const refetch = useCallback(() => { if (provider) { diff --git a/web-app/src/services/providers/web.ts b/web-app/src/services/providers/web.ts index 30fe71366..5ac1430da 100644 --- a/web-app/src/services/providers/web.ts +++ b/web-app/src/services/providers/web.ts @@ -138,9 +138,24 @@ export class WebProvidersService implements ProvidersService { }) if (!response.ok) { - throw new Error( - `Cannot connect to ${provider.provider} at ${provider.base_url}. Please check that the service is running and accessible.` - ) + // Provide more specific error messages based on status code + if (response.status === 401) { + throw new Error( + `Authentication failed: API key is required or invalid for ${provider.provider}` + ) + } else if (response.status === 403) { + throw new Error( + `Access forbidden: Check your API key permissions for ${provider.provider}` + ) + } else if (response.status === 404) { + throw new Error( + `Models endpoint not found for ${provider.provider}. Check the base URL configuration.` + ) + } else { + throw new Error( + `Failed to fetch models from ${provider.provider}: ${response.status} ${response.statusText}` + ) + } } const data = await response.json() @@ -170,13 +185,28 @@ export class WebProvidersService implements ProvidersService { } catch (error) { console.error('Error fetching models from provider:', error) + const structuredErrorPrefixes = [ + 'Authentication failed', + 'Access forbidden', + 'Models endpoint not found', + 'Failed to fetch models from' + ] + + if (error instanceof Error && + structuredErrorPrefixes.some(prefix => (error as Error).message.startsWith(prefix))) { + throw new Error(error.message) + } + // Provide helpful error message for any connection errors if (error instanceof Error && error.message.includes('Cannot connect')) { - throw error + throw new Error( + `Cannot connect to ${provider.provider} at ${provider.base_url}. Please check that the service is running and accessible.` + ) } - + + // Generic fallback throw new Error( - `Cannot connect to ${provider.provider} at ${provider.base_url}. Please check that the service is running and accessible.` + `Unexpected error while fetching models from ${provider.provider}: ${error instanceof Error ? error.message : 'Unknown error'}` ) } } From 9fcd9503e7b16399699d44954efdaa377d35ea9e Mon Sep 17 00:00:00 2001 From: lugnicca Date: Sat, 6 Sep 2025 17:42:54 +0200 Subject: [PATCH 14/24] fix: error on message with "fetch" --- web-app/src/services/providers/web.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web-app/src/services/providers/web.ts b/web-app/src/services/providers/web.ts index 5ac1430da..5ad426a11 100644 --- a/web-app/src/services/providers/web.ts +++ b/web-app/src/services/providers/web.ts @@ -198,7 +198,7 @@ export class WebProvidersService implements ProvidersService { } // Provide helpful error message for any connection errors - if (error instanceof Error && error.message.includes('Cannot connect')) { + if (error instanceof Error && error.message.includes('fetch')) { throw new Error( `Cannot connect to ${provider.provider} at ${provider.base_url}. Please check that the service is running and accessible.` ) From 2db9af94fa7321f98472577451ed3191bd85572e Mon Sep 17 00:00:00 2001 From: lugnicca Date: Mon, 8 Sep 2025 17:45:35 +0200 Subject: [PATCH 15/24] fix: use serviceHub to fetch models and fix error message on app --- .../hooks/__tests__/useProviderModels.test.ts | 25 ++++++----- web-app/src/hooks/useProviderModels.ts | 10 ++--- web-app/src/services/providers/tauri.ts | 43 ++++++++++++++++--- web-app/src/test/setup.ts | 1 + 4 files changed, 55 insertions(+), 24 deletions(-) diff --git a/web-app/src/hooks/__tests__/useProviderModels.test.ts b/web-app/src/hooks/__tests__/useProviderModels.test.ts index e6ca301f7..da9b60e07 100644 --- a/web-app/src/hooks/__tests__/useProviderModels.test.ts +++ b/web-app/src/hooks/__tests__/useProviderModels.test.ts @@ -1,9 +1,7 @@ import { describe, it, expect, beforeEach, vi } from 'vitest' import { renderHook, waitFor } from '@testing-library/react' import { useProviderModels } from '../useProviderModels' -import { WebProvidersService } from '../../services/providers/web' - -let fetchModelsSpy: ReturnType +import { useServiceHub } from '@/hooks/useServiceHub' // Local minimal provider type for tests type MockModelProvider = { @@ -27,13 +25,17 @@ describe('useProviderModels', () => { const mockModels = ['gpt-4', 'gpt-3.5-turbo', 'gpt-4-turbo'] + let fetchModelsSpy: ReturnType + beforeEach(() => { vi.restoreAllMocks() vi.clearAllMocks() - fetchModelsSpy = vi.spyOn( - WebProvidersService.prototype, - 'fetchModelsFromProvider' - ) + const hub = (useServiceHub as unknown as () => any)() + const mockedFetch = vi.fn() + vi.spyOn(hub, 'providers').mockReturnValue({ + fetchModelsFromProvider: mockedFetch, + } as any) + fetchModelsSpy = mockedFetch }) it('should initialize with empty state', () => { @@ -62,11 +64,9 @@ describe('useProviderModels', () => { const { result } = renderHook(() => useProviderModels(mockProvider)) await waitFor(() => { - expect(result.current.loading).toBe(false) + expect(result.current.models).toEqual(['gpt-3.5-turbo', 'gpt-4', 'gpt-4-turbo']) }) - // Should be sorted alphabetically - expect(result.current.models).toEqual(['gpt-3.5-turbo', 'gpt-4', 'gpt-4-turbo']) expect(result.current.error).toBe(null) expect(fetchModelsSpy).toHaveBeenCalledWith(mockProvider) }) @@ -80,10 +80,9 @@ describe('useProviderModels', () => { ) await waitFor(() => { + expect(result.current.models).toEqual(['gpt-3.5-turbo', 'gpt-4', 'gpt-4-turbo']) expect(result.current.loading).toBe(false) - }) - - expect(result.current.models).toEqual(['gpt-3.5-turbo', 'gpt-4', 'gpt-4-turbo']) + }, { timeout: 500 }) // Switch to invalid provider rerender({ provider: { ...mockProvider, base_url: undefined } }) diff --git a/web-app/src/hooks/useProviderModels.ts b/web-app/src/hooks/useProviderModels.ts index 1be17b706..3c51a7f70 100644 --- a/web-app/src/hooks/useProviderModels.ts +++ b/web-app/src/hooks/useProviderModels.ts @@ -1,5 +1,5 @@ -import { useState, useEffect, useCallback, useRef, useMemo } from 'react' -import { WebProvidersService } from '../services/providers/web' +import { useState, useEffect, useCallback, useRef } from 'react' +import { useServiceHub } from './useServiceHub' type UseProviderModelsState = { models: string[] @@ -12,7 +12,7 @@ const modelsCache = new Map() const CACHE_DURATION = 5 * 60 * 1000 // 5 minutes export const useProviderModels = (provider?: ModelProvider): UseProviderModelsState => { - const providersService = useMemo(() => new WebProvidersService(), []) + const serviceHub = useServiceHub() const [models, setModels] = useState([]) const [loading, setLoading] = useState(false) const [error, setError] = useState(null) @@ -51,7 +51,7 @@ export const useProviderModels = (provider?: ModelProvider): UseProviderModelsSt setError(null) try { - const fetchedModels = await providersService.fetchModelsFromProvider(provider) + const fetchedModels = await serviceHub.providers().fetchModelsFromProvider(provider) if (currentRequestId !== requestIdRef.current) return const sortedModels = fetchedModels.sort((a, b) => a.localeCompare(b)) @@ -70,7 +70,7 @@ export const useProviderModels = (provider?: ModelProvider): UseProviderModelsSt } finally { if (currentRequestId === requestIdRef.current) setLoading(false) } - }, [provider, providersService]) + }, [provider, serviceHub]) const refetch = useCallback(() => { if (provider) { diff --git a/web-app/src/services/providers/tauri.ts b/web-app/src/services/providers/tauri.ts index 5c5103b20..110794322 100644 --- a/web-app/src/services/providers/tauri.ts +++ b/web-app/src/services/providers/tauri.ts @@ -113,7 +113,7 @@ export class TauriProvidersService extends DefaultProvidersService { } return runtimeProviders.concat(builtinProviders as ModelProvider[]) - } catch (error) { + } catch (error: unknown) { console.error('Error getting providers in Tauri:', error) return [] } @@ -142,9 +142,24 @@ export class TauriProvidersService extends DefaultProvidersService { }) if (!response.ok) { - throw new Error( - `Failed to fetch models: ${response.status} ${response.statusText}` - ) + // Provide more specific error messages based on status code (aligned with web implementation) + if (response.status === 401) { + throw new Error( + `Authentication failed: API key is required or invalid for ${provider.provider}` + ) + } else if (response.status === 403) { + throw new Error( + `Access forbidden: Check your API key permissions for ${provider.provider}` + ) + } else if (response.status === 404) { + throw new Error( + `Models endpoint not found for ${provider.provider}. Check the base URL configuration.` + ) + } else { + throw new Error( + `Failed to fetch models from ${provider.provider}: ${response.status} ${response.statusText}` + ) + } } const data = await response.json() @@ -174,14 +189,30 @@ export class TauriProvidersService extends DefaultProvidersService { } catch (error) { console.error('Error fetching models from provider:', error) - // Provide helpful error message + // Preserve structured error messages thrown above + const structuredErrorPrefixes = [ + 'Authentication failed', + 'Access forbidden', + 'Models endpoint not found', + 'Failed to fetch models from' + ] + + if (error instanceof Error && + structuredErrorPrefixes.some(prefix => (error as Error).message.startsWith(prefix))) { + throw new Error(error.message) + } + + // Provide helpful error message for any connection errors if (error instanceof Error && error.message.includes('fetch')) { throw new Error( `Cannot connect to ${provider.provider} at ${provider.base_url}. Please check that the service is running and accessible.` ) } - throw error + // Generic fallback + throw new Error( + `Unexpected error while fetching models from ${provider.provider}: ${error instanceof Error ? error.message : 'Unknown error'}` + ) } } diff --git a/web-app/src/test/setup.ts b/web-app/src/test/setup.ts index a542ffec9..cf93dba6b 100644 --- a/web-app/src/test/setup.ts +++ b/web-app/src/test/setup.ts @@ -103,6 +103,7 @@ const mockServiceHub = { deleteProvider: vi.fn().mockResolvedValue(undefined), updateProvider: vi.fn().mockResolvedValue(undefined), getProvider: vi.fn().mockResolvedValue(null), + fetchModelsFromProvider: vi.fn().mockResolvedValue([]), }), models: () => ({ getModels: vi.fn().mockResolvedValue([]), From 78d816b435a3948a8f268f9494f701624d3f54a6 Mon Sep 17 00:00:00 2001 From: Bui Quang Huy <34532913+LazyYuuki@users.noreply.github.com> Date: Sun, 14 Sep 2025 22:26:57 +0800 Subject: [PATCH 16/24] Update 4-goal.md --- .github/ISSUE_TEMPLATE/4-goal.md | 38 ++++++++++---------------------- 1 file changed, 12 insertions(+), 26 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/4-goal.md b/.github/ISSUE_TEMPLATE/4-goal.md index 8d649c281..6de52fb16 100644 --- a/.github/ISSUE_TEMPLATE/4-goal.md +++ b/.github/ISSUE_TEMPLATE/4-goal.md @@ -5,34 +5,20 @@ title: 'goal: ' type: Goal --- -## Goal +## 🎯 Goal + -> Why are we doing this? 1 liner value proposition +## 📖 Context + -_e.g. Make onboarding to Jan 3x easier_ +## ✅ Scope + -## Success Criteria +## ❌ Out of Scope + -> When do we consider this done? Limit to 3. +## 🛠 Deliverables + -1. _e.g. Redesign onboarding flow to remove redundant steps._ -2. _e.g. Add a “getting started” guide_ -3. _e.g. Make local model setup more “normie” friendly_ - -## Non Goals - -> What is out of scope? - -- _e.g. Take advanced users through customizing settings_ - -## User research (if any) - -> Links to user messages and interviews - -## Design inspo - -> Links - -## Open questions - -> What are we not sure about? +## ❓Open questions + From 0771b998a591531497b02f829d69d850758067a2 Mon Sep 17 00:00:00 2001 From: Dinh Long Nguyen Date: Mon, 15 Sep 2025 09:08:30 +0700 Subject: [PATCH 17/24] Fix: Web Services Improvement Fix: Web Services Improvement --- extensions-web/src/jan-provider-web/api.ts | 7 ++-- extensions-web/src/mcp-web/index.ts | 6 ++-- extensions-web/src/shared/auth.ts | 37 +++++++++++----------- extensions-web/src/shared/index.ts | 2 +- web-app/src/lib/completion.ts | 8 +++-- web-app/src/routes/__root.tsx | 2 +- 6 files changed, 29 insertions(+), 33 deletions(-) diff --git a/extensions-web/src/jan-provider-web/api.ts b/extensions-web/src/jan-provider-web/api.ts index 3ea083f5d..16c4dc70e 100644 --- a/extensions-web/src/jan-provider-web/api.ts +++ b/extensions-web/src/jan-provider-web/api.ts @@ -3,7 +3,7 @@ * Handles API requests to Jan backend for models and chat completions */ -import { JanAuthService } from '../shared/auth' +import { getSharedAuthService, JanAuthService } from '../shared' import { JanModel, janProviderStore } from './store' // JAN_API_BASE is defined in vite.config.ts @@ -77,7 +77,7 @@ export class JanApiClient { private authService: JanAuthService private constructor() { - this.authService = JanAuthService.getInstance() + this.authService = getSharedAuthService() } static getInstance(): JanApiClient { @@ -216,12 +216,9 @@ export class JanApiClient { async initialize(): Promise { try { - await this.authService.initialize() janProviderStore.setAuthenticated(true) - // Fetch initial models await this.getModels() - console.log('Jan API client initialized successfully') } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Failed to initialize API client' diff --git a/extensions-web/src/mcp-web/index.ts b/extensions-web/src/mcp-web/index.ts index 5b55643d4..5e13846a7 100644 --- a/extensions-web/src/mcp-web/index.ts +++ b/extensions-web/src/mcp-web/index.ts @@ -5,7 +5,7 @@ */ import { MCPExtension, MCPTool, MCPToolCallResult } from '@janhq/core' -import { JanAuthService } from '../shared/auth' +import { getSharedAuthService, JanAuthService } from '../shared' import { Client } from '@modelcontextprotocol/sdk/client/index.js' import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js' import { JanMCPOAuthProvider } from './oauth-provider' @@ -30,14 +30,12 @@ export default class MCPExtensionWeb extends MCPExtension { version?: string ) { super(url, name, productName, active, description, version) - this.authService = JanAuthService.getInstance() + this.authService = getSharedAuthService() this.oauthProvider = new JanMCPOAuthProvider(this.authService) } async onLoad(): Promise { try { - // Initialize authentication first - await this.authService.initialize() // Initialize MCP client with OAuth await this.initializeMCPClient() // Then fetch tools diff --git a/extensions-web/src/shared/auth.ts b/extensions-web/src/shared/auth.ts index 3e7a3869b..8b44ed714 100644 --- a/extensions-web/src/shared/auth.ts +++ b/extensions-web/src/shared/auth.ts @@ -20,21 +20,13 @@ const AUTH_STORAGE_KEY = 'jan_auth_tokens' const TOKEN_EXPIRY_BUFFER = 60 * 1000 // 1 minute buffer before actual expiry export class JanAuthService { - private static instance: JanAuthService private tokens: AuthTokens | null = null private tokenExpiryTime: number = 0 - private constructor() { + constructor() { this.loadTokensFromStorage() } - static getInstance(): JanAuthService { - if (!JanAuthService.instance) { - JanAuthService.instance = new JanAuthService() - } - return JanAuthService.instance - } - private loadTokensFromStorage(): void { try { const storedTokens = localStorage.getItem(AUTH_STORAGE_KEY) @@ -169,16 +161,6 @@ export class JanAuthService { return this.tokens.access_token } - async initialize(): Promise { - try { - await this.getValidAccessToken() - console.log('Jan auth service initialized successfully') - } catch (error) { - console.error('Failed to initialize Jan auth service:', error) - throw error - } - } - async getAuthHeader(): Promise<{ Authorization: string }> { const token = await this.getValidAccessToken() return { @@ -217,4 +199,21 @@ export class JanAuthService { logout(): void { this.clearTokens() } +} + +declare global { + interface Window { + janAuthService?: JanAuthService + } +} + +/** + * Gets or creates the shared JanAuthService instance on the window object + * This ensures all extensions use the same auth service instance + */ +export function getSharedAuthService(): JanAuthService { + if (!window.janAuthService) { + window.janAuthService = new JanAuthService() + } + return window.janAuthService } \ No newline at end of file diff --git a/extensions-web/src/shared/index.ts b/extensions-web/src/shared/index.ts index d97ca4161..92399c75f 100644 --- a/extensions-web/src/shared/index.ts +++ b/extensions-web/src/shared/index.ts @@ -1,3 +1,3 @@ export { getSharedDB } from './db' -export { JanAuthService } from './auth' +export { JanAuthService, getSharedAuthService } from './auth' export type { AuthTokens, AuthResponse } from './auth' \ No newline at end of file diff --git a/web-app/src/lib/completion.ts b/web-app/src/lib/completion.ts index 4d30f0750..023c39481 100644 --- a/web-app/src/lib/completion.ts +++ b/web-app/src/lib/completion.ts @@ -193,6 +193,7 @@ export const sendCompletion = async ( if ( thread.model.id && + models[providerName]?.models !== true && // Skip if provider accepts any model (models: true) !Object.values(models[providerName]).flat().includes(thread.model.id) && !tokenJS.extendedModelExist(providerName as any, thread.model.id) && provider.provider !== 'llamacpp' @@ -396,9 +397,12 @@ export const postMessageProcessing = async ( let toolParameters = {} if (toolCall.function.arguments.length) { try { + console.log('Raw tool arguments:', toolCall.function.arguments) toolParameters = JSON.parse(toolCall.function.arguments) + console.log('Parsed tool parameters:', toolParameters) } catch (error) { console.error('Failed to parse tool arguments:', error) + console.error('Raw arguments that failed:', toolCall.function.arguments) } } const approved = @@ -414,9 +418,7 @@ export const postMessageProcessing = async ( const { promise, cancel } = getServiceHub().mcp().callToolWithCancellation({ toolName: toolCall.function.name, - arguments: toolCall.function.arguments.length - ? JSON.parse(toolCall.function.arguments) - : {}, + arguments: toolCall.function.arguments.length ? toolParameters : {}, }) useAppState.getState().setCancelToolCall(cancel) diff --git a/web-app/src/routes/__root.tsx b/web-app/src/routes/__root.tsx index f97e0c96d..710de4399 100644 --- a/web-app/src/routes/__root.tsx +++ b/web-app/src/routes/__root.tsx @@ -114,7 +114,7 @@ const AppLayout = () => { {/* Fake absolute panel top to enable window drag */}
- + {PlatformFeatures[PlatformFeature.LOCAL_INFERENCE] && } {/* Use ResizablePanelGroup only on larger screens */} {!isSmallScreen && isLeftPanelOpen ? ( From 1d36a4ad6e100e659ed8fa7d8d68c47836a31c51 Mon Sep 17 00:00:00 2001 From: Louis Date: Mon, 15 Sep 2025 10:27:07 +0700 Subject: [PATCH 18/24] refactor: clean up empty folders (#6454) --- .gitignore | 1 + pre-install/.gitkeep | 0 scripts/download-bin.mjs | 5 ----- 3 files changed, 1 insertion(+), 5 deletions(-) delete mode 100644 pre-install/.gitkeep diff --git a/.gitignore b/.gitignore index e087b3b66..6b51867ef 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,7 @@ core/lib/** .yarnrc *.tsbuildinfo test_results.html +pre-install # docs docs/yarn.lock diff --git a/pre-install/.gitkeep b/pre-install/.gitkeep deleted file mode 100644 index e69de29bb..000000000 diff --git a/scripts/download-bin.mjs b/scripts/download-bin.mjs index a1884d940..36e17b3f0 100644 --- a/scripts/download-bin.mjs +++ b/scripts/download-bin.mjs @@ -231,11 +231,6 @@ async function main() { console.log('Downloads completed.') } -// Ensure the downloads directory exists -if (!fs.existsSync('downloads')) { - fs.mkdirSync('downloads') -} - main().catch((err) => { console.error('Error:', err) process.exit(1) From 1ca765476ec6d6ce250d3aa2db93a3ddc83246e4 Mon Sep 17 00:00:00 2001 From: Faisal Amir Date: Mon, 15 Sep 2025 15:22:00 +0700 Subject: [PATCH 19/24] chore: revert back old docs --- docs/src/pages/_meta.json | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/docs/src/pages/_meta.json b/docs/src/pages/_meta.json index 66d0fff38..d095500f4 100644 --- a/docs/src/pages/_meta.json +++ b/docs/src/pages/_meta.json @@ -9,13 +9,7 @@ }, "docs": { "type": "page", - "title": "Docs", - "display": "hidden" - }, - "Documentation": { - "type": "page", - "title": "Documentation", - "href": "https://docs.jan.ai" + "title": "Docs" }, "platforms": { "type": "page", From 7921bb21c7cbfcba8bce7234668ec9318e8127c7 Mon Sep 17 00:00:00 2001 From: Faisal Amir Date: Mon, 15 Sep 2025 15:40:46 +0700 Subject: [PATCH 20/24] chore: missing local api server --- docs/src/pages/docs/llama-cpp-server.mdx | 2 +- docs/src/pages/docs/server-settings.mdx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/src/pages/docs/llama-cpp-server.mdx b/docs/src/pages/docs/llama-cpp-server.mdx index d89f48139..3a3d24c46 100644 --- a/docs/src/pages/docs/llama-cpp-server.mdx +++ b/docs/src/pages/docs/llama-cpp-server.mdx @@ -24,7 +24,7 @@ import { Settings } from 'lucide-react' `llama.cpp` is the core **inference engine** Jan uses to run AI models locally on your computer. This section covers the settings for the engine itself, which control *how* a model processes information on your hardware. -Looking for API server settings (like port, host, CORS)? They have been moved to the dedicated [**Local API Server**](/docs/local-server/api-server) page. +Looking for API server settings (like port, host, CORS)? They have been moved to the dedicated [**Local API Server**](/docs/api-server) page. ## Accessing Engine Settings diff --git a/docs/src/pages/docs/server-settings.mdx b/docs/src/pages/docs/server-settings.mdx index 80d2cc0b2..b352293e5 100644 --- a/docs/src/pages/docs/server-settings.mdx +++ b/docs/src/pages/docs/server-settings.mdx @@ -174,7 +174,7 @@ This includes configuration for: - CORS (Cross-Origin Resource Sharing) - Verbose Logging -[**Go to Local API Server Settings →**](/docs/local-server/api-server) +[**Go to Local API Server Settings →**](/docs/api-server) ## Emergency Options From 1aa39392ab214d19f8eec9ba0c4b23182331c262 Mon Sep 17 00:00:00 2001 From: Trang Le Date: Mon, 15 Sep 2025 17:13:07 +0700 Subject: [PATCH 21/24] set bullet list style to be circle (#6437) --- web-app/src/styles/markdown.css | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/web-app/src/styles/markdown.css b/web-app/src/styles/markdown.css index a3f0c2a9c..ad58dd87e 100644 --- a/web-app/src/styles/markdown.css +++ b/web-app/src/styles/markdown.css @@ -77,6 +77,10 @@ list-style-type: disc; } + ul > li { + list-style-type: circle; + } + ol { list-style-type: decimal; } From 71e2e241121d56e58843339ce567612fd35be65d Mon Sep 17 00:00:00 2001 From: Maksym Krasovakyi Date: Sun, 7 Sep 2025 12:02:12 +0300 Subject: [PATCH 22/24] Add model response timeout for local api server as configurable value via UI --- .../tauri-plugin-llamacpp/src/commands.rs | 2 +- src-tauri/src/core/server/commands.rs | 2 + src-tauri/src/core/server/proxy.rs | 3 +- web-app/src/containers/ProxyTimeoutInput.tsx | 39 +++++++++++++++++ .../hooks/__tests__/useLocalApiServer.test.ts | 43 +++++++++++++++++++ web-app/src/hooks/useLocalApiServer.ts | 5 +++ web-app/src/locales/de-DE/settings.json | 4 +- web-app/src/locales/en/settings.json | 4 +- web-app/src/locales/id/settings.json | 4 +- web-app/src/locales/pl/settings.json | 4 +- web-app/src/locales/vn/settings.json | 4 +- web-app/src/locales/zh-CN/settings.json | 4 +- web-app/src/locales/zh-TW/settings.json | 4 +- web-app/src/providers/DataProvider.tsx | 2 + .../src/routes/settings/local-api-server.tsx | 8 ++++ .../content/docs/local-server/api-server.mdx | 4 ++ 16 files changed, 127 insertions(+), 9 deletions(-) create mode 100644 web-app/src/containers/ProxyTimeoutInput.tsx diff --git a/src-tauri/plugins/tauri-plugin-llamacpp/src/commands.rs b/src-tauri/plugins/tauri-plugin-llamacpp/src/commands.rs index a25d7825f..79ec81f5a 100644 --- a/src-tauri/plugins/tauri-plugin-llamacpp/src/commands.rs +++ b/src-tauri/plugins/tauri-plugin-llamacpp/src/commands.rs @@ -162,7 +162,7 @@ pub async fn load_llama_model( } // Wait for server to be ready or timeout - let timeout_duration = Duration::from_secs(180); // 3 minutes timeout + let timeout_duration = Duration::from_secs(300); // 5 minutes timeout let start_time = Instant::now(); log::info!("Waiting for model session to be ready..."); loop { diff --git a/src-tauri/src/core/server/commands.rs b/src-tauri/src/core/server/commands.rs index c1c6a9294..85450bee5 100644 --- a/src-tauri/src/core/server/commands.rs +++ b/src-tauri/src/core/server/commands.rs @@ -13,6 +13,7 @@ pub async fn start_server( prefix: String, api_key: String, trusted_hosts: Vec, + proxy_timeout: u64, ) -> Result { let server_handle = state.server_handle.clone(); let plugin_state: State = app_handle.state(); @@ -26,6 +27,7 @@ pub async fn start_server( prefix, api_key, vec![trusted_hosts], + proxy_timeout, ) .await .map_err(|e| e.to_string())?; diff --git a/src-tauri/src/core/server/proxy.rs b/src-tauri/src/core/server/proxy.rs index 47f642716..9b33d4ba5 100644 --- a/src-tauri/src/core/server/proxy.rs +++ b/src-tauri/src/core/server/proxy.rs @@ -631,6 +631,7 @@ pub async fn start_server( prefix: String, proxy_api_key: String, trusted_hosts: Vec>, + proxy_timeout: u64, ) -> Result> { let mut handle_guard = server_handle.lock().await; if handle_guard.is_some() { @@ -648,7 +649,7 @@ pub async fn start_server( }; let client = Client::builder() - .timeout(std::time::Duration::from_secs(300)) + .timeout(std::time::Duration::from_secs(proxy_timeout)) .pool_max_idle_per_host(10) .pool_idle_timeout(std::time::Duration::from_secs(30)) .build()?; diff --git a/web-app/src/containers/ProxyTimeoutInput.tsx b/web-app/src/containers/ProxyTimeoutInput.tsx new file mode 100644 index 000000000..daa1e61bf --- /dev/null +++ b/web-app/src/containers/ProxyTimeoutInput.tsx @@ -0,0 +1,39 @@ +import { Input } from '@/components/ui/input' +import { useLocalApiServer } from '@/hooks/useLocalApiServer' +import { cn } from '@/lib/utils' +import { useState } from 'react' + +export function ProxyTimeoutInput({ isServerRunning }: { isServerRunning?: boolean }) { + const { proxyTimeout, setProxyTimeout } = useLocalApiServer() + const [inputValue, setInputValue] = useState(proxyTimeout.toString()) + + const handleChange = (e: React.ChangeEvent) => { + const value = e.target.value + setInputValue(value) + } + + const handleBlur = () => { + const timeout = parseInt(inputValue) + if (!isNaN(timeout) && timeout >= 0 && timeout <= 86400) { + setProxyTimeout(timeout) + } else { + // Reset to current value if invalid + setInputValue(proxyTimeout.toString()) + } + } + + return ( + + ) +} diff --git a/web-app/src/hooks/__tests__/useLocalApiServer.test.ts b/web-app/src/hooks/__tests__/useLocalApiServer.test.ts index e347d2307..27c0c7ea0 100644 --- a/web-app/src/hooks/__tests__/useLocalApiServer.test.ts +++ b/web-app/src/hooks/__tests__/useLocalApiServer.test.ts @@ -32,6 +32,7 @@ describe('useLocalApiServer', () => { store.setVerboseLogs(true) store.setTrustedHosts([]) store.setApiKey('') + store.setProxyTimeout(600) }) it('should initialize with default values', () => { @@ -45,6 +46,7 @@ describe('useLocalApiServer', () => { expect(result.current.verboseLogs).toBe(true) expect(result.current.trustedHosts).toEqual([]) expect(result.current.apiKey).toBe('') + expect(result.current.proxyTimeout).toBe(600) }) describe('enableOnStartup', () => { @@ -317,6 +319,32 @@ describe('useLocalApiServer', () => { }) }) + describe('proxyTimeout', () => { + it('should set proxy timeout', () => { + const { result } = renderHook(() => useLocalApiServer()) + + act(() => { + result.current.setProxyTimeout(1800) + }) + + expect(result.current.proxyTimeout).toBe(1800) + }) + + it('should handle different proxy timeouts', () => { + const { result } = renderHook(() => useLocalApiServer()) + + const testTimeouts = [100, 300, 600, 3600] + + testTimeouts.forEach((timeout) => { + act(() => { + result.current.setProxyTimeout(timeout) + }) + + expect(result.current.proxyTimeout).toBe(timeout) + }) + }) + }) + describe('state persistence', () => { it('should maintain state across multiple hook instances', () => { const { result: result1 } = renderHook(() => useLocalApiServer()) @@ -331,6 +359,7 @@ describe('useLocalApiServer', () => { result1.current.setVerboseLogs(false) result1.current.setApiKey('test-key') result1.current.addTrustedHost('example.com') + result1.current.setProxyTimeout(1800) }) expect(result2.current.enableOnStartup).toBe(false) @@ -341,6 +370,7 @@ describe('useLocalApiServer', () => { expect(result2.current.verboseLogs).toBe(false) expect(result2.current.apiKey).toBe('test-key') expect(result2.current.trustedHosts).toEqual(['example.com']) + expect(result2.current.proxyTimeout).toBe(1800) }) }) @@ -356,6 +386,7 @@ describe('useLocalApiServer', () => { result.current.addTrustedHost('localhost') result.current.addTrustedHost('127.0.0.1') result.current.setApiKey('sk-test-key') + result.current.setProxyTimeout(800) }) expect(result.current.serverHost).toBe('0.0.0.0') @@ -364,6 +395,7 @@ describe('useLocalApiServer', () => { expect(result.current.corsEnabled).toBe(false) expect(result.current.trustedHosts).toEqual(['localhost', '127.0.0.1']) expect(result.current.apiKey).toBe('sk-test-key') + expect(result.current.proxyTimeout).toBe(800) }) it('should preserve independent state changes', () => { @@ -376,6 +408,17 @@ describe('useLocalApiServer', () => { expect(result.current.serverPort).toBe(9000) expect(result.current.serverHost).toBe('127.0.0.1') // Should remain default expect(result.current.apiPrefix).toBe('/v1') // Should remain default + expect(result.current.proxyTimeout).toBe(600) // Should remain default + + act(() => { + result.current.setProxyTimeout(400) + }) + + expect(result.current.proxyTimeout).toBe(400) + expect(result.current.serverPort).toBe(9000) // Should remain default + expect(result.current.serverHost).toBe('127.0.0.1') // Should remain default + expect(result.current.apiPrefix).toBe('/v1') // Should remain default + act(() => { result.current.addTrustedHost('example.com') diff --git a/web-app/src/hooks/useLocalApiServer.ts b/web-app/src/hooks/useLocalApiServer.ts index a353df75c..712277425 100644 --- a/web-app/src/hooks/useLocalApiServer.ts +++ b/web-app/src/hooks/useLocalApiServer.ts @@ -28,6 +28,9 @@ type LocalApiServerState = { addTrustedHost: (host: string) => void removeTrustedHost: (host: string) => void setTrustedHosts: (hosts: string[]) => void + // Server request timeout (default 600 sec) + proxyTimeout: number + setProxyTimeout: (value: number) => void } export const useLocalApiServer = create()( @@ -55,6 +58,8 @@ export const useLocalApiServer = create()( trustedHosts: state.trustedHosts.filter((h) => h !== host), })), setTrustedHosts: (hosts) => set({ trustedHosts: hosts }), + proxyTimeout: 600, + setProxyTimeout: (value) => set({ proxyTimeout: value }), apiKey: '', setApiKey: (value) => set({ apiKey: value }), }), diff --git a/web-app/src/locales/de-DE/settings.json b/web-app/src/locales/de-DE/settings.json index 5e16f2679..9e199eef0 100644 --- a/web-app/src/locales/de-DE/settings.json +++ b/web-app/src/locales/de-DE/settings.json @@ -180,7 +180,9 @@ "cors": "Cross-Origin Resource Sharing (CORS)", "corsDesc": "Erlaube Cross-Origin-Anfragen an den API-Server.", "verboseLogs": "Ausführliche Server Logs", - "verboseLogsDesc": "Aktiviere detaillierte Server Logs zum Debuggen" + "verboseLogsDesc": "Aktiviere detaillierte Server Logs zum Debuggen", + "proxyTimeout": "Zeitüberschreitung bei der Anfrage", + "proxyTimeoutDesc": "Wartezeit auf eine Antwort vom lokalen Modell in Sekunden." }, "privacy": { "analytics": "Analytik", diff --git a/web-app/src/locales/en/settings.json b/web-app/src/locales/en/settings.json index be6b15b98..734736f01 100644 --- a/web-app/src/locales/en/settings.json +++ b/web-app/src/locales/en/settings.json @@ -183,7 +183,9 @@ "cors": "Cross-Origin Resource Sharing (CORS)", "corsDesc": "Allow cross-origin requests to the API server.", "verboseLogs": "Verbose Server Logs", - "verboseLogsDesc": "Enable detailed server logs for debugging." + "verboseLogsDesc": "Enable detailed server logs for debugging.", + "proxyTimeout": "Request timeout", + "proxyTimeoutDesc": "Time to wait for a response from the local model, seconds." }, "privacy": { "analytics": "Analytics", diff --git a/web-app/src/locales/id/settings.json b/web-app/src/locales/id/settings.json index 8747c96d1..394acb298 100644 --- a/web-app/src/locales/id/settings.json +++ b/web-app/src/locales/id/settings.json @@ -180,7 +180,9 @@ "cors": "Berbagi Sumber Daya Lintas Asal (CORS)", "corsDesc": "Izinkan permintaan lintas asal ke server API.", "verboseLogs": "Log Server Verbose", - "verboseLogsDesc": "Aktifkan log server terperinci untuk debugging." + "verboseLogsDesc": "Aktifkan log server terperinci untuk debugging.", + "proxyTimeout": "Permintaan melebihi batas waktu", + "proxyTimeoutDesc": "Waktu tunggu untuk respons dari model lokal dalam detik." }, "privacy": { "analytics": "Analitik", diff --git a/web-app/src/locales/pl/settings.json b/web-app/src/locales/pl/settings.json index d5f1e30a5..1695f1ccd 100644 --- a/web-app/src/locales/pl/settings.json +++ b/web-app/src/locales/pl/settings.json @@ -183,7 +183,9 @@ "cors": "Cross-Origin Resource Sharing (CORS)", "corsDesc": "Pozwalaj na żądania cross-origin do serwera API.", "verboseLogs": "Szczegółowe Wpisy Dzienników Serwera", - "verboseLogsDesc": "Włącz szczegółowe wpisy dzienników serwera na potrzeby rozwiązywania problemów." + "verboseLogsDesc": "Włącz szczegółowe wpisy dzienników serwera na potrzeby rozwiązywania problemów.", + "proxyTimeout": "Przekroczenie limitu czasu żądania", + "proxyTimeoutDesc": "Czas oczekiwania na odpowiedź od lokalnego modelu w sekundach." }, "privacy": { "analytics": "Dane Analityczne", diff --git a/web-app/src/locales/vn/settings.json b/web-app/src/locales/vn/settings.json index 789fc3344..45b6f9f22 100644 --- a/web-app/src/locales/vn/settings.json +++ b/web-app/src/locales/vn/settings.json @@ -180,7 +180,9 @@ "cors": "Chia sẻ tài nguyên giữa các nguồn gốc (CORS)", "corsDesc": "Cho phép các yêu cầu cross-origin đến máy chủ API.", "verboseLogs": "Nhật ký máy chủ chi tiết", - "verboseLogsDesc": "Bật nhật ký máy chủ chi tiết để gỡ lỗi." + "verboseLogsDesc": "Bật nhật ký máy chủ chi tiết để gỡ lỗi.", + "proxyTimeout": "Hết thời gian chờ yêu cầu", + "proxyTimeoutDesc": "Thời gian chờ phản hồi từ mô hình cục bộ (tính bằng giây)." }, "privacy": { "analytics": "Phân tích", diff --git a/web-app/src/locales/zh-CN/settings.json b/web-app/src/locales/zh-CN/settings.json index e1897c0bd..33f752f9d 100644 --- a/web-app/src/locales/zh-CN/settings.json +++ b/web-app/src/locales/zh-CN/settings.json @@ -180,7 +180,9 @@ "cors": "跨源资源共享 (CORS)", "corsDesc": "允许跨源请求访问 API 服务器。", "verboseLogs": "详细服务器日志", - "verboseLogsDesc": "启用详细服务器日志以进行调试。" + "verboseLogsDesc": "启用详细服务器日志以进行调试。", + "proxyTimeout": "请求超时", + "proxyTimeoutDesc": "等待本地模型响应的时间(单位:秒)。" }, "privacy": { "analytics": "分析", diff --git a/web-app/src/locales/zh-TW/settings.json b/web-app/src/locales/zh-TW/settings.json index 714ca7e19..9af67dcbb 100644 --- a/web-app/src/locales/zh-TW/settings.json +++ b/web-app/src/locales/zh-TW/settings.json @@ -180,7 +180,9 @@ "cors": "跨來源資源共用 (CORS)", "corsDesc": "允許跨來源請求存取 API 伺服器。", "verboseLogs": "詳細伺服器日誌", - "verboseLogsDesc": "啟用詳細伺服器日誌以進行偵錯。" + "verboseLogsDesc": "啟用詳細伺服器日誌以進行偵錯。", + "proxyTimeout": "請求逾時", + "proxyTimeoutDesc": "等待本地模型回應的時間(秒)。" }, "privacy": { "analytics": "分析", diff --git a/web-app/src/providers/DataProvider.tsx b/web-app/src/providers/DataProvider.tsx index 352026175..a734cd39f 100644 --- a/web-app/src/providers/DataProvider.tsx +++ b/web-app/src/providers/DataProvider.tsx @@ -36,6 +36,7 @@ export function DataProvider() { trustedHosts, corsEnabled, verboseLogs, + proxyTimeout, } = useLocalApiServer() const { setServerStatus } = useAppState() @@ -169,6 +170,7 @@ export function DataProvider() { trustedHosts, isCorsEnabled: corsEnabled, isVerboseEnabled: verboseLogs, + proxyTimeout: proxyTimeout, }) }) .then(() => { diff --git a/web-app/src/routes/settings/local-api-server.tsx b/web-app/src/routes/settings/local-api-server.tsx index 840e1fdb7..9f2b2315b 100644 --- a/web-app/src/routes/settings/local-api-server.tsx +++ b/web-app/src/routes/settings/local-api-server.tsx @@ -8,6 +8,7 @@ import { Button } from '@/components/ui/button' import { useTranslation } from '@/i18n/react-i18next-compat' import { ServerHostSwitcher } from '@/containers/ServerHostSwitcher' import { PortInput } from '@/containers/PortInput' +import { ProxyTimeoutInput } from '@/containers/ProxyTimeoutInput' import { ApiPrefixInput } from '@/containers/ApiPrefixInput' import { TrustedHostsInput } from '@/containers/TrustedHostsInput' import { useLocalApiServer } from '@/hooks/useLocalApiServer' @@ -50,6 +51,7 @@ function LocalAPIServerContent() { apiPrefix, apiKey, trustedHosts, + proxyTimeout, } = useLocalApiServer() const { serverStatus, setServerStatus } = useAppState() @@ -157,6 +159,7 @@ function LocalAPIServerContent() { trustedHosts, isCorsEnabled: corsEnabled, isVerboseEnabled: verboseLogs, + proxyTimeout: proxyTimeout, }) }) .then(() => { @@ -311,6 +314,11 @@ function LocalAPIServerContent() { } /> + } + /> {/* Advanced Settings */} diff --git a/website/src/content/docs/local-server/api-server.mdx b/website/src/content/docs/local-server/api-server.mdx index 9ab97865e..491c31db9 100644 --- a/website/src/content/docs/local-server/api-server.mdx +++ b/website/src/content/docs/local-server/api-server.mdx @@ -72,6 +72,10 @@ A mandatory secret key to authenticate requests. ### Trusted Hosts A comma-separated list of hostnames allowed to access the server. This provides an additional layer of security when the server is exposed on your network. +### Request timeout +Request timeout for local model response in seconds. +- **`600`** (Default): You can change this to any suitable value. + ## Advanced Settings ### Cross-Origin Resource Sharing (CORS) From 311a451005f78ea3171cc9defa83206024079373 Mon Sep 17 00:00:00 2001 From: Dinh Long Nguyen Date: Mon, 15 Sep 2025 20:13:46 +0700 Subject: [PATCH 23/24] Always allow MCP for web (#6462) * mcp and extension setting disabled + always allow mcp tools on web * fix tests --- web-app/src/containers/SettingsMenu.tsx | 4 +- web-app/src/hooks/useToolApproval.ts | 10 +- web-app/src/lib/platform/const.ts | 15 ++- web-app/src/lib/platform/types.ts | 15 ++- web-app/src/routes/settings/extensions.tsx | 2 +- web-app/src/routes/settings/mcp-servers.tsx | 133 +++----------------- web-app/src/test/setup.ts | 7 +- 7 files changed, 49 insertions(+), 137 deletions(-) diff --git a/web-app/src/containers/SettingsMenu.tsx b/web-app/src/containers/SettingsMenu.tsx index 7a543b03f..da0e94870 100644 --- a/web-app/src/containers/SettingsMenu.tsx +++ b/web-app/src/containers/SettingsMenu.tsx @@ -104,7 +104,7 @@ const SettingsMenu = () => { title: 'common:mcp-servers', route: route.settings.mcp_servers, hasSubMenu: false, - isEnabled: PlatformFeatures[PlatformFeature.MCP_SERVERS], + isEnabled: PlatformFeatures[PlatformFeature.MCP_SERVERS_SETTINGS], }, { title: 'common:local_api_server', @@ -122,7 +122,7 @@ const SettingsMenu = () => { title: 'common:extensions', route: route.settings.extensions, hasSubMenu: false, - isEnabled: PlatformFeatures[PlatformFeature.EXTENSION_MANAGEMENT], + isEnabled: PlatformFeatures[PlatformFeature.EXTENSIONS_SETTINGS], }, ] diff --git a/web-app/src/hooks/useToolApproval.ts b/web-app/src/hooks/useToolApproval.ts index 5edc5dddc..13529115d 100644 --- a/web-app/src/hooks/useToolApproval.ts +++ b/web-app/src/hooks/useToolApproval.ts @@ -1,6 +1,8 @@ import { create } from 'zustand' import { persist, createJSONStorage } from 'zustand/middleware' import { localStorageKey } from '@/constants/localStorage' +import { PlatformFeatures } from '@/lib/platform/const' +import { PlatformFeature } from '@/lib/platform/types' export type ToolApprovalModalProps = { toolName: string @@ -32,7 +34,7 @@ export const useToolApproval = create()( persist( (set, get) => ({ approvedTools: {}, - allowAllMCPPermissions: false, + allowAllMCPPermissions: PlatformFeatures[PlatformFeature.MCP_AUTO_APPROVE_TOOLS], isModalOpen: false, modalProps: null, @@ -55,6 +57,12 @@ export const useToolApproval = create()( showApprovalModal: (toolName: string, threadId: string, toolParameters?: object) => { return new Promise((resolve) => { + // Auto-approve MCP tools when feature is enabled + if (PlatformFeatures[PlatformFeature.MCP_AUTO_APPROVE_TOOLS]) { + resolve(true) + return + } + // Check if tool is already approved for this thread const state = get() if (state.isToolApproved(threadId, toolName)) { diff --git a/web-app/src/lib/platform/const.ts b/web-app/src/lib/platform/const.ts index f761b30f1..5192a6d1e 100644 --- a/web-app/src/lib/platform/const.ts +++ b/web-app/src/lib/platform/const.ts @@ -14,15 +14,9 @@ export const PlatformFeatures: Record = { // Hardware monitoring and GPU usage [PlatformFeature.HARDWARE_MONITORING]: isPlatformTauri(), - // Extension installation/management - [PlatformFeature.EXTENSION_MANAGEMENT]: true, - // Local model inference (llama.cpp) [PlatformFeature.LOCAL_INFERENCE]: isPlatformTauri(), - // MCP (Model Context Protocol) servers - [PlatformFeature.MCP_SERVERS]: true, - // Local API server [PlatformFeature.LOCAL_API_SERVER]: isPlatformTauri(), @@ -46,4 +40,13 @@ export const PlatformFeatures: Record = { // Model provider settings page management - disabled for web only [PlatformFeature.MODEL_PROVIDER_SETTINGS]: isPlatformTauri(), + + // Auto-enable MCP tool permissions - enabled for web platform + [PlatformFeature.MCP_AUTO_APPROVE_TOOLS]: !isPlatformTauri(), + + // MCP servers settings page - disabled for web + [PlatformFeature.MCP_SERVERS_SETTINGS]: isPlatformTauri(), + + // Extensions settings page - disabled for web + [PlatformFeature.EXTENSIONS_SETTINGS]: isPlatformTauri(), } \ No newline at end of file diff --git a/web-app/src/lib/platform/types.ts b/web-app/src/lib/platform/types.ts index 5aa0fe7f4..48d917cab 100644 --- a/web-app/src/lib/platform/types.ts +++ b/web-app/src/lib/platform/types.ts @@ -16,15 +16,9 @@ export enum PlatformFeature { // Hardware monitoring and GPU usage HARDWARE_MONITORING = 'hardwareMonitoring', - // Extension installation/management - EXTENSION_MANAGEMENT = 'extensionManagement', - // Local model inference (llama.cpp) LOCAL_INFERENCE = 'localInference', - // MCP (Model Context Protocol) servers - MCP_SERVERS = 'mcpServers', - // Local API server LOCAL_API_SERVER = 'localApiServer', @@ -48,4 +42,13 @@ export enum PlatformFeature { // Model provider settings page management MODEL_PROVIDER_SETTINGS = 'modelProviderSettings', + + // Auto-enable MCP tool permissions without approval + MCP_AUTO_APPROVE_TOOLS = 'mcpAutoApproveTools', + + // MCP servers settings page management + MCP_SERVERS_SETTINGS = 'mcpServersSettings', + + // Extensions settings page management + EXTENSIONS_SETTINGS = 'extensionsSettings', } diff --git a/web-app/src/routes/settings/extensions.tsx b/web-app/src/routes/settings/extensions.tsx index 9843d48b0..f278a44b2 100644 --- a/web-app/src/routes/settings/extensions.tsx +++ b/web-app/src/routes/settings/extensions.tsx @@ -18,7 +18,7 @@ export const Route = createFileRoute(route.settings.extensions as any)({ function Extensions() { return ( - + ) diff --git a/web-app/src/routes/settings/mcp-servers.tsx b/web-app/src/routes/settings/mcp-servers.tsx index 0eaa61696..0b95cf7ce 100644 --- a/web-app/src/routes/settings/mcp-servers.tsx +++ b/web-app/src/routes/settings/mcp-servers.tsx @@ -23,8 +23,6 @@ import { useTranslation } from '@/i18n/react-i18next-compat' import { useAppState } from '@/hooks/useAppState' import { PlatformGuard } from '@/lib/platform/PlatformGuard' import { PlatformFeature } from '@/lib/platform' -import { isPlatformTauri } from '@/lib/platform/utils' -import { MCPTool } from '@janhq/core' // Function to mask sensitive values const maskSensitiveValue = (value: string) => { @@ -92,118 +90,12 @@ export const Route = createFileRoute(route.settings.mcp_servers as any)({ function MCPServers() { return ( - - {isPlatformTauri() ? : } + + ) } -// Web version of MCP servers - simpler UI without server management -function MCPServersWeb() { - const { t } = useTranslation() - const serviceHub = useServiceHub() - const { allowAllMCPPermissions, setAllowAllMCPPermissions } = useToolApproval() - - const [webMcpTools, setWebMcpTools] = useState([]) - const [webMcpServers, setWebMcpServers] = useState([]) - const [webMcpLoading, setWebMcpLoading] = useState(true) - - useEffect(() => { - async function loadWebMcpData() { - setWebMcpLoading(true) - try { - const [tools, servers] = await Promise.all([ - serviceHub.mcp().getTools(), - serviceHub.mcp().getConnectedServers(), - ]) - setWebMcpTools(tools) - setWebMcpServers(servers) - } catch (error) { - console.error('Failed to load web MCP data:', error) - setWebMcpTools([]) - setWebMcpServers([]) - } finally { - setWebMcpLoading(false) - } - } - loadWebMcpData() - }, [serviceHub]) - - return ( -
- -

{t('common:settings')}

-
-
- -
-
- -

- {t('mcp-servers:title')} -

-

- MCP tools are automatically available in your chat sessions -

-
- } - > - - -
- } - /> - - - - 0 - ? `Connected to ${webMcpServers.join(', ')}. ${webMcpTools.length} tools available.` - : "MCP service not connected" - } - descriptionOutside={ - webMcpTools.length > 0 && !webMcpLoading && ( -
-

Available Tools:

-
- {webMcpTools.map((tool) => ( -
-
-
{tool.name}
-
{tool.description}
- {tool.server && ( -
Server: {tool.server}
- )} -
-
- ))} -
-
- ) - } - /> -
-
-
-
- - ) -} - -// Desktop version of MCP servers - full server management capabilities function MCPServersDesktop() { const { t } = useTranslation() const serviceHub = useServiceHub() @@ -351,10 +243,12 @@ function MCPServersDesktop() { setLoadingServers((prev) => ({ ...prev, [serverKey]: true })) const config = getServerConfig(serverKey) if (active && config) { - serviceHub.mcp().activateMCPServer(serverKey, { - ...(config ?? (mcpServers[serverKey] as MCPServerConfig)), - active, - }) + serviceHub + .mcp() + .activateMCPServer(serverKey, { + ...(config ?? (mcpServers[serverKey] as MCPServerConfig)), + active, + }) .then(() => { // Save single server editServer(serverKey, { @@ -388,10 +282,13 @@ function MCPServersDesktop() { active, }) syncServers() - serviceHub.mcp().deactivateMCPServer(serverKey).finally(() => { - serviceHub.mcp().getConnectedServers().then(setConnectedServers) - setLoadingServers((prev) => ({ ...prev, [serverKey]: false })) - }) + serviceHub + .mcp() + .deactivateMCPServer(serverKey) + .finally(() => { + serviceHub.mcp().getConnectedServers().then(setConnectedServers) + setLoadingServers((prev) => ({ ...prev, [serverKey]: false })) + }) } } } diff --git a/web-app/src/test/setup.ts b/web-app/src/test/setup.ts index a542ffec9..9fb6d928d 100644 --- a/web-app/src/test/setup.ts +++ b/web-app/src/test/setup.ts @@ -11,17 +11,18 @@ expect.extend(matchers) vi.mock('@/lib/platform/const', () => ({ PlatformFeatures: { hardwareMonitoring: true, - extensionManagement: true, localInference: true, - mcpServers: true, localApiServer: true, modelHub: true, systemIntegrations: true, httpsProxy: true, defaultProviders: true, analytics: true, - webAutoModelSelection: true, + webAutoModelSelection: false, modelProviderSettings: true, + mcpAutoApproveTools: false, + mcpServersSettings: true, + extensionsSettings: true, } })) From 491012fa876b0209b49e8b2166210b0b04e914b5 Mon Sep 17 00:00:00 2001 From: Dinh Long Nguyen Date: Mon, 15 Sep 2025 23:53:59 +0700 Subject: [PATCH 24/24] remove assistant from web (#6468) --- extensions-web/src/assistant-web/index.ts | 198 ------------------- extensions-web/src/index.ts | 7 +- extensions-web/src/types.ts | 9 +- web-app/src/containers/DropdownAssistant.tsx | 2 +- web-app/src/containers/LeftPanel.tsx | 2 +- web-app/src/hooks/useAppState.ts | 2 +- web-app/src/hooks/useAssistant.ts | 59 ++++-- web-app/src/hooks/useChat.ts | 8 +- web-app/src/hooks/useMessages.ts | 2 +- web-app/src/lib/platform/const.ts | 3 + web-app/src/lib/platform/types.ts | 3 + web-app/src/routes/assistant.tsx | 10 + web-app/src/routes/index.tsx | 4 +- web-app/src/routes/threads/$threadId.tsx | 6 +- web-app/src/test/setup.ts | 1 + 15 files changed, 78 insertions(+), 238 deletions(-) delete mode 100644 extensions-web/src/assistant-web/index.ts diff --git a/extensions-web/src/assistant-web/index.ts b/extensions-web/src/assistant-web/index.ts deleted file mode 100644 index 0a800d36d..000000000 --- a/extensions-web/src/assistant-web/index.ts +++ /dev/null @@ -1,198 +0,0 @@ -/** - * Web Assistant Extension - * Implements assistant management using IndexedDB - */ - -import { Assistant, AssistantExtension } from '@janhq/core' -import { getSharedDB } from '../shared/db' - -export default class AssistantExtensionWeb extends AssistantExtension { - private db: IDBDatabase | null = null - - private defaultAssistant: Assistant = { - avatar: '👋', - thread_location: undefined, - id: 'jan', - object: 'assistant', - created_at: Date.now() / 1000, - name: 'Jan', - description: - 'Jan is a helpful desktop assistant that can reason through complex tasks and use tools to complete them on the user\'s behalf.', - model: '*', - instructions: - 'You are a helpful AI assistant. Your primary goal is to assist users with their questions and tasks to the best of your abilities.\n\n' + - 'When responding:\n' + - '- Answer directly from your knowledge when you can\n' + - '- Be concise, clear, and helpful\n' + - '- Admit when you\'re unsure rather than making things up\n\n' + - 'If tools are available to you:\n' + - '- Only use tools when they add real value to your response\n' + - '- Use tools when the user explicitly asks (e.g., "search for...", "calculate...", "run this code")\n' + - '- Use tools for information you don\'t know or that needs verification\n' + - '- Never use tools just because they\'re available\n\n' + - 'When using tools:\n' + - '- Use one tool at a time and wait for results\n' + - '- Use actual values as arguments, not variable names\n' + - '- Learn from each result before deciding next steps\n' + - '- Avoid repeating the same tool call with identical parameters\n\n' + - 'Remember: Most questions can be answered without tools. Think first whether you need them.\n\n' + - 'Current date: {{current_date}}', - tools: [ - { - type: 'retrieval', - enabled: false, - useTimeWeightedRetriever: false, - settings: { - top_k: 2, - chunk_size: 1024, - chunk_overlap: 64, - retrieval_template: `Use the following pieces of context to answer the question at the end. -{context} -Question: {question} -Helpful Answer:`, - }, - }, - ], - file_ids: [], - metadata: undefined, - } - - async onLoad() { - console.log('Loading Web Assistant Extension') - this.db = await getSharedDB() - - // Create default assistant if none exist - const assistants = await this.getAssistants() - if (assistants.length === 0) { - await this.createAssistant(this.defaultAssistant) - } - } - - onUnload() { - // Don't close shared DB, other extensions might be using it - this.db = null - } - - private ensureDB(): void { - if (!this.db) { - throw new Error('Database not initialized. Call onLoad() first.') - } - } - - async getAssistants(): Promise { - this.ensureDB() - - return new Promise((resolve, reject) => { - const transaction = this.db!.transaction(['assistants'], 'readonly') - const store = transaction.objectStore('assistants') - const request = store.getAll() - - request.onsuccess = () => { - resolve(request.result || []) - } - - request.onerror = () => { - reject(request.error) - } - }) - } - - async createAssistant(assistant: Assistant): Promise { - this.ensureDB() - - return new Promise((resolve, reject) => { - const transaction = this.db!.transaction(['assistants'], 'readwrite') - const store = transaction.objectStore('assistants') - - const assistantToStore = { - ...assistant, - created_at: assistant.created_at || Date.now() / 1000, - } - - const request = store.add(assistantToStore) - - request.onsuccess = () => { - console.log('Assistant created:', assistant.id) - resolve() - } - - request.onerror = () => { - console.error('Failed to create assistant:', request.error) - reject(request.error) - } - }) - } - - async updateAssistant(id: string, assistant: Partial): Promise { - this.ensureDB() - - return new Promise((resolve, reject) => { - const transaction = this.db!.transaction(['assistants'], 'readwrite') - const store = transaction.objectStore('assistants') - - // First get the existing assistant - const getRequest = store.get(id) - - getRequest.onsuccess = () => { - const existingAssistant = getRequest.result - if (!existingAssistant) { - reject(new Error(`Assistant with id ${id} not found`)) - return - } - - const updatedAssistant = { - ...existingAssistant, - ...assistant, - id, // Ensure ID doesn't change - } - - const putRequest = store.put(updatedAssistant) - - putRequest.onsuccess = () => resolve() - putRequest.onerror = () => reject(putRequest.error) - } - - getRequest.onerror = () => { - reject(getRequest.error) - } - }) - } - - async deleteAssistant(assistant: Assistant): Promise { - this.ensureDB() - - return new Promise((resolve, reject) => { - const transaction = this.db!.transaction(['assistants'], 'readwrite') - const store = transaction.objectStore('assistants') - const request = store.delete(assistant.id) - - request.onsuccess = () => { - console.log('Assistant deleted:', assistant.id) - resolve() - } - - request.onerror = () => { - console.error('Failed to delete assistant:', request.error) - reject(request.error) - } - }) - } - - async getAssistant(id: string): Promise { - this.ensureDB() - - return new Promise((resolve, reject) => { - const transaction = this.db!.transaction(['assistants'], 'readonly') - const store = transaction.objectStore('assistants') - const request = store.get(id) - - request.onsuccess = () => { - resolve(request.result || null) - } - - request.onerror = () => { - reject(request.error) - } - }) - } -} \ No newline at end of file diff --git a/extensions-web/src/index.ts b/extensions-web/src/index.ts index e45c2d71c..9e7f3aab3 100644 --- a/extensions-web/src/index.ts +++ b/extensions-web/src/index.ts @@ -5,18 +5,16 @@ import type { WebExtensionRegistry } from './types' -export { default as AssistantExtensionWeb } from './assistant-web' export { default as ConversationalExtensionWeb } from './conversational-web' export { default as JanProviderWeb } from './jan-provider-web' export { default as MCPExtensionWeb } from './mcp-web' // Re-export types -export type { - WebExtensionRegistry, +export type { + WebExtensionRegistry, WebExtensionModule, WebExtensionName, WebExtensionLoader, - AssistantWebModule, ConversationalWebModule, JanProviderWebModule, MCPWebModule @@ -24,7 +22,6 @@ export type { // Extension registry for dynamic loading export const WEB_EXTENSIONS: WebExtensionRegistry = { - 'assistant-web': () => import('./assistant-web'), 'conversational-web': () => import('./conversational-web'), 'jan-provider-web': () => import('./jan-provider-web'), 'mcp-web': () => import('./mcp-web'), diff --git a/extensions-web/src/types.ts b/extensions-web/src/types.ts index f98d761cc..47ef0be71 100644 --- a/extensions-web/src/types.ts +++ b/extensions-web/src/types.ts @@ -2,14 +2,10 @@ * Web Extension Types */ -import type { AssistantExtension, ConversationalExtension, BaseExtension, AIEngine, MCPExtension } from '@janhq/core' +import type { ConversationalExtension, BaseExtension, AIEngine, MCPExtension } from '@janhq/core' type ExtensionConstructorParams = ConstructorParameters -export interface AssistantWebModule { - default: new (...args: ExtensionConstructorParams) => AssistantExtension -} - export interface ConversationalWebModule { default: new (...args: ExtensionConstructorParams) => ConversationalExtension } @@ -22,10 +18,9 @@ export interface MCPWebModule { default: new (...args: ExtensionConstructorParams) => MCPExtension } -export type WebExtensionModule = AssistantWebModule | ConversationalWebModule | JanProviderWebModule | MCPWebModule +export type WebExtensionModule = ConversationalWebModule | JanProviderWebModule | MCPWebModule export interface WebExtensionRegistry { - 'assistant-web': () => Promise 'conversational-web': () => Promise 'jan-provider-web': () => Promise 'mcp-web': () => Promise diff --git a/web-app/src/containers/DropdownAssistant.tsx b/web-app/src/containers/DropdownAssistant.tsx index 44a24e5e6..a75925002 100644 --- a/web-app/src/containers/DropdownAssistant.tsx +++ b/web-app/src/containers/DropdownAssistant.tsx @@ -28,7 +28,7 @@ const DropdownAssistant = () => { ) const selectedAssistant = - assistants.find((a) => a.id === currentAssistant.id) || assistants[0] + assistants.find((a) => a.id === currentAssistant?.id) || assistants[0] return ( <> diff --git a/web-app/src/containers/LeftPanel.tsx b/web-app/src/containers/LeftPanel.tsx index 2f4ce9ecc..8ddfdbd36 100644 --- a/web-app/src/containers/LeftPanel.tsx +++ b/web-app/src/containers/LeftPanel.tsx @@ -46,7 +46,7 @@ const mainMenus = [ title: 'common:assistants', icon: IconClipboardSmileFilled, route: route.assistant, - isEnabled: true, + isEnabled: PlatformFeatures[PlatformFeature.ASSISTANTS], }, { title: 'common:hub', diff --git a/web-app/src/hooks/useAppState.ts b/web-app/src/hooks/useAppState.ts index fe885e043..837ed8c38 100644 --- a/web-app/src/hooks/useAppState.ts +++ b/web-app/src/hooks/useAppState.ts @@ -50,7 +50,7 @@ export const useAppState = create()((set) => ({ const currentAssistant = useAssistant.getState().currentAssistant const selectedAssistant = - assistants.find((a) => a.id === currentAssistant.id) || assistants[0] + assistants.find((a) => a.id === currentAssistant?.id) || assistants[0] set(() => ({ streamingContent: content diff --git a/web-app/src/hooks/useAssistant.ts b/web-app/src/hooks/useAssistant.ts index eab1fffc9..577ff1283 100644 --- a/web-app/src/hooks/useAssistant.ts +++ b/web-app/src/hooks/useAssistant.ts @@ -2,10 +2,12 @@ import { getServiceHub } from '@/hooks/useServiceHub' import { Assistant as CoreAssistant } from '@janhq/core' import { create } from 'zustand' import { localStorageKey } from '@/constants/localStorage' +import { PlatformFeatures } from '@/lib/platform/const' +import { PlatformFeature } from '@/lib/platform/types' interface AssistantState { assistants: Assistant[] - currentAssistant: Assistant + currentAssistant: Assistant | null addAssistant: (assistant: Assistant) => void updateAssistant: (assistant: Assistant) => void deleteAssistant: (id: string) => void @@ -46,14 +48,31 @@ export const defaultAssistant: Assistant = { 'You are a helpful AI assistant. Your primary goal is to assist users with their questions and tasks to the best of your abilities.\n\nWhen responding:\n- Answer directly from your knowledge when you can\n- Be concise, clear, and helpful\n- Admit when you’re unsure rather than making things up\n\nIf tools are available to you:\n- Only use tools when they add real value to your response\n- Use tools when the user explicitly asks (e.g., "search for...", "calculate...", "run this code")\n- Use tools for information you don’t know or that needs verification\n- Never use tools just because they’re available\n\nWhen using tools:\n- Use one tool at a time and wait for results\n- Use actual values as arguments, not variable names\n- Learn from each result before deciding next steps\n- Avoid repeating the same tool call with identical parameters\n\nRemember: Most questions can be answered without tools. Think first whether you need them.\n\nCurrent date: {{current_date}}', } -export const useAssistant = create()((set, get) => ({ - assistants: [defaultAssistant], - currentAssistant: defaultAssistant, +// Platform-aware initial state +const getInitialAssistantState = () => { + if (PlatformFeatures[PlatformFeature.ASSISTANTS]) { + return { + assistants: [defaultAssistant], + currentAssistant: defaultAssistant, + } + } else { + return { + assistants: [], + currentAssistant: null, + } + } +} + +export const useAssistant = create((set, get) => ({ + ...getInitialAssistantState(), addAssistant: (assistant) => { set({ assistants: [...get().assistants, assistant] }) - getServiceHub().assistants().createAssistant(assistant as unknown as CoreAssistant).catch((error) => { - console.error('Failed to create assistant:', error) - }) + getServiceHub() + .assistants() + .createAssistant(assistant as unknown as CoreAssistant) + .catch((error) => { + console.error('Failed to create assistant:', error) + }) }, updateAssistant: (assistant) => { const state = get() @@ -63,25 +82,31 @@ export const useAssistant = create()((set, get) => ({ ), // Update currentAssistant if it's the same assistant being updated currentAssistant: - state.currentAssistant.id === assistant.id + state.currentAssistant?.id === assistant.id ? assistant : state.currentAssistant, }) // Create assistant already cover update logic - getServiceHub().assistants().createAssistant(assistant as unknown as CoreAssistant).catch((error) => { - console.error('Failed to update assistant:', error) - }) + getServiceHub() + .assistants() + .createAssistant(assistant as unknown as CoreAssistant) + .catch((error) => { + console.error('Failed to update assistant:', error) + }) }, deleteAssistant: (id) => { const state = get() - getServiceHub().assistants().deleteAssistant( - state.assistants.find((e) => e.id === id) as unknown as CoreAssistant - ).catch((error) => { - console.error('Failed to delete assistant:', error) - }) + getServiceHub() + .assistants() + .deleteAssistant( + state.assistants.find((e) => e.id === id) as unknown as CoreAssistant + ) + .catch((error) => { + console.error('Failed to delete assistant:', error) + }) // Check if we're deleting the current assistant - const wasCurrentAssistant = state.currentAssistant.id === id + const wasCurrentAssistant = state.currentAssistant?.id === id set({ assistants: state.assistants.filter((a) => a.id !== id) }) diff --git a/web-app/src/hooks/useChat.ts b/web-app/src/hooks/useChat.ts index 029dfe722..f56a650b6 100644 --- a/web-app/src/hooks/useChat.ts +++ b/web-app/src/hooks/useChat.ts @@ -73,7 +73,7 @@ export const useChat = () => { }, [provider, selectedProvider]) const selectedAssistant = - assistants.find((a) => a.id === currentAssistant.id) || assistants[0] + assistants.find((a) => a.id === currentAssistant?.id) || assistants[0] const getCurrentThread = useCallback(async () => { let currentThread = retrieveThread() @@ -237,7 +237,7 @@ export const useChat = () => { const builder = new CompletionMessagesBuilder( messages, - renderInstructions(currentAssistant?.instructions) + currentAssistant ? renderInstructions(currentAssistant.instructions) : undefined ) if (troubleshooting) builder.addUserMessage(message, attachments) @@ -284,10 +284,10 @@ export const useChat = () => { builder.getMessages(), abortController, availableTools, - currentAssistant.parameters?.stream === false ? false : true, + currentAssistant?.parameters?.stream === false ? false : true, { ...modelSettings, - ...currentAssistant.parameters, + ...(currentAssistant?.parameters || {}), } as unknown as Record ) diff --git a/web-app/src/hooks/useMessages.ts b/web-app/src/hooks/useMessages.ts index 8dba73b9b..fc9dcf793 100644 --- a/web-app/src/hooks/useMessages.ts +++ b/web-app/src/hooks/useMessages.ts @@ -29,7 +29,7 @@ export const useMessages = create()((set, get) => ({ const currentAssistant = useAssistant.getState().currentAssistant const selectedAssistant = - assistants.find((a) => a.id === currentAssistant.id) || assistants[0] + assistants.find((a) => a.id === currentAssistant?.id) || assistants[0] const newMessage = { ...message, diff --git a/web-app/src/lib/platform/const.ts b/web-app/src/lib/platform/const.ts index 5192a6d1e..c8beccf94 100644 --- a/web-app/src/lib/platform/const.ts +++ b/web-app/src/lib/platform/const.ts @@ -49,4 +49,7 @@ export const PlatformFeatures: Record = { // Extensions settings page - disabled for web [PlatformFeature.EXTENSIONS_SETTINGS]: isPlatformTauri(), + + // Assistant functionality - disabled for web + [PlatformFeature.ASSISTANTS]: isPlatformTauri(), } \ No newline at end of file diff --git a/web-app/src/lib/platform/types.ts b/web-app/src/lib/platform/types.ts index 48d917cab..64a8a2367 100644 --- a/web-app/src/lib/platform/types.ts +++ b/web-app/src/lib/platform/types.ts @@ -51,4 +51,7 @@ export enum PlatformFeature { // Extensions settings page management EXTENSIONS_SETTINGS = 'extensionsSettings', + + // Assistant functionality (creation, editing, management) + ASSISTANTS = 'assistants', } diff --git a/web-app/src/routes/assistant.tsx b/web-app/src/routes/assistant.tsx index 22e913445..bf4fd928c 100644 --- a/web-app/src/routes/assistant.tsx +++ b/web-app/src/routes/assistant.tsx @@ -10,6 +10,8 @@ import AddEditAssistant from '@/containers/dialogs/AddEditAssistant' import { DeleteAssistantDialog } from '@/containers/dialogs' import { AvatarEmoji } from '@/containers/AvatarEmoji' import { useTranslation } from '@/i18n/react-i18next-compat' +import { PlatformGuard } from '@/lib/platform/PlatformGuard' +import { PlatformFeature } from '@/lib/platform/types' // eslint-disable-next-line @typescript-eslint/no-explicit-any export const Route = createFileRoute(route.assistant as any)({ @@ -17,6 +19,14 @@ export const Route = createFileRoute(route.assistant as any)({ }) function Assistant() { + return ( + + + + ) +} + +function AssistantContent() { const { t } = useTranslation() const { assistants, addAssistant, updateAssistant, deleteAssistant } = useAssistant() diff --git a/web-app/src/routes/index.tsx b/web-app/src/routes/index.tsx index 4ff643356..a23b29de4 100644 --- a/web-app/src/routes/index.tsx +++ b/web-app/src/routes/index.tsx @@ -18,6 +18,8 @@ type SearchParams = { import DropdownAssistant from '@/containers/DropdownAssistant' import { useEffect } from 'react' import { useThreads } from '@/hooks/useThreads' +import { PlatformFeatures } from '@/lib/platform/const' +import { PlatformFeature } from '@/lib/platform/types' export const Route = createFileRoute(route.home as any)({ component: Index, @@ -54,7 +56,7 @@ function Index() { return (
- + {PlatformFeatures[PlatformFeature.ASSISTANTS] && }
diff --git a/web-app/src/routes/threads/$threadId.tsx b/web-app/src/routes/threads/$threadId.tsx index 6f2a83de8..a7c62c807 100644 --- a/web-app/src/routes/threads/$threadId.tsx +++ b/web-app/src/routes/threads/$threadId.tsx @@ -24,6 +24,8 @@ import { useTranslation } from '@/i18n/react-i18next-compat' import { useChat } from '@/hooks/useChat' import { useSmallScreen } from '@/hooks/useMediaQuery' import { useTools } from '@/hooks/useTools' +import { PlatformFeatures } from '@/lib/platform/const' +import { PlatformFeature } from '@/lib/platform/types' // as route.threadsDetail export const Route = createFileRoute('/threads/$threadId')({ @@ -300,10 +302,10 @@ function ThreadDetail() {
- + {PlatformFeatures[PlatformFeature.ASSISTANTS] && }
-
+
({ mcpAutoApproveTools: false, mcpServersSettings: true, extensionsSettings: true, + assistants: true, } }))