Feature/lmstudio provider (#30)
* chore: just a plan update * Add LM Studio as local AI provider (OpenAI-compatible, like Ollama) * Convert WebP thumbnails to JPEG before image analysis for LM Studio compatibility * Strengthen language enforcement in image analysis prompt for local models * Use i18n localized prompts for image analysis instead of English instructions * Add airplane mode (Flugmodus) with status bar toggle and offline model preferences * Fix flightmode: persist model IDs, skip network when offline, airplane icon * Auto-fallback to offline models in airplane mode for chat, title, and image analysis * Auto-select first local model as offline fallback when no explicit offline model configured * Block git fetch/pull/push and site upload in airplane mode * fix: thumbnails optimized for AI * fix: error handling in airplane mode --------- Co-authored-by: hugo <hugoms@me.com>
This commit is contained in:
191
tests/engine/offline-mode.test.ts
Normal file
191
tests/engine/offline-mode.test.ts
Normal file
@@ -0,0 +1,191 @@
|
||||
/**
|
||||
* Offline / airplane mode tests.
|
||||
*
|
||||
* Verifies ProviderRegistry offline mode behavior:
|
||||
* - toggles on/off
|
||||
* - restricts model resolution to local providers only
|
||||
* - restricts available models to local only
|
||||
* - isReady reflects local provider state
|
||||
* - getProviderStatus includes offline state
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { ProviderRegistry } from '../../src/main/engine/ai/providers';
|
||||
|
||||
describe('ProviderRegistry offline mode', () => {
|
||||
let registry: ProviderRegistry;
|
||||
|
||||
beforeEach(() => {
|
||||
registry = new ProviderRegistry();
|
||||
});
|
||||
|
||||
// ---------- toggle ----------
|
||||
|
||||
it('starts with offline mode disabled', () => {
|
||||
expect(registry.isOfflineMode()).toBe(false);
|
||||
});
|
||||
|
||||
it('can enable and disable offline mode', () => {
|
||||
registry.setOfflineMode(true);
|
||||
expect(registry.isOfflineMode()).toBe(true);
|
||||
registry.setOfflineMode(false);
|
||||
expect(registry.isOfflineMode()).toBe(false);
|
||||
});
|
||||
|
||||
// ---------- isReady in offline mode ----------
|
||||
|
||||
it('isReady returns false in offline mode when no local provider enabled', () => {
|
||||
registry.setOpencodeKey('test-key');
|
||||
registry.setOfflineMode(true);
|
||||
expect(registry.isReady()).toBe(false);
|
||||
});
|
||||
|
||||
it('isReady returns true in offline mode when Ollama enabled', () => {
|
||||
registry.setOfflineMode(true);
|
||||
registry.setOllamaEnabled(true);
|
||||
expect(registry.isReady()).toBe(true);
|
||||
});
|
||||
|
||||
it('isReady returns true in offline mode when LM Studio enabled', () => {
|
||||
registry.setOfflineMode(true);
|
||||
registry.setLmstudioEnabled(true);
|
||||
expect(registry.isReady()).toBe(true);
|
||||
});
|
||||
|
||||
// ---------- resolveModel in offline mode ----------
|
||||
|
||||
it('resolveModel throws for cloud models when offline', () => {
|
||||
registry.setOpencodeKey('test-key');
|
||||
registry.setOfflineMode(true);
|
||||
expect(() => registry.resolveModel('claude-sonnet-4')).toThrow('offline');
|
||||
});
|
||||
|
||||
it('resolveModel throws for mistral models when offline', () => {
|
||||
registry.setMistralKey('test-key');
|
||||
registry.setOfflineMode(true);
|
||||
expect(() => registry.resolveModel('mistral-large-latest')).toThrow('offline');
|
||||
});
|
||||
|
||||
it('resolveModel succeeds for Ollama model when offline and Ollama enabled', () => {
|
||||
registry.setOllamaEnabled(true);
|
||||
registry.registerOllamaModel('llama3');
|
||||
registry.setOfflineMode(true);
|
||||
const model = registry.resolveModel('llama3');
|
||||
expect(model).toBeDefined();
|
||||
});
|
||||
|
||||
it('resolveModel succeeds for LM Studio model when offline and LM Studio enabled', () => {
|
||||
registry.setLmstudioEnabled(true);
|
||||
registry.registerLmstudioModel('gemma-3-12b-it');
|
||||
registry.setOfflineMode(true);
|
||||
const model = registry.resolveModel('gemma-3-12b-it');
|
||||
expect(model).toBeDefined();
|
||||
});
|
||||
|
||||
// ---------- getAvailableModels filtering ----------
|
||||
|
||||
it('getAvailableModels returns only local models when offline', async () => {
|
||||
registry.setOpencodeKey('test-key');
|
||||
registry.setOllamaEnabled(true);
|
||||
registry.registerOllamaModel('llama3:latest');
|
||||
registry.setOfflineMode(true);
|
||||
|
||||
// No fetch should happen at all in offline mode
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = vi.fn().mockImplementation(async (url: string) => {
|
||||
throw new Error(`Unexpected fetch to ${url} in offline mode`);
|
||||
});
|
||||
|
||||
try {
|
||||
const models = await registry.getAvailableModels();
|
||||
// Only local model should appear
|
||||
expect(models.length).toBe(1);
|
||||
expect(models.every(m => m.provider === 'ollama' || m.provider === 'lmstudio')).toBe(true);
|
||||
// fetch should NOT have been called
|
||||
expect(globalThis.fetch).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
// ---------- getKnownLocalModels ----------
|
||||
|
||||
it('getKnownLocalModels returns registered Ollama and LM Studio models', () => {
|
||||
registry.setOllamaEnabled(true);
|
||||
registry.setLmstudioEnabled(true);
|
||||
registry.registerOllamaModel('llama3:latest');
|
||||
registry.registerLmstudioModel('gemma-3-12b-it');
|
||||
|
||||
const models = registry.getKnownLocalModels();
|
||||
expect(models.length).toBe(2);
|
||||
expect(models.find(m => m.id === 'llama3:latest')?.provider).toBe('ollama');
|
||||
expect(models.find(m => m.id === 'gemma-3-12b-it')?.provider).toBe('lmstudio');
|
||||
});
|
||||
|
||||
it('getKnownLocalModels returns empty when no models registered', () => {
|
||||
const models = registry.getKnownLocalModels();
|
||||
expect(models.length).toBe(0);
|
||||
});
|
||||
|
||||
// ---------- getFirstKnownLocalModelId ----------
|
||||
|
||||
it('getFirstKnownLocalModelId returns first registered model', () => {
|
||||
registry.setOllamaEnabled(true);
|
||||
registry.registerOllamaModel('llama3:latest');
|
||||
registry.registerOllamaModel('mistral:latest');
|
||||
expect(registry.getFirstKnownLocalModelId()).toBe('llama3:latest');
|
||||
});
|
||||
|
||||
it('getFirstKnownLocalModelId returns null when no models registered', () => {
|
||||
expect(registry.getFirstKnownLocalModelId()).toBeNull();
|
||||
});
|
||||
|
||||
it('getFirstKnownLocalModelId falls back to LM Studio when no Ollama models', () => {
|
||||
registry.setLmstudioEnabled(true);
|
||||
registry.registerLmstudioModel('gemma-3-12b-it');
|
||||
expect(registry.getFirstKnownLocalModelId()).toBe('gemma-3-12b-it');
|
||||
});
|
||||
|
||||
// ---------- getProviderStatus includes offline ----------
|
||||
|
||||
it('getProviderStatus includes offlineMode field', () => {
|
||||
registry.setOfflineMode(true);
|
||||
const status = registry.getProviderStatus();
|
||||
expect(status.offlineMode).toBe(true);
|
||||
|
||||
registry.setOfflineMode(false);
|
||||
expect(registry.getProviderStatus().offlineMode).toBe(false);
|
||||
});
|
||||
|
||||
// ---------- isProviderKeySet in offline mode ----------
|
||||
|
||||
it('isProviderKeySet returns false for cloud providers when offline', () => {
|
||||
registry.setOpencodeKey('test-key');
|
||||
registry.setMistralKey('test-key');
|
||||
registry.setOfflineMode(true);
|
||||
expect(registry.isProviderKeySet('anthropic')).toBe(false);
|
||||
expect(registry.isProviderKeySet('openai')).toBe(false);
|
||||
expect(registry.isProviderKeySet('mistral')).toBe(false);
|
||||
});
|
||||
|
||||
it('isProviderKeySet returns true for local providers when offline and enabled', () => {
|
||||
registry.setOllamaEnabled(true);
|
||||
registry.setLmstudioEnabled(true);
|
||||
registry.setOfflineMode(true);
|
||||
expect(registry.isProviderKeySet('ollama')).toBe(true);
|
||||
expect(registry.isProviderKeySet('lmstudio')).toBe(true);
|
||||
});
|
||||
|
||||
// ---------- model cache invalidation on toggle ----------
|
||||
|
||||
it('invalidates model cache on offline mode toggle', () => {
|
||||
// Pre-populate cache
|
||||
registry.setOpencodeKey('test-key');
|
||||
registry.setOfflineMode(true);
|
||||
// Toggling should have invalidated
|
||||
expect(registry.isOfflineMode()).toBe(true);
|
||||
// Toggle back
|
||||
registry.setOfflineMode(false);
|
||||
expect(registry.isOfflineMode()).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user