From c5a5968bf8f67952f667ce4c3337178daacb293c Mon Sep 17 00:00:00 2001 From: Nghia Doan Date: Mon, 29 Sep 2025 22:15:13 +0700 Subject: [PATCH 01/97] 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/97] 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/97] 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/97] 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/97] 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/97] 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/97] 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/97] 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 fb8b61c567a75c380e0d971162ffccc2ba6008f7 Mon Sep 17 00:00:00 2001 From: Vanalite Date: Wed, 1 Oct 2025 22:47:03 +0700 Subject: [PATCH 09/97] fix: Fix editing model without saving should restore original name --- .../containers/__tests__/EditModel.test.tsx | 70 ++++++- web-app/src/containers/dialogs/EditModel.tsx | 180 +++++------------- 2 files changed, 118 insertions(+), 132 deletions(-) diff --git a/web-app/src/containers/__tests__/EditModel.test.tsx b/web-app/src/containers/__tests__/EditModel.test.tsx index 9f4eafc84..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() }) + + 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 1b9efee52c7e16f3a7b58932aae3f53f0c0b2e28 Mon Sep 17 00:00:00 2001 From: Dinh Long Nguyen Date: Wed, 1 Oct 2025 22:47:38 +0700 Subject: [PATCH 10/97] 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 11/97] 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 12/97] 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 eccaa282e04c5cb3375e31bd7e0fe83c94f83cd1 Mon Sep 17 00:00:00 2001 From: Roushan Kumar Singh <158602016+github-roushan@users.noreply.github.com> Date: Thu, 2 Oct 2025 13:31:06 +0530 Subject: [PATCH 13/97] refactor: resolve rust analyzer warnings and improve code quality (#6696) - Update string formatting to use modern interpolation syntax - Simplify expressions and remove unnecessary intermediate variables - Improve logging statements for better readability - Clean up code across core modules (app, downloads, mcp, server, etc.) --- src-tauri/src/core/app/commands.rs | 41 ++----- src-tauri/src/core/downloads/commands.rs | 6 +- src-tauri/src/core/downloads/helpers.rs | 48 ++++---- src-tauri/src/core/extensions/commands.rs | 10 +- src-tauri/src/core/filesystem/tests.rs | 6 +- src-tauri/src/core/mcp/commands.rs | 36 +++--- src-tauri/src/core/mcp/helpers.rs | 130 ++++++++-------------- src-tauri/src/core/mcp/tests.rs | 4 +- src-tauri/src/core/server/proxy.rs | 88 ++++++--------- src-tauri/src/core/setup.rs | 14 +-- src-tauri/src/core/system/commands.rs | 22 ++-- src-tauri/src/core/threads/commands.rs | 6 +- src-tauri/src/core/threads/helpers.rs | 2 +- src-tauri/src/core/threads/tests.rs | 6 +- src-tauri/src/lib.rs | 11 +- 15 files changed, 177 insertions(+), 253 deletions(-) diff --git a/src-tauri/src/core/app/commands.rs b/src-tauri/src/core/app/commands.rs index 0d9c66c12..18e746869 100644 --- a/src-tauri/src/core/app/commands.rs +++ b/src-tauri/src/core/app/commands.rs @@ -19,10 +19,7 @@ pub fn get_app_configurations(app_handle: tauri::AppHandle) -> Ap let default_data_folder = default_data_folder_path(app_handle.clone()); if !configuration_file.exists() { - log::info!( - "App config not found, creating default config at {:?}", - configuration_file - ); + log::info!("App config not found, creating default config at {configuration_file:?}"); app_default_configuration.data_folder = default_data_folder; @@ -30,7 +27,7 @@ pub fn get_app_configurations(app_handle: tauri::AppHandle) -> Ap &configuration_file, serde_json::to_string(&app_default_configuration).unwrap(), ) { - log::error!("Failed to create default config: {}", err); + log::error!("Failed to create default config: {err}"); } return app_default_configuration; @@ -40,18 +37,12 @@ pub fn get_app_configurations(app_handle: tauri::AppHandle) -> Ap Ok(content) => match serde_json::from_str::(&content) { Ok(app_configurations) => app_configurations, Err(err) => { - log::error!( - "Failed to parse app config, returning default config instead. Error: {}", - err - ); + log::error!("Failed to parse app config, returning default config instead. Error: {err}"); app_default_configuration } }, Err(err) => { - log::error!( - "Failed to read app config, returning default config instead. Error: {}", - err - ); + log::error!("Failed to read app config, returning default config instead. Error: {err}"); app_default_configuration } } @@ -63,10 +54,7 @@ pub fn update_app_configuration( configuration: AppConfiguration, ) -> Result<(), String> { let configuration_file = get_configuration_file_path(app_handle); - log::info!( - "update_app_configuration, configuration_file: {:?}", - configuration_file - ); + log::info!("update_app_configuration, configuration_file: {configuration_file:?}"); fs::write( configuration_file, @@ -95,8 +83,7 @@ pub fn get_jan_data_folder_path(app_handle: tauri::AppHandle) -> pub fn get_configuration_file_path(app_handle: tauri::AppHandle) -> PathBuf { let app_path = app_handle.path().app_data_dir().unwrap_or_else(|err| { log::error!( - "Failed to get app data directory: {}. Using home directory instead.", - err + "Failed to get app data directory: {err}. Using home directory instead." ); let home_dir = std::env::var(if cfg!(target_os = "windows") { @@ -130,9 +117,9 @@ pub fn get_configuration_file_path(app_handle: tauri::AppHandle) .join(package_name); if old_data_dir.exists() { - return old_data_dir.join(CONFIGURATION_FILE_NAME); + old_data_dir.join(CONFIGURATION_FILE_NAME) } else { - return app_path.join(CONFIGURATION_FILE_NAME); + app_path.join(CONFIGURATION_FILE_NAME) } } @@ -156,7 +143,7 @@ pub fn default_data_folder_path(app_handle: tauri::AppHandle) -> #[tauri::command] pub fn get_user_home_path(app: AppHandle) -> String { - return get_app_configurations(app.clone()).data_folder; + get_app_configurations(app.clone()).data_folder } #[tauri::command] @@ -171,16 +158,12 @@ pub fn change_app_data_folder( // Create the new data folder if it doesn't exist if !new_data_folder_path.exists() { fs::create_dir_all(&new_data_folder_path) - .map_err(|e| format!("Failed to create new data folder: {}", e))?; + .map_err(|e| format!("Failed to create new data folder: {e}"))?; } // Copy all files from the old folder to the new one if current_data_folder.exists() { - log::info!( - "Copying data from {:?} to {:?}", - current_data_folder, - new_data_folder_path - ); + log::info!("Copying data from {current_data_folder:?} to {new_data_folder_path:?}"); // Check if this is a parent directory to avoid infinite recursion if new_data_folder_path.starts_with(¤t_data_folder) { @@ -193,7 +176,7 @@ pub fn change_app_data_folder( &new_data_folder_path, &[".uvx", ".npx"], ) - .map_err(|e| format!("Failed to copy data to new folder: {}", e))?; + .map_err(|e| format!("Failed to copy data to new folder: {e}"))?; } else { log::info!("Current data folder does not exist, nothing to copy"); } diff --git a/src-tauri/src/core/downloads/commands.rs b/src-tauri/src/core/downloads/commands.rs index 6d50ed1a3..a24ae32f0 100644 --- a/src-tauri/src/core/downloads/commands.rs +++ b/src-tauri/src/core/downloads/commands.rs @@ -19,7 +19,7 @@ pub async fn download_files( { let mut download_manager = state.download_manager.lock().await; if download_manager.cancel_tokens.contains_key(task_id) { - return Err(format!("task_id {} exists", task_id)); + return Err(format!("task_id {task_id} exists")); } download_manager .cancel_tokens @@ -60,9 +60,9 @@ pub async fn cancel_download_task(state: State<'_, AppState>, task_id: &str) -> let mut download_manager = state.download_manager.lock().await; if let Some(token) = download_manager.cancel_tokens.remove(task_id) { token.cancel(); - log::info!("Cancelled download task: {}", task_id); + log::info!("Cancelled download task: {task_id}"); Ok(()) } else { - Err(format!("No download task: {}", task_id)) + Err(format!("No download task: {task_id}")) } } diff --git a/src-tauri/src/core/downloads/helpers.rs b/src-tauri/src/core/downloads/helpers.rs index d3d8f6b7c..3ce1d89fa 100644 --- a/src-tauri/src/core/downloads/helpers.rs +++ b/src-tauri/src/core/downloads/helpers.rs @@ -15,7 +15,7 @@ use url::Url; // ===== UTILITY FUNCTIONS ===== pub fn err_to_string(e: E) -> String { - format!("Error: {}", e) + format!("Error: {e}") } @@ -55,7 +55,7 @@ async fn validate_downloaded_file( ) .unwrap(); - log::info!("Starting validation for model: {}", model_id); + log::info!("Starting validation for model: {model_id}"); // Validate size if provided (fast check first) if let Some(expected_size) = &item.size { @@ -73,8 +73,7 @@ async fn validate_downloaded_file( actual_size ); return Err(format!( - "Size verification failed. Expected {} bytes but got {} bytes.", - expected_size, actual_size + "Size verification failed. Expected {expected_size} bytes but got {actual_size} bytes." )); } @@ -90,7 +89,7 @@ async fn validate_downloaded_file( save_path.display(), e ); - return Err(format!("Failed to verify file size: {}", e)); + return Err(format!("Failed to verify file size: {e}")); } } } @@ -115,9 +114,7 @@ async fn validate_downloaded_file( computed_sha256 ); - return Err(format!( - "Hash verification failed. The downloaded file is corrupted or has been tampered with." - )); + return Err("Hash verification failed. The downloaded file is corrupted or has been tampered with.".to_string()); } log::info!("Hash verification successful for {}", item.url); @@ -128,7 +125,7 @@ async fn validate_downloaded_file( save_path.display(), e ); - return Err(format!("Failed to verify file integrity: {}", e)); + return Err(format!("Failed to verify file integrity: {e}")); } } } @@ -140,14 +137,14 @@ async fn validate_downloaded_file( pub fn validate_proxy_config(config: &ProxyConfig) -> Result<(), String> { // Validate proxy URL format if let Err(e) = Url::parse(&config.url) { - return Err(format!("Invalid proxy URL '{}': {}", config.url, e)); + return Err(format!("Invalid proxy URL '{}': {e}", config.url)); } // Check if proxy URL has valid scheme let url = Url::parse(&config.url).unwrap(); // Safe to unwrap as we just validated it match url.scheme() { "http" | "https" | "socks4" | "socks5" => {} - scheme => return Err(format!("Unsupported proxy scheme: {}", scheme)), + scheme => return Err(format!("Unsupported proxy scheme: {scheme}")), } // Validate authentication credentials @@ -167,7 +164,7 @@ pub fn validate_proxy_config(config: &ProxyConfig) -> Result<(), String> { } // Basic validation for wildcard patterns if entry.starts_with("*.") && entry.len() < 3 { - return Err(format!("Invalid wildcard pattern: {}", entry)); + return Err(format!("Invalid wildcard pattern: {entry}")); } } } @@ -214,8 +211,7 @@ pub fn should_bypass_proxy(url: &str, no_proxy: &[String]) -> bool { } // Simple wildcard matching - if entry.starts_with("*.") { - let domain = &entry[2..]; + if let Some(domain) = entry.strip_prefix("*.") { if host.ends_with(domain) { return true; } @@ -305,7 +301,7 @@ pub async fn _download_files_internal( resume: bool, cancel_token: CancellationToken, ) -> Result<(), String> { - log::info!("Start download task: {}", task_id); + log::info!("Start download task: {task_id}"); let header_map = _convert_headers(headers).map_err(err_to_string)?; @@ -320,9 +316,9 @@ pub async fn _download_files_internal( } let total_size: u64 = file_sizes.values().sum(); - log::info!("Total download size: {}", total_size); + log::info!("Total download size: {total_size}"); - let evt_name = format!("download-{}", task_id); + let evt_name = format!("download-{task_id}"); // Create progress tracker let progress_tracker = ProgressTracker::new(items, file_sizes.clone()); @@ -352,7 +348,7 @@ pub async fn _download_files_internal( let cancel_token_clone = cancel_token.clone(); let evt_name_clone = evt_name.clone(); let progress_tracker_clone = progress_tracker.clone(); - let file_id = format!("{}-{}", task_id, index); + let file_id = format!("{task_id}-{index}"); let file_size = file_sizes.get(&item.url).copied().unwrap_or(0); let task = tokio::spawn(async move { @@ -377,7 +373,7 @@ pub async fn _download_files_internal( // Wait for all downloads to complete let mut validation_tasks = Vec::new(); for (task, item) in download_tasks.into_iter().zip(items.iter()) { - let result = task.await.map_err(|e| format!("Task join error: {}", e))?; + let result = task.await.map_err(|e| format!("Task join error: {e}"))?; match result { Ok(downloaded_path) => { @@ -399,7 +395,7 @@ pub async fn _download_files_internal( for (validation_task, save_path, _item) in validation_tasks { let validation_result = validation_task .await - .map_err(|e| format!("Validation task join error: {}", e))?; + .map_err(|e| format!("Validation task join error: {e}"))?; if let Err(validation_error) = validation_result { // Clean up the file if validation fails @@ -448,7 +444,7 @@ async fn download_single_file( if current_extension.is_empty() { ext.to_string() } else { - format!("{}.{}", current_extension, ext) + format!("{current_extension}.{ext}") } }; let tmp_save_path = save_path.with_extension(append_extension("tmp")); @@ -469,8 +465,8 @@ async fn download_single_file( let decoded_url = url::Url::parse(&item.url) .map(|u| u.to_string()) .unwrap_or_else(|_| item.url.clone()); - log::info!("Started downloading: {}", decoded_url); - let client = _get_client_for_item(item, &header_map).map_err(err_to_string)?; + log::info!("Started downloading: {decoded_url}"); + let client = _get_client_for_item(item, header_map).map_err(err_to_string)?; let mut download_delta = 0u64; let mut initial_progress = 0u64; @@ -503,7 +499,7 @@ async fn download_single_file( } Err(e) => { // fallback to normal download - log::warn!("Failed to resume download: {}", e); + log::warn!("Failed to resume download: {e}"); should_resume = false; _get_maybe_resume(&client, &item.url, 0).await? } @@ -592,7 +588,7 @@ async fn download_single_file( let decoded_url = url::Url::parse(&item.url) .map(|u| u.to_string()) .unwrap_or_else(|_| item.url.clone()); - log::info!("Finished downloading: {}", decoded_url); + log::info!("Finished downloading: {decoded_url}"); Ok(save_path.to_path_buf()) } @@ -606,7 +602,7 @@ pub async fn _get_maybe_resume( if start_bytes > 0 { let resp = client .get(url) - .header("Range", format!("bytes={}-", start_bytes)) + .header("Range", format!("bytes={start_bytes}-")) .send() .await .map_err(err_to_string)?; diff --git a/src-tauri/src/core/extensions/commands.rs b/src-tauri/src/core/extensions/commands.rs index 4c5a44a53..679ffb695 100644 --- a/src-tauri/src/core/extensions/commands.rs +++ b/src-tauri/src/core/extensions/commands.rs @@ -13,7 +13,7 @@ pub fn get_jan_extensions_path(app_handle: tauri::AppHandle) -> P #[tauri::command] pub fn install_extensions(app: AppHandle) { if let Err(err) = setup::install_extensions(app, true) { - log::error!("Failed to install extensions: {}", err); + log::error!("Failed to install extensions: {err}"); } } @@ -21,7 +21,7 @@ pub fn install_extensions(app: AppHandle) { 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); + log::info!("get jan extensions, path: {path:?}"); let contents = fs::read_to_string(path); let contents: Vec = match contents { @@ -40,14 +40,14 @@ pub fn get_active_extensions(app: AppHandle) -> Vec { - log::error!("Failed to parse extensions.json: {}", error); + log::error!("Failed to parse extensions.json: {error}"); vec![] } }, Err(error) => { - log::error!("Failed to read extensions.json: {}", error); + log::error!("Failed to read extensions.json: {error}"); vec![] } }; - return contents; + contents } diff --git a/src-tauri/src/core/filesystem/tests.rs b/src-tauri/src/core/filesystem/tests.rs index b4e96e994..b89b834d6 100644 --- a/src-tauri/src/core/filesystem/tests.rs +++ b/src-tauri/src/core/filesystem/tests.rs @@ -9,7 +9,7 @@ fn test_rm() { let app = mock_app(); let path = "test_rm_dir"; fs::create_dir_all(get_jan_data_folder_path(app.handle().clone()).join(path)).unwrap(); - let args = vec![format!("file://{}", path).to_string()]; + let args = vec![format!("file://{path}").to_string()]; let result = rm(app.handle().clone(), args); assert!(result.is_ok()); assert!(!get_jan_data_folder_path(app.handle().clone()) @@ -21,7 +21,7 @@ fn test_rm() { fn test_mkdir() { let app = mock_app(); let path = "test_mkdir_dir"; - let args = vec![format!("file://{}", path).to_string()]; + let args = vec![format!("file://{path}").to_string()]; let result = mkdir(app.handle().clone(), args); assert!(result.is_ok()); assert!(get_jan_data_folder_path(app.handle().clone()) @@ -39,7 +39,7 @@ fn test_join_path() { assert_eq!( result, get_jan_data_folder_path(app.handle().clone()) - .join(&format!("test_dir{}test_file", std::path::MAIN_SEPARATOR)) + .join(format!("test_dir{}test_file", std::path::MAIN_SEPARATOR)) .to_string_lossy() .to_string() ); diff --git a/src-tauri/src/core/mcp/commands.rs b/src-tauri/src/core/mcp/commands.rs index a86db598e..6eb6dab40 100644 --- a/src-tauri/src/core/mcp/commands.rs +++ b/src-tauri/src/core/mcp/commands.rs @@ -30,28 +30,28 @@ pub async fn activate_mcp_server( #[tauri::command] pub async fn deactivate_mcp_server(state: State<'_, AppState>, name: String) -> Result<(), String> { - log::info!("Deactivating MCP server: {}", name); + log::info!("Deactivating MCP server: {name}"); // First, mark server as manually deactivated to prevent restart // Remove from active servers list to prevent restart { let mut active_servers = state.mcp_active_servers.lock().await; active_servers.remove(&name); - log::info!("Removed MCP server {} from active servers list", name); + log::info!("Removed MCP server {name} from active servers list"); } // Mark as not successfully connected to prevent restart logic { let mut connected = state.mcp_successfully_connected.lock().await; connected.insert(name.clone(), false); - log::info!("Marked MCP server {} as not successfully connected", name); + log::info!("Marked MCP server {name} as not successfully connected"); } // Reset restart count { let mut counts = state.mcp_restart_counts.lock().await; counts.remove(&name); - log::info!("Reset restart count for MCP server {}", name); + log::info!("Reset restart count for MCP server {name}"); } // Now remove and stop the server @@ -60,7 +60,7 @@ pub async fn deactivate_mcp_server(state: State<'_, AppState>, name: String) -> let service = servers_map .remove(&name) - .ok_or_else(|| format!("Server {} not found", name))?; + .ok_or_else(|| format!("Server {name} not found"))?; // Release the lock before calling cancel drop(servers_map); @@ -89,7 +89,7 @@ pub async fn restart_mcp_servers(app: AppHandle, state: State<'_, restart_active_mcp_servers(&app, servers).await?; app.emit("mcp-update", "MCP servers updated") - .map_err(|e| format!("Failed to emit event: {}", e))?; + .map_err(|e| format!("Failed to emit event: {e}"))?; Ok(()) } @@ -110,9 +110,7 @@ pub async fn reset_mcp_restart_count( let old_count = *count; *count = 0; log::info!( - "MCP server {} restart count reset from {} to 0.", - server_name, - old_count + "MCP server {server_name} restart count reset from {old_count} to 0." ); Ok(()) } @@ -219,7 +217,7 @@ pub async fn call_tool( continue; // Tool not found in this server, try next } - println!("Found tool {} in server", tool_name); + println!("Found tool {tool_name} in server"); // Call the tool with timeout and cancellation support let tool_call = service.call_tool(CallToolRequestParam { @@ -234,22 +232,20 @@ pub async fn call_tool( match result { Ok(call_result) => call_result.map_err(|e| e.to_string()), Err(_) => Err(format!( - "Tool call '{}' timed out after {} seconds", - tool_name, + "Tool call '{tool_name}' timed out after {} seconds", MCP_TOOL_CALL_TIMEOUT.as_secs() )), } } _ = cancel_rx => { - Err(format!("Tool call '{}' was cancelled", tool_name)) + Err(format!("Tool call '{tool_name}' was cancelled")) } } } else { match timeout(MCP_TOOL_CALL_TIMEOUT, tool_call).await { Ok(call_result) => call_result.map_err(|e| e.to_string()), Err(_) => Err(format!( - "Tool call '{}' timed out after {} seconds", - tool_name, + "Tool call '{tool_name}' timed out after {} seconds", MCP_TOOL_CALL_TIMEOUT.as_secs() )), } @@ -264,7 +260,7 @@ pub async fn call_tool( return result; } - Err(format!("Tool {} not found", tool_name)) + Err(format!("Tool {tool_name} not found")) } /// Cancels a running tool call by its cancellation token @@ -285,10 +281,10 @@ pub async fn cancel_tool_call( if let Some(cancel_tx) = cancellations.remove(&cancellation_token) { // Send cancellation signal - ignore if receiver is already dropped let _ = cancel_tx.send(()); - println!("Tool call with token {} cancelled", cancellation_token); + println!("Tool call with token {cancellation_token} cancelled"); Ok(()) } else { - Err(format!("Cancellation token {} not found", cancellation_token)) + Err(format!("Cancellation token {cancellation_token} not found")) } } @@ -301,7 +297,7 @@ pub async fn get_mcp_configs(app: AppHandle) -> Result(app: AppHandle) -> Result(app: AppHandle, configs: String) -> Result<(), String> { let mut path = get_jan_data_folder_path(app); path.push("mcp_config.json"); - log::info!("save mcp configs, path: {:?}", path); + log::info!("save mcp configs, path: {path:?}"); fs::write(path, configs).map_err(|e| e.to_string()) } diff --git a/src-tauri/src/core/mcp/helpers.rs b/src-tauri/src/core/mcp/helpers.rs index 48c92ba2c..4e226a055 100644 --- a/src-tauri/src/core/mcp/helpers.rs +++ b/src-tauri/src/core/mcp/helpers.rs @@ -56,22 +56,13 @@ pub fn calculate_exponential_backoff_delay(attempt: u32) -> u64 { let hash = hasher.finish(); // Convert hash to jitter value in range [-jitter_range, +jitter_range] - let jitter_offset = (hash % (jitter_range * 2)) as i64 - jitter_range as i64; - jitter_offset + (hash % (jitter_range * 2)) as i64 - jitter_range as i64 } else { 0 }; // Apply jitter while ensuring delay stays positive and within bounds - let final_delay = cmp::max( - 100, // Minimum 100ms delay - cmp::min( - MCP_MAX_RESTART_DELAY_MS, - (capped_delay as i64 + jitter) as u64, - ), - ); - - final_delay + ((capped_delay as i64 + jitter) as u64).clamp(100, MCP_MAX_RESTART_DELAY_MS) } /// Runs MCP commands by reading configuration from a JSON file and initializing servers @@ -135,9 +126,7 @@ pub async fn run_mcp_commands( // If initial startup failed, we still want to continue with other servers if let Err(e) = &result { log::error!( - "Initial startup failed for MCP server {}: {}", - name_clone, - e + "Initial startup failed for MCP server {name_clone}: {e}" ); } @@ -155,25 +144,23 @@ pub async fn run_mcp_commands( match handle.await { Ok((name, result)) => match result { Ok(_) => { - log::info!("MCP server {} initialized successfully", name); + log::info!("MCP server {name} initialized successfully"); successful_count += 1; } Err(e) => { - log::error!("MCP server {} failed to initialize: {}", name, e); + log::error!("MCP server {name} failed to initialize: {e}"); failed_count += 1; } }, Err(e) => { - log::error!("Failed to join startup task: {}", e); + log::error!("Failed to join startup task: {e}"); failed_count += 1; } } } log::info!( - "MCP server initialization complete: {} successful, {} failed", - successful_count, - failed_count + "MCP server initialization complete: {successful_count} successful, {failed_count} failed" ); Ok(()) @@ -184,7 +171,7 @@ pub async fn monitor_mcp_server_handle( servers_state: SharedMcpServers, name: String, ) -> Option { - log::info!("Monitoring MCP server {} health", name); + log::info!("Monitoring MCP server {name} health"); // Monitor server health with periodic checks loop { @@ -202,17 +189,17 @@ pub async fn monitor_mcp_server_handle( true } Ok(Err(e)) => { - log::warn!("MCP server {} health check failed: {}", name, e); + log::warn!("MCP server {name} health check failed: {e}"); false } Err(_) => { - log::warn!("MCP server {} health check timed out", name); + log::warn!("MCP server {name} health check timed out"); false } } } else { // Server was removed from HashMap (e.g., by deactivate_mcp_server) - log::info!("MCP server {} no longer in running services", name); + log::info!("MCP server {name} no longer in running services"); return Some(rmcp::service::QuitReason::Closed); } }; @@ -220,8 +207,7 @@ pub async fn monitor_mcp_server_handle( if !health_check_result { // Server failed health check - remove it and return log::error!( - "MCP server {} failed health check, removing from active servers", - name + "MCP server {name} failed health check, removing from active servers" ); let mut servers = servers_state.lock().await; if let Some(service) = servers.remove(&name) { @@ -262,7 +248,7 @@ pub async fn start_mcp_server_with_restart( let max_restarts = max_restarts.unwrap_or(5); // Try the first start attempt and return its result - log::info!("Starting MCP server {} (Initial attempt)", name); + log::info!("Starting MCP server {name} (Initial attempt)"); let first_start_result = schedule_mcp_start_task( app.clone(), servers_state.clone(), @@ -273,7 +259,7 @@ pub async fn start_mcp_server_with_restart( match first_start_result { Ok(_) => { - log::info!("MCP server {} started successfully on first attempt", name); + log::info!("MCP server {name} started successfully on first attempt"); reset_restart_count(&restart_counts, &name).await; // Check if server was marked as successfully connected (passed verification) @@ -298,18 +284,15 @@ pub async fn start_mcp_server_with_restart( Ok(()) } else { // Server failed verification, don't monitor for restarts - log::error!("MCP server {} failed verification after startup", name); + log::error!("MCP server {name} failed verification after startup"); Err(format!( - "MCP server {} failed verification after startup", - name + "MCP server {name} failed verification after startup" )) } } Err(e) => { log::error!( - "Failed to start MCP server {} on first attempt: {}", - name, - e + "Failed to start MCP server {name} on first attempt: {e}" ); Err(e) } @@ -336,9 +319,7 @@ pub async fn start_restart_loop( if current_restart_count > max_restarts { log::error!( - "MCP server {} reached maximum restart attempts ({}). Giving up.", - name, - max_restarts + "MCP server {name} reached maximum restart attempts ({max_restarts}). Giving up." ); if let Err(e) = app.emit( "mcp_max_restarts_reached", @@ -353,19 +334,13 @@ pub async fn start_restart_loop( } log::info!( - "Restarting MCP server {} (Attempt {}/{})", - name, - current_restart_count, - max_restarts + "Restarting MCP server {name} (Attempt {current_restart_count}/{max_restarts})" ); // Calculate exponential backoff delay let delay_ms = calculate_exponential_backoff_delay(current_restart_count); log::info!( - "Waiting {}ms before restart attempt {} for MCP server {}", - delay_ms, - current_restart_count, - name + "Waiting {delay_ms}ms before restart attempt {current_restart_count} for MCP server {name}" ); sleep(Duration::from_millis(delay_ms)).await; @@ -380,7 +355,7 @@ pub async fn start_restart_loop( match start_result { Ok(_) => { - log::info!("MCP server {} restarted successfully.", name); + log::info!("MCP server {name} restarted successfully."); // Check if server passed verification (was marked as successfully connected) let passed_verification = { @@ -390,8 +365,7 @@ pub async fn start_restart_loop( if !passed_verification { log::error!( - "MCP server {} failed verification after restart - stopping permanently", - name + "MCP server {name} failed verification after restart - stopping permanently" ); break; } @@ -402,9 +376,7 @@ pub async fn start_restart_loop( if let Some(count) = counts.get_mut(&name) { if *count > 0 { log::info!( - "MCP server {} restarted successfully, resetting restart count from {} to 0.", - name, - *count + "MCP server {name} restarted successfully, resetting restart count from {count} to 0." ); *count = 0; } @@ -415,7 +387,7 @@ pub async fn start_restart_loop( let quit_reason = monitor_mcp_server_handle(servers_state.clone(), name.clone()).await; - log::info!("MCP server {} quit with reason: {:?}", name, quit_reason); + log::info!("MCP server {name} quit with reason: {quit_reason:?}"); // Check if server was marked as successfully connected let was_connected = { @@ -426,8 +398,7 @@ pub async fn start_restart_loop( // Only continue restart loop if server was previously connected if !was_connected { log::error!( - "MCP server {} failed before establishing successful connection - stopping permanently", - name + "MCP server {name} failed before establishing successful connection - stopping permanently" ); break; } @@ -435,11 +406,11 @@ pub async fn start_restart_loop( // Determine if we should restart based on quit reason let should_restart = match quit_reason { Some(reason) => { - log::warn!("MCP server {} terminated unexpectedly: {:?}", name, reason); + log::warn!("MCP server {name} terminated unexpectedly: {reason:?}"); true } None => { - log::info!("MCP server {} was manually stopped - not restarting", name); + log::info!("MCP server {name} was manually stopped - not restarting"); false } }; @@ -450,7 +421,7 @@ pub async fn start_restart_loop( // Continue the loop for another restart attempt } Err(e) => { - log::error!("Failed to restart MCP server {}: {}", name, e); + log::error!("Failed to restart MCP server {name}: {e}"); // Check if server was marked as successfully connected before let was_connected = { @@ -461,8 +432,7 @@ pub async fn start_restart_loop( // Only continue restart attempts if server was previously connected if !was_connected { log::error!( - "MCP server {} failed restart and was never successfully connected - stopping permanently", - name + "MCP server {name} failed restart and was never successfully connected - stopping permanently" ); break; } @@ -529,7 +499,7 @@ async fn schedule_mcp_start_task( }, }; let client = client_info.serve(transport).await.inspect_err(|e| { - log::error!("client error: {:?}", e); + log::error!("client error: {e:?}"); }); match client { @@ -545,12 +515,12 @@ async fn schedule_mcp_start_task( let app_state = app.state::(); let mut connected = app_state.mcp_successfully_connected.lock().await; connected.insert(name.clone(), true); - log::info!("Marked MCP server {} as successfully connected", name); + log::info!("Marked MCP server {name} as successfully connected"); } } Err(e) => { - log::error!("Failed to connect to server: {}", e); - return Err(format!("Failed to connect to server: {}", e)); + log::error!("Failed to connect to server: {e}"); + return Err(format!("Failed to connect to server: {e}")); } } } else if config_params.transport_type.as_deref() == Some("sse") && config_params.url.is_some() @@ -587,8 +557,8 @@ async fn schedule_mcp_start_task( ) .await .map_err(|e| { - log::error!("transport error: {:?}", e); - format!("Failed to start SSE transport: {}", e) + log::error!("transport error: {e:?}"); + format!("Failed to start SSE transport: {e}") })?; let client_info = ClientInfo { @@ -600,7 +570,7 @@ async fn schedule_mcp_start_task( }, }; let client = client_info.serve(transport).await.map_err(|e| { - log::error!("client error: {:?}", e); + log::error!("client error: {e:?}"); e.to_string() }); @@ -617,12 +587,12 @@ async fn schedule_mcp_start_task( let app_state = app.state::(); let mut connected = app_state.mcp_successfully_connected.lock().await; connected.insert(name.clone(), true); - log::info!("Marked MCP server {} as successfully connected", name); + log::info!("Marked MCP server {name} as successfully connected"); } } Err(e) => { - log::error!("Failed to connect to server: {}", e); - return Err(format!("Failed to connect to server: {}", e)); + log::error!("Failed to connect to server: {e}"); + return Err(format!("Failed to connect to server: {e}")); } } } else { @@ -639,7 +609,7 @@ async fn schedule_mcp_start_task( cache_dir.push(".npx"); cmd = Command::new(bun_x_path.display().to_string()); cmd.arg("x"); - cmd.env("BUN_INSTALL", cache_dir.to_str().unwrap().to_string()); + cmd.env("BUN_INSTALL", cache_dir.to_str().unwrap()); } let uv_path = if cfg!(windows) { @@ -654,7 +624,7 @@ async fn schedule_mcp_start_task( cmd = Command::new(uv_path); cmd.arg("tool"); cmd.arg("run"); - cmd.env("UV_CACHE_DIR", cache_dir.to_str().unwrap().to_string()); + cmd.env("UV_CACHE_DIR", cache_dir.to_str().unwrap()); } #[cfg(windows)] { @@ -726,8 +696,7 @@ async fn schedule_mcp_start_task( if !server_still_running { return Err(format!( - "MCP server {} quit immediately after starting", - name + "MCP server {name} quit immediately after starting" )); } // Mark server as successfully connected (for restart policy) @@ -735,7 +704,7 @@ async fn schedule_mcp_start_task( let app_state = app.state::(); let mut connected = app_state.mcp_successfully_connected.lock().await; connected.insert(name.clone(), true); - log::info!("Marked MCP server {} as successfully connected", name); + log::info!("Marked MCP server {name} as successfully connected"); } } Ok(()) @@ -792,7 +761,7 @@ pub async fn restart_active_mcp_servers( ); for (name, config) in active_servers.iter() { - log::info!("Restarting MCP server: {}", name); + log::info!("Restarting MCP server: {name}"); // Start server with restart monitoring - spawn async task let app_clone = app.clone(); @@ -891,9 +860,7 @@ pub async fn spawn_server_monitoring_task( monitor_mcp_server_handle(servers_clone.clone(), name_clone.clone()).await; log::info!( - "MCP server {} quit with reason: {:?}", - name_clone, - quit_reason + "MCP server {name_clone} quit with reason: {quit_reason:?}" ); // Check if we should restart based on connection status and quit reason @@ -928,8 +895,7 @@ pub async fn should_restart_server( // Only restart if server was previously connected if !was_connected { log::error!( - "MCP server {} failed before establishing successful connection - stopping permanently", - name + "MCP server {name} failed before establishing successful connection - stopping permanently" ); return false; } @@ -937,11 +903,11 @@ pub async fn should_restart_server( // Determine if we should restart based on quit reason match quit_reason { Some(reason) => { - log::warn!("MCP server {} terminated unexpectedly: {:?}", name, reason); + log::warn!("MCP server {name} terminated unexpectedly: {reason:?}"); true } None => { - log::info!("MCP server {} was manually stopped - not restarting", name); + log::info!("MCP server {name} was manually stopped - not restarting"); false } } diff --git a/src-tauri/src/core/mcp/tests.rs b/src-tauri/src/core/mcp/tests.rs index d973ce647..71967cd96 100644 --- a/src-tauri/src/core/mcp/tests.rs +++ b/src-tauri/src/core/mcp/tests.rs @@ -70,7 +70,7 @@ fn test_add_server_config_new_file() { Some("mcp_config_test_new.json"), ); - assert!(result.is_ok(), "Failed to add server config: {:?}", result); + assert!(result.is_ok(), "Failed to add server config: {result:?}"); // Verify the config was added correctly let config_content = std::fs::read_to_string(&config_path) @@ -128,7 +128,7 @@ fn test_add_server_config_existing_servers() { Some("mcp_config_test_existing.json"), ); - assert!(result.is_ok(), "Failed to add server config: {:?}", result); + assert!(result.is_ok(), "Failed to add server config: {result:?}"); // Verify both servers exist let config_content = std::fs::read_to_string(&config_path) diff --git a/src-tauri/src/core/server/proxy.rs b/src-tauri/src/core/server/proxy.rs index 12398ac02..b832b03a2 100644 --- a/src-tauri/src/core/server/proxy.rs +++ b/src-tauri/src/core/server/proxy.rs @@ -67,7 +67,7 @@ async fn proxy_request( .any(|&method| method.eq_ignore_ascii_case(requested_method)); if !method_allowed { - log::warn!("CORS preflight: Method '{}' not allowed", requested_method); + log::warn!("CORS preflight: Method '{requested_method}' not allowed"); return Ok(Response::builder() .status(StatusCode::METHOD_NOT_ALLOWED) .body(Body::from("Method not allowed")) @@ -80,14 +80,12 @@ async fn proxy_request( let is_trusted = if is_whitelisted_path { log::debug!( - "CORS preflight: Bypassing host check for whitelisted path: {}", - request_path + "CORS preflight: Bypassing host check for whitelisted path: {request_path}" ); true } else if !host.is_empty() { log::debug!( - "CORS preflight: Host is '{}', trusted hosts: {:?}", - host, + "CORS preflight: Host is '{host}', trusted hosts: {:?}", &config.trusted_hosts ); is_valid_host(host, &config.trusted_hosts) @@ -98,9 +96,7 @@ async fn proxy_request( if !is_trusted { log::warn!( - "CORS preflight: Host '{}' not trusted for path '{}'", - host, - request_path + "CORS preflight: Host '{host}' not trusted for path '{request_path}'" ); return Ok(Response::builder() .status(StatusCode::FORBIDDEN) @@ -158,8 +154,7 @@ async fn proxy_request( if !headers_valid { log::warn!( - "CORS preflight: Some requested headers not allowed: {}", - requested_headers + "CORS preflight: Some requested headers not allowed: {requested_headers}" ); return Ok(Response::builder() .status(StatusCode::FORBIDDEN) @@ -186,9 +181,7 @@ async fn proxy_request( } log::debug!( - "CORS preflight response: host_trusted={}, origin='{}'", - is_trusted, - origin + "CORS preflight response: host_trusted={is_trusted}, origin='{origin}'" ); return Ok(response.body(Body::empty()).unwrap()); } @@ -252,7 +245,7 @@ async fn proxy_request( .unwrap()); } } else { - log::debug!("Bypassing host validation for whitelisted path: {}", path); + log::debug!("Bypassing host validation for whitelisted path: {path}"); } if !is_whitelisted_path && !config.proxy_api_key.is_empty() { @@ -285,8 +278,7 @@ async fn proxy_request( } } else if is_whitelisted_path { log::debug!( - "Bypassing authorization check for whitelisted path: {}", - path + "Bypassing authorization check for whitelisted path: {path}" ); } @@ -312,8 +304,7 @@ async fn proxy_request( | (hyper::Method::POST, "/completions") | (hyper::Method::POST, "/embeddings") => { log::debug!( - "Handling POST request to {} requiring model lookup in body", - destination_path + "Handling POST request to {destination_path} requiring model lookup in body", ); let body_bytes = match hyper::body::to_bytes(body).await { Ok(bytes) => bytes, @@ -336,13 +327,12 @@ async fn proxy_request( match serde_json::from_slice::(&body_bytes) { Ok(json_body) => { if let Some(model_id) = json_body.get("model").and_then(|v| v.as_str()) { - log::debug!("Extracted model_id: {}", model_id); + log::debug!("Extracted model_id: {model_id}"); let sessions_guard = sessions.lock().await; if sessions_guard.is_empty() { log::warn!( - "Request for model '{}' but no models are running.", - model_id + "Request for model '{model_id}' but no models are running." ); let mut error_response = Response::builder().status(StatusCode::SERVICE_UNAVAILABLE); @@ -363,9 +353,9 @@ async fn proxy_request( { target_port = Some(session.info.port); session_api_key = Some(session.info.api_key.clone()); - log::debug!("Found session for model_id {}", model_id,); + log::debug!("Found session for model_id {model_id}"); } else { - log::warn!("No running session found for model_id: {}", model_id); + log::warn!("No running session found for model_id: {model_id}"); let mut error_response = Response::builder().status(StatusCode::NOT_FOUND); error_response = add_cors_headers_with_host_and_origin( @@ -376,15 +366,13 @@ async fn proxy_request( ); return Ok(error_response .body(Body::from(format!( - "No running session found for model '{}'", - model_id + "No running session found for model '{model_id}'" ))) .unwrap()); } } else { log::warn!( - "POST body for {} is missing 'model' field or it's not a string", - destination_path + "POST body for {destination_path} is missing 'model' field or it's not a string" ); let mut error_response = Response::builder().status(StatusCode::BAD_REQUEST); @@ -401,9 +389,7 @@ async fn proxy_request( } Err(e) => { log::warn!( - "Failed to parse POST body for {} as JSON: {}", - destination_path, - e + "Failed to parse POST body for {destination_path} as JSON: {e}" ); let mut error_response = Response::builder().status(StatusCode::BAD_REQUEST); error_response = add_cors_headers_with_host_and_origin( @@ -535,7 +521,7 @@ async fn proxy_request( let is_explicitly_whitelisted_get = method == hyper::Method::GET && whitelisted_paths.contains(&destination_path.as_str()); if is_explicitly_whitelisted_get { - log::debug!("Handled whitelisted GET path: {}", destination_path); + log::debug!("Handled whitelisted GET path: {destination_path}"); let mut error_response = Response::builder().status(StatusCode::NOT_FOUND); error_response = add_cors_headers_with_host_and_origin( error_response, @@ -546,9 +532,7 @@ async fn proxy_request( return Ok(error_response.body(Body::from("Not Found")).unwrap()); } else { log::warn!( - "Unhandled method/path for dynamic routing: {} {}", - method, - destination_path + "Unhandled method/path for dynamic routing: {method} {destination_path}" ); let mut error_response = Response::builder().status(StatusCode::NOT_FOUND); error_response = add_cors_headers_with_host_and_origin( @@ -581,7 +565,7 @@ async fn proxy_request( } }; - let upstream_url = format!("http://127.0.0.1:{}{}", port, destination_path); + let upstream_url = format!("http://127.0.0.1:{port}{destination_path}"); let mut outbound_req = client.request(method.clone(), &upstream_url); @@ -593,13 +577,14 @@ async fn proxy_request( if let Some(key) = session_api_key { log::debug!("Adding session Authorization header"); - outbound_req = outbound_req.header("Authorization", format!("Bearer {}", key)); + outbound_req = outbound_req.header("Authorization", format!("Bearer {key}")); } else { log::debug!("No session API key available for this request"); } let outbound_req_with_body = if let Some(bytes) = buffered_body { - log::debug!("Sending buffered body ({} bytes)", bytes.len()); + let bytes_len = bytes.len(); + log::debug!("Sending buffered body ({bytes_len} bytes)"); outbound_req.body(bytes) } else { log::error!("Internal logic error: Request reached proxy stage without a buffered body."); @@ -618,7 +603,7 @@ async fn proxy_request( match outbound_req_with_body.send().await { Ok(response) => { let status = response.status(); - log::debug!("Received response with status: {}", status); + log::debug!("Received response with status: {status}"); let mut builder = Response::builder().status(status); @@ -648,7 +633,7 @@ async fn proxy_request( } } Err(e) => { - log::error!("Stream error: {}", e); + log::error!("Stream error: {e}"); break; } } @@ -659,8 +644,8 @@ async fn proxy_request( Ok(builder.body(body).unwrap()) } Err(e) => { - let error_msg = format!("Proxy request to model failed: {}", e); - log::error!("{}", error_msg); + let error_msg = format!("Proxy request to model failed: {e}"); + log::error!("{error_msg}"); let mut error_response = Response::builder().status(StatusCode::BAD_GATEWAY); error_response = add_cors_headers_with_host_and_origin( error_response, @@ -675,14 +660,12 @@ async fn proxy_request( fn add_cors_headers_with_host_and_origin( builder: hyper::http::response::Builder, - host: &str, + _host: &str, origin: &str, - trusted_hosts: &[Vec], + _trusted_hosts: &[Vec], ) -> hyper::http::response::Builder { let mut builder = builder; - let allow_origin_header = if !origin.is_empty() && is_valid_host(host, trusted_hosts) { - origin.to_string() - } else if !origin.is_empty() { + let allow_origin_header = if !origin.is_empty() { origin.to_string() } else { "*".to_string() @@ -706,6 +689,7 @@ pub async fn is_server_running(server_handle: Arc>>) handle_guard.is_some() } +#[allow(clippy::too_many_arguments)] pub async fn start_server( server_handle: Arc>>, sessions: Arc>>, @@ -721,9 +705,9 @@ pub async fn start_server( return Err("Server is already running".into()); } - let addr: SocketAddr = format!("{}:{}", host, port) + let addr: SocketAddr = format!("{host}:{port}") .parse() - .map_err(|e| format!("Invalid address: {}", e))?; + .map_err(|e| format!("Invalid address: {e}"))?; let config = ProxyConfig { prefix, @@ -752,15 +736,15 @@ pub async fn start_server( let server = match Server::try_bind(&addr) { Ok(builder) => builder.serve(make_svc), Err(e) => { - log::error!("Failed to bind to {}: {}", addr, e); + log::error!("Failed to bind to {addr}: {e}"); return Err(Box::new(e)); } }; - log::info!("Jan API server started on http://{}", addr); + log::info!("Jan API server started on http://{addr}"); let server_task = tokio::spawn(async move { if let Err(e) = server.await { - log::error!("Server error: {}", e); + log::error!("Server error: {e}"); return Err(Box::new(e) as Box); } Ok(()) @@ -768,7 +752,7 @@ pub async fn start_server( *handle_guard = Some(server_task); let actual_port = addr.port(); - log::info!("Jan API server started successfully on port {}", actual_port); + log::info!("Jan API server started successfully on port {actual_port}"); Ok(actual_port) } diff --git a/src-tauri/src/core/setup.rs b/src-tauri/src/core/setup.rs index 38eca440e..2506434f3 100644 --- a/src-tauri/src/core/setup.rs +++ b/src-tauri/src/core/setup.rs @@ -38,7 +38,7 @@ pub fn install_extensions(app: tauri::AppHandle, force: bool) -> if std::env::var("IS_CLEAN").is_ok() { clean_up = true; } - log::info!("Installing extensions. Clean up: {}", clean_up); + log::info!("Installing extensions. Clean up: {clean_up}"); if !clean_up && extensions_path.exists() { return Ok(()); } @@ -68,7 +68,7 @@ pub fn install_extensions(app: tauri::AppHandle, force: bool) -> let entry = entry.map_err(|e| e.to_string())?; let path = entry.path(); - if path.extension().map_or(false, |ext| ext == "tgz") { + if path.extension().is_some_and(|ext| ext == "tgz") { let tar_gz = File::open(&path).map_err(|e| e.to_string())?; let gz_decoder = GzDecoder::new(tar_gz); let mut archive = Archive::new(gz_decoder); @@ -134,7 +134,7 @@ pub fn install_extensions(app: tauri::AppHandle, force: bool) -> extensions_list.push(new_extension); - log::info!("Installed extension to {:?}", extension_dir); + log::info!("Installed extension to {extension_dir:?}"); } } fs::write( @@ -154,7 +154,7 @@ pub fn migrate_mcp_servers( let mcp_version = store .get("mcp_version") .and_then(|v| v.as_i64()) - .unwrap_or_else(|| 0); + .unwrap_or(0); if mcp_version < 1 { log::info!("Migrating MCP schema version 1"); let result = add_server_config( @@ -168,7 +168,7 @@ pub fn migrate_mcp_servers( }), ); if let Err(e) = result { - log::error!("Failed to add server config: {}", e); + log::error!("Failed to add server config: {e}"); } } store.set("mcp_version", 1); @@ -212,7 +212,7 @@ pub fn setup_mcp(app: &App) { let app_handle = app.handle().clone(); tauri::async_runtime::spawn(async move { if let Err(e) = run_mcp_commands(&app_handle, servers).await { - log::error!("Failed to run mcp commands: {}", e); + log::error!("Failed to run mcp commands: {e}"); } app_handle .emit("mcp-update", "MCP servers updated") @@ -258,7 +258,7 @@ pub fn setup_tray(app: &App) -> tauri::Result { app.exit(0); } other => { - println!("menu item {} not handled", other); + println!("menu item {other} not handled"); } }) .build(app) diff --git a/src-tauri/src/core/system/commands.rs b/src-tauri/src/core/system/commands.rs index f5e9d7618..938e6f8bf 100644 --- a/src-tauri/src/core/system/commands.rs +++ b/src-tauri/src/core/system/commands.rs @@ -18,12 +18,12 @@ pub fn factory_reset(app_handle: tauri::AppHandle, state: State<' let windows = app_handle.webview_windows(); for (label, window) in windows.iter() { window.close().unwrap_or_else(|_| { - log::warn!("Failed to close window: {:?}", label); + log::warn!("Failed to close window: {label:?}"); }); } } let data_folder = get_jan_data_folder_path(app_handle.clone()); - log::info!("Factory reset, removing data folder: {:?}", data_folder); + log::info!("Factory reset, removing data folder: {data_folder:?}"); tauri::async_runtime::block_on(async { clean_up_mcp_servers(state.clone()).await; @@ -31,7 +31,7 @@ pub fn factory_reset(app_handle: tauri::AppHandle, state: State<' if data_folder.exists() { if let Err(e) = fs::remove_dir_all(&data_folder) { - log::error!("Failed to remove data folder: {}", e); + log::error!("Failed to remove data folder: {e}"); return; } } @@ -59,17 +59,17 @@ pub fn open_app_directory(app: AppHandle) { if cfg!(target_os = "windows") { std::process::Command::new("explorer") .arg(app_path) - .spawn() + .status() .expect("Failed to open app directory"); } else if cfg!(target_os = "macos") { std::process::Command::new("open") .arg(app_path) - .spawn() + .status() .expect("Failed to open app directory"); } else { std::process::Command::new("xdg-open") .arg(app_path) - .spawn() + .status() .expect("Failed to open app directory"); } } @@ -80,17 +80,17 @@ pub fn open_file_explorer(path: String) { if cfg!(target_os = "windows") { std::process::Command::new("explorer") .arg(path) - .spawn() + .status() .expect("Failed to open file explorer"); } else if cfg!(target_os = "macos") { std::process::Command::new("open") .arg(path) - .spawn() + .status() .expect("Failed to open file explorer"); } else { std::process::Command::new("xdg-open") .arg(path) - .spawn() + .status() .expect("Failed to open file explorer"); } } @@ -102,7 +102,7 @@ pub async fn read_logs(app: AppHandle) -> Result let content = fs::read_to_string(log_path).map_err(|e| e.to_string())?; Ok(content) } else { - Err(format!("Log file not found")) + Err("Log file not found".to_string()) } } @@ -112,7 +112,7 @@ pub fn is_library_available(library: &str) -> bool { match unsafe { libloading::Library::new(library) } { Ok(_) => true, Err(e) => { - log::info!("Library {} is not available: {}", library, e); + log::info!("Library {library} is not available: {e}"); false } } diff --git a/src-tauri/src/core/threads/commands.rs b/src-tauri/src/core/threads/commands.rs index 44ac1964d..892a6d1ca 100644 --- a/src-tauri/src/core/threads/commands.rs +++ b/src-tauri/src/core/threads/commands.rs @@ -38,7 +38,7 @@ pub async fn list_threads( match serde_json::from_str(&data) { Ok(thread) => threads.push(thread), Err(e) => { - println!("Failed to parse thread file: {}", e); + println!("Failed to parse thread file: {e}"); continue; // skip invalid thread files } } @@ -149,7 +149,7 @@ pub async fn create_message( .map_err(|e| e.to_string())?; let data = serde_json::to_string(&message).map_err(|e| e.to_string())?; - writeln!(file, "{}", data).map_err(|e| e.to_string())?; + writeln!(file, "{data}").map_err(|e| e.to_string())?; // Explicitly flush to ensure data is written before returning file.flush().map_err(|e| e.to_string())?; @@ -234,7 +234,7 @@ pub async fn get_thread_assistant( let data = fs::read_to_string(&path).map_err(|e| e.to_string())?; let thread: serde_json::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()) { - if let Some(first) = assistants.get(0) { + if let Some(first) = assistants.first() { Ok(first.clone()) } else { Err("Assistant not found".to_string()) diff --git a/src-tauri/src/core/threads/helpers.rs b/src-tauri/src/core/threads/helpers.rs index 76d2c2e59..ebe6abab4 100644 --- a/src-tauri/src/core/threads/helpers.rs +++ b/src-tauri/src/core/threads/helpers.rs @@ -33,7 +33,7 @@ pub fn write_messages_to_file( let mut file = File::create(path).map_err(|e| e.to_string())?; for msg in messages { let data = serde_json::to_string(msg).map_err(|e| e.to_string())?; - writeln!(file, "{}", data).map_err(|e| e.to_string())?; + writeln!(file, "{data}").map_err(|e| e.to_string())?; } Ok(()) } diff --git a/src-tauri/src/core/threads/tests.rs b/src-tauri/src/core/threads/tests.rs index 8d3524d06..4540f5a8b 100644 --- a/src-tauri/src/core/threads/tests.rs +++ b/src-tauri/src/core/threads/tests.rs @@ -16,7 +16,7 @@ fn mock_app_with_temp_data_dir() -> (tauri::App, PathBuf) { .as_nanos(); let data_dir = std::env::current_dir() .unwrap_or_else(|_| PathBuf::from(".")) - .join(format!("test-data-{:?}-{}", unique_id, timestamp)); + .join(format!("test-data-{unique_id:?}-{timestamp}")); println!("Mock app data dir: {}", data_dir.display()); // Ensure the unique test directory exists let _ = fs::create_dir_all(&data_dir); @@ -42,7 +42,7 @@ async fn test_create_and_list_threads() { // List threads let threads = list_threads(app.handle().clone()).await.unwrap(); - assert!(threads.len() > 0); + assert!(!threads.is_empty()); // Clean up let _ = fs::remove_dir_all(data_dir); @@ -88,7 +88,7 @@ async fn test_create_and_list_messages() { let messages = list_messages(app.handle().clone(), thread_id.clone()) .await .unwrap(); - assert!(messages.len() > 0, "Expected at least one message, but got none. Thread ID: {}", thread_id); + assert!(!messages.is_empty(), "Expected at least one message, but got none. Thread ID: {thread_id}"); assert_eq!(messages[0]["role"], "user"); // Clean up diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index abd12ddb7..a232e11f5 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -151,17 +151,17 @@ pub fn run() { .config() .version .clone() - .unwrap_or_else(|| "".to_string()); + .unwrap_or_default(); // Migrate extensions if let Err(e) = setup::install_extensions(app.handle().clone(), stored_version != app_version) { - log::error!("Failed to install extensions: {}", e); + log::error!("Failed to install extensions: {e}"); } // Migrate MCP servers if let Err(e) = setup::migrate_mcp_servers(app.handle().clone(), store.clone()) { - log::error!("Failed to migrate MCP servers: {}", e); + log::error!("Failed to migrate MCP servers: {e}"); } // Store the new app version @@ -187,8 +187,8 @@ pub fn run() { .expect("error while running tauri application"); // Handle app lifecycle events - app.run(|app, event| match event { - RunEvent::Exit => { + app.run(|app, event| { + if let RunEvent::Exit = event { // This is called when the app is actually exiting (e.g., macOS dock quit) // We can't prevent this, so run cleanup quickly let app_handle = app.clone(); @@ -208,6 +208,5 @@ pub fn run() { }); }); } - _ => {} }); } From f6f9813ef2fb7400b12c880ee526d6ff3cd54071 Mon Sep 17 00:00:00 2001 From: Minh141120 Date: Thu, 2 Oct 2025 15:26:37 +0700 Subject: [PATCH 14/97] 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 4fbc7873ca0e7e3ea12c4604ac3eda239a9e03e4 Mon Sep 17 00:00:00 2001 From: eckartal Date: Thu, 2 Oct 2025 16:48:07 +0800 Subject: [PATCH 15/97] docs: add Jan v0.7.0 changelog --- .../images/changelog/jan-release-v0.7.0.jpeg | Bin 0 -> 268036 bytes .../changelog/2025-10-02-jan-projects-.mdx | 27 ++++++++++++++++++ 2 files changed, 27 insertions(+) create mode 100644 docs/public/assets/images/changelog/jan-release-v0.7.0.jpeg create mode 100644 docs/src/pages/changelog/2025-10-02-jan-projects-.mdx diff --git a/docs/public/assets/images/changelog/jan-release-v0.7.0.jpeg b/docs/public/assets/images/changelog/jan-release-v0.7.0.jpeg new file mode 100644 index 0000000000000000000000000000000000000000..cb0d4a3a94183fbada4168b2488b240063529ff0 GIT binary patch literal 268036 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ⓈI+qm-OK!{bqCgdUv=iVoH%ReSP93d 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 DNwLHZ literal 0 HcmV?d00001 diff --git a/docs/src/pages/changelog/2025-10-02-jan-projects-.mdx b/docs/src/pages/changelog/2025-10-02-jan-projects-.mdx new file mode 100644 index 000000000..dbac3fd5f --- /dev/null +++ b/docs/src/pages/changelog/2025-10-02-jan-projects-.mdx @@ -0,0 +1,27 @@ +--- +title: "Jan v0.7.0" +version: v0.7.0 +description: "Jan v0.7.0 introduces Projects, model renaming, llama.cpp auto-tuning, model stats, and Azure support." +date: 2025-10-02 +ogImage: "/assets/images/changelog/jan-release-v0.7.0.jpeg" +--- + +import ChangelogHeader from "@/components/Changelog/ChangelogHeader" +import { Callout } from 'nextra/components' + + + +## Jan v0.7.0: Jan Projects + +Jan v0.7.0 is live! This release focuses on helping you organize your workspace and making models run more efficiently. + +### What’s new +- **Projects**: Group related chats under one project for a cleaner workflow. +- **Rename models**: Give your models custom names for easier identification. +- **Model context stats**: See context usage when a model runs. +- **Automatic llama.cpp tuning**: Jan now adjusts settings such as context size and GPU layers based on your hardware, so local models run more efficiently out of the box. +- **Auto-loaded cloud models**: Cloud model names now appear automatically, with support for Azure as a provider. + +Update your Jan or [download the latest version](https://jan.ai/). + +For the complete list of changes, see the [GitHub release notes](https://github.com/janhq/jan/releases/tag/v0.7.0). From e6f366d373fa62d72f4219bd6c45a772da28501f Mon Sep 17 00:00:00 2001 From: eckartal Date: Thu, 2 Oct 2025 16:55:08 +0800 Subject: [PATCH 16/97] docs: update Jan v0.7.0 changelog content --- docs/src/pages/changelog/2025-10-02-jan-projects-.mdx | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/docs/src/pages/changelog/2025-10-02-jan-projects-.mdx b/docs/src/pages/changelog/2025-10-02-jan-projects-.mdx index dbac3fd5f..63f29194d 100644 --- a/docs/src/pages/changelog/2025-10-02-jan-projects-.mdx +++ b/docs/src/pages/changelog/2025-10-02-jan-projects-.mdx @@ -1,5 +1,5 @@ --- -title: "Jan v0.7.0" +title: "Jan v0.7.0: Jan Projects" version: v0.7.0 description: "Jan v0.7.0 introduces Projects, model renaming, llama.cpp auto-tuning, model stats, and Azure support." date: 2025-10-02 @@ -13,14 +13,15 @@ import { Callout } from 'nextra/components' ## Jan v0.7.0: Jan Projects -Jan v0.7.0 is live! This release focuses on helping you organize your workspace and making models run more efficiently. +Jan v0.7.0 is live! This release focuses on helping you organize your workspace and better understand how models run. ### What’s new - **Projects**: Group related chats under one project for a cleaner workflow. - **Rename models**: Give your models custom names for easier identification. - **Model context stats**: See context usage when a model runs. -- **Automatic llama.cpp tuning**: Jan now adjusts settings such as context size and GPU layers based on your hardware, so local models run more efficiently out of the box. -- **Auto-loaded cloud models**: Cloud model names now appear automatically, with support for Azure as a provider. +- **Auto-loaded cloud models**: Cloud model names now appear automatically. + +--- Update your Jan or [download the latest version](https://jan.ai/). From d3b4144c5bc0c0fecd9899e5170c630e7a30baa3 Mon Sep 17 00:00:00 2001 From: eckartal Date: Thu, 2 Oct 2025 17:38:09 +0800 Subject: [PATCH 17/97] docs: rename changelog file to remove trailing dash --- .../{2025-10-02-jan-projects-.mdx => 2025-10-02-jan-projects.mdx} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename docs/src/pages/changelog/{2025-10-02-jan-projects-.mdx => 2025-10-02-jan-projects.mdx} (100%) diff --git a/docs/src/pages/changelog/2025-10-02-jan-projects-.mdx b/docs/src/pages/changelog/2025-10-02-jan-projects.mdx similarity index 100% rename from docs/src/pages/changelog/2025-10-02-jan-projects-.mdx rename to docs/src/pages/changelog/2025-10-02-jan-projects.mdx From 9720ad368e459d48f1857631a5ccf9a47b0b4c8c Mon Sep 17 00:00:00 2001 From: Vanalite Date: Mon, 29 Sep 2025 11:02:55 +0700 Subject: [PATCH 18/97] 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 19/97] 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 20/97] 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 24ff36d424074673a7a2941c5959a254ed666a75 Mon Sep 17 00:00:00 2001 From: Vanalite Date: Thu, 2 Oct 2025 21:48:07 +0700 Subject: [PATCH 21/97] fix: Extract model capabilities correctly for various providers on various platforms --- web-app/src/containers/dialogs/AddModel.tsx | 21 +----- web-app/src/lib/__tests__/models.test.ts | 82 +++++++++++++++++++++ web-app/src/lib/models.ts | 33 +++++++++ web-app/src/services/providers/tauri.ts | 23 +----- web-app/src/services/providers/web.ts | 13 +--- 5 files changed, 123 insertions(+), 49 deletions(-) diff --git a/web-app/src/containers/dialogs/AddModel.tsx b/web-app/src/containers/dialogs/AddModel.tsx index e8fd4e0fd..c44d3a0a5 100644 --- a/web-app/src/containers/dialogs/AddModel.tsx +++ b/web-app/src/containers/dialogs/AddModel.tsx @@ -15,8 +15,7 @@ import { IconPlus } from '@tabler/icons-react' import { useState } from 'react' import { getProviderTitle } from '@/lib/utils' import { useTranslation } from '@/i18n/react-i18next-compat' -import { ModelCapabilities } from '@/types/models' -import { models as providerModels } from 'token.js' +import { getModelCapabilities } from '@/lib/models' import { toast } from 'sonner' type DialogAddModelProps = { @@ -52,23 +51,7 @@ export const DialogAddModel = ({ provider, trigger }: DialogAddModelProps) => { id: modelId, model: modelId, name: modelId, - capabilities: [ - ModelCapabilities.COMPLETION, - ( - providerModels[ - provider.provider as unknown as keyof typeof providerModels - ]?.supportsToolCalls as unknown as string[] - )?.includes(modelId) - ? ModelCapabilities.TOOLS - : undefined, - ( - providerModels[ - provider.provider as unknown as keyof typeof providerModels - ]?.supportsImages as unknown as string[] - )?.includes(modelId) - ? ModelCapabilities.VISION - : undefined, - ].filter(Boolean) as string[], + capabilities: getModelCapabilities(provider.provider, modelId), version: '1.0', } diff --git a/web-app/src/lib/__tests__/models.test.ts b/web-app/src/lib/__tests__/models.test.ts index 67f37f873..bba4a64a1 100644 --- a/web-app/src/lib/__tests__/models.test.ts +++ b/web-app/src/lib/__tests__/models.test.ts @@ -5,19 +5,30 @@ import { removeYamlFrontMatter, extractModelName, extractModelRepo, + getModelCapabilities, } from '../models' +import { ModelCapabilities } from '@/types/models' // Mock the token.js module vi.mock('token.js', () => ({ models: { openai: { models: ['gpt-3.5-turbo', 'gpt-4'], + supportsToolCalls: ['gpt-3.5-turbo', 'gpt-4'], + supportsImages: ['gpt-4-vision-preview'], }, anthropic: { models: ['claude-3-sonnet', 'claude-3-haiku'], + supportsToolCalls: ['claude-3-sonnet'], + supportsImages: ['claude-3-sonnet', 'claude-3-haiku'], }, mistral: { models: ['mistral-7b', 'mistral-8x7b'], + supportsToolCalls: ['mistral-8x7b'], + }, + // Provider with no capability arrays + cohere: { + models: ['command', 'command-light'], }, }, })) @@ -223,3 +234,74 @@ describe('extractModelRepo', () => { ) }) }) + +describe('getModelCapabilities', () => { + it('returns completion capability for all models', () => { + const capabilities = getModelCapabilities('openai', 'gpt-3.5-turbo') + expect(capabilities).toContain(ModelCapabilities.COMPLETION) + }) + + it('includes tools capability when model supports it', () => { + const capabilities = getModelCapabilities('openai', 'gpt-3.5-turbo') + expect(capabilities).toContain(ModelCapabilities.TOOLS) + expect(capabilities).toContain(ModelCapabilities.COMPLETION) + }) + + it('excludes tools capability when model does not support it', () => { + const capabilities = getModelCapabilities('mistral', 'mistral-7b') + expect(capabilities).not.toContain(ModelCapabilities.TOOLS) + expect(capabilities).toContain(ModelCapabilities.COMPLETION) + }) + + it('includes vision capability when model supports it', () => { + const capabilities = getModelCapabilities('openai', 'gpt-4-vision-preview') + expect(capabilities).toContain(ModelCapabilities.VISION) + expect(capabilities).toContain(ModelCapabilities.COMPLETION) + }) + + it('excludes vision capability when model does not support it', () => { + const capabilities = getModelCapabilities('openai', 'gpt-3.5-turbo') + expect(capabilities).not.toContain(ModelCapabilities.VISION) + }) + + it('includes both tools and vision when model supports both', () => { + const capabilities = getModelCapabilities('anthropic', 'claude-3-sonnet') + expect(capabilities).toContain(ModelCapabilities.COMPLETION) + expect(capabilities).toContain(ModelCapabilities.TOOLS) + expect(capabilities).toContain(ModelCapabilities.VISION) + }) + + it('handles provider with no capability arrays gracefully', () => { + const capabilities = getModelCapabilities('cohere', 'command') + expect(capabilities).toEqual([ModelCapabilities.COMPLETION]) + expect(capabilities).not.toContain(ModelCapabilities.TOOLS) + expect(capabilities).not.toContain(ModelCapabilities.VISION) + }) + + it('handles unknown provider gracefully', () => { + const capabilities = getModelCapabilities('openrouter', 'some-model') + expect(capabilities).toEqual([ModelCapabilities.COMPLETION]) + expect(capabilities).not.toContain(ModelCapabilities.TOOLS) + expect(capabilities).not.toContain(ModelCapabilities.VISION) + }) + + it('handles model not in capability list', () => { + const capabilities = getModelCapabilities('anthropic', 'claude-3-haiku') + expect(capabilities).toContain(ModelCapabilities.COMPLETION) + expect(capabilities).toContain(ModelCapabilities.VISION) + expect(capabilities).not.toContain(ModelCapabilities.TOOLS) + }) + + it('returns only completion for provider with partial capability data', () => { + // Mistral has supportsToolCalls but no supportsImages + const capabilities = getModelCapabilities('mistral', 'mistral-7b') + expect(capabilities).toEqual([ModelCapabilities.COMPLETION]) + }) + + it('handles model that supports tools but not vision', () => { + const capabilities = getModelCapabilities('mistral', 'mistral-8x7b') + expect(capabilities).toContain(ModelCapabilities.COMPLETION) + expect(capabilities).toContain(ModelCapabilities.TOOLS) + expect(capabilities).not.toContain(ModelCapabilities.VISION) + }) +}) diff --git a/web-app/src/lib/models.ts b/web-app/src/lib/models.ts index 0f9b79c40..18d0b6d8e 100644 --- a/web-app/src/lib/models.ts +++ b/web-app/src/lib/models.ts @@ -1,4 +1,5 @@ import { models } from 'token.js' +import { ModelCapabilities } from '@/types/models' export const defaultModel = (provider?: string) => { if (!provider || !Object.keys(models).includes(provider)) { @@ -10,6 +11,38 @@ export const defaultModel = (provider?: string) => { )[0] } +/** + * Determines model capabilities based on provider configuration from token.js + * @param providerName - The provider name (e.g., 'openai', 'anthropic', 'openrouter') + * @param modelId - The model ID to check capabilities for + * @returns Array of model capabilities + */ +export const getModelCapabilities = ( + providerName: string, + modelId: string +): string[] => { + const providerConfig = + models[providerName as unknown as keyof typeof models] + + const supportsToolCalls = Array.isArray( + providerConfig?.supportsToolCalls as unknown + ) + ? (providerConfig.supportsToolCalls as unknown as string[]) + : [] + + const supportsImages = Array.isArray( + providerConfig?.supportsImages as unknown + ) + ? (providerConfig.supportsImages as unknown as string[]) + : [] + + return [ + ModelCapabilities.COMPLETION, + supportsToolCalls.includes(modelId) ? ModelCapabilities.TOOLS : undefined, + supportsImages.includes(modelId) ? ModelCapabilities.VISION : undefined, + ].filter(Boolean) as string[] +} + /** * This utility is to extract cortexso model description from README.md file * @returns diff --git a/web-app/src/services/providers/tauri.ts b/web-app/src/services/providers/tauri.ts index 50f1217da..a8ca36fbb 100644 --- a/web-app/src/services/providers/tauri.ts +++ b/web-app/src/services/providers/tauri.ts @@ -10,6 +10,7 @@ import { modelSettings } from '@/lib/predefined' import { ExtensionManager } from '@/lib/extension' import { fetch as fetchTauri } from '@tauri-apps/plugin-http' import { DefaultProvidersService } from './default' +import { getModelCapabilities } from '@/lib/models' export class TauriProvidersService extends DefaultProvidersService { fetch(): typeof fetch { @@ -26,32 +27,16 @@ export class TauriProvidersService extends DefaultProvidersService { provider.provider as unknown as keyof typeof providerModels ].models as unknown as string[] - if (Array.isArray(builtInModels)) + if (Array.isArray(builtInModels)) { models = builtInModels.map((model) => { const modelManifest = models.find((e) => e.id === model) // TODO: Check chat_template for tool call support - const capabilities = [ - ModelCapabilities.COMPLETION, - ( - providerModels[ - provider.provider as unknown as keyof typeof providerModels - ]?.supportsToolCalls as unknown as string[] - )?.includes(model) - ? ModelCapabilities.TOOLS - : undefined, - ( - providerModels[ - provider.provider as unknown as keyof typeof providerModels - ]?.supportsImages as unknown as string[] - )?.includes(model) - ? ModelCapabilities.VISION - : undefined, - ].filter(Boolean) as string[] return { ...(modelManifest ?? { id: model, name: model }), - capabilities, + capabilities: getModelCapabilities(provider.provider, model), } as Model }) + } } return { diff --git a/web-app/src/services/providers/web.ts b/web-app/src/services/providers/web.ts index 6a7865be8..29d4a9cb7 100644 --- a/web-app/src/services/providers/web.ts +++ b/web-app/src/services/providers/web.ts @@ -11,6 +11,7 @@ import { ExtensionManager } from '@/lib/extension' import type { ProvidersService } from './types' import { PlatformFeatures } from '@/lib/platform/const' import { PlatformFeature } from '@/lib/platform/types' +import { getModelCapabilities } from '@/lib/models' export class WebProvidersService implements ProvidersService { async getProviders(): Promise { @@ -88,19 +89,9 @@ export class WebProvidersService implements ProvidersService { models = builtInModels.map((model) => { const modelManifest = models.find((e) => e.id === model) // TODO: Check chat_template for tool call support - const capabilities = [ - ModelCapabilities.COMPLETION, - ( - providerModels[ - provider.provider as unknown as keyof typeof providerModels - ]?.supportsToolCalls as unknown as string[] - )?.includes(model) - ? ModelCapabilities.TOOLS - : undefined, - ].filter(Boolean) as string[] return { ...(modelManifest ?? { id: model, name: model }), - capabilities, + capabilities: getModelCapabilities(provider.provider, model), } as Model }) } From 4da0fd1ca3e7ad1314fd06c92255afe646559554 Mon Sep 17 00:00:00 2001 From: Vanalite Date: Fri, 3 Oct 2025 10:25:41 +0700 Subject: [PATCH 22/97] 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 c4af638a17a6105011bfb46ae4d0112dc3ae164b Mon Sep 17 00:00:00 2001 From: Minh141120 Date: Fri, 3 Oct 2025 11:56:31 +0700 Subject: [PATCH 23/97] ci: remove upload msi --- .github/workflows/template-tauri-build-windows-x64.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.github/workflows/template-tauri-build-windows-x64.yml b/.github/workflows/template-tauri-build-windows-x64.yml index ed00ef90f..963bb144c 100644 --- a/.github/workflows/template-tauri-build-windows-x64.yml +++ b/.github/workflows/template-tauri-build-windows-x64.yml @@ -234,8 +234,6 @@ jobs: # Upload for tauri updater aws s3 cp ./${{ steps.metadata.outputs.FILE_NAME }} s3://${{ secrets.DELTA_AWS_S3_BUCKET_NAME }}/temp-${{ inputs.channel }}/${{ steps.metadata.outputs.FILE_NAME }} aws s3 cp ./${{ steps.metadata.outputs.FILE_NAME }}.sig s3://${{ secrets.DELTA_AWS_S3_BUCKET_NAME }}/temp-${{ inputs.channel }}/${{ steps.metadata.outputs.FILE_NAME }}.sig - - aws s3 cp ./src-tauri/target/release/bundle/msi/${{ steps.metadata.outputs.MSI_FILE_NAME }} s3://${{ secrets.DELTA_AWS_S3_BUCKET_NAME }}/temp-${{ inputs.channel }}/${{ steps.metadata.outputs.MSI_FILE_NAME }} env: AWS_ACCESS_KEY_ID: ${{ secrets.DELTA_AWS_ACCESS_KEY_ID }} AWS_SECRET_ACCESS_KEY: ${{ secrets.DELTA_AWS_SECRET_ACCESS_KEY }} From 5adaf6297538919bcf7125cca15f4274d2d49702 Mon Sep 17 00:00:00 2001 From: Dinh Long Nguyen Date: Fri, 3 Oct 2025 13:54:37 +0700 Subject: [PATCH 24/97] fix: extensions missing on Unix dev (#6724) * fix: extensions missing on Unix dev * re add bun uv for mcp --- src-tauri/tauri.linux.conf.json | 3 ++- src-tauri/tauri.macos.conf.json | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src-tauri/tauri.linux.conf.json b/src-tauri/tauri.linux.conf.json index 85f39ba50..02fa8cdf6 100644 --- a/src-tauri/tauri.linux.conf.json +++ b/src-tauri/tauri.linux.conf.json @@ -6,7 +6,8 @@ }, "bundle": { "targets": ["deb", "appimage"], - "resources": ["resources/LICENSE"], + "resources": ["resources/pre-install/**/*", "resources/LICENSE"], + "externalBin": ["resources/bin/uv"], "linux": { "appimage": { "bundleMediaFramework": false, diff --git a/src-tauri/tauri.macos.conf.json b/src-tauri/tauri.macos.conf.json index 2113bd0fa..92f937f0f 100644 --- a/src-tauri/tauri.macos.conf.json +++ b/src-tauri/tauri.macos.conf.json @@ -6,6 +6,7 @@ }, "bundle": { "targets": ["app", "dmg"], - "resources": ["resources/LICENSE"] + "resources": ["resources/pre-install/**/*", "resources/LICENSE"], + "externalBin": ["resources/bin/bun", "resources/bin/uv"] } } From cef351bfd0803bc539f76694402eed69f9e8cba2 Mon Sep 17 00:00:00 2001 From: Louis Date: Fri, 3 Oct 2025 14:12:16 +0700 Subject: [PATCH 25/97] fix: Local API Server - disable settings on run (#6707) --- web-app/src/routes/settings/local-api-server.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web-app/src/routes/settings/local-api-server.tsx b/web-app/src/routes/settings/local-api-server.tsx index 3628a6f8c..b3360e98e 100644 --- a/web-app/src/routes/settings/local-api-server.tsx +++ b/web-app/src/routes/settings/local-api-server.tsx @@ -258,7 +258,7 @@ function LocalAPIServerContent() { } } - const isServerRunning = serverStatus === 'running' + const isServerRunning = serverStatus !== 'stopped' return (
From b628b3d9ab73ab030e69879cf04e77cb550e0c9a Mon Sep 17 00:00:00 2001 From: Vanalite Date: Fri, 3 Oct 2025 14:17:59 +0700 Subject: [PATCH 26/97] 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 8b448d1c0b6bfc9d435b9b0d2413eaa65ad8ef89 Mon Sep 17 00:00:00 2001 From: Faisal Amir Date: Fri, 3 Oct 2025 23:17:54 +0700 Subject: [PATCH 27/97] changelog: release 0.7.1 --- .../changelog/2025-10-02-jan-projects.mdx | 2 +- .../2025-10-03-jan-stability-improvements.mdx | 28 +++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) create mode 100644 docs/src/pages/changelog/2025-10-03-jan-stability-improvements.mdx diff --git a/docs/src/pages/changelog/2025-10-02-jan-projects.mdx b/docs/src/pages/changelog/2025-10-02-jan-projects.mdx index 63f29194d..851e26403 100644 --- a/docs/src/pages/changelog/2025-10-02-jan-projects.mdx +++ b/docs/src/pages/changelog/2025-10-02-jan-projects.mdx @@ -1,6 +1,6 @@ --- title: "Jan v0.7.0: Jan Projects" -version: v0.7.0 +version: 0.7.0 description: "Jan v0.7.0 introduces Projects, model renaming, llama.cpp auto-tuning, model stats, and Azure support." date: 2025-10-02 ogImage: "/assets/images/changelog/jan-release-v0.7.0.jpeg" diff --git a/docs/src/pages/changelog/2025-10-03-jan-stability-improvements.mdx b/docs/src/pages/changelog/2025-10-03-jan-stability-improvements.mdx new file mode 100644 index 000000000..5fa69d98c --- /dev/null +++ b/docs/src/pages/changelog/2025-10-03-jan-stability-improvements.mdx @@ -0,0 +1,28 @@ +--- +title: "Jan v0.7.1: Fixes Windows Version Revert & OpenRouter Models" +version: 0.7.1 +description: "Jan v0.7.1 focuses on bug fixes, including a windows version revert and improvements to OpenRouter models." +date: 2025-10-03 +--- + +import ChangelogHeader from "@/components/Changelog/ChangelogHeader" +import { Callout } from 'nextra/components' + + + +## Jan v0.7.1: Bug Fixes: Version Revert & OpenRouter Models + +Jan v0.7.0 is live! This release focuses on helping you organize your workspace and better understand how models run. + +### Two quick fixes: +- Jan no longer reverts to an older version on load +- OpenRouter can now add models again +- Add headers for anthropic request to fetch models + +--- + +Update your Jan or [download the latest version](https://jan.ai/). + +For the complete list of changes, see the [GitHub release notes](https://github.com/janhq/jan/releases/tag/v0.7.1). + + From e346b293f671b533a4117c7610876a6cf27ef779 Mon Sep 17 00:00:00 2001 From: Faisal Amir Date: Fri, 3 Oct 2025 23:23:46 +0700 Subject: [PATCH 28/97] chore: wrong version in detail changelog --- .../pages/changelog/2025-10-03-jan-stability-improvements.mdx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/src/pages/changelog/2025-10-03-jan-stability-improvements.mdx b/docs/src/pages/changelog/2025-10-03-jan-stability-improvements.mdx index 5fa69d98c..b2379c7cb 100644 --- a/docs/src/pages/changelog/2025-10-03-jan-stability-improvements.mdx +++ b/docs/src/pages/changelog/2025-10-03-jan-stability-improvements.mdx @@ -10,9 +10,9 @@ import { Callout } from 'nextra/components' -## Jan v0.7.1: Bug Fixes: Version Revert & OpenRouter Models +## Bug Fixes: Windows Version Revert & OpenRouter Models -Jan v0.7.0 is live! This release focuses on helping you organize your workspace and better understand how models run. +Jan v0.7.1 is live! This release focuses on helping you organize your workspace and better understand how models run. ### Two quick fixes: - Jan no longer reverts to an older version on load From ca485b4a35b0a8f405b4f27567a17186e01a65b7 Mon Sep 17 00:00:00 2001 From: Faisal Amir Date: Fri, 3 Oct 2025 23:33:02 +0700 Subject: [PATCH 29/97] fix: update detail changelog 0.7.1 --- .../changelog/2025-10-03-jan-stability-improvements.mdx | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/docs/src/pages/changelog/2025-10-03-jan-stability-improvements.mdx b/docs/src/pages/changelog/2025-10-03-jan-stability-improvements.mdx index b2379c7cb..df756ccfc 100644 --- a/docs/src/pages/changelog/2025-10-03-jan-stability-improvements.mdx +++ b/docs/src/pages/changelog/2025-10-03-jan-stability-improvements.mdx @@ -10,11 +10,9 @@ import { Callout } from 'nextra/components' -## Bug Fixes: Windows Version Revert & OpenRouter Models +### Bug Fixes: Windows Version Revert & OpenRouter Models -Jan v0.7.1 is live! This release focuses on helping you organize your workspace and better understand how models run. - -### Two quick fixes: +#### Two quick fixes: - Jan no longer reverts to an older version on load - OpenRouter can now add models again - Add headers for anthropic request to fetch models From cb9eb6d23837fcba861e3d69b904279b3c1a66be Mon Sep 17 00:00:00 2001 From: Roushan Singh Date: Sat, 4 Oct 2025 22:21:02 +0530 Subject: [PATCH 30/97] 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 36/97] (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 37/97] 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 38/97] (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 39/97] 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 40/97] 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 ( <> -
+
) : (
)} diff --git a/web-app/src/containers/ThreadContent.tsx b/web-app/src/containers/ThreadContent.tsx index 2f83ad513..1c17d086b 100644 --- a/web-app/src/containers/ThreadContent.tsx +++ b/web-app/src/containers/ThreadContent.tsx @@ -26,6 +26,8 @@ import TokenSpeedIndicator from '@/containers/TokenSpeedIndicator' import { useTranslation } from '@/i18n/react-i18next-compat' import { useModelProvider } from '@/hooks/useModelProvider' +import { extractFilesFromPrompt } from '@/lib/fileMetadata' +import { createImageAttachment } from '@/types/attachment' const CopyButton = ({ text }: { text: string }) => { const [copied, setCopied] = useState(false) @@ -102,6 +104,14 @@ export const ThreadContent = memo( [item.content] ) + // Extract file metadata from user message text + const { files: attachedFiles, cleanPrompt } = useMemo(() => { + if (item.role === 'user') { + return extractFilesFromPrompt(text) + } + return { files: [], cleanPrompt: text } + }, [text, item.role]) + const { reasoningSegment, textSegment } = useMemo(() => { // Check for thinking formats const hasThinkTag = text.includes('') && !text.includes('') @@ -153,9 +163,9 @@ export const ThreadContent = memo( if (toSendMessage) { deleteMessage(toSendMessage.thread_id, toSendMessage.id ?? '') // Extract text content and any attachments - const textContent = - toSendMessage.content?.find((c) => c.type === 'text')?.text?.value || - '' + const rawText = + toSendMessage.content?.find((c) => c.type === 'text')?.text?.value || '' + const { cleanPrompt: textContent } = extractFilesFromPrompt(rawText) const attachments = toSendMessage.content ?.filter((c) => (c.type === 'image_url' && c.image_url?.url) || false) .map((c) => { @@ -164,24 +174,19 @@ export const ThreadContent = memo( const [mimeType, base64] = url .replace('data:', '') .split(';base64,') - return { - name: 'image', // We don't have the original filename - type: mimeType, - size: 0, // We don't have the original size + return createImageAttachment({ + name: 'image', // Original filename unavailable + mimeType, + size: 0, base64: base64, dataUrl: url, - } + }) } return null }) - .filter(Boolean) as Array<{ - name: string - type: string - size: number - base64: string - dataUrl: string - }> - sendMessage(textContent, true, attachments) + .filter(Boolean) + // Keep embedded document metadata in the message for regenerate + sendMessage(rawText, true, attachments) } }, [deleteMessage, getMessages, item, sendMessage]) @@ -225,7 +230,56 @@ export const ThreadContent = memo( {item.role === 'user' && (
- {/* Render attachments above the message bubble */} + {/* Render text content in the message bubble */} + {cleanPrompt && ( +
+
+
+ +
+
+
+ )} + + {/* Render document file attachments (extracted from message text) - below text */} + {attachedFiles.length > 0 && ( +
+
+ {attachedFiles.map((file, index) => ( +
+ + + + {file.name} + {file.type && ( + + .{file.type} + + )} +
+ ))} +
+
+ )} + + {/* Render image attachments - below files */} {item.content?.some( (c) => (c.type === 'image_url' && c.image_url?.url) || false ) && ( @@ -258,33 +312,9 @@ export const ThreadContent = memo(
)} - {/* Render text content in the message bubble */} - {item.content?.some((c) => c.type === 'text' && c.text?.value) && ( -
-
-
- {item.content - ?.filter((c) => c.type === 'text' && c.text?.value) - .map((contentPart, index) => ( -
- -
- ))} -
-
-
- )} -
c.type === 'text')?.text?.value || - '' - } + message={cleanPrompt || ''} imageUrls={ item.content ?.filter((c) => c.type === 'image_url' && c.image_url?.url) diff --git a/web-app/src/hooks/useChat.ts b/web-app/src/hooks/useChat.ts index 79e414185..13dd906fc 100644 --- a/web-app/src/hooks/useChat.ts +++ b/web-app/src/hooks/useChat.ts @@ -37,6 +37,8 @@ import { import { useAssistant } from './useAssistant' import { useShallow } from 'zustand/shallow' import { TEMPORARY_CHAT_QUERY_ID, TEMPORARY_CHAT_ID } from '@/constants/chat' +import { toast } from 'sonner' +import { Attachment } from '@/types/attachment' export const useChat = () => { const [ @@ -257,14 +259,12 @@ export const useChat = () => { async ( message: string, troubleshooting = true, - attachments?: Array<{ - name: string - type: string - size: number - base64: string - dataUrl: string - }>, - projectId?: string + attachments?: Attachment[], + projectId?: string, + updateAttachmentProcessing?: ( + fileName: string, + status: 'processing' | 'done' | 'error' | 'clear_docs' | 'clear_all' + ) => void ) => { const activeThread = await getCurrentThread(projectId) const selectedProvider = useModelProvider.getState().selectedProvider @@ -272,14 +272,124 @@ export const useChat = () => { resetTokenSpeed() if (!activeThread || !activeProvider) return + + // Separate images and documents + const images = attachments?.filter((a) => a.type === 'image') || [] + const documents = attachments?.filter((a) => a.type === 'document') || [] + + // Process attachments BEFORE sending + const processedAttachments: Attachment[] = [] + + // 1) Images ingestion (placeholder/no-op for now) + // Track attachment ingestion; all must succeed before sending + + if (images.length > 0) { + for (const img of images) { + try { + // Skip if already processed (ingested in ChatInput when thread existed) + if (img.processed && img.id) { + processedAttachments.push(img) + continue + } + + if (updateAttachmentProcessing) { + updateAttachmentProcessing(img.name, 'processing') + } + // Upload image, get id/URL + const res = await serviceHub.uploads().ingestImage(activeThread.id, img) + processedAttachments.push({ + ...img, + id: res.id, + processed: true, + processing: false, + }) + if (updateAttachmentProcessing) { + updateAttachmentProcessing(img.name, 'done') + } + } catch (err) { + console.error(`Failed to ingest image ${img.name}:`, err) + if (updateAttachmentProcessing) { + updateAttachmentProcessing(img.name, 'error') + } + const desc = err instanceof Error ? err.message : String(err) + toast.error('Failed to ingest image attachment', { description: desc }) + return + } + } + } + + if (documents.length > 0) { + try { + for (const doc of documents) { + // Skip if already processed (ingested in ChatInput when thread existed) + if (doc.processed && doc.id) { + processedAttachments.push(doc) + continue + } + + // Update UI to show spinner on this file + if (updateAttachmentProcessing) { + updateAttachmentProcessing(doc.name, 'processing') + } + + try { + const res = await serviceHub + .uploads() + .ingestFileAttachment(activeThread.id, doc) + + // Add processed document with ID + processedAttachments.push({ + ...doc, + id: res.id, + size: res.size ?? doc.size, + chunkCount: res.chunkCount ?? doc.chunkCount, + processing: false, + processed: true, + }) + + // Update UI to show done state + if (updateAttachmentProcessing) { + updateAttachmentProcessing(doc.name, 'done') + } + } catch (err) { + console.error(`Failed to ingest ${doc.name}:`, err) + if (updateAttachmentProcessing) { + updateAttachmentProcessing(doc.name, 'error') + } + throw err // Re-throw to handle in outer catch + } + } + } catch (err) { + console.error('Failed to ingest documents:', err) + const desc = err instanceof Error ? err.message : String(err) + toast.error('Failed to index attachments', { description: desc }) + // Don't continue with message send if ingestion failed + return + } + } + + // All attachments prepared successfully + const messages = getMessages(activeThread.id) const abortController = new AbortController() setAbortController(activeThread.id, abortController) updateStreamingContent(emptyThreadContent) updatePromptProgress(undefined) // Do not add new message on retry - if (troubleshooting) - addMessage(newUserThreadContent(activeThread.id, message, attachments)) + // All attachments (images + docs) ingested successfully. + // Build the user content once; use it for both the outbound request + // and persisting to the store so both are identical. + if (updateAttachmentProcessing) { + updateAttachmentProcessing('__CLEAR_ALL__' as any, 'clear_all') + } + const userContent = newUserThreadContent( + activeThread.id, + message, + processedAttachments + ) + if (troubleshooting) { + addMessage(userContent) + } updateThreadTimestamp(activeThread.id) usePrompt.getState().setPrompt('') const selectedModel = useModelProvider.getState().selectedModel @@ -296,7 +406,8 @@ export const useChat = () => { ? renderInstructions(currentAssistant.instructions) : undefined ) - if (troubleshooting) builder.addUserMessage(message, attachments) + // Using addUserMessage to respect legacy code. Should be using the userContent above. + if (troubleshooting) builder.addUserMessage(userContent) let isCompleted = false diff --git a/web-app/src/lib/__tests__/messages.test.ts b/web-app/src/lib/__tests__/messages.test.ts index 752a4ea51..bd7e478b4 100644 --- a/web-app/src/lib/__tests__/messages.test.ts +++ b/web-app/src/lib/__tests__/messages.test.ts @@ -137,7 +137,9 @@ describe('CompletionMessagesBuilder', () => { it('should add user message to messages array', () => { const builder = new CompletionMessagesBuilder([]) - builder.addUserMessage('Hello, how are you?') + builder.addUserMessage( + createMockThreadMessage('user', 'Hello, how are you?') + ) const result = builder.getMessages() expect(result).toHaveLength(1) @@ -150,8 +152,8 @@ describe('CompletionMessagesBuilder', () => { it('should not add consecutive user messages', () => { const builder = new CompletionMessagesBuilder([]) - builder.addUserMessage('First message') - builder.addUserMessage('Second message') + builder.addUserMessage(createMockThreadMessage('user', 'First message')) + builder.addUserMessage(createMockThreadMessage('user', 'Second message')) const result = builder.getMessages() expect(result).toHaveLength(1) @@ -161,7 +163,7 @@ describe('CompletionMessagesBuilder', () => { it('should handle empty user message', () => { const builder = new CompletionMessagesBuilder([]) - builder.addUserMessage('') + builder.addUserMessage(createMockThreadMessage('user', '')) const result = builder.getMessages() expect(result).toHaveLength(1) @@ -338,7 +340,7 @@ describe('CompletionMessagesBuilder', () => { 'You are helpful' ) - builder.addUserMessage('How are you?') + builder.addUserMessage(createMockThreadMessage('user', 'How are you?')) builder.addAssistantMessage('I am well, thank you!') builder.addToolMessage('Tool response', 'call_123') @@ -353,7 +355,7 @@ describe('CompletionMessagesBuilder', () => { it('should return the same array reference (not immutable)', () => { const builder = new CompletionMessagesBuilder([]) - builder.addUserMessage('Test message') + builder.addUserMessage(createMockThreadMessage('user', 'Test message')) const result1 = builder.getMessages() builder.addAssistantMessage('Response') diff --git a/web-app/src/lib/completion.ts b/web-app/src/lib/completion.ts index ece27ed6c..301941cf1 100644 --- a/web-app/src/lib/completion.ts +++ b/web-app/src/lib/completion.ts @@ -36,6 +36,8 @@ import { CompletionMessagesBuilder } from './messages' import { ChatCompletionMessageToolCall } from 'openai/resources' import { ExtensionManager } from './extension' import { useAppState } from '@/hooks/useAppState' +import { injectFilesIntoPrompt } from './fileMetadata' +import { Attachment } from '@/types/attachment' export type ChatCompletionResponse = | chatCompletion @@ -54,38 +56,48 @@ export type ChatCompletionResponse = export const newUserThreadContent = ( threadId: string, content: string, - attachments?: Array<{ - name: string - type: string - size: number - base64: string - dataUrl: string - }> + attachments?: Attachment[] ): ThreadMessage => { + // Separate images and documents + const images = attachments?.filter((a) => a.type === 'image') || [] + const documents = attachments?.filter((a) => a.type === 'document') || [] + + // Inject document metadata into the text content (id, name, fileType only - no path) + const docMetadata = documents + .filter((doc) => doc.id) // Only include processed documents + .map((doc) => ({ + id: doc.id!, + name: doc.name, + type: doc.fileType, + size: typeof doc.size === 'number' ? doc.size : undefined, + chunkCount: typeof doc.chunkCount === 'number' ? doc.chunkCount : undefined, + })) + + const textWithFiles = + docMetadata.length > 0 ? injectFilesIntoPrompt(content, docMetadata) : content + const contentParts = [ { type: ContentType.Text, text: { - value: content, + value: textWithFiles, annotations: [], }, }, ] - // Add attachments to content array - if (attachments) { - attachments.forEach((attachment) => { - if (attachment.type.startsWith('image/')) { - contentParts.push({ - type: ContentType.Image, - image_url: { - url: `data:${attachment.type};base64,${attachment.base64}`, - detail: 'auto', - }, - } as any) - } - }) - } + // Add image attachments to content array + images.forEach((img) => { + if (img.base64 && img.mimeType) { + contentParts.push({ + type: ContentType.Image, + image_url: { + url: `data:${img.mimeType};base64,${img.base64}`, + detail: 'auto', + }, + } as any) + } + }) return { type: 'text', diff --git a/web-app/src/lib/fileMetadata.ts b/web-app/src/lib/fileMetadata.ts new file mode 100644 index 000000000..21067c56e --- /dev/null +++ b/web-app/src/lib/fileMetadata.ts @@ -0,0 +1,96 @@ +/** + * Utility functions for embedding and extracting file metadata from user prompts + */ + +export interface FileMetadata { + id: string + name: string + type?: string + size?: number + chunkCount?: number +} + +const FILE_METADATA_START = '[ATTACHED_FILES]' +const FILE_METADATA_END = '[/ATTACHED_FILES]' + +/** + * Inject file metadata into user prompt at the end + * @param prompt - The user's message + * @param files - Array of file metadata + * @returns Prompt with embedded file metadata + */ +export function injectFilesIntoPrompt( + prompt: string, + files: FileMetadata[] +): string { + if (!files || files.length === 0) return prompt + + const fileLines = files + .map((file) => { + const parts = [`file_id: ${file.id}`, `name: ${file.name}`] + if (file.type) parts.push(`type: ${file.type}`) + if (typeof file.size === 'number') parts.push(`size: ${file.size}`) + if (typeof file.chunkCount === 'number') parts.push(`chunks: ${file.chunkCount}`) + return `- ${parts.join(', ')}` + }) + .join('\n') + + const fileBlock = `\n\n${FILE_METADATA_START}\n${fileLines}\n${FILE_METADATA_END}` + + return prompt + fileBlock +} + +/** + * Extract file metadata from user prompt + * @param prompt - The prompt potentially containing file metadata + * @returns Object containing extracted files and clean prompt + */ +export function extractFilesFromPrompt(prompt: string): { + files: FileMetadata[] + cleanPrompt: string +} { + if (!prompt.includes(FILE_METADATA_START)) { + return { files: [], cleanPrompt: prompt } + } + + const startIndex = prompt.indexOf(FILE_METADATA_START) + const endIndex = prompt.indexOf(FILE_METADATA_END) + + if (startIndex === -1 || endIndex === -1 || endIndex <= startIndex) { + return { files: [], cleanPrompt: prompt } + } + + // Extract the file metadata block + const fileBlock = prompt.substring( + startIndex + FILE_METADATA_START.length, + endIndex + ) + + // Parse file metadata (flexible key:value parser) + const files: FileMetadata[] = [] + const lines = fileBlock.trim().split('\n') + for (const line of lines) { + const trimmed = line.replace(/^\s*-\s*/, '').trim() + const parts = trimmed.split(',') + const map: Record = {} + for (const part of parts) { + const [k, ...rest] = part.split(':') + if (!k || rest.length === 0) continue + map[k.trim()] = rest.join(':').trim() + } + const id = map['file_id'] + const name = map['name'] + if (!id || !name) continue + const type = map['type'] + const size = map['size'] ? Number(map['size']) : undefined + const chunkCount = map['chunks'] ? Number(map['chunks']) : undefined + files.push({ id, name, ...(type && { type }), ...(typeof size === 'number' && !Number.isNaN(size) ? { size } : {}), ...(typeof chunkCount === 'number' && !Number.isNaN(chunkCount) ? { chunkCount } : {}) }) + } + + // Extract clean prompt (everything before [ATTACHED_FILES]) + const cleanPrompt = prompt + .substring(0, startIndex) + .trim() + + return { files, cleanPrompt } +} diff --git a/web-app/src/lib/messages.ts b/web-app/src/lib/messages.ts index fd2e84181..61acf8bde 100644 --- a/web-app/src/lib/messages.ts +++ b/web-app/src/lib/messages.ts @@ -3,6 +3,7 @@ import { ChatCompletionMessageParam } from 'token.js' import { ChatCompletionMessageToolCall } from 'openai/resources' import { ThreadMessage } from '@janhq/core' import { removeReasoningContent } from '@/utils/reasoning' +// Attachments are now handled upstream in newUserThreadContent /** * @fileoverview Helper functions for creating chat completion request. @@ -21,106 +22,62 @@ export class CompletionMessagesBuilder { this.messages.push( ...messages .filter((e) => !e.metadata?.error) - .map((msg) => { - if (msg.role === 'assistant') { - return { - role: msg.role, - content: removeReasoningContent( - msg.content[0]?.text?.value || '.' - ), - } as ChatCompletionMessageParam - } else { - // For user messages, handle multimodal content - if (msg.content.length > 1) { - // Multiple content parts (text + images + files) - - const content = msg.content.map((contentPart) => { - if (contentPart.type === 'text') { - return { - type: 'text', - text: contentPart.text?.value || '', - } - } else if (contentPart.type === 'image_url') { - return { - type: 'image_url', - image_url: { - url: contentPart.image_url?.url || '', - detail: contentPart.image_url?.detail || 'auto', - }, - } - } else { - return contentPart - } - }) - return { - role: msg.role, - content, - } as ChatCompletionMessageParam - } else { - // Single text content - return { - role: msg.role, - content: msg.content[0]?.text?.value || '.', - } as ChatCompletionMessageParam - } - } - }) + .map((msg) => this.toCompletionParamFromThread(msg)) ) } + // Normalize a ThreadMessage into a ChatCompletionMessageParam for Token.js + private toCompletionParamFromThread(msg: ThreadMessage): ChatCompletionMessageParam { + if (msg.role === 'assistant') { + return { + role: 'assistant', + content: removeReasoningContent(msg.content?.[0]?.text?.value || '.'), + } as ChatCompletionMessageParam + } + + // System messages are uncommon here; normalize to plain text + if (msg.role === 'system') { + return { + role: 'system', + content: msg.content?.[0]?.text?.value || '.', + } as ChatCompletionMessageParam + } + + // User messages: handle multimodal content + if (Array.isArray(msg.content) && msg.content.length > 1) { + const content = msg.content.map((part: any) => { + if (part.type === 'text') { + return { type: 'text', text: part.text?.value ?? '' } + } + if (part.type === 'image_url') { + return { + type: 'image_url', + image_url: { url: part.image_url?.url || '', detail: part.image_url?.detail || 'auto' }, + } + } + return part + }) + return { role: 'user', content } as any + } + // Single text part + const text = msg?.content?.[0]?.text?.value ?? '.' + return { role: 'user', content: text } + } + /** - * Add a user message to the messages array. - * @param content - The content of the user message. - * @param attachments - Optional attachments for the message. + * Add a user message to the messages array from a parsed ThreadMessage. + * Upstream code should construct the message via newUserThreadContent + * and pass it here to avoid duplicated logic. */ - addUserMessage( - content: string, - attachments?: Array<{ - name: string - type: string - size: number - base64: string - dataUrl: string - }> - ) { + addUserMessage(message: ThreadMessage) { + if (message.role !== 'user') { + throw new Error('addUserMessage expects a user ThreadMessage') + } // Ensure no consecutive user messages if (this.messages[this.messages.length - 1]?.role === 'user') { this.messages.pop() } - - // Handle multimodal content with attachments - if (attachments && attachments.length > 0) { - const messageContent: any[] = [ - { - type: 'text', - text: content, - }, - ] - - // Add attachments (images and PDFs) - attachments.forEach((attachment) => { - if (attachment.type.startsWith('image/')) { - messageContent.push({ - type: 'image_url', - image_url: { - url: `data:${attachment.type};base64,${attachment.base64}`, - detail: 'auto', - }, - }) - } - }) - - this.messages.push({ - role: 'user', - content: messageContent, - } as any) - } else { - // Text-only message - this.messages.push({ - role: 'user', - content: content, - }) - } + this.messages.push(this.toCompletionParamFromThread(message)) } /** diff --git a/web-app/src/services/index.ts b/web-app/src/services/index.ts index c5a585171..65a117986 100644 --- a/web-app/src/services/index.ts +++ b/web-app/src/services/index.ts @@ -29,6 +29,8 @@ import { DefaultDeepLinkService } from './deeplink/default' import { DefaultProjectsService } from './projects/default' import { DefaultRAGService } from './rag/default' import type { RAGService } from './rag/types' +import { DefaultUploadsService } from './uploads/default' +import type { UploadsService } from './uploads/types' // Import service types import type { ThemeService } from './theme/types' @@ -73,6 +75,7 @@ export interface ServiceHub { deeplink(): DeepLinkService projects(): ProjectsService rag(): RAGService + uploads(): UploadsService } class PlatformServiceHub implements ServiceHub { @@ -96,6 +99,7 @@ class PlatformServiceHub implements ServiceHub { private deepLinkService: DeepLinkService = new DefaultDeepLinkService() private projectsService: ProjectsService = new DefaultProjectsService() private ragService: RAGService = new DefaultRAGService() + private uploadsService: UploadsService = new DefaultUploadsService() private initialized = false /** @@ -352,6 +356,11 @@ class PlatformServiceHub implements ServiceHub { this.ensureInitialized() return this.ragService } + + uploads(): UploadsService { + this.ensureInitialized() + return this.uploadsService + } } export async function initializeServiceHub(): Promise { diff --git a/web-app/src/services/uploads/default.ts b/web-app/src/services/uploads/default.ts new file mode 100644 index 000000000..0b26bd274 --- /dev/null +++ b/web-app/src/services/uploads/default.ts @@ -0,0 +1,32 @@ +import type { UploadsService, UploadResult } from './types' +import type { Attachment, } from '@/types/attachment' +import { ulid } from 'ulidx' +import { ExtensionManager } from '@/lib/extension' +import { ExtensionTypeEnum, type RAGExtension, type IngestAttachmentsResult } from '@janhq/core' + +export class DefaultUploadsService implements UploadsService { + async ingestImage(_threadId: string, attachment: Attachment): Promise { + if (attachment.type !== 'image') throw new Error('ingestImage: attachment is not image') + // Placeholder upload flow; swap for real API call when backend is ready + await new Promise((r) => setTimeout(r, 100)) + return { id: ulid() } + } + + async ingestFileAttachment(threadId: string, attachment: Attachment): Promise { + if (attachment.type !== 'document') throw new Error('ingestFileAttachment: attachment is not document') + const ext = ExtensionManager.getInstance().get(ExtensionTypeEnum.RAG) + if (!ext?.ingestAttachments) throw new Error('RAG extension not available') + const res: IngestAttachmentsResult = await ext.ingestAttachments(threadId, [ + { path: attachment.path!, name: attachment.name, type: attachment.fileType, size: attachment.size }, + ]) + const files = res.files + if (Array.isArray(files) && files[0]?.id) { + return { + id: files[0].id, + size: typeof files[0].size === 'number' ? Number(files[0].size) : undefined, + chunkCount: typeof files[0].chunk_count === 'number' ? Number(files[0].chunk_count) : undefined, + } + } + throw new Error('Failed to resolve ingested attachment id') + } +} diff --git a/web-app/src/services/uploads/types.ts b/web-app/src/services/uploads/types.ts new file mode 100644 index 000000000..4f36b8d51 --- /dev/null +++ b/web-app/src/services/uploads/types.ts @@ -0,0 +1,16 @@ +import type { Attachment } from '@/types/attachment' + +export type UploadResult = { + id: string + url?: string + size?: number + chunkCount?: number +} + +export interface UploadsService { + // Ingest an image attachment (placeholder upload) + ingestImage(threadId: string, attachment: Attachment): Promise + + // Ingest a document attachment in the context of a thread + ingestFileAttachment(threadId: string, attachment: Attachment): Promise +} diff --git a/web-app/src/types/attachment.ts b/web-app/src/types/attachment.ts new file mode 100644 index 000000000..9ae23eccd --- /dev/null +++ b/web-app/src/types/attachment.ts @@ -0,0 +1,57 @@ +/** + * Unified attachment type for both images and documents + */ +export type Attachment = { + name: string + type: 'image' | 'document' + + // Common fields + size?: number + chunkCount?: number + processing?: boolean + processed?: boolean + error?: string + + // For images (before upload) + base64?: string + dataUrl?: string + mimeType?: string + + // For documents (local files) + path?: string + fileType?: string // e.g., 'pdf', 'docx' + + // After processing (images uploaded, documents ingested) + id?: string +} + +/** + * Helper to create image attachment + */ +export function createImageAttachment(data: { + name: string + base64: string + dataUrl: string + mimeType: string + size: number +}): Attachment { + return { + ...data, + type: 'image', + } +} + +/** + * Helper to create document attachment + */ +export function createDocumentAttachment(data: { + name: string + path: string + fileType?: string + size?: number +}): Attachment { + return { + ...data, + type: 'document', + } +} From fc784620e03560bf23d00be35a2163f4b8df18a4 Mon Sep 17 00:00:00 2001 From: Dinh Long Nguyen Date: Thu, 9 Oct 2025 04:28:08 +0700 Subject: [PATCH 78/97] fix tests --- core/src/types/setting/settingComponent.ts | 2 + extensions/rag-extension/src/tools.ts | 2 +- web-app/src/containers/ChatInput.tsx | 2 +- web-app/src/containers/ThreadContent.tsx | 4 +- web-app/src/hooks/useAttachments.ts | 92 ++++++++++++++------- web-app/src/hooks/useChat.ts | 2 +- web-app/src/lib/completion.ts | 5 +- web-app/src/lib/messages.ts | 29 ++++--- web-app/src/routes/settings/attachments.tsx | 62 ++++++++++---- web-app/src/services/rag/default.ts | 5 +- 10 files changed, 141 insertions(+), 64 deletions(-) diff --git a/core/src/types/setting/settingComponent.ts b/core/src/types/setting/settingComponent.ts index 9dfd9b597..57b222d87 100644 --- a/core/src/types/setting/settingComponent.ts +++ b/core/src/types/setting/settingComponent.ts @@ -12,6 +12,8 @@ export type SettingComponentProps = { extensionName?: string requireModelReload?: boolean configType?: ConfigType + titleKey?: string + descriptionKey?: string } export type ConfigType = 'runtime' | 'setting' diff --git a/extensions/rag-extension/src/tools.ts b/extensions/rag-extension/src/tools.ts index f2199ed86..a881891b4 100644 --- a/extensions/rag-extension/src/tools.ts +++ b/extensions/rag-extension/src/tools.ts @@ -41,7 +41,7 @@ export function getRAGTools(retrievalLimit: number): MCPTool[] { { name: GET_CHUNKS, description: - 'Retrieve chunks from a file by their order range. For a single chunk, use start_order = end_order. Thread context is inferred automatically; you may optionally provide {"scope":"thread"}.', + 'Retrieve chunks from a file by their order range. For a single chunk, use start_order = end_order. Thread context is inferred automatically; you may optionally provide {"scope":"thread"}. Use sparingly; intended for advanced usage. Prefer using retrieve instead for relevance-based fetching.', inputSchema: { type: 'object', properties: { diff --git a/web-app/src/containers/ChatInput.tsx b/web-app/src/containers/ChatInput.tsx index 3a926e78c..b736845d5 100644 --- a/web-app/src/containers/ChatInput.tsx +++ b/web-app/src/containers/ChatInput.tsx @@ -565,7 +565,7 @@ const ChatInput = ({ // If thread exists, ingest images immediately if (currentThreadId) { - ;(async () => { + void (async () => { for (const img of newFiles) { try { // Mark as processing diff --git a/web-app/src/containers/ThreadContent.tsx b/web-app/src/containers/ThreadContent.tsx index 1c17d086b..e120544c1 100644 --- a/web-app/src/containers/ThreadContent.tsx +++ b/web-app/src/containers/ThreadContent.tsx @@ -184,9 +184,9 @@ export const ThreadContent = memo( } return null }) - .filter(Boolean) + .filter((v) => v !== null) // Keep embedded document metadata in the message for regenerate - sendMessage(rawText, true, attachments) + sendMessage(textContent, true, attachments) } }, [deleteMessage, getMessages, item, sendMessage]) diff --git a/web-app/src/hooks/useAttachments.ts b/web-app/src/hooks/useAttachments.ts index 9405c4110..4407fa26b 100644 --- a/web-app/src/hooks/useAttachments.ts +++ b/web-app/src/hooks/useAttachments.ts @@ -1,6 +1,6 @@ import { create } from 'zustand' import { ExtensionManager } from '@/lib/extension' -import { ExtensionTypeEnum, type RAGExtension } from '@janhq/core' +import { ExtensionTypeEnum, type RAGExtension, type SettingComponentProps } from '@janhq/core' export type AttachmentsSettings = { enabled: boolean @@ -14,7 +14,7 @@ export type AttachmentsSettings = { type AttachmentsStore = AttachmentsSettings & { // Dynamic controller definitions for rendering UI - settingsDefs: any[] + settingsDefs: SettingComponentProps[] loadSettingsDefs: () => Promise setEnabled: (v: boolean) => void setMaxFileSizeMB: (v: number) => void @@ -27,7 +27,7 @@ type AttachmentsStore = AttachmentsSettings & { const getRagExtension = (): RAGExtension | undefined => { try { - return ExtensionManager.getInstance().get(ExtensionTypeEnum.RAG) as any + return ExtensionManager.getInstance().get(ExtensionTypeEnum.RAG) } catch { return undefined } @@ -43,94 +43,124 @@ export const useAttachments = create()((set) => ({ searchMode: 'auto', settingsDefs: [], loadSettingsDefs: async () => { - const ext = getRagExtension() as any + const ext = getRagExtension() if (!ext?.getSettings) return try { const defs = await ext.getSettings() if (Array.isArray(defs)) set({ settingsDefs: defs }) - } catch {} + } catch (e) { + console.debug('Failed to load attachment settings defs:', e) + } }, setEnabled: async (v) => { const ext = getRagExtension() if (ext?.updateSettings) { - await ext.updateSettings([{ key: 'enabled', controllerProps: { value: !!v } } as any]) + await ext.updateSettings([ + { key: 'enabled', controllerProps: { value: !!v } } as Partial, + ]) } set((s) => ({ enabled: v, settingsDefs: s.settingsDefs.map((d) => - d.key === 'enabled' ? { ...d, controllerProps: { ...d.controllerProps, value: !!v } } : d + d.key === 'enabled' + ? ({ ...d, controllerProps: { ...d.controllerProps, value: !!v } } as SettingComponentProps) + : d ), })) }, setMaxFileSizeMB: async (val) => { const ext = getRagExtension() if (ext?.updateSettings) { - await ext.updateSettings([{ key: 'max_file_size_mb', controllerProps: { value: val } } as any]) + await ext.updateSettings([ + { key: 'max_file_size_mb', controllerProps: { value: val } } as Partial, + ]) } set((s) => ({ maxFileSizeMB: val, settingsDefs: s.settingsDefs.map((d) => - d.key === 'max_file_size_mb' ? { ...d, controllerProps: { ...d.controllerProps, value: val } } : d + d.key === 'max_file_size_mb' + ? ({ ...d, controllerProps: { ...d.controllerProps, value: val } } as SettingComponentProps) + : d ), })) }, setRetrievalLimit: async (val) => { const ext = getRagExtension() if (ext?.updateSettings) { - await ext.updateSettings([{ key: 'retrieval_limit', controllerProps: { value: val } } as any]) + await ext.updateSettings([ + { key: 'retrieval_limit', controllerProps: { value: val } } as Partial, + ]) } set((s) => ({ retrievalLimit: val, settingsDefs: s.settingsDefs.map((d) => - d.key === 'retrieval_limit' ? { ...d, controllerProps: { ...d.controllerProps, value: val } } : d + d.key === 'retrieval_limit' + ? ({ ...d, controllerProps: { ...d.controllerProps, value: val } } as SettingComponentProps) + : d ), })) }, setRetrievalThreshold: async (val) => { const ext = getRagExtension() if (ext?.updateSettings) { - await ext.updateSettings([{ key: 'retrieval_threshold', controllerProps: { value: val } } as any]) + await ext.updateSettings([ + { key: 'retrieval_threshold', controllerProps: { value: val } } as Partial, + ]) } set((s) => ({ retrievalThreshold: val, settingsDefs: s.settingsDefs.map((d) => - d.key === 'retrieval_threshold' ? { ...d, controllerProps: { ...d.controllerProps, value: val } } : d + d.key === 'retrieval_threshold' + ? ({ ...d, controllerProps: { ...d.controllerProps, value: val } } as SettingComponentProps) + : d ), })) }, setChunkSizeTokens: async (val) => { const ext = getRagExtension() if (ext?.updateSettings) { - await ext.updateSettings([{ key: 'chunk_size_tokens', controllerProps: { value: val } } as any]) + await ext.updateSettings([ + { key: 'chunk_size_tokens', controllerProps: { value: val } } as Partial, + ]) } set((s) => ({ chunkSizeTokens: val, settingsDefs: s.settingsDefs.map((d) => - d.key === 'chunk_size_tokens' ? { ...d, controllerProps: { ...d.controllerProps, value: val } } : d + d.key === 'chunk_size_tokens' + ? ({ ...d, controllerProps: { ...d.controllerProps, value: val } } as SettingComponentProps) + : d ), })) }, setOverlapTokens: async (val) => { const ext = getRagExtension() if (ext?.updateSettings) { - await ext.updateSettings([{ key: 'overlap_tokens', controllerProps: { value: val } } as any]) + await ext.updateSettings([ + { key: 'overlap_tokens', controllerProps: { value: val } } as Partial, + ]) } set((s) => ({ overlapTokens: val, settingsDefs: s.settingsDefs.map((d) => - d.key === 'overlap_tokens' ? { ...d, controllerProps: { ...d.controllerProps, value: val } } : d + d.key === 'overlap_tokens' + ? ({ ...d, controllerProps: { ...d.controllerProps, value: val } } as SettingComponentProps) + : d ), })) }, setSearchMode: async (v) => { const ext = getRagExtension() if (ext?.updateSettings) { - await ext.updateSettings([{ key: 'search_mode', controllerProps: { value: v } } as any]) + await ext.updateSettings([ + { key: 'search_mode', controllerProps: { value: v } } as Partial, + ]) } set((s) => ({ searchMode: v, settingsDefs: s.settingsDefs.map((d) => - d.key === 'search_mode' ? { ...d, controllerProps: { ...d.controllerProps, value: v } } : d + d.key === 'search_mode' + ? ({ ...d, controllerProps: { ...d.controllerProps, value: v } } as SettingComponentProps) + : d ), })) }, @@ -139,22 +169,26 @@ export const useAttachments = create()((set) => ({ // Initialize from extension settings once on import ;(async () => { try { - const ext = getRagExtension() as any + const ext = getRagExtension() if (!ext?.getSettings) return const settings = await ext.getSettings() if (!Array.isArray(settings)) return - const map = new Map() + const map = new Map() for (const s of settings) map.set(s.key, s?.controllerProps?.value) // seed defs and values useAttachments.setState((prev) => ({ settingsDefs: settings, - enabled: map.get('enabled') ?? prev.enabled, - maxFileSizeMB: map.get('max_file_size_mb') ?? prev.maxFileSizeMB, - retrievalLimit: map.get('retrieval_limit') ?? prev.retrievalLimit, - retrievalThreshold: map.get('retrieval_threshold') ?? prev.retrievalThreshold, - chunkSizeTokens: map.get('chunk_size_tokens') ?? prev.chunkSizeTokens, - overlapTokens: map.get('overlap_tokens') ?? prev.overlapTokens, - searchMode: map.get('search_mode') ?? prev.searchMode, + enabled: (map.get('enabled') as boolean | undefined) ?? prev.enabled, + maxFileSizeMB: (map.get('max_file_size_mb') as number | undefined) ?? prev.maxFileSizeMB, + retrievalLimit: (map.get('retrieval_limit') as number | undefined) ?? prev.retrievalLimit, + retrievalThreshold: + (map.get('retrieval_threshold') as number | undefined) ?? prev.retrievalThreshold, + chunkSizeTokens: (map.get('chunk_size_tokens') as number | undefined) ?? prev.chunkSizeTokens, + overlapTokens: (map.get('overlap_tokens') as number | undefined) ?? prev.overlapTokens, + searchMode: + (map.get('search_mode') as 'auto' | 'ann' | 'linear' | undefined) ?? prev.searchMode, })) - } catch {} + } catch (e) { + console.debug('Failed to initialize attachment settings from extension:', e) + } })() diff --git a/web-app/src/hooks/useChat.ts b/web-app/src/hooks/useChat.ts index 13dd906fc..15d06f506 100644 --- a/web-app/src/hooks/useChat.ts +++ b/web-app/src/hooks/useChat.ts @@ -380,7 +380,7 @@ export const useChat = () => { // Build the user content once; use it for both the outbound request // and persisting to the store so both are identical. if (updateAttachmentProcessing) { - updateAttachmentProcessing('__CLEAR_ALL__' as any, 'clear_all') + updateAttachmentProcessing('__CLEAR_ALL__', 'clear_all') } const userContent = newUserThreadContent( activeThread.id, diff --git a/web-app/src/lib/completion.ts b/web-app/src/lib/completion.ts index 301941cf1..e602ff88e 100644 --- a/web-app/src/lib/completion.ts +++ b/web-app/src/lib/completion.ts @@ -241,7 +241,10 @@ export const sendCompletion = async ( usableTools = [...tools, ...ragTools] } } - } catch {} + } catch (e) { + // Ignore RAG tool injection errors during completion setup + console.debug('Skipping RAG tools injection:', e) + } const engine = ExtensionManager.getInstance().getEngine(provider.provider) diff --git a/web-app/src/lib/messages.ts b/web-app/src/lib/messages.ts index 61acf8bde..3361e2703 100644 --- a/web-app/src/lib/messages.ts +++ b/web-app/src/lib/messages.ts @@ -1,10 +1,11 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ import { ChatCompletionMessageParam } from 'token.js' import { ChatCompletionMessageToolCall } from 'openai/resources' -import { ThreadMessage } from '@janhq/core' +import { ThreadMessage, ContentType } from '@janhq/core' import { removeReasoningContent } from '@/utils/reasoning' // Attachments are now handled upstream in newUserThreadContent +type ThreadContent = NonNullable[number] + /** * @fileoverview Helper functions for creating chat completion request. * These functions are used to create chat completion request objects @@ -22,7 +23,14 @@ export class CompletionMessagesBuilder { this.messages.push( ...messages .filter((e) => !e.metadata?.error) - .map((msg) => this.toCompletionParamFromThread(msg)) + .map((msg) => { + const param = this.toCompletionParamFromThread(msg) + // In constructor context, normalize empty user text to a placeholder + if (param.role === 'user' && typeof param.content === 'string' && param.content === '') { + return { ...param, content: '.' } + } + return param + }) ) } @@ -45,19 +53,20 @@ export class CompletionMessagesBuilder { // User messages: handle multimodal content if (Array.isArray(msg.content) && msg.content.length > 1) { - const content = msg.content.map((part: any) => { - if (part.type === 'text') { - return { type: 'text', text: part.text?.value ?? '' } + const content = msg.content.map((part: ThreadContent) => { + if (part.type === ContentType.Text) { + return { type: 'text' as const, text: part.text?.value ?? '' } } - if (part.type === 'image_url') { + if (part.type === ContentType.Image) { return { - type: 'image_url', + type: 'image_url' as const, image_url: { url: part.image_url?.url || '', detail: part.image_url?.detail || 'auto' }, } } - return part + // Fallback for unknown content types + return { type: 'text' as const, text: '' } }) - return { role: 'user', content } as any + return { role: 'user', content } as ChatCompletionMessageParam } // Single text part const text = msg?.content?.[0]?.text?.value ?? '.' diff --git a/web-app/src/routes/settings/attachments.tsx b/web-app/src/routes/settings/attachments.tsx index 2fea5a866..342db2a70 100644 --- a/web-app/src/routes/settings/attachments.tsx +++ b/web-app/src/routes/settings/attachments.tsx @@ -1,9 +1,9 @@ import { createFileRoute } from '@tanstack/react-router' -import { route } from '@/constants/routes' import SettingsMenu from '@/containers/SettingsMenu' import HeaderPage from '@/containers/HeaderPage' import { Card, CardItem } from '@/containers/Card' import { useAttachments } from '@/hooks/useAttachments' +import type { SettingComponentProps } from '@janhq/core' import { useTranslation } from '@/i18n/react-i18next-compat' import { PlatformGuard } from '@/lib/platform/PlatformGuard' import { DynamicControllerSetting } from '@/containers/dynamicControllerSetting' @@ -11,14 +11,13 @@ import { PlatformFeature } from '@/lib/platform/types' import { useEffect, useState, useCallback, useRef } from 'react' import { useShallow } from 'zustand/react/shallow' -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export const Route = createFileRoute(route.settings.attachments as any)({ +export const Route = createFileRoute('/settings/attachments')({ component: AttachmentsSettings, }) // Helper to extract constraints from settingsDefs -function getConstraints(def: any) { - const props = def?.controllerProps || {} +function getConstraints(def: SettingComponentProps) { + const props = def.controllerProps as Partial<{ min: number; max: number; step: number }> return { min: props.min ?? -Infinity, max: props.max ?? Infinity, @@ -27,7 +26,7 @@ function getConstraints(def: any) { } // Helper to validate and clamp numeric values -function clampValue(val: any, def: any, currentValue: number): number { +function clampValue(val: unknown, def: SettingComponentProps, currentValue: number): number { const num = typeof val === 'number' ? val : Number(val) if (!Number.isFinite(num)) return currentValue const { min, max, step } = getConstraints(def) @@ -40,7 +39,7 @@ function AttachmentsSettings() { const { t } = useTranslation() const hookDefs = useAttachments((s) => s.settingsDefs) const loadDefs = useAttachments((s) => s.loadSettingsDefs) - const [defs, setDefs] = useState([]) + const [defs, setDefs] = useState([]) // Load schema from extension via the hook once useEffect(() => { @@ -73,32 +72,36 @@ function AttachmentsSettings() { ) // Local state for inputs to allow intermediate values while typing - const [localValues, setLocalValues] = useState>({}) + const [localValues, setLocalValues] = useState>({}) // Debounce timers - const timersRef = useRef>({}) + const timersRef = useRef>>({}) // Cleanup timers on unmount useEffect(() => { + const timers = timersRef.current return () => { - Object.values(timersRef.current).forEach(clearTimeout) + Object.values(timers).forEach(clearTimeout) } }, []) // Debounced setter with validation - const debouncedSet = useCallback((key: string, val: any, def: any) => { + const debouncedSet = useCallback((key: string, val: unknown, def: SettingComponentProps) => { // Clear existing timer for this key if (timersRef.current[key]) { clearTimeout(timersRef.current[key]) } // Set local value immediately for responsive UI - setLocalValues((prev) => ({ ...prev, [key]: val })) + setLocalValues((prev) => ({ + ...prev, + [key]: val as string | number | boolean | string[] + })) // For non-numeric inputs, apply immediately without debounce if (key === 'enabled' || key === 'search_mode') { if (key === 'enabled') sel.setEnabled(!!val) - else if (key === 'search_mode') sel.setSearchMode(val) + else if (key === 'search_mode') sel.setSearchMode(val as 'auto' | 'ann' | 'linear') return } @@ -136,7 +139,10 @@ function AttachmentsSettings() { } // Update local value to validated one - setLocalValues((prev) => ({ ...prev, [key]: validated })) + setLocalValues((prev) => ({ + ...prev, + [key]: validated as string | number | boolean | string[] + })) }, 500) // 500ms debounce }, [sel]) @@ -174,8 +180,30 @@ function AttachmentsSettings() { } })() - const currentValue = localValues[d.key] !== undefined ? localValues[d.key] : storeValue - const props = { ...(d.controllerProps || {}), value: currentValue } + const currentValue = + localValues[d.key] !== undefined ? localValues[d.key] : storeValue + + // Convert to DynamicControllerSetting compatible props + const baseProps = d.controllerProps + const normalizedValue: string | number | boolean = (() => { + if (Array.isArray(currentValue)) { + return currentValue.join(',') + } + return currentValue as string | number | boolean + })() + + const props = { + value: normalizedValue, + placeholder: 'placeholder' in baseProps ? baseProps.placeholder : undefined, + type: 'type' in baseProps ? baseProps.type : undefined, + options: 'options' in baseProps ? baseProps.options : undefined, + input_actions: 'inputActions' in baseProps ? baseProps.inputActions : undefined, + rows: undefined, + min: 'min' in baseProps ? baseProps.min : undefined, + max: 'max' in baseProps ? baseProps.max : undefined, + step: 'step' in baseProps ? baseProps.step : undefined, + recommended: 'recommended' in baseProps ? baseProps.recommended : undefined, + } const title = d.titleKey ? t(d.titleKey) : d.title const description = d.descriptionKey ? t(d.descriptionKey) : d.description @@ -188,7 +216,7 @@ function AttachmentsSettings() { actions={ debouncedSet(d.key, val, d)} /> } diff --git a/web-app/src/services/rag/default.ts b/web-app/src/services/rag/default.ts index cfaae5abc..f4535c4fd 100644 --- a/web-app/src/services/rag/default.ts +++ b/web-app/src/services/rag/default.ts @@ -16,14 +16,15 @@ export class DefaultRAGService implements RAGService { return [] } - async callTool(args: { toolName: string; arguments: object; threadId?: string }): Promise { + async callTool(args: { toolName: string; arguments: Record; threadId?: string }): Promise { const ext = ExtensionManager.getInstance().get(ExtensionTypeEnum.RAG) if (!ext?.callTool) { return { error: 'RAG extension not available', content: [{ type: 'text', text: 'RAG extension not available' }] } } try { // Inject thread context when scope requires it - const a: any = { ...(args.arguments as any) } + type ToolCallArgs = Record & { scope?: string; thread_id?: string } + const a: ToolCallArgs = { ...(args.arguments as Record) } if (!a.scope) a.scope = 'thread' if (a.scope === 'thread' && !a.thread_id) { a.thread_id = args.threadId From a2fbce698f39cf684cea25c6c51fcfcad28c30c9 Mon Sep 17 00:00:00 2001 From: Dinh Long Nguyen Date: Thu, 9 Oct 2025 04:41:18 +0700 Subject: [PATCH 79/97] fix thread scrolling --- web-app/src/hooks/useThreadScrolling.tsx | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/web-app/src/hooks/useThreadScrolling.tsx b/web-app/src/hooks/useThreadScrolling.tsx index bdc4df9b1..41362db61 100644 --- a/web-app/src/hooks/useThreadScrolling.tsx +++ b/web-app/src/hooks/useThreadScrolling.tsx @@ -54,6 +54,7 @@ export const useThreadScrolling = ( } }, [scrollContainerRef]) + const handleScroll = useCallback((e: Event) => { const target = e.target as HTMLDivElement const { scrollTop, scrollHeight, clientHeight } = target @@ -68,7 +69,7 @@ export const useThreadScrolling = ( setIsAtBottom(isBottom) setHasScrollbar(hasScroll) lastScrollTopRef.current = scrollTop - }, [streamingContent, setIsAtBottom, setHasScrollbar]) + }, [streamingContent]) useEffect(() => { const scrollContainer = scrollContainerRef.current @@ -77,7 +78,7 @@ export const useThreadScrolling = ( return () => scrollContainer.removeEventListener('scroll', handleScroll) } - }, [handleScroll, scrollContainerRef]) + }, [handleScroll]) const checkScrollState = useCallback(() => { const scrollContainer = scrollContainerRef.current @@ -89,7 +90,7 @@ export const useThreadScrolling = ( setIsAtBottom(isBottom) setHasScrollbar(hasScroll) - }, [scrollContainerRef, setIsAtBottom, setHasScrollbar]) + }, []) useEffect(() => { if (!scrollContainerRef.current) return @@ -100,7 +101,7 @@ export const useThreadScrolling = ( scrollToBottom(false) checkScrollState() } - }, [checkScrollState, scrollToBottom, scrollContainerRef]) + }, [checkScrollState, scrollToBottom]) const prevCountRef = useRef(messageCount) @@ -145,7 +146,7 @@ export const useThreadScrolling = ( } prevCountRef.current = messageCount - }, [messageCount, lastMessageRole, getDOMElements, setPaddingHeight]) + }, [messageCount, lastMessageRole]) useEffect(() => { const previouslyStreaming = wasStreamingRef.current @@ -196,7 +197,7 @@ export const useThreadScrolling = ( } wasStreamingRef.current = currentlyStreaming - }, [streamingContent, threadId, getDOMElements, setPaddingHeight]) + }, [streamingContent, threadId]) useEffect(() => { userIntendedPositionRef.current = null @@ -206,7 +207,7 @@ export const useThreadScrolling = ( prevCountRef.current = messageCount scrollToBottom(false) checkScrollState() - }, [threadId, messageCount, scrollToBottom, checkScrollState, setPaddingHeight]) + }, [threadId]) return useMemo( () => ({ From f4066e6e5a79e9aff4105a8870c0d9eb8ea78830 Mon Sep 17 00:00:00 2001 From: Dinh Long Nguyen Date: Thu, 9 Oct 2025 04:50:31 +0700 Subject: [PATCH 80/97] Update web-app/src/lib/fileMetadata.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- web-app/src/lib/fileMetadata.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/web-app/src/lib/fileMetadata.ts b/web-app/src/lib/fileMetadata.ts index 21067c56e..ce5eb3de4 100644 --- a/web-app/src/lib/fileMetadata.ts +++ b/web-app/src/lib/fileMetadata.ts @@ -84,7 +84,17 @@ export function extractFilesFromPrompt(prompt: string): { const type = map['type'] const size = map['size'] ? Number(map['size']) : undefined const chunkCount = map['chunks'] ? Number(map['chunks']) : undefined - files.push({ id, name, ...(type && { type }), ...(typeof size === 'number' && !Number.isNaN(size) ? { size } : {}), ...(typeof chunkCount === 'number' && !Number.isNaN(chunkCount) ? { chunkCount } : {}) }) + const fileObj: FileMetadata = { id, name }; + if (type) { + fileObj.type = type; + } + if (typeof size === 'number' && !Number.isNaN(size)) { + fileObj.size = size; + } + if (typeof chunkCount === 'number' && !Number.isNaN(chunkCount)) { + fileObj.chunkCount = chunkCount; + } + files.push(fileObj); } // Extract clean prompt (everything before [ATTACHED_FILES]) From 45d57dd34d0b1ac36f488d043224e7d81eca25a3 Mon Sep 17 00:00:00 2001 From: Dinh Long Nguyen Date: Thu, 9 Oct 2025 04:53:19 +0700 Subject: [PATCH 81/97] Update web-app/src/services/uploads/default.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- web-app/src/services/uploads/default.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web-app/src/services/uploads/default.ts b/web-app/src/services/uploads/default.ts index 0b26bd274..d1a9b2d3b 100644 --- a/web-app/src/services/uploads/default.ts +++ b/web-app/src/services/uploads/default.ts @@ -1,5 +1,5 @@ import type { UploadsService, UploadResult } from './types' -import type { Attachment, } from '@/types/attachment' +import type { Attachment } from '@/types/attachment' import { ulid } from 'ulidx' import { ExtensionManager } from '@/lib/extension' import { ExtensionTypeEnum, type RAGExtension, type IngestAttachmentsResult } from '@janhq/core' From 01050f3103acb5bf15e41909628504993400b31f Mon Sep 17 00:00:00 2001 From: Akarshan Biswas Date: Thu, 9 Oct 2025 07:21:53 +0530 Subject: [PATCH 82/97] fix: Gracefully handle offline mode during backend check (#6767) The `listSupportedBackends` function now includes error handling for the `fetchRemoteSupportedBackends` call. This addresses an issue where an error thrown during the remote fetch (e.g., due to no network connection in offline mode) would prevent the subsequent loading of locally installed or manually provided llama.cpp backends. The remote backend versions array will now default to empty if the fetch fails, allowing the rest of the backend initialization process to proceed as expected. --- extensions/llamacpp-extension/src/backend.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/extensions/llamacpp-extension/src/backend.ts b/extensions/llamacpp-extension/src/backend.ts index a313e01c6..bd0543227 100644 --- a/extensions/llamacpp-extension/src/backend.ts +++ b/extensions/llamacpp-extension/src/backend.ts @@ -156,8 +156,13 @@ export async function listSupportedBackends(): Promise< supportedBackends.push('macos-arm64') } // get latest backends from Github - const remoteBackendVersions = + let remoteBackendVersions = [] + try { + remoteBackendVersions = await fetchRemoteSupportedBackends(supportedBackends) + } catch (e) { + console.debug(`Not able to get remote backends, Jan might be offline or network problem: ${String(e)}`) + } // Get locally installed versions const localBackendVersions = await getLocalInstalledBackends() From c096929d8bf707c8aaca1a02da66e6fb2317e838 Mon Sep 17 00:00:00 2001 From: Roushan Kumar Singh <158602016+github-roushan@users.noreply.github.com> Date: Thu, 9 Oct 2025 22:03:07 +0530 Subject: [PATCH 83/97] fix(amd/linux): show dedicated VRAM on device list (override Vulkan UMA) (#6533) --- extensions/llamacpp-extension/src/index.ts | 63 ++++++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/extensions/llamacpp-extension/src/index.ts b/extensions/llamacpp-extension/src/index.ts index f1a750138..d5d13804f 100644 --- a/extensions/llamacpp-extension/src/index.ts +++ b/extensions/llamacpp-extension/src/index.ts @@ -2012,6 +2012,69 @@ export default class llamacpp_extension extends AIEngine { libraryPath, envs, }) + // On Linux with AMD GPUs, llama.cpp via Vulkan may report UMA (shared) memory as device-local. + // For clearer UX, override with dedicated VRAM from the hardware plugin when available. + try { + const sysInfo = await getSystemInfo() + if (sysInfo?.os_type === 'linux' && Array.isArray(sysInfo.gpus)) { + const usage = await getSystemUsage() + if (usage && Array.isArray(usage.gpus)) { + const uuidToUsage: Record = {} + for (const u of usage.gpus as any[]) { + if (u && typeof u.uuid === 'string') { + uuidToUsage[u.uuid] = u + } + } + + const indexToAmdUuid = new Map() + for (const gpu of sysInfo.gpus as any[]) { + const vendorStr = + typeof gpu?.vendor === 'string' + ? gpu.vendor + : typeof gpu?.vendor === 'object' && gpu.vendor !== null + ? String(gpu.vendor) + : '' + if ( + vendorStr.toUpperCase().includes('AMD') && + gpu?.vulkan_info && + typeof gpu.vulkan_info.index === 'number' && + typeof gpu.uuid === 'string' + ) { + indexToAmdUuid.set(gpu.vulkan_info.index, gpu.uuid) + } + } + + if (indexToAmdUuid.size > 0) { + const adjusted = dList.map((dev) => { + if (dev.id?.startsWith('Vulkan')) { + const match = /^Vulkan(\d+)/.exec(dev.id) + if (match) { + const vIdx = Number(match[1]) + const uuid = indexToAmdUuid.get(vIdx) + if (uuid) { + const u = uuidToUsage[uuid] + if ( + u && + typeof u.total_memory === 'number' && + typeof u.used_memory === 'number' + ) { + const total = Math.max(0, Math.floor(u.total_memory)) + const free = Math.max(0, Math.floor(u.total_memory - u.used_memory)) + return { ...dev, mem: total, free } + } + } + } + } + return dev + }) + return adjusted + } + } + } + } catch (e) { + logger.warn('Device memory override (AMD/Linux) failed:', e) + } + return dList } catch (error) { logger.error('Failed to query devices:\n', error) From 31f9501d8e2534f3e51f21c5f1b1e73442e51209 Mon Sep 17 00:00:00 2001 From: Akarshan Date: Fri, 10 Oct 2025 20:25:17 +0530 Subject: [PATCH 84/97] feat: Optimize state updates in server and model checks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Added shallow equality guard for `connectedServers` state to prevent redundant updates when the fetched server list hasn't changed. - Updated error handling for server fetch to only clear the state when it actually contains data. - Introduced `newHasActiveModels` variable and conditional updater for `hasActiveModels` to avoid unnecessary state changes. - Adjusted error handling for active model fetch to only set `hasActiveModels` to `false` when the current state differs. These changes reduce needless re‑renders and improve component performance. --- web-app/src/containers/ChatInput.tsx | 94 +++++++++++++++++++++------- 1 file changed, 72 insertions(+), 22 deletions(-) diff --git a/web-app/src/containers/ChatInput.tsx b/web-app/src/containers/ChatInput.tsx index b736845d5..b06c803eb 100644 --- a/web-app/src/containers/ChatInput.tsx +++ b/web-app/src/containers/ChatInput.tsx @@ -50,7 +50,11 @@ import { PlatformFeatures } from '@/lib/platform/const' import { PlatformFeature } from '@/lib/platform/types' import { useAnalytic } from '@/hooks/useAnalytic' import posthog from 'posthog-js' -import { Attachment, createImageAttachment, createDocumentAttachment } from '@/types/attachment' +import { + Attachment, + createImageAttachment, + createDocumentAttachment, +} from '@/types/attachment' type ChatInputProps = { className?: string @@ -123,10 +127,19 @@ const ChatInput = ({ const checkConnectedServers = async () => { try { const servers = await serviceHub.mcp().getConnectedServers() - setConnectedServers(servers) + // Only update state if the servers list has actually changed + setConnectedServers((prev) => { + if (JSON.stringify(prev) === JSON.stringify(servers)) { + return prev + } + return servers + }) } catch (error) { console.error('Failed to get connected servers:', error) - setConnectedServers([]) + setConnectedServers((prev) => { + if (prev.length === 0) return prev + return [] + }) } } @@ -148,10 +161,22 @@ const ChatInput = ({ const hasMatchingActiveModel = activeModels.some( (model) => String(model) === selectedModel?.id ) - setHasActiveModels(activeModels.length > 0 && hasMatchingActiveModel) + const newHasActiveModels = + activeModels.length > 0 && hasMatchingActiveModel + + // Only update state if the value has actually changed + setHasActiveModels((prev) => { + if (prev === newHasActiveModels) { + return prev + } + return newHasActiveModels + }) } catch (error) { console.error('Failed to get active models:', error) - setHasActiveModels(false) + setHasActiveModels((prev) => { + if (prev === false) return prev + return false + }) } } @@ -327,7 +352,19 @@ const ChatInput = ({ filters: [ { name: 'Documents', - extensions: ['pdf', 'docx', 'txt', 'md', 'csv', 'xlsx', 'xls', 'ods', 'pptx', 'html', 'htm'], + extensions: [ + 'pdf', + 'docx', + 'txt', + 'md', + 'csv', + 'xlsx', + 'xls', + 'ods', + 'pptx', + 'html', + 'htm', + ], }, ], }) @@ -437,7 +474,9 @@ const ChatInput = ({ console.error('Failed to ingest document:', error) // Remove failed document setAttachments((prev) => - prev.filter((a) => !(a.path === doc.path && a.type === 'document')) + prev.filter( + (a) => !(a.path === doc.path && a.type === 'document') + ) ) toast.error(`Failed to ingest ${doc.name}`, { description: @@ -491,9 +530,7 @@ const ChatInput = ({ const newFiles: Attachment[] = [] const duplicates: string[] = [] const existingImageNames = new Set( - attachments - .filter((a) => a.type === 'image') - .map((a) => a.name) + attachments.filter((a) => a.type === 'image').map((a) => a.name) ) Array.from(files).forEach((file) => { @@ -503,7 +540,6 @@ const ChatInput = ({ return } - // Check file size if (file.size > maxSize) { setMessage(`File is too large. Maximum size is 10MB.`) @@ -577,10 +613,9 @@ const ChatInput = ({ ) ) - const result = await serviceHub.uploads().ingestImage( - currentThreadId, - img - ) + const result = await serviceHub + .uploads() + .ingestImage(currentThreadId, img) if (result?.id) { // Mark as processed with ID @@ -609,7 +644,9 @@ const ChatInput = ({ ) toast.error(`Failed to ingest ${img.name}`, { description: - error instanceof Error ? error.message : String(error), + error instanceof Error + ? error.message + : String(error), }) } } @@ -844,7 +881,10 @@ const ChatInput = ({ const isImage = att.type === 'image' const ext = att.fileType || att.mimeType?.split('/')[1] return ( -
+
@@ -886,7 +926,10 @@ const ChatInput = ({ {att.processed && !att.processing && (
- +
)} @@ -894,14 +937,21 @@ const ChatInput = ({
-
+
{att.name}
{isImage - ? (att.mimeType || 'image') - : (ext ? `.${ext}` : 'document')} - {att.size ? ` · ${formatBytes(att.size)}` : ''} + ? att.mimeType || 'image' + : ext + ? `.${ext}` + : 'document'} + {att.size + ? ` · ${formatBytes(att.size)}` + : ''}
From 584daa96829137c4f5003f657cb415c6556e5574 Mon Sep 17 00:00:00 2001 From: Faisal Amir Date: Sat, 11 Oct 2025 21:46:15 +0700 Subject: [PATCH 85/97] chore: revert track event posthog --- web-app/src/containers/ChatInput.tsx | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/web-app/src/containers/ChatInput.tsx b/web-app/src/containers/ChatInput.tsx index 3d70e93f0..1cf20fdd2 100644 --- a/web-app/src/containers/ChatInput.tsx +++ b/web-app/src/containers/ChatInput.tsx @@ -40,8 +40,6 @@ import { useShallow } from 'zustand/react/shallow' import { McpExtensionToolLoader } from './McpExtensionToolLoader' import { ExtensionTypeEnum, MCPExtension } from '@janhq/core' import { ExtensionManager } from '@/lib/extension' -import { useAnalytic } from '@/hooks/useAnalytic' -import posthog from 'posthog-js' type ChatInputProps = { className?: string @@ -90,7 +88,6 @@ const ChatInput = ({ const selectedModel = useModelProvider((state) => state.selectedModel) const selectedProvider = useModelProvider((state) => state.selectedProvider) const sendMessage = useChat() - const { productAnalytic } = useAnalytic() const [message, setMessage] = useState('') const [dropdownToolsAvailable, setDropdownToolsAvailable] = useState(false) const [tooltipToolsAvailable, setTooltipToolsAvailable] = useState(false) @@ -192,18 +189,6 @@ const ChatInput = ({ } setMessage('') - // Track message send event with PostHog (only if product analytics is enabled) - if (productAnalytic && selectedModel && selectedProvider) { - try { - posthog.capture('message_sent', { - model_provider: selectedProvider, - model_id: selectedModel.id, - }) - } catch (error) { - console.debug('Failed to track message send event:', error) - } - } - sendMessage( prompt, true, From 176ad07f1d279923b76044b3d6899dd76ae29eb2 Mon Sep 17 00:00:00 2001 From: Minh141120 Date: Tue, 14 Oct 2025 13:54:43 +0700 Subject: [PATCH 86/97] docs: update jan server url --- docs/src/pages/docs/desktop/index.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/src/pages/docs/desktop/index.mdx b/docs/src/pages/docs/desktop/index.mdx index 852f097a5..3c225abb3 100644 --- a/docs/src/pages/docs/desktop/index.mdx +++ b/docs/src/pages/docs/desktop/index.mdx @@ -41,7 +41,7 @@ Jan is an open-source replacement for ChatGPT: Jan is a full [product suite](https://en.wikipedia.org/wiki/Software_suite) that offers an alternative to Big AI: - [Jan Desktop](/docs/desktop/quickstart): macOS, Windows, and Linux apps with offline mode -- [Jan Web](https://chat.jan.ai): Jan on browser, a direct alternative to chatgpt.com +- [Jan Web](https://chat.menlo.ai): Jan on browser, a direct alternative to chatgpt.com - Jan Mobile: iOS and Android apps (Coming Soon) - [Jan Server](/docs/server): deploy locally, in your cloud, or on-prem - [Jan Models](/docs/models): Open-source models optimized for deep research, tool use, and reasoning From 476fdd604037f2e3fd207d75c16722f0496a2d51 Mon Sep 17 00:00:00 2001 From: Trang Le Date: Tue, 14 Oct 2025 14:04:52 +0700 Subject: [PATCH 87/97] feat: Enable new prompt input while waiting for an answer (#6676) * enable new prompt input while waiting for an answer * correct spelling of handleSendMessage function * remove test for disabling input while streaming content --- web-app/src/containers/ChatInput.tsx | 17 ++++++++--------- .../src/containers/__tests__/ChatInput.test.tsx | 12 ------------ 2 files changed, 8 insertions(+), 21 deletions(-) diff --git a/web-app/src/containers/ChatInput.tsx b/web-app/src/containers/ChatInput.tsx index 1cf20fdd2..2ba52c2e9 100644 --- a/web-app/src/containers/ChatInput.tsx +++ b/web-app/src/containers/ChatInput.tsx @@ -594,7 +594,6 @@ const ChatInput = ({ )} { }) }) - it('disables input when streaming', async () => { - // Mock streaming state - mockAppState.streamingContent = { thread_id: 'test-thread' } - - await act(async () => { - renderWithRouter() - }) - - const textarea = screen.getByTestId('chat-input') - expect(textarea).toBeDisabled() - }) - it('shows tools dropdown when model supports tools and MCP servers are connected', async () => { // Mock connected servers mockGetConnectedServers.mockResolvedValue(['server1']) From 946b347f44232a189773a27981bc2654e3381500 Mon Sep 17 00:00:00 2001 From: dinhlongviolin1 Date: Wed, 15 Oct 2025 00:21:10 +0700 Subject: [PATCH 88/97] fix: lint --- web-app/src/containers/ChatInput.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/web-app/src/containers/ChatInput.tsx b/web-app/src/containers/ChatInput.tsx index 671de1267..0647bb0a8 100644 --- a/web-app/src/containers/ChatInput.tsx +++ b/web-app/src/containers/ChatInput.tsx @@ -48,7 +48,6 @@ import { open } from '@tauri-apps/plugin-dialog' import { toast } from 'sonner' import { PlatformFeatures } from '@/lib/platform/const' import { PlatformFeature } from '@/lib/platform/types' -import { useAnalytic } from '@/hooks/useAnalytic' import posthog from 'posthog-js' import { Attachment, From 462b05e612ded3fb17b3f2c1bbae6b5fa968c4e5 Mon Sep 17 00:00:00 2001 From: Faisal Amir Date: Wed, 15 Oct 2025 10:35:36 +0700 Subject: [PATCH 89/97] chore: fix conflict revert analytic --- web-app/src/containers/ChatInput.tsx | 14 +------------- 1 file changed, 1 insertion(+), 13 deletions(-) diff --git a/web-app/src/containers/ChatInput.tsx b/web-app/src/containers/ChatInput.tsx index 0647bb0a8..744ff5bab 100644 --- a/web-app/src/containers/ChatInput.tsx +++ b/web-app/src/containers/ChatInput.tsx @@ -48,7 +48,7 @@ import { open } from '@tauri-apps/plugin-dialog' import { toast } from 'sonner' import { PlatformFeatures } from '@/lib/platform/const' import { PlatformFeature } from '@/lib/platform/types' -import posthog from 'posthog-js' + import { Attachment, createImageAttachment, @@ -226,18 +226,6 @@ const ChatInput = ({ setMessage('') - // Track message send event with PostHog (only if product analytics is enabled) - if (productAnalytic && selectedModel && selectedProvider) { - try { - posthog.capture('message_sent', { - model_provider: selectedProvider, - model_id: selectedModel.id, - }) - } catch (error) { - console.debug('Failed to track message send event:', error) - } - } - // Callback to update attachment processing state const updateAttachmentProcessing = ( fileName: string, From f0ca9cce3540fe00fb80ec4e7437caf0813deb66 Mon Sep 17 00:00:00 2001 From: Minh141120 Date: Wed, 15 Oct 2025 14:43:58 +0700 Subject: [PATCH 90/97] chore: update happy-dom version --- core/package.json | 2 +- yarn.lock | 47 ++++++++++++++++++++++++++++------------------- 2 files changed, 29 insertions(+), 20 deletions(-) diff --git a/core/package.json b/core/package.json index 203eaf293..df9302210 100644 --- a/core/package.json +++ b/core/package.json @@ -31,7 +31,7 @@ "@vitest/coverage-v8": "^2.1.8", "@vitest/ui": "^2.1.8", "eslint": "8.57.0", - "happy-dom": "^15.11.6", + "happy-dom": "^20.0.0", "pacote": "^21.0.0", "react": "19.0.0", "request": "^2.88.2", diff --git a/yarn.lock b/yarn.lock index c167e87f0..55abe85e8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3491,7 +3491,7 @@ __metadata: "@vitest/coverage-v8": "npm:^2.1.8" "@vitest/ui": "npm:^2.1.8" eslint: "npm:8.57.0" - happy-dom: "npm:^15.11.6" + happy-dom: "npm:^20.0.0" pacote: "npm:^21.0.0" react: "npm:19.0.0" request: "npm:^2.88.2" @@ -3604,7 +3604,7 @@ __metadata: sonner: "npm:2.0.5" tailwind-merge: "npm:3.3.1" tailwindcss: "npm:4.1.4" - token.js: "npm:token.js-fork@0.7.27" + token.js: "npm:token.js-fork@0.7.29" tw-animate-css: "npm:1.2.8" typescript: "npm:5.9.2" typescript-eslint: "npm:8.31.0" @@ -7811,6 +7811,15 @@ __metadata: languageName: node linkType: hard +"@types/node@npm:^20.0.0": + version: 20.19.21 + resolution: "@types/node@npm:20.19.21" + dependencies: + undici-types: "npm:~6.21.0" + checksum: 10c0/a5bedcc71b363abfa425c01e69696820736614dfba03d2363df6d306d2ee6f7619b370c72b4ec75d992e5d76eaccdcdeb362276cac1cd01756db6dec2eb8227c + languageName: node + linkType: hard + "@types/node@npm:^22.10.0": version: 22.18.3 resolution: "@types/node@npm:22.18.3" @@ -7896,6 +7905,13 @@ __metadata: languageName: node linkType: hard +"@types/whatwg-mimetype@npm:^3.0.2": + version: 3.0.2 + resolution: "@types/whatwg-mimetype@npm:3.0.2" + checksum: 10c0/dad39d1e4abe760a0a963c84bbdbd26b1df0eb68aff83bdf6ecbb50ad781ead777f6906d19a87007790b750f7500a12e5624d31fc6a1529d14bd19b5c3a316d1 + languageName: node + linkType: hard + "@typescript-eslint/eslint-plugin@npm:8.31.0": version: 8.31.0 resolution: "@typescript-eslint/eslint-plugin@npm:8.31.0" @@ -10527,13 +10543,6 @@ __metadata: languageName: node linkType: hard -"entities@npm:^4.5.0": - version: 4.5.0 - resolution: "entities@npm:4.5.0" - checksum: 10c0/5b039739f7621f5d1ad996715e53d964035f75ad3b9a4d38c6b3804bb226e282ffeae2443624d8fdd9c47d8e926ae9ac009c54671243f0c3294c26af7cc85250 - languageName: node - linkType: hard - "entities@npm:^6.0.0": version: 6.0.1 resolution: "entities@npm:6.0.1" @@ -12257,14 +12266,14 @@ __metadata: languageName: node linkType: hard -"happy-dom@npm:^15.11.6": - version: 15.11.7 - resolution: "happy-dom@npm:15.11.7" +"happy-dom@npm:^20.0.0": + version: 20.0.1 + resolution: "happy-dom@npm:20.0.1" dependencies: - entities: "npm:^4.5.0" - webidl-conversions: "npm:^7.0.0" + "@types/node": "npm:^20.0.0" + "@types/whatwg-mimetype": "npm:^3.0.2" whatwg-mimetype: "npm:^3.0.0" - checksum: 10c0/22b08cac20192b08edf2e9c857ceeda8333a3301c4b5965a9550787b00db60d6d107c726390bd45a35305cd12ab086abd656bf957a408be0fcdc9fcd389f1973 + checksum: 10c0/fb867fcca270ebb185b6f2031721d3ea43c99e0699069187dceee99b14683baca243157feed2ce0da3ba8905b914262caa7bc8403384175a0ad2c81e19bf2f5a languageName: node linkType: hard @@ -19473,9 +19482,9 @@ __metadata: languageName: node linkType: hard -"token.js@npm:token.js-fork@0.7.27": - version: 0.7.27 - resolution: "token.js-fork@npm:0.7.27" +"token.js@npm:token.js-fork@0.7.29": + version: 0.7.29 + resolution: "token.js-fork@npm:0.7.29" dependencies: "@anthropic-ai/sdk": "npm:0.24.3" "@aws-sdk/client-bedrock-runtime": "npm:3.609.0" @@ -19486,7 +19495,7 @@ __metadata: mime-types: "npm:^2.1.35" nanoid: "npm:^5.0.7" openai: "npm:4.91.1" - checksum: 10c0/ec4e8e441b6747db29eed0d21e364eaf8d4636e3d8376bdd63d836499970de15357e8c0b2ef1e470027e7a2c8bc4924138a86f6d207469b6f0b6fb0f24f6d035 + checksum: 10c0/b045de56e06a1066b1fdfcca24bc57e7b10aa6cd1995b9ded27af699afcf0e72e216c3672cc3a85b10ce5b6ea81e7d1d453859f073861176b0c816e8f91e6627 languageName: node linkType: hard From 9bc56f6e30ffe488ec2d0f4d7179b6d47b4661fb Mon Sep 17 00:00:00 2001 From: Minh141120 Date: Wed, 15 Oct 2025 15:15:38 +0700 Subject: [PATCH 91/97] chore: remove redudant deps in yarn lock file --- yarn.lock | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/yarn.lock b/yarn.lock index 55abe85e8..0929ec7d2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7811,15 +7811,6 @@ __metadata: languageName: node linkType: hard -"@types/node@npm:^20.0.0": - version: 20.19.21 - resolution: "@types/node@npm:20.19.21" - dependencies: - undici-types: "npm:~6.21.0" - checksum: 10c0/a5bedcc71b363abfa425c01e69696820736614dfba03d2363df6d306d2ee6f7619b370c72b4ec75d992e5d76eaccdcdeb362276cac1cd01756db6dec2eb8227c - languageName: node - linkType: hard - "@types/node@npm:^22.10.0": version: 22.18.3 resolution: "@types/node@npm:22.18.3" @@ -7905,13 +7896,6 @@ __metadata: languageName: node linkType: hard -"@types/whatwg-mimetype@npm:^3.0.2": - version: 3.0.2 - resolution: "@types/whatwg-mimetype@npm:3.0.2" - checksum: 10c0/dad39d1e4abe760a0a963c84bbdbd26b1df0eb68aff83bdf6ecbb50ad781ead777f6906d19a87007790b750f7500a12e5624d31fc6a1529d14bd19b5c3a316d1 - languageName: node - linkType: hard - "@typescript-eslint/eslint-plugin@npm:8.31.0": version: 8.31.0 resolution: "@typescript-eslint/eslint-plugin@npm:8.31.0" From 4dee0a4ba1d5b18b4016c1222831b831a0f90e98 Mon Sep 17 00:00:00 2001 From: Minh141120 Date: Thu, 16 Oct 2025 13:08:09 +0700 Subject: [PATCH 92/97] docs: update changelog for Jan v0.7.2 --- .../2025-10-16-jan-security-update.mdx | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 docs/src/pages/changelog/2025-10-16-jan-security-update.mdx diff --git a/docs/src/pages/changelog/2025-10-16-jan-security-update.mdx b/docs/src/pages/changelog/2025-10-16-jan-security-update.mdx new file mode 100644 index 000000000..3437cb66a --- /dev/null +++ b/docs/src/pages/changelog/2025-10-16-jan-security-update.mdx @@ -0,0 +1,25 @@ +--- +title: "Jan v0.7.2: Security Update" +version: 0.7.2 +description: "Jan v0.7.2 updates the happy-dom dependency to v20.0.0 to address a recently disclosed sandbox vulnerability." +date: 2025-10-16 +--- + +import ChangelogHeader from "@/components/Changelog/ChangelogHeader" +import { Callout } from 'nextra/components' + + + +## Jan v0.7.2: Security Update (happy-dom v20) + +This release focuses on **security and stability improvements**. +It updates the `happy-dom` dependency to the latest version to address a recently disclosed vulnerability. + +### Security Fix +- Updated `happy-dom` to **^20.0.0**, preventing untrusted JavaScript executed within HAPPY DOM from accessing process-level functions and executing arbitrary code outside the intended sandbox. + +--- + +Update your Jan or [download the latest version](https://jan.ai/). + +For the complete list of changes, see the [GitHub release notes](https://github.com/janhq/jan/releases/tag/v0.7.2). From 147cab94a881cae9a9a28c9384882cc39632fc2b Mon Sep 17 00:00:00 2001 From: Akarshan Biswas Date: Thu, 16 Oct 2025 12:15:24 +0530 Subject: [PATCH 93/97] fix: Escape dollar signs followed by numbers in Markdown (#6797) This commit introduces a change to prevent **Markdown** rendering issues where a dollar sign followed by a number (like **`$1`**) is incorrectly interpreted as **LaTeX** by the rendering engine. --- The `normalizeLatex` function in `RenderMarkdown.tsx` now explicitly escapes these sequences (e.g., **`$1`** becomes **`\$1`**), ensuring they are displayed literally instead of being processed as mathematical expressions. This improves the fidelity of text that might contain currency or similar numerical notations. --- web-app/src/containers/RenderMarkdown.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/web-app/src/containers/RenderMarkdown.tsx b/web-app/src/containers/RenderMarkdown.tsx index c941b512d..3225e5b72 100644 --- a/web-app/src/containers/RenderMarkdown.tsx +++ b/web-app/src/containers/RenderMarkdown.tsx @@ -62,6 +62,10 @@ const normalizeLatex = (input: string): string => { (_, pre, inner) => `${pre}$${inner.trim()}$` ) + // --- Escape $ to prevent Markdown from treating it as LaTeX + // Example: "$1" → "\$1" + s = s.replace(/\$(\d+)/g, '\\$$1') + return s }) .join('') From e46200868e9dc0966f96ecf363d257817a51b7c4 Mon Sep 17 00:00:00 2001 From: Dinh Long Nguyen Date: Fri, 24 Oct 2025 01:31:21 +0700 Subject: [PATCH 94/97] web: update model capabilites (#6814) * update model capabilites * refactor + remove projects --- extensions-web/src/jan-provider-web/api.ts | 140 +++++++++++++++--- extensions-web/src/jan-provider-web/const.ts | 7 + .../src/jan-provider-web/helpers.ts | 122 +++++++++++++++ .../src/jan-provider-web/provider.ts | 68 ++------- extensions-web/src/jan-provider-web/store.ts | 3 + extensions-web/src/shared/auth/service.ts | 26 +++- extensions-web/src/shared/auth/types.ts | 3 +- web-app/src/containers/ChatInput.tsx | 3 +- web-app/src/containers/LeftPanel.tsx | 48 +++--- web-app/src/containers/ThreadList.tsx | 77 ++++++---- .../containers/__tests__/ChatInput.test.tsx | 3 + ...DropdownModelProvider.displayName.test.tsx | 1 + .../containers/__tests__/LeftPanel.test.tsx | 1 + .../__tests__/SettingsMenu.test.tsx | 1 + web-app/src/lib/platform/const.ts | 4 + web-app/src/lib/platform/types.ts | 2 + web-app/src/routes/project/$projectId.tsx | 9 ++ web-app/src/routes/project/index.tsx | 8 +- web-app/src/test/setup.ts | 2 + 19 files changed, 396 insertions(+), 132 deletions(-) create mode 100644 extensions-web/src/jan-provider-web/const.ts create mode 100644 extensions-web/src/jan-provider-web/helpers.ts diff --git a/extensions-web/src/jan-provider-web/api.ts b/extensions-web/src/jan-provider-web/api.ts index 97a9608f2..1d15c31b9 100644 --- a/extensions-web/src/jan-provider-web/api.ts +++ b/extensions-web/src/jan-provider-web/api.ts @@ -4,8 +4,9 @@ */ import { getSharedAuthService, JanAuthService } from '../shared' -import { JanModel, janProviderStore } from './store' import { ApiError } from '../shared/types/errors' +import { JAN_API_ROUTES } from './const' +import { JanModel, janProviderStore } from './store' // JAN_API_BASE is defined in vite.config.ts @@ -19,12 +20,7 @@ const TEMPORARY_CHAT_ID = 'temporary-chat' */ function getChatCompletionConfig(request: JanChatCompletionRequest, stream: boolean = false) { const isTemporaryChat = request.conversation_id === TEMPORARY_CHAT_ID - - // For temporary chats, use the stateless /chat/completions endpoint - // For regular conversations, use the stateful /conv/chat/completions endpoint - const endpoint = isTemporaryChat - ? `${JAN_API_BASE}/chat/completions` - : `${JAN_API_BASE}/conv/chat/completions` + const endpoint = `${JAN_API_BASE}${JAN_API_ROUTES.CHAT_COMPLETIONS}` const payload = { ...request, @@ -44,9 +40,30 @@ function getChatCompletionConfig(request: JanChatCompletionRequest, stream: bool return { endpoint, payload, isTemporaryChat } } -export interface JanModelsResponse { +interface JanModelSummary { + id: string object: string - data: JanModel[] + owned_by: string + created?: number +} + +interface JanModelsResponse { + object: string + data: JanModelSummary[] +} + +interface JanModelCatalogResponse { + id: string + supported_parameters?: { + names?: string[] + default?: Record + } + extras?: { + supported_parameters?: string[] + default_parameters?: Record + [key: string]: unknown + } + [key: string]: unknown } export interface JanChatMessage { @@ -112,6 +129,8 @@ export interface JanChatCompletionChunk { export class JanApiClient { private static instance: JanApiClient private authService: JanAuthService + private modelsCache: JanModel[] | null = null + private modelsFetchPromise: Promise | null = null private constructor() { this.authService = getSharedAuthService() @@ -124,25 +143,64 @@ export class JanApiClient { return JanApiClient.instance } - async getModels(): Promise { + async getModels(options?: { forceRefresh?: boolean }): Promise { try { + const forceRefresh = options?.forceRefresh ?? false + + if (forceRefresh) { + this.modelsCache = null + } else if (this.modelsCache) { + return this.modelsCache + } + + if (this.modelsFetchPromise) { + return this.modelsFetchPromise + } + janProviderStore.setLoadingModels(true) janProviderStore.clearError() - const response = await this.authService.makeAuthenticatedRequest( - `${JAN_API_BASE}/conv/models` - ) + this.modelsFetchPromise = (async () => { + const response = await this.authService.makeAuthenticatedRequest( + `${JAN_API_BASE}${JAN_API_ROUTES.MODELS}` + ) - const models = response.data || [] - janProviderStore.setModels(models) - - return models + const summaries = response.data || [] + + const models: JanModel[] = await Promise.all( + summaries.map(async (summary) => { + const supportedParameters = await this.fetchSupportedParameters(summary.id) + const capabilities = this.deriveCapabilitiesFromParameters(supportedParameters) + + return { + id: summary.id, + object: summary.object, + owned_by: summary.owned_by, + created: summary.created, + capabilities, + supportedParameters, + } + }) + ) + + this.modelsCache = models + janProviderStore.setModels(models) + + return models + })() + + return await this.modelsFetchPromise } catch (error) { + this.modelsCache = null + this.modelsFetchPromise = null + const errorMessage = error instanceof ApiError ? error.message : error instanceof Error ? error.message : 'Failed to fetch models' janProviderStore.setError(errorMessage) janProviderStore.setLoadingModels(false) throw error + } finally { + this.modelsFetchPromise = null } } @@ -254,7 +312,7 @@ export class JanApiClient { async initialize(): Promise { try { janProviderStore.setAuthenticated(true) - // Fetch initial models + // Fetch initial models (cached for subsequent calls) await this.getModels() console.log('Jan API client initialized successfully') } catch (error) { @@ -266,6 +324,52 @@ export class JanApiClient { janProviderStore.setInitializing(false) } } + + private async fetchSupportedParameters(modelId: string): Promise { + try { + const endpoint = `${JAN_API_BASE}${JAN_API_ROUTES.MODEL_CATALOGS}/${this.encodeModelIdForCatalog(modelId)}` + const catalog = await this.authService.makeAuthenticatedRequest(endpoint) + return this.extractSupportedParameters(catalog) + } catch (error) { + console.warn(`Failed to fetch catalog metadata for model "${modelId}":`, error) + return [] + } + } + + private encodeModelIdForCatalog(modelId: string): string { + return modelId + .split('/') + .map((segment) => encodeURIComponent(segment)) + .join('/') + } + + private extractSupportedParameters(catalog: JanModelCatalogResponse | null | undefined): string[] { + if (!catalog) { + return [] + } + + const primaryNames = catalog.supported_parameters?.names + if (Array.isArray(primaryNames) && primaryNames.length > 0) { + return [...new Set(primaryNames)] + } + + const extraNames = catalog.extras?.supported_parameters + if (Array.isArray(extraNames) && extraNames.length > 0) { + return [...new Set(extraNames)] + } + + return [] + } + + private deriveCapabilitiesFromParameters(parameters: string[]): string[] { + const capabilities = new Set() + + if (parameters.includes('tools')) { + capabilities.add('tools') + } + + return Array.from(capabilities) + } } export const janApiClient = JanApiClient.getInstance() diff --git a/extensions-web/src/jan-provider-web/const.ts b/extensions-web/src/jan-provider-web/const.ts new file mode 100644 index 000000000..8f691551d --- /dev/null +++ b/extensions-web/src/jan-provider-web/const.ts @@ -0,0 +1,7 @@ +export const JAN_API_ROUTES = { + MODELS: '/models', + CHAT_COMPLETIONS: '/chat/completions', + MODEL_CATALOGS: '/models/catalogs', +} as const + +export const MODEL_PROVIDER_STORAGE_KEY = 'model-provider' diff --git a/extensions-web/src/jan-provider-web/helpers.ts b/extensions-web/src/jan-provider-web/helpers.ts new file mode 100644 index 000000000..09edb9867 --- /dev/null +++ b/extensions-web/src/jan-provider-web/helpers.ts @@ -0,0 +1,122 @@ +import type { JanModel } from './store' +import { MODEL_PROVIDER_STORAGE_KEY } from './const' + +type StoredModel = { + id?: string + capabilities?: unknown + [key: string]: unknown +} + +type StoredProvider = { + provider?: string + models?: StoredModel[] + [key: string]: unknown +} + +type StoredState = { + state?: { + providers?: StoredProvider[] + [key: string]: unknown + } + version?: number + [key: string]: unknown +} + +const normalizeCapabilities = (capabilities: unknown): string[] => { + if (!Array.isArray(capabilities)) { + return [] + } + + return [...new Set(capabilities.filter((item): item is string => typeof item === 'string'))].sort( + (a, b) => a.localeCompare(b) + ) +} + +/** + * Synchronize Jan models stored in localStorage with the latest server state. + * Returns true if the stored data was modified (including being cleared). + */ +export function syncJanModelsLocalStorage( + remoteModels: JanModel[], + storageKey: string = MODEL_PROVIDER_STORAGE_KEY +): boolean { + const rawStorage = localStorage.getItem(storageKey) + if (!rawStorage) { + return false + } + + let storedState: StoredState + try { + storedState = JSON.parse(rawStorage) as StoredState + } catch (error) { + console.warn('Failed to parse Jan model storage; clearing entry.', error) + localStorage.removeItem(storageKey) + return true + } + + const providers = storedState?.state?.providers + if (!Array.isArray(providers)) { + return false + } + + const remoteModelMap = new Map(remoteModels.map((model) => [model.id, model])) + let storageUpdated = false + + for (const provider of providers) { + if (provider.provider !== 'jan' || !Array.isArray(provider.models)) { + continue + } + + const updatedModels: StoredModel[] = [] + + for (const model of provider.models) { + const modelId = typeof model.id === 'string' ? model.id : null + if (!modelId) { + storageUpdated = true + continue + } + + const remoteModel = remoteModelMap.get(modelId) + if (!remoteModel) { + console.log(`Removing unknown Jan model from localStorage: ${modelId}`) + storageUpdated = true + continue + } + + const storedCapabilities = normalizeCapabilities(model.capabilities) + const remoteCapabilities = normalizeCapabilities(remoteModel.capabilities) + + const capabilitiesMatch = + storedCapabilities.length === remoteCapabilities.length && + storedCapabilities.every((cap, index) => cap === remoteCapabilities[index]) + + if (!capabilitiesMatch) { + console.log( + `Updating capabilities for Jan model ${modelId}:`, + storedCapabilities, + '=>', + remoteCapabilities + ) + updatedModels.push({ + ...model, + capabilities: remoteModel.capabilities, + }) + storageUpdated = true + } else { + updatedModels.push(model) + } + } + + if (updatedModels.length !== provider.models.length) { + storageUpdated = true + } + + provider.models = updatedModels + } + + if (storageUpdated) { + localStorage.setItem(storageKey, JSON.stringify(storedState)) + } + + return storageUpdated +} diff --git a/extensions-web/src/jan-provider-web/provider.ts b/extensions-web/src/jan-provider-web/provider.ts index 67e513c3f..a535b0fa0 100644 --- a/extensions-web/src/jan-provider-web/provider.ts +++ b/extensions-web/src/jan-provider-web/provider.ts @@ -14,12 +14,10 @@ import { ImportOptions, } from '@janhq/core' // cspell: disable-line import { janApiClient, JanChatMessage } from './api' +import { syncJanModelsLocalStorage } from './helpers' import { janProviderStore } from './store' import { ApiError } from '../shared/types/errors' -// Jan models support tools via MCP -const JAN_MODEL_CAPABILITIES = ['tools'] as const - export default class JanProviderWeb extends AIEngine { readonly provider = 'jan' private activeSessions: Map = new Map() @@ -28,11 +26,11 @@ export default class JanProviderWeb extends AIEngine { console.log('Loading Jan Provider Extension...') try { - // Check and clear invalid Jan models (capabilities mismatch) - this.validateJanModelsLocalStorage() - - // Initialize authentication and fetch models + // Initialize authentication await janApiClient.initialize() + // Check and sync stored Jan models against latest catalog data + await this.validateJanModelsLocalStorage() + console.log('Jan Provider Extension loaded successfully') } catch (error) { console.error('Failed to load Jan Provider Extension:', error) @@ -43,59 +41,17 @@ export default class JanProviderWeb extends AIEngine { } // Verify Jan models capabilities in localStorage - private validateJanModelsLocalStorage() { + private async validateJanModelsLocalStorage(): Promise { try { console.log('Validating Jan models in localStorage...') - const storageKey = 'model-provider' - const data = localStorage.getItem(storageKey) - if (!data) return - const parsed = JSON.parse(data) - if (!parsed?.state?.providers) return + const remoteModels = await janApiClient.getModels() + const storageUpdated = syncJanModelsLocalStorage(remoteModels) - // Check if any Jan model has incorrect capabilities - let hasInvalidModel = false - - for (const provider of parsed.state.providers) { - if (provider.provider === 'jan' && provider.models) { - for (const model of provider.models) { - console.log(`Checking Jan model: ${model.id}`, model.capabilities) - if ( - JSON.stringify(model.capabilities) !== - JSON.stringify(JAN_MODEL_CAPABILITIES) - ) { - hasInvalidModel = true - console.log( - `Found invalid Jan model: ${model.id}, clearing localStorage` - ) - break - } - } - } - if (hasInvalidModel) break - } - - // If any invalid model found, just clear the storage - if (hasInvalidModel) { - // Force clear the storage - localStorage.removeItem(storageKey) - // Verify it's actually removed - const afterRemoval = localStorage.getItem(storageKey) - // If still present, try setting to empty state - if (afterRemoval) { - // Try alternative clearing method - localStorage.setItem( - storageKey, - JSON.stringify({ - state: { providers: [] }, - version: parsed.version || 3, - }) - ) - } + if (storageUpdated) { console.log( - 'Cleared model-provider from localStorage due to invalid Jan capabilities' + 'Synchronized Jan models in localStorage with server capabilities; reloading...' ) - // Force a page reload to ensure clean state window.location.reload() } } catch (error) { @@ -132,7 +88,7 @@ export default class JanProviderWeb extends AIEngine { path: undefined, // Remote model, no local path owned_by: model.owned_by, object: model.object, - capabilities: [...JAN_MODEL_CAPABILITIES], + capabilities: [...model.capabilities], } : undefined ) @@ -153,7 +109,7 @@ export default class JanProviderWeb extends AIEngine { path: undefined, // Remote model, no local path owned_by: model.owned_by, object: model.object, - capabilities: [...JAN_MODEL_CAPABILITIES], + capabilities: [...model.capabilities], })) } catch (error) { console.error('Failed to list Jan models:', error) diff --git a/extensions-web/src/jan-provider-web/store.ts b/extensions-web/src/jan-provider-web/store.ts index 2ff341147..887a72246 100644 --- a/extensions-web/src/jan-provider-web/store.ts +++ b/extensions-web/src/jan-provider-web/store.ts @@ -9,6 +9,9 @@ export interface JanModel { id: string object: string owned_by: string + created?: number + capabilities: string[] + supportedParameters?: string[] } export interface JanProviderState { diff --git a/extensions-web/src/shared/auth/service.ts b/extensions-web/src/shared/auth/service.ts index eb15c4893..55371a940 100644 --- a/extensions-web/src/shared/auth/service.ts +++ b/extensions-web/src/shared/auth/service.ts @@ -5,7 +5,7 @@ declare const JAN_API_BASE: string -import { User, AuthState, AuthBroadcastMessage } from './types' +import { User, AuthState, AuthBroadcastMessage, AuthTokens } from './types' import { AUTH_STORAGE_KEYS, AUTH_ENDPOINTS, @@ -115,7 +115,7 @@ export class JanAuthService { // Store tokens and set authenticated state this.accessToken = tokens.access_token - this.tokenExpiryTime = Date.now() + tokens.expires_in * 1000 + this.tokenExpiryTime = this.computeTokenExpiry(tokens) this.setAuthProvider(providerId) this.authBroadcast.broadcastLogin() @@ -158,7 +158,7 @@ export class JanAuthService { const tokens = await refreshToken() this.accessToken = tokens.access_token - this.tokenExpiryTime = Date.now() + tokens.expires_in * 1000 + this.tokenExpiryTime = this.computeTokenExpiry(tokens) } catch (error) { console.error('Failed to refresh access token:', error) if (error instanceof ApiError && error.isStatus(401)) { @@ -343,6 +343,23 @@ export class JanAuthService { localStorage.removeItem(AUTH_STORAGE_KEYS.AUTH_PROVIDER) } + private computeTokenExpiry(tokens: AuthTokens): number { + if (tokens.expires_at) { + const expiresAt = new Date(tokens.expires_at).getTime() + if (!Number.isNaN(expiresAt)) { + return expiresAt + } + console.warn('Invalid expires_at format in auth tokens:', tokens.expires_at) + } + + if (typeof tokens.expires_in === 'number') { + return Date.now() + tokens.expires_in * 1000 + } + + console.warn('Auth tokens missing expiry information; defaulting to immediate expiry') + return Date.now() + } + /** * Ensure guest access is available */ @@ -352,7 +369,7 @@ export class JanAuthService { if (!this.accessToken || Date.now() > this.tokenExpiryTime) { const tokens = await guestLogin() this.accessToken = tokens.access_token - this.tokenExpiryTime = Date.now() + tokens.expires_in * 1000 + this.tokenExpiryTime = this.computeTokenExpiry(tokens) } } catch (error) { console.error('Failed to ensure guest access:', error) @@ -387,7 +404,6 @@ export class JanAuthService { case AUTH_EVENTS.LOGOUT: // Another tab logged out, clear our state this.clearAuthState() - this.ensureGuestAccess().catch(console.error) break } }) diff --git a/extensions-web/src/shared/auth/types.ts b/extensions-web/src/shared/auth/types.ts index 65f2dd06a..3e5df6e3c 100644 --- a/extensions-web/src/shared/auth/types.ts +++ b/extensions-web/src/shared/auth/types.ts @@ -16,7 +16,8 @@ export type AuthType = ProviderType | 'guest' export interface AuthTokens { access_token: string - expires_in: number + expires_in?: number + expires_at?: string object: string } diff --git a/web-app/src/containers/ChatInput.tsx b/web-app/src/containers/ChatInput.tsx index 744ff5bab..564e295c4 100644 --- a/web-app/src/containers/ChatInput.tsx +++ b/web-app/src/containers/ChatInput.tsx @@ -44,7 +44,6 @@ import { McpExtensionToolLoader } from './McpExtensionToolLoader' import { ExtensionTypeEnum, MCPExtension, fs, RAGExtension } from '@janhq/core' import { ExtensionManager } from '@/lib/extension' import { useAttachments } from '@/hooks/useAttachments' -import { open } from '@tauri-apps/plugin-dialog' import { toast } from 'sonner' import { PlatformFeatures } from '@/lib/platform/const' import { PlatformFeature } from '@/lib/platform/types' @@ -333,7 +332,7 @@ const ChatInput = ({ toast.info('Attachments are disabled in Settings') return } - const selection = await open({ + const selection = await serviceHub.dialog().open({ multiple: true, filters: [ { diff --git a/web-app/src/containers/LeftPanel.tsx b/web-app/src/containers/LeftPanel.tsx index 8fe4b3c24..56973cd1d 100644 --- a/web-app/src/containers/LeftPanel.tsx +++ b/web-app/src/containers/LeftPanel.tsx @@ -56,7 +56,8 @@ const mainMenus = [ title: 'common:projects.title', icon: IconFolderPlus, route: route.project, - isEnabled: !(IS_IOS || IS_ANDROID), + isEnabled: + PlatformFeatures[PlatformFeature.PROJECTS] && !(IS_IOS || IS_ANDROID), }, ] @@ -88,6 +89,7 @@ const LeftPanel = () => { const navigate = useNavigate() const [searchTerm, setSearchTerm] = useState('') const { isAuthenticated } = useAuth() + const projectsEnabled = PlatformFeatures[PlatformFeature.PROJECTS] const isSmallScreen = useSmallScreen() const prevScreenSizeRef = useRef(null) @@ -402,7 +404,9 @@ const LeftPanel = () => { })}
- {filteredProjects.length > 0 && !(IS_IOS || IS_ANDROID) && ( + {projectsEnabled && + filteredProjects.length > 0 && + !(IS_IOS || IS_ANDROID) && (
@@ -670,23 +674,29 @@ const LeftPanel = () => { {/* Project Dialogs */} - - + {projectsEnabled && ( + <> + + + + )} ) } diff --git a/web-app/src/containers/ThreadList.tsx b/web-app/src/containers/ThreadList.tsx index e48a2373d..97ddda856 100644 --- a/web-app/src/containers/ThreadList.tsx +++ b/web-app/src/containers/ThreadList.tsx @@ -25,6 +25,8 @@ import { useLeftPanel } from '@/hooks/useLeftPanel' import { useMessages } from '@/hooks/useMessages' import { cn, extractThinkingContent } from '@/lib/utils' import { useSmallScreen } from '@/hooks/useMediaQuery' +import { PlatformFeatures } from '@/lib/platform/const' +import { PlatformFeature } from '@/lib/platform/types' import { DropdownMenu, @@ -88,6 +90,7 @@ const SortableItem = memo( 'threadId' in match.params && match.params.threadId === thread.id ) + const projectsEnabled = PlatformFeatures[PlatformFeature.PROJECTS] const handleClick = (e: MouseEvent) => { if (openDropdown) { @@ -111,6 +114,9 @@ const SortableItem = memo( }, [thread.title]) const availableProjects = useMemo(() => { + if (!projectsEnabled) { + return [] + } return folders .filter((f) => { // Exclude the current project page we're on @@ -120,9 +126,18 @@ const SortableItem = memo( return true }) .sort((a, b) => b.updated_at - a.updated_at) - }, [folders, currentProjectId, thread.metadata?.project?.id]) + }, [ + projectsEnabled, + folders, + currentProjectId, + thread.metadata?.project?.id, + ]) const assignThreadToProject = (threadId: string, projectId: string) => { + if (!projectsEnabled) { + return + } + const project = getFolderById(projectId) if (project && updateThread) { const projectMetadata = { @@ -234,37 +249,39 @@ const SortableItem = memo( onDropdownClose={() => setOpenDropdown(false)} /> - - - - {t('common:projects.addToProject')} - - - {availableProjects.length === 0 ? ( - - - {t('common:projects.noProjectsAvailable')} - - - ) : ( - availableProjects.map((folder) => ( - { - e.stopPropagation() - assignThreadToProject(thread.id, folder.id) - }} - > - - - {folder.name} + {projectsEnabled && ( + + + + {t('common:projects.addToProject')} + + + {availableProjects.length === 0 ? ( + + + {t('common:projects.noProjectsAvailable')} - )) - )} - - - {thread.metadata?.project && ( + ) : ( + availableProjects.map((folder) => ( + { + e.stopPropagation() + assignThreadToProject(thread.id, folder.id) + }} + > + + + {folder.name} + + + )) + )} + + + )} + {projectsEnabled && thread.metadata?.project && ( <> ({ IconAtom: () => Atom, IconTool: () => Tool, IconCodeCircle2: () => Code, + IconPaperclip: () => Paperclip, + IconLoader2: () => Loader, + IconCheck: () => Check, IconPlayerStopFilled: () => Stop, IconX: () => X, })) diff --git a/web-app/src/containers/__tests__/DropdownModelProvider.displayName.test.tsx b/web-app/src/containers/__tests__/DropdownModelProvider.displayName.test.tsx index 5f5fba96a..d1ca8a189 100644 --- a/web-app/src/containers/__tests__/DropdownModelProvider.displayName.test.tsx +++ b/web-app/src/containers/__tests__/DropdownModelProvider.displayName.test.tsx @@ -73,6 +73,7 @@ vi.mock('@/lib/platform/const', () => ({ PlatformFeatures: { WEB_AUTO_MODEL_SELECTION: false, MODEL_PROVIDER_SETTINGS: true, + projects: true, }, })) diff --git a/web-app/src/containers/__tests__/LeftPanel.test.tsx b/web-app/src/containers/__tests__/LeftPanel.test.tsx index d8fcccc33..4bb62409a 100644 --- a/web-app/src/containers/__tests__/LeftPanel.test.tsx +++ b/web-app/src/containers/__tests__/LeftPanel.test.tsx @@ -122,6 +122,7 @@ vi.mock('@/lib/platform/const', () => ({ ASSISTANTS: true, MODEL_HUB: true, AUTHENTICATION: false, + projects: true, }, })) diff --git a/web-app/src/containers/__tests__/SettingsMenu.test.tsx b/web-app/src/containers/__tests__/SettingsMenu.test.tsx index a8532da89..d5d88af5b 100644 --- a/web-app/src/containers/__tests__/SettingsMenu.test.tsx +++ b/web-app/src/containers/__tests__/SettingsMenu.test.tsx @@ -34,6 +34,7 @@ vi.mock('@/lib/platform/const', () => ({ alternateShortcutBindings: false, firstMessagePersistedThread: false, temporaryChat: false, + projects: true, }, })) diff --git a/web-app/src/lib/platform/const.ts b/web-app/src/lib/platform/const.ts index ead0d6751..881448f3d 100644 --- a/web-app/src/lib/platform/const.ts +++ b/web-app/src/lib/platform/const.ts @@ -38,6 +38,10 @@ export const PlatformFeatures: Record = { // Default model providers (OpenAI, Anthropic, etc.) - disabled for web-only Jan builds [PlatformFeature.DEFAULT_PROVIDERS]: isPlatformTauri(), + // Projects management + [PlatformFeature.PROJECTS]: + isPlatformTauri() && !isPlatformIOS() && !isPlatformAndroid(), + // Analytics and telemetry - disabled for web [PlatformFeature.ANALYTICS]: isPlatformTauri() && !isPlatformIOS() && !isPlatformAndroid(), diff --git a/web-app/src/lib/platform/types.ts b/web-app/src/lib/platform/types.ts index 823105e13..01cb305d1 100644 --- a/web-app/src/lib/platform/types.ts +++ b/web-app/src/lib/platform/types.ts @@ -35,6 +35,8 @@ export enum PlatformFeature { // Default model providers (OpenAI, Anthropic, etc.) DEFAULT_PROVIDERS = 'defaultProviders', + // Projects management + PROJECTS = 'projects', // Analytics and telemetry ANALYTICS = 'analytics', diff --git a/web-app/src/routes/project/$projectId.tsx b/web-app/src/routes/project/$projectId.tsx index a87a87e09..25fd4552b 100644 --- a/web-app/src/routes/project/$projectId.tsx +++ b/web-app/src/routes/project/$projectId.tsx @@ -11,6 +11,7 @@ import ThreadList from '@/containers/ThreadList' import DropdownAssistant from '@/containers/DropdownAssistant' import { PlatformFeatures } from '@/lib/platform/const' +import { PlatformGuard } from '@/lib/platform/PlatformGuard' import { PlatformFeature } from '@/lib/platform/types' import { IconMessage } from '@tabler/icons-react' import { cn } from '@/lib/utils' @@ -22,6 +23,14 @@ export const Route = createFileRoute('/project/$projectId')({ }) function ProjectPage() { + return ( + + + + ) +} + +function ProjectPageContent() { const { t } = useTranslation() const { projectId } = useParams({ from: '/project/$projectId' }) const { getFolderById } = useThreadManagement() diff --git a/web-app/src/routes/project/index.tsx b/web-app/src/routes/project/index.tsx index be3e20cf6..f6ac50a5d 100644 --- a/web-app/src/routes/project/index.tsx +++ b/web-app/src/routes/project/index.tsx @@ -4,6 +4,8 @@ import { useState, useMemo } from 'react' import { useThreadManagement } from '@/hooks/useThreadManagement' import { useThreads } from '@/hooks/useThreads' import { useTranslation } from '@/i18n/react-i18next-compat' +import { PlatformGuard } from '@/lib/platform/PlatformGuard' +import { PlatformFeature } from '@/lib/platform/types' import HeaderPage from '@/containers/HeaderPage' import ThreadList from '@/containers/ThreadList' @@ -28,7 +30,11 @@ export const Route = createFileRoute('/project/')({ }) function Project() { - return + return ( + + + + ) } function ProjectContent() { diff --git a/web-app/src/test/setup.ts b/web-app/src/test/setup.ts index b2286c2f3..79045cec0 100644 --- a/web-app/src/test/setup.ts +++ b/web-app/src/test/setup.ts @@ -17,6 +17,7 @@ vi.mock('@/lib/platform/const', () => ({ systemIntegrations: true, httpsProxy: true, defaultProviders: true, + projects: true, analytics: true, webAutoModelSelection: false, modelProviderSettings: true, @@ -25,6 +26,7 @@ vi.mock('@/lib/platform/const', () => ({ extensionsSettings: true, assistants: true, authentication: false, + attachments: true, } })) From 370527bb500af28609c2f96461bc1c4c49d5dcc3 Mon Sep 17 00:00:00 2001 From: Dinh Long Nguyen Date: Fri, 24 Oct 2025 01:40:34 +0700 Subject: [PATCH 95/97] update tracker --- WEB_VERSION_TRACKER.md | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/WEB_VERSION_TRACKER.md b/WEB_VERSION_TRACKER.md index f9f7aa416..7b16c8280 100644 --- a/WEB_VERSION_TRACKER.md +++ b/WEB_VERSION_TRACKER.md @@ -2,7 +2,17 @@ Internal tracker for web component changes and features. -## v0.0.12 (Current) +## v0.0.13 (current) +**Release Date**: 2025-10-24 +**Commit SHA**: 22645549cea48b1ae24b5b9dc70411fd3bfc9935 + +**Main Features**: +- Migrate auth to platform menlo +- Remove conv prefix +- Disable Project for web +- Model capabilites are fetched correctly from model catalog + +## v0.0.12 **Release Date**: 2025-10-02 **Commit SHA**: df145d63a93bd27336b5b539ce0719fe9c7719e3 From f07e43cfe01bc96054053512852148ed8ae3a80b Mon Sep 17 00:00:00 2001 From: Dinh Long Nguyen Date: Fri, 24 Oct 2025 09:01:31 +0700 Subject: [PATCH 96/97] fix: conversation items (#6815) --- .github/workflows/jan-server-web-ci-dev.yml | 4 +- .github/workflows/jan-server-web-ci-prod.yml | 4 +- .github/workflows/jan-server-web-ci-stag.yml | 4 +- Dockerfile | 4 +- extensions-web/src/conversational-web/api.ts | 12 +- .../src/conversational-web/types.ts | 36 ++- .../src/conversational-web/utils.ts | 301 ++++++++++++------ extensions-web/src/jan-provider-web/api.ts | 8 +- extensions-web/src/mcp-web/index.ts | 6 +- extensions-web/src/shared/auth/api.ts | 8 +- .../src/shared/auth/providers/api.ts | 6 +- extensions-web/src/shared/auth/service.ts | 4 +- extensions-web/src/types/global.d.ts | 2 +- extensions-web/vite.config.ts | 2 +- 14 files changed, 274 insertions(+), 127 deletions(-) diff --git a/.github/workflows/jan-server-web-ci-dev.yml b/.github/workflows/jan-server-web-ci-dev.yml index 59515f443..be2243864 100644 --- a/.github/workflows/jan-server-web-ci-dev.yml +++ b/.github/workflows/jan-server-web-ci-dev.yml @@ -12,7 +12,7 @@ jobs: build-and-preview: runs-on: [ubuntu-24-04-docker] env: - JAN_API_BASE: "https://api-dev.menlo.ai/v1" + MENLO_PLATFORM_BASE_URL: "https://api-dev.menlo.ai/v1" permissions: pull-requests: write contents: write @@ -52,7 +52,7 @@ jobs: - name: Build docker image run: | - docker build --build-arg JAN_API_BASE=${{ env.JAN_API_BASE }} -t ${{ steps.vars.outputs.FULL_IMAGE }} . + docker build --build-arg MENLO_PLATFORM_BASE_URL=${{ env.MENLO_PLATFORM_BASE_URL }} -t ${{ steps.vars.outputs.FULL_IMAGE }} . - name: Push docker image if: github.event_name == 'push' diff --git a/.github/workflows/jan-server-web-ci-prod.yml b/.github/workflows/jan-server-web-ci-prod.yml index 1477fea32..cb5b597b7 100644 --- a/.github/workflows/jan-server-web-ci-prod.yml +++ b/.github/workflows/jan-server-web-ci-prod.yml @@ -13,7 +13,7 @@ jobs: deployments: write pull-requests: write env: - JAN_API_BASE: "https://api.menlo.ai/v1" + MENLO_PLATFORM_BASE_URL: "https://api.menlo.ai/v1" GA_MEASUREMENT_ID: "G-YK53MX8M8M" CLOUDFLARE_PROJECT_NAME: "jan-server-web" steps: @@ -43,7 +43,7 @@ jobs: - name: Install dependencies run: make config-yarn && yarn install && yarn build:core && make build-web-app env: - JAN_API_BASE: ${{ env.JAN_API_BASE }} + MENLO_PLATFORM_BASE_URL: ${{ env.MENLO_PLATFORM_BASE_URL }} GA_MEASUREMENT_ID: ${{ env.GA_MEASUREMENT_ID }} - name: Publish to Cloudflare Pages Production diff --git a/.github/workflows/jan-server-web-ci-stag.yml b/.github/workflows/jan-server-web-ci-stag.yml index b1851ebdd..3c2581952 100644 --- a/.github/workflows/jan-server-web-ci-stag.yml +++ b/.github/workflows/jan-server-web-ci-stag.yml @@ -12,7 +12,7 @@ jobs: build-and-preview: runs-on: [ubuntu-24-04-docker] env: - JAN_API_BASE: "https://api-stag.menlo.ai/v1" + MENLO_PLATFORM_BASE_URL: "https://api-stag.menlo.ai/v1" permissions: pull-requests: write contents: write @@ -52,7 +52,7 @@ jobs: - name: Build docker image run: | - docker build --build-arg JAN_API_BASE=${{ env.JAN_API_BASE }} -t ${{ steps.vars.outputs.FULL_IMAGE }} . + docker build --build-arg MENLO_PLATFORM_BASE_URL=${{ env.MENLO_PLATFORM_BASE_URL }} -t ${{ steps.vars.outputs.FULL_IMAGE }} . - name: Push docker image if: github.event_name == 'push' diff --git a/Dockerfile b/Dockerfile index 236aa583c..ad14af852 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,8 +1,8 @@ # Stage 1: Build stage with Node.js and Yarn v4 FROM node:20-alpine AS builder -ARG JAN_API_BASE=https://api-dev.jan.ai/v1 -ENV JAN_API_BASE=$JAN_API_BASE +ARG MENLO_PLATFORM_BASE_URL=https://api-dev.menlo.ai/v1 +ENV MENLO_PLATFORM_BASE_URL=$MENLO_PLATFORM_BASE_URL # Install build dependencies RUN apk add --no-cache \ diff --git a/extensions-web/src/conversational-web/api.ts b/extensions-web/src/conversational-web/api.ts index 0e398eb05..4fdbd6c69 100644 --- a/extensions-web/src/conversational-web/api.ts +++ b/extensions-web/src/conversational-web/api.ts @@ -16,7 +16,7 @@ import { ListConversationItemsResponse } from './types' -declare const JAN_API_BASE: string +declare const MENLO_PLATFORM_BASE_URL: string export class RemoteApi { private authService: JanAuthService @@ -28,7 +28,7 @@ export class RemoteApi { async createConversation( data: Conversation ): Promise { - const url = `${JAN_API_BASE}${CONVERSATION_API_ROUTES.CONVERSATIONS}` + const url = `${MENLO_PLATFORM_BASE_URL}${CONVERSATION_API_ROUTES.CONVERSATIONS}` return this.authService.makeAuthenticatedRequest( url, @@ -43,7 +43,7 @@ export class RemoteApi { conversationId: string, data: Conversation ): Promise { - const url = `${JAN_API_BASE}${CONVERSATION_API_ROUTES.CONVERSATION_BY_ID(conversationId)}` + const url = `${MENLO_PLATFORM_BASE_URL}${CONVERSATION_API_ROUTES.CONVERSATION_BY_ID(conversationId)}` return this.authService.makeAuthenticatedRequest( url, @@ -70,7 +70,7 @@ export class RemoteApi { } const queryString = queryParams.toString() - const url = `${JAN_API_BASE}${CONVERSATION_API_ROUTES.CONVERSATIONS}${queryString ? `?${queryString}` : ''}` + const url = `${MENLO_PLATFORM_BASE_URL}${CONVERSATION_API_ROUTES.CONVERSATIONS}${queryString ? `?${queryString}` : ''}` return this.authService.makeAuthenticatedRequest( url, @@ -114,7 +114,7 @@ export class RemoteApi { } async deleteConversation(conversationId: string): Promise { - const url = `${JAN_API_BASE}${CONVERSATION_API_ROUTES.CONVERSATION_BY_ID(conversationId)}` + const url = `${MENLO_PLATFORM_BASE_URL}${CONVERSATION_API_ROUTES.CONVERSATION_BY_ID(conversationId)}` await this.authService.makeAuthenticatedRequest( url, @@ -141,7 +141,7 @@ export class RemoteApi { } const queryString = queryParams.toString() - const url = `${JAN_API_BASE}${CONVERSATION_API_ROUTES.CONVERSATION_ITEMS(conversationId)}${queryString ? `?${queryString}` : ''}` + const url = `${MENLO_PLATFORM_BASE_URL}${CONVERSATION_API_ROUTES.CONVERSATION_ITEMS(conversationId)}${queryString ? `?${queryString}` : ''}` return this.authService.makeAuthenticatedRequest( url, diff --git a/extensions-web/src/conversational-web/types.ts b/extensions-web/src/conversational-web/types.ts index a6057da5d..ceb994808 100644 --- a/extensions-web/src/conversational-web/types.ts +++ b/extensions-web/src/conversational-web/types.ts @@ -31,7 +31,7 @@ export interface ConversationResponse { id: string object: 'conversation' title?: string - created_at: number + created_at: number | string metadata: ConversationMetadata } @@ -50,6 +50,7 @@ export interface ConversationItemAnnotation { } export interface ConversationItemContent { + type?: string file?: { file_id?: string mime_type?: string @@ -62,23 +63,50 @@ export interface ConversationItemContent { file_id?: string url?: string } + image_file?: { + file_id?: string + mime_type?: string + } input_text?: string output_text?: { annotations?: ConversationItemAnnotation[] text?: string } - reasoning_content?: string text?: { value?: string + text?: string } - type?: string + reasoning_content?: string + tool_calls?: Array<{ + id?: string + type?: string + function?: { + name?: string + arguments?: string + } + }> + tool_call_id?: string + tool_result?: { + content?: Array<{ + type?: string + text?: string + output_text?: { + text?: string + } + }> + output_text?: { + text?: string + } + } + text_result?: string } export interface ConversationItem { content?: ConversationItemContent[] - created_at: number + created_at: number | string id: string object: string + metadata?: Record role: string status?: string type?: string diff --git a/extensions-web/src/conversational-web/utils.ts b/extensions-web/src/conversational-web/utils.ts index 6448d9f4d..ad2f6fde9 100644 --- a/extensions-web/src/conversational-web/utils.ts +++ b/extensions-web/src/conversational-web/utils.ts @@ -1,5 +1,5 @@ import { Thread, ThreadAssistantInfo, ThreadMessage, ContentType } from '@janhq/core' -import { Conversation, ConversationResponse, ConversationItem } from './types' +import { Conversation, ConversationResponse, ConversationItem, ConversationItemContent, ConversationMetadata } from './types' import { DEFAULT_ASSISTANT } from './const' export class ObjectParser { @@ -7,7 +7,7 @@ export class ObjectParser { const modelName = thread.assistants?.[0]?.model?.id || undefined const modelProvider = thread.assistants?.[0]?.model?.engine || undefined const isFavorite = thread.metadata?.is_favorite?.toString() || 'false' - let metadata = {} + let metadata: ConversationMetadata = {} if (modelName && modelProvider) { metadata = { model_id: modelName, @@ -23,15 +23,14 @@ export class ObjectParser { static conversationToThread(conversation: ConversationResponse): Thread { const assistants: ThreadAssistantInfo[] = [] - if ( - conversation.metadata?.model_id && - conversation.metadata?.model_provider - ) { + const metadata: ConversationMetadata = conversation.metadata || {} + + if (metadata.model_id && metadata.model_provider) { assistants.push({ ...DEFAULT_ASSISTANT, model: { - id: conversation.metadata.model_id, - engine: conversation.metadata.model_provider, + id: metadata.model_id, + engine: metadata.model_provider, }, }) } else { @@ -44,16 +43,18 @@ export class ObjectParser { }) } - const isFavorite = conversation.metadata?.is_favorite === 'true' + const isFavorite = metadata.is_favorite === 'true' + const createdAtMs = parseTimestamp(conversation.created_at) + return { id: conversation.id, title: conversation.title || '', assistants, - created: conversation.created_at, - updated: conversation.created_at, + created: createdAtMs, + updated: createdAtMs, model: { - id: conversation.metadata.model_id, - provider: conversation.metadata.model_provider, + id: metadata.model_id, + provider: metadata.model_provider, }, isFavorite, metadata: { is_favorite: isFavorite }, @@ -65,74 +66,70 @@ export class ObjectParser { threadId: string ): ThreadMessage { // Extract text content and metadata from the item - let textContent = '' - let reasoningContent = '' + const textSegments: string[] = [] + const reasoningSegments: string[] = [] const imageUrls: string[] = [] let toolCalls: any[] = [] - let finishReason = '' if (item.content && item.content.length > 0) { for (const content of item.content) { - // Handle text content - if (content.text?.value) { - textContent = content.text.value - } - // Handle output_text for assistant messages - if (content.output_text?.text) { - textContent = content.output_text.text - } - // Handle reasoning content - if (content.reasoning_content) { - reasoningContent = content.reasoning_content - } - // Handle image content - if (content.image?.url) { - imageUrls.push(content.image.url) - } - // Extract finish_reason - if (content.finish_reason) { - finishReason = content.finish_reason - } - } - } - - // Handle tool calls parsing for assistant messages - if (item.role === 'assistant' && finishReason === 'tool_calls') { - try { - // Tool calls are embedded as JSON string in textContent - const toolCallMatch = textContent.match(/\[.*\]/) - if (toolCallMatch) { - const toolCallsData = JSON.parse(toolCallMatch[0]) - toolCalls = toolCallsData.map((toolCall: any) => ({ - tool: { - id: toolCall.id || 'unknown', - function: { - name: toolCall.function?.name || 'unknown', - arguments: toolCall.function?.arguments || '{}' - }, - type: toolCall.type || 'function' - }, - response: { - error: '', - content: [] - }, - state: 'ready' - })) - // Remove tool calls JSON from text content, keep only reasoning - textContent = '' - } - } catch (error) { - console.error('Failed to parse tool calls:', error) + extractContentByType(content, { + onText: (value) => { + if (value) { + textSegments.push(value) + } + }, + onReasoning: (value) => { + if (value) { + reasoningSegments.push(value) + } + }, + onImage: (url) => { + if (url) { + imageUrls.push(url) + } + }, + onToolCalls: (calls) => { + toolCalls = calls.map((toolCall) => { + const callId = toolCall.id || 'unknown' + const rawArgs = toolCall.function?.arguments + const normalizedArgs = + typeof rawArgs === 'string' + ? rawArgs + : JSON.stringify(rawArgs ?? {}) + return { + id: callId, + tool_call_id: callId, + tool: { + id: callId, + function: { + name: toolCall.function?.name || 'unknown', + arguments: normalizedArgs, + }, + type: toolCall.type || 'function', + }, + response: { + error: '', + content: [], + }, + state: 'pending', + } + }) + }, + }) } } // Format final content with reasoning if present let finalTextValue = '' - if (reasoningContent) { - finalTextValue = `${reasoningContent}` + if (reasoningSegments.length > 0) { + finalTextValue += `${reasoningSegments.join('\n')}` } - if (textContent) { - finalTextValue += textContent + if (textSegments.length > 0) { + if (finalTextValue) { + finalTextValue += '\n' + } + finalTextValue += textSegments.join('\n') } // Build content array for ThreadMessage @@ -157,22 +154,26 @@ export class ObjectParser { } // Build metadata - const metadata: any = {} + const metadata: any = { ...(item.metadata || {}) } if (toolCalls.length > 0) { metadata.tool_calls = toolCalls } + const createdAtMs = parseTimestamp(item.created_at) + // Map status from server format to frontend format const mappedStatus = item.status === 'completed' ? 'ready' : item.status || 'ready' + const role = item.role === 'user' || item.role === 'assistant' ? item.role : 'assistant' + return { type: 'text', id: item.id, object: 'thread.message', thread_id: threadId, - role: item.role as 'user' | 'assistant', + role, content: messageContent, - created_at: item.created_at * 1000, // Convert to milliseconds + created_at: createdAtMs, completed_at: 0, status: mappedStatus, metadata, @@ -201,25 +202,46 @@ export const combineConversationItemsToMessages = ( ): ThreadMessage[] => { const messages: ThreadMessage[] = [] const toolResponseMap = new Map() + const sortedItems = [...items].sort( + (a, b) => parseTimestamp(a.created_at) - parseTimestamp(b.created_at) + ) // First pass: collect tool responses - for (const item of items) { + for (const item of sortedItems) { if (item.role === 'tool') { - const toolContent = item.content?.[0]?.text?.value || '' - toolResponseMap.set(item.id, { - error: '', - content: [ - { - type: 'text', - text: toolContent - } - ] - }) + for (const content of item.content ?? []) { + const toolCallId = content.tool_call_id || item.id + const toolResultText = + content.tool_result?.output_text?.text || + (Array.isArray(content.tool_result?.content) + ? content.tool_result?.content + ?.map((entry) => entry.text || entry.output_text?.text) + .filter((text): text is string => Boolean(text)) + .join('\n') + : undefined) + const toolContent = + content.text?.text || + content.text?.value || + content.output_text?.text || + content.input_text || + content.text_result || + toolResultText || + '' + toolResponseMap.set(toolCallId, { + error: '', + content: [ + { + type: 'text', + text: toolContent, + }, + ], + }) + } } } // Second pass: build messages and merge tool responses - for (const item of items) { + for (const item of sortedItems) { // Skip tool messages as they will be merged into assistant messages if (item.role === 'tool') { continue @@ -228,14 +250,35 @@ export const combineConversationItemsToMessages = ( const message = ObjectParser.conversationItemToThreadMessage(item, threadId) // If this is an assistant message with tool calls, merge tool responses - if (message.role === 'assistant' && message.metadata?.tool_calls && Array.isArray(message.metadata.tool_calls)) { + if ( + message.role === 'assistant' && + message.metadata?.tool_calls && + Array.isArray(message.metadata.tool_calls) + ) { const toolCalls = message.metadata.tool_calls as any[] - let toolResponseIndex = 0 - for (const [responseId, responseData] of toolResponseMap.entries()) { - if (toolResponseIndex < toolCalls.length) { - toolCalls[toolResponseIndex].response = responseData - toolResponseIndex++ + for (const toolCall of toolCalls) { + const callId = toolCall.tool_call_id || toolCall.id || toolCall.tool?.id + let responseKey: string | undefined + let response: any = null + + if (callId && toolResponseMap.has(callId)) { + responseKey = callId + response = toolResponseMap.get(callId) + } else { + const iterator = toolResponseMap.entries().next() + if (!iterator.done) { + responseKey = iterator.value[0] + response = iterator.value[1] + } + } + + if (response) { + toolCall.response = response + toolCall.state = 'succeeded' + if (responseKey) { + toolResponseMap.delete(responseKey) + } } } } @@ -245,3 +288,79 @@ export const combineConversationItemsToMessages = ( return messages } + +const parseTimestamp = (value: number | string | undefined): number => { + if (typeof value === 'number') { + // Distinguish between seconds and milliseconds + return value > 1e12 ? value : value * 1000 + } + if (typeof value === 'string') { + const parsed = Date.parse(value) + return Number.isNaN(parsed) ? Date.now() : parsed + } + return Date.now() +} + +const extractContentByType = ( + content: ConversationItemContent, + handlers: { + onText: (value: string) => void + onReasoning: (value: string) => void + onImage: (url: string) => void + onToolCalls: (calls: NonNullable) => void + } +) => { + const type = content.type || '' + + switch (type) { + case 'input_text': + handlers.onText(content.input_text || '') + break + case 'text': + handlers.onText(content.text?.text || content.text?.value || '') + break + case 'output_text': + handlers.onText(content.output_text?.text || '') + break + case 'reasoning_content': + handlers.onReasoning(content.reasoning_content || '') + break + case 'image': + case 'image_url': + if (content.image?.url) { + handlers.onImage(content.image.url) + } + break + case 'tool_calls': + if (content.tool_calls && Array.isArray(content.tool_calls)) { + handlers.onToolCalls(content.tool_calls) + } + break + case 'tool_result': + if (content.tool_result?.output_text?.text) { + handlers.onText(content.tool_result.output_text.text) + } + break + default: + // Fallback for legacy fields without explicit type + if (content.text?.value || content.text?.text) { + handlers.onText(content.text.value || content.text.text || '') + } + if (content.text_result) { + handlers.onText(content.text_result) + } + if (content.output_text?.text) { + handlers.onText(content.output_text.text) + } + if (content.reasoning_content) { + handlers.onReasoning(content.reasoning_content) + } + if (content.image?.url) { + handlers.onImage(content.image.url) + } + if (content.tool_calls && Array.isArray(content.tool_calls)) { + handlers.onToolCalls(content.tool_calls) + } + break + } +} diff --git a/extensions-web/src/jan-provider-web/api.ts b/extensions-web/src/jan-provider-web/api.ts index 1d15c31b9..ded8f3214 100644 --- a/extensions-web/src/jan-provider-web/api.ts +++ b/extensions-web/src/jan-provider-web/api.ts @@ -8,7 +8,7 @@ import { ApiError } from '../shared/types/errors' import { JAN_API_ROUTES } from './const' import { JanModel, janProviderStore } from './store' -// JAN_API_BASE is defined in vite.config.ts +// MENLO_PLATFORM_BASE_URL is defined in vite.config.ts // Constants const TEMPORARY_CHAT_ID = 'temporary-chat' @@ -20,7 +20,7 @@ const TEMPORARY_CHAT_ID = 'temporary-chat' */ function getChatCompletionConfig(request: JanChatCompletionRequest, stream: boolean = false) { const isTemporaryChat = request.conversation_id === TEMPORARY_CHAT_ID - const endpoint = `${JAN_API_BASE}${JAN_API_ROUTES.CHAT_COMPLETIONS}` + const endpoint = `${MENLO_PLATFORM_BASE_URL}${JAN_API_ROUTES.CHAT_COMPLETIONS}` const payload = { ...request, @@ -162,7 +162,7 @@ export class JanApiClient { this.modelsFetchPromise = (async () => { const response = await this.authService.makeAuthenticatedRequest( - `${JAN_API_BASE}${JAN_API_ROUTES.MODELS}` + `${MENLO_PLATFORM_BASE_URL}${JAN_API_ROUTES.MODELS}` ) const summaries = response.data || [] @@ -327,7 +327,7 @@ export class JanApiClient { private async fetchSupportedParameters(modelId: string): Promise { try { - const endpoint = `${JAN_API_BASE}${JAN_API_ROUTES.MODEL_CATALOGS}/${this.encodeModelIdForCatalog(modelId)}` + const endpoint = `${MENLO_PLATFORM_BASE_URL}${JAN_API_ROUTES.MODEL_CATALOGS}/${this.encodeModelIdForCatalog(modelId)}` const catalog = await this.authService.makeAuthenticatedRequest(endpoint) return this.extractSupportedParameters(catalog) } catch (error) { diff --git a/extensions-web/src/mcp-web/index.ts b/extensions-web/src/mcp-web/index.ts index 3d588753f..6545fd426 100644 --- a/extensions-web/src/mcp-web/index.ts +++ b/extensions-web/src/mcp-web/index.ts @@ -12,8 +12,8 @@ import { JanMCPOAuthProvider } from './oauth-provider' import { WebSearchButton } from './components' import type { ComponentType } from 'react' -// JAN_API_BASE is defined in vite.config.ts (defaults to 'https://api-dev.jan.ai/jan/v1') -declare const JAN_API_BASE: string +// MENLO_PLATFORM_BASE_URL is defined in vite.config.ts (defaults to 'https://api-dev.menlo.ai/jan/v1') +declare const MENLO_PLATFORM_BASE_URL: string export default class MCPExtensionWeb extends MCPExtension { private mcpEndpoint = '/mcp' @@ -77,7 +77,7 @@ export default class MCPExtensionWeb extends MCPExtension { // Create transport with OAuth provider (handles token refresh automatically) const transport = new StreamableHTTPClientTransport( - new URL(`${JAN_API_BASE}${this.mcpEndpoint}`), + new URL(`${MENLO_PLATFORM_BASE_URL}${this.mcpEndpoint}`), { authProvider: this.oauthProvider // No sessionId needed - server will generate one automatically diff --git a/extensions-web/src/shared/auth/api.ts b/extensions-web/src/shared/auth/api.ts index 61163984b..a14617190 100644 --- a/extensions-web/src/shared/auth/api.ts +++ b/extensions-web/src/shared/auth/api.ts @@ -6,13 +6,13 @@ import { AuthTokens } from './types' import { AUTH_ENDPOINTS } from './const' -declare const JAN_API_BASE: string +declare const MENLO_PLATFORM_BASE_URL: string /** * Logout user on server */ export async function logoutUser(): Promise { - const response = await fetch(`${JAN_API_BASE}${AUTH_ENDPOINTS.LOGOUT}`, { + const response = await fetch(`${MENLO_PLATFORM_BASE_URL}${AUTH_ENDPOINTS.LOGOUT}`, { method: 'GET', credentials: 'include', headers: { @@ -29,7 +29,7 @@ export async function logoutUser(): Promise { * Guest login */ export async function guestLogin(): Promise { - const response = await fetch(`${JAN_API_BASE}${AUTH_ENDPOINTS.GUEST_LOGIN}`, { + const response = await fetch(`${MENLO_PLATFORM_BASE_URL}${AUTH_ENDPOINTS.GUEST_LOGIN}`, { method: 'POST', credentials: 'include', headers: { @@ -51,7 +51,7 @@ export async function guestLogin(): Promise { */ export async function refreshToken(): Promise { const response = await fetch( - `${JAN_API_BASE}${AUTH_ENDPOINTS.REFRESH_TOKEN}`, + `${MENLO_PLATFORM_BASE_URL}${AUTH_ENDPOINTS.REFRESH_TOKEN}`, { method: 'GET', credentials: 'include', diff --git a/extensions-web/src/shared/auth/providers/api.ts b/extensions-web/src/shared/auth/providers/api.ts index f2830e911..f63b5a915 100644 --- a/extensions-web/src/shared/auth/providers/api.ts +++ b/extensions-web/src/shared/auth/providers/api.ts @@ -5,10 +5,10 @@ import { AuthTokens, LoginUrlResponse } from './types' -declare const JAN_API_BASE: string +declare const MENLO_PLATFORM_BASE_URL: string export async function getLoginUrl(endpoint: string): Promise { - const response: Response = await fetch(`${JAN_API_BASE}${endpoint}`, { + const response: Response = await fetch(`${MENLO_PLATFORM_BASE_URL}${endpoint}`, { method: 'GET', credentials: 'include', headers: { @@ -30,7 +30,7 @@ export async function handleOAuthCallback( code: string, state?: string ): Promise { - const response: Response = await fetch(`${JAN_API_BASE}${endpoint}`, { + const response: Response = await fetch(`${MENLO_PLATFORM_BASE_URL}${endpoint}`, { method: 'POST', headers: { 'Content-Type': 'application/json', diff --git a/extensions-web/src/shared/auth/service.ts b/extensions-web/src/shared/auth/service.ts index 55371a940..fc12b5ffd 100644 --- a/extensions-web/src/shared/auth/service.ts +++ b/extensions-web/src/shared/auth/service.ts @@ -3,7 +3,7 @@ * Handles authentication flows for any OAuth provider */ -declare const JAN_API_BASE: string +declare const MENLO_PLATFORM_BASE_URL: string import { User, AuthState, AuthBroadcastMessage, AuthTokens } from './types' import { @@ -429,7 +429,7 @@ export class JanAuthService { private async fetchUserProfile(): Promise { try { return await this.makeAuthenticatedRequest( - `${JAN_API_BASE}${AUTH_ENDPOINTS.ME}` + `${MENLO_PLATFORM_BASE_URL}${AUTH_ENDPOINTS.ME}` ) } catch (error) { console.error('Failed to fetch user profile:', error) diff --git a/extensions-web/src/types/global.d.ts b/extensions-web/src/types/global.d.ts index 8d70d398b..22dea22d0 100644 --- a/extensions-web/src/types/global.d.ts +++ b/extensions-web/src/types/global.d.ts @@ -1,5 +1,5 @@ export {} declare global { - declare const JAN_API_BASE: string + declare const MENLO_PLATFORM_BASE_URL: string } diff --git a/extensions-web/vite.config.ts b/extensions-web/vite.config.ts index 8c144b0ab..b68fc4d5a 100644 --- a/extensions-web/vite.config.ts +++ b/extensions-web/vite.config.ts @@ -14,6 +14,6 @@ export default defineConfig({ emptyOutDir: false // Don't clean the output directory }, define: { - JAN_API_BASE: JSON.stringify(process.env.JAN_API_BASE || 'https://api-dev.jan.ai/v1'), + MENLO_PLATFORM_BASE_URL: JSON.stringify(process.env.MENLO_PLATFORM_BASE_URL || 'https://api-dev.menlo.ai/v1'), } }) From 4c5c8e6aedb4ec6f0364edcfb13afa428178a8d8 Mon Sep 17 00:00:00 2001 From: "nguyen.ngo" Date: Fri, 24 Oct 2025 13:09:35 +0700 Subject: [PATCH 97/97] we use POST to update now --- extensions-web/src/conversational-web/api.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/extensions-web/src/conversational-web/api.ts b/extensions-web/src/conversational-web/api.ts index 4fdbd6c69..bdd147edd 100644 --- a/extensions-web/src/conversational-web/api.ts +++ b/extensions-web/src/conversational-web/api.ts @@ -48,7 +48,7 @@ export class RemoteApi { return this.authService.makeAuthenticatedRequest( url, { - method: 'PATCH', + method: 'POST', body: JSON.stringify(data), } )