From c5a5968bf8f67952f667ce4c3337178daacb293c Mon Sep 17 00:00:00 2001 From: Nghia Doan Date: Mon, 29 Sep 2025 22:15:13 +0700 Subject: [PATCH 01/12] Merge pull request #6643 from menloresearch/fix/model-name-change fix: Apply model name change correctly --- src-tauri/Cargo.lock | 1 + .../src/containers/DropdownModelProvider.tsx | 12 +- web-app/src/containers/ModelSetting.tsx | 4 +- ...DropdownModelProvider.displayName.test.tsx | 277 ++++++++++++++++++ .../containers/__tests__/EditModel.test.tsx | 184 ++++++++++++ web-app/src/containers/dialogs/EditModel.tsx | 82 +++--- .../hooks/__tests__/useModelProvider.test.ts | 182 ++++++++++++ web-app/src/hooks/useModelProvider.ts | 1 + web-app/src/lib/__tests__/utils.test.ts | 50 ++++ web-app/src/lib/utils.ts | 8 +- .../settings/providers/$providerName.tsx | 4 +- web-app/src/services/__tests__/models.test.ts | 9 +- web-app/src/services/models/default.ts | 9 +- web-app/src/types/modelProviders.d.ts | 1 + 14 files changed, 767 insertions(+), 57 deletions(-) create mode 100644 web-app/src/containers/__tests__/DropdownModelProvider.displayName.test.tsx create mode 100644 web-app/src/containers/__tests__/EditModel.test.tsx create mode 100644 web-app/src/hooks/__tests__/useModelProvider.test.ts diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 85a90422a..ca75cbd77 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -5323,6 +5323,7 @@ dependencies = [ "sysinfo", "tauri", "tauri-plugin", + "tauri-plugin-hardware", "thiserror 2.0.12", "tokio", ] diff --git a/web-app/src/containers/DropdownModelProvider.tsx b/web-app/src/containers/DropdownModelProvider.tsx index 8f9ea35a8..a8614f89d 100644 --- a/web-app/src/containers/DropdownModelProvider.tsx +++ b/web-app/src/containers/DropdownModelProvider.tsx @@ -6,7 +6,7 @@ import { PopoverTrigger, } from '@/components/ui/popover' import { useModelProvider } from '@/hooks/useModelProvider' -import { cn, getProviderTitle } from '@/lib/utils' +import { cn, getProviderTitle, getModelDisplayName } from '@/lib/utils' import { highlightFzfMatch } from '@/utils/highlight' import Capabilities from './Capabilities' import { IconSettings, IconX } from '@tabler/icons-react' @@ -240,7 +240,7 @@ const DropdownModelProvider = ({ // Update display model when selection changes useEffect(() => { if (selectedProvider && selectedModel) { - setDisplayModel(selectedModel.id) + setDisplayModel(getModelDisplayName(selectedModel)) } else { setDisplayModel(t('common:selectAModel')) } @@ -326,7 +326,7 @@ const DropdownModelProvider = ({ // Create Fzf instance for fuzzy search const fzfInstance = useMemo(() => { return new Fzf(searchableItems, { - selector: (item) => item.model.id.toLowerCase(), + selector: (item) => `${getModelDisplayName(item.model)} ${item.model.id}`.toLowerCase(), }) }, [searchableItems]) @@ -390,7 +390,7 @@ const DropdownModelProvider = ({ const handleSelect = useCallback( async (searchableModel: SearchableModel) => { // Immediately update display to prevent double-click issues - setDisplayModel(searchableModel.model.id) + setDisplayModel(getModelDisplayName(searchableModel.model)) setSearchValue('') setOpen(false) @@ -576,7 +576,7 @@ const DropdownModelProvider = ({ /> - {searchableModel.model.id} + {getModelDisplayName(searchableModel.model)}
{capabilities.length > 0 && ( @@ -669,7 +669,7 @@ const DropdownModelProvider = ({ className="text-main-view-fg/80 text-sm" title={searchableModel.model.id} > - {searchableModel.model.id} + {getModelDisplayName(searchableModel.model)}
{capabilities.length > 0 && ( diff --git a/web-app/src/containers/ModelSetting.tsx b/web-app/src/containers/ModelSetting.tsx index 9a3bfd814..079b735aa 100644 --- a/web-app/src/containers/ModelSetting.tsx +++ b/web-app/src/containers/ModelSetting.tsx @@ -14,7 +14,7 @@ import { Button } from '@/components/ui/button' import { DynamicControllerSetting } from '@/containers/dynamicControllerSetting' import { useModelProvider } from '@/hooks/useModelProvider' import { useServiceHub } from '@/hooks/useServiceHub' -import { cn } from '@/lib/utils' +import { cn, getModelDisplayName } from '@/lib/utils' import { useTranslation } from '@/i18n/react-i18next-compat' type ModelSettingProps = { @@ -261,7 +261,7 @@ export function ModelSetting({ - {t('common:modelSettings.title', { modelId: model.id })} + {t('common:modelSettings.title', { modelId: getModelDisplayName(model) })} {t('common:modelSettings.description')} diff --git a/web-app/src/containers/__tests__/DropdownModelProvider.displayName.test.tsx b/web-app/src/containers/__tests__/DropdownModelProvider.displayName.test.tsx new file mode 100644 index 000000000..38783dfab --- /dev/null +++ b/web-app/src/containers/__tests__/DropdownModelProvider.displayName.test.tsx @@ -0,0 +1,277 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { render, screen } from '@testing-library/react' +import '@testing-library/jest-dom' +import DropdownModelProvider from '../DropdownModelProvider' +import { getModelDisplayName } from '@/lib/utils' +import { useModelProvider } from '@/hooks/useModelProvider' + +// Define basic types to avoid missing declarations +type ModelProvider = { + provider: string + active: boolean + models: Array<{ + id: string + displayName?: string + capabilities: string[] + }> + settings: unknown[] +} + +type Model = { + id: string + displayName?: string + capabilities?: string[] +} + +type MockHookReturn = { + providers: ModelProvider[] + selectedProvider: string + selectedModel: Model + getProviderByName: (name: string) => ModelProvider | undefined + selectModelProvider: () => void + getModelBy: (id: string) => Model | undefined + updateProvider: () => void +} + +// Mock the dependencies +vi.mock('@/hooks/useModelProvider', () => ({ + useModelProvider: vi.fn(), +})) + +vi.mock('@/hooks/useThreads', () => ({ + useThreads: vi.fn(() => ({ + updateCurrentThreadModel: vi.fn(), + })), +})) + +vi.mock('@/hooks/useServiceHub', () => ({ + useServiceHub: vi.fn(() => ({ + models: () => ({ + checkMmprojExists: vi.fn(() => Promise.resolve(false)), + checkMmprojExistsAndUpdateOffloadMMprojSetting: vi.fn(() => Promise.resolve()), + }), + })), +})) + +vi.mock('@/i18n/react-i18next-compat', () => ({ + useTranslation: vi.fn(() => ({ + t: (key: string) => key, + })), +})) + +vi.mock('@tanstack/react-router', () => ({ + useNavigate: vi.fn(() => vi.fn()), +})) + +vi.mock('@/hooks/useFavoriteModel', () => ({ + useFavoriteModel: vi.fn(() => ({ + favoriteModels: [], + })), +})) + +vi.mock('@/lib/platform/const', () => ({ + PlatformFeatures: { + WEB_AUTO_MODEL_SELECTION: false, + MODEL_PROVIDER_SETTINGS: true, + }, +})) + +// Mock UI components +vi.mock('@/components/ui/popover', () => ({ + Popover: ({ children }: { children: React.ReactNode }) =>
{children}
, + PopoverTrigger: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + PopoverContent: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), +})) + +vi.mock('../ProvidersAvatar', () => ({ + default: ({ provider }: { provider: any }) => ( +
+ ), +})) + +vi.mock('../Capabilities', () => ({ + default: ({ capabilities }: { capabilities: string[] }) => ( +
{capabilities.join(',')}
+ ), +})) + +vi.mock('../ModelSetting', () => ({ + ModelSetting: () =>
, +})) + +vi.mock('../ModelSupportStatus', () => ({ + ModelSupportStatus: () =>
, +})) + +describe('DropdownModelProvider - Display Name Integration', () => { + const mockProviders: ModelProvider[] = [ + { + provider: 'llamacpp', + active: true, + models: [ + { + id: 'model1.gguf', + displayName: 'Custom Model 1', + capabilities: ['completion'], + }, + { + id: 'model2-very-long-filename.gguf', + displayName: 'Short Name', + capabilities: ['completion'], + }, + { + id: 'model3.gguf', + // No displayName - should fall back to ID + capabilities: ['completion'], + }, + ], + settings: [], + }, + ] + + const mockSelectedModel = { + id: 'model1.gguf', + displayName: 'Custom Model 1', + capabilities: ['completion'], + } + + beforeEach(() => { + vi.clearAllMocks() + + // Reset the mock for each test + vi.mocked(useModelProvider).mockReturnValue({ + providers: mockProviders, + selectedProvider: 'llamacpp', + selectedModel: mockSelectedModel, + getProviderByName: vi.fn((name: string) => + mockProviders.find((p: ModelProvider) => p.provider === name) + ), + selectModelProvider: vi.fn(), + getModelBy: vi.fn((id: string) => + mockProviders[0].models.find((m: Model) => m.id === id) + ), + updateProvider: vi.fn(), + } as MockHookReturn) + }) + + it('should display custom model name in the trigger button', () => { + render() + + // Should show the display name in both trigger and dropdown + expect(screen.getAllByText('Custom Model 1')).toHaveLength(2) // One in trigger, one in dropdown + // Model ID should not be visible as text (it's only in title attributes) + expect(screen.queryByDisplayValue('model1.gguf')).not.toBeInTheDocument() + }) + + it('should fall back to model ID when no displayName is set', () => { + vi.mocked(useModelProvider).mockReturnValue({ + providers: mockProviders, + selectedProvider: 'llamacpp', + selectedModel: mockProviders[0].models[2], // model3 without displayName + getProviderByName: vi.fn((name: string) => + mockProviders.find((p: ModelProvider) => p.provider === name) + ), + selectModelProvider: vi.fn(), + getModelBy: vi.fn((id: string) => + mockProviders[0].models.find((m: Model) => m.id === id) + ), + updateProvider: vi.fn(), + } as MockHookReturn) + + render() + + expect(screen.getAllByText('model3.gguf')).toHaveLength(2) // Trigger and dropdown + }) + + it('should show display names in the model list items', () => { + render() + + // Check if the display names are shown in the options + expect(screen.getAllByText('Custom Model 1')).toHaveLength(2) // Selected: Trigger + dropdown + expect(screen.getByText('Short Name')).toBeInTheDocument() // Only in dropdown + expect(screen.getByText('model3.gguf')).toBeInTheDocument() // Only in dropdown + }) + + it('should use getModelDisplayName utility correctly', () => { + // Test the utility function directly with different model scenarios + const modelWithDisplayName = { + id: 'long-model-name.gguf', + displayName: 'Short Name', + } as Model + + const modelWithoutDisplayName = { + id: 'model-without-display-name.gguf', + } as Model + + const modelWithEmptyDisplayName = { + id: 'model-with-empty.gguf', + displayName: '', + } as Model + + expect(getModelDisplayName(modelWithDisplayName)).toBe('Short Name') + expect(getModelDisplayName(modelWithoutDisplayName)).toBe('model-without-display-name.gguf') + expect(getModelDisplayName(modelWithEmptyDisplayName)).toBe('model-with-empty.gguf') + }) + + it('should maintain model ID for internal operations while showing display name', () => { + const mockSelectModelProvider = vi.fn() + + vi.mocked(useModelProvider).mockReturnValue({ + providers: mockProviders, + selectedProvider: 'llamacpp', + selectedModel: mockSelectedModel, + getProviderByName: vi.fn((name: string) => + mockProviders.find((p: ModelProvider) => p.provider === name) + ), + selectModelProvider: mockSelectModelProvider, + getModelBy: vi.fn((id: string) => + mockProviders[0].models.find((m: Model) => m.id === id) + ), + updateProvider: vi.fn(), + } as MockHookReturn) + + render() + + // Verify that display name is shown in UI + expect(screen.getAllByText('Custom Model 1')).toHaveLength(2) // Trigger + dropdown + + // The actual model ID should still be preserved for backend operations + // This would be tested in the click handlers, but that requires more complex mocking + expect(mockSelectedModel.id).toBe('model1.gguf') + }) + + it('should handle updating display model when selection changes', () => { + // Test that when a new model is selected, the trigger updates correctly + // First render with model1 selected + const { rerender } = render() + + // Check trigger shows Custom Model 1 + const triggerButton = screen.getByRole('button') + expect(triggerButton).toHaveTextContent('Custom Model 1') + + // Update to select model2 + vi.mocked(useModelProvider).mockReturnValue({ + providers: mockProviders, + selectedProvider: 'llamacpp', + selectedModel: mockProviders[0].models[1], // model2 + getProviderByName: vi.fn((name: string) => + mockProviders.find((p: ModelProvider) => p.provider === name) + ), + selectModelProvider: vi.fn(), + getModelBy: vi.fn((id: string) => + mockProviders[0].models.find((m: Model) => m.id === id) + ), + updateProvider: vi.fn(), + } as MockHookReturn) + + rerender() + // Check trigger now shows Short Name + expect(triggerButton).toHaveTextContent('Short Name') + // Both models are still visible in the dropdown, so we can't test for absence + expect(screen.getAllByText('Short Name')).toHaveLength(2) // trigger + dropdown + }) +}) \ No newline at end of file diff --git a/web-app/src/containers/__tests__/EditModel.test.tsx b/web-app/src/containers/__tests__/EditModel.test.tsx new file mode 100644 index 000000000..a02e72476 --- /dev/null +++ b/web-app/src/containers/__tests__/EditModel.test.tsx @@ -0,0 +1,184 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { render, screen, fireEvent, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { DialogEditModel } from '../dialogs/EditModel' +import { useModelProvider } from '@/hooks/useModelProvider' +import '@testing-library/jest-dom' + +// Mock the dependencies +vi.mock('@/hooks/useModelProvider', () => ({ + useModelProvider: vi.fn(() => ({ + updateProvider: vi.fn(), + setProviders: vi.fn(), + })), +})) + +vi.mock('@/hooks/useServiceHub', () => ({ + useServiceHub: vi.fn(() => ({ + providers: () => ({ + getProviders: vi.fn(() => Promise.resolve([])), + }), + })), +})) + +vi.mock('@/i18n/react-i18next-compat', () => ({ + useTranslation: vi.fn(() => ({ + t: (key: string) => key, + })), +})) + +vi.mock('sonner', () => ({ + toast: { + success: vi.fn(), + error: vi.fn(), + }, +})) + +// Mock Dialog components +vi.mock('@/components/ui/dialog', () => ({ + Dialog: ({ children, open }: { children: React.ReactNode; open: boolean }) => + open ?
{children}
: null, + DialogContent: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + DialogHeader: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + DialogTitle: ({ children }: { children: React.ReactNode }) => ( +

{children}

+ ), + DialogDescription: ({ children }: { children: React.ReactNode }) => ( +

{children}

+ ), + DialogTrigger: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), +})) + +vi.mock('@/components/ui/input', () => ({ + Input: ({ value, onChange, ...props }: any) => ( + + ), +})) + +vi.mock('@/components/ui/button', () => ({ + Button: ({ children, onClick, ...props }: any) => ( + + ), +})) + +// Mock other UI components +vi.mock('@tabler/icons-react', () => ({ + IconPencil: () =>
, + IconCheck: () =>
, + IconX: () =>
, + IconAlertTriangle: () =>
, + IconEye: () =>
, + IconTool: () =>
, + IconLoader2: () =>
, +})) + +describe('DialogEditModel - Basic Component Tests', () => { + const mockProvider = { + provider: 'llamacpp', + active: true, + models: [ + { + id: 'test-model.gguf', + displayName: 'My Custom Model', + capabilities: ['completion'], + }, + ], + settings: [], + } as any + + const mockUpdateProvider = vi.fn() + const mockSetProviders = vi.fn() + + beforeEach(() => { + vi.clearAllMocks() + + vi.mocked(useModelProvider).mockReturnValue({ + updateProvider: mockUpdateProvider, + setProviders: mockSetProviders, + } as any) + }) + + it('should render without errors', () => { + const { container } = render( + + ) + + // Component should render without throwing errors + expect(container).toBeInTheDocument() + }) + + it('should handle provider without models', () => { + const emptyProvider = { + ...mockProvider, + models: [], + } as any + + const { container } = render( + + ) + + // Component should handle empty models gracefully + expect(container).toBeInTheDocument() + }) + + it('should accept provider and modelId props', () => { + const { container } = render( + + ) + + expect(container).toBeInTheDocument() + }) + + it('should not crash with minimal props', () => { + const minimalProvider = { + provider: 'test', + active: false, + models: [], + settings: [], + } as any + + expect(() => { + render( + + ) + }).not.toThrow() + }) + + it('should have mocked dependencies available', () => { + render( + + ) + + // Verify our mocks are in place + expect(mockUpdateProvider).toBeDefined() + expect(mockSetProviders).toBeDefined() + }) +}) \ No newline at end of file diff --git a/web-app/src/containers/dialogs/EditModel.tsx b/web-app/src/containers/dialogs/EditModel.tsx index e1406f4f0..67576fbd6 100644 --- a/web-app/src/containers/dialogs/EditModel.tsx +++ b/web-app/src/containers/dialogs/EditModel.tsx @@ -23,7 +23,6 @@ import { } from '@tabler/icons-react' import { useState, useEffect } from 'react' import { useTranslation } from '@/i18n/react-i18next-compat' -import { useServiceHub } from '@/hooks/useServiceHub' import { toast } from 'sonner' // No need to define our own interface, we'll use the existing Model type @@ -37,16 +36,15 @@ export const DialogEditModel = ({ modelId, }: DialogEditModelProps) => { const { t } = useTranslation() - const { updateProvider, setProviders } = useModelProvider() + const { updateProvider } = useModelProvider() const [selectedModelId, setSelectedModelId] = useState('') - const [modelName, setModelName] = useState('') - const [originalModelName, setOriginalModelName] = useState('') + const [displayName, setDisplayName] = useState('') + const [originalDisplayName, setOriginalDisplayName] = useState('') const [originalCapabilities, setOriginalCapabilities] = useState< Record >({}) const [isOpen, setIsOpen] = useState(false) const [isLoading, setIsLoading] = useState(false) - const serviceHub = useServiceHub() const [capabilities, setCapabilities] = useState>({ completion: false, vision: false, @@ -82,11 +80,10 @@ export const DialogEditModel = ({ // Get the currently selected model const selectedModel = provider.models.find( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (m: any) => m.id === selectedModelId + (m: Model) => m.id === selectedModelId ) - // Initialize capabilities and model name from selected model + // Initialize capabilities and display name from selected model useEffect(() => { if (selectedModel) { const modelCapabilities = selectedModel.capabilities || [] @@ -98,9 +95,10 @@ export const DialogEditModel = ({ web_search: modelCapabilities.includes('web_search'), reasoning: modelCapabilities.includes('reasoning'), }) - const modelNameValue = selectedModel.id - setModelName(modelNameValue) - setOriginalModelName(modelNameValue) + // Use existing displayName if available, otherwise fall back to model ID + const displayNameValue = (selectedModel as Model & { displayName?: string }).displayName || selectedModel.id + setDisplayName(displayNameValue) + setOriginalDisplayName(displayNameValue) const originalCaps = { completion: modelCapabilities.includes('completion'), @@ -122,14 +120,14 @@ export const DialogEditModel = ({ })) } - // Handle model name change - const handleModelNameChange = (newName: string) => { - setModelName(newName) + // Handle display name change + const handleDisplayNameChange = (newName: string) => { + setDisplayName(newName) } // Check if there are unsaved changes const hasUnsavedChanges = () => { - const nameChanged = modelName !== originalModelName + const nameChanged = displayName !== originalDisplayName const capabilitiesChanged = JSON.stringify(capabilities) !== JSON.stringify(originalCapabilities) return nameChanged || capabilitiesChanged @@ -141,13 +139,21 @@ export const DialogEditModel = ({ setIsLoading(true) try { - // Update model name if changed - if (modelName !== originalModelName) { - await serviceHub - .models() - .updateModel(selectedModel.id, { id: modelName }) - setOriginalModelName(modelName) - await serviceHub.providers().getProviders().then(setProviders) + let updatedModels = provider.models + + // Update display name if changed + if (displayName !== originalDisplayName) { + // Update the model in the provider models array with displayName + updatedModels = updatedModels.map((m: Model) => { + if (m.id === selectedModelId) { + return { + ...m, + displayName: displayName, + } + } + return m + }) + setOriginalDisplayName(displayName) } // Update capabilities if changed @@ -159,8 +165,7 @@ export const DialogEditModel = ({ .map(([capName]) => capName) // Find and update the model in the provider - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const updatedModels = provider.models.map((m: any) => { + updatedModels = updatedModels.map((m: Model) => { if (m.id === selectedModelId) { return { ...m, @@ -172,15 +177,15 @@ export const DialogEditModel = ({ return m }) - // Update the provider with the updated models - updateProvider(provider.provider, { - ...provider, - models: updatedModels, - }) - setOriginalCapabilities(capabilities) } + // Update the provider with the updated models + updateProvider(provider.provider, { + ...provider, + models: updatedModels, + }) + // Show success toast and close dialog toast.success('Model updated successfully') setIsOpen(false) @@ -213,22 +218,25 @@ export const DialogEditModel = ({ - {/* Model Name Section */} + {/* Model Display Name Section */}
handleModelNameChange(e.target.value)} - placeholder="Enter model name" + id="display-name" + value={displayName} + onChange={(e) => handleDisplayNameChange(e.target.value)} + placeholder="Enter display name" className="w-full" disabled={isLoading} /> +

+ This is the name that will be shown in the interface. The original model file remains unchanged. +

{/* Warning Banner */} diff --git a/web-app/src/hooks/__tests__/useModelProvider.test.ts b/web-app/src/hooks/__tests__/useModelProvider.test.ts new file mode 100644 index 000000000..88272cf57 --- /dev/null +++ b/web-app/src/hooks/__tests__/useModelProvider.test.ts @@ -0,0 +1,182 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { act, renderHook } from '@testing-library/react' +import { useModelProvider } from '../useModelProvider' + +// Mock getServiceHub +vi.mock('@/hooks/useServiceHub', () => ({ + getServiceHub: vi.fn(() => ({ + path: () => ({ + sep: () => '/', + }), + })), +})) + +// Mock the localStorage key constants +vi.mock('@/constants/localStorage', () => ({ + localStorageKey: { + modelProvider: 'jan-model-provider', + }, +})) + +// Mock localStorage +const localStorageMock = { + getItem: vi.fn(), + setItem: vi.fn(), + removeItem: vi.fn(), + clear: vi.fn(), +} +Object.defineProperty(window, 'localStorage', { + value: localStorageMock, + writable: true, +}) + +describe('useModelProvider - displayName functionality', () => { + beforeEach(() => { + vi.clearAllMocks() + localStorageMock.getItem.mockReturnValue(null) + + // Reset Zustand store to default state + act(() => { + useModelProvider.setState({ + providers: [], + selectedProvider: 'llamacpp', + selectedModel: null, + deletedModels: [], + }) + }) + }) + + it('should handle models without displayName property', () => { + const { result } = renderHook(() => useModelProvider()) + + const provider = { + provider: 'llamacpp', + active: true, + models: [ + { + id: 'test-model.gguf', + capabilities: ['completion'], + }, + ], + settings: [], + } as any + + // First add the provider, then update it (since updateProvider only updates existing providers) + act(() => { + result.current.addProvider(provider) + }) + + const updatedProvider = result.current.getProviderByName('llamacpp') + expect(updatedProvider?.models[0].displayName).toBeUndefined() + expect(updatedProvider?.models[0].id).toBe('test-model.gguf') + }) + + it('should preserve displayName when merging providers in setProviders', () => { + const { result } = renderHook(() => useModelProvider()) + + // First, set up initial state with displayName via direct state manipulation + // This simulates the scenario where a user has already customized a display name + act(() => { + useModelProvider.setState({ + providers: [ + { + provider: 'llamacpp', + active: true, + models: [ + { + id: 'test-model.gguf', + displayName: 'My Custom Model', + capabilities: ['completion'], + }, + ], + settings: [], + }, + ] as any, + selectedProvider: 'llamacpp', + selectedModel: null, + deletedModels: [], + }) + }) + + // Now simulate setProviders with fresh data (like from server refresh) + const freshProviders = [ + { + provider: 'llamacpp', + active: true, + persist: true, + models: [ + { + id: 'test-model.gguf', + capabilities: ['completion'], + // Note: no displayName in fresh data + }, + ], + settings: [], + }, + ] as any + + act(() => { + result.current.setProviders(freshProviders) + }) + + // The displayName should be preserved from existing state + const provider = result.current.getProviderByName('llamacpp') + expect(provider?.models[0].displayName).toBe('My Custom Model') + }) + + it('should provide basic functionality without breaking existing behavior', () => { + const { result } = renderHook(() => useModelProvider()) + + // Test that basic provider operations work + expect(result.current.providers).toEqual([]) + expect(result.current.selectedProvider).toBe('llamacpp') + expect(result.current.selectedModel).toBeNull() + + // Test addProvider functionality + const provider = { + provider: 'openai', + active: true, + models: [], + settings: [], + } as any + + act(() => { + result.current.addProvider(provider) + }) + + expect(result.current.providers).toHaveLength(1) + expect(result.current.getProviderByName('openai')).toBeDefined() + }) + + it('should handle provider operations with models that have displayName', () => { + const { result } = renderHook(() => useModelProvider()) + + // Test that we can at least get and set providers with displayName models + const providerWithDisplayName = { + provider: 'llamacpp', + active: true, + models: [ + { + id: 'test-model.gguf', + displayName: 'Custom Model Name', + capabilities: ['completion'], + }, + ], + settings: [], + } as any + + // Set the state directly (simulating what would happen in real usage) + act(() => { + useModelProvider.setState({ + providers: [providerWithDisplayName], + selectedProvider: 'llamacpp', + selectedModel: null, + deletedModels: [], + }) + }) + + const provider = result.current.getProviderByName('llamacpp') + expect(provider?.models[0].displayName).toBe('Custom Model Name') + expect(provider?.models[0].id).toBe('test-model.gguf') + }) +}) \ No newline at end of file diff --git a/web-app/src/hooks/useModelProvider.ts b/web-app/src/hooks/useModelProvider.ts index bd3dbc49b..a0b5a96ce 100644 --- a/web-app/src/hooks/useModelProvider.ts +++ b/web-app/src/hooks/useModelProvider.ts @@ -104,6 +104,7 @@ export const useModelProvider = create()( ...model, settings: settings, capabilities: existingModel?.capabilities || model.capabilities, + displayName: existingModel?.displayName || model.displayName, } }) diff --git a/web-app/src/lib/__tests__/utils.test.ts b/web-app/src/lib/__tests__/utils.test.ts index 25bc91334..33c51447e 100644 --- a/web-app/src/lib/__tests__/utils.test.ts +++ b/web-app/src/lib/__tests__/utils.test.ts @@ -6,6 +6,7 @@ import { toGigabytes, formatMegaBytes, formatDuration, + getModelDisplayName, } from '../utils' describe('getProviderLogo', () => { @@ -200,3 +201,52 @@ describe('formatDuration', () => { expect(formatDuration(start, 86400000)).toBe('1d 0h 0m 0s') // exactly 1 day }) }) + +describe('getModelDisplayName', () => { + it('returns displayName when it exists', () => { + const model = { + id: 'llama-3.2-1b-instruct-q4_k_m.gguf', + displayName: 'My Custom Model', + } as Model + expect(getModelDisplayName(model)).toBe('My Custom Model') + }) + + it('returns model.id when displayName is undefined', () => { + const model = { + id: 'llama-3.2-1b-instruct-q4_k_m.gguf', + } as Model + expect(getModelDisplayName(model)).toBe('llama-3.2-1b-instruct-q4_k_m.gguf') + }) + + it('returns model.id when displayName is empty string', () => { + const model = { + id: 'llama-3.2-1b-instruct-q4_k_m.gguf', + displayName: '', + } as Model + expect(getModelDisplayName(model)).toBe('llama-3.2-1b-instruct-q4_k_m.gguf') + }) + + it('returns model.id when displayName is null', () => { + const model = { + id: 'llama-3.2-1b-instruct-q4_k_m.gguf', + displayName: null as any, + } as Model + expect(getModelDisplayName(model)).toBe('llama-3.2-1b-instruct-q4_k_m.gguf') + }) + + it('handles models with complex display names', () => { + const model = { + id: 'very-long-model-file-name-with-lots-of-details.gguf', + displayName: 'Short Name 🤖', + } as Model + expect(getModelDisplayName(model)).toBe('Short Name 🤖') + }) + + it('handles models with special characters in displayName', () => { + const model = { + id: 'model.gguf', + displayName: 'Model (Version 2.0) - Fine-tuned', + } as Model + expect(getModelDisplayName(model)).toBe('Model (Version 2.0) - Fine-tuned') + }) +}) diff --git a/web-app/src/lib/utils.ts b/web-app/src/lib/utils.ts index d34937b04..9b4c8a973 100644 --- a/web-app/src/lib/utils.ts +++ b/web-app/src/lib/utils.ts @@ -7,7 +7,6 @@ export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) } - export function basenameNoExt(filePath: string): string { const base = path.basename(filePath); const VALID_EXTENSIONS = [".tar.gz", ".zip"]; @@ -23,6 +22,13 @@ export function basenameNoExt(filePath: string): string { return base.slice(0, -path.extname(base).length); } +/** + * Get the display name for a model, falling back to the model ID if no display name is set + */ +export function getModelDisplayName(model: Model): string { + return model.displayName || model.id +} + export function getProviderLogo(provider: string) { switch (provider) { case 'jan': diff --git a/web-app/src/routes/settings/providers/$providerName.tsx b/web-app/src/routes/settings/providers/$providerName.tsx index 0b29c7bd3..2ee868a1c 100644 --- a/web-app/src/routes/settings/providers/$providerName.tsx +++ b/web-app/src/routes/settings/providers/$providerName.tsx @@ -3,7 +3,7 @@ import { Card, CardItem } from '@/containers/Card' import HeaderPage from '@/containers/HeaderPage' import SettingsMenu from '@/containers/SettingsMenu' import { useModelProvider } from '@/hooks/useModelProvider' -import { cn, getProviderTitle } from '@/lib/utils' +import { cn, getProviderTitle, getModelDisplayName } from '@/lib/utils' import { createFileRoute, Link, @@ -767,7 +767,7 @@ function ProviderDetail() { className="font-medium line-clamp-1" title={model.id} > - {model.id} + {getModelDisplayName(model)}
diff --git a/web-app/src/services/__tests__/models.test.ts b/web-app/src/services/__tests__/models.test.ts index 4322cfe40..d0c9daa2d 100644 --- a/web-app/src/services/__tests__/models.test.ts +++ b/web-app/src/services/__tests__/models.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' import { DefaultModelsService } from '../models/default' import type { HuggingFaceRepo, CatalogModel } from '../models/types' -import { EngineManager, Model } from '@janhq/core' +import { EngineManager } from '@janhq/core' // Mock EngineManager vi.mock('@janhq/core', () => ({ @@ -131,18 +131,19 @@ describe('DefaultModelsService', () => { expect(mockEngine.update).not.toHaveBeenCalled() }) - it('should update model when modelId differs from model.id', async () => { + it('should handle model when modelId differs from model.id', async () => { const modelId = 'old-model-id' const model = { id: 'new-model-id', settings: [{ key: 'temperature', value: 0.7 }], } - mockEngine.update.mockResolvedValue(undefined) await modelsService.updateModel(modelId, model as any) expect(mockEngine.updateSettings).toHaveBeenCalledWith(model.settings) - expect(mockEngine.update).toHaveBeenCalledWith(modelId, model) + // Note: Model ID updates are now handled at the provider level in the frontend + // The engine no longer has an update method for model metadata + expect(mockEngine.update).not.toHaveBeenCalled() }) }) diff --git a/web-app/src/services/models/default.ts b/web-app/src/services/models/default.ts index 39c80f551..746f869d1 100644 --- a/web-app/src/services/models/default.ts +++ b/web-app/src/services/models/default.ts @@ -163,15 +163,14 @@ export class DefaultModelsService implements ModelsService { } async updateModel(modelId: string, model: Partial): Promise { - if (model.settings) + if (model.settings) { this.getEngine()?.updateSettings( model.settings as SettingComponentProps[] ) - if (modelId !== model.id) { - await this.getEngine() - ?.update(modelId, model) - .then(() => console.log('Model updated successfully')) } + // Note: Model name/ID updates are handled at the provider level in the frontend + // The engine doesn't have an update method for model metadata + console.log('Model update request processed for modelId:', modelId) } async pullModel( diff --git a/web-app/src/types/modelProviders.d.ts b/web-app/src/types/modelProviders.d.ts index eb035e471..93cdd0df2 100644 --- a/web-app/src/types/modelProviders.d.ts +++ b/web-app/src/types/modelProviders.d.ts @@ -28,6 +28,7 @@ type Model = { id: string model?: string name?: string + displayName?: string version?: number | string description?: string format?: string From 2679b19e324e5820ac92ce36406bc7a64414af20 Mon Sep 17 00:00:00 2001 From: Faisal Amir Date: Wed, 1 Oct 2025 11:04:28 +0700 Subject: [PATCH 02/12] fix: local api server auto start first model when missing last used --- .../src/containers/DropdownModelProvider.tsx | 11 +- web-app/src/locales/de-DE/settings.json | 6 + web-app/src/locales/en/settings.json | 5 +- web-app/src/locales/id/settings.json | 6 + web-app/src/locales/pl/settings.json | 5 +- web-app/src/locales/vn/settings.json | 6 + web-app/src/locales/zh-CN/settings.json | 6 + web-app/src/locales/zh-TW/settings.json | 6 + web-app/src/providers/DataProvider.tsx | 71 +++--------- .../src/routes/settings/local-api-server.tsx | 105 ++++++++---------- web-app/src/utils/getModelToStart.ts | 69 ++++++++++++ 11 files changed, 168 insertions(+), 128 deletions(-) create mode 100644 web-app/src/utils/getModelToStart.ts diff --git a/web-app/src/containers/DropdownModelProvider.tsx b/web-app/src/containers/DropdownModelProvider.tsx index a8614f89d..b36f634f1 100644 --- a/web-app/src/containers/DropdownModelProvider.tsx +++ b/web-app/src/containers/DropdownModelProvider.tsx @@ -24,6 +24,7 @@ import { predefinedProviders } from '@/consts/providers' import { useServiceHub } from '@/hooks/useServiceHub' import { PlatformFeatures } from '@/lib/platform/const' import { PlatformFeature } from '@/lib/platform/types' +import { getLastUsedModel } from '@/utils/getModelToStart' type DropdownModelProviderProps = { model?: ThreadModel @@ -39,16 +40,6 @@ interface SearchableModel { } // Helper functions for localStorage -const getLastUsedModel = (): { provider: string; model: string } | null => { - try { - const stored = localStorage.getItem(localStorageKey.lastUsedModel) - return stored ? JSON.parse(stored) : null - } catch (error) { - console.debug('Failed to get last used model from localStorage:', error) - return null - } -} - const setLastUsedModel = (provider: string, model: string) => { try { localStorage.setItem( diff --git a/web-app/src/locales/de-DE/settings.json b/web-app/src/locales/de-DE/settings.json index ec1429353..6f0374ed7 100644 --- a/web-app/src/locales/de-DE/settings.json +++ b/web-app/src/locales/de-DE/settings.json @@ -169,6 +169,12 @@ "serverLogs": "Server Logs", "serverLogsDesc": "Zeige detaillierte Logs des lokalen API-Servers an.", "openLogs": "Logs öffnen", + "swaggerDocs": "API-Dokumentation", + "swaggerDocsDesc": "Zeige interaktive API-Dokumentation (Swagger UI) an.", + "openDocs": "Dokumentation öffnen", + "startupConfiguration": "Startkonfiguration", + "runOnStartup": "Standardmäßig beim Start aktivieren", + "runOnStartupDesc": "Starte den lokalen API-Server automatisch beim Anwendungsstart. Verwendet das zuletzt verwendete Modell oder wählt das erste verfügbare Modell, falls nicht verfügbar.", "serverConfiguration": "Server Konfiguration", "serverHost": "Server Host", "serverHostDesc": "Netzwerkadresse für den Server.", diff --git a/web-app/src/locales/en/settings.json b/web-app/src/locales/en/settings.json index afc6d6a47..aebb43d92 100644 --- a/web-app/src/locales/en/settings.json +++ b/web-app/src/locales/en/settings.json @@ -169,9 +169,12 @@ "serverLogs": "Server Logs", "serverLogsDesc": "View detailed logs of the local API server.", "openLogs": "Open Logs", + "swaggerDocs": "API Documentation", + "swaggerDocsDesc": "View interactive API documentation (Swagger UI).", + "openDocs": "Open Docs", "startupConfiguration": "Startup Configuration", "runOnStartup": "Enable by default on startup", - "runOnStartupDesc": "Automatically start the Local API Server when the application launches.", + "runOnStartupDesc": "Automatically start the Local API Server when the application launches. Uses last used model, or picks the first available model if unavailable.", "serverConfiguration": "Server Configuration", "serverHost": "Server Host", "serverHostDesc": "Network address for the server.", diff --git a/web-app/src/locales/id/settings.json b/web-app/src/locales/id/settings.json index d6da82b7f..a0c702c55 100644 --- a/web-app/src/locales/id/settings.json +++ b/web-app/src/locales/id/settings.json @@ -167,6 +167,12 @@ "serverLogs": "Log Server", "serverLogsDesc": "Lihat log terperinci dari server API lokal.", "openLogs": "Buka Log", + "swaggerDocs": "Dokumentasi API", + "swaggerDocsDesc": "Lihat dokumentasi API interaktif (Swagger UI).", + "openDocs": "Buka Dokumentasi", + "startupConfiguration": "Konfigurasi Startup", + "runOnStartup": "Aktifkan secara default saat startup", + "runOnStartupDesc": "Mulai Server API Lokal secara otomatis saat aplikasi diluncurkan. Menggunakan model terakhir yang digunakan, atau memilih model pertama yang tersedia jika tidak tersedia.", "serverConfiguration": "Konfigurasi Server", "serverHost": "Host Server", "serverHostDesc": "Alamat jaringan untuk server.", diff --git a/web-app/src/locales/pl/settings.json b/web-app/src/locales/pl/settings.json index 94de1c36c..aeda400a2 100644 --- a/web-app/src/locales/pl/settings.json +++ b/web-app/src/locales/pl/settings.json @@ -167,9 +167,12 @@ "serverLogs": "Dzienniki Serwera", "serverLogsDesc": "Wyświetl szczegółowe dzienniki lokalnego serwera API.", "openLogs": "Otwórz Dzienniki", + "swaggerDocs": "Dokumentacja API", + "swaggerDocsDesc": "Wyświetl interaktywną dokumentację API (Swagger UI).", + "openDocs": "Otwórz Dokumentację", "startupConfiguration": "Konfiguracja Startowa", "runOnStartup": "Domyślnie włączaj przy starcie", - "runOnStartupDesc": "Automatycznie uruchamiaj lokalny serwer API podczas uruchamiania aplikacji.", + "runOnStartupDesc": "Automatycznie uruchamiaj lokalny serwer API podczas uruchamiania aplikacji. Używa ostatnio używanego modelu lub wybiera pierwszy dostępny model, jeśli nie jest dostępny.", "serverConfiguration": "Konfiguracja Serwera", "serverHost": "Host", "serverHostDesc": "Adres sieciowy serwera.", diff --git a/web-app/src/locales/vn/settings.json b/web-app/src/locales/vn/settings.json index f1b6ba22e..931d9d70f 100644 --- a/web-app/src/locales/vn/settings.json +++ b/web-app/src/locales/vn/settings.json @@ -169,6 +169,12 @@ "serverLogs": "Nhật ký máy chủ", "serverLogsDesc": "Xem nhật ký chi tiết của máy chủ API cục bộ.", "openLogs": "Mở nhật ký", + "swaggerDocs": "Tài liệu API", + "swaggerDocsDesc": "Xem tài liệu API tương tác (Swagger UI).", + "openDocs": "Mở tài liệu", + "startupConfiguration": "Cấu hình khởi động", + "runOnStartup": "Bật mặc định khi khởi động", + "runOnStartupDesc": "Tự động khởi động Máy chủ API Cục bộ khi ứng dụng khởi chạy. Sử dụng mô hình đã dùng gần nhất hoặc chọn mô hình đầu tiên có sẵn nếu không khả dụng.", "serverConfiguration": "Cấu hình máy chủ", "serverHost": "Máy chủ lưu trữ", "serverHostDesc": "Địa chỉ mạng cho máy chủ.", diff --git a/web-app/src/locales/zh-CN/settings.json b/web-app/src/locales/zh-CN/settings.json index 82a39ab66..62e7670c9 100644 --- a/web-app/src/locales/zh-CN/settings.json +++ b/web-app/src/locales/zh-CN/settings.json @@ -169,6 +169,12 @@ "serverLogs": "服务器日志", "serverLogsDesc": "查看本地 API 服务器的详细日志。", "openLogs": "打开日志", + "swaggerDocs": "API 文档", + "swaggerDocsDesc": "查看交互式 API 文档(Swagger UI)。", + "openDocs": "打开文档", + "startupConfiguration": "启动配置", + "runOnStartup": "默认在启动时启用", + "runOnStartupDesc": "应用程序启动时自动启动本地 API 服务器。使用上次使用的模型,如果不可用则选择第一个可用模型。", "serverConfiguration": "服务器配置", "serverHost": "服务器主机", "serverHostDesc": "服务器的网络地址。", diff --git a/web-app/src/locales/zh-TW/settings.json b/web-app/src/locales/zh-TW/settings.json index bc2029f07..bf56f0220 100644 --- a/web-app/src/locales/zh-TW/settings.json +++ b/web-app/src/locales/zh-TW/settings.json @@ -167,6 +167,12 @@ "serverLogs": "伺服器日誌", "serverLogsDesc": "檢視本機 API 伺服器的詳細日誌。", "openLogs": "開啟日誌", + "swaggerDocs": "API 文件", + "swaggerDocsDesc": "查看互動式 API 文件(Swagger UI)。", + "openDocs": "開啟文件", + "startupConfiguration": "啟動設定", + "runOnStartup": "預設在啟動時啟用", + "runOnStartupDesc": "應用程式啟動時自動啟動本機 API 伺服器。使用上次使用的模型,如果不可用則選擇第一個可用模型。", "serverConfiguration": "伺服器設定", "serverHost": "伺服器主機", "serverHostDesc": "伺服器的網路位址。", diff --git a/web-app/src/providers/DataProvider.tsx b/web-app/src/providers/DataProvider.tsx index 934dde1dd..b8c928485 100644 --- a/web-app/src/providers/DataProvider.tsx +++ b/web-app/src/providers/DataProvider.tsx @@ -12,8 +12,8 @@ import { useThreads } from '@/hooks/useThreads' import { useLocalApiServer } from '@/hooks/useLocalApiServer' import { useAppState } from '@/hooks/useAppState' import { AppEvent, events } from '@janhq/core' -import { localStorageKey } from '@/constants/localStorage' import { SystemEvent } from '@/types/events' +import { getModelToStart } from '@/utils/getModelToStart' export function DataProvider() { const { setProviders, selectedModel, selectedProvider, getProviderByName } = @@ -66,12 +66,15 @@ export function DataProvider() { // Listen for deep link events let unsubscribe = () => {} - serviceHub.events().listen(SystemEvent.DEEP_LINK, (event) => { - const deep_link = event.payload as string - handleDeepLink([deep_link]) - }).then((unsub) => { - unsubscribe = unsub - }) + serviceHub + .events() + .listen(SystemEvent.DEEP_LINK, (event) => { + const deep_link = event.payload as string + handleDeepLink([deep_link]) + }) + .then((unsub) => { + unsubscribe = unsub + }) return () => { unsubscribe() } @@ -109,54 +112,6 @@ export function DataProvider() { }) }, [serviceHub, setProviders]) - const getLastUsedModel = (): { provider: string; model: string } | null => { - try { - const stored = localStorage.getItem(localStorageKey.lastUsedModel) - return stored ? JSON.parse(stored) : null - } catch (error) { - console.debug('Failed to get last used model from localStorage:', error) - return null - } - } - - // Helper function to determine which model to start - const getModelToStart = () => { - // Use last used model if available - const lastUsedModel = getLastUsedModel() - if (lastUsedModel) { - const provider = getProviderByName(lastUsedModel.provider) - if ( - provider && - provider.models.some((m) => m.id === lastUsedModel.model) - ) { - return { model: lastUsedModel.model, provider } - } - } - - // Use selected model if available - if (selectedModel && selectedProvider) { - const provider = getProviderByName(selectedProvider) - if (provider) { - return { model: selectedModel.id, provider } - } - } - - // Use first model from llamacpp provider - const llamacppProvider = getProviderByName('llamacpp') - if ( - llamacppProvider && - llamacppProvider.models && - llamacppProvider.models.length > 0 - ) { - return { - model: llamacppProvider.models[0].id, - provider: llamacppProvider, - } - } - - return null - } - // Auto-start Local API Server on app startup if enabled useEffect(() => { if (enableOnStartup) { @@ -166,7 +121,11 @@ export function DataProvider() { return } - const modelToStart = getModelToStart() + const modelToStart = getModelToStart({ + selectedModel, + selectedProvider, + getProviderByName, + }) // Only start server if we have a model to load if (!modelToStart) { diff --git a/web-app/src/routes/settings/local-api-server.tsx b/web-app/src/routes/settings/local-api-server.tsx index f3bb3c349..e8f41177b 100644 --- a/web-app/src/routes/settings/local-api-server.tsx +++ b/web-app/src/routes/settings/local-api-server.tsx @@ -15,7 +15,6 @@ import { useLocalApiServer } from '@/hooks/useLocalApiServer' import { useAppState } from '@/hooks/useAppState' import { useModelProvider } from '@/hooks/useModelProvider' import { useServiceHub } from '@/hooks/useServiceHub' -import { localStorageKey } from '@/constants/localStorage' import { IconLogs } from '@tabler/icons-react' import { cn } from '@/lib/utils' import { ApiKeyInput } from '@/containers/ApiKeyInput' @@ -23,6 +22,7 @@ import { useEffect, useState } from 'react' import { PlatformGuard } from '@/lib/platform/PlatformGuard' import { PlatformFeature } from '@/lib/platform' import { toast } from 'sonner' +import { getModelToStart } from '@/utils/getModelToStart' // eslint-disable-next-line @typescript-eslint/no-explicit-any export const Route = createFileRoute(route.settings.local_api_server as any)({ @@ -81,54 +81,6 @@ function LocalAPIServerContent() { setIsApiKeyEmpty(!isValid) } - const getLastUsedModel = (): { provider: string; model: string } | null => { - try { - const stored = localStorage.getItem(localStorageKey.lastUsedModel) - return stored ? JSON.parse(stored) : null - } catch (error) { - console.debug('Failed to get last used model from localStorage:', error) - return null - } - } - - // Helper function to determine which model to start - const getModelToStart = () => { - // Use last used model if available - const lastUsedModel = getLastUsedModel() - if (lastUsedModel) { - const provider = getProviderByName(lastUsedModel.provider) - if ( - provider && - provider.models.some((m) => m.id === lastUsedModel.model) - ) { - return { model: lastUsedModel.model, provider } - } - } - - // Use selected model if available - if (selectedModel && selectedProvider) { - const provider = getProviderByName(selectedProvider) - if (provider) { - return { model: selectedModel.id, provider } - } - } - - // Use first model from llamacpp provider - const llamacppProvider = getProviderByName('llamacpp') - if ( - llamacppProvider && - llamacppProvider.models && - llamacppProvider.models.length > 0 - ) { - return { - model: llamacppProvider.models[0].id, - provider: llamacppProvider, - } - } - - return null - } - const [isModelLoading, setIsModelLoading] = useState(false) const toggleAPIServer = async () => { @@ -136,7 +88,7 @@ function LocalAPIServerContent() { if (serverStatus === 'stopped') { console.log('Starting server with port:', serverPort) toast.info('Starting server...', { - description: `Attempting to start server on port ${serverPort}` + description: `Attempting to start server on port ${serverPort}`, }) if (!apiKey || apiKey.toString().trim().length === 0) { @@ -145,7 +97,11 @@ function LocalAPIServerContent() { } setShowApiKeyError(false) - const modelToStart = getModelToStart() + const modelToStart = getModelToStart({ + selectedModel, + selectedProvider, + getProviderByName, + }) // Only start server if we have a model to load if (!modelToStart) { console.warn( @@ -191,31 +147,31 @@ function LocalAPIServerContent() { toast.dismiss() // Extract error message from various error formats - const errorMsg = error && typeof error === 'object' && 'message' in error - ? String(error.message) - : String(error) + const errorMsg = + error && typeof error === 'object' && 'message' in error + ? String(error.message) + : String(error) // Port-related errors (highest priority) if (errorMsg.includes('Address already in use')) { toast.error('Port has been occupied', { - description: `Port ${serverPort} is already in use. Please try a different port.` + description: `Port ${serverPort} is already in use. Please try a different port.`, }) } // Model-related errors else if (errorMsg.includes('Invalid or inaccessible model path')) { toast.error('Invalid or inaccessible model path', { - description: errorMsg + description: errorMsg, }) - } - else if (errorMsg.includes('model')) { + } else if (errorMsg.includes('model')) { toast.error('Failed to start model', { - description: errorMsg + description: errorMsg, }) } // Generic server errors else { toast.error('Failed to start server', { - description: errorMsg + description: errorMsg, }) } }) @@ -307,6 +263,35 @@ function LocalAPIServerContent() { } /> + + + + } + /> {/* Startup Configuration */} diff --git a/web-app/src/utils/getModelToStart.ts b/web-app/src/utils/getModelToStart.ts new file mode 100644 index 000000000..bea719ec0 --- /dev/null +++ b/web-app/src/utils/getModelToStart.ts @@ -0,0 +1,69 @@ +import { localStorageKey } from '@/constants/localStorage' +import type { ModelInfo } from '@janhq/core' + +export const getLastUsedModel = (): { + provider: string + model: string +} | null => { + try { + const stored = localStorage.getItem(localStorageKey.lastUsedModel) + return stored ? JSON.parse(stored) : null + } catch (error) { + console.debug('Failed to get last used model from localStorage:', error) + return null + } +} + +// Helper function to determine which model to start +export const getModelToStart = (params: { + selectedModel?: ModelInfo | null + selectedProvider?: string | null + getProviderByName: (name: string) => ModelProvider | undefined +}): { model: string; provider: ModelProvider } | null => { + const { selectedModel, selectedProvider, getProviderByName } = params + + // Use last used model if available + const lastUsedModel = getLastUsedModel() + if (lastUsedModel) { + const provider = getProviderByName(lastUsedModel.provider) + if (provider && provider.models.some((m) => m.id === lastUsedModel.model)) { + return { model: lastUsedModel.model, provider } + } else { + // Last used model not found under provider, fallback to first llamacpp model + const llamacppProvider = getProviderByName('llamacpp') + if ( + llamacppProvider && + llamacppProvider.models && + llamacppProvider.models.length > 0 + ) { + return { + model: llamacppProvider.models[0].id, + provider: llamacppProvider, + } + } + } + } + + // Use selected model if available + if (selectedModel && selectedProvider) { + const provider = getProviderByName(selectedProvider) + if (provider) { + return { model: selectedModel.id, provider } + } + } + + // Use first model from llamacpp provider + const llamacppProvider = getProviderByName('llamacpp') + if ( + llamacppProvider && + llamacppProvider.models && + llamacppProvider.models.length > 0 + ) { + return { + model: llamacppProvider.models[0].id, + provider: llamacppProvider, + } + } + + return null +} From 199623b4146cacdcd39f2072b8f4161f5c740c48 Mon Sep 17 00:00:00 2001 From: Faisal Amir Date: Wed, 1 Oct 2025 11:23:59 +0700 Subject: [PATCH 03/12] chore: clear flow loacl api server --- .../src/routes/settings/local-api-server.tsx | 57 ++++++++++++------- 1 file changed, 36 insertions(+), 21 deletions(-) diff --git a/web-app/src/routes/settings/local-api-server.tsx b/web-app/src/routes/settings/local-api-server.tsx index e8f41177b..396273f11 100644 --- a/web-app/src/routes/settings/local-api-server.tsx +++ b/web-app/src/routes/settings/local-api-server.tsx @@ -97,32 +97,47 @@ function LocalAPIServerContent() { } setShowApiKeyError(false) - const modelToStart = getModelToStart({ - selectedModel, - selectedProvider, - getProviderByName, - }) - // Only start server if we have a model to load - if (!modelToStart) { - console.warn( - 'Cannot start Local API Server: No model available to load' - ) - return - } - setServerStatus('pending') - setIsModelLoading(true) // Start loading state - // Start the model first + // Check if there's already a loaded model serviceHub .models() - .startModel(modelToStart.provider, modelToStart.model) - .then(() => { - console.log(`Model ${modelToStart.model} started successfully`) - setIsModelLoading(false) // Model loaded, stop loading state + .getActiveModels() + .then((loadedModels) => { + if (loadedModels && loadedModels.length > 0) { + console.log(`Using already loaded model: ${loadedModels[0]}`) + // Model already loaded, just start the server + return Promise.resolve() + } else { + // No loaded model, start one first + const modelToStart = getModelToStart({ + selectedModel, + selectedProvider, + getProviderByName, + }) - // Add a small delay for the backend to update state - return new Promise((resolve) => setTimeout(resolve, 500)) + // Only start server if we have a model to load + if (!modelToStart) { + console.warn( + 'Cannot start Local API Server: No model available to load' + ) + throw new Error('No model available to load') + } + + setIsModelLoading(true) // Start loading state + + // Start the model first + return serviceHub + .models() + .startModel(modelToStart.provider, modelToStart.model) + .then(() => { + console.log(`Model ${modelToStart.model} started successfully`) + setIsModelLoading(false) // Model loaded, stop loading state + + // Add a small delay for the backend to update state + return new Promise((resolve) => setTimeout(resolve, 500)) + }) + } }) .then(() => { // Then start the server From d1021650282ea39e0a7b1258a80074acde0c8a19 Mon Sep 17 00:00:00 2001 From: Faisal Amir Date: Wed, 1 Oct 2025 11:44:49 +0700 Subject: [PATCH 04/12] chore: move auto start server setting --- web-app/src/locales/de-DE/settings.json | 2 +- web-app/src/locales/en/settings.json | 2 +- web-app/src/locales/id/settings.json | 2 +- web-app/src/locales/pl/settings.json | 2 +- web-app/src/locales/vn/settings.json | 2 +- web-app/src/locales/zh-CN/settings.json | 2 +- web-app/src/locales/zh-TW/settings.json | 2 +- .../src/routes/settings/local-api-server.tsx | 37 +++++++++---------- 8 files changed, 24 insertions(+), 27 deletions(-) diff --git a/web-app/src/locales/de-DE/settings.json b/web-app/src/locales/de-DE/settings.json index 6f0374ed7..c57667679 100644 --- a/web-app/src/locales/de-DE/settings.json +++ b/web-app/src/locales/de-DE/settings.json @@ -173,7 +173,7 @@ "swaggerDocsDesc": "Zeige interaktive API-Dokumentation (Swagger UI) an.", "openDocs": "Dokumentation öffnen", "startupConfiguration": "Startkonfiguration", - "runOnStartup": "Standardmäßig beim Start aktivieren", + "runOnStartup": "Auto start", "runOnStartupDesc": "Starte den lokalen API-Server automatisch beim Anwendungsstart. Verwendet das zuletzt verwendete Modell oder wählt das erste verfügbare Modell, falls nicht verfügbar.", "serverConfiguration": "Server Konfiguration", "serverHost": "Server Host", diff --git a/web-app/src/locales/en/settings.json b/web-app/src/locales/en/settings.json index aebb43d92..be19a8cef 100644 --- a/web-app/src/locales/en/settings.json +++ b/web-app/src/locales/en/settings.json @@ -173,7 +173,7 @@ "swaggerDocsDesc": "View interactive API documentation (Swagger UI).", "openDocs": "Open Docs", "startupConfiguration": "Startup Configuration", - "runOnStartup": "Enable by default on startup", + "runOnStartup": "Auto start", "runOnStartupDesc": "Automatically start the Local API Server when the application launches. Uses last used model, or picks the first available model if unavailable.", "serverConfiguration": "Server Configuration", "serverHost": "Server Host", diff --git a/web-app/src/locales/id/settings.json b/web-app/src/locales/id/settings.json index a0c702c55..e1439209b 100644 --- a/web-app/src/locales/id/settings.json +++ b/web-app/src/locales/id/settings.json @@ -171,7 +171,7 @@ "swaggerDocsDesc": "Lihat dokumentasi API interaktif (Swagger UI).", "openDocs": "Buka Dokumentasi", "startupConfiguration": "Konfigurasi Startup", - "runOnStartup": "Aktifkan secara default saat startup", + "runOnStartup": "Auto start", "runOnStartupDesc": "Mulai Server API Lokal secara otomatis saat aplikasi diluncurkan. Menggunakan model terakhir yang digunakan, atau memilih model pertama yang tersedia jika tidak tersedia.", "serverConfiguration": "Konfigurasi Server", "serverHost": "Host Server", diff --git a/web-app/src/locales/pl/settings.json b/web-app/src/locales/pl/settings.json index aeda400a2..37c29c8ee 100644 --- a/web-app/src/locales/pl/settings.json +++ b/web-app/src/locales/pl/settings.json @@ -171,7 +171,7 @@ "swaggerDocsDesc": "Wyświetl interaktywną dokumentację API (Swagger UI).", "openDocs": "Otwórz Dokumentację", "startupConfiguration": "Konfiguracja Startowa", - "runOnStartup": "Domyślnie włączaj przy starcie", + "runOnStartup": "Auto start", "runOnStartupDesc": "Automatycznie uruchamiaj lokalny serwer API podczas uruchamiania aplikacji. Używa ostatnio używanego modelu lub wybiera pierwszy dostępny model, jeśli nie jest dostępny.", "serverConfiguration": "Konfiguracja Serwera", "serverHost": "Host", diff --git a/web-app/src/locales/vn/settings.json b/web-app/src/locales/vn/settings.json index 931d9d70f..74503b602 100644 --- a/web-app/src/locales/vn/settings.json +++ b/web-app/src/locales/vn/settings.json @@ -173,7 +173,7 @@ "swaggerDocsDesc": "Xem tài liệu API tương tác (Swagger UI).", "openDocs": "Mở tài liệu", "startupConfiguration": "Cấu hình khởi động", - "runOnStartup": "Bật mặc định khi khởi động", + "runOnStartup": "Auto start", "runOnStartupDesc": "Tự động khởi động Máy chủ API Cục bộ khi ứng dụng khởi chạy. Sử dụng mô hình đã dùng gần nhất hoặc chọn mô hình đầu tiên có sẵn nếu không khả dụng.", "serverConfiguration": "Cấu hình máy chủ", "serverHost": "Máy chủ lưu trữ", diff --git a/web-app/src/locales/zh-CN/settings.json b/web-app/src/locales/zh-CN/settings.json index 62e7670c9..be81820d9 100644 --- a/web-app/src/locales/zh-CN/settings.json +++ b/web-app/src/locales/zh-CN/settings.json @@ -173,7 +173,7 @@ "swaggerDocsDesc": "查看交互式 API 文档(Swagger UI)。", "openDocs": "打开文档", "startupConfiguration": "启动配置", - "runOnStartup": "默认在启动时启用", + "runOnStartup": "Auto start", "runOnStartupDesc": "应用程序启动时自动启动本地 API 服务器。使用上次使用的模型,如果不可用则选择第一个可用模型。", "serverConfiguration": "服务器配置", "serverHost": "服务器主机", diff --git a/web-app/src/locales/zh-TW/settings.json b/web-app/src/locales/zh-TW/settings.json index bf56f0220..aed446974 100644 --- a/web-app/src/locales/zh-TW/settings.json +++ b/web-app/src/locales/zh-TW/settings.json @@ -171,7 +171,7 @@ "swaggerDocsDesc": "查看互動式 API 文件(Swagger UI)。", "openDocs": "開啟文件", "startupConfiguration": "啟動設定", - "runOnStartup": "預設在啟動時啟用", + "runOnStartup": "Auto start", "runOnStartupDesc": "應用程式啟動時自動啟動本機 API 伺服器。使用上次使用的模型,如果不可用則選擇第一個可用模型。", "serverConfiguration": "伺服器設定", "serverHost": "伺服器主機", diff --git a/web-app/src/routes/settings/local-api-server.tsx b/web-app/src/routes/settings/local-api-server.tsx index 396273f11..d8ac1de4f 100644 --- a/web-app/src/routes/settings/local-api-server.tsx +++ b/web-app/src/routes/settings/local-api-server.tsx @@ -260,6 +260,22 @@ function LocalAPIServerContent() {
} > + { + if (!apiKey || apiKey.toString().trim().length === 0) { + setShowApiKeyError(true) + return + } + setEnableOnStartup(checked) + }} + /> + } + /> } /> + - {/* Startup Configuration */} - - { - if (!apiKey || apiKey.toString().trim().length === 0) { - setShowApiKeyError(true) - return - } - setEnableOnStartup(checked) - }} - /> - } - /> - - {/* Server Configuration */} Date: Wed, 1 Oct 2025 14:00:55 +0700 Subject: [PATCH 05/12] fix: dropdown type assistant --- web-app/src/components/ui/dropdown-menu.tsx | 2 +- web-app/src/containers/dialogs/AddEditAssistant.tsx | 6 +----- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/web-app/src/components/ui/dropdown-menu.tsx b/web-app/src/components/ui/dropdown-menu.tsx index 7a527aaca..6c98a6f58 100644 --- a/web-app/src/components/ui/dropdown-menu.tsx +++ b/web-app/src/components/ui/dropdown-menu.tsx @@ -41,7 +41,7 @@ function DropdownMenuContent({ data-slot="dropdown-menu-content" sideOffset={sideOffset} className={cn( - 'bg-main-view select-none text-main-view-fg border-main-view-fg/5 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-[51] max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md', + 'bg-main-view select-none text-main-view-fg border-main-view-fg/5 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md z-[90]', className )} {...props} diff --git a/web-app/src/containers/dialogs/AddEditAssistant.tsx b/web-app/src/containers/dialogs/AddEditAssistant.tsx index 328064e48..d7f7beff8 100644 --- a/web-app/src/containers/dialogs/AddEditAssistant.tsx +++ b/web-app/src/containers/dialogs/AddEditAssistant.tsx @@ -243,11 +243,7 @@ export default function AddEditAssistant({ return ( - { - e.preventDefault() - }} - > + {editingKey From e0ab77cb24aace0f558829ac4bdeac0fd099c249 Mon Sep 17 00:00:00 2001 From: Louis Date: Wed, 1 Oct 2025 14:07:32 +0700 Subject: [PATCH 06/12] fix: token count error (#6680) --- web-app/src/hooks/useTokensCount.ts | 15 ++++++++++++- web-app/src/lib/messages.ts | 31 +++----------------------- web-app/src/utils/reasoning.ts | 34 ++++++++++++++++++++++++++++- 3 files changed, 50 insertions(+), 30 deletions(-) diff --git a/web-app/src/hooks/useTokensCount.ts b/web-app/src/hooks/useTokensCount.ts index 90f740a4a..db75ebe34 100644 --- a/web-app/src/hooks/useTokensCount.ts +++ b/web-app/src/hooks/useTokensCount.ts @@ -3,6 +3,7 @@ import { ThreadMessage, ContentType } from '@janhq/core' import { useServiceHub } from './useServiceHub' import { useModelProvider } from './useModelProvider' import { usePrompt } from './usePrompt' +import { removeReasoningContent } from '@/utils/reasoning' export interface TokenCountData { tokenCount: number @@ -69,7 +70,19 @@ export const useTokensCount = ( } as ThreadMessage) } } - return result + return result.map((e) => ({ + ...e, + content: e.content.map((c) => ({ + ...c, + text: + c.type === 'text' + ? { + value: removeReasoningContent(c.text?.value ?? '.'), + annotations: [], + } + : c.text, + })), + })) }, [messages, prompt, uploadedFiles]) // Debounced calculation that includes current prompt diff --git a/web-app/src/lib/messages.ts b/web-app/src/lib/messages.ts index b662c5b90..fd2e84181 100644 --- a/web-app/src/lib/messages.ts +++ b/web-app/src/lib/messages.ts @@ -2,6 +2,7 @@ import { ChatCompletionMessageParam } from 'token.js' import { ChatCompletionMessageToolCall } from 'openai/resources' import { ThreadMessage } from '@janhq/core' +import { removeReasoningContent } from '@/utils/reasoning' /** * @fileoverview Helper functions for creating chat completion request. @@ -24,7 +25,7 @@ export class CompletionMessagesBuilder { if (msg.role === 'assistant') { return { role: msg.role, - content: this.normalizeContent( + content: removeReasoningContent( msg.content[0]?.text?.value || '.' ), } as ChatCompletionMessageParam @@ -135,7 +136,7 @@ export class CompletionMessagesBuilder { ) { this.messages.push({ role: 'assistant', - content: this.normalizeContent(content), + content: removeReasoningContent(content), refusal: refusal, tool_calls: calls, }) @@ -202,30 +203,4 @@ export class CompletionMessagesBuilder { return result } - /** - * Normalize the content of a message by removing reasoning content. - * This is useful to ensure that reasoning content does not get sent to the model. - * @param content - * @returns - */ - private normalizeContent = (content: string): string => { - // Reasoning content should not be sent to the model - if (content.includes('')) { - const match = content.match(/([\s\S]*?)<\/think>/) - if (match?.index !== undefined) { - const splitIndex = match.index + match[0].length - content = content.slice(splitIndex).trim() - } - } - if (content.includes('<|channel|>analysis<|message|>')) { - const match = content.match( - /<\|channel\|>analysis<\|message\|>([\s\S]*?)<\|start\|>assistant<\|channel\|>final<\|message\|>/ - ) - if (match?.index !== undefined) { - const splitIndex = match.index + match[0].length - content = content.slice(splitIndex).trim() - } - } - return content - } } diff --git a/web-app/src/utils/reasoning.ts b/web-app/src/utils/reasoning.ts index a189639f0..32b2958e6 100644 --- a/web-app/src/utils/reasoning.ts +++ b/web-app/src/utils/reasoning.ts @@ -6,10 +6,42 @@ import { } from '@janhq/core' // Helper function to get reasoning content from an object -function getReasoning(obj: { reasoning_content?: string | null; reasoning?: string | null } | null | undefined): string | null { +function getReasoning( + obj: + | { reasoning_content?: string | null; reasoning?: string | null } + | null + | undefined +): string | null { return obj?.reasoning_content ?? obj?.reasoning ?? null } +/** + * Normalize the content of a message by removing reasoning content. + * This is useful to ensure that reasoning content does not get sent to the model. + * @param content + * @returns + */ +export function removeReasoningContent(content: string): string { + // Reasoning content should not be sent to the model + if (content.includes('')) { + const match = content.match(/([\s\S]*?)<\/think>/) + if (match?.index !== undefined) { + const splitIndex = match.index + match[0].length + content = content.slice(splitIndex).trim() + } + } + if (content.includes('<|channel|>analysis<|message|>')) { + const match = content.match( + /<\|channel\|>analysis<\|message\|>([\s\S]*?)<\|start\|>assistant<\|channel\|>final<\|message\|>/ + ) + if (match?.index !== undefined) { + const splitIndex = match.index + match[0].length + content = content.slice(splitIndex).trim() + } + } + return content +} + // Extract reasoning from a message (for completed responses) export function extractReasoningFromMessage( message: chatCompletionRequestMessage | ChatCompletionMessage From a5574eaacbb46088411bc60a4e2ed19bee640260 Mon Sep 17 00:00:00 2001 From: Minh141120 Date: Wed, 1 Oct 2025 17:00:03 +0700 Subject: [PATCH 07/12] ci: revert upload msi to github release --- .github/workflows/template-tauri-build-windows-x64.yml | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/.github/workflows/template-tauri-build-windows-x64.yml b/.github/workflows/template-tauri-build-windows-x64.yml index ed00ef90f..4258f1059 100644 --- a/.github/workflows/template-tauri-build-windows-x64.yml +++ b/.github/workflows/template-tauri-build-windows-x64.yml @@ -252,13 +252,3 @@ jobs: asset_path: ./src-tauri/target/release/bundle/nsis/${{ steps.metadata.outputs.FILE_NAME }} asset_name: ${{ steps.metadata.outputs.FILE_NAME }} asset_content_type: application/octet-stream - - name: Upload release assert if public provider is github - if: inputs.public_provider == 'github' - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - uses: actions/upload-release-asset@v1.0.1 - with: - upload_url: ${{ inputs.upload_url }} - asset_path: ./src-tauri/target/release/bundle/msi/${{ steps.metadata.outputs.MSI_FILE_NAME }} - asset_name: ${{ steps.metadata.outputs.MSI_FILE_NAME }} - asset_content_type: application/octet-stream From 0f0ba43b7f8818cf330f2467e3bcf0816725f235 Mon Sep 17 00:00:00 2001 From: Akarshan Biswas Date: Wed, 1 Oct 2025 17:28:14 +0530 Subject: [PATCH 08/12] feat: Adjust RAM/VRAM calculation for unified memory systems (#6687) * feat: Adjust RAM/VRAM calculation for unified memory systems This commit refactors the logic for calculating **total RAM** and **total VRAM** in `is_model_supported` and `plan_model_load` commands, specifically targeting systems with **unified memory** (like modern macOS devices where the GPU list may be empty). The changes are as follows: * **Total RAM Calculation:** If no GPUs are detected (`sys_info.gpus.is_empty()` is true), **total RAM** is now set to $0$. This avoids confusing total system memory with dedicated GPU memory when planning model placement. * **Total VRAM Calculation:** If no GPUs are detected, **total VRAM** is still calculated as the system's **total memory (RAM)**, as this shared memory acts as VRAM on unified memory architectures. This adjustment improves the accuracy of memory availability checks and model planning on unified memory systems. * fix: total usable memory in case there is no system vram reported * chore: temporarily change to self-hosted runner mac * ci: revert back to github hosted runner macos --------- Co-authored-by: Louis Co-authored-by: Minh141120 --- .../src/gguf/commands.rs | 22 +++++++++------ .../src/gguf/model_planner.rs | 28 +++++++++---------- 2 files changed, 28 insertions(+), 22 deletions(-) diff --git a/src-tauri/plugins/tauri-plugin-llamacpp/src/gguf/commands.rs b/src-tauri/plugins/tauri-plugin-llamacpp/src/gguf/commands.rs index c636fa8bd..61ab6128f 100644 --- a/src-tauri/plugins/tauri-plugin-llamacpp/src/gguf/commands.rs +++ b/src-tauri/plugins/tauri-plugin-llamacpp/src/gguf/commands.rs @@ -89,19 +89,25 @@ pub async fn is_model_supported( ); const RESERVE_BYTES: u64 = 2288490189; - let total_system_memory = system_info.total_memory * 1024 * 1024; + let total_system_memory: u64 = match system_info.gpus.is_empty() { + // on MacOS with unified memory, treat RAM = 0 for now + true => 0, + false => system_info.total_memory * 1024 * 1024, + }; + // Calculate total VRAM from all GPUs - let total_vram: u64 = if system_info.gpus.is_empty() { + let total_vram: u64 = match system_info.gpus.is_empty() { // On macOS with unified memory, GPU info may be empty // Use total RAM as VRAM since memory is shared - log::info!("No GPUs detected (likely unified memory system), using total RAM as VRAM"); - total_system_memory - } else { - system_info + true => { + log::info!("No GPUs detected (likely unified memory system), using total RAM as VRAM"); + system_info.total_memory * 1024 * 1024 + } + false => system_info .gpus .iter() .map(|g| g.total_memory * 1024 * 1024) - .sum::() + .sum::(), }; log::info!("Total VRAM reported/calculated (in bytes): {}", &total_vram); @@ -115,7 +121,7 @@ pub async fn is_model_supported( let usable_total_memory = if total_system_memory > RESERVE_BYTES { (total_system_memory - RESERVE_BYTES) + usable_vram } else { - 0 + usable_vram }; log::info!("System RAM: {} bytes", &total_system_memory); log::info!("Total VRAM: {} bytes", &total_vram); diff --git a/src-tauri/plugins/tauri-plugin-llamacpp/src/gguf/model_planner.rs b/src-tauri/plugins/tauri-plugin-llamacpp/src/gguf/model_planner.rs index 118894871..a03766041 100644 --- a/src-tauri/plugins/tauri-plugin-llamacpp/src/gguf/model_planner.rs +++ b/src-tauri/plugins/tauri-plugin-llamacpp/src/gguf/model_planner.rs @@ -82,25 +82,25 @@ pub async fn plan_model_load( log::info!("Got GPUs:\n{:?}", &sys_info.gpus); - let total_ram: u64 = sys_info.total_memory * 1024 * 1024; - log::info!( - "Total system memory reported from tauri_plugin_hardware(in bytes): {}", - &total_ram - ); + let total_ram: u64 = match sys_info.gpus.is_empty() { + // Consider RAM as 0 for unified memory + true => 0, + false => sys_info.total_memory * 1024 * 1024, + }; - let total_vram: u64 = if sys_info.gpus.is_empty() { - // On macOS with unified memory, GPU info may be empty - // Use total RAM as VRAM since memory is shared - log::info!("No GPUs detected (likely unified memory system), using total RAM as VRAM"); - total_ram - } else { - sys_info + // Calculate total VRAM from all GPUs + let total_vram: u64 = match sys_info.gpus.is_empty() { + true => { + log::info!("No GPUs detected (likely unified memory system), using total RAM as VRAM"); + sys_info.total_memory * 1024 * 1024 + } + false => sys_info .gpus .iter() .map(|g| g.total_memory * 1024 * 1024) - .sum::() + .sum::(), }; - + log::info!("Total RAM reported/calculated (in bytes): {}", &total_ram); log::info!("Total VRAM reported/calculated (in bytes): {}", &total_vram); let usable_vram: u64 = if total_vram > RESERVE_BYTES { (((total_vram - RESERVE_BYTES) as f64) * multiplier) as u64 From 1b9efee52c7e16f3a7b58932aae3f53f0c0b2e28 Mon Sep 17 00:00:00 2001 From: Dinh Long Nguyen Date: Wed, 1 Oct 2025 22:47:38 +0700 Subject: [PATCH 09/12] feat: improve projects (#6698) * decouple successfully * only show movable projects for project items * handle delete covnersations when projects is removed * fix leftpanel assignemtn * fix lint --- web-app/src/containers/ChatInput.tsx | 28 +-- web-app/src/containers/LeftPanel.tsx | 21 +-- web-app/src/containers/ThreadList.tsx | 51 ++++-- .../dialogs/DeleteProjectDialog.tsx | 66 ++++++- web-app/src/hooks/useChat.ts | 24 ++- web-app/src/hooks/useThreadManagement.ts | 165 +++++++++++------- web-app/src/locales/en/common.json | 9 +- web-app/src/routes/project/$projectId.tsx | 2 +- web-app/src/routes/project/index.tsx | 21 +-- web-app/src/services/index.ts | 12 ++ web-app/src/services/projects/default.ts | 78 +++++++++ web-app/src/services/projects/types.ts | 42 +++++ web-app/src/services/projects/web.ts | 11 ++ web-app/src/services/threads/default.ts | 1 + 14 files changed, 380 insertions(+), 151 deletions(-) create mode 100644 web-app/src/services/projects/default.ts create mode 100644 web-app/src/services/projects/types.ts create mode 100644 web-app/src/services/projects/web.ts diff --git a/web-app/src/containers/ChatInput.tsx b/web-app/src/containers/ChatInput.tsx index cba580ebd..c96141876 100644 --- a/web-app/src/containers/ChatInput.tsx +++ b/web-app/src/containers/ChatInput.tsx @@ -4,7 +4,6 @@ import TextareaAutosize from 'react-textarea-autosize' import { cn } from '@/lib/utils' import { usePrompt } from '@/hooks/usePrompt' import { useThreads } from '@/hooks/useThreads' -import { useThreadManagement } from '@/hooks/useThreadManagement' import { useCallback, useEffect, useRef, useState } from 'react' import { Button } from '@/components/ui/button' import { @@ -65,8 +64,6 @@ const ChatInput = ({ const prompt = usePrompt((state) => state.prompt) const setPrompt = usePrompt((state) => state.setPrompt) const currentThreadId = useThreads((state) => state.currentThreadId) - const updateThread = useThreads((state) => state.updateThread) - const { getFolderById } = useThreadManagement() const { t } = useTranslation() const spellCheckChatInput = useGeneralSetting( (state) => state.spellCheckChatInput @@ -183,31 +180,10 @@ const ChatInput = ({ sendMessage( prompt, true, - uploadedFiles.length > 0 ? uploadedFiles : undefined + uploadedFiles.length > 0 ? uploadedFiles : undefined, + projectId ) setUploadedFiles([]) - - // Handle project assignment for new threads - if (projectId && !currentThreadId) { - const project = getFolderById(projectId) - if (project) { - // Use setTimeout to ensure the thread is created first - setTimeout(() => { - const newCurrentThreadId = useThreads.getState().currentThreadId - if (newCurrentThreadId) { - updateThread(newCurrentThreadId, { - metadata: { - project: { - id: project.id, - name: project.name, - updated_at: project.updated_at, - }, - }, - }) - } - }, 100) - } - } } useEffect(() => { diff --git a/web-app/src/containers/LeftPanel.tsx b/web-app/src/containers/LeftPanel.tsx index f24dcec0d..0658bdd87 100644 --- a/web-app/src/containers/LeftPanel.tsx +++ b/web-app/src/containers/LeftPanel.tsx @@ -163,7 +163,7 @@ const LeftPanel = () => { const getFilteredThreads = useThreads((state) => state.getFilteredThreads) const threads = useThreads((state) => state.threads) - const { folders, addFolder, updateFolder, deleteFolder, getFolderById } = + const { folders, addFolder, updateFolder, getFolderById } = useThreadManagement() // Project dialog states @@ -204,19 +204,16 @@ const LeftPanel = () => { setDeleteProjectConfirmOpen(true) } - const confirmProjectDelete = () => { - if (deletingProjectId) { - deleteFolder(deletingProjectId) - setDeleteProjectConfirmOpen(false) - setDeletingProjectId(null) - } + const handleProjectDeleteClose = () => { + setDeleteProjectConfirmOpen(false) + setDeletingProjectId(null) } - const handleProjectSave = (name: string) => { + const handleProjectSave = async (name: string) => { if (editingProjectKey) { - updateFolder(editingProjectKey, name) + await updateFolder(editingProjectKey, name) } else { - const newProject = addFolder(name) + const newProject = await addFolder(name) // Navigate to the newly created project navigate({ to: '/project/$projectId', @@ -680,8 +677,8 @@ const LeftPanel = () => { /> { const { attributes, @@ -108,6 +110,18 @@ const SortableItem = memo( return (thread.title || '').replace(/]*>|<\/span>/g, '') }, [thread.title]) + const availableProjects = useMemo(() => { + return folders + .filter((f) => { + // Exclude the current project page we're on + if (f.id === currentProjectId) return false + // Exclude the project this thread is already assigned to + if (f.id === thread.metadata?.project?.id) return false + return true + }) + .sort((a, b) => b.updated_at - a.updated_at) + }, [folders, currentProjectId, thread.metadata?.project?.id]) + const assignThreadToProject = (threadId: string, projectId: string) => { const project = getFolderById(projectId) if (project && updateThread) { @@ -226,29 +240,27 @@ const SortableItem = memo( Add to project - {folders.length === 0 ? ( + {availableProjects.length === 0 ? ( No projects available ) : ( - folders - .sort((a, b) => b.updated_at - a.updated_at) - .map((folder) => ( - { - e.stopPropagation() - assignThreadToProject(thread.id, folder.id) - }} - > - - - {folder.name} - - - )) + availableProjects.map((folder) => ( + { + e.stopPropagation() + assignThreadToProject(thread.id, folder.id) + }} + > + + + {folder.name} + + + )) )} {thread.metadata?.project && ( <> @@ -296,9 +308,10 @@ type ThreadListProps = { isFavoriteSection?: boolean variant?: 'default' | 'project' showDate?: boolean + currentProjectId?: string } -function ThreadList({ threads, variant = 'default' }: ThreadListProps) { +function ThreadList({ threads, variant = 'default', currentProjectId }: ThreadListProps) { const sortedThreads = useMemo(() => { return threads.sort((a, b) => { return (b.updated || 0) - (a.updated || 0) @@ -322,7 +335,7 @@ function ThreadList({ threads, variant = 'default' }: ThreadListProps) { strategy={verticalListSortingStrategy} > {sortedThreads.map((thread, index) => ( - + ))} diff --git a/web-app/src/containers/dialogs/DeleteProjectDialog.tsx b/web-app/src/containers/dialogs/DeleteProjectDialog.tsx index f8c86a3b4..50379570d 100644 --- a/web-app/src/containers/dialogs/DeleteProjectDialog.tsx +++ b/web-app/src/containers/dialogs/DeleteProjectDialog.tsx @@ -1,4 +1,4 @@ -import { useRef } from 'react' +import { useRef, useMemo } from 'react' import { Dialog, DialogContent, @@ -10,26 +10,49 @@ import { import { Button } from '@/components/ui/button' import { toast } from 'sonner' import { useTranslation } from '@/i18n/react-i18next-compat' +import { useThreads } from '@/hooks/useThreads' +import { useThreadManagement } from '@/hooks/useThreadManagement' interface DeleteProjectDialogProps { open: boolean onOpenChange: (open: boolean) => void - onConfirm: () => void + projectId?: string projectName?: string } export function DeleteProjectDialog({ open, onOpenChange, - onConfirm, + projectId, projectName, }: DeleteProjectDialogProps) { const { t } = useTranslation() const deleteButtonRef = useRef(null) + const threads = useThreads((state) => state.threads) + const { deleteFolderWithThreads } = useThreadManagement() + + // Calculate thread stats for this project + const { threadCount, starredThreadCount } = useMemo(() => { + if (!projectId) return { threadCount: 0, starredThreadCount: 0 } + + const projectThreads = Object.values(threads).filter( + (thread) => thread.metadata?.project?.id === projectId + ) + const starredCount = projectThreads.filter( + (thread) => thread.isFavorite + ).length + + return { + threadCount: projectThreads.length, + starredThreadCount: starredCount, + } + }, [projectId, threads]) + + const handleConfirm = async () => { + if (!projectId) return - const handleConfirm = () => { try { - onConfirm() + await deleteFolderWithThreads(projectId) toast.success( projectName ? t('projects.deleteProjectDialog.successWithName', { projectName }) @@ -42,12 +65,15 @@ export function DeleteProjectDialog({ } } - const handleKeyDown = (e: React.KeyboardEvent) => { + const handleKeyDown = async (e: React.KeyboardEvent) => { if (e.key === 'Enter') { - handleConfirm() + await handleConfirm() } } + const hasStarredThreads = starredThreadCount > 0 + const hasThreads = threadCount > 0 + return ( {t('projects.deleteProjectDialog.title')} - - {t('projects.deleteProjectDialog.description')} + + {hasStarredThreads ? ( + <> +

+ {t('projects.deleteProjectDialog.starredWarning')} +

+

+ {t('projects.deleteProjectDialog.permanentDeleteWarning')} +

+ + ) : hasThreads ? ( +

+ {t('projects.deleteProjectDialog.permanentDelete')} +

+ ) : ( +

+ {t('projects.deleteProjectDialog.deleteEmptyProject', { projectName })} +

+ )} + {hasThreads && ( +

+ {t('projects.deleteProjectDialog.saveThreadsAdvice')} +

+ )}
diff --git a/web-app/src/hooks/useChat.ts b/web-app/src/hooks/useChat.ts index 516a61b20..b4da700d2 100644 --- a/web-app/src/hooks/useChat.ts +++ b/web-app/src/hooks/useChat.ts @@ -83,7 +83,7 @@ export const useChat = () => { const setModelLoadError = useModelLoad((state) => state.setModelLoadError) const router = useRouter() - const getCurrentThread = useCallback(async () => { + const getCurrentThread = useCallback(async (projectId?: string) => { let currentThread = retrieveThread() if (!currentThread) { @@ -93,13 +93,28 @@ export const useChat = () => { const assistants = useAssistant.getState().assistants const selectedModel = useModelProvider.getState().selectedModel const selectedProvider = useModelProvider.getState().selectedProvider + + // Get project metadata if projectId is provided + let projectMetadata: { id: string; name: string; updated_at: number } | undefined + if (projectId) { + const project = await serviceHub.projects().getProjectById(projectId) + if (project) { + projectMetadata = { + id: project.id, + name: project.name, + updated_at: project.updated_at, + } + } + } + currentThread = await createThread( { id: selectedModel?.id ?? defaultModel(selectedProvider), provider: selectedProvider, }, currentPrompt, - assistants.find((a) => a.id === currentAssistant?.id) || assistants[0] + assistants.find((a) => a.id === currentAssistant?.id) || assistants[0], + projectMetadata, ) router.navigate({ to: route.threadsDetail, @@ -221,9 +236,10 @@ export const useChat = () => { size: number base64: string dataUrl: string - }> + }>, + projectId?: string ) => { - const activeThread = await getCurrentThread() + const activeThread = await getCurrentThread(projectId) const selectedProvider = useModelProvider.getState().selectedProvider let activeProvider = getProviderByName(selectedProvider) diff --git a/web-app/src/hooks/useThreadManagement.ts b/web-app/src/hooks/useThreadManagement.ts index becb41def..86ce03991 100644 --- a/web-app/src/hooks/useThreadManagement.ts +++ b/web-app/src/hooks/useThreadManagement.ts @@ -1,83 +1,116 @@ import { create } from 'zustand' -import { persist, createJSONStorage } from 'zustand/middleware' -import { ulid } from 'ulidx' -import { localStorageKey } from '@/constants/localStorage' +import { getServiceHub } from '@/hooks/useServiceHub' import { useThreads } from '@/hooks/useThreads' - -type ThreadFolder = { - id: string - name: string - updated_at: number -} +import type { ThreadFolder } from '@/services/projects/types' +import { useEffect } from 'react' type ThreadManagementState = { folders: ThreadFolder[] setFolders: (folders: ThreadFolder[]) => void - addFolder: (name: string) => ThreadFolder - updateFolder: (id: string, name: string) => void - deleteFolder: (id: string) => void + addFolder: (name: string) => Promise + updateFolder: (id: string, name: string) => Promise + deleteFolder: (id: string) => Promise + deleteFolderWithThreads: (id: string) => Promise getFolderById: (id: string) => ThreadFolder | undefined + getProjectById: (id: string) => Promise } -export const useThreadManagement = create()( - persist( - (set, get) => ({ - folders: [], +const useThreadManagementStore = create()((set, get) => ({ + folders: [], - setFolders: (folders) => { - set({ folders }) - }, + setFolders: (folders) => { + set({ folders }) + }, - addFolder: (name) => { - const newFolder: ThreadFolder = { - id: ulid(), - name, - updated_at: Date.now(), - } - set((state) => ({ - folders: [...state.folders, newFolder], - })) - return newFolder - }, + addFolder: async (name) => { + const projectsService = getServiceHub().projects() + const newFolder = await projectsService.addProject(name) + const updatedProjects = await projectsService.getProjects() + set({ folders: updatedProjects }) + return newFolder + }, - updateFolder: (id, name) => { - set((state) => ({ - folders: state.folders.map((folder) => - folder.id === id - ? { ...folder, name, updated_at: Date.now() } - : folder - ), - })) - }, + updateFolder: async (id, name) => { + const projectsService = getServiceHub().projects() + await projectsService.updateProject(id, name) + const updatedProjects = await projectsService.getProjects() + set({ folders: updatedProjects }) + }, - deleteFolder: (id) => { - // Remove project metadata from all threads that belong to this project - const threadsState = useThreads.getState() - const threadsToUpdate = Object.values(threadsState.threads).filter( - (thread) => thread.metadata?.project?.id === id - ) + deleteFolder: async (id) => { + // Remove project metadata from all threads that belong to this project + const threadsState = useThreads.getState() + const threadsToUpdate = Object.values(threadsState.threads).filter( + (thread) => thread.metadata?.project?.id === id + ) - threadsToUpdate.forEach((thread) => { - threadsState.updateThread(thread.id, { - metadata: { - ...thread.metadata, - project: undefined, - }, - }) - }) + threadsToUpdate.forEach((thread) => { + threadsState.updateThread(thread.id, { + metadata: { + ...thread.metadata, + project: undefined, + }, + }) + }) - set((state) => ({ - folders: state.folders.filter((folder) => folder.id !== id), - })) - }, + const projectsService = getServiceHub().projects() + await projectsService.deleteProject(id) + const updatedProjects = await projectsService.getProjects() + set({ folders: updatedProjects }) + }, - getFolderById: (id) => { - return get().folders.find((folder) => folder.id === id) - }, - }), - { - name: localStorageKey.threadManagement, - storage: createJSONStorage(() => localStorage), + deleteFolderWithThreads: async (id) => { + // Get all threads that belong to this project + const threadsState = useThreads.getState() + const projectThreads = Object.values(threadsState.threads).filter( + (thread) => thread.metadata?.project?.id === id + ) + + // Delete threads from backend first + const serviceHub = getServiceHub() + for (const thread of projectThreads) { + await serviceHub.threads().deleteThread(thread.id) } - ) -) + + // Delete threads from frontend state + for (const thread of projectThreads) { + threadsState.deleteThread(thread.id) + } + + // Delete the project from storage + const projectsService = serviceHub.projects() + await projectsService.deleteProject(id) + + const updatedProjects = await projectsService.getProjects() + set({ folders: updatedProjects }) + }, + + getFolderById: (id) => { + return get().folders.find((folder) => folder.id === id) + }, + + getProjectById: async (id) => { + const projectsService = getServiceHub().projects() + return await projectsService.getProjectById(id) + }, +})) + +export const useThreadManagement = () => { + const store = useThreadManagementStore() + + // Load projects from service on mount + useEffect(() => { + const syncProjects = async () => { + try { + const projectsService = getServiceHub().projects() + const projects = await projectsService.getProjects() + useThreadManagementStore.setState({ folders: projects }) + } catch (error) { + console.error('Error syncing projects:', error) + } + } + syncProjects() + }, []) + + return store +} diff --git a/web-app/src/locales/en/common.json b/web-app/src/locales/en/common.json index c829dbdf8..bf246ba90 100644 --- a/web-app/src/locales/en/common.json +++ b/web-app/src/locales/en/common.json @@ -249,7 +249,11 @@ "projectNotFoundDesc": "The project you're looking for doesn't exist or has been deleted.", "deleteProjectDialog": { "title": "Delete Project", - "description": "Are you sure you want to delete this project? This action cannot be undone.", + "permanentDelete": "This will permanently delete all threads.", + "permanentDeleteWarning": "This action will permanently delete ALL threads within the project!", + "deleteEmptyProject": "This action will delete project \"{{projectName}}\".", + "saveThreadsAdvice": "To save threads, move them to your thread list or another project before deleting.", + "starredWarning": "You still have starred threads within the project.", "deleteButton": "Delete", "successWithName": "Project \"{{projectName}}\" deleted successfully", "successWithoutName": "Project deleted successfully", @@ -360,4 +364,5 @@ "description": "Thread removed from \"{{projectName}}\" successfully" } } -} \ No newline at end of file +} + diff --git a/web-app/src/routes/project/$projectId.tsx b/web-app/src/routes/project/$projectId.tsx index f25680112..042038e12 100644 --- a/web-app/src/routes/project/$projectId.tsx +++ b/web-app/src/routes/project/$projectId.tsx @@ -105,7 +105,7 @@ function ProjectPage() { {/* Thread List or Empty State */}
{projectThreads.length > 0 ? ( - + ) : (
state.threads) const [open, setOpen] = useState(false) @@ -48,19 +48,16 @@ function ProjectContent() { setDeleteConfirmOpen(true) } - const confirmDelete = () => { - if (deletingId) { - deleteFolder(deletingId) - setDeleteConfirmOpen(false) - setDeletingId(null) - } + const handleDeleteClose = () => { + setDeleteConfirmOpen(false) + setDeletingId(null) } - const handleSave = (name: string) => { + const handleSave = async (name: string) => { if (editingKey) { - updateFolder(editingKey, name) + await updateFolder(editingKey, name) } else { - const newProject = addFolder(name) + const newProject = await addFolder(name) // Navigate to the newly created project navigate({ to: '/project/$projectId', @@ -244,8 +241,8 @@ function ProjectContent() { />
diff --git a/web-app/src/services/index.ts b/web-app/src/services/index.ts index 121742177..0bfba90e6 100644 --- a/web-app/src/services/index.ts +++ b/web-app/src/services/index.ts @@ -26,6 +26,7 @@ import { DefaultUpdaterService } from './updater/default' import { DefaultPathService } from './path/default' import { DefaultCoreService } from './core/default' import { DefaultDeepLinkService } from './deeplink/default' +import { DefaultProjectsService } from './projects/default' // Import service types import type { ThemeService } from './theme/types' @@ -46,6 +47,7 @@ import type { UpdaterService } from './updater/types' import type { PathService } from './path/types' import type { CoreService } from './core/types' import type { DeepLinkService } from './deeplink/types' +import type { ProjectsService } from './projects/types' export interface ServiceHub { // Service getters - all synchronous after initialization @@ -67,6 +69,7 @@ export interface ServiceHub { path(): PathService core(): CoreService deeplink(): DeepLinkService + projects(): ProjectsService } class PlatformServiceHub implements ServiceHub { @@ -88,6 +91,7 @@ class PlatformServiceHub implements ServiceHub { private pathService: PathService = new DefaultPathService() private coreService: CoreService = new DefaultCoreService() private deepLinkService: DeepLinkService = new DefaultDeepLinkService() + private projectsService: ProjectsService = new DefaultProjectsService() private initialized = false /** @@ -158,6 +162,7 @@ class PlatformServiceHub implements ServiceHub { deepLinkModule, providersModule, mcpModule, + projectsModule, ] = await Promise.all([ import('./theme/web'), import('./app/web'), @@ -169,6 +174,7 @@ class PlatformServiceHub implements ServiceHub { import('./deeplink/web'), import('./providers/web'), import('./mcp/web'), + import('./projects/web'), ]) this.themeService = new themeModule.WebThemeService() @@ -181,6 +187,7 @@ class PlatformServiceHub implements ServiceHub { this.deepLinkService = new deepLinkModule.WebDeepLinkService() this.providersService = new providersModule.WebProvidersService() this.mcpService = new mcpModule.WebMCPService() + this.projectsService = new projectsModule.WebProjectsService() } this.initialized = true @@ -290,6 +297,11 @@ class PlatformServiceHub implements ServiceHub { this.ensureInitialized() return this.deepLinkService } + + projects(): ProjectsService { + this.ensureInitialized() + return this.projectsService + } } export async function initializeServiceHub(): Promise { diff --git a/web-app/src/services/projects/default.ts b/web-app/src/services/projects/default.ts new file mode 100644 index 000000000..e570453c9 --- /dev/null +++ b/web-app/src/services/projects/default.ts @@ -0,0 +1,78 @@ +/** + * Default Projects Service - localStorage implementation + */ + +import { ulid } from 'ulidx' +import type { ProjectsService, ThreadFolder } from './types' +import { localStorageKey } from '@/constants/localStorage' + +export class DefaultProjectsService implements ProjectsService { + private storageKey = localStorageKey.threadManagement + + private loadFromStorage(): ThreadFolder[] { + try { + const stored = localStorage.getItem(this.storageKey) + if (!stored) return [] + const data = JSON.parse(stored) + return data.state?.folders || [] + } catch (error) { + console.error('Error loading projects from localStorage:', error) + return [] + } + } + + private saveToStorage(projects: ThreadFolder[]): void { + try { + const data = { + state: { folders: projects }, + version: 0, + } + localStorage.setItem(this.storageKey, JSON.stringify(data)) + } catch (error) { + console.error('Error saving projects to localStorage:', error) + } + } + + async getProjects(): Promise { + return this.loadFromStorage() + } + + async addProject(name: string): Promise { + const newProject: ThreadFolder = { + id: ulid(), + name, + updated_at: Date.now(), + } + + const projects = this.loadFromStorage() + const updatedProjects = [...projects, newProject] + this.saveToStorage(updatedProjects) + + return newProject + } + + async updateProject(id: string, name: string): Promise { + const projects = this.loadFromStorage() + const updatedProjects = projects.map((project) => + project.id === id + ? { ...project, name, updated_at: Date.now() } + : project + ) + this.saveToStorage(updatedProjects) + } + + async deleteProject(id: string): Promise { + const projects = this.loadFromStorage() + const updatedProjects = projects.filter((project) => project.id !== id) + this.saveToStorage(updatedProjects) + } + + async getProjectById(id: string): Promise { + const projects = this.loadFromStorage() + return projects.find((project) => project.id === id) + } + + async setProjects(projects: ThreadFolder[]): Promise { + this.saveToStorage(projects) + } +} diff --git a/web-app/src/services/projects/types.ts b/web-app/src/services/projects/types.ts new file mode 100644 index 000000000..d4ddc83bd --- /dev/null +++ b/web-app/src/services/projects/types.ts @@ -0,0 +1,42 @@ +/** + * Projects Service Types + * Types for project/folder management operations + */ + +export interface ThreadFolder { + id: string + name: string + updated_at: number +} + +export interface ProjectsService { + /** + * Get all projects/folders + */ + getProjects(): Promise + + /** + * Add a new project/folder + */ + addProject(name: string): Promise + + /** + * Update a project/folder name + */ + updateProject(id: string, name: string): Promise + + /** + * Delete a project/folder + */ + deleteProject(id: string): Promise + + /** + * Get a project/folder by ID + */ + getProjectById(id: string): Promise + + /** + * Set all projects/folders (for bulk updates) + */ + setProjects(projects: ThreadFolder[]): Promise +} diff --git a/web-app/src/services/projects/web.ts b/web-app/src/services/projects/web.ts new file mode 100644 index 000000000..8dbaecd99 --- /dev/null +++ b/web-app/src/services/projects/web.ts @@ -0,0 +1,11 @@ +/** + * Web Projects Service - Web implementation + * Currently extends default, will be customized by extension-web team later + */ + +import { DefaultProjectsService } from './default' + +export class WebProjectsService extends DefaultProjectsService { + // Currently uses the same localStorage implementation as default + // Extension-web team can override methods here later +} diff --git a/web-app/src/services/threads/default.ts b/web-app/src/services/threads/default.ts index 72c66841a..bea5912a6 100644 --- a/web-app/src/services/threads/default.ts +++ b/web-app/src/services/threads/default.ts @@ -62,6 +62,7 @@ export class DefaultThreadsService implements ThreadsService { }, ], metadata: { + ...thread.metadata, order: thread.order, }, }) From 9f72debc1735b03af37bdf3432366cebca7a744c Mon Sep 17 00:00:00 2001 From: Dinh Long Nguyen Date: Wed, 1 Oct 2025 22:47:27 +0700 Subject: [PATCH 10/12] fix: thread item overfetching (#6699) * fix: thread item overfetching * chore: cleanup left over import --- web-app/src/providers/AuthProvider.tsx | 9 --------- web-app/src/providers/DataProvider.tsx | 10 +--------- 2 files changed, 1 insertion(+), 18 deletions(-) diff --git a/web-app/src/providers/AuthProvider.tsx b/web-app/src/providers/AuthProvider.tsx index a62ea4fdd..733296dd4 100644 --- a/web-app/src/providers/AuthProvider.tsx +++ b/web-app/src/providers/AuthProvider.tsx @@ -31,19 +31,10 @@ export function AuthProvider({ children }: AuthProviderProps) { const fetchUserData = useCallback(async () => { try { const { setThreads } = useThreads.getState() - const { setMessages } = useMessages.getState() // Fetch threads first const threads = await serviceHub.threads().fetchThreads() setThreads(threads) - - // Fetch messages for each thread - const messagePromises = threads.map(async (thread) => { - const messages = await serviceHub.messages().fetchMessages(thread.id) - setMessages(thread.id, messages) - }) - - await Promise.all(messagePromises) } catch (error) { console.error('Failed to fetch user data:', error) } diff --git a/web-app/src/providers/DataProvider.tsx b/web-app/src/providers/DataProvider.tsx index b8c928485..333bd2eec 100644 --- a/web-app/src/providers/DataProvider.tsx +++ b/web-app/src/providers/DataProvider.tsx @@ -1,4 +1,3 @@ -import { useMessages } from '@/hooks/useMessages' import { useModelProvider } from '@/hooks/useModelProvider' import { useAppUpdater } from '@/hooks/useAppUpdater' @@ -19,7 +18,6 @@ export function DataProvider() { const { setProviders, selectedModel, selectedProvider, getProviderByName } = useModelProvider() - const { setMessages } = useMessages() const { checkForUpdate } = useAppUpdater() const { setServers } = useMCPServers() const { setAssistants, initializeWithLastUsed } = useAssistant() @@ -87,14 +85,8 @@ export function DataProvider() { .fetchThreads() .then((threads) => { setThreads(threads) - threads.forEach((thread) => - serviceHub - .messages() - .fetchMessages(thread.id) - .then((messages) => setMessages(thread.id, messages)) - ) }) - }, [serviceHub, setThreads, setMessages]) + }, [serviceHub, setThreads]) // Check for app updates useEffect(() => { From 87db633b7db120cb45738742ceb253709b68816b Mon Sep 17 00:00:00 2001 From: Nghia Doan Date: Thu, 2 Oct 2025 08:58:48 +0700 Subject: [PATCH 11/12] Merge pull request #6700 from menloresearch/fix/edit-model-name fix: Fix editing model without saving should restore original name # Conflicts: # web-app/src/containers/__tests__/EditModel.test.tsx --- .../containers/__tests__/EditModel.test.tsx | 72 ++++++- web-app/src/containers/dialogs/EditModel.tsx | 180 +++++------------- 2 files changed, 119 insertions(+), 133 deletions(-) diff --git a/web-app/src/containers/__tests__/EditModel.test.tsx b/web-app/src/containers/__tests__/EditModel.test.tsx index a02e72476..6c0dfd059 100644 --- a/web-app/src/containers/__tests__/EditModel.test.tsx +++ b/web-app/src/containers/__tests__/EditModel.test.tsx @@ -1,6 +1,5 @@ import { describe, it, expect, beforeEach, vi } from 'vitest' -import { render, screen, fireEvent, waitFor } from '@testing-library/react' -import userEvent from '@testing-library/user-event' +import { render } from '@testing-library/react' import { DialogEditModel } from '../dialogs/EditModel' import { useModelProvider } from '@/hooks/useModelProvider' import '@testing-library/jest-dom' @@ -38,8 +37,8 @@ vi.mock('sonner', () => ({ vi.mock('@/components/ui/dialog', () => ({ Dialog: ({ children, open }: { children: React.ReactNode; open: boolean }) => open ?
{children}
: null, - DialogContent: ({ children }: { children: React.ReactNode }) => ( -
{children}
+ DialogContent: ({ children, onKeyDown }: { children: React.ReactNode; onKeyDown?: (e: React.KeyboardEvent) => void }) => ( +
{children}
), DialogHeader: ({ children }: { children: React.ReactNode }) => (
{children}
@@ -181,4 +180,67 @@ describe('DialogEditModel - Basic Component Tests', () => { expect(mockUpdateProvider).toBeDefined() expect(mockSetProviders).toBeDefined() }) -}) \ No newline at end of file + + it('should consolidate capabilities initialization without duplication', () => { + const providerWithCaps = { + provider: 'llamacpp', + active: true, + models: [ + { + id: 'test-model.gguf', + displayName: 'Test Model', + capabilities: ['vision', 'tools'], + }, + ], + settings: [], + } as any + + const { container } = render( + + ) + + // Should render without issues - capabilities helper function should work + expect(container).toBeInTheDocument() + }) + + it('should handle Enter key press with keyDown handler', () => { + const { container } = render( + + ) + + // Component should render with keyDown handler + expect(container).toBeInTheDocument() + }) + + it('should handle vision and tools capabilities', () => { + const providerWithAllCaps = { + provider: 'llamacpp', + active: true, + models: [ + { + id: 'test-model.gguf', + displayName: 'Test Model', + capabilities: ['vision', 'tools', 'completion', 'embeddings', 'web_search', 'reasoning'], + }, + ], + settings: [], + } as any + + const { container } = render( + + ) + + // Component should render without errors even with extra capabilities + // The capabilities helper should only extract vision and tools + expect(container).toBeInTheDocument() + }) +}) diff --git a/web-app/src/containers/dialogs/EditModel.tsx b/web-app/src/containers/dialogs/EditModel.tsx index 67576fbd6..f7dec06eb 100644 --- a/web-app/src/containers/dialogs/EditModel.tsx +++ b/web-app/src/containers/dialogs/EditModel.tsx @@ -17,9 +17,6 @@ import { IconTool, IconAlertTriangle, IconLoader2, - // IconWorld, - // IconAtom, - // IconCodeCircle2, } from '@tabler/icons-react' import { useState, useEffect } from 'react' import { useTranslation } from '@/i18n/react-i18next-compat' @@ -46,69 +43,45 @@ export const DialogEditModel = ({ const [isOpen, setIsOpen] = useState(false) const [isLoading, setIsLoading] = useState(false) const [capabilities, setCapabilities] = useState>({ - completion: false, vision: false, tools: false, - reasoning: false, - embeddings: false, - web_search: false, }) // Initialize with the provided model ID or the first model if available useEffect(() => { - // Only set the selected model ID if the dialog is not open to prevent switching during downloads - if (!isOpen) { + if (isOpen && !selectedModelId || !isOpen) { if (modelId) { setSelectedModelId(modelId) } else if (provider.models && provider.models.length > 0) { setSelectedModelId(provider.models[0].id) } } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [modelId, isOpen]) // Add isOpen dependency to prevent switching when dialog is open - - // Handle dialog opening - set the initial model selection - useEffect(() => { - if (isOpen && !selectedModelId) { - if (modelId) { - setSelectedModelId(modelId) - } else if (provider.models && provider.models.length > 0) { - setSelectedModelId(provider.models[0].id) - } - } - }, [isOpen, selectedModelId, modelId, provider.models]) + }, [modelId, isOpen, selectedModelId, provider.models]) // Get the currently selected model const selectedModel = provider.models.find( (m: Model) => m.id === selectedModelId ) + // Helper function to convert capabilities array to object + const capabilitiesToObject = (capabilitiesList: string[]) => ({ + vision: capabilitiesList.includes('vision'), + tools: capabilitiesList.includes('tools'), + }) + // Initialize capabilities and display name from selected model useEffect(() => { if (selectedModel) { const modelCapabilities = selectedModel.capabilities || [] - setCapabilities({ - completion: modelCapabilities.includes('completion'), - vision: modelCapabilities.includes('vision'), - tools: modelCapabilities.includes('tools'), - embeddings: modelCapabilities.includes('embeddings'), - web_search: modelCapabilities.includes('web_search'), - reasoning: modelCapabilities.includes('reasoning'), - }) + const capsObject = capabilitiesToObject(modelCapabilities) + + setCapabilities(capsObject) + setOriginalCapabilities(capsObject) + // Use existing displayName if available, otherwise fall back to model ID const displayNameValue = (selectedModel as Model & { displayName?: string }).displayName || selectedModel.id setDisplayName(displayNameValue) setOriginalDisplayName(displayNameValue) - - const originalCaps = { - completion: modelCapabilities.includes('completion'), - vision: modelCapabilities.includes('vision'), - tools: modelCapabilities.includes('tools'), - embeddings: modelCapabilities.includes('embeddings'), - web_search: modelCapabilities.includes('web_search'), - reasoning: modelCapabilities.includes('reasoning'), - } - setOriginalCapabilities(originalCaps) } }, [selectedModel]) @@ -139,53 +112,38 @@ export const DialogEditModel = ({ setIsLoading(true) try { - let updatedModels = provider.models + const nameChanged = displayName !== originalDisplayName + const capabilitiesChanged = JSON.stringify(capabilities) !== JSON.stringify(originalCapabilities) - // Update display name if changed - if (displayName !== originalDisplayName) { - // Update the model in the provider models array with displayName - updatedModels = updatedModels.map((m: Model) => { - if (m.id === selectedModelId) { - return { - ...m, - displayName: displayName, - } - } - return m - }) - setOriginalDisplayName(displayName) + // Build the update object for the selected model + const modelUpdate: Partial & { _userConfiguredCapabilities?: boolean } = {} + + if (nameChanged) { + modelUpdate.displayName = displayName } - // Update capabilities if changed - if ( - JSON.stringify(capabilities) !== JSON.stringify(originalCapabilities) - ) { - const updatedCapabilities = Object.entries(capabilities) + if (capabilitiesChanged) { + modelUpdate.capabilities = Object.entries(capabilities) .filter(([, isEnabled]) => isEnabled) .map(([capName]) => capName) - - // Find and update the model in the provider - updatedModels = updatedModels.map((m: Model) => { - if (m.id === selectedModelId) { - return { - ...m, - capabilities: updatedCapabilities, - // Mark that user has manually configured capabilities - _userConfiguredCapabilities: true, - } - } - return m - }) - - setOriginalCapabilities(capabilities) + modelUpdate._userConfiguredCapabilities = true } + // Update the model in the provider models array + const updatedModels = provider.models.map((m: Model) => + m.id === selectedModelId ? { ...m, ...modelUpdate } : m + ) + // Update the provider with the updated models updateProvider(provider.provider, { ...provider, models: updatedModels, }) + // Update original values + if (nameChanged) setOriginalDisplayName(displayName) + if (capabilitiesChanged) setOriginalCapabilities(capabilities) + // Show success toast and close dialog toast.success('Model updated successfully') setIsOpen(false) @@ -201,14 +159,32 @@ export const DialogEditModel = ({ return null } + // Handle dialog close - reset to original values if not saved + const handleDialogChange = (open: boolean) => { + if (!open && hasUnsavedChanges()) { + // Reset to original values when closing without saving + setDisplayName(originalDisplayName) + setCapabilities(originalCapabilities) + } + setIsOpen(open) + } + + // Handle keyboard events for Enter key + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && hasUnsavedChanges() && !isLoading) { + e.preventDefault() + handleSaveChanges() + } + } + return ( - +
- + {t('providers:editModel.title', { modelId: selectedModel.id })} @@ -292,58 +268,6 @@ export const DialogEditModel = ({ disabled={isLoading} />
- - {/*
-
- - - {t('providers:editModel.embeddings')} - -
- - - - handleCapabilityChange('embeddings', checked) - } - /> - - - {t('providers:editModel.notAvailable')} - - -
*/} - - {/*
-
- - Web Search -
- - handleCapabilityChange('web_search', checked) - } - /> -
*/} - - {/*
-
- - {t('reasoning')} -
- - handleCapabilityChange('reasoning', checked) - } - /> -
*/}
From f6f9813ef2fb7400b12c880ee526d6ff3cd54071 Mon Sep 17 00:00:00 2001 From: Minh141120 Date: Thu, 2 Oct 2025 15:26:37 +0700 Subject: [PATCH 12/12] feat: update checklist for 0.7.0 --- tests/checklist.md | 63 +++++++++++++++++++++++++++++++++------------- 1 file changed, 45 insertions(+), 18 deletions(-) diff --git a/tests/checklist.md b/tests/checklist.md index b2e1da7ca..8e9e65d4b 100644 --- a/tests/checklist.md +++ b/tests/checklist.md @@ -16,7 +16,7 @@ Before testing, set-up the following in the old version to make sure that we can - [ ] Change the `App Data` to some other folder - [ ] Create a Custom Provider - [ ] Disable some model providers -- [NEW] Change llama.cpp setting of 2 models +- [ ] Change llama.cpp setting of 2 models #### Validate that the update does not corrupt existing user data or settings (before and after update show the same information): - [ ] Threads - [ ] Previously used model and assistants is shown correctly @@ -73,35 +73,44 @@ Before testing, set-up the following in the old version to make sure that we can - [ ] Ensure that when this value is changed, there is no broken UI caused by it - [ ] Code Block - [ ] Show Line Numbers -- [ENG] Ensure that when click on `Reset` in the `Appearance` section, it reset back to the default values -- [ENG] Ensure that when click on `Reset` in the `Code Block` section, it reset back to the default values +- [ ] [0.7.0] Compact Token Counter will show token counter in side chat input when toggle, if not it will show a small token counter below the chat input +- [ ] [ENG] Ensure that when click on `Reset` in the `Appearance` section, it reset back to the default values +- [ ] [ENG] Ensure that when click on `Reset` in the `Code Block` section, it reset back to the default values #### In `Model Providers`: In `Llama.cpp`: - [ ] After downloading a model from hub, the model is listed with the correct name under `Models` - [ ] Can import `gguf` model with no error +- [ ] [0.7.0] While importing, there should be an import indication appear under `Models` - [ ] Imported model will be listed with correct name under the `Models` +- [ ] [0.6.9] Take a `gguf` file and delete the `.gguf` extensions from the file name, import it into Jan and verify that it works. +- [ ] [0.6.10] Can import vlm models and chat with images +- [ ] [0.6.10] Import a file that is not `mmproj` in the `mmproj field` should show validation error +- [ ] [0.6.10] Import `mmproj` from different models should error +- [ ] [0.7.0] Users can customize model display names according to their own preferences. - [ ] Check that when click `delete` the model will be removed from the list - [ ] Deleted model doesn't appear in the selectable models section in chat input (even in old threads that use the model previously) - [ ] Ensure that user can re-import deleted imported models +- [ ] [0.6.8] Ensure that there is a recommended `llama.cpp` for each system and that it works out of the box for users. +- [ ] [0.6.10] Change to an older version of llama.cpp backend. Click on `Check for Llamacpp Updates` it should alert that there is a new version. +- [ ] [0.7.0] Users can cancel a backend download while it is in progress. +- [ ] [0.6.10] Try `Install backend from file` for a backend and it should show as an option for backend +- [ ] [0.7.0] User can install a backend from file in both .tar.gz and .zip formats, and the backend appears in the backend selection menu +- [ ] [0.7.0] A manually installed backend is automatically selected after import, and the backend menu updates to show it as the latest imported backend. - [ ] Enable `Auto-Unload Old Models`, and ensure that only one model can run / start at a time. If there are two model running at the time of enable, both of them will be stopped. - [ ] Disable `Auto-Unload Old Models`, and ensure that multiple models can run at the same time. - [ ] Enable `Context Shift` and ensure that context can run for long without encountering memory error. Use the `banana test` by turn on fetch MCP => ask local model to fetch and summarize the history of banana (banana has a very long history on wiki it turns out). It should run out of context memory sufficiently fast if `Context Shift` is not enabled. + +In `Model Settings`: - [ ] [0.6.8] Ensure that user can change the Jinja chat template of individual model and it doesn't affect the template of other model -- [ ] [0.6.8] Ensure that there is a recommended `llama.cpp` for each system and that it works out of the box for users. - [ ] [0.6.8] Ensure we can override Tensor Buffer Type in the model settings to offload layers between GPU and CPU => Download any MoE Model (i.e., gpt-oss-20b) => Set tensor buffer type as `blk\\.([0-30]*[02468])\\.ffn_.*_exps\\.=CPU` => check if those tensors are in cpu and run inference (you can view the app.log if it contains `--override-tensor", "blk\\\\.([0-30]*[02468])\\\\.ffn_.*_exps\\\\.=CPU`) -- [ ] [0.6.9] Take a `gguf` file and delete the `.gguf` extensions from the file name, import it into Jan and verify that it works. -- [ ] [0.6.10] Can import vlm models and chat with images -- [ ] [0.6.10] Import model on mmproj field should show validation error -- [ ] [0.6.10] Import mmproj from different models should not be able to chat with the models -- [ ] [0.6.10] Change to an older version of llama.cpp backend. Click on `Check for Llamacpp Updates` it should alert that there is a new version. -- [ ] [0.6.10] Try `Install backend from file` for a backend and it should show as an option for backend In Remote Model Providers: - [ ] Check that the following providers are presence: - [ ] OpenAI - [ ] Anthropic + - [ ] [0.7.0] Azure - [ ] Cohere - [ ] OpenRouter - [ ] Mistral @@ -113,12 +122,15 @@ In Remote Model Providers: - [ ] Delete a model and ensure that it doesn't show up in the `Models` list view or in the selectable dropdown in chat input. - [ ] Ensure that a deleted model also not selectable or appear in old threads that used it. - [ ] Adding of new model manually works and user can chat with the newly added model without error (you can add back the model you just delete for testing) -- [ ] [0.6.9] Make sure that Ollama set-up as a custom provider work with Jan +- [ ] [0.7.0] Vision capabilities are now automatically detected for vision models +- [ ] [0.7.0] New default models are available for adding to remote providers through a drop down (OpenAI, Mistral, Groq) + In Custom Providers: - [ ] Ensure that user can create a new custom providers with the right baseURL and API key. - [ ] Click `Refresh` should retrieve a list of available models from the Custom Providers. - [ ] User can chat with the custom providers - [ ] Ensure that Custom Providers can be deleted and won't reappear in a new session +- [ ] [0.6.9] Make sure that Ollama set-up as a custom provider work with Jan In general: - [ ] Disabled Model Provider should not show up as selectable in chat input of new thread and old thread alike (old threads' chat input should show `Select Model` instead of disabled model) @@ -162,9 +174,10 @@ Ensure that the following section information show up for hardware - [ ] When the user click `Always Allow` on the pop up, the tool will retain permission and won't ask for confirmation again. (this applied at an individual tool level, not at the MCP server level) - [ ] If `Allow All MCP Tool Permissions` is enabled, in every new thread, there should not be any confirmation dialog pop up when a tool is called. - [ ] When the pop-up appear, make sure that the `Tool Parameters` is also shown with detail in the pop-up -- [ ] [0.6.9] Go to Enter JSON configuration when created a new MCp => paste the JSON config inside => click `Save` => server works +- [ ] [0.6.9] Go to Enter JSON configuration when created a new MCP => paste the JSON config inside => click `Save` => server works - [ ] [0.6.9] If individual JSON config format is failed, the MCP server should not be activated - [ ] [0.6.9] Make sure that MCP server can be used with streamable-http transport => connect to Smithery and test MCP server +- [ ] [0.7.0] When deleting an MCP Server, a toast notification is shown #### In `Local API Server`: - [ ] User can `Start Server` and chat with the default endpoint @@ -175,7 +188,8 @@ Ensure that the following section information show up for hardware - [ ] [0.6.9] When the startup configuration, the last used model is also automatically start (users does not have to manually start a model before starting the server) - [ ] [0.6.9] Make sure that you can send an image to a Local API Server and it also works (can set up Local API Server as a Custom Provider in Jan to test) - [ ] [0.6.10] Make sure you are still able to see API key when server local status is running - +- [ ] [0.7.0] Users can see the Jan API Server Swagger UI by opening the following path in their browser `http://:` +- [ ] [0.7.0] Users can set the trusted host to * in the server configuration to accept requests from all host or without host #### In `HTTPS Proxy`: - [ ] Model download request goes through proxy endpoint @@ -188,6 +202,7 @@ Ensure that the following section information show up for hardware - [ ] Clicking download work inside the Model card HTML - [ ] [0.6.9] Check that the model recommendation base on user hardware work as expected in the Model Hub - [ ] [0.6.10] Check that model of the same name but different author can be found in the Hub catalog (test with [https://huggingface.co/unsloth/Qwen3-4B-Thinking-2507-GGUF](https://huggingface.co/unsloth/Qwen3-4B-Thinking-2507-GGUF)) +- [ ] [0.7.0] Support downloading models with the same name from different authors, models not listed on the hub will be prefixed with the author name ## D. Threads @@ -214,19 +229,30 @@ Ensure that the following section information show up for hardware - [ ] User can send message with different type of text content (e.g text, emoji, ...) - [ ] When request model to generate a markdown table, the table is correctly formatted as returned from the model. - [ ] When model generate code, ensure that the code snippets is properly formatted according to the `Appearance -> Code Block` setting. +- [ ] [0.7.0] LaTeX formulas now render correctly in chat. Both inline \(...\) and block \[...\] formats are supported. Code blocks and HTML tags are not affected - [ ] Users can edit their old message and user can regenerate the answer based on the new message - [ ] User can click `Copy` to copy the model response +- [ ] [0.6.10] When click on copy code block from model generation, it will only copy one code-block at a time instead of multiple code block at once - [ ] User can click `Delete` to delete either the user message or the model response. - [ ] The token speed appear when a response from model is being generated and the final value is show under the response. - [ ] Make sure that user when using IME keyboard to type Chinese and Japanese character and they press `Enter`, the `Send` button doesn't trigger automatically after each words. -- [ ] [0.6.9] Attach an image to the chat input and see if you can chat with it using a remote model -- [ ] [0.6.9] Attach an image to the chat input and see if you can chat with it using a local model +- [ ] [0.6.9] Attach an image to the chat input and see if you can chat with it using a Remote model & Local model - [ ] [0.6.9] Check that you can paste an image to text box from your system clipboard (Copy - Paste) -- [ ] [0.6.9] Make sure that user can favourite a model in the llama.cpp list and see the favourite model selection in chat input +- [ ] [0.6.10] User can Paste (e.g Ctrl + v) text into chat input when it is a vision model +- [ ] [0.6.9] Make sure that user can favourite a model in the Model list and see the favourite model selection in chat input - [ ] [0.6.10] User can click mode's setting on chat, enable Auto-Optimize Settings, and continue chatting with the model without interruption. - [ ] Verify this works with at least two models of different sizes (e.g., 1B and 7B). -- [ ] [0.6.10] User can Paste (e.g Ctrl + v) text into chat input when it is a vision model -- [ ] [0.6.10] When click on copy code block from model generation, it will only copy one code-block at a time instead of multiple code block at once +- [ ] [0.7.0] When chatting with a model, the UI displays a token usage counter showing the percentage of context consumed. +- [ ] [0.7.0] When chatting with a model, the scroll no longer follows the model’s streaming response; it only auto-scrolls when the user sends a new message +#### In Project + +- [ ] [0.7.0] User can create new project +- [ ] [0.7.0] User can add existing threads to a project +- [ ] [0.7.0] When the user attempts to delete a project, a confirmation dialog must appear warning that this action will permanently delete the project and all its associated threads. +- [ ] [0.7.0] The user can successfully delete a project, and all threads contained within that project are also permanently deleted. +- [ ] [0.7.0] A thread that already belongs to a project cannot be re-added to the same project. +- [ ] [0.7.0] Favorited threads retain their "favorite" status even after being added to a project + ## E. Assistants - [ ] There is always at least one default Assistant which is Jan - [ ] The default Jan assistant has `stream = True` by default @@ -238,6 +264,7 @@ Ensure that the following section information show up for hardware In `Settings -> General`: - [ ] Change the location of the `App Data` to some other path that is not the default path +- [ ] [0.7.0] Users cannot set the data location to root directories (e.g., C:\, D:\ on Windows), but can select subfolders within those drives (e.g., C:\data, D:\data) - [ ] Click on `Reset` button in `Other` to factory reset the app: - [ ] All threads deleted - [ ] All Assistant deleted except for default Jan Assistant