Remove dead OllamaSetup onboarding flow (#7861)

Co-authored-by: Douwe Osinga <douwe@squareup.com>
This commit is contained in:
Douwe Osinga
2026-03-13 13:58:33 -04:00
committed by GitHub
parent 196ee3ccc8
commit 831cb9bb82
6 changed files with 0 additions and 1083 deletions
-4
View File
@@ -65,10 +65,6 @@ 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: () => ({
@@ -1,266 +0,0 @@
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 { toastService } from '../toasts';
// Mock dependencies
vi.mock('../utils/ollamaDetection');
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('Cancel'));
});
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',
})
);
});
});
});
// TODO: re-enable when we have ollama back in the onboarding
describe.skip('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 detected and running/)).toBeInTheDocument();
});
});
it('should handle successful connection', async () => {
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(toastService.success).toHaveBeenCalled();
expect(mockOnSuccess).toHaveBeenCalled();
});
});
it('should handle connection failure', async () => {
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 detected and running')).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();
});
});
});
});
-258
View File
@@ -1,258 +0,0 @@
import { useState, useEffect, useRef } from 'react';
import { useConfig } from './ConfigContext';
import {
checkOllamaStatus,
getOllamaDownloadUrl,
pollForOllama,
hasModel,
pullOllamaModel,
getPreferredModel,
type PullProgress,
} from '../utils/ollamaDetection';
import { toastService } from '../toasts';
import { Ollama } from './icons';
import { errorMessage } from '../utils/conversionUtils';
interface OllamaSetupProps {
onSuccess: () => void;
onCancel: () => void;
}
export function OllamaSetup({ onSuccess, onCancel }: OllamaSetupProps) {
//const { addExtension, getExtensions, upsert } = useConfig();
const { 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);
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: ${errorMessage(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"></div>
</div>
<p className="text-center text-text-secondary">Checking for Ollama...</p>
</div>
);
}
return (
<div className="space-y-6">
{/* Header with icon above heading - left aligned like onboarding cards */}
<div className="text-left">
<Ollama className="w-6 h-6 mb-3 text-text-primary" />
<h3 className="text-lg font-semibold text-text-primary mb-2">Ollama Setup</h3>
<p className="text-text-secondary">
Ollama lets you run AI models for free, private and locally on your computer.
</p>
</div>
{ollamaDetected ? (
<div className="space-y-4">
<div className="flex items-start mb-16">
<span className="inline-block px-2 py-1 text-xs font-medium bg-green-600 text-white rounded-full">
Ollama is detected and running
</span>
</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"></div>
</div>
) : modelStatus === 'not-available' ? (
<div className="space-y-4">
<div className="flex items-start mb-16">
<p className="text-text-warning text-sm">
The {getPreferredModel()} model is not installed
</p>
<p className="text-text-secondary 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-secondary text-text-primary rounded-lg 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-sm">Downloading {getPreferredModel()}...</p>
{downloadProgress && (
<>
<p className="text-text-secondary text-xs mt-2">{downloadProgress.status}</p>
{downloadProgress.total && downloadProgress.completed && (
<div className="mt-3">
<div className="bg-background-secondary rounded-full h-2 overflow-hidden">
<div
className="h-full transition-all duration-300"
style={{
width: `${(downloadProgress.completed / downloadProgress.total) * 100}%`,
}}
/>
</div>
<p className="text-text-secondary 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-secondary text-text-primary rounded-lg 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="flex items-start mb-16">
<span className="inline-block px-2 py-1 text-xs font-medium bg-orange-600 text-white rounded-full">
Ollama is not detected on your system
</span>
</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"></div>
</div>
<p className="text-text-secondary text-sm">Waiting for Ollama to start...</p>
<p className="text-text-secondary 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-secondary text-text-primary rounded-lg transition-colors font-medium text-center"
>
Install Ollama
</a>
)}
</div>
)}
<button
onClick={onCancel}
className="w-full px-6 py-3 bg-transparent text-text-secondary rounded-lg hover:bg-background-secondary transition-colors"
>
Cancel
</button>
</div>
);
}
@@ -7,7 +7,6 @@ import { startTetrateSetup } from '../utils/tetrateSetup';
import { startChatGptCodexSetup } from '../utils/chatgptCodexSetup';
import WelcomeGooseLogo from './WelcomeGooseLogo';
import { toastService } from '../toasts';
import { OllamaSetup } from './OllamaSetup';
import { LocalModelSetup } from './LocalModelSetup';
import ApiKeyTester from './ApiKeyTester';
import { SwitchModelModal } from './settings/models/subcomponents/SwitchModelModal';
@@ -34,7 +33,6 @@ export default function ProviderGuard({ didSelectProvider, children }: ProviderG
const [isChecking, setIsChecking] = useState(true);
const [hasProvider, setHasProvider] = useState(false);
const [showFirstTimeSetup, setShowFirstTimeSetup] = useState(false);
const [showOllamaSetup, setShowOllamaSetup] = useState(false);
const [showLocalModelSetup, setShowLocalModelSetup] = useState(false);
const [userInActiveSetup, setUserInActiveSetup] = useState(false);
const [showSwitchModelModal, setShowSwitchModelModal] = useState(false);
@@ -189,19 +187,6 @@ export default function ProviderGuard({ didSelectProvider, children }: ProviderG
}
};
const handleOllamaComplete = () => {
trackOnboardingCompleted('ollama');
setShowOllamaSetup(false);
setShowFirstTimeSetup(false);
setHasProvider(true);
navigate('/', { replace: true });
};
const handleOllamaCancel = () => {
trackOnboardingAbandoned('ollama_setup');
setShowOllamaSetup(false);
};
const handleLocalModelComplete = () => {
trackOnboardingCompleted('local');
setShowLocalModelSetup(false);
@@ -296,10 +281,6 @@ export default function ProviderGuard({ didSelectProvider, children }: ProviderG
);
}
if (showOllamaSetup) {
return <OllamaSetup onSuccess={handleOllamaComplete} onCancel={handleOllamaCancel} />;
}
if (showLocalModelSetup) {
return (
<div className="h-screen w-full bg-background-default overflow-hidden">
@@ -1,323 +0,0 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/* global AbortSignal, TextEncoder, 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');
});
});
});
-213
View File
@@ -1,213 +0,0 @@
import { errorMessage } from './conversionUtils';
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: errorMessage(error, '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 {
// 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;
}