From c5a5968bf8f67952f667ce4c3337178daacb293c Mon Sep 17 00:00:00 2001 From: Nghia Doan Date: Mon, 29 Sep 2025 22:15:13 +0700 Subject: [PATCH 01/54] 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/54] 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/54] 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/54] 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/54] 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/54] 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/54] 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/54] 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/54] 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/54] 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/54] 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/54] 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 From 9720ad368e459d48f1857631a5ccf9a47b0b4c8c Mon Sep 17 00:00:00 2001 From: Vanalite Date: Mon, 29 Sep 2025 11:02:55 +0700 Subject: [PATCH 13/54] feat: use sql for mobile storage --- autoqa/scripts/setup-android-env.sh | 4 +- extensions/yarn.lock | 24 +- package.json | 10 +- src-tauri/Cargo.lock | 513 ++++++++++++++++++++++ src-tauri/Cargo.toml | 2 + src-tauri/src/core/extensions/commands.rs | 62 +-- src-tauri/src/core/setup.rs | 7 + src-tauri/src/core/threads/commands.rs | 59 ++- src-tauri/src/core/threads/db.rs | 406 +++++++++++++++++ src-tauri/src/core/threads/mod.rs | 1 + src-tauri/src/lib.rs | 12 + src-tauri/tauri.android.conf.json | 10 +- src-tauri/tauri.ios.conf.json | 11 +- web-app/src/lib/platform/const.ts | 8 +- web-app/src/services/core/mobile.ts | 53 +++ web-app/src/services/index.ts | 47 +- web-app/tsconfig.app.json | 3 +- web-app/tsconfig.json | 3 +- web-app/vite.config.ts | 1 + 19 files changed, 1180 insertions(+), 56 deletions(-) create mode 100644 src-tauri/src/core/threads/db.rs create mode 100644 web-app/src/services/core/mobile.ts diff --git a/autoqa/scripts/setup-android-env.sh b/autoqa/scripts/setup-android-env.sh index 62adc079f..2cf18ae8f 100755 --- a/autoqa/scripts/setup-android-env.sh +++ b/autoqa/scripts/setup-android-env.sh @@ -25,8 +25,8 @@ export RANLIB_aarch64_linux_android="$NDK_HOME/toolchains/llvm/prebuilt/darwin-x # Additional environment variables for Rust cross-compilation export CARGO_TARGET_AARCH64_LINUX_ANDROID_LINKER="$NDK_HOME/toolchains/llvm/prebuilt/darwin-x86_64/bin/aarch64-linux-android21-clang" -# Only set global CC and AR for Android builds (when TAURI_ANDROID_BUILD is set) -if [ "$TAURI_ANDROID_BUILD" = "true" ]; then +# Only set global CC and AR for Android builds (when IS_ANDROID is set) +if [ "$IS_ANDROID" = "true" ]; then export CC="$NDK_HOME/toolchains/llvm/prebuilt/darwin-x86_64/bin/aarch64-linux-android21-clang" export AR="$NDK_HOME/toolchains/llvm/prebuilt/darwin-x86_64/bin/llvm-ar" echo "Global CC and AR set for Android build" diff --git a/extensions/yarn.lock b/extensions/yarn.lock index f4a58c14f..c5f37ba35 100644 --- a/extensions/yarn.lock +++ b/extensions/yarn.lock @@ -342,41 +342,49 @@ __metadata: "@janhq/core@file:../../core/package.tgz::locator=%40janhq%2Fassistant-extension%40workspace%3Aassistant-extension": version: 0.1.10 - resolution: "@janhq/core@file:../../core/package.tgz#../../core/package.tgz::hash=f15485&locator=%40janhq%2Fassistant-extension%40workspace%3Aassistant-extension" + resolution: "@janhq/core@file:../../core/package.tgz#../../core/package.tgz::hash=bfbced&locator=%40janhq%2Fassistant-extension%40workspace%3Aassistant-extension" dependencies: rxjs: "npm:^7.8.1" ulidx: "npm:^2.3.0" - checksum: 10c0/257621cb56db31a4dd3a2b509ec4c61217022e74bbd39cf6a1a172073654b9a65eee94ef9c1b4d4f5d2231d159c8818cb02846f3d88fe14f102f43169ad3737c + peerDependencies: + react: 19.0.0 + checksum: 10c0/67ebb430e1e8433441ce3b24d0ce88ce3f079d99c6518bf71492edeaefbc7a774b5f17c6f34282941e466a30787b711a0779ccb0fd28fe8376f1967edb581b53 languageName: node linkType: hard "@janhq/core@file:../../core/package.tgz::locator=%40janhq%2Fconversational-extension%40workspace%3Aconversational-extension": version: 0.1.10 - resolution: "@janhq/core@file:../../core/package.tgz#../../core/package.tgz::hash=f15485&locator=%40janhq%2Fconversational-extension%40workspace%3Aconversational-extension" + resolution: "@janhq/core@file:../../core/package.tgz#../../core/package.tgz::hash=bfbced&locator=%40janhq%2Fconversational-extension%40workspace%3Aconversational-extension" dependencies: rxjs: "npm:^7.8.1" ulidx: "npm:^2.3.0" - checksum: 10c0/257621cb56db31a4dd3a2b509ec4c61217022e74bbd39cf6a1a172073654b9a65eee94ef9c1b4d4f5d2231d159c8818cb02846f3d88fe14f102f43169ad3737c + peerDependencies: + react: 19.0.0 + checksum: 10c0/67ebb430e1e8433441ce3b24d0ce88ce3f079d99c6518bf71492edeaefbc7a774b5f17c6f34282941e466a30787b711a0779ccb0fd28fe8376f1967edb581b53 languageName: node linkType: hard "@janhq/core@file:../../core/package.tgz::locator=%40janhq%2Fdownload-extension%40workspace%3Adownload-extension": version: 0.1.10 - resolution: "@janhq/core@file:../../core/package.tgz#../../core/package.tgz::hash=f15485&locator=%40janhq%2Fdownload-extension%40workspace%3Adownload-extension" + resolution: "@janhq/core@file:../../core/package.tgz#../../core/package.tgz::hash=bfbced&locator=%40janhq%2Fdownload-extension%40workspace%3Adownload-extension" dependencies: rxjs: "npm:^7.8.1" ulidx: "npm:^2.3.0" - checksum: 10c0/257621cb56db31a4dd3a2b509ec4c61217022e74bbd39cf6a1a172073654b9a65eee94ef9c1b4d4f5d2231d159c8818cb02846f3d88fe14f102f43169ad3737c + peerDependencies: + react: 19.0.0 + checksum: 10c0/67ebb430e1e8433441ce3b24d0ce88ce3f079d99c6518bf71492edeaefbc7a774b5f17c6f34282941e466a30787b711a0779ccb0fd28fe8376f1967edb581b53 languageName: node linkType: hard "@janhq/core@file:../../core/package.tgz::locator=%40janhq%2Fllamacpp-extension%40workspace%3Allamacpp-extension": version: 0.1.10 - resolution: "@janhq/core@file:../../core/package.tgz#../../core/package.tgz::hash=f15485&locator=%40janhq%2Fllamacpp-extension%40workspace%3Allamacpp-extension" + resolution: "@janhq/core@file:../../core/package.tgz#../../core/package.tgz::hash=bfbced&locator=%40janhq%2Fllamacpp-extension%40workspace%3Allamacpp-extension" dependencies: rxjs: "npm:^7.8.1" ulidx: "npm:^2.3.0" - checksum: 10c0/257621cb56db31a4dd3a2b509ec4c61217022e74bbd39cf6a1a172073654b9a65eee94ef9c1b4d4f5d2231d159c8818cb02846f3d88fe14f102f43169ad3737c + peerDependencies: + react: 19.0.0 + checksum: 10c0/67ebb430e1e8433441ce3b24d0ce88ce3f079d99c6518bf71492edeaefbc7a774b5f17c6f34282941e466a30787b711a0779ccb0fd28fe8376f1967edb581b53 languageName: node linkType: hard diff --git a/package.json b/package.json index cf3767e66..bc209f025 100644 --- a/package.json +++ b/package.json @@ -26,11 +26,11 @@ "serve:web-app": "yarn workspace @janhq/web-app serve:web", "build:serve:web-app": "yarn build:web-app && yarn serve:web-app", "dev:tauri": "yarn build:icon && yarn copy:assets:tauri && cross-env IS_CLEAN=true tauri dev", - "dev:ios": "yarn build:extensions-web && yarn copy:assets:mobile && RUSTC_WRAPPER= yarn tauri ios dev --features mobile", - "dev:android": "yarn build:extensions-web && yarn copy:assets:mobile && cross-env IS_CLEAN=true TAURI_ANDROID_BUILD=true yarn tauri android dev --features mobile", - "build:android": "yarn build:icon && yarn copy:assets:mobile && cross-env IS_CLEAN=true TAURI_ANDROID_BUILD=true yarn tauri android build -- --no-default-features --features mobile", - "build:ios": "yarn copy:assets:mobile && yarn tauri ios build -- --no-default-features --features mobile", - "build:ios:device": "yarn build:icon && yarn copy:assets:mobile && yarn tauri ios build -- --no-default-features --features mobile --export-method debugging", + "dev:ios": "yarn copy:assets:mobile && RUSTC_WRAPPER= cross-env IS_IOS=true yarn tauri ios dev --features mobile", + "dev:android": "yarn copy:assets:mobile && cross-env IS_ANDROID=true yarn tauri android dev --features mobile", + "build:android": "yarn build:icon && yarn copy:assets:mobile && cross-env IS_CLEAN=true yarn tauri android build -- --no-default-features --features mobile", + "build:ios": "yarn build:icon && yarn copy:assets:mobile && cross-env IS_IOS=true yarn tauri ios build -- --no-default-features --features mobile", + "build:ios:device": "yarn build:icon && yarn copy:assets:mobile && cross-env IS_IOS=true yarn tauri ios build -- --no-default-features --features mobile --export-method debugging", "copy:assets:tauri": "cpx \"pre-install/*.tgz\" \"src-tauri/resources/pre-install/\" && cpx \"LICENSE\" \"src-tauri/resources/\"", "copy:assets:mobile": "cpx \"pre-install/*.tgz\" \"src-tauri/resources/pre-install/\" && cpx \"LICENSE\" \"src-tauri/resources/\"", "download:lib": "node ./scripts/download-lib.mjs", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index da2ca059e..fc36377c8 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -23,6 +23,7 @@ dependencies = [ "serde", "serde_json", "serde_yaml", + "sqlx", "tar", "tauri", "tauri-build", @@ -107,6 +108,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + [[package]] name = "android-tzdata" version = "0.1.1" @@ -344,6 +351,15 @@ dependencies = [ "system-deps", ] +[[package]] +name = "atoi" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" +dependencies = [ + "num-traits", +] + [[package]] name = "atomic-waker" version = "1.1.2" @@ -734,6 +750,12 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + [[package]] name = "const-random" version = "0.1.18" @@ -865,6 +887,21 @@ dependencies = [ "libc", ] +[[package]] +name = "crc" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9710d3b3739c2e349eb44fe848ad0b7c8cb1e42bd87ee49371df2f7acaf3e675" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" + [[package]] name = "crc32fast" version = "1.5.0" @@ -1027,6 +1064,17 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5c297a1c74b71ae29df00c3e22dd9534821d60eb9af5a0192823fa2acea70c2a" +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + [[package]] name = "deranged" version = "0.4.0" @@ -1068,6 +1116,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", + "const-oid", "crypto-common", "subtle", ] @@ -1172,6 +1221,12 @@ dependencies = [ "litrs", ] +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + [[package]] name = "downcast-rs" version = "1.2.1" @@ -1214,6 +1269,15 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +dependencies = [ + "serde", +] + [[package]] name = "embed-resource" version = "3.0.5" @@ -1315,6 +1379,17 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "etcetera" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "136d1b5283a1ab77bd9257427ffd09d8667ced0570b6f938942bc7568ed5b943" +dependencies = [ + "cfg-if", + "home", + "windows-sys 0.48.0", +] + [[package]] name = "event-listener" version = "5.4.1" @@ -1402,12 +1477,29 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "flume" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" +dependencies = [ + "futures-core", + "futures-sink", + "spin", +] + [[package]] name = "fnv" version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "foreign-types" version = "0.3.2" @@ -1517,6 +1609,17 @@ dependencies = [ "futures-util", ] +[[package]] +name = "futures-intrusive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" +dependencies = [ + "futures-core", + "lock_api", + "parking_lot", +] + [[package]] name = "futures-io" version = "0.3.31" @@ -1966,6 +2069,20 @@ name = "hashbrown" version = "0.15.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5971ac85611da7067dbfcabef3c70ebb5606018acd9e2a3903a0da507521e0d5" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hashlink" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +dependencies = [ + "hashbrown 0.15.4", +] [[package]] name = "heck" @@ -1991,6 +2108,15 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + [[package]] name = "hmac" version = "0.12.1" @@ -2566,6 +2692,9 @@ name = "lazy_static" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin", +] [[package]] name = "libappindicator" @@ -2617,6 +2746,12 @@ dependencies = [ "windows-targets 0.53.3", ] +[[package]] +name = "libm" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de" + [[package]] name = "libredox" version = "0.1.9" @@ -2628,6 +2763,17 @@ dependencies = [ "redox_syscall", ] +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + [[package]] name = "linux-raw-sys" version = "0.9.4" @@ -2717,6 +2863,16 @@ version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5" +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", +] + [[package]] name = "memchr" version = "2.7.5" @@ -2867,12 +3023,49 @@ dependencies = [ "winapi", ] +[[package]] +name = "num-bigint-dig" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc84195820f291c7697304f3cbdadd1cb7199c0efc917ff5eafd71225c136151" +dependencies = [ + "byteorder", + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand 0.8.5", + "smallvec", + "zeroize", +] + [[package]] name = "num-conv" version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -2880,6 +3073,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", + "libm", ] [[package]] @@ -3426,6 +3620,15 @@ dependencies = [ "sha2", ] +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + [[package]] name = "percent-encoding" version = "2.3.1" @@ -3589,6 +3792,27 @@ dependencies = [ "futures-io", ] +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + [[package]] name = "pkg-config" version = "0.3.32" @@ -4272,6 +4496,26 @@ dependencies = [ "syn 2.0.104", ] +[[package]] +name = "rsa" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78928ac1ed176a5ca1d17e578a1825f3d81ca54cf41053a592584b020cfd691b" +dependencies = [ + "const-oid", + "digest", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core 0.6.4", + "signature", + "spki", + "subtle", + "zeroize", +] + [[package]] name = "rust-ini" version = "0.21.2" @@ -4780,6 +5024,16 @@ dependencies = [ "libc", ] +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core 0.6.4", +] + [[package]] name = "simd-adler32" version = "0.3.7" @@ -4815,6 +5069,9 @@ name = "smallvec" version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +dependencies = [ + "serde", +] [[package]] name = "socket2" @@ -4884,6 +5141,213 @@ dependencies = [ "system-deps", ] +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +dependencies = [ + "lock_api", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "sqlx" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fefb893899429669dcdd979aff487bd78f4064e5e7907e4269081e0ef7d97dc" +dependencies = [ + "sqlx-core", + "sqlx-macros", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", +] + +[[package]] +name = "sqlx-core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6" +dependencies = [ + "base64 0.22.1", + "bytes", + "crc", + "crossbeam-queue", + "either", + "event-listener", + "futures-core", + "futures-intrusive", + "futures-io", + "futures-util", + "hashbrown 0.15.4", + "hashlink", + "indexmap 2.10.0", + "log", + "memchr", + "once_cell", + "percent-encoding", + "serde", + "serde_json", + "sha2", + "smallvec", + "thiserror 2.0.12", + "tokio", + "tokio-stream", + "tracing", + "url", +] + +[[package]] +name = "sqlx-macros" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2d452988ccaacfbf5e0bdbc348fb91d7c8af5bee192173ac3636b5fb6e6715d" +dependencies = [ + "proc-macro2", + "quote", + "sqlx-core", + "sqlx-macros-core", + "syn 2.0.104", +] + +[[package]] +name = "sqlx-macros-core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19a9c1841124ac5a61741f96e1d9e2ec77424bf323962dd894bdb93f37d5219b" +dependencies = [ + "dotenvy", + "either", + "heck 0.5.0", + "hex", + "once_cell", + "proc-macro2", + "quote", + "serde", + "serde_json", + "sha2", + "sqlx-core", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", + "syn 2.0.104", + "tokio", + "url", +] + +[[package]] +name = "sqlx-mysql" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" +dependencies = [ + "atoi", + "base64 0.22.1", + "bitflags 2.9.1", + "byteorder", + "bytes", + "crc", + "digest", + "dotenvy", + "either", + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "generic-array", + "hex", + "hkdf", + "hmac", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "percent-encoding", + "rand 0.8.5", + "rsa", + "serde", + "sha1", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror 2.0.12", + "tracing", + "whoami", +] + +[[package]] +name = "sqlx-postgres" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" +dependencies = [ + "atoi", + "base64 0.22.1", + "bitflags 2.9.1", + "byteorder", + "crc", + "dotenvy", + "etcetera", + "futures-channel", + "futures-core", + "futures-util", + "hex", + "hkdf", + "hmac", + "home", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "rand 0.8.5", + "serde", + "serde_json", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror 2.0.12", + "tracing", + "whoami", +] + +[[package]] +name = "sqlx-sqlite" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea" +dependencies = [ + "atoi", + "flume", + "futures-channel", + "futures-core", + "futures-executor", + "futures-intrusive", + "futures-util", + "libsqlite3-sys", + "log", + "percent-encoding", + "serde", + "serde_urlencoded", + "sqlx-core", + "thiserror 2.0.12", + "tracing", + "url", +] + [[package]] name = "sse-stream" version = "0.2.1" @@ -4934,6 +5398,17 @@ dependencies = [ "quote", ] +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + [[package]] name = "strip-ansi-escapes" version = "0.2.1" @@ -6037,6 +6512,7 @@ version = "0.1.41" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" dependencies = [ + "log", "pin-project-lite", "tracing-attributes", "tracing-core", @@ -6154,12 +6630,33 @@ dependencies = [ "unic-common", ] +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + [[package]] name = "unicode-ident" version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" +[[package]] +name = "unicode-normalization" +version = "0.1.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5033c97c4262335cded6d6fc3e5c18ab755e1a3dc96376350f3d8e9f009ad956" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-properties" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e70f2a8b45122e719eb623c01822704c4e0907e7e426a05927e1a1cfff5b75d0" + [[package]] name = "unicode-segmentation" version = "1.12.0" @@ -6376,6 +6873,12 @@ dependencies = [ "wit-bindgen-rt", ] +[[package]] +name = "wasite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" + [[package]] name = "wasm-bindgen" version = "0.2.100" @@ -6629,6 +7132,16 @@ dependencies = [ "windows-core 0.61.2", ] +[[package]] +name = "whoami" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d" +dependencies = [ + "libredox", + "wasite", +] + [[package]] name = "winapi" version = "0.3.9" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 43738b032..6d1eeefd4 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -35,6 +35,7 @@ mobile = [ "tauri/protocol-asset", "tauri/test", "tauri/wry", + "dep:sqlx", ] test-tauri = [ "tauri/wry", @@ -83,6 +84,7 @@ tauri-plugin-opener = "2.2.7" tauri-plugin-os = "2.2.1" tauri-plugin-shell = "2.2.0" tauri-plugin-store = "2" +sqlx = { version = "0.8", features = ["runtime-tokio", "sqlite"], optional = true } thiserror = "2.0.12" tokio = { version = "1", features = ["full"] } tokio-util = "0.7.14" diff --git a/src-tauri/src/core/extensions/commands.rs b/src-tauri/src/core/extensions/commands.rs index 4c5a44a53..c61d744a7 100644 --- a/src-tauri/src/core/extensions/commands.rs +++ b/src-tauri/src/core/extensions/commands.rs @@ -19,35 +19,45 @@ pub fn install_extensions(app: AppHandle) { #[tauri::command] pub fn get_active_extensions(app: AppHandle) -> Vec { - let mut path = get_jan_extensions_path(app); - path.push("extensions.json"); - log::info!("get jan extensions, path: {:?}", path); + // On mobile platforms, extensions are pre-bundled in the frontend + // Return empty array so frontend's MobileCoreService handles it + #[cfg(any(target_os = "android", target_os = "ios"))] + { + return vec![]; + } - let contents = fs::read_to_string(path); - let contents: Vec = match contents { - Ok(data) => match serde_json::from_str::>(&data) { - Ok(exts) => exts - .into_iter() - .map(|ext| { - serde_json::json!({ - "url": ext["url"], - "name": ext["name"], - "productName": ext["productName"], - "active": ext["_active"], - "description": ext["description"], - "version": ext["version"] + #[cfg(not(any(target_os = "android", target_os = "ios")))] + { + let mut path = get_jan_extensions_path(app); + path.push("extensions.json"); + log::info!("get jan extensions, path: {:?}", path); + + let contents = fs::read_to_string(path); + let contents: Vec = match contents { + Ok(data) => match serde_json::from_str::>(&data) { + Ok(exts) => exts + .into_iter() + .map(|ext| { + serde_json::json!({ + "url": ext["url"], + "name": ext["name"], + "productName": ext["productName"], + "active": ext["_active"], + "description": ext["description"], + "version": ext["version"] + }) }) - }) - .collect(), + .collect(), + Err(error) => { + log::error!("Failed to parse extensions.json: {}", error); + vec![] + } + }, Err(error) => { - log::error!("Failed to parse extensions.json: {}", error); + log::error!("Failed to read extensions.json: {}", error); vec![] } - }, - Err(error) => { - log::error!("Failed to read extensions.json: {}", error); - vec![] - } - }; - return contents; + }; + return contents; + } } diff --git a/src-tauri/src/core/setup.rs b/src-tauri/src/core/setup.rs index 38eca440e..4eebaedb7 100644 --- a/src-tauri/src/core/setup.rs +++ b/src-tauri/src/core/setup.rs @@ -24,6 +24,13 @@ use super::{ }; pub fn install_extensions(app: tauri::AppHandle, force: bool) -> Result<(), String> { + // Skip extension installation on mobile platforms + // Mobile uses pre-bundled extensions loaded via MobileCoreService in the frontend + #[cfg(any(target_os = "android", target_os = "ios"))] + { + return Ok(()); + } + let extensions_path = get_jan_extensions_path(app.clone()); let pre_install_path = app .path() diff --git a/src-tauri/src/core/threads/commands.rs b/src-tauri/src/core/threads/commands.rs index 44ac1964d..034d7d536 100644 --- a/src-tauri/src/core/threads/commands.rs +++ b/src-tauri/src/core/threads/commands.rs @@ -3,6 +3,7 @@ use std::io::Write; use tauri::Runtime; use uuid::Uuid; +use super::db; use super::helpers::{ get_lock_for_thread, read_messages_from_file, update_thread_metadata, write_messages_to_file, }; @@ -14,12 +15,18 @@ use super::{ }, }; -/// Lists all threads by reading their metadata from the threads directory. +/// Lists all threads by reading their metadata from the threads directory or database. /// Returns a vector of thread metadata as JSON values. #[tauri::command] pub async fn list_threads( app_handle: tauri::AppHandle, ) -> Result, String> { + if db::should_use_sqlite() { + // Use SQLite on mobile platforms + return db::db_list_threads(app_handle).await; + } + + // Use file-based storage on desktop ensure_data_dirs(app_handle.clone())?; let data_dir = get_data_dir(app_handle.clone()); let mut threads = Vec::new(); @@ -56,6 +63,11 @@ pub async fn create_thread( app_handle: tauri::AppHandle, mut thread: serde_json::Value, ) -> Result { + if db::should_use_sqlite() { + return db::db_create_thread(app_handle, thread).await; + } + + // Use file-based storage on desktop ensure_data_dirs(app_handle.clone())?; let uuid = Uuid::new_v4().to_string(); thread["id"] = serde_json::Value::String(uuid.clone()); @@ -76,6 +88,11 @@ pub async fn modify_thread( app_handle: tauri::AppHandle, thread: serde_json::Value, ) -> Result<(), String> { + if db::should_use_sqlite() { + return db::db_modify_thread(app_handle, thread).await; + } + + // Use file-based storage on desktop let thread_id = thread .get("id") .and_then(|id| id.as_str()) @@ -96,6 +113,11 @@ pub async fn delete_thread( app_handle: tauri::AppHandle, thread_id: String, ) -> Result<(), String> { + if db::should_use_sqlite() { + return db::db_delete_thread(app_handle, &thread_id).await; + } + + // Use file-based storage on desktop let thread_dir = get_thread_dir(app_handle.clone(), &thread_id); if thread_dir.exists() { let _ = fs::remove_dir_all(thread_dir); @@ -110,6 +132,11 @@ pub async fn list_messages( app_handle: tauri::AppHandle, thread_id: String, ) -> Result, String> { + if db::should_use_sqlite() { + return db::db_list_messages(app_handle, &thread_id).await; + } + + // Use file-based storage on desktop read_messages_from_file(app_handle, &thread_id) } @@ -120,6 +147,11 @@ pub async fn create_message( app_handle: tauri::AppHandle, mut message: serde_json::Value, ) -> Result { + if db::should_use_sqlite() { + return db::db_create_message(app_handle, message).await; + } + + // Use file-based storage on desktop let thread_id = { let id = message .get("thread_id") @@ -166,6 +198,11 @@ pub async fn modify_message( app_handle: tauri::AppHandle, message: serde_json::Value, ) -> Result { + if db::should_use_sqlite() { + return db::db_modify_message(app_handle, message).await; + } + + // Use file-based storage on desktop let thread_id = message .get("thread_id") .and_then(|v| v.as_str()) @@ -204,6 +241,11 @@ pub async fn delete_message( thread_id: String, message_id: String, ) -> Result<(), String> { + if db::should_use_sqlite() { + return db::db_delete_message(app_handle, &thread_id, &message_id).await; + } + + // Use file-based storage on desktop // Acquire per-thread lock before modifying { let lock = get_lock_for_thread(&thread_id).await; @@ -227,6 +269,11 @@ pub async fn get_thread_assistant( app_handle: tauri::AppHandle, thread_id: String, ) -> Result { + if db::should_use_sqlite() { + return db::db_get_thread_assistant(app_handle, &thread_id).await; + } + + // Use file-based storage on desktop let path = get_thread_metadata_path(app_handle, &thread_id); if !path.exists() { return Err("Thread not found".to_string()); @@ -252,6 +299,11 @@ pub async fn create_thread_assistant( thread_id: String, assistant: serde_json::Value, ) -> Result { + if db::should_use_sqlite() { + return db::db_create_thread_assistant(app_handle, &thread_id, assistant).await; + } + + // Use file-based storage on desktop let path = get_thread_metadata_path(app_handle.clone(), &thread_id); if !path.exists() { return Err("Thread not found".to_string()); @@ -277,6 +329,11 @@ pub async fn modify_thread_assistant( thread_id: String, assistant: serde_json::Value, ) -> Result { + if db::should_use_sqlite() { + return db::db_modify_thread_assistant(app_handle, &thread_id, assistant).await; + } + + // Use file-based storage on desktop let path = get_thread_metadata_path(app_handle.clone(), &thread_id); if !path.exists() { return Err("Thread not found".to_string()); diff --git a/src-tauri/src/core/threads/db.rs b/src-tauri/src/core/threads/db.rs new file mode 100644 index 000000000..0d8e88438 --- /dev/null +++ b/src-tauri/src/core/threads/db.rs @@ -0,0 +1,406 @@ +/*! + SQLite Database Module for Mobile Thread Storage + + This module provides SQLite-based storage for threads and messages on mobile platforms. + It ensures data persistence and retrieval work correctly on Android and iOS devices. + + Note: This module is only compiled and used on mobile platforms (Android/iOS). + On desktop, the file-based storage in helpers.rs is used instead. +*/ + +#![allow(dead_code)] // Functions only used on mobile platforms + +use serde_json::Value; +use sqlx::sqlite::{SqliteConnectOptions, SqlitePool, SqlitePoolOptions}; +use sqlx::Row; +use std::str::FromStr; +use std::sync::OnceLock; +use tauri::{AppHandle, Manager, Runtime}; +use tokio::sync::Mutex; + +const DB_NAME: &str = "jan.db"; + +/// Global database pool for mobile platforms +static DB_POOL: OnceLock>> = OnceLock::new(); + +/// Check if the platform should use SQLite (mobile platforms) +pub fn should_use_sqlite() -> bool { + cfg!(any(target_os = "android", target_os = "ios")) +} + +/// Initialize database with connection pool and run migrations +pub async fn init_database(app: &AppHandle) -> Result<(), String> { + if !should_use_sqlite() { + return Ok(()); // Skip DB initialization on desktop + } + + // Get app data directory + let app_data_dir = app + .path() + .app_data_dir() + .map_err(|e| format!("Failed to get app data dir: {}", e))?; + + // Ensure directory exists + std::fs::create_dir_all(&app_data_dir) + .map_err(|e| format!("Failed to create app data dir: {}", e))?; + + // Create database path + let db_path = app_data_dir.join(DB_NAME); + let db_url = format!("sqlite:{}", db_path.display()); + + log::info!("Initializing SQLite database at: {}", db_url); + + // Create connection options + let connect_options = SqliteConnectOptions::from_str(&db_url) + .map_err(|e| format!("Failed to parse connection options: {}", e))? + .create_if_missing(true); + + // Create connection pool + let pool = SqlitePoolOptions::new() + .max_connections(5) + .connect_with(connect_options) + .await + .map_err(|e| format!("Failed to create connection pool: {}", e))?; + + // Run migrations + sqlx::query( + r#" + CREATE TABLE IF NOT EXISTS threads ( + id TEXT PRIMARY KEY, + data TEXT NOT NULL, + created_at INTEGER DEFAULT (strftime('%s', 'now')), + updated_at INTEGER DEFAULT (strftime('%s', 'now')) + ); + "#, + ) + .execute(&pool) + .await + .map_err(|e| format!("Failed to create threads table: {}", e))?; + + sqlx::query( + r#" + CREATE TABLE IF NOT EXISTS messages ( + id TEXT PRIMARY KEY, + thread_id TEXT NOT NULL, + data TEXT NOT NULL, + created_at INTEGER DEFAULT (strftime('%s', 'now')), + FOREIGN KEY (thread_id) REFERENCES threads(id) ON DELETE CASCADE + ); + "#, + ) + .execute(&pool) + .await + .map_err(|e| format!("Failed to create messages table: {}", e))?; + + // Create indexes + sqlx::query( + "CREATE INDEX IF NOT EXISTS idx_messages_thread_id ON messages(thread_id);", + ) + .execute(&pool) + .await + .map_err(|e| format!("Failed to create thread_id index: {}", e))?; + + sqlx::query( + "CREATE INDEX IF NOT EXISTS idx_messages_created_at ON messages(created_at);", + ) + .execute(&pool) + .await + .map_err(|e| format!("Failed to create created_at index: {}", e))?; + + // Store pool globally + DB_POOL + .get_or_init(|| Mutex::new(None)) + .lock() + .await + .replace(pool); + + log::info!("SQLite database initialized successfully for mobile platform"); + Ok(()) +} + +/// Get database pool +async fn get_pool() -> Result { + let pool_mutex = DB_POOL + .get() + .ok_or("Database not initialized")?; + + let pool_guard = pool_mutex.lock().await; + pool_guard + .clone() + .ok_or("Database pool not available".to_string()) +} + +/// List all threads from database +pub async fn db_list_threads( + _app_handle: AppHandle, +) -> Result, String> { + let pool = get_pool().await?; + + let rows = sqlx::query("SELECT data FROM threads ORDER BY updated_at DESC") + .fetch_all(&pool) + .await + .map_err(|e| format!("Failed to list threads: {}", e))?; + + let threads: Result, _> = rows + .iter() + .map(|row| { + let data: String = row.get("data"); + serde_json::from_str(&data).map_err(|e| e.to_string()) + }) + .collect(); + + threads +} + +/// Create a new thread in database +pub async fn db_create_thread( + _app_handle: AppHandle, + thread: Value, +) -> Result { + let pool = get_pool().await?; + + let thread_id = thread + .get("id") + .and_then(|v| v.as_str()) + .ok_or("Missing thread id")?; + + let data = serde_json::to_string(&thread).map_err(|e| e.to_string())?; + + sqlx::query("INSERT INTO threads (id, data) VALUES (?1, ?2)") + .bind(thread_id) + .bind(&data) + .execute(&pool) + .await + .map_err(|e| format!("Failed to create thread: {}", e))?; + + Ok(thread) +} + +/// Modify an existing thread in database +pub async fn db_modify_thread( + _app_handle: AppHandle, + thread: Value, +) -> Result<(), String> { + let pool = get_pool().await?; + + let thread_id = thread + .get("id") + .and_then(|v| v.as_str()) + .ok_or("Missing thread id")?; + + let data = serde_json::to_string(&thread).map_err(|e| e.to_string())?; + + sqlx::query("UPDATE threads SET data = ?1, updated_at = strftime('%s', 'now') WHERE id = ?2") + .bind(&data) + .bind(thread_id) + .execute(&pool) + .await + .map_err(|e| format!("Failed to modify thread: {}", e))?; + + Ok(()) +} + +/// Delete a thread from database +pub async fn db_delete_thread( + _app_handle: AppHandle, + thread_id: &str, +) -> Result<(), String> { + let pool = get_pool().await?; + + // Messages will be auto-deleted via CASCADE + sqlx::query("DELETE FROM threads WHERE id = ?1") + .bind(thread_id) + .execute(&pool) + .await + .map_err(|e| format!("Failed to delete thread: {}", e))?; + + Ok(()) +} + +/// List all messages for a thread from database +pub async fn db_list_messages( + _app_handle: AppHandle, + thread_id: &str, +) -> Result, String> { + let pool = get_pool().await?; + + let rows = sqlx::query( + "SELECT data FROM messages WHERE thread_id = ?1 ORDER BY created_at ASC", + ) + .bind(thread_id) + .fetch_all(&pool) + .await + .map_err(|e| format!("Failed to list messages: {}", e))?; + + let messages: Result, _> = rows + .iter() + .map(|row| { + let data: String = row.get("data"); + serde_json::from_str(&data).map_err(|e| e.to_string()) + }) + .collect(); + + messages +} + +/// Create a new message in database +pub async fn db_create_message( + _app_handle: AppHandle, + message: Value, +) -> Result { + let pool = get_pool().await?; + + let message_id = message + .get("id") + .and_then(|v| v.as_str()) + .ok_or("Missing message id")?; + + let thread_id = message + .get("thread_id") + .and_then(|v| v.as_str()) + .ok_or("Missing thread_id")?; + + let data = serde_json::to_string(&message).map_err(|e| e.to_string())?; + + sqlx::query("INSERT INTO messages (id, thread_id, data) VALUES (?1, ?2, ?3)") + .bind(message_id) + .bind(thread_id) + .bind(&data) + .execute(&pool) + .await + .map_err(|e| format!("Failed to create message: {}", e))?; + + Ok(message) +} + +/// Modify an existing message in database +pub async fn db_modify_message( + _app_handle: AppHandle, + message: Value, +) -> Result { + let pool = get_pool().await?; + + let message_id = message + .get("id") + .and_then(|v| v.as_str()) + .ok_or("Missing message id")?; + + let data = serde_json::to_string(&message).map_err(|e| e.to_string())?; + + sqlx::query("UPDATE messages SET data = ?1 WHERE id = ?2") + .bind(&data) + .bind(message_id) + .execute(&pool) + .await + .map_err(|e| format!("Failed to modify message: {}", e))?; + + Ok(message) +} + +/// Delete a message from database +pub async fn db_delete_message( + _app_handle: AppHandle, + _thread_id: &str, + message_id: &str, +) -> Result<(), String> { + let pool = get_pool().await?; + + sqlx::query("DELETE FROM messages WHERE id = ?1") + .bind(message_id) + .execute(&pool) + .await + .map_err(|e| format!("Failed to delete message: {}", e))?; + + Ok(()) +} + +/// Get thread assistant information from thread metadata +pub async fn db_get_thread_assistant( + _app_handle: AppHandle, + thread_id: &str, +) -> Result { + let pool = get_pool().await?; + + let row = sqlx::query("SELECT data FROM threads WHERE id = ?1") + .bind(thread_id) + .fetch_optional(&pool) + .await + .map_err(|e| format!("Failed to get thread: {}", e))? + .ok_or("Thread not found")?; + + let data: String = row.get("data"); + let thread: Value = serde_json::from_str(&data).map_err(|e| e.to_string())?; + + if let Some(assistants) = thread.get("assistants").and_then(|a| a.as_array()) { + assistants + .first() + .cloned() + .ok_or("Assistant not found".to_string()) + } else { + Err("Assistant not found".to_string()) + } +} + +/// Create thread assistant in database +pub async fn db_create_thread_assistant( + app_handle: AppHandle, + thread_id: &str, + assistant: Value, +) -> Result { + let pool = get_pool().await?; + + let row = sqlx::query("SELECT data FROM threads WHERE id = ?1") + .bind(thread_id) + .fetch_optional(&pool) + .await + .map_err(|e| format!("Failed to get thread: {}", e))? + .ok_or("Thread not found")?; + + let data: String = row.get("data"); + let mut thread: Value = serde_json::from_str(&data).map_err(|e| e.to_string())?; + + if let Some(assistants) = thread.get_mut("assistants").and_then(|a| a.as_array_mut()) { + assistants.push(assistant.clone()); + } else { + thread["assistants"] = Value::Array(vec![assistant.clone()]); + } + + db_modify_thread(app_handle, thread).await?; + Ok(assistant) +} + +/// Modify thread assistant in database +pub async fn db_modify_thread_assistant( + app_handle: AppHandle, + thread_id: &str, + assistant: Value, +) -> Result { + let pool = get_pool().await?; + + let row = sqlx::query("SELECT data FROM threads WHERE id = ?1") + .bind(thread_id) + .fetch_optional(&pool) + .await + .map_err(|e| format!("Failed to get thread: {}", e))? + .ok_or("Thread not found")?; + + let data: String = row.get("data"); + let mut thread: Value = serde_json::from_str(&data).map_err(|e| e.to_string())?; + + let assistant_id = assistant + .get("id") + .and_then(|v| v.as_str()) + .ok_or("Missing assistant id")?; + + if let Some(assistants) = thread.get_mut("assistants").and_then(|a| a.as_array_mut()) { + if let Some(index) = assistants + .iter() + .position(|a| a.get("id").and_then(|v| v.as_str()) == Some(assistant_id)) + { + assistants[index] = assistant.clone(); + db_modify_thread(app_handle, thread).await?; + } + } + + Ok(assistant) +} diff --git a/src-tauri/src/core/threads/mod.rs b/src-tauri/src/core/threads/mod.rs index 25225d538..f868899dd 100644 --- a/src-tauri/src/core/threads/mod.rs +++ b/src-tauri/src/core/threads/mod.rs @@ -12,6 +12,7 @@ pub mod commands; mod constants; +pub mod db; pub mod helpers; pub mod utils; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index abd12ddb7..1b4acbdf4 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -180,6 +180,18 @@ pub fn run() { use tauri_plugin_deep_link::DeepLinkExt; app.deep_link().register_all()?; } + + // Initialize SQLite database for mobile platforms + #[cfg(any(target_os = "android", target_os = "ios"))] + { + let app_handle = app.handle().clone(); + tauri::async_runtime::spawn(async move { + if let Err(e) = crate::core::threads::db::init_database(&app_handle).await { + log::error!("Failed to initialize mobile database: {}", e); + } + }); + } + setup_mcp(app); Ok(()) }) diff --git a/src-tauri/tauri.android.conf.json b/src-tauri/tauri.android.conf.json index a0b795207..2f1144c20 100644 --- a/src-tauri/tauri.android.conf.json +++ b/src-tauri/tauri.android.conf.json @@ -2,7 +2,9 @@ "identifier": "jan.ai.app", "build": { "devUrl": null, - "frontendDist": "../web-app/dist" + "frontendDist": "../web-app/dist", + "beforeDevCommand": "cross-env IS_DEV=true IS_ANDROID=true yarn build:web", + "beforeBuildCommand": "cross-env IS_ANDROID=true yarn build:web" }, "app": { "security": { @@ -11,7 +13,11 @@ }, "plugins": {}, "bundle": { - "resources": ["resources/LICENSE"], + "active": true, + "resources": [ + "resources/pre-install/**/*", + "resources/LICENSE" + ], "externalBin": [], "android": { "minSdkVersion": 24 diff --git a/src-tauri/tauri.ios.conf.json b/src-tauri/tauri.ios.conf.json index 546cb4950..347f16bbd 100644 --- a/src-tauri/tauri.ios.conf.json +++ b/src-tauri/tauri.ios.conf.json @@ -1,9 +1,11 @@ { + "identifier": "jan.ai.app.ios", "build": { "devUrl": null, - "frontendDist": "../web-app/dist" + "frontendDist": "../web-app/dist", + "beforeDevCommand": "cross-env IS_DEV=true IS_IOS=true yarn build:web", + "beforeBuildCommand": "cross-env IS_IOS=true yarn build:web" }, - "identifier": "jan.ai.app", "app": { "security": { "capabilities": ["mobile"] @@ -15,7 +17,10 @@ "iOS": { "developmentTeam": "" }, - "resources": ["resources/LICENSE"], + "resources": [ + "resources/pre-install/**/*", + "resources/LICENSE" + ], "externalBin": [] } } \ No newline at end of file diff --git a/web-app/src/lib/platform/const.ts b/web-app/src/lib/platform/const.ts index d38455a49..9627b61ab 100644 --- a/web-app/src/lib/platform/const.ts +++ b/web-app/src/lib/platform/const.ts @@ -57,7 +57,7 @@ export const PlatformFeatures: Record = { // Extensions settings page - disabled for web [PlatformFeature.EXTENSIONS_SETTINGS]: - isPlatformTauri() && !isPlatformIOS() && !isPlatformAndroid(), + isPlatformTauri(), // Assistant functionality - disabled for web [PlatformFeature.ASSISTANTS]: isPlatformTauri(), @@ -74,9 +74,9 @@ export const PlatformFeatures: Record = { // Shortcut [PlatformFeature.SHORTCUT]: !isPlatformIOS() && !isPlatformAndroid(), - - // First message persisted thread - enabled for web only - [PlatformFeature.FIRST_MESSAGE_PERSISTED_THREAD]: !isPlatformTauri(), + + // First message persisted thread - enabled for web and mobile platforms + [PlatformFeature.FIRST_MESSAGE_PERSISTED_THREAD]: !isPlatformTauri() || isPlatformIOS() || isPlatformAndroid(), // Temporary chat mode - enabled for web only [PlatformFeature.TEMPORARY_CHAT]: !isPlatformTauri(), diff --git a/web-app/src/services/core/mobile.ts b/web-app/src/services/core/mobile.ts new file mode 100644 index 000000000..45df4d7fd --- /dev/null +++ b/web-app/src/services/core/mobile.ts @@ -0,0 +1,53 @@ +/** + * Mobile Core Service - Android/iOS implementation + * + * This service extends TauriCoreService but provides mobile-specific + * extension loading. Instead of reading extensions from the filesystem, + * it returns pre-bundled web extensions. + */ + +import { TauriCoreService } from './tauri' +import type { ExtensionManifest } from '@/lib/extension' +import JanConversationalExtension from '@janhq/conversational-extension' + +export class MobileCoreService extends TauriCoreService { + /** + * Override getActiveExtensions to return pre-loaded web extensions + * for mobile platforms where filesystem access is restricted. + */ + async getActiveExtensions(): Promise { + + // Return conversational extension as a pre-loaded instance + const conversationalExt = new JanConversationalExtension( + 'built-in', + '@janhq/conversational-extension', + 'Conversational Extension', + true, + 'Manages conversation threads and messages', + '1.0.0' + ) + + const extensions: ExtensionManifest[] = [ + { + name: '@janhq/conversational-extension', + productName: 'Conversational Extension', + url: 'built-in', // Not loaded from file, but bundled + active: true, + description: 'Manages conversation threads and messages', + version: '1.0.0', + extensionInstance: conversationalExt, // Pre-instantiated! + }, + ] + + return extensions + } + + /** + * Mobile-specific install extensions implementation + * On mobile, extensions are pre-bundled, so this is a no-op + */ + async installExtensions(): Promise { + console.log('[Mobile] Extensions are pre-bundled, skipping installation') + // No-op on mobile - extensions are built-in + } +} diff --git a/web-app/src/services/index.ts b/web-app/src/services/index.ts index 0bfba90e6..11f8c4ecf 100644 --- a/web-app/src/services/index.ts +++ b/web-app/src/services/index.ts @@ -5,7 +5,7 @@ * then provides synchronous access to service instances throughout the app. */ -import { isPlatformTauri } from '@/lib/platform/utils' +import { isPlatformTauri, isPlatformIOS, isPlatformAndroid } from '@/lib/platform/utils' // Import default services import { DefaultThemeService } from './theme/default' @@ -102,11 +102,14 @@ class PlatformServiceHub implements ServiceHub { console.log( 'Initializing service hub for platform:', - isPlatformTauri() ? 'Tauri' : 'Web' + isPlatformTauri() && !isPlatformIOS() && !isPlatformAndroid() ? 'Tauri' : + isPlatformIOS() ? 'iOS' : + isPlatformAndroid() ? 'Android' : 'Web' ) try { - if (isPlatformTauri()) { + if (isPlatformTauri() && !isPlatformIOS() && !isPlatformAndroid()) { + // Desktop Tauri const [ themeModule, windowModule, @@ -150,6 +153,44 @@ class PlatformServiceHub implements ServiceHub { this.pathService = new pathModule.TauriPathService() this.coreService = new coreModule.TauriCoreService() this.deepLinkService = new deepLinkModule.TauriDeepLinkService() + } else if (isPlatformIOS() || isPlatformAndroid()) { + const [ + themeModule, + windowModule, + eventsModule, + appModule, + mcpModule, + providersModule, + dialogModule, + openerModule, + pathModule, + coreModule, + deepLinkModule, + ] = await Promise.all([ + import('./theme/tauri'), + import('./window/tauri'), + import('./events/tauri'), + import('./app/tauri'), + import('./mcp/tauri'), + import('./providers/tauri'), + import('./dialog/tauri'), + import('./opener/tauri'), + import('./path/tauri'), + import('./core/mobile'), // Use mobile-specific core service + import('./deeplink/tauri'), + ]) + + this.themeService = new themeModule.TauriThemeService() + this.windowService = new windowModule.TauriWindowService() + this.eventsService = new eventsModule.TauriEventsService() + this.appService = new appModule.TauriAppService() + this.mcpService = new mcpModule.TauriMCPService() + this.providersService = new providersModule.TauriProvidersService() + this.dialogService = new dialogModule.TauriDialogService() + this.openerService = new openerModule.TauriOpenerService() + this.pathService = new pathModule.TauriPathService() + this.coreService = new coreModule.MobileCoreService() // Mobile service with pre-loaded extensions + this.deepLinkService = new deepLinkModule.TauriDeepLinkService() } else { const [ themeModule, diff --git a/web-app/tsconfig.app.json b/web-app/tsconfig.app.json index 0aefd5942..c672a79f1 100644 --- a/web-app/tsconfig.app.json +++ b/web-app/tsconfig.app.json @@ -25,7 +25,8 @@ /* Url */ "baseUrl": ".", "paths": { - "@/*": ["./src/*"] + "@/*": ["./src/*"], + "@janhq/conversational-extension": ["../extensions/conversational-extension/src/index.ts"] } }, "include": ["src"], diff --git a/web-app/tsconfig.json b/web-app/tsconfig.json index fec8c8e5c..ab1a13f13 100644 --- a/web-app/tsconfig.json +++ b/web-app/tsconfig.json @@ -7,7 +7,8 @@ "compilerOptions": { "baseUrl": ".", "paths": { - "@/*": ["./src/*"] + "@/*": ["./src/*"], + "@janhq/conversational-extension": ["../extensions/conversational-extension/src/index.ts"] } } } diff --git a/web-app/vite.config.ts b/web-app/vite.config.ts index befdaae57..298493889 100644 --- a/web-app/vite.config.ts +++ b/web-app/vite.config.ts @@ -64,6 +64,7 @@ export default defineConfig(({ mode }) => { resolve: { alias: { '@': path.resolve(__dirname, './src'), + '@janhq/conversational-extension': path.resolve(__dirname, '../extensions/conversational-extension/src/index.ts'), }, }, optimizeDeps: { From 08d527366e83990a50797dba26a3a31ca3192f8b Mon Sep 17 00:00:00 2001 From: Vanalite Date: Thu, 2 Oct 2025 20:53:46 +0700 Subject: [PATCH 14/54] feat: organize code for proper import Move platform checker for db access to helper Add test for to threads controller --- src-tauri/src/core/threads/commands.rs | 37 +- src-tauri/src/core/threads/db.rs | 9 - src-tauri/src/core/threads/helpers.rs | 5 + src-tauri/src/core/threads/mod.rs | 1 + src-tauri/src/core/threads/tests.rs | 331 ++++++++++++++++++ .../__tests__/serviceHub.integration.test.ts | 8 +- 6 files changed, 368 insertions(+), 23 deletions(-) diff --git a/src-tauri/src/core/threads/commands.rs b/src-tauri/src/core/threads/commands.rs index 034d7d536..f53239206 100644 --- a/src-tauri/src/core/threads/commands.rs +++ b/src-tauri/src/core/threads/commands.rs @@ -3,9 +3,11 @@ use std::io::Write; use tauri::Runtime; use uuid::Uuid; +#[cfg(any(target_os = "android", target_os = "ios"))] use super::db; use super::helpers::{ - get_lock_for_thread, read_messages_from_file, update_thread_metadata, write_messages_to_file, + get_lock_for_thread, read_messages_from_file, should_use_sqlite, update_thread_metadata, + write_messages_to_file, }; use super::{ constants::THREADS_FILE, @@ -21,8 +23,9 @@ use super::{ pub async fn list_threads( app_handle: tauri::AppHandle, ) -> Result, String> { - if db::should_use_sqlite() { + if should_use_sqlite() { // Use SQLite on mobile platforms + #[cfg(any(target_os = "android", target_os = "ios"))] return db::db_list_threads(app_handle).await; } @@ -63,7 +66,8 @@ pub async fn create_thread( app_handle: tauri::AppHandle, mut thread: serde_json::Value, ) -> Result { - if db::should_use_sqlite() { + if should_use_sqlite() { + #[cfg(any(target_os = "android", target_os = "ios"))] return db::db_create_thread(app_handle, thread).await; } @@ -88,7 +92,8 @@ pub async fn modify_thread( app_handle: tauri::AppHandle, thread: serde_json::Value, ) -> Result<(), String> { - if db::should_use_sqlite() { + if should_use_sqlite() { + #[cfg(any(target_os = "android", target_os = "ios"))] return db::db_modify_thread(app_handle, thread).await; } @@ -113,7 +118,8 @@ pub async fn delete_thread( app_handle: tauri::AppHandle, thread_id: String, ) -> Result<(), String> { - if db::should_use_sqlite() { + if should_use_sqlite() { + #[cfg(any(target_os = "android", target_os = "ios"))] return db::db_delete_thread(app_handle, &thread_id).await; } @@ -132,7 +138,8 @@ pub async fn list_messages( app_handle: tauri::AppHandle, thread_id: String, ) -> Result, String> { - if db::should_use_sqlite() { + if should_use_sqlite() { + #[cfg(any(target_os = "android", target_os = "ios"))] return db::db_list_messages(app_handle, &thread_id).await; } @@ -147,7 +154,8 @@ pub async fn create_message( app_handle: tauri::AppHandle, mut message: serde_json::Value, ) -> Result { - if db::should_use_sqlite() { + if should_use_sqlite() { + #[cfg(any(target_os = "android", target_os = "ios"))] return db::db_create_message(app_handle, message).await; } @@ -198,7 +206,8 @@ pub async fn modify_message( app_handle: tauri::AppHandle, message: serde_json::Value, ) -> Result { - if db::should_use_sqlite() { + if should_use_sqlite() { + #[cfg(any(target_os = "android", target_os = "ios"))] return db::db_modify_message(app_handle, message).await; } @@ -241,7 +250,8 @@ pub async fn delete_message( thread_id: String, message_id: String, ) -> Result<(), String> { - if db::should_use_sqlite() { + if should_use_sqlite() { + #[cfg(any(target_os = "android", target_os = "ios"))] return db::db_delete_message(app_handle, &thread_id, &message_id).await; } @@ -269,7 +279,8 @@ pub async fn get_thread_assistant( app_handle: tauri::AppHandle, thread_id: String, ) -> Result { - if db::should_use_sqlite() { + if should_use_sqlite() { + #[cfg(any(target_os = "android", target_os = "ios"))] return db::db_get_thread_assistant(app_handle, &thread_id).await; } @@ -299,7 +310,8 @@ pub async fn create_thread_assistant( thread_id: String, assistant: serde_json::Value, ) -> Result { - if db::should_use_sqlite() { + if should_use_sqlite() { + #[cfg(any(target_os = "android", target_os = "ios"))] return db::db_create_thread_assistant(app_handle, &thread_id, assistant).await; } @@ -329,7 +341,8 @@ pub async fn modify_thread_assistant( thread_id: String, assistant: serde_json::Value, ) -> Result { - if db::should_use_sqlite() { + if should_use_sqlite() { + #[cfg(any(target_os = "android", target_os = "ios"))] return db::db_modify_thread_assistant(app_handle, &thread_id, assistant).await; } diff --git a/src-tauri/src/core/threads/db.rs b/src-tauri/src/core/threads/db.rs index 0d8e88438..b888b94bb 100644 --- a/src-tauri/src/core/threads/db.rs +++ b/src-tauri/src/core/threads/db.rs @@ -23,17 +23,8 @@ const DB_NAME: &str = "jan.db"; /// Global database pool for mobile platforms static DB_POOL: OnceLock>> = OnceLock::new(); -/// Check if the platform should use SQLite (mobile platforms) -pub fn should_use_sqlite() -> bool { - cfg!(any(target_os = "android", target_os = "ios")) -} - /// Initialize database with connection pool and run migrations pub async fn init_database(app: &AppHandle) -> Result<(), String> { - if !should_use_sqlite() { - return Ok(()); // Skip DB initialization on desktop - } - // Get app data directory let app_data_dir = app .path() diff --git a/src-tauri/src/core/threads/helpers.rs b/src-tauri/src/core/threads/helpers.rs index 76d2c2e59..06b3561d9 100644 --- a/src-tauri/src/core/threads/helpers.rs +++ b/src-tauri/src/core/threads/helpers.rs @@ -13,6 +13,11 @@ use super::utils::{get_messages_path, get_thread_metadata_path}; // Global per-thread locks for message file writes pub static MESSAGE_LOCKS: OnceLock>>>> = OnceLock::new(); +/// Check if the platform should use SQLite (mobile platforms) +pub fn should_use_sqlite() -> bool { + cfg!(any(target_os = "android", target_os = "ios")) +} + /// Get a lock for a specific thread to ensure thread-safe message file operations pub async fn get_lock_for_thread(thread_id: &str) -> Arc> { let locks = MESSAGE_LOCKS.get_or_init(|| Mutex::new(HashMap::new())); diff --git a/src-tauri/src/core/threads/mod.rs b/src-tauri/src/core/threads/mod.rs index f868899dd..99c00253e 100644 --- a/src-tauri/src/core/threads/mod.rs +++ b/src-tauri/src/core/threads/mod.rs @@ -12,6 +12,7 @@ pub mod commands; mod constants; +#[cfg(any(target_os = "android", target_os = "ios"))] pub mod db; pub mod helpers; pub mod utils; diff --git a/src-tauri/src/core/threads/tests.rs b/src-tauri/src/core/threads/tests.rs index 8d3524d06..a6170dfe7 100644 --- a/src-tauri/src/core/threads/tests.rs +++ b/src-tauri/src/core/threads/tests.rs @@ -1,5 +1,6 @@ use super::commands::*; +use super::helpers::should_use_sqlite; use serde_json::json; use std::fs; use std::path::PathBuf; @@ -23,6 +24,32 @@ fn mock_app_with_temp_data_dir() -> (tauri::App, PathBuf) { (app, data_dir) } +// Helper to create a basic thread +fn create_test_thread(title: &str) -> serde_json::Value { + json!({ + "object": "thread", + "title": title, + "assistants": [], + "created": 123, + "updated": 123, + "metadata": null + }) +} + +// Helper to create a basic message +fn create_test_message(thread_id: &str, content_text: &str) -> serde_json::Value { + json!({ + "object": "message", + "thread_id": thread_id, + "role": "user", + "content": [{"type": "text", "text": content_text}], + "status": "sent", + "created_at": 123, + "completed_at": 123, + "metadata": null + }) +} + #[tokio::test] async fn test_create_and_list_threads() { let (app, data_dir) = mock_app_with_temp_data_dir(); @@ -137,3 +164,307 @@ async fn test_create_and_get_thread_assistant() { // Clean up let _ = fs::remove_dir_all(data_dir); } + +#[test] +fn test_should_use_sqlite_platform_detection() { + // Test that should_use_sqlite returns correct value based on platform + // On desktop platforms (macOS, Linux, Windows), it should return false + // On mobile platforms (Android, iOS), it should return true + + #[cfg(any(target_os = "android", target_os = "ios"))] + { + assert!(should_use_sqlite(), "should_use_sqlite should return true on mobile platforms"); + } + + #[cfg(not(any(target_os = "android", target_os = "ios")))] + { + assert!(!should_use_sqlite(), "should_use_sqlite should return false on desktop platforms"); + } +} + +#[tokio::test] +async fn test_desktop_storage_backend() { + // This test verifies that on desktop platforms, the file-based storage is used + #[cfg(not(any(target_os = "android", target_os = "ios")))] + { + let (app, _data_dir) = mock_app_with_temp_data_dir(); + + // Create a thread + let thread = json!({ + "object": "thread", + "title": "Desktop Test Thread", + "assistants": [], + "created": 1234567890, + "updated": 1234567890, + "metadata": null + }); + + let created = create_thread(app.handle().clone(), thread.clone()) + .await + .unwrap(); + let thread_id = created["id"].as_str().unwrap().to_string(); + + // Verify we can retrieve the thread (which proves file storage works) + let threads = list_threads(app.handle().clone()).await.unwrap(); + let found = threads.iter().any(|t| t["id"] == thread_id); + assert!(found, "Thread should be retrievable from file-based storage"); + + // Create a message + let message = json!({ + "object": "message", + "thread_id": thread_id, + "role": "user", + "content": [], + "status": "sent", + "created_at": 123, + "completed_at": 123, + "metadata": null + }); + + let _created_msg = create_message(app.handle().clone(), message).await.unwrap(); + + // Verify we can retrieve the message (which proves file storage works) + let messages = list_messages(app.handle().clone(), thread_id.clone()) + .await + .unwrap(); + assert_eq!(messages.len(), 1, "Message should be retrievable from file-based storage"); + + // Clean up - get the actual data directory used by the app + use super::utils::get_data_dir; + let actual_data_dir = get_data_dir(app.handle().clone()); + let _ = fs::remove_dir_all(actual_data_dir); + } +} + +#[tokio::test] +async fn test_modify_and_delete_thread() { + let (app, data_dir) = mock_app_with_temp_data_dir(); + + // Create a thread + let thread = json!({ + "object": "thread", + "title": "Original Title", + "assistants": [], + "created": 1234567890, + "updated": 1234567890, + "metadata": null + }); + + let created = create_thread(app.handle().clone(), thread.clone()) + .await + .unwrap(); + let thread_id = created["id"].as_str().unwrap().to_string(); + + // Modify the thread + let mut modified_thread = created.clone(); + modified_thread["title"] = json!("Modified Title"); + + modify_thread(app.handle().clone(), modified_thread.clone()) + .await + .unwrap(); + + // Verify modification by listing threads + let threads = list_threads(app.handle().clone()).await.unwrap(); + let found_thread = threads.iter().find(|t| t["id"] == thread_id); + assert!(found_thread.is_some(), "Modified thread should exist"); + assert_eq!(found_thread.unwrap()["title"], "Modified Title"); + + // Delete the thread + delete_thread(app.handle().clone(), thread_id.clone()) + .await + .unwrap(); + + // Verify deletion + #[cfg(not(any(target_os = "android", target_os = "ios")))] + { + let thread_dir = data_dir.join(&thread_id); + assert!(!thread_dir.exists(), "Thread directory should be deleted"); + } + + // Clean up + let _ = fs::remove_dir_all(data_dir); +} + +#[tokio::test] +async fn test_modify_and_delete_message() { + let (app, data_dir) = mock_app_with_temp_data_dir(); + + // Create a thread + let thread = json!({ + "object": "thread", + "title": "Message Test Thread", + "assistants": [], + "created": 123, + "updated": 123, + "metadata": null + }); + + let created = create_thread(app.handle().clone(), thread.clone()) + .await + .unwrap(); + let thread_id = created["id"].as_str().unwrap().to_string(); + + // Create a message + let message = json!({ + "object": "message", + "thread_id": thread_id, + "role": "user", + "content": [{"type": "text", "text": "Original content"}], + "status": "sent", + "created_at": 123, + "completed_at": 123, + "metadata": null + }); + + let created_msg = create_message(app.handle().clone(), message).await.unwrap(); + let message_id = created_msg["id"].as_str().unwrap().to_string(); + + // Modify the message + let mut modified_msg = created_msg.clone(); + modified_msg["content"] = json!([{"type": "text", "text": "Modified content"}]); + + modify_message(app.handle().clone(), modified_msg.clone()) + .await + .unwrap(); + + // Verify modification + let messages = list_messages(app.handle().clone(), thread_id.clone()) + .await + .unwrap(); + assert_eq!(messages.len(), 1); + assert_eq!(messages[0]["content"][0]["text"], "Modified content"); + + // Delete the message + delete_message(app.handle().clone(), thread_id.clone(), message_id.clone()) + .await + .unwrap(); + + // Verify deletion + let messages = list_messages(app.handle().clone(), thread_id.clone()) + .await + .unwrap(); + assert_eq!(messages.len(), 0, "Message should be deleted"); + + // Clean up + let _ = fs::remove_dir_all(data_dir); +} + +#[tokio::test] +async fn test_modify_thread_assistant() { + let (app, data_dir) = mock_app_with_temp_data_dir(); + let app_handle = app.handle().clone(); + + let created = create_thread(app_handle.clone(), create_test_thread("Assistant Mod Thread")) + .await + .unwrap(); + let thread_id = created["id"].as_str().unwrap(); + + let assistant = json!({ + "id": "assistant-1", + "assistant_name": "Original Assistant", + "model": {"id": "model-1", "name": "Test Model"} + }); + + create_thread_assistant(app_handle.clone(), thread_id.to_string(), assistant.clone()) + .await + .unwrap(); + + let mut modified_assistant = assistant; + modified_assistant["assistant_name"] = json!("Modified Assistant"); + + modify_thread_assistant(app_handle.clone(), thread_id.to_string(), modified_assistant) + .await + .unwrap(); + + let retrieved = get_thread_assistant(app_handle, thread_id.to_string()) + .await + .unwrap(); + assert_eq!(retrieved["assistant_name"], "Modified Assistant"); + + let _ = fs::remove_dir_all(data_dir); +} + +#[tokio::test] +async fn test_thread_not_found_errors() { + let (app, data_dir) = mock_app_with_temp_data_dir(); + let app_handle = app.handle().clone(); + let fake_thread_id = "non-existent-thread-id".to_string(); + let assistant = json!({"id": "assistant-1", "assistant_name": "Test Assistant"}); + + assert!(get_thread_assistant(app_handle.clone(), fake_thread_id.clone()).await.is_err()); + assert!(create_thread_assistant(app_handle.clone(), fake_thread_id.clone(), assistant.clone()).await.is_err()); + assert!(modify_thread_assistant(app_handle, fake_thread_id, assistant).await.is_err()); + + let _ = fs::remove_dir_all(data_dir); +} + +#[tokio::test] +async fn test_message_without_id_gets_generated() { + let (app, data_dir) = mock_app_with_temp_data_dir(); + let app_handle = app.handle().clone(); + + let created = create_thread(app_handle.clone(), create_test_thread("Message ID Test")) + .await + .unwrap(); + let thread_id = created["id"].as_str().unwrap(); + + let message = json!({"object": "message", "thread_id": thread_id, "role": "user", "content": [], "status": "sent"}); + let created_msg = create_message(app_handle, message).await.unwrap(); + + assert!(created_msg["id"].as_str().is_some_and(|id| !id.is_empty())); + + let _ = fs::remove_dir_all(data_dir); +} + +#[tokio::test] +async fn test_concurrent_message_operations() { + let (app, data_dir) = mock_app_with_temp_data_dir(); + let app_handle = app.handle().clone(); + + let created = create_thread(app_handle.clone(), create_test_thread("Concurrent Test")) + .await + .unwrap(); + let thread_id = created["id"].as_str().unwrap().to_string(); + + let handles: Vec<_> = (0..5) + .map(|i| { + let app_h = app_handle.clone(); + let tid = thread_id.clone(); + tokio::spawn(async move { + create_message(app_h, create_test_message(&tid, &format!("Message {}", i))).await + }) + }) + .collect(); + + let results = futures::future::join_all(handles).await; + assert!(results.iter().all(|r| r.is_ok() && r.as_ref().unwrap().is_ok())); + + let messages = list_messages(app_handle, thread_id).await.unwrap(); + assert_eq!(messages.len(), 5); + + let _ = fs::remove_dir_all(data_dir); +} + +#[tokio::test] +async fn test_empty_thread_list() { + let (app, data_dir) = mock_app_with_temp_data_dir(); + let threads = list_threads(app.handle().clone()).await.unwrap(); + assert_eq!(threads.len(), 0); + let _ = fs::remove_dir_all(data_dir); +} + +#[tokio::test] +async fn test_empty_message_list() { + let (app, data_dir) = mock_app_with_temp_data_dir(); + let app_handle = app.handle().clone(); + + let created = create_thread(app_handle.clone(), create_test_thread("Empty Messages Test")) + .await + .unwrap(); + let thread_id = created["id"].as_str().unwrap(); + + let messages = list_messages(app_handle, thread_id.to_string()).await.unwrap(); + assert_eq!(messages.len(), 0); + + let _ = fs::remove_dir_all(data_dir); +} diff --git a/web-app/src/services/__tests__/serviceHub.integration.test.ts b/web-app/src/services/__tests__/serviceHub.integration.test.ts index 8a8a10344..b39a24831 100644 --- a/web-app/src/services/__tests__/serviceHub.integration.test.ts +++ b/web-app/src/services/__tests__/serviceHub.integration.test.ts @@ -4,7 +4,11 @@ import { isPlatformTauri } from '@/lib/platform/utils' // Mock platform detection vi.mock('@/lib/platform/utils', () => ({ - isPlatformTauri: vi.fn().mockReturnValue(false) + isPlatformTauri: vi.fn().mockReturnValue(false), + isPlatformIOS: vi.fn().mockReturnValue(false), + isPlatformAndroid: vi.fn().mockReturnValue(false), + isIOS: vi.fn().mockReturnValue(false), + isAndroid: vi.fn().mockReturnValue(false) })) // Mock @jan/extensions-web to return empty extensions for testing @@ -213,4 +217,4 @@ describe('ServiceHub Integration Tests', () => { }) }) -}) +}) From 524ac1129459b08cac5ff119ccc719c7a6f71058 Mon Sep 17 00:00:00 2001 From: Vanalite Date: Thu, 2 Oct 2025 21:20:07 +0700 Subject: [PATCH 15/54] feat: better structure for MobileCoreService MobileCoreService should inherit TauriCoreService to match Tauri architecture patterns --- web-app/src/services/core/mobile.ts | 52 +++++++++++++++++++---------- 1 file changed, 34 insertions(+), 18 deletions(-) diff --git a/web-app/src/services/core/mobile.ts b/web-app/src/services/core/mobile.ts index 45df4d7fd..d7de2f547 100644 --- a/web-app/src/services/core/mobile.ts +++ b/web-app/src/services/core/mobile.ts @@ -12,12 +12,39 @@ import JanConversationalExtension from '@janhq/conversational-extension' export class MobileCoreService extends TauriCoreService { /** - * Override getActiveExtensions to return pre-loaded web extensions - * for mobile platforms where filesystem access is restricted. + * Override: Return pre-bundled extensions instead of reading from filesystem */ - async getActiveExtensions(): Promise { + override async getActiveExtensions(): Promise { + return this.getBundledExtensions() + } - // Return conversational extension as a pre-loaded instance + /** + * Override: No-op on mobile - extensions are pre-bundled in the app + */ + override async installExtensions(): Promise { + console.log('[Mobile] Extensions are pre-bundled, skipping installation') + } + + /** + * Override: No-op on mobile - cannot install additional extensions + */ + override async installExtension(_extensions: ExtensionManifest[]): Promise { + console.log('[Mobile] Cannot install extensions on mobile, they are pre-bundled') + return this.getBundledExtensions() + } + + /** + * Override: No-op on mobile - cannot uninstall bundled extensions + */ + override async uninstallExtension(_extensions: string[], _reload = true): Promise { + console.log('[Mobile] Cannot uninstall pre-bundled extensions on mobile') + return false + } + + /** + * Private method to return pre-bundled mobile extensions + */ + private getBundledExtensions(): ExtensionManifest[] { const conversationalExt = new JanConversationalExtension( 'built-in', '@janhq/conversational-extension', @@ -27,27 +54,16 @@ export class MobileCoreService extends TauriCoreService { '1.0.0' ) - const extensions: ExtensionManifest[] = [ + return [ { name: '@janhq/conversational-extension', productName: 'Conversational Extension', - url: 'built-in', // Not loaded from file, but bundled + url: 'built-in', active: true, description: 'Manages conversation threads and messages', version: '1.0.0', - extensionInstance: conversationalExt, // Pre-instantiated! + extensionInstance: conversationalExt, }, ] - - return extensions - } - - /** - * Mobile-specific install extensions implementation - * On mobile, extensions are pre-bundled, so this is a no-op - */ - async installExtensions(): Promise { - console.log('[Mobile] Extensions are pre-bundled, skipping installation') - // No-op on mobile - extensions are built-in } } From 4da0fd1ca3e7ad1314fd06c92255afe646559554 Mon Sep 17 00:00:00 2001 From: Vanalite Date: Fri, 3 Oct 2025 10:25:41 +0700 Subject: [PATCH 16/54] fix: yarn lint --- web-app/src/hooks/useChat.ts | 3 ++- web-app/src/services/core/mobile.ts | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/web-app/src/hooks/useChat.ts b/web-app/src/hooks/useChat.ts index 3e41fc52a..79e414185 100644 --- a/web-app/src/hooks/useChat.ts +++ b/web-app/src/hooks/useChat.ts @@ -149,7 +149,7 @@ export const useChat = () => { }) } return currentThread - }, [createThread, retrieveThread, router, setMessages]) + }, [createThread, retrieveThread, router, setMessages, serviceHub]) const restartModel = useCallback( async (provider: ProviderObject, modelId: string) => { @@ -639,6 +639,7 @@ export const useChat = () => { toggleOnContextShifting, setModelLoadError, serviceHub, + setTokenSpeed, ] ) diff --git a/web-app/src/services/core/mobile.ts b/web-app/src/services/core/mobile.ts index d7de2f547..e5aedefa0 100644 --- a/web-app/src/services/core/mobile.ts +++ b/web-app/src/services/core/mobile.ts @@ -28,7 +28,7 @@ export class MobileCoreService extends TauriCoreService { /** * Override: No-op on mobile - cannot install additional extensions */ - override async installExtension(_extensions: ExtensionManifest[]): Promise { + override async installExtension(): Promise { console.log('[Mobile] Cannot install extensions on mobile, they are pre-bundled') return this.getBundledExtensions() } @@ -36,7 +36,7 @@ export class MobileCoreService extends TauriCoreService { /** * Override: No-op on mobile - cannot uninstall bundled extensions */ - override async uninstallExtension(_extensions: string[], _reload = true): Promise { + override async uninstallExtension(): Promise { console.log('[Mobile] Cannot uninstall pre-bundled extensions on mobile') return false } From b628b3d9ab73ab030e69879cf04e77cb550e0c9a Mon Sep 17 00:00:00 2001 From: Vanalite Date: Fri, 3 Oct 2025 14:17:59 +0700 Subject: [PATCH 17/54] fix: Fix tests in threads with proper mock folder properly --- src-tauri/src/core/threads/tests.rs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src-tauri/src/core/threads/tests.rs b/src-tauri/src/core/threads/tests.rs index 07cbba9ef..15c91de85 100644 --- a/src-tauri/src/core/threads/tests.rs +++ b/src-tauri/src/core/threads/tests.rs @@ -1,6 +1,7 @@ use super::commands::*; use super::helpers::should_use_sqlite; +use futures_util::future; use serde_json::json; use std::fs; use std::path::PathBuf; @@ -436,7 +437,7 @@ async fn test_concurrent_message_operations() { }) .collect(); - let results = futures::future::join_all(handles).await; + let results = future::join_all(handles).await; assert!(results.iter().all(|r| r.is_ok() && r.as_ref().unwrap().is_ok())); let messages = list_messages(app_handle, thread_id).await.unwrap(); @@ -448,6 +449,13 @@ async fn test_concurrent_message_operations() { #[tokio::test] async fn test_empty_thread_list() { let (app, data_dir) = mock_app_with_temp_data_dir(); + // Clean up any leftover test data + let test_data_threads = std::env::current_dir() + .unwrap_or_else(|_| PathBuf::from(".")) + .join("test-data") + .join("threads"); + let _ = fs::remove_dir_all(&test_data_threads); + let threads = list_threads(app.handle().clone()).await.unwrap(); assert_eq!(threads.len(), 0); let _ = fs::remove_dir_all(data_dir); From cb9eb6d23837fcba861e3d69b904279b3c1a66be Mon Sep 17 00:00:00 2001 From: Roushan Singh Date: Sat, 4 Oct 2025 22:21:02 +0530 Subject: [PATCH 18/54] fix(ui): restore missing border on model selector (#6692) --- web-app/src/containers/DropdownModelProvider.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web-app/src/containers/DropdownModelProvider.tsx b/web-app/src/containers/DropdownModelProvider.tsx index a8614f89d..ac108998a 100644 --- a/web-app/src/containers/DropdownModelProvider.tsx +++ b/web-app/src/containers/DropdownModelProvider.tsx @@ -461,7 +461,7 @@ const DropdownModelProvider = ({ return ( -
+
+ )} +
+
+ )} + {folders.length === 0 ? (
@@ -123,9 +163,19 @@ function ProjectContent() { {t('projects.noProjectsYetDesc')}

+ ) : filteredProjects.length === 0 ? ( +
+ +

+ {t('projects.noProjectsFound')} +

+

+ {t('projects.tryDifferentSearch')} +

+
) : (
- {folders + {filteredProjects .slice() .sort((a, b) => b.updated_at - a.updated_at) .map((folder) => { @@ -218,7 +268,9 @@ function ProjectContent() { {/* Thread List */} {isExpanded && projectThreads.length > 0 && ( -
+
Date: Fri, 3 Oct 2025 16:42:10 +0530 Subject: [PATCH 24/54] (chore): rename translation keys to collapseProject/expandProject --- web-app/src/locales/de-DE/common.json | 8 ++++---- web-app/src/locales/en/common.json | 4 ++-- web-app/src/locales/id/common.json | 4 ++-- web-app/src/locales/pl/common.json | 4 ++-- web-app/src/locales/vn/common.json | 26 ++++++++++++++++++++++++++ web-app/src/locales/zh-CN/common.json | 26 ++++++++++++++++++++++++++ web-app/src/locales/zh-TW/common.json | 26 ++++++++++++++++++++++++++ web-app/src/routes/project/index.tsx | 4 ++-- 8 files changed, 90 insertions(+), 12 deletions(-) diff --git a/web-app/src/locales/de-DE/common.json b/web-app/src/locales/de-DE/common.json index 4ce743b46..65b7af707 100644 --- a/web-app/src/locales/de-DE/common.json +++ b/web-app/src/locales/de-DE/common.json @@ -272,8 +272,8 @@ "thread": "Thread", "threads": "Threads", "updated": "Aktualisiert:", - "collapseThreads": "Threads einklappen", - "expandThreads": "Threads ausklappen", + "collapseProject": "Projekt einklappen", + "expandProject": "Projekt ausklappen", "update": "Aktualisieren" }, "toast": { @@ -428,8 +428,8 @@ "thread": "Thread", "threads": "Threads", "updated": "Aktualisiert:", - "collapseThreads": "Threads einklappen", - "expandThreads": "Threads ausklappen", + "collapseProject": "Projekt einklappen", + "expandProject": "Projekt ausklappen", "update": "Aktualisieren" } } diff --git a/web-app/src/locales/en/common.json b/web-app/src/locales/en/common.json index 690381188..e39465545 100644 --- a/web-app/src/locales/en/common.json +++ b/web-app/src/locales/en/common.json @@ -282,8 +282,8 @@ "thread": "thread", "threads": "threads", "updated": "Updated:", - "collapseThreads": "Collapse threads", - "expandThreads": "Expand threads", + "collapseProject": "Collapse project", + "expandProject": "Expand project", "update": "Update", "searchProjects": "Search projects...", "noProjectsFound": "No projects found", diff --git a/web-app/src/locales/id/common.json b/web-app/src/locales/id/common.json index aa0c83fd9..3dbdfd90e 100644 --- a/web-app/src/locales/id/common.json +++ b/web-app/src/locales/id/common.json @@ -354,8 +354,8 @@ "thread": "utas", "threads": "utas", "updated": "Diperbarui:", - "collapseThreads": "Tutup utas", - "expandThreads": "Buka utas", + "collapseProject": "Tutup proyek", + "expandProject": "Buka proyek", "update": "Perbarui" } } diff --git a/web-app/src/locales/pl/common.json b/web-app/src/locales/pl/common.json index ca6f6b6b7..87e31117e 100644 --- a/web-app/src/locales/pl/common.json +++ b/web-app/src/locales/pl/common.json @@ -272,8 +272,8 @@ "thread": "wątek", "threads": "wątki", "updated": "Zaktualizowano:", - "collapseThreads": "Zwiń wątki", - "expandThreads": "Rozwiń wątki", + "collapseProject": "Zwiń projekt", + "expandProject": "Rozwiń projekt", "update": "Aktualizuj" }, "toast": { diff --git a/web-app/src/locales/vn/common.json b/web-app/src/locales/vn/common.json index 4c2d95101..92d4908a2 100644 --- a/web-app/src/locales/vn/common.json +++ b/web-app/src/locales/vn/common.json @@ -199,6 +199,32 @@ "title": "Cài đặt mô hình - {{modelId}}", "description": "Định cấu hình cài đặt mô hình để tối ưu hóa hiệu suất và hành vi." }, + "projects": { + "title": "Dự án", + "addProject": "Thêm dự án", + "editProject": "Chỉnh sửa dự án", + "deleteProject": "Xóa dự án", + "projectName": "Tên dự án", + "enterProjectName": "Nhập tên dự án", + "noProjectsYet": "Chưa có dự án nào", + "noProjectsYetDesc": "Tạo dự án đầu tiên của bạn để tổ chức các cuộc trò chuyện.", + "projectNotFound": "Không tìm thấy dự án", + "projectNotFoundDesc": "Dự án mà bạn đang tìm kiếm không tồn tại.", + "deleteProjectConfirm": "Bạn có chắc chắn muốn xóa dự án này không? Hành động này không thể hoàn tác.", + "noConversationsIn": "Chưa có cuộc trò chuyện nào trong {{projectName}}", + "startNewConversation": "Bắt đầu một cuộc trò chuyện mới với {{projectName}} bên dưới", + "conversationsIn": "Cuộc trò chuyện trong {{projectName}}", + "conversationsDescription": "Nhấp vào bất kỳ cuộc trò chuyện nào để tiếp tục trò chuyện hoặc bắt đầu một cuộc trò chuyện mới bên dưới.", + "thread": "chủ đề", + "threads": "chủ đề", + "updated": "Đã cập nhật:", + "collapseProject": "Thu gọn dự án", + "expandProject": "Mở rộng dự án", + "update": "Cập nhật", + "searchProjects": "Tìm kiếm dự án...", + "noProjectsFound": "Không tìm thấy dự án nào", + "tryDifferentSearch": "Thử từ khóa tìm kiếm khác" + }, "dialogs": { "changeDataFolder": { "title": "Thay đổi vị trí thư mục dữ liệu", diff --git a/web-app/src/locales/zh-CN/common.json b/web-app/src/locales/zh-CN/common.json index 6da4a83fa..05f7884a9 100644 --- a/web-app/src/locales/zh-CN/common.json +++ b/web-app/src/locales/zh-CN/common.json @@ -199,6 +199,32 @@ "title": "模型设置 - {{modelId}}", "description": "配置模型设置以优化性能和行为。" }, + "projects": { + "title": "项目", + "addProject": "添加项目", + "editProject": "编辑项目", + "deleteProject": "删除项目", + "projectName": "项目名称", + "enterProjectName": "输入项目名称", + "noProjectsYet": "还没有项目", + "noProjectsYetDesc": "创建您的第一个项目来组织对话。", + "projectNotFound": "未找到项目", + "projectNotFoundDesc": "您正在查找的项目不存在。", + "deleteProjectConfirm": "您确定要删除此项目吗?此操作无法撤销。", + "noConversationsIn": "{{projectName}} 中还没有对话", + "startNewConversation": "在下方开始与 {{projectName}} 的新对话", + "conversationsIn": "{{projectName}} 中的对话", + "conversationsDescription": "点击任何对话以继续聊天,或在下方开始新的对话。", + "thread": "线程", + "threads": "线程", + "updated": "已更新:", + "collapseProject": "收起项目", + "expandProject": "展开项目", + "update": "更新", + "searchProjects": "搜索项目...", + "noProjectsFound": "未找到项目", + "tryDifferentSearch": "尝试不同的搜索词" + }, "dialogs": { "changeDataFolder": { "title": "更改数据文件夹位置", diff --git a/web-app/src/locales/zh-TW/common.json b/web-app/src/locales/zh-TW/common.json index 4b9d1e7f6..d34858aa2 100644 --- a/web-app/src/locales/zh-TW/common.json +++ b/web-app/src/locales/zh-TW/common.json @@ -199,6 +199,32 @@ "title": "模型設定 - {{modelId}}", "description": "設定模型設定以最佳化效能和行為。" }, + "projects": { + "title": "專案", + "addProject": "新增專案", + "editProject": "編輯專案", + "deleteProject": "刪除專案", + "projectName": "專案名稱", + "enterProjectName": "輸入專案名稱", + "noProjectsYet": "尚無專案", + "noProjectsYetDesc": "建立您的第一個專案來組織對話。", + "projectNotFound": "找不到專案", + "projectNotFoundDesc": "您正在尋找的專案不存在。", + "deleteProjectConfirm": "您確定要刪除此專案嗎?此操作無法復原。", + "noConversationsIn": "{{projectName}} 中尚無對話", + "startNewConversation": "在下方開始與 {{projectName}} 的新對話", + "conversationsIn": "{{projectName}} 中的對話", + "conversationsDescription": "點擊任何對話以繼續聊天,或在下方開始新的對話。", + "thread": "執行緒", + "threads": "執行緒", + "updated": "已更新:", + "collapseProject": "收合專案", + "expandProject": "展開專案", + "update": "更新", + "searchProjects": "搜尋專案...", + "noProjectsFound": "找不到專案", + "tryDifferentSearch": "嘗試不同的搜尋詞" + }, "dialogs": { "changeDataFolder": { "title": "變更資料夾位置", diff --git a/web-app/src/routes/project/index.tsx b/web-app/src/routes/project/index.tsx index 5d3a30315..be3e20cf6 100644 --- a/web-app/src/routes/project/index.tsx +++ b/web-app/src/routes/project/index.tsx @@ -222,8 +222,8 @@ function ProjectContent() { className="size-8 cursor-pointer flex items-center justify-center rounded-md hover:bg-main-view-fg/10 transition-all duration-200 ease-in-out mr-1" title={ isExpanded - ? t('projects.collapseThreads') - : t('projects.expandThreads') + ? t('projects.collapseProject') + : t('projects.expandProject') } onClick={() => toggleProjectExpansion(folder.id)} > From cc5130c1afd13789a555d236d3941da2850017da Mon Sep 17 00:00:00 2001 From: Roushan Singh Date: Fri, 3 Oct 2025 16:54:22 +0530 Subject: [PATCH 25/54] Fix Translation changes across locales --- web-app/src/containers/ThreadList.tsx | 6 ++-- web-app/src/locales/de-DE/common.json | 46 --------------------------- web-app/src/locales/vn/common.json | 3 ++ web-app/src/locales/zh-CN/common.json | 3 ++ web-app/src/locales/zh-TW/common.json | 3 ++ 5 files changed, 12 insertions(+), 49 deletions(-) diff --git a/web-app/src/containers/ThreadList.tsx b/web-app/src/containers/ThreadList.tsx index 734aec710..69bcc4d82 100644 --- a/web-app/src/containers/ThreadList.tsx +++ b/web-app/src/containers/ThreadList.tsx @@ -237,13 +237,13 @@ const SortableItem = memo( - Add to project + {t('common:projects.addToProject')} {availableProjects.length === 0 ? ( - No projects available + {t('common:projects.noProjectsAvailable')} ) : ( @@ -282,7 +282,7 @@ const SortableItem = memo( }} > - Remove + {t('common:projects.removeFromProject')} )} diff --git a/web-app/src/locales/de-DE/common.json b/web-app/src/locales/de-DE/common.json index 65b7af707..ad71eb789 100644 --- a/web-app/src/locales/de-DE/common.json +++ b/web-app/src/locales/de-DE/common.json @@ -385,51 +385,5 @@ "title": "Thread entfernt", "description": "Thread erfolgreich von \"{{projectName}}\" entfernt" } - }, - "projects": { - "title": "Projekte", - "addProject": "Projekt hinzufügen", - "addToProject": "Zu Projekt hinzufügen", - "removeFromProject": "Von Projekt entfernen", - "createNewProject": "Neues Projekt erstellen", - "editProject": "Projekt bearbeiten", - "deleteProject": "Projekt löschen", - "projectName": "Projektname", - "enterProjectName": "Projektname eingeben...", - "noProjectsAvailable": "Keine Projekte verfügbar", - "noProjectsYet": "Noch keine Projekte", - "noProjectsYetDesc": "Starten Sie ein neues Projekt, indem Sie auf die Schaltfläche Projekt hinzufügen klicken.", - "projectNotFound": "Projekt nicht gefunden", - "projectNotFoundDesc": "Das gesuchte Projekt existiert nicht oder wurde gelöscht.", - "deleteProjectDialog": { - "title": "Projekt löschen", - "description": "Sind Sie sicher, dass Sie dieses Projekt löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden.", - "deleteButton": "Löschen", - "successWithName": "Projekt \"{{projectName}}\" erfolgreich gelöscht", - "successWithoutName": "Projekt erfolgreich gelöscht", - "error": "Projekt konnte nicht gelöscht werden. Bitte versuchen Sie es erneut.", - "ariaLabel": "{{projectName}} löschen" - }, - "addProjectDialog": { - "createTitle": "Neues Projekt erstellen", - "editTitle": "Projekt bearbeiten", - "nameLabel": "Projektname", - "namePlaceholder": "Projektname eingeben...", - "createButton": "Erstellen", - "updateButton": "Aktualisieren", - "alreadyExists": "Projekt \"{{projectName}}\" existiert bereits", - "createSuccess": "Projekt \"{{projectName}}\" erfolgreich erstellt", - "renameSuccess": "Projekt von \"{{oldName}}\" zu \"{{newName}}\" umbenannt" - }, - "noConversationsIn": "Keine Gespräche in {{projectName}}", - "startNewConversation": "Starten Sie ein neues Gespräch mit {{projectName}} unten", - "conversationsIn": "Gespräche in {{projectName}}", - "conversationsDescription": "Klicken Sie auf ein Gespräch, um weiterzuchatten, oder starten Sie unten ein neues.", - "thread": "Thread", - "threads": "Threads", - "updated": "Aktualisiert:", - "collapseProject": "Projekt einklappen", - "expandProject": "Projekt ausklappen", - "update": "Aktualisieren" } } diff --git a/web-app/src/locales/vn/common.json b/web-app/src/locales/vn/common.json index 92d4908a2..28ddd29a7 100644 --- a/web-app/src/locales/vn/common.json +++ b/web-app/src/locales/vn/common.json @@ -211,6 +211,8 @@ "projectNotFound": "Không tìm thấy dự án", "projectNotFoundDesc": "Dự án mà bạn đang tìm kiếm không tồn tại.", "deleteProjectConfirm": "Bạn có chắc chắn muốn xóa dự án này không? Hành động này không thể hoàn tác.", + "addToProject": "Thêm vào dự án", + "removeFromProject": "Xóa khỏi dự án", "noConversationsIn": "Chưa có cuộc trò chuyện nào trong {{projectName}}", "startNewConversation": "Bắt đầu một cuộc trò chuyện mới với {{projectName}} bên dưới", "conversationsIn": "Cuộc trò chuyện trong {{projectName}}", @@ -221,6 +223,7 @@ "collapseProject": "Thu gọn dự án", "expandProject": "Mở rộng dự án", "update": "Cập nhật", + "noProjectsAvailable": "Không có dự án nào", "searchProjects": "Tìm kiếm dự án...", "noProjectsFound": "Không tìm thấy dự án nào", "tryDifferentSearch": "Thử từ khóa tìm kiếm khác" diff --git a/web-app/src/locales/zh-CN/common.json b/web-app/src/locales/zh-CN/common.json index 05f7884a9..69b15ac90 100644 --- a/web-app/src/locales/zh-CN/common.json +++ b/web-app/src/locales/zh-CN/common.json @@ -211,6 +211,8 @@ "projectNotFound": "未找到项目", "projectNotFoundDesc": "您正在查找的项目不存在。", "deleteProjectConfirm": "您确定要删除此项目吗?此操作无法撤销。", + "addToProject": "添加到项目", + "removeFromProject": "从项目中删除", "noConversationsIn": "{{projectName}} 中还没有对话", "startNewConversation": "在下方开始与 {{projectName}} 的新对话", "conversationsIn": "{{projectName}} 中的对话", @@ -221,6 +223,7 @@ "collapseProject": "收起项目", "expandProject": "展开项目", "update": "更新", + "noProjectsAvailable": "没有可用的项目", "searchProjects": "搜索项目...", "noProjectsFound": "未找到项目", "tryDifferentSearch": "尝试不同的搜索词" diff --git a/web-app/src/locales/zh-TW/common.json b/web-app/src/locales/zh-TW/common.json index d34858aa2..809ac0cd4 100644 --- a/web-app/src/locales/zh-TW/common.json +++ b/web-app/src/locales/zh-TW/common.json @@ -211,6 +211,8 @@ "projectNotFound": "找不到專案", "projectNotFoundDesc": "您正在尋找的專案不存在。", "deleteProjectConfirm": "您確定要刪除此專案嗎?此操作無法復原。", + "addToProject": "加入專案", + "removeFromProject": "從專案中移除", "noConversationsIn": "{{projectName}} 中尚無對話", "startNewConversation": "在下方開始與 {{projectName}} 的新對話", "conversationsIn": "{{projectName}} 中的對話", @@ -221,6 +223,7 @@ "collapseProject": "收合專案", "expandProject": "展開專案", "update": "更新", + "noProjectsAvailable": "沒有可用的專案", "searchProjects": "搜尋專案...", "noProjectsFound": "找不到專案", "tryDifferentSearch": "嘗試不同的搜尋詞" From 154bc177787f50ac31221f12267281b0c6694aa6 Mon Sep 17 00:00:00 2001 From: Roushan Singh Date: Fri, 3 Oct 2025 17:08:12 +0530 Subject: [PATCH 26/54] (chore): remove duplicate keys from de-DE/common.json --- web-app/src/locales/de-DE/common.json | 28 --------------------------- 1 file changed, 28 deletions(-) diff --git a/web-app/src/locales/de-DE/common.json b/web-app/src/locales/de-DE/common.json index ad71eb789..c2cf34164 100644 --- a/web-app/src/locales/de-DE/common.json +++ b/web-app/src/locales/de-DE/common.json @@ -356,34 +356,6 @@ "downloadAndVerificationComplete": { "title": "Download abgeschlossen", "description": "Modell \"{{item}}\" erfolgreich heruntergeladen und verifiziert" - }, - "projectCreated": { - "title": "Projekt erstellt", - "description": "Projekt \"{{projectName}}\" erfolgreich erstellt" - }, - "projectRenamed": { - "title": "Projekt umbenannt", - "description": "Projekt von \"{{oldName}}\" zu \"{{newName}}\" umbenannt" - }, - "projectDeleted": { - "title": "Projekt gelöscht", - "description": "Projekt \"{{projectName}}\" erfolgreich gelöscht" - }, - "projectAlreadyExists": { - "title": "Projekt existiert bereits", - "description": "Projekt \"{{projectName}}\" existiert bereits" - }, - "projectDeleteFailed": { - "title": "Löschen fehlgeschlagen", - "description": "Projekt konnte nicht gelöscht werden. Bitte versuchen Sie es erneut." - }, - "threadAssignedToProject": { - "title": "Thread zugewiesen", - "description": "Thread erfolgreich zu \"{{projectName}}\" hinzugefügt" - }, - "threadRemovedFromProject": { - "title": "Thread entfernt", - "description": "Thread erfolgreich von \"{{projectName}}\" entfernt" } } } From 291482cc166ee279dd8a5b6ff689a427473b089a Mon Sep 17 00:00:00 2001 From: Roushan Singh Date: Fri, 3 Oct 2025 17:17:10 +0530 Subject: [PATCH 27/54] Add SearchProjects to missing locales --- web-app/src/locales/de-DE/common.json | 5 ++++- web-app/src/locales/id/common.json | 5 ++++- web-app/src/locales/pl/common.json | 5 ++++- 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/web-app/src/locales/de-DE/common.json b/web-app/src/locales/de-DE/common.json index c2cf34164..699c15a08 100644 --- a/web-app/src/locales/de-DE/common.json +++ b/web-app/src/locales/de-DE/common.json @@ -274,7 +274,10 @@ "updated": "Aktualisiert:", "collapseProject": "Projekt einklappen", "expandProject": "Projekt ausklappen", - "update": "Aktualisieren" + "update": "Aktualisieren", + "searchProjects": "Projekte durchsuchen...", + "noProjectsFound": "Keine Projekte gefunden", + "tryDifferentSearch": "Versuchen Sie einen anderen Suchbegriff" }, "toast": { "allThreadsUnfavorited": { diff --git a/web-app/src/locales/id/common.json b/web-app/src/locales/id/common.json index 3dbdfd90e..77af93d31 100644 --- a/web-app/src/locales/id/common.json +++ b/web-app/src/locales/id/common.json @@ -356,6 +356,9 @@ "updated": "Diperbarui:", "collapseProject": "Tutup proyek", "expandProject": "Buka proyek", - "update": "Perbarui" + "update": "Perbarui", + "searchProjects": "Cari proyek...", + "noProjectsFound": "Tidak ada proyek ditemukan", + "tryDifferentSearch": "Coba kata kunci pencarian lain" } } diff --git a/web-app/src/locales/pl/common.json b/web-app/src/locales/pl/common.json index 87e31117e..ee25f6068 100644 --- a/web-app/src/locales/pl/common.json +++ b/web-app/src/locales/pl/common.json @@ -274,7 +274,10 @@ "updated": "Zaktualizowano:", "collapseProject": "Zwiń projekt", "expandProject": "Rozwiń projekt", - "update": "Aktualizuj" + "update": "Aktualizuj", + "searchProjects": "Szukaj projektów...", + "noProjectsFound": "Nie znaleziono projektów", + "tryDifferentSearch": "Spróbuj innego wyszukiwania" }, "toast": { "allThreadsUnfavorited": { From aa0c4b0d1b7676d8394bff95d0dc797de0f47c88 Mon Sep 17 00:00:00 2001 From: Faisal Amir Date: Thu, 2 Oct 2025 12:32:52 +0700 Subject: [PATCH 28/54] fix: theme native system and check os support blur --- src-tauri/capabilities/log-app-window.json | 3 + src-tauri/capabilities/logs-window.json | 3 + .../capabilities/system-monitor-window.json | 16 ++- src-tauri/src/core/setup.rs | 31 +++++- src-tauri/src/core/system/commands.rs | 105 ++++++++++++++++++ src-tauri/src/lib.rs | 2 + src-tauri/tauri.conf.json | 2 +- web-app/index.html | 2 +- .../src/containers/ColorPickerAppBgColor.tsx | 26 +++-- web-app/src/containers/DropdownAssistant.tsx | 2 +- web-app/src/containers/LeftPanel.tsx | 3 +- .../src/hooks/__tests__/useAppearance.test.ts | 43 ++++++- web-app/src/hooks/useAppearance.ts | 68 +++++++++++- web-app/src/hooks/useTheme.ts | 4 +- web-app/src/index.css | 1 - web-app/src/providers/AppearanceProvider.tsx | 11 +- web-app/src/providers/ThemeProvider.tsx | 47 +++++++- web-app/src/routes/index.tsx | 13 ++- web-app/src/routes/logs.tsx | 21 ++-- web-app/src/services/app/default.ts | 5 + web-app/src/services/app/tauri.ts | 4 + web-app/src/services/app/types.ts | 1 + web-app/src/services/app/web.ts | 5 + web-app/src/services/theme/tauri.ts | 27 ++++- web-app/src/services/window/tauri.ts | 78 +++++++++++-- 25 files changed, 464 insertions(+), 59 deletions(-) diff --git a/src-tauri/capabilities/log-app-window.json b/src-tauri/capabilities/log-app-window.json index 9f95d1bb9..97dbf952d 100644 --- a/src-tauri/capabilities/log-app-window.json +++ b/src-tauri/capabilities/log-app-window.json @@ -3,10 +3,13 @@ "identifier": "logs-app-window", "description": "enables permissions for the logs app window", "windows": ["logs-app-window"], + "platforms": ["linux", "macOS", "windows"], "permissions": [ "core:default", "core:window:allow-start-dragging", "core:window:allow-set-theme", + "core:window:allow-get-all-windows", + "core:event:allow-listen", "log:default", "core:webview:allow-create-webview-window", "core:window:allow-set-focus" diff --git a/src-tauri/capabilities/logs-window.json b/src-tauri/capabilities/logs-window.json index ef56e6f75..34b1e033a 100644 --- a/src-tauri/capabilities/logs-window.json +++ b/src-tauri/capabilities/logs-window.json @@ -3,10 +3,13 @@ "identifier": "logs-window", "description": "enables permissions for the logs window", "windows": ["logs-window-local-api-server"], + "platforms": ["linux", "macOS", "windows"], "permissions": [ "core:default", "core:window:allow-start-dragging", "core:window:allow-set-theme", + "core:window:allow-get-all-windows", + "core:event:allow-listen", "log:default", "core:webview:allow-create-webview-window", "core:window:allow-set-focus" diff --git a/src-tauri/capabilities/system-monitor-window.json b/src-tauri/capabilities/system-monitor-window.json index 68a75e9fb..75a26fd87 100644 --- a/src-tauri/capabilities/system-monitor-window.json +++ b/src-tauri/capabilities/system-monitor-window.json @@ -8,6 +8,8 @@ "core:default", "core:window:allow-start-dragging", "core:window:allow-set-theme", + "core:window:allow-get-all-windows", + "core:event:allow-listen", "log:default", "core:webview:allow-create-webview-window", "core:window:allow-set-focus", @@ -15,6 +17,18 @@ "hardware:allow-get-system-usage", "llamacpp:allow-get-devices", "llamacpp:allow-read-gguf-metadata", - "deep-link:allow-get-current" + "deep-link:allow-get-current", + { + "identifier": "http:default", + "allow": [ + { + "url": "https://*:*" + }, + { + "url": "http://*:*" + } + ], + "deny": [] + } ] } diff --git a/src-tauri/src/core/setup.rs b/src-tauri/src/core/setup.rs index 6564d3609..7ba8f2f74 100644 --- a/src-tauri/src/core/setup.rs +++ b/src-tauri/src/core/setup.rs @@ -7,7 +7,7 @@ use std::{ }; use tar::Archive; use tauri::{ - App, Emitter, Manager, Runtime, Wry + App, Emitter, Manager, Runtime, Wry, WindowEvent }; #[cfg(desktop)] @@ -270,3 +270,32 @@ pub fn setup_tray(app: &App) -> tauri::Result { }) .build(app) } + +pub fn setup_theme_listener(app: &App) -> tauri::Result<()> { + // Setup theme listener for main window + if let Some(window) = app.get_webview_window("main") { + setup_window_theme_listener(app.handle().clone(), window); + } + + Ok(()) +} + +fn setup_window_theme_listener( + app_handle: tauri::AppHandle, + window: tauri::WebviewWindow, +) { + let window_label = window.label().to_string(); + let app_handle_clone = app_handle.clone(); + + window.on_window_event(move |event| { + if let WindowEvent::ThemeChanged(theme) = event { + let theme_str = match theme { + tauri::Theme::Light => "light", + tauri::Theme::Dark => "dark", + _ => "auto", + }; + log::info!("System theme changed to: {} for window: {}", theme_str, window_label); + let _ = app_handle_clone.emit("theme-changed", theme_str); + } + }); +} diff --git a/src-tauri/src/core/system/commands.rs b/src-tauri/src/core/system/commands.rs index 938e6f8bf..e01c36854 100644 --- a/src-tauri/src/core/system/commands.rs +++ b/src-tauri/src/core/system/commands.rs @@ -117,3 +117,108 @@ pub fn is_library_available(library: &str) -> bool { } } } + +// Check if the system supports blur/acrylic effects +// - Windows: Checks build version (17134+ for acrylic support) +// - Linux: Checks for KWin (KDE) or compositor with blur support +// - macOS: Always supported +#[tauri::command] +pub fn supports_blur_effects() -> bool { + #[cfg(target_os = "windows")] + { + // Windows 10 build 17134 (1803) and later support acrylic effects + // Windows 11 (build 22000+) has better support + use std::process::Command; + + if let Ok(output) = Command::new("cmd") + .args(&["/C", "ver"]) + .output() + { + if let Ok(version_str) = String::from_utf8(output.stdout) { + // Parse Windows version from output like "Microsoft Windows [Version 10.0.22631.4602]" + if let Some(version_part) = version_str.split("Version ").nth(1) { + if let Some(build_str) = version_part.split('.').nth(2) { + if let Ok(build) = build_str.split(']').next().unwrap_or("0").trim().parse::() { + // Windows 10 build 17134+ or Windows 11 build 22000+ support blur + let supports_blur = build >= 17134; + if supports_blur { + log::info!("✅ Windows build {} detected - Blur/Acrylic effects SUPPORTED", build); + } else { + log::warn!("❌ Windows build {} detected - Blur/Acrylic effects NOT SUPPORTED (requires build 17134+)", build); + } + return supports_blur; + } + } + } + } + } + + // If we can't detect version, assume it doesn't support blur for safety + log::warn!("❌ Could not detect Windows version - Assuming NO blur support for safety"); + false + } + + #[cfg(target_os = "linux")] + { + use std::process::Command; + + // Check for KDE Plasma with KWin (best blur support) + if let Ok(output) = Command::new("kwin_x11").arg("--version").output() { + if output.status.success() { + log::info!("✅ KDE/KWin detected - Blur effects SUPPORTED"); + return true; + } + } + + // Check for Wayland KWin + if let Ok(output) = Command::new("kwin_wayland").arg("--version").output() { + if output.status.success() { + log::info!("✅ KDE/KWin Wayland detected - Blur effects SUPPORTED"); + return true; + } + } + + // Check for GNOME with blur extensions (less reliable) + if std::env::var("XDG_CURRENT_DESKTOP").unwrap_or_default().contains("GNOME") { + log::info!("🔍 GNOME detected - Blur support depends on extensions"); + // GNOME might have blur through extensions, allow it + return true; + } + + // Check for Compiz (older but has blur) + if let Ok(_) = Command::new("compiz").arg("--version").output() { + log::info!("✅ Compiz compositor detected - Blur effects SUPPORTED"); + return true; + } + + // Check for Picom with blur (common X11 compositor) + if let Ok(output) = Command::new("picom").arg("--version").output() { + if output.status.success() { + log::info!("✅ Picom compositor detected - Blur effects SUPPORTED"); + return true; + } + } + + // Check environment variable for compositor + if let Ok(compositor) = std::env::var("COMPOSITOR") { + log::info!("🔍 Compositor detected: {} - Assuming blur support", compositor); + return true; + } + + log::warn!("❌ No known blur-capable compositor detected on Linux"); + false + } + + #[cfg(target_os = "macos")] + { + // macOS always supports blur/vibrancy effects + log::info!("✅ macOS detected - Blur/Vibrancy effects SUPPORTED"); + true + } + + #[cfg(not(any(target_os = "windows", target_os = "linux", target_os = "macos")))] + { + log::warn!("❌ Unknown platform - Assuming NO blur support"); + false + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 628f22b08..85916f6cf 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -78,6 +78,7 @@ pub fn run() { core::system::commands::factory_reset, core::system::commands::read_logs, core::system::commands::is_library_available, + core::system::commands::supports_blur_effects, // Server commands core::server::commands::start_server, core::server::commands::stop_server, @@ -193,6 +194,7 @@ pub fn run() { } setup_mcp(app); + setup::setup_theme_listener(app)?; Ok(()) }) .build(tauri::generate_context!()) diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index b0df3fc2f..fb1b1950b 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -40,7 +40,7 @@ } ], "security": { - "capabilities": ["default"], + "capabilities": ["default", "logs-app-window", "logs-window", "system-monitor-window"], "csp": { "default-src": "'self' customprotocol: asset: http://localhost:* http://127.0.0.1:* ws://localhost:* ws://127.0.0.1:*", "connect-src": "ipc: http://ipc.localhost http://127.0.0.1:* ws://localhost:* ws://127.0.0.1:* https: http:", diff --git a/web-app/index.html b/web-app/index.html index dd2e76ee6..55625d33c 100644 --- a/web-app/index.html +++ b/web-app/index.html @@ -17,7 +17,7 @@ Jan diff --git a/web-app/src/containers/ColorPickerAppBgColor.tsx b/web-app/src/containers/ColorPickerAppBgColor.tsx index 72e098aa4..36566d03b 100644 --- a/web-app/src/containers/ColorPickerAppBgColor.tsx +++ b/web-app/src/containers/ColorPickerAppBgColor.tsx @@ -1,4 +1,4 @@ -import { useAppearance, isDefaultColor } from '@/hooks/useAppearance' +import { useAppearance, isDefaultColor, useBlurSupport } from '@/hooks/useAppearance' import { cn } from '@/lib/utils' import { RgbaColor, RgbaColorPicker } from 'react-colorful' import { IconColorPicker } from '@tabler/icons-react' @@ -14,6 +14,12 @@ export function ColorPickerAppBgColor() { const { appBgColor, setAppBgColor } = useAppearance() const { isDark } = useTheme() const { t } = useTranslation() + const showAlphaSlider = useBlurSupport() + + // Helper to get alpha value based on blur support + const getAlpha = (defaultAlpha: number) => { + return showAlphaSlider ? defaultAlpha : 1 + } const predefineAppBgColor: RgbaColor[] = [ isDark @@ -21,38 +27,38 @@ export function ColorPickerAppBgColor() { r: 25, g: 25, b: 25, - a: IS_WINDOWS || IS_LINUX || !IS_TAURI ? 1 : 0.4, + a: getAlpha(0.4), } : { r: 255, g: 255, b: 255, - a: IS_WINDOWS || IS_LINUX || !IS_TAURI ? 1 : 0.4, + a: getAlpha(0.4), }, { r: 70, g: 79, b: 229, - a: IS_WINDOWS || IS_LINUX || !IS_TAURI ? 1 : 0.5, + a: getAlpha(0.5), }, { r: 238, g: 130, b: 238, - a: IS_WINDOWS || IS_LINUX || !IS_TAURI ? 1 : 0.5, + a: getAlpha(0.5), }, { r: 255, g: 99, b: 71, - a: IS_WINDOWS || IS_LINUX || !IS_TAURI ? 1 : 0.5, + a: getAlpha(0.5), }, { r: 255, g: 165, b: 0, - a: IS_WINDOWS || IS_LINUX || !IS_TAURI ? 1 : 0.5, + a: getAlpha(0.5), }, ] @@ -61,9 +67,9 @@ export function ColorPickerAppBgColor() { {predefineAppBgColor.map((item, i) => { const isSelected = (item.r === appBgColor.r && - item.g === appBgColor.g && - item.b === appBgColor.b && - item.a === appBgColor.a) || + item.g === appBgColor.g && + item.b === appBgColor.b && + item.a === appBgColor.a) || (isDefaultColor(appBgColor) && isDefaultColor(item)) return (
{ return ( <> -
+