feat: first time automated ollama install experience and openrouter (#3881)
Co-authored-by: Zane Staggs <zane@squareup.com>
This commit is contained in:
@@ -14,7 +14,7 @@ use tokio::sync::oneshot;
|
|||||||
use tokio::time::timeout;
|
use tokio::time::timeout;
|
||||||
|
|
||||||
/// Default models for openrouter config configuration
|
/// Default models for openrouter config configuration
|
||||||
const OPENROUTER_DEFAULT_MODEL: &str = "qwen/qwen3-coder";
|
const OPENROUTER_DEFAULT_MODEL: &str = "anthropic/claude-sonnet-4";
|
||||||
|
|
||||||
const OPENROUTER_AUTH_URL: &str = "https://openrouter.ai/auth";
|
const OPENROUTER_AUTH_URL: &str = "https://openrouter.ai/auth";
|
||||||
const OPENROUTER_TOKEN_URL: &str = "https://openrouter.ai/api/v1/auth/keys";
|
const OPENROUTER_TOKEN_URL: &str = "https://openrouter.ai/api/v1/auth/keys";
|
||||||
|
|||||||
@@ -202,6 +202,12 @@ fn create_request_based_on_model(
|
|||||||
payload = update_request_for_anthropic(&payload);
|
payload = update_request_for_anthropic(&payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Always add transforms: ["middle-out"] for OpenRouter to handle prompts > context size
|
||||||
|
payload
|
||||||
|
.as_object_mut()
|
||||||
|
.unwrap()
|
||||||
|
.insert("transforms".to_string(), json!(["middle-out"]));
|
||||||
|
|
||||||
Ok(payload)
|
Ok(payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,291 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @vitest-environment jsdom
|
||||||
|
*/
|
||||||
|
import React from 'react';
|
||||||
|
import { render, waitFor } from '@testing-library/react';
|
||||||
|
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||||
|
import App from './App';
|
||||||
|
|
||||||
|
// Set up globals for jsdom
|
||||||
|
Object.defineProperty(window, 'location', {
|
||||||
|
value: {
|
||||||
|
hash: '',
|
||||||
|
search: '',
|
||||||
|
href: 'http://localhost:3000',
|
||||||
|
origin: 'http://localhost:3000',
|
||||||
|
},
|
||||||
|
writable: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
Object.defineProperty(window, 'history', {
|
||||||
|
value: {
|
||||||
|
replaceState: vi.fn(),
|
||||||
|
state: null,
|
||||||
|
},
|
||||||
|
writable: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mock dependencies
|
||||||
|
vi.mock('./utils/providerUtils', () => ({
|
||||||
|
initializeSystem: vi.fn().mockResolvedValue(undefined),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('./utils/costDatabase', () => ({
|
||||||
|
initializeCostDatabase: vi.fn().mockResolvedValue(undefined),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('./api/sdk.gen', () => ({
|
||||||
|
initConfig: vi.fn().mockResolvedValue(undefined),
|
||||||
|
readAllConfig: vi.fn().mockResolvedValue(undefined),
|
||||||
|
backupConfig: vi.fn().mockResolvedValue(undefined),
|
||||||
|
recoverConfig: vi.fn().mockResolvedValue(undefined),
|
||||||
|
validateConfig: vi.fn().mockResolvedValue(undefined),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('./utils/openRouterSetup', () => ({
|
||||||
|
startOpenRouterSetup: vi.fn().mockResolvedValue({ success: false, message: 'Test' }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('./utils/ollamaDetection', () => ({
|
||||||
|
checkOllamaStatus: vi.fn().mockResolvedValue({ isRunning: false }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock the ConfigContext module
|
||||||
|
vi.mock('./components/ConfigContext', () => ({
|
||||||
|
useConfig: () => ({
|
||||||
|
read: vi.fn().mockResolvedValue(null),
|
||||||
|
update: vi.fn(),
|
||||||
|
getExtensions: vi.fn().mockReturnValue([]),
|
||||||
|
addExtension: vi.fn(),
|
||||||
|
updateExtension: vi.fn(),
|
||||||
|
createProviderDefaults: vi.fn(),
|
||||||
|
}),
|
||||||
|
ConfigProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock other components to simplify testing
|
||||||
|
vi.mock('./components/ErrorBoundary', () => ({
|
||||||
|
ErrorUI: ({ error }: { error: Error }) => <div>Error: {error.message}</div>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock ProviderGuard to show the welcome screen when no provider is configured
|
||||||
|
vi.mock('./components/ProviderGuard', () => ({
|
||||||
|
default: ({ children }: { children: React.ReactNode }) => {
|
||||||
|
// In a real app, ProviderGuard would check for provider and show welcome screen
|
||||||
|
// For this test, we'll simulate that behavior
|
||||||
|
const hasProvider = window.electron?.getConfig()?.GOOSE_DEFAULT_PROVIDER;
|
||||||
|
if (!hasProvider) {
|
||||||
|
return <div>Welcome to Goose!</div>;
|
||||||
|
}
|
||||||
|
return <>{children}</>;
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('./components/ModelAndProviderContext', () => ({
|
||||||
|
ModelAndProviderProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||||
|
useModelAndProvider: () => ({
|
||||||
|
provider: null,
|
||||||
|
model: null,
|
||||||
|
getCurrentModelAndProvider: vi.fn(),
|
||||||
|
setCurrentModelAndProvider: vi.fn(),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('./contexts/ChatContext', () => ({
|
||||||
|
ChatProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('./contexts/DraftContext', () => ({
|
||||||
|
DraftProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('./components/ui/ConfirmationModal', () => ({
|
||||||
|
ConfirmationModal: () => null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('react-toastify', () => ({
|
||||||
|
ToastContainer: () => null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('./components/GoosehintsModal', () => ({
|
||||||
|
GoosehintsModal: () => null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('./components/AnnouncementModal', () => ({
|
||||||
|
default: () => null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('./hooks/useChat', () => ({
|
||||||
|
useChat: () => ({
|
||||||
|
chat: {
|
||||||
|
id: 'test-id',
|
||||||
|
title: 'Test Chat',
|
||||||
|
messages: [],
|
||||||
|
messageHistoryIndex: 0,
|
||||||
|
recipeConfig: null,
|
||||||
|
},
|
||||||
|
setChat: vi.fn(),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock react-router-dom to avoid HashRouter issues in tests
|
||||||
|
vi.mock('react-router-dom', () => ({
|
||||||
|
HashRouter: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||||
|
Routes: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||||
|
Route: ({ element }: { element: React.ReactNode }) => element,
|
||||||
|
useNavigate: () => vi.fn(),
|
||||||
|
useLocation: () => ({ state: null, pathname: '/' }),
|
||||||
|
Outlet: () => null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock electron API
|
||||||
|
const mockElectron = {
|
||||||
|
getConfig: vi.fn().mockReturnValue({
|
||||||
|
GOOSE_ALLOWLIST_WARNING: false,
|
||||||
|
GOOSE_WORKING_DIR: '/test/dir',
|
||||||
|
}),
|
||||||
|
logInfo: vi.fn(),
|
||||||
|
on: vi.fn(),
|
||||||
|
off: vi.fn(),
|
||||||
|
reactReady: vi.fn(),
|
||||||
|
getAllowedExtensions: vi.fn().mockResolvedValue([]),
|
||||||
|
platform: 'darwin',
|
||||||
|
createChatWindow: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mock appConfig
|
||||||
|
const mockAppConfig = {
|
||||||
|
get: vi.fn((key: string) => {
|
||||||
|
if (key === 'GOOSE_WORKING_DIR') return '/test/dir';
|
||||||
|
return null;
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Attach mocks to window
|
||||||
|
(window as any).electron = mockElectron;
|
||||||
|
(window as any).appConfig = mockAppConfig;
|
||||||
|
|
||||||
|
// Mock matchMedia
|
||||||
|
Object.defineProperty(window, 'matchMedia', {
|
||||||
|
writable: true,
|
||||||
|
value: vi.fn().mockImplementation((query) => ({
|
||||||
|
matches: false,
|
||||||
|
media: query,
|
||||||
|
onchange: null,
|
||||||
|
addListener: vi.fn(), // deprecated
|
||||||
|
removeListener: vi.fn(), // deprecated
|
||||||
|
addEventListener: vi.fn(),
|
||||||
|
removeEventListener: vi.fn(),
|
||||||
|
dispatchEvent: vi.fn(),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('App Component - Brand New State', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
window.location.hash = '';
|
||||||
|
window.location.search = '';
|
||||||
|
window.sessionStorage.clear();
|
||||||
|
window.localStorage.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should redirect to "/" when app is brand new (no provider configured)', async () => {
|
||||||
|
// Mock no provider configured
|
||||||
|
mockElectron.getConfig.mockReturnValue({
|
||||||
|
GOOSE_DEFAULT_PROVIDER: null,
|
||||||
|
GOOSE_DEFAULT_MODEL: null,
|
||||||
|
GOOSE_ALLOWLIST_WARNING: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<App />);
|
||||||
|
|
||||||
|
// Wait for initialization
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockElectron.reactReady).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check that we navigated to "/" not "/welcome"
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(window.location.hash).toBe('#/');
|
||||||
|
});
|
||||||
|
|
||||||
|
// History should have been updated to "/"
|
||||||
|
expect(window.history.replaceState).toHaveBeenCalledWith({}, '', '#/');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle deep links correctly when app is brand new', async () => {
|
||||||
|
// Mock no provider configured
|
||||||
|
mockElectron.getConfig.mockReturnValue({
|
||||||
|
GOOSE_DEFAULT_PROVIDER: null,
|
||||||
|
GOOSE_DEFAULT_MODEL: null,
|
||||||
|
GOOSE_ALLOWLIST_WARNING: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Simulate a deep link
|
||||||
|
window.location.search = '?view=settings';
|
||||||
|
|
||||||
|
render(<App />);
|
||||||
|
|
||||||
|
// Wait for initialization
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockElectron.reactReady).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Should redirect to settings route via hash
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(window.location.hash).toBe('#/settings');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not redirect to /welcome when provider is configured', async () => {
|
||||||
|
// Mock provider configured
|
||||||
|
mockElectron.getConfig.mockReturnValue({
|
||||||
|
GOOSE_DEFAULT_PROVIDER: 'openai',
|
||||||
|
GOOSE_DEFAULT_MODEL: 'gpt-4',
|
||||||
|
GOOSE_ALLOWLIST_WARNING: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<App />);
|
||||||
|
|
||||||
|
// Wait for initialization
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockElectron.reactReady).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Should stay at "/" since provider is configured
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(window.location.hash).toBe('#/');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle config recovery gracefully', async () => {
|
||||||
|
// Mock config error that triggers recovery
|
||||||
|
const { readAllConfig, recoverConfig } = await import('./api/sdk.gen');
|
||||||
|
console.log(recoverConfig);
|
||||||
|
vi.mocked(readAllConfig).mockRejectedValueOnce(new Error('Config read error'));
|
||||||
|
|
||||||
|
mockElectron.getConfig.mockReturnValue({
|
||||||
|
GOOSE_DEFAULT_PROVIDER: null,
|
||||||
|
GOOSE_DEFAULT_MODEL: null,
|
||||||
|
GOOSE_ALLOWLIST_WARNING: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<App />);
|
||||||
|
|
||||||
|
// Wait for initialization and recovery
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockElectron.reactReady).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
// App should still initialize and navigate to "/"
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(window.location.hash).toBe('#/');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -986,12 +986,12 @@ export default function App() {
|
|||||||
if (error instanceof MalformedConfigError) {
|
if (error instanceof MalformedConfigError) {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
window.location.hash = '#/welcome';
|
window.location.hash = '#/';
|
||||||
window.history.replaceState({}, '', '#/welcome');
|
window.history.replaceState({}, '', '#/');
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
window.location.hash = '#/welcome';
|
window.location.hash = '#/';
|
||||||
window.history.replaceState({}, '', '#/welcome');
|
window.history.replaceState({}, '', '#/');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Fatal error during initialization:', error);
|
console.error('Fatal error during initialization:', error);
|
||||||
|
|||||||
@@ -0,0 +1,277 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
|
||||||
|
import { OllamaSetup } from './OllamaSetup';
|
||||||
|
import * as ollamaDetection from '../utils/ollamaDetection';
|
||||||
|
import * as providerUtils from '../utils/providerUtils';
|
||||||
|
import { toastService } from '../toasts';
|
||||||
|
|
||||||
|
// Mock dependencies
|
||||||
|
vi.mock('../utils/ollamaDetection');
|
||||||
|
vi.mock('../utils/providerUtils');
|
||||||
|
vi.mock('../toasts');
|
||||||
|
|
||||||
|
// Mock useConfig hook
|
||||||
|
const mockUpsert = vi.fn();
|
||||||
|
const mockAddExtension = vi.fn();
|
||||||
|
const mockGetExtensions = vi.fn();
|
||||||
|
|
||||||
|
vi.mock('./ConfigContext', () => ({
|
||||||
|
useConfig: () => ({
|
||||||
|
upsert: mockUpsert,
|
||||||
|
addExtension: mockAddExtension,
|
||||||
|
getExtensions: mockGetExtensions,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('OllamaSetup', () => {
|
||||||
|
const mockOnSuccess = vi.fn();
|
||||||
|
const mockOnCancel = vi.fn();
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
// Default mocks
|
||||||
|
vi.mocked(ollamaDetection.getPreferredModel).mockReturnValue('gpt-oss:20b');
|
||||||
|
vi.mocked(ollamaDetection.getOllamaDownloadUrl).mockReturnValue('https://ollama.com/download');
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('when Ollama is not detected', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.mocked(ollamaDetection.checkOllamaStatus).mockResolvedValue({
|
||||||
|
isRunning: false,
|
||||||
|
host: 'http://127.0.0.1:11434',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show installation instructions', async () => {
|
||||||
|
render(<OllamaSetup onSuccess={mockOnSuccess} onCancel={mockOnCancel} />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Ollama Setup')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/Ollama is not detected on your system/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should provide download link', async () => {
|
||||||
|
render(<OllamaSetup onSuccess={mockOnSuccess} onCancel={mockOnCancel} />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const downloadLink = screen.getByRole('link', { name: /Install Ollama/ });
|
||||||
|
expect(downloadLink).toHaveAttribute('href', 'https://ollama.com/download');
|
||||||
|
expect(downloadLink).toHaveAttribute('target', '_blank');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show polling state when install link is clicked', async () => {
|
||||||
|
render(<OllamaSetup onSuccess={mockOnSuccess} onCancel={mockOnCancel} />);
|
||||||
|
|
||||||
|
// Mock pollForOllama
|
||||||
|
const mockStopPolling = vi.fn();
|
||||||
|
vi.mocked(ollamaDetection.pollForOllama).mockReturnValue(mockStopPolling);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const installLink = screen.getByText('Install Ollama');
|
||||||
|
fireEvent.click(installLink);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getByText(/Waiting for Ollama to start/)).toBeInTheDocument();
|
||||||
|
expect(ollamaDetection.pollForOllama).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle cancel button', async () => {
|
||||||
|
render(<OllamaSetup onSuccess={mockOnSuccess} onCancel={mockOnCancel} />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
fireEvent.click(screen.getByText('Use a different provider'));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockOnCancel).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('when Ollama is detected but model is not available', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.mocked(ollamaDetection.checkOllamaStatus).mockResolvedValue({
|
||||||
|
isRunning: true,
|
||||||
|
host: 'http://127.0.0.1:11434',
|
||||||
|
});
|
||||||
|
vi.mocked(ollamaDetection.hasModel).mockResolvedValue(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show model download prompt', async () => {
|
||||||
|
render(<OllamaSetup onSuccess={mockOnSuccess} onCancel={mockOnCancel} />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText(/The gpt-oss:20b model is not installed/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/Download gpt-oss:20b/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle model download', async () => {
|
||||||
|
vi.mocked(ollamaDetection.pullOllamaModel).mockResolvedValue(true);
|
||||||
|
|
||||||
|
render(<OllamaSetup onSuccess={mockOnSuccess} onCancel={mockOnCancel} />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
fireEvent.click(screen.getByText(/Download gpt-oss:20b/));
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(toastService.success).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
title: 'Model Downloaded!',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle download failure', async () => {
|
||||||
|
vi.mocked(ollamaDetection.pullOllamaModel).mockResolvedValue(false);
|
||||||
|
|
||||||
|
render(<OllamaSetup onSuccess={mockOnSuccess} onCancel={mockOnCancel} />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
fireEvent.click(screen.getByText(/Download gpt-oss:20b/));
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(toastService.error).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
title: 'Download Failed',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('when Ollama and model are both available', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.mocked(ollamaDetection.checkOllamaStatus).mockResolvedValue({
|
||||||
|
isRunning: true,
|
||||||
|
host: 'http://127.0.0.1:11434',
|
||||||
|
});
|
||||||
|
vi.mocked(ollamaDetection.hasModel).mockResolvedValue(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show ready state and connect button', async () => {
|
||||||
|
render(<OllamaSetup onSuccess={mockOnSuccess} onCancel={mockOnCancel} />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText(/Ollama is running on your system/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle successful connection', async () => {
|
||||||
|
vi.mocked(providerUtils.initializeSystem).mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
render(<OllamaSetup onSuccess={mockOnSuccess} onCancel={mockOnCancel} />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
fireEvent.click(screen.getByText(/Use Goose with Ollama/));
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockUpsert).toHaveBeenCalledWith('GOOSE_PROVIDER', 'ollama', false);
|
||||||
|
expect(mockUpsert).toHaveBeenCalledWith('GOOSE_MODEL', 'gpt-oss:20b', false);
|
||||||
|
expect(mockUpsert).toHaveBeenCalledWith('OLLAMA_HOST', 'localhost', false);
|
||||||
|
expect(providerUtils.initializeSystem).toHaveBeenCalledWith(
|
||||||
|
'ollama',
|
||||||
|
'gpt-oss:20b',
|
||||||
|
expect.any(Object)
|
||||||
|
);
|
||||||
|
expect(toastService.success).toHaveBeenCalled();
|
||||||
|
expect(mockOnSuccess).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle connection failure', async () => {
|
||||||
|
const testError = new Error('Initialization failed');
|
||||||
|
vi.mocked(providerUtils.initializeSystem).mockRejectedValue(testError);
|
||||||
|
|
||||||
|
render(<OllamaSetup onSuccess={mockOnSuccess} onCancel={mockOnCancel} />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
fireEvent.click(screen.getByText('Use Goose with Ollama'));
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(toastService.error).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
title: 'Connection Failed',
|
||||||
|
msg: expect.stringContaining('Initialization failed'),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('polling behavior', () => {
|
||||||
|
it('should clean up polling on unmount', async () => {
|
||||||
|
const mockStopPolling = vi.fn();
|
||||||
|
vi.mocked(ollamaDetection.pollForOllama).mockReturnValue(mockStopPolling);
|
||||||
|
|
||||||
|
vi.mocked(ollamaDetection.checkOllamaStatus).mockResolvedValue({
|
||||||
|
isRunning: false,
|
||||||
|
host: 'http://127.0.0.1:11434',
|
||||||
|
});
|
||||||
|
|
||||||
|
const { unmount } = render(<OllamaSetup onSuccess={mockOnSuccess} onCancel={mockOnCancel} />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
fireEvent.click(screen.getByText('Install Ollama'));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(ollamaDetection.pollForOllama).toHaveBeenCalled();
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
|
||||||
|
expect(mockStopPolling).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle Ollama detection during polling', async () => {
|
||||||
|
vi.mocked(ollamaDetection.checkOllamaStatus).mockResolvedValue({
|
||||||
|
isRunning: false,
|
||||||
|
host: 'http://127.0.0.1:11434',
|
||||||
|
});
|
||||||
|
|
||||||
|
let pollCallback: ((status: { isRunning: boolean; host: string }) => void) | undefined;
|
||||||
|
vi.mocked(ollamaDetection.pollForOllama).mockImplementation((onDetected) => {
|
||||||
|
pollCallback = onDetected;
|
||||||
|
return vi.fn();
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<OllamaSetup onSuccess={mockOnSuccess} onCancel={mockOnCancel} />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
fireEvent.click(screen.getByText('Install Ollama'));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getByText(/Waiting for Ollama/)).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Simulate Ollama being detected
|
||||||
|
vi.mocked(ollamaDetection.hasModel).mockResolvedValue(true);
|
||||||
|
pollCallback!({ isRunning: true, host: 'http://127.0.0.1:11434' });
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('✓ Ollama is running on your system')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('error states', () => {
|
||||||
|
it('should handle errors during initial check', async () => {
|
||||||
|
// Mock checkOllamaStatus to resolve with isRunning: false after an error
|
||||||
|
vi.mocked(ollamaDetection.checkOllamaStatus).mockResolvedValue({
|
||||||
|
isRunning: false,
|
||||||
|
host: 'http://127.0.0.1:11434',
|
||||||
|
error: 'Network error',
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<OllamaSetup onSuccess={mockOnSuccess} onCancel={mockOnCancel} />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
// Should still show not detected state
|
||||||
|
expect(screen.getByText('Ollama is not detected on your system')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,260 @@
|
|||||||
|
import { useState, useEffect, useRef } from 'react';
|
||||||
|
import { useConfig } from './ConfigContext';
|
||||||
|
import {
|
||||||
|
checkOllamaStatus,
|
||||||
|
getOllamaDownloadUrl,
|
||||||
|
pollForOllama,
|
||||||
|
hasModel,
|
||||||
|
pullOllamaModel,
|
||||||
|
getPreferredModel,
|
||||||
|
type PullProgress,
|
||||||
|
} from '../utils/ollamaDetection';
|
||||||
|
import { initializeSystem } from '../utils/providerUtils';
|
||||||
|
import { toastService } from '../toasts';
|
||||||
|
|
||||||
|
interface OllamaSetupProps {
|
||||||
|
onSuccess: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OllamaSetup({ onSuccess, onCancel }: OllamaSetupProps) {
|
||||||
|
const { addExtension, getExtensions, upsert } = useConfig();
|
||||||
|
const [isChecking, setIsChecking] = useState(true);
|
||||||
|
const [ollamaDetected, setOllamaDetected] = useState(false);
|
||||||
|
const [isPolling, setIsPolling] = useState(false);
|
||||||
|
const [isConnecting, setIsConnecting] = useState(false);
|
||||||
|
const [modelStatus, setModelStatus] = useState<
|
||||||
|
'checking' | 'available' | 'not-available' | 'downloading'
|
||||||
|
>('checking');
|
||||||
|
const [downloadProgress, setDownloadProgress] = useState<PullProgress | null>(null);
|
||||||
|
const stopPollingRef = useRef<(() => void) | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Check if Ollama is already running
|
||||||
|
const checkInitial = async () => {
|
||||||
|
const status = await checkOllamaStatus();
|
||||||
|
setOllamaDetected(status.isRunning);
|
||||||
|
|
||||||
|
// If Ollama is running, check for the preferred model
|
||||||
|
if (status.isRunning) {
|
||||||
|
const modelAvailable = await hasModel(getPreferredModel());
|
||||||
|
setModelStatus(modelAvailable ? 'available' : 'not-available');
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsChecking(false);
|
||||||
|
};
|
||||||
|
checkInitial();
|
||||||
|
|
||||||
|
// Cleanup polling on unmount
|
||||||
|
return () => {
|
||||||
|
if (stopPollingRef.current) {
|
||||||
|
stopPollingRef.current();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleInstallClick = () => {
|
||||||
|
setIsPolling(true);
|
||||||
|
|
||||||
|
// Start polling for Ollama
|
||||||
|
stopPollingRef.current = pollForOllama(
|
||||||
|
async (status) => {
|
||||||
|
setOllamaDetected(status.isRunning);
|
||||||
|
setIsPolling(false);
|
||||||
|
|
||||||
|
// Check for the model
|
||||||
|
const modelAvailable = await hasModel(getPreferredModel());
|
||||||
|
setModelStatus(modelAvailable ? 'available' : 'not-available');
|
||||||
|
|
||||||
|
toastService.success({
|
||||||
|
title: 'Ollama Detected!',
|
||||||
|
msg: 'Ollama is now running. You can connect to it.',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
3000 // Check every 3 seconds
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDownloadModel = async () => {
|
||||||
|
setModelStatus('downloading');
|
||||||
|
setDownloadProgress({ status: 'Starting download...' });
|
||||||
|
|
||||||
|
const success = await pullOllamaModel(getPreferredModel(), (progress) => {
|
||||||
|
setDownloadProgress(progress);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
setModelStatus('available');
|
||||||
|
toastService.success({
|
||||||
|
title: 'Model Downloaded!',
|
||||||
|
msg: `Successfully downloaded ${getPreferredModel()}`,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setModelStatus('not-available');
|
||||||
|
toastService.error({
|
||||||
|
title: 'Download Failed',
|
||||||
|
msg: `Failed to download ${getPreferredModel()}. Please try again.`,
|
||||||
|
traceback: '',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setDownloadProgress(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConnectOllama = async () => {
|
||||||
|
setIsConnecting(true);
|
||||||
|
try {
|
||||||
|
// Set up Ollama configuration
|
||||||
|
await upsert('GOOSE_PROVIDER', 'ollama', false);
|
||||||
|
await upsert('GOOSE_MODEL', getPreferredModel(), false);
|
||||||
|
await upsert('OLLAMA_HOST', 'localhost', false);
|
||||||
|
|
||||||
|
// Initialize the system with Ollama
|
||||||
|
await initializeSystem('ollama', getPreferredModel(), {
|
||||||
|
getExtensions,
|
||||||
|
addExtension,
|
||||||
|
});
|
||||||
|
|
||||||
|
toastService.success({
|
||||||
|
title: 'Success!',
|
||||||
|
msg: `Connected to Ollama with ${getPreferredModel()} model.`,
|
||||||
|
});
|
||||||
|
|
||||||
|
onSuccess();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to connect to Ollama:', error);
|
||||||
|
toastService.error({
|
||||||
|
title: 'Connection Failed',
|
||||||
|
msg: `Failed to connect to Ollama: ${error instanceof Error ? error.message : String(error)}`,
|
||||||
|
traceback: error instanceof Error ? error.stack || '' : '',
|
||||||
|
});
|
||||||
|
setIsConnecting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isChecking) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-center py-8">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-t-2 border-b-2 border-textStandard"></div>
|
||||||
|
</div>
|
||||||
|
<p className="text-center text-text-muted">Checking for Ollama...</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="text-center">
|
||||||
|
<h3 className="text-lg font-semibold text-text-standard mb-2">Ollama Setup</h3>
|
||||||
|
<p className="text-text-muted">
|
||||||
|
Ollama lets you run AI models for free, private and locally on your computer.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{ollamaDetected ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="bg-background-success/10 border border-border-success rounded-lg p-4">
|
||||||
|
<p className="text-text-success text-center">✓ Ollama is running on your system</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{modelStatus === 'checking' ? (
|
||||||
|
<div className="flex items-center justify-center py-4">
|
||||||
|
<div className="animate-spin rounded-full h-6 w-6 border-t-2 border-b-2 border-textStandard"></div>
|
||||||
|
</div>
|
||||||
|
) : modelStatus === 'not-available' ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="bg-background-warning/10 border border-border-warning rounded-lg p-4">
|
||||||
|
<p className="text-text-warning text-center text-sm">
|
||||||
|
The {getPreferredModel()} model is not installed
|
||||||
|
</p>
|
||||||
|
<p className="text-text-muted text-center text-xs mt-1">
|
||||||
|
This model is recommended for the best experience with Goose
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleDownloadModel}
|
||||||
|
disabled={false}
|
||||||
|
className="w-full px-6 py-3 bg-background-muted text-text-standard rounded-lg hover:bg-background-hover transition-colors font-medium flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
Download {getPreferredModel()} (~11GB)
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : modelStatus === 'downloading' ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="bg-background-info/10 border border-border-info rounded-lg p-4">
|
||||||
|
<p className="text-text-info text-center text-sm">
|
||||||
|
Downloading {getPreferredModel()}...
|
||||||
|
</p>
|
||||||
|
{downloadProgress && (
|
||||||
|
<>
|
||||||
|
<p className="text-text-muted text-center text-xs mt-2">
|
||||||
|
{downloadProgress.status}
|
||||||
|
</p>
|
||||||
|
{downloadProgress.total && downloadProgress.completed && (
|
||||||
|
<div className="mt-3">
|
||||||
|
<div className="bg-background-muted rounded-full h-2 overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="bg-background-primary h-full transition-all duration-300"
|
||||||
|
style={{
|
||||||
|
width: `${(downloadProgress.completed / downloadProgress.total) * 100}%`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-text-muted text-center text-xs mt-1">
|
||||||
|
{Math.round((downloadProgress.completed / downloadProgress.total) * 100)}%
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={handleConnectOllama}
|
||||||
|
disabled={isConnecting}
|
||||||
|
className="w-full px-6 py-3 bg-background-muted text-text-standard rounded-lg hover:bg-background-hover transition-colors font-medium flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
{isConnecting ? 'Connecting...' : 'Use Goose with Ollama'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="bg-background-warning/10 border border-border-warning rounded-lg p-4">
|
||||||
|
<p className="text-text-warning text-center">Ollama is not detected on your system</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isPolling ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-center py-4">
|
||||||
|
<div className="animate-spin rounded-full h-6 w-6 border-t-2 border-b-2 border-textStandard"></div>
|
||||||
|
</div>
|
||||||
|
<p className="text-center text-text-muted text-sm">Waiting for Ollama to start...</p>
|
||||||
|
<p className="text-center text-text-muted text-xs">
|
||||||
|
Once Ollama is installed and running, we'll automatically detect it.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<a
|
||||||
|
href={getOllamaDownloadUrl()}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
onClick={handleInstallClick}
|
||||||
|
className="block w-full px-6 py-3 bg-background-muted text-text-standard rounded-lg hover:bg-background-hover transition-colors font-medium text-center"
|
||||||
|
>
|
||||||
|
Install Ollama
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={onCancel}
|
||||||
|
className="w-full px-6 py-3 bg-transparent text-text-muted rounded-lg hover:bg-background-muted transition-colors"
|
||||||
|
>
|
||||||
|
Use a different provider
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,6 +6,8 @@ import { startOpenRouterSetup } from '../utils/openRouterSetup';
|
|||||||
import WelcomeGooseLogo from './WelcomeGooseLogo';
|
import WelcomeGooseLogo from './WelcomeGooseLogo';
|
||||||
import { initializeSystem } from '../utils/providerUtils';
|
import { initializeSystem } from '../utils/providerUtils';
|
||||||
import { toastService } from '../toasts';
|
import { toastService } from '../toasts';
|
||||||
|
import { OllamaSetup } from './OllamaSetup';
|
||||||
|
import { checkOllamaStatus } from '../utils/ollamaDetection';
|
||||||
|
|
||||||
interface ProviderGuardProps {
|
interface ProviderGuardProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
@@ -17,6 +19,8 @@ export default function ProviderGuard({ children }: ProviderGuardProps) {
|
|||||||
const [isChecking, setIsChecking] = useState(true);
|
const [isChecking, setIsChecking] = useState(true);
|
||||||
const [hasProvider, setHasProvider] = useState(false);
|
const [hasProvider, setHasProvider] = useState(false);
|
||||||
const [showFirstTimeSetup, setShowFirstTimeSetup] = useState(false);
|
const [showFirstTimeSetup, setShowFirstTimeSetup] = useState(false);
|
||||||
|
const [showOllamaSetup, setShowOllamaSetup] = useState(false);
|
||||||
|
const [ollamaDetected, setOllamaDetected] = useState(false);
|
||||||
const [openRouterSetupState, setOpenRouterSetupState] = useState<{
|
const [openRouterSetupState, setOpenRouterSetupState] = useState<{
|
||||||
show: boolean;
|
show: boolean;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -101,11 +105,15 @@ export default function ProviderGuard({ children }: ProviderGuardProps) {
|
|||||||
const provider = (await read('GOOSE_PROVIDER', false)) ?? config.GOOSE_DEFAULT_PROVIDER;
|
const provider = (await read('GOOSE_PROVIDER', false)) ?? config.GOOSE_DEFAULT_PROVIDER;
|
||||||
const model = (await read('GOOSE_MODEL', false)) ?? config.GOOSE_DEFAULT_MODEL;
|
const model = (await read('GOOSE_MODEL', false)) ?? config.GOOSE_DEFAULT_MODEL;
|
||||||
|
|
||||||
|
// Always check for Ollama regardless of provider status
|
||||||
|
const ollamaStatus = await checkOllamaStatus();
|
||||||
|
setOllamaDetected(ollamaStatus.isRunning);
|
||||||
|
|
||||||
if (provider && model) {
|
if (provider && model) {
|
||||||
console.log('ProviderGuard - Provider and model found, continuing normally');
|
console.log('ProviderGuard - Provider and model found, continuing normally');
|
||||||
setHasProvider(true);
|
setHasProvider(true);
|
||||||
} else {
|
} else {
|
||||||
console.log('ProviderGuard - No provider/model configured, showing first time setup');
|
console.log('ProviderGuard - No provider/model configured');
|
||||||
setShowFirstTimeSetup(true);
|
setShowFirstTimeSetup(true);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -121,7 +129,24 @@ export default function ProviderGuard({ children }: ProviderGuardProps) {
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [read]);
|
}, [read]);
|
||||||
|
|
||||||
if (isChecking && !openRouterSetupState?.show && !showFirstTimeSetup) {
|
// Poll for Ollama status while the first time setup is shown
|
||||||
|
useEffect(() => {
|
||||||
|
if (!showFirstTimeSetup) return;
|
||||||
|
|
||||||
|
const checkOllama = async () => {
|
||||||
|
const status = await checkOllamaStatus();
|
||||||
|
setOllamaDetected(status.isRunning);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check every 3 seconds
|
||||||
|
const interval = window.setInterval(checkOllama, 3000);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.clearInterval(interval);
|
||||||
|
};
|
||||||
|
}, [showFirstTimeSetup]);
|
||||||
|
|
||||||
|
if (isChecking && !openRouterSetupState?.show && !showFirstTimeSetup && !showOllamaSetup) {
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-center items-center py-12">
|
<div className="flex justify-center items-center py-12">
|
||||||
<div className="animate-spin rounded-full h-8 w-8 border-t-2 border-b-2 border-textStandard"></div>
|
<div className="animate-spin rounded-full h-8 w-8 border-t-2 border-b-2 border-textStandard"></div>
|
||||||
@@ -143,6 +168,28 @@ export default function ProviderGuard({ children }: ProviderGuardProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (showOllamaSetup) {
|
||||||
|
return (
|
||||||
|
<div className="h-screen w-full flex flex-col items-center justify-center bg-background-default">
|
||||||
|
<div className="max-w-md w-full mx-auto p-8">
|
||||||
|
<div className="mb-8 text-center">
|
||||||
|
<WelcomeGooseLogo />
|
||||||
|
</div>
|
||||||
|
<OllamaSetup
|
||||||
|
onSuccess={() => {
|
||||||
|
setShowOllamaSetup(false);
|
||||||
|
setHasProvider(true);
|
||||||
|
}}
|
||||||
|
onCancel={() => {
|
||||||
|
setShowOllamaSetup(false);
|
||||||
|
setShowFirstTimeSetup(true);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (showFirstTimeSetup) {
|
if (showFirstTimeSetup) {
|
||||||
return (
|
return (
|
||||||
<div className="h-screen w-full flex flex-col items-center justify-center bg-background-default">
|
<div className="h-screen w-full flex flex-col items-center justify-center bg-background-default">
|
||||||
@@ -156,11 +203,28 @@ export default function ProviderGuard({ children }: ProviderGuardProps) {
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<button
|
<button
|
||||||
onClick={handleOpenRouterSetup}
|
onClick={handleOpenRouterSetup}
|
||||||
className="w-full px-6 py-3 bg-background-muted text-text-standard rounded-lg hover:bg-background-hover transition-colors font-medium"
|
className="w-full px-6 py-3 bg-background-muted text-text-standard rounded-lg hover:bg-background-hover transition-colors font-medium flex items-center justify-center gap-2"
|
||||||
>
|
>
|
||||||
Automatic setup with OpenRouter (recommended)
|
Automatic setup with OpenRouter (recommended)
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setShowFirstTimeSetup(false);
|
||||||
|
setShowOllamaSetup(true);
|
||||||
|
}}
|
||||||
|
className="w-full px-6 py-3 bg-background-muted text-text-standard rounded-lg hover:bg-background-hover transition-colors font-medium flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
{ollamaDetected ? (
|
||||||
|
<>
|
||||||
|
<span className="text-text-success">●</span>
|
||||||
|
Use Ollama (auto detected)
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Set up Ollama (run AI locally and free)'
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate('/welcome', { replace: true })}
|
onClick={() => navigate('/welcome', { replace: true })}
|
||||||
className="w-full px-6 py-3 bg-background-muted text-text-standard rounded-lg hover:bg-background-hover transition-colors font-medium"
|
className="w-full px-6 py-3 bg-background-muted text-text-standard rounded-lg hover:bg-background-hover transition-colors font-medium"
|
||||||
@@ -170,8 +234,10 @@ export default function ProviderGuard({ children }: ProviderGuardProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-sm text-text-muted mt-6">
|
<p className="text-sm text-text-muted mt-6">
|
||||||
OpenRouter provides access to multiple AI models. To use this it will need to create an
|
OpenRouter provides instant access to multiple AI models with a simple setup.
|
||||||
account with OpenRouter.
|
{ollamaDetected
|
||||||
|
? ' Ollama is also detected on your system for running models locally.'
|
||||||
|
: ' You can also install Ollama to run free AI models locally on your computer.'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,323 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
|
/* global AbortSignal, TextEncoder, Event, EventListener */
|
||||||
|
|
||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
import {
|
||||||
|
checkOllamaStatus,
|
||||||
|
getOllamaModels,
|
||||||
|
hasModel,
|
||||||
|
pullOllamaModel,
|
||||||
|
pollForOllama,
|
||||||
|
getOllamaDownloadUrl,
|
||||||
|
getPreferredModel,
|
||||||
|
} from './ollamaDetection';
|
||||||
|
|
||||||
|
// Mock fetch globally
|
||||||
|
globalThis.fetch = vi.fn();
|
||||||
|
|
||||||
|
// Define global objects for testing environment if they don't exist
|
||||||
|
if (typeof globalThis.AbortSignal === 'undefined') {
|
||||||
|
globalThis.AbortSignal = class AbortSignal {
|
||||||
|
aborted = false;
|
||||||
|
reason: any = undefined;
|
||||||
|
onabort: ((this: AbortSignal, ev: Event) => any) | null = null;
|
||||||
|
|
||||||
|
addEventListener(_type: string, _listener: EventListener): void {
|
||||||
|
// Mock implementation
|
||||||
|
}
|
||||||
|
|
||||||
|
removeEventListener(_type: string, _listener: EventListener): void {
|
||||||
|
// Mock implementation
|
||||||
|
}
|
||||||
|
|
||||||
|
dispatchEvent(_event: Event): boolean {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} as any;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof globalThis.TextEncoder === 'undefined') {
|
||||||
|
globalThis.TextEncoder = class TextEncoder {
|
||||||
|
encode(str: string): Uint8Array {
|
||||||
|
return new Uint8Array(str.split('').map((c) => c.charCodeAt(0)));
|
||||||
|
}
|
||||||
|
} as any;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ollamaDetection', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
vi.useFakeTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('checkOllamaStatus', () => {
|
||||||
|
it('should return isRunning: true when Ollama is accessible', async () => {
|
||||||
|
(globalThis.fetch as any).mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ models: [] }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await checkOllamaStatus();
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
isRunning: true,
|
||||||
|
host: 'http://127.0.0.1:11434',
|
||||||
|
});
|
||||||
|
expect(globalThis.fetch).toHaveBeenCalledWith('http://127.0.0.1:11434/api/tags', {
|
||||||
|
method: 'GET',
|
||||||
|
signal: expect.any(globalThis.AbortSignal),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return isRunning: false when Ollama is not accessible', async () => {
|
||||||
|
(globalThis.fetch as any).mockRejectedValueOnce(new Error('Connection refused'));
|
||||||
|
|
||||||
|
const result = await checkOllamaStatus();
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
isRunning: false,
|
||||||
|
host: 'http://127.0.0.1:11434',
|
||||||
|
error: 'Connection refused',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should timeout after 2 seconds', async () => {
|
||||||
|
let abortSignal: AbortSignal | undefined;
|
||||||
|
|
||||||
|
(globalThis.fetch as any).mockImplementationOnce((_url: string, options: any) => {
|
||||||
|
abortSignal = options.signal;
|
||||||
|
return new Promise((_, reject) => {
|
||||||
|
// Listen for abort signal
|
||||||
|
options.signal.addEventListener('abort', () => {
|
||||||
|
reject(new Error('The operation was aborted'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const checkPromise = checkOllamaStatus();
|
||||||
|
|
||||||
|
// Fast-forward 2 seconds
|
||||||
|
vi.advanceTimersByTime(2000);
|
||||||
|
|
||||||
|
// The abort signal should be triggered
|
||||||
|
expect(abortSignal?.aborted).toBe(true);
|
||||||
|
|
||||||
|
const result = await checkPromise;
|
||||||
|
expect(result.isRunning).toBe(false);
|
||||||
|
expect(result.error).toBe('The operation was aborted');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getOllamaModels', () => {
|
||||||
|
it('should return models when API call is successful', async () => {
|
||||||
|
const mockModels = [
|
||||||
|
{ name: 'llama2:latest', size: 4733363377, digest: 'abc123', modified_at: '2023-10-01' },
|
||||||
|
{ name: 'gpt-oss:20b', size: 13780173839, digest: 'def456', modified_at: '2023-10-02' },
|
||||||
|
];
|
||||||
|
|
||||||
|
(globalThis.fetch as any).mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ models: mockModels }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await getOllamaModels();
|
||||||
|
|
||||||
|
expect(result).toEqual(mockModels);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return empty array when API call fails', async () => {
|
||||||
|
(globalThis.fetch as any).mockRejectedValueOnce(new Error('Network error'));
|
||||||
|
|
||||||
|
const result = await getOllamaModels();
|
||||||
|
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle non-ok responses', async () => {
|
||||||
|
(globalThis.fetch as any).mockResolvedValueOnce({
|
||||||
|
ok: false,
|
||||||
|
statusText: 'Not Found',
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await getOllamaModels();
|
||||||
|
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('hasModel', () => {
|
||||||
|
it('should return true when model exists', async () => {
|
||||||
|
(globalThis.fetch as any).mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
models: [
|
||||||
|
{
|
||||||
|
name: 'llama2:latest',
|
||||||
|
size: 4733363377,
|
||||||
|
digest: 'abc123',
|
||||||
|
modified_at: '2023-10-01',
|
||||||
|
},
|
||||||
|
{ name: 'gpt-oss:20b', size: 13780173839, digest: 'def456', modified_at: '2023-10-02' },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await hasModel('gpt-oss:20b');
|
||||||
|
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false when model does not exist', async () => {
|
||||||
|
(globalThis.fetch as any).mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
models: [
|
||||||
|
{
|
||||||
|
name: 'llama2:latest',
|
||||||
|
size: 4733363377,
|
||||||
|
digest: 'abc123',
|
||||||
|
modified_at: '2023-10-01',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await hasModel('gpt-oss:20b');
|
||||||
|
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('pullOllamaModel', () => {
|
||||||
|
it('should successfully pull a model and report progress', async () => {
|
||||||
|
const progressUpdates: any[] = [];
|
||||||
|
const onProgress = vi.fn((progress) => progressUpdates.push(progress));
|
||||||
|
|
||||||
|
const mockResponse = {
|
||||||
|
ok: true,
|
||||||
|
body: {
|
||||||
|
getReader: () => ({
|
||||||
|
read: vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
done: false,
|
||||||
|
value: new TextEncoder().encode(
|
||||||
|
JSON.stringify({ status: 'downloading', completed: 100, total: 1000 }) + '\n'
|
||||||
|
),
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
done: false,
|
||||||
|
value: new TextEncoder().encode(
|
||||||
|
JSON.stringify({ status: 'downloading', completed: 500, total: 1000 }) + '\n'
|
||||||
|
),
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
done: false,
|
||||||
|
value: new TextEncoder().encode(JSON.stringify({ status: 'success' }) + '\n'),
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({ done: true }),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
(globalThis.fetch as any).mockResolvedValueOnce(mockResponse);
|
||||||
|
|
||||||
|
const result = await pullOllamaModel('gpt-oss:20b', onProgress);
|
||||||
|
|
||||||
|
expect(result).toBe(true);
|
||||||
|
expect(onProgress).toHaveBeenCalledTimes(3);
|
||||||
|
expect(progressUpdates).toContainEqual({
|
||||||
|
status: 'downloading',
|
||||||
|
completed: 100,
|
||||||
|
total: 1000,
|
||||||
|
});
|
||||||
|
expect(progressUpdates).toContainEqual({
|
||||||
|
status: 'downloading',
|
||||||
|
completed: 500,
|
||||||
|
total: 1000,
|
||||||
|
});
|
||||||
|
expect(progressUpdates).toContainEqual({ status: 'success' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false on API error', async () => {
|
||||||
|
(globalThis.fetch as any).mockResolvedValueOnce({
|
||||||
|
ok: false,
|
||||||
|
statusText: 'Model not found',
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await pullOllamaModel('invalid-model');
|
||||||
|
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('pollForOllama', () => {
|
||||||
|
it('should poll until Ollama is detected', async () => {
|
||||||
|
const onDetected = vi.fn();
|
||||||
|
|
||||||
|
// First call: Ollama not running
|
||||||
|
(globalThis.fetch as any).mockRejectedValueOnce(new Error('Connection refused'));
|
||||||
|
|
||||||
|
const stopPolling = pollForOllama(onDetected, 100);
|
||||||
|
|
||||||
|
// Verify initial call was made
|
||||||
|
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
// Second call: Still not running
|
||||||
|
(globalThis.fetch as any).mockRejectedValueOnce(new Error('Connection refused'));
|
||||||
|
vi.advanceTimersByTime(100);
|
||||||
|
|
||||||
|
// Third call: Ollama is running
|
||||||
|
(globalThis.fetch as any).mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ models: [] }),
|
||||||
|
});
|
||||||
|
vi.advanceTimersByTime(100);
|
||||||
|
|
||||||
|
// Wait for async operations
|
||||||
|
await vi.runAllTimersAsync();
|
||||||
|
|
||||||
|
expect(onDetected).toHaveBeenCalledWith({
|
||||||
|
isRunning: true,
|
||||||
|
host: 'http://127.0.0.1:11434',
|
||||||
|
});
|
||||||
|
|
||||||
|
stopPolling();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should stop polling when stop function is called', () => {
|
||||||
|
const onDetected = vi.fn();
|
||||||
|
|
||||||
|
(globalThis.fetch as any).mockRejectedValue(new Error('Connection refused'));
|
||||||
|
|
||||||
|
const stopPolling = pollForOllama(onDetected, 100);
|
||||||
|
|
||||||
|
// Should make initial call
|
||||||
|
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
// Stop polling
|
||||||
|
stopPolling();
|
||||||
|
|
||||||
|
// Advance time and verify no more calls are made
|
||||||
|
vi.advanceTimersByTime(500);
|
||||||
|
|
||||||
|
// Only the initial call should have been made
|
||||||
|
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
|
||||||
|
expect(onDetected).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('utility functions', () => {
|
||||||
|
it('should return correct download URL', () => {
|
||||||
|
expect(getOllamaDownloadUrl()).toBe('https://ollama.com/download');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return correct preferred model', () => {
|
||||||
|
expect(getPreferredModel()).toBe('gpt-oss:20b');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
const DEFAULT_OLLAMA_HOST = 'http://127.0.0.1:11434';
|
||||||
|
const OLLAMA_DOWNLOAD_URL = 'https://ollama.com/download';
|
||||||
|
const PREFERRED_MODEL = 'gpt-oss:20b';
|
||||||
|
|
||||||
|
export interface OllamaStatus {
|
||||||
|
isRunning: boolean;
|
||||||
|
host: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OllamaModel {
|
||||||
|
name: string;
|
||||||
|
size: number;
|
||||||
|
digest: string;
|
||||||
|
modified_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PullProgress {
|
||||||
|
status: string;
|
||||||
|
digest?: string;
|
||||||
|
total?: number;
|
||||||
|
completed?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if Ollama is running on the default port
|
||||||
|
*/
|
||||||
|
export async function checkOllamaStatus(): Promise<OllamaStatus> {
|
||||||
|
try {
|
||||||
|
// Create an AbortController for timeout
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeoutId = window.setTimeout(() => controller.abort(), 2000);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Ollama exposes a health endpoint at /api/tags
|
||||||
|
const response = await fetch(`${DEFAULT_OLLAMA_HOST}/api/tags`, {
|
||||||
|
method: 'GET',
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
window.clearTimeout(timeoutId);
|
||||||
|
|
||||||
|
return {
|
||||||
|
isRunning: response.ok,
|
||||||
|
host: DEFAULT_OLLAMA_HOST,
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
window.clearTimeout(timeoutId);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
isRunning: false,
|
||||||
|
host: DEFAULT_OLLAMA_HOST,
|
||||||
|
error: error instanceof Error ? error.message : 'Unknown error',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the Ollama download URL
|
||||||
|
*/
|
||||||
|
export function getOllamaDownloadUrl(): string {
|
||||||
|
return OLLAMA_DOWNLOAD_URL;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the preferred model name
|
||||||
|
*/
|
||||||
|
export function getPreferredModel(): string {
|
||||||
|
return PREFERRED_MODEL;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check which models are available in Ollama
|
||||||
|
*/
|
||||||
|
export async function getOllamaModels(): Promise<OllamaModel[]> {
|
||||||
|
try {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeoutId = window.setTimeout(() => controller.abort(), 5000);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${DEFAULT_OLLAMA_HOST}/api/tags`, {
|
||||||
|
method: 'GET',
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
window.clearTimeout(timeoutId);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to get models: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
return data.models || [];
|
||||||
|
} catch (err) {
|
||||||
|
window.clearTimeout(timeoutId);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to get Ollama models:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a specific model is available
|
||||||
|
*/
|
||||||
|
export async function hasModel(modelName: string): Promise<boolean> {
|
||||||
|
const models = await getOllamaModels();
|
||||||
|
return models.some((model) => model.name === modelName);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pull a model from Ollama
|
||||||
|
*/
|
||||||
|
export async function pullOllamaModel(
|
||||||
|
modelName: string,
|
||||||
|
onProgress?: (progress: PullProgress) => void
|
||||||
|
): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${DEFAULT_OLLAMA_HOST}/api/pull`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: modelName,
|
||||||
|
stream: true,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to pull model: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = response.body?.getReader();
|
||||||
|
if (!reader) {
|
||||||
|
throw new Error('No response body');
|
||||||
|
}
|
||||||
|
|
||||||
|
const decoder = new window.TextDecoder();
|
||||||
|
let done = false;
|
||||||
|
|
||||||
|
while (!done) {
|
||||||
|
const { value, done: readerDone } = await reader.read();
|
||||||
|
done = readerDone;
|
||||||
|
|
||||||
|
if (value) {
|
||||||
|
const text = decoder.decode(value);
|
||||||
|
const lines = text.split('\n').filter((line) => line.trim());
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
try {
|
||||||
|
const progress = JSON.parse(line) as PullProgress;
|
||||||
|
if (onProgress) {
|
||||||
|
onProgress(progress);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Ignore parse errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to pull model:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Poll for Ollama availability
|
||||||
|
* @param onDetected Callback when Ollama is detected
|
||||||
|
* @param intervalMs Polling interval in milliseconds
|
||||||
|
* @returns A function to stop polling
|
||||||
|
*/
|
||||||
|
export function pollForOllama(
|
||||||
|
onDetected: (status: OllamaStatus) => void,
|
||||||
|
intervalMs: number = 5000
|
||||||
|
): () => void {
|
||||||
|
let intervalId: number | null = null;
|
||||||
|
let isPolling = true;
|
||||||
|
|
||||||
|
const poll = async () => {
|
||||||
|
if (!isPolling) return;
|
||||||
|
|
||||||
|
const status = await checkOllamaStatus();
|
||||||
|
if (status.isRunning) {
|
||||||
|
onDetected(status);
|
||||||
|
stopPolling();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const stopPolling = () => {
|
||||||
|
isPolling = false;
|
||||||
|
if (intervalId) {
|
||||||
|
window.clearInterval(intervalId);
|
||||||
|
intervalId = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Start polling immediately
|
||||||
|
poll();
|
||||||
|
|
||||||
|
// Then poll at intervals
|
||||||
|
intervalId = window.setInterval(poll, intervalMs);
|
||||||
|
|
||||||
|
return stopPolling;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user