feat: added local LLM context length option

This commit is contained in:
Xiaohan-Tian
2026-05-14 19:44:25 -07:00
parent dbf0c20542
commit a48966d761
13 changed files with 467 additions and 4 deletions
+1
View File
@@ -200,6 +200,7 @@ const ChatBox: React.FC<ChatBoxProps> = ({ isVisible }) => {
const unsubscribe = configManager.addChangeListener((changedKeys) => {
if (
changedKeys.includes('general.llm_provider') ||
changedKeys.includes('general.local_browser.context_length') ||
changedKeys.some(k => k.startsWith('general.openai.')) ||
changedKeys.some(k => k.startsWith('general.openai_compatible.'))
) {
@@ -0,0 +1,120 @@
import React from 'react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import GeneralSettings from './GeneralSettings';
const configState = new Map<string, unknown>([
['general.llm_provider', 'local_browser'],
['general.persist_api_keys_non_localhost', false],
['general.openai.api_key', ''],
['general.openai.model', 'gpt-5.4-mini'],
['general.openai.flex', false],
['general.gemini.api_key', ''],
['general.gemini.model', 'gemini-2.5-flash'],
['general.claude.api_key', ''],
['general.claude.model', 'claude-sonnet-4.6'],
['general.claude_openrouter.api_key', ''],
['general.claude_openrouter.base_url', 'https://openrouter.ai/api/v1'],
['general.claude_openrouter.model', 'anthropic/claude-sonnet-4.6'],
['general.openai_compatible.api_key', ''],
['general.openai_compatible.base_url', ''],
['general.openai_compatible.model', ''],
['general.local_browser.context_length', 65536],
['general.soundfont.base_url', 'https://cdn.jsdelivr.net/npm/soundfont-for-samplers/FluidR3_GM/'],
['general.kgone.enabled', false],
['general.kgone.base_url', 'http://127.0.0.1:8000'],
]);
const configManagerMock = {
getIsInitialized: vi.fn(() => true),
initialize: vi.fn().mockResolvedValue(undefined),
get: vi.fn((key: string) => configState.get(key)),
set: vi.fn(async (key: string, value: unknown) => {
configState.set(key, value);
}),
isKGOneServerManaged: vi.fn(() => false),
isSoundfontServerManaged: vi.fn(() => false),
};
vi.mock('../../../core/config/ConfigManager', () => ({
ConfigManager: {
instance: () => configManagerMock,
},
}));
vi.mock('../../../util/localLLMModelManager', () => ({
LocalLLMModelManager: {
getState: () => ({
isCached: false,
isChecking: false,
isDownloading: false,
isDeleting: false,
progressPercent: 0,
progressText: '',
error: '',
runtimeSupport: {
supported: true,
webgpuExposed: true,
crossOriginIsolated: true,
sharedArrayBufferAvailable: true,
secureContext: true,
reason: null,
},
}),
subscribe: (listener: (state: unknown) => void) => {
listener({
isCached: false,
isChecking: false,
isDownloading: false,
isDeleting: false,
progressPercent: 0,
progressText: '',
error: '',
runtimeSupport: {
supported: true,
webgpuExposed: true,
crossOriginIsolated: true,
sharedArrayBufferAvailable: true,
secureContext: true,
reason: null,
},
});
return () => {};
},
deleteCachedModel: vi.fn().mockResolvedValue(undefined),
},
}));
describe('GeneralSettings', () => {
beforeEach(() => {
configState.set('general.local_browser.context_length', 65536);
configManagerMock.get.mockClear();
configManagerMock.set.mockClear();
});
it('renders the local context length selector and VRAM hint', async () => {
render(<GeneralSettings />);
expect(await screen.findByText('Gemma 4 E4B Local Runtime')).toBeTruthy();
expect(screen.getByLabelText('Context Length')).toBeTruthy();
expect(screen.getByText(/require more VRAM/i)).toBeTruthy();
});
it('initializes the local context length from config', async () => {
render(<GeneralSettings />);
const select = await screen.findByLabelText('Context Length');
expect((select as HTMLSelectElement).value).toBe('65536');
});
it('persists local context length changes', async () => {
render(<GeneralSettings />);
const select = await screen.findByLabelText('Context Length');
fireEvent.change(select, { target: { value: '131072' } });
await waitFor(() => {
expect(configManagerMock.set).toHaveBeenCalledWith('general.local_browser.context_length', 131072);
});
});
});
@@ -1,7 +1,15 @@
import React, { useState, useEffect, useCallback, useMemo } from 'react';
import { ConfigManager } from '../../../core/config/ConfigManager';
import { LocalLLMModelManager, type LocalLLMModelState } from '../../../util/localLLMModelManager';
import { LOCAL_LLM_DISPLAY_NAME, LOCAL_LLM_PROVIDER_KEY } from '../../../util/localLLMConfig';
import {
formatLocalLLMContextLength,
LOCAL_LLM_CONTEXT_LENGTH_OPTIONS,
LOCAL_LLM_DEFAULT_CONTEXT_LENGTH,
LOCAL_LLM_DISPLAY_NAME,
LOCAL_LLM_PROVIDER_KEY,
normalizeLocalLLMContextLength,
type LocalLLMContextLength,
} from '../../../util/localLLMConfig';
const GeneralSettings: React.FC = () => {
const [llmProvider, setLlmProvider] = useState<string>(LOCAL_LLM_PROVIDER_KEY);
@@ -24,6 +32,7 @@ const GeneralSettings: React.FC = () => {
const [kgoneBaseUrl, setKgoneBaseUrl] = useState<string>('');
const [kgoneServerManaged, setKgoneServerManaged] = useState<boolean>(false);
const [soundfontServerManaged, setSoundfontServerManaged] = useState<boolean>(false);
const [localContextLength, setLocalContextLength] = useState<LocalLLMContextLength>(LOCAL_LLM_DEFAULT_CONTEXT_LENGTH);
const [localModelState, setLocalModelState] = useState<LocalLLMModelState>(LocalLLMModelManager.getState());
const configManager = ConfigManager.instance();
@@ -64,6 +73,7 @@ const GeneralSettings: React.FC = () => {
setCompatibleKey((configManager.get('general.openai_compatible.api_key') as string) || '');
setCompatibleBaseUrl((configManager.get('general.openai_compatible.base_url') as string) || '');
setCompatibleModel((configManager.get('general.openai_compatible.model') as string) || '');
setLocalContextLength(normalizeLocalLLMContextLength(configManager.get('general.local_browser.context_length')));
setSoundfontBaseUrl((configManager.get('general.soundfont.base_url') as string) || '');
setKgoneEnabled((configManager.get('general.kgone.enabled') as boolean) ?? false);
setKgoneBaseUrl((configManager.get('general.kgone.base_url') as string) || '');
@@ -210,6 +220,18 @@ const GeneralSettings: React.FC = () => {
}
};
const handleLocalContextLengthChange = async (value: string) => {
const parsed = Number(value);
const normalized = normalizeLocalLLMContextLength(parsed);
setLocalContextLength(normalized);
try {
await configManager.set('general.local_browser.context_length', normalized);
console.log('Local browser context length changed to:', normalized);
} catch (error) {
console.error('Failed to save local browser context length:', error);
}
};
// NOTE: Gemini and Claude are not supported yet due to CORS issues.
return (
<div className="settings-section">
@@ -279,6 +301,27 @@ const GeneralSettings: React.FC = () => {
</div>
</div>
<div className="settings-item">
<label className="settings-label" htmlFor="local-llm-context-length">
Context Length
</label>
<select
id="local-llm-context-length"
className="settings-select"
value={localContextLength}
onChange={(e) => void handleLocalContextLengthChange(e.target.value)}
>
{LOCAL_LLM_CONTEXT_LENGTH_OPTIONS.map(option => (
<option key={option} value={option}>
{formatLocalLLMContextLength(option)}
</option>
))}
</select>
<div className="settings-help" style={{ fontSize: '12px', color: '#888', marginTop: '4px' }}>
Larger context lengths require more VRAM and may also reduce performance as conversations become longer.
</div>
</div>
{!localModelState.isCached && !localModelState.isDownloading && localModelState.runtimeSupport.supported && (
<div className="settings-help" style={{ fontSize: '12px', color: '#888', marginTop: '4px', marginBottom: '8px' }}>
The local model downloads automatically the next time you chat with `Local LLM (Browser)`.