diff --git a/README.md b/README.md index a618606..7a87de7 100644 --- a/README.md +++ b/README.md @@ -16,13 +16,19 @@ A Chromium-based browser with integrated local LLM capabilities for intelligent - 🛠️ Developer tools integration (F12) - 📄 Page printing and source viewing - 🔎 Zoom controls (Ctrl +/-/0) +- 🤖 Ollama/LLM integration with streaming inference +- 💬 Chat sidebar for AI conversations with model capability detection +- ⚡ Comprehensive model manager with download progress tracking +- 🎯 Vision-capable and text-only model support +- 🚀 Automatic GPU acceleration (CUDA, ROCm, Metal) +- ⭐ Default model selection and persistent settings ### Planned Features -- 🤖 Local multi-modal vision LLMs (no cloud dependency) -- 💬 Chat interface for page analysis and interaction -- 📥 Model management with downloads from Hugging Face -- 🔒 Privacy-first AI inference (all processing happens locally) -- ⚡ Powered by Ollama for efficient inference +- 🖼️ Vision model integration for screenshot analysis +- 📊 AI-powered page summarization and content extraction +- 📥 Model management UI with progress tracking +- 🏷️ Smart bookmarking with AI categorization +- 🔍 Semantic search across browsing history ## Tech Stack @@ -32,9 +38,10 @@ A Chromium-based browser with integrated local LLM capabilities for intelligent - **Tailwind CSS** - Utility-first styling - **Zustand** - Lightweight state management - **Better-SQLite3** - Local database for history and bookmarks +- **Axios** - HTTP client for Ollama API communication - **ESLint + Prettier** - Code quality and formatting - **Husky** - Git hooks for pre-commit checks -- **Ollama** - Local LLM inference engine (planned integration) +- **Ollama** - Local LLM inference engine ## Development @@ -42,7 +49,7 @@ A Chromium-based browser with integrated local LLM capabilities for intelligent - Node.js 18+ (LTS recommended) - npm or pnpm -- Ollama installed ([ollama.com](https://ollama.com)) +- Ollama installed ([ollama.com](https://ollama.com)) - Required for AI features ### Getting Started @@ -50,6 +57,12 @@ A Chromium-based browser with integrated local LLM capabilities for intelligent # Install dependencies npm install +# Start Ollama (required for AI features) +ollama serve + +# Pull a model (optional, for testing AI features) +ollama pull llama2 + # Start development server npm run dev @@ -67,11 +80,11 @@ open-browser/ ├── src/ │ ├── main/ # Electron main process │ │ ├── ipc/ # IPC handlers for renderer communication -│ │ ├── services/ # Database and backend services +│ │ ├── services/ # Backend services (database, ollama) │ │ └── utils/ # Validation and utilities │ ├── renderer/ # React UI │ │ ├── components/ # React components (Browser, Chat, etc.) -│ │ ├── store/ # Zustand state management +│ │ ├── store/ # Zustand state management (browser, chat, models) │ │ └── services/ # Frontend services │ └── shared/ # Shared types and utilities ├── .github/ # GitHub configuration and workflows @@ -103,14 +116,24 @@ See [TECH_BRIEFING.md](./TECH_BRIEFING.md) for comprehensive technical documenta - [x] Context menus and keyboard shortcuts - [x] Code quality tooling (ESLint, Prettier, Husky) - [x] CI/CD with GitHub Actions +- [x] Ollama service integration with auto-start capability +- [x] Chat interface with streaming message support +- [x] Comprehensive model manager UI with tabs +- [x] Model registry with 12+ pre-configured models +- [x] Vision vs text-only model capability tracking +- [x] Download progress tracking with real-time updates +- [x] Default model selection with persistent storage +- [x] Model metadata display (size, parameters, capabilities) +- [x] GPU acceleration support (automatic detection) +- [x] IPC handlers for secure LLM operations +- [x] Chat and Model state management with Zustand ### In Progress / Planned -- [ ] Ollama integration for local LLM inference -- [ ] Chat interface for page interaction -- [ ] Model management system -- [ ] Vision model integration for screenshot analysis -- [ ] AI-powered page summarization +- [ ] Vision model integration for screenshot and page analysis +- [ ] Content capture service for page context extraction +- [ ] AI-powered page summarization with readability - [ ] Smart bookmarking with AI categorization +- [ ] Model registry with pre-configured models ## Keyboard Shortcuts @@ -123,6 +146,7 @@ See [TECH_BRIEFING.md](./TECH_BRIEFING.md) for comprehensive technical documenta | `Ctrl/Cmd + R` or `F5` | Reload page | | `Ctrl/Cmd + H` | Toggle history sidebar | | `Ctrl/Cmd + B` | Toggle bookmarks sidebar | +| `Ctrl/Cmd + M` | Open model manager | | `Alt + Left` | Go back | | `Alt + Right` | Go forward | | `Ctrl/Cmd + Plus` | Zoom in | diff --git a/package.json b/package.json index 9b8656a..97e73b7 100644 --- a/package.json +++ b/package.json @@ -65,6 +65,7 @@ "wait-on": "^9.0.1" }, "dependencies": { + "axios": "^1.7.0", "better-sqlite3": "^12.4.1", "react": "^19.2.0", "react-dom": "^19.2.0", diff --git a/src/main/ipc/handlers.ts b/src/main/ipc/handlers.ts index 055cb60..bbf78f7 100644 --- a/src/main/ipc/handlers.ts +++ b/src/main/ipc/handlers.ts @@ -6,6 +6,8 @@ import { validateString, validateBoolean, } from '../utils/validation'; +import { ollamaService } from '../services/ollama'; +import type { GenerateOptions, ChatOptions } from '../../shared/types'; export function registerIpcHandlers() { console.log('registerIpcHandlers called'); @@ -248,4 +250,136 @@ export function registerIpcHandlers() { throw error; } }); + + // Ollama/LLM handlers + ipcMain.handle('ollama:isRunning', async () => { + try { + return await ollamaService.isRunning(); + } catch (error: any) { + console.error('ollama:isRunning error:', error.message); + throw error; + } + }); + + ipcMain.handle('ollama:start', async () => { + try { + await ollamaService.start(); + return { success: true }; + } catch (error: any) { + console.error('ollama:start error:', error.message); + throw error; + } + }); + + ipcMain.handle('ollama:listModels', async () => { + try { + return await ollamaService.listModels(); + } catch (error: any) { + console.error('ollama:listModels error:', error.message); + throw error; + } + }); + + ipcMain.handle('ollama:pullModel', async (event, modelName: string) => { + try { + validateString(modelName, 'Model name', 256); + + // Stream progress updates back to renderer + const generator = ollamaService.pullModel(modelName); + + for await (const progress of generator) { + event.sender.send('ollama:pullProgress', progress); + } + + return { success: true }; + } catch (error: any) { + console.error('ollama:pullModel error:', error.message); + throw error; + } + }); + + ipcMain.handle('ollama:deleteModel', async (event, modelName: string) => { + try { + validateString(modelName, 'Model name', 256); + await ollamaService.deleteModel(modelName); + return { success: true }; + } catch (error: any) { + console.error('ollama:deleteModel error:', error.message); + throw error; + } + }); + + ipcMain.handle('ollama:generate', async (event, options: GenerateOptions) => { + try { + if (!options || typeof options !== 'object') { + throw new Error('Invalid generate options'); + } + + validateString(options.model, 'Model name', 256); + validateString(options.prompt, 'Prompt', 50000); + + if (options.system) { + validateString(options.system, 'System prompt', 10000); + } + + // Stream response tokens back to renderer + const generator = ollamaService.generate({ + model: options.model, + prompt: options.prompt, + images: options.images, + system: options.system, + stream: true, + }); + + for await (const token of generator) { + event.sender.send('ollama:generateToken', token); + } + + return { success: true }; + } catch (error: any) { + console.error('ollama:generate error:', error.message); + throw error; + } + }); + + ipcMain.handle('ollama:chat', async (event, options: ChatOptions) => { + try { + if (!options || typeof options !== 'object') { + throw new Error('Invalid chat options'); + } + + validateString(options.model, 'Model name', 256); + + if (!Array.isArray(options.messages)) { + throw new Error('Messages must be an array'); + } + + // Validate messages + for (const msg of options.messages) { + if (!msg || typeof msg !== 'object') { + throw new Error('Invalid message object'); + } + validateString(msg.content, 'Message content', 50000); + if (!['system', 'user', 'assistant'].includes(msg.role)) { + throw new Error('Invalid message role'); + } + } + + // Stream response tokens back to renderer + const generator = ollamaService.chat({ + model: options.model, + messages: options.messages, + stream: true, + }); + + for await (const token of generator) { + event.sender.send('ollama:chatToken', token); + } + + return { success: true }; + } catch (error: any) { + console.error('ollama:chat error:', error.message); + throw error; + } + }); } diff --git a/src/main/preload.ts b/src/main/preload.ts index 1f77a06..36739d1 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -21,9 +21,21 @@ const ALLOWED_INVOKE_CHANNELS = [ 'webview:openDevTools', 'webview:print', 'webview:viewSource', + 'ollama:isRunning', + 'ollama:start', + 'ollama:listModels', + 'ollama:pullModel', + 'ollama:deleteModel', + 'ollama:generate', + 'ollama:chat', ]; -const ALLOWED_LISTEN_CHANNELS = ['open-view-source']; +const ALLOWED_LISTEN_CHANNELS = [ + 'open-view-source', + 'ollama:pullProgress', + 'ollama:generateToken', + 'ollama:chatToken', +]; // Expose protected methods that allow the renderer process to use // the ipcRenderer without exposing the entire object diff --git a/src/main/services/ollama.ts b/src/main/services/ollama.ts new file mode 100644 index 0000000..a5d66d0 --- /dev/null +++ b/src/main/services/ollama.ts @@ -0,0 +1,310 @@ +import axios, { AxiosInstance } from 'axios'; +import { spawn, ChildProcess } from 'child_process'; + +export interface OllamaModel { + name: string; + size: number; + digest: string; + modified_at: string; +} + +export interface PullProgress { + status: string; + completed?: number; + total?: number; + digest?: string; +} + +export interface GenerateRequest { + model: string; + prompt: string; + images?: string[]; + stream?: boolean; + system?: string; +} + +export interface GenerateResponse { + model: string; + created_at: string; + response: string; + done: boolean; + context?: number[]; + total_duration?: number; + load_duration?: number; + prompt_eval_count?: number; + eval_count?: number; +} + +export interface ChatMessage { + role: 'system' | 'user' | 'assistant'; + content: string; + images?: string[]; +} + +export interface ChatRequest { + model: string; + messages: ChatMessage[]; + stream?: boolean; +} + +export class OllamaService { + private baseURL: string; + private client: AxiosInstance; + private process: ChildProcess | null = null; + private isServerRunning = false; + + constructor(baseURL = 'http://localhost:11434') { + this.baseURL = baseURL; + this.client = axios.create({ + baseURL: this.baseURL, + timeout: 120000, // 2 minutes for model operations + }); + } + + /** + * Check if Ollama server is running + */ + async isRunning(): Promise { + try { + const response = await this.client.get('/api/version', { timeout: 3000 }); + this.isServerRunning = response.status === 200; + return this.isServerRunning; + } catch (error) { + this.isServerRunning = false; + return false; + } + } + + /** + * Start Ollama server process + */ + async start(): Promise { + if (await this.isRunning()) { + console.log('Ollama server is already running'); + return; + } + + return new Promise((resolve, reject) => { + try { + // Spawn ollama serve process + this.process = spawn('ollama', ['serve'], { + stdio: 'pipe', + detached: false, + }); + + this.process.on('error', (error) => { + console.error('Failed to start Ollama:', error); + reject(new Error('Failed to start Ollama. Make sure Ollama is installed.')); + }); + + // Wait for server to be ready + const checkInterval = setInterval(async () => { + if (await this.isRunning()) { + clearInterval(checkInterval); + console.log('Ollama server started successfully'); + resolve(); + } + }, 500); + + // Timeout after 10 seconds + setTimeout(() => { + clearInterval(checkInterval); + reject(new Error('Ollama server failed to start within timeout')); + }, 10000); + } catch (error) { + reject(error); + } + }); + } + + /** + * Ensure Ollama is running, start it if not + */ + async ensureRunning(): Promise { + if (!(await this.isRunning())) { + await this.start(); + } + } + + /** + * Stop Ollama server process + */ + stop(): void { + if (this.process) { + this.process.kill(); + this.process = null; + this.isServerRunning = false; + } + } + + /** + * List all installed models + */ + async listModels(): Promise { + await this.ensureRunning(); + + try { + const response = await this.client.get<{ models: OllamaModel[] }>('/api/tags'); + return response.data.models || []; + } catch (error) { + console.error('Failed to list models:', error); + throw new Error('Failed to list Ollama models'); + } + } + + /** + * Pull/download a model from Ollama library + * Returns an async generator for progress updates + */ + async *pullModel(modelName: string): AsyncGenerator { + await this.ensureRunning(); + + try { + const response = await this.client.post( + '/api/pull', + { name: modelName }, + { + responseType: 'stream', + timeout: 0, // No timeout for downloads + } + ); + + const stream = response.data; + let buffer = ''; + + for await (const chunk of stream) { + buffer += chunk.toString(); + const lines = buffer.split('\n'); + buffer = lines.pop() || ''; + + for (const line of lines) { + if (line.trim()) { + try { + const progress: PullProgress = JSON.parse(line); + yield progress; + + // Check if pull is complete + if (progress.status === 'success' || progress.status === 'complete') { + return; + } + } catch (e) { + console.warn('Failed to parse progress line:', line); + } + } + } + } + } catch (error) { + console.error('Failed to pull model:', error); + throw new Error(`Failed to pull model ${modelName}`); + } + } + + /** + * Delete a model + */ + async deleteModel(modelName: string): Promise { + await this.ensureRunning(); + + try { + await this.client.delete('/api/delete', { + data: { name: modelName }, + }); + } catch (error) { + console.error('Failed to delete model:', error); + throw new Error(`Failed to delete model ${modelName}`); + } + } + + /** + * Generate text with optional vision input + * Returns an async generator for streaming responses + */ + async *generate(request: GenerateRequest): AsyncGenerator { + await this.ensureRunning(); + + try { + const response = await this.client.post('/api/generate', request, { + responseType: 'stream', + timeout: 0, // No timeout for generation + }); + + const stream = response.data; + let buffer = ''; + + for await (const chunk of stream) { + buffer += chunk.toString(); + const lines = buffer.split('\n'); + buffer = lines.pop() || ''; + + for (const line of lines) { + if (line.trim()) { + try { + const data: GenerateResponse = JSON.parse(line); + + if (data.response) { + yield data.response; + } + + if (data.done) { + return; + } + } catch (e) { + console.warn('Failed to parse response line:', line); + } + } + } + } + } catch (error) { + console.error('Failed to generate:', error); + throw new Error('Failed to generate response from Ollama'); + } + } + + /** + * Chat completion with conversation history + * Returns an async generator for streaming responses + */ + async *chat(request: ChatRequest): AsyncGenerator { + await this.ensureRunning(); + + try { + const response = await this.client.post('/api/chat', request, { + responseType: 'stream', + timeout: 0, + }); + + const stream = response.data; + let buffer = ''; + + for await (const chunk of stream) { + buffer += chunk.toString(); + const lines = buffer.split('\n'); + buffer = lines.pop() || ''; + + for (const line of lines) { + if (line.trim()) { + try { + const data = JSON.parse(line); + + if (data.message?.content) { + yield data.message.content; + } + + if (data.done) { + return; + } + } catch (e) { + console.warn('Failed to parse chat response line:', line); + } + } + } + } + } catch (error) { + console.error('Failed to chat:', error); + throw new Error('Failed to chat with Ollama'); + } + } +} + +// Export singleton instance +export const ollamaService = new OllamaService(); diff --git a/src/renderer/components/Browser/BrowserLayout.tsx b/src/renderer/components/Browser/BrowserLayout.tsx index bee2033..c2ef3ac 100644 --- a/src/renderer/components/Browser/BrowserLayout.tsx +++ b/src/renderer/components/Browser/BrowserLayout.tsx @@ -5,14 +5,17 @@ import { TabBar } from './TabBar'; import { ChatSidebar } from '../Chat/ChatSidebar'; import { HistorySidebar } from './HistorySidebar'; import { BookmarksSidebar } from './BookmarksSidebar'; +import { ModelManager } from '../Models/ModelManager'; import { useBrowserStore } from '../../store/browser'; import { useTabsStore } from '../../store/tabs'; +import { useModelStore } from '../../store/models'; export const BrowserLayout: React.FC = () => { const webviewRef = useRef(null); const { toggleHistory, toggleBookmarks } = useBrowserStore(); const { tabs, activeTabId, addTab, closeTab, setActiveTab, loadTabs, suspendInactiveTabs } = useTabsStore(); + const { setIsModelManagerOpen } = useModelStore(); // Load tabs on mount useEffect(() => { @@ -81,6 +84,12 @@ export const BrowserLayout: React.FC = () => { toggleBookmarks(); return; } + // Ctrl/Cmd + M - Open Model Manager + if ((e.ctrlKey || e.metaKey) && e.key === 'm') { + e.preventDefault(); + setIsModelManagerOpen(true); + return; + } if (isTyping) { return; @@ -139,7 +148,16 @@ export const BrowserLayout: React.FC = () => { window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); - }, [tabs, activeTabId, addTab, closeTab, setActiveTab, toggleHistory, toggleBookmarks]); + }, [ + tabs, + activeTabId, + addTab, + closeTab, + setActiveTab, + toggleHistory, + toggleBookmarks, + setIsModelManagerOpen, + ]); return (
@@ -159,6 +177,9 @@ export const BrowserLayout: React.FC = () => {
+ + {/* Modal Overlays */} + ); }; diff --git a/src/renderer/components/Chat/ChatSidebar.tsx b/src/renderer/components/Chat/ChatSidebar.tsx index 8869302..3b58e0b 100644 --- a/src/renderer/components/Chat/ChatSidebar.tsx +++ b/src/renderer/components/Chat/ChatSidebar.tsx @@ -1,29 +1,122 @@ -import React, { useState } from 'react'; +import React, { useState, useEffect, useRef } from 'react'; import { useChatStore, Message } from '../../store/chat'; import { useBrowserStore } from '../../store/browser'; +import { useModelStore } from '../../store/models'; export const ChatSidebar: React.FC = () => { - const { messages, isStreaming, currentModel, addMessage } = useChatStore(); + const { + messages, + isStreaming, + currentModel, + addMessage, + appendToLastMessage, + setIsStreaming, + setCurrentModel, + setError, + startNewMessage, + } = useChatStore(); + const { + models, + defaultModel, + isOllamaRunning, + refreshModels, + setIsOllamaRunning, + setIsModelManagerOpen, + } = useModelStore(); const { isChatOpen, toggleChat } = useBrowserStore(); const [input, setInput] = useState(''); + const messagesEndRef = useRef(null); - const handleSend = () => { - if (!input.trim() || isStreaming) return; + // Get current model metadata + const currentModelInfo = models.find((m) => m.name === currentModel); + const supportsVision = currentModelInfo?.metadata?.capabilities.vision ?? false; + // Load models on mount + useEffect(() => { + const checkOllama = async () => { + try { + const running = await window.electron.invoke('ollama:isRunning'); + setIsOllamaRunning(running); + + if (running) { + await refreshModels(); + + // Set default or first model if none selected + if (!currentModel) { + if (defaultModel) { + setCurrentModel(defaultModel); + } else if (models.length > 0) { + setCurrentModel(models[0].name); + } + } + } + } catch (error) { + console.error('Failed to check Ollama:', error); + setIsOllamaRunning(false); + } + }; + + if (isChatOpen) { + checkOllama(); + } + // Only run when chat opens - intentionally not including other deps to avoid re-fetch loops + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isChatOpen]); + + // Auto-scroll to bottom on new messages + useEffect(() => { + messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); + }, [messages]); + + const handleSend = async () => { + if (!input.trim() || isStreaming || !currentModel) return; + + const userMessage = input.trim(); + setInput(''); + + // Add user message addMessage({ role: 'user', - content: input.trim(), + content: userMessage, }); - setInput(''); + // Start assistant message + setIsStreaming(true); + setError(null); + startNewMessage('assistant'); - // TODO: Implement actual LLM call - setTimeout(() => { - addMessage({ - role: 'assistant', - content: 'LLM integration coming soon! This is a placeholder response.', + try { + // Set up streaming listener + const unsubscribe = window.electron.on('ollama:chatToken', (token: string) => { + appendToLastMessage(token); }); - }, 500); + + // Convert messages to Ollama format + const chatMessages = messages.map((msg) => ({ + role: msg.role, + content: msg.content, + })); + + // Add the new user message + chatMessages.push({ + role: 'user' as const, + content: userMessage, + }); + + // Send chat request + await window.electron.invoke('ollama:chat', { + model: currentModel, + messages: chatMessages, + }); + + // Cleanup listener + unsubscribe(); + } catch (error: any) { + console.error('Chat error:', error); + setError(error.message || 'Failed to get response from AI'); + } finally { + setIsStreaming(false); + } }; const handleKeyDown = (e: React.KeyboardEvent) => { @@ -71,21 +164,71 @@ export const ChatSidebar: React.FC = () => { - {/* Model Selector */} -
- + {/* Model Selector and Management */} +
+ {!isOllamaRunning ? ( +
+ Ollama is not running. Please start Ollama to use the AI assistant. +
+ ) : models.length === 0 ? ( +
+
+ No models installed. Download models to get started. +
+ +
+ ) : ( + <> + + {currentModelInfo && ( +
+
+ {supportsVision ? ( + Vision + ) : ( + Text-Only + )} +
+ +
+ )} + + )}
{/* Messages */} @@ -136,6 +279,7 @@ export const ChatSidebar: React.FC = () => { AI is thinking...
)} +
{/* Input */} @@ -152,7 +296,7 @@ export const ChatSidebar: React.FC = () => { /> + + + + +
+ {/* Recommended Models */} + {recommendedModels.length > 0 && ( +
+

+ + + + Recommended Models +

+
+ {recommendedModels.map((model) => ( + + ))} +
+
+ )} + + {/* Other Models */} + {otherModels.length > 0 && ( +
+ {recommendedModels.length > 0 && ( +

Other Models

+ )} +
+ {otherModels.map((model) => ( + + ))} +
+
+ )} + + {filteredModels.length === 0 && ( +
+

No models available with current filter

+
+ )} +
+ + ); +}; + +interface ModelCardProps { + model: ModelMetadata; + onPull: (modelName: string) => void; + isPulling: boolean; + progress?: PullProgress; +} + +const ModelCard: React.FC = ({ model, onPull, isPulling, progress }) => { + const badges = getCapabilityBadges(model); + const isDownloading = progress && progress.status !== 'success'; + const progressPercent = + progress && progress.total ? Math.round((progress.completed / progress.total) * 100) : 0; + + return ( +
+
+
+

{model.displayName}

+

{model.name}

+
+ {model.capabilities.vision && ( +
+ + + + +
+ )} +
+ +

{model.description}

+ + {/* Metadata */} +
+ {model.size && {model.size}} + {model.parameters && ( + {model.parameters} + )} + {model.minRAM && ( + {model.minRAM} RAM + )} +
+ + {/* Capability Badges */} + {badges.length > 0 && ( +
+ {badges.map((badge) => ( + + {badge} + + ))} +
+ )} + + {/* Download Button or Progress */} + {isDownloading ? ( +
+
+ {progress.status} + {progressPercent}% +
+
+
+
+
+ ) : ( + + )} +
+ ); +}; diff --git a/src/renderer/components/Models/InstalledModels.tsx b/src/renderer/components/Models/InstalledModels.tsx new file mode 100644 index 0000000..81d20a3 --- /dev/null +++ b/src/renderer/components/Models/InstalledModels.tsx @@ -0,0 +1,197 @@ +import React, { useState } from 'react'; +import { useModelStore } from '../../store/models'; +import { formatModelSize, getCapabilityBadges } from '../../../shared/modelRegistry'; + +export const InstalledModels: React.FC = () => { + const { models, defaultModel, setDefaultModel, refreshModels, isLoading } = useModelStore(); + const [deletingModel, setDeletingModel] = useState(null); + + const handleSetDefault = (modelName: string) => { + setDefaultModel(modelName); + }; + + const handleDelete = async (modelName: string) => { + // Simple confirmation - consider replacing with a proper modal in the future + const confirmed = window.confirm( + `Are you sure you want to delete "${modelName}"?\n\nThis will remove the model from your system.` + ); + + if (!confirmed) return; + + setDeletingModel(modelName); + try { + await window.electron.invoke('ollama:deleteModel', modelName); + await refreshModels(); + } catch (error: any) { + console.error('Failed to delete model:', error); + // TODO: Replace with toast notification + window.alert(`Failed to delete model: ${error.message}`); + } finally { + setDeletingModel(null); + } + }; + + if (isLoading) { + return ( +
+
+
+

Loading models...

+
+
+ ); + } + + if (models.length === 0) { + return ( +
+
+
+ + + +
+
+

No Models Installed

+

+ Download models from the "Available Models" tab to get started with AI features. +

+
+
+
+ ); + } + + return ( +
+
+ {models.map((model) => { + const isDefault = model.name === defaultModel; + const badges = getCapabilityBadges(model.metadata); + const isDeleting = deletingModel === model.name; + + return ( +
+
+
+ {/* Model Name and Badges */} +
+

+ {model.metadata?.displayName || model.name} +

+ {isDefault && ( + + DEFAULT + + )} +
+ + {/* Model ID */} +

{model.name}

+ + {/* Description */} + {model.metadata?.description && ( +

{model.metadata.description}

+ )} + + {/* Metadata */} +
+
+ + + + {formatModelSize(model.size)} +
+ + {model.metadata?.parameters && ( +
+ + + + {model.metadata.parameters} +
+ )} + +
+ + + + {new Date(model.modified_at).toLocaleDateString()} +
+
+ + {/* Capability Badges */} + {badges.length > 0 && ( +
+ {badges.map((badge) => ( + + {badge} + + ))} +
+ )} +
+ + {/* Actions */} +
+ {!isDefault && ( + + )} + +
+
+
+ ); + })} +
+
+ ); +}; diff --git a/src/renderer/components/Models/ModelManager.tsx b/src/renderer/components/Models/ModelManager.tsx new file mode 100644 index 0000000..f54a022 --- /dev/null +++ b/src/renderer/components/Models/ModelManager.tsx @@ -0,0 +1,119 @@ +import React, { useState, useEffect } from 'react'; +import { useModelStore } from '../../store/models'; +import { InstalledModels } from './InstalledModels'; +import { AvailableModels } from './AvailableModels'; + +export const ModelManager: React.FC = () => { + const { isModelManagerOpen, setIsModelManagerOpen, refreshModels, isOllamaRunning } = + useModelStore(); + const [activeTab, setActiveTab] = useState<'installed' | 'available'>('installed'); + + useEffect(() => { + if (isModelManagerOpen) { + refreshModels(); + } + }, [isModelManagerOpen, refreshModels]); + + if (!isModelManagerOpen) return null; + + return ( +
+
+ {/* Header */} +
+
+
+ + + +
+
+

Model Manager

+

+ {isOllamaRunning + ? 'Manage your local AI models' + : 'Ollama is not running'} +

+
+
+ + +
+ + {/* Tabs */} +
+ + +
+ + {/* Content */} +
+ {activeTab === 'installed' ? : } +
+ + {/* Footer */} +
+
+
+ {isOllamaRunning ? 'Ollama is running' : 'Ollama is not running'} +
+ +
+
+
+ ); +}; diff --git a/src/renderer/store/chat.ts b/src/renderer/store/chat.ts index 6f27340..2d644a3 100644 --- a/src/renderer/store/chat.ts +++ b/src/renderer/store/chat.ts @@ -1,9 +1,8 @@ import { create } from 'zustand'; +import type { ChatMessage } from '../../shared/types'; -export interface Message { +export interface Message extends ChatMessage { id: string; - role: 'user' | 'assistant'; - content: string; timestamp: Date; } @@ -11,16 +10,25 @@ interface ChatState { messages: Message[]; isStreaming: boolean; currentModel: string | null; + streamingContent: string; + error: string | null; addMessage: (message: Omit) => void; + appendToLastMessage: (content: string) => void; + setStreamingContent: (content: string) => void; setIsStreaming: (streaming: boolean) => void; setCurrentModel: (model: string) => void; + setError: (error: string | null) => void; clearMessages: () => void; + startNewMessage: (role: 'user' | 'assistant') => string; } -export const useChatStore = create((set) => ({ +export const useChatStore = create((set, get) => ({ messages: [], isStreaming: false, currentModel: null, + streamingContent: '', + error: null, + addMessage: (message) => set((state) => ({ messages: [ @@ -32,7 +40,40 @@ export const useChatStore = create((set) => ({ }, ], })), - setIsStreaming: (streaming) => set({ isStreaming: streaming }), - setCurrentModel: (model) => set({ currentModel: model }), - clearMessages: () => set({ messages: [] }), + + appendToLastMessage: (content: string) => + set((state) => { + const messages = [...state.messages]; + const lastMessage = messages[messages.length - 1]; + if (lastMessage) { + lastMessage.content += content; + } + return { messages }; + }), + + setStreamingContent: (content: string) => set({ streamingContent: content }), + + setIsStreaming: (streaming: boolean) => set({ isStreaming: streaming }), + + setCurrentModel: (model: string) => set({ currentModel: model }), + + setError: (error: string | null) => set({ error }), + + clearMessages: () => set({ messages: [], error: null, streamingContent: '' }), + + startNewMessage: (role: 'user' | 'assistant') => { + const id = crypto.randomUUID(); + set((state) => ({ + messages: [ + ...state.messages, + { + id, + role, + content: '', + timestamp: new Date(), + }, + ], + })); + return id; + }, })); diff --git a/src/renderer/store/models.ts b/src/renderer/store/models.ts new file mode 100644 index 0000000..4892920 --- /dev/null +++ b/src/renderer/store/models.ts @@ -0,0 +1,115 @@ +import { create } from 'zustand'; +import { persist } from 'zustand/middleware'; +import type { InstalledModelInfo, PullProgress } from '../../shared/types'; +import { enrichInstalledModels } from '../../shared/modelRegistry'; + +interface ModelState { + models: InstalledModelInfo[]; + defaultModel: string | null; + isLoading: boolean; + error: string | null; + pullProgress: Map; + isPulling: boolean; + isOllamaRunning: boolean; + isModelManagerOpen: boolean; + setModels: (models: InstalledModelInfo[]) => void; + setDefaultModel: (modelName: string | null) => void; + setIsLoading: (loading: boolean) => void; + setError: (error: string | null) => void; + setPullProgress: (modelName: string, progress: PullProgress) => void; + setIsPulling: (pulling: boolean) => void; + setIsOllamaRunning: (running: boolean) => void; + setIsModelManagerOpen: (open: boolean) => void; + clearPullProgress: (modelName: string) => void; + refreshModels: () => Promise; +} + +export const useModelStore = create()( + persist( + (set, get) => ({ + models: [], + defaultModel: null, + isLoading: false, + error: null, + pullProgress: new Map(), + isPulling: false, + isOllamaRunning: false, + isModelManagerOpen: false, + + setModels: (models) => set({ models }), + + setDefaultModel: (modelName) => { + set({ defaultModel: modelName }); + // Also mark the model as default in the models list + set((state) => ({ + models: state.models.map((m) => ({ + ...m, + isDefault: m.name === modelName, + })), + })); + }, + + setIsLoading: (loading) => set({ isLoading: loading }), + + setError: (error) => set({ error }), + + setPullProgress: (modelName, progress) => + set((state) => { + const newProgress = new Map(state.pullProgress); + newProgress.set(modelName, progress); + return { pullProgress: newProgress }; + }), + + setIsPulling: (pulling) => set({ isPulling: pulling }), + + setIsOllamaRunning: (running) => set({ isOllamaRunning: running }), + + setIsModelManagerOpen: (open) => set({ isModelManagerOpen: open }), + + clearPullProgress: (modelName) => + set((state) => { + const newProgress = new Map(state.pullProgress); + newProgress.delete(modelName); + return { pullProgress: newProgress }; + }), + + refreshModels: async () => { + try { + set({ isLoading: true, error: null }); + const running = await window.electron.invoke('ollama:isRunning'); + set({ isOllamaRunning: running }); + + if (running) { + const rawModels = await window.electron.invoke('ollama:listModels'); + const enrichedModels = enrichInstalledModels(rawModels); + + // Mark default model + const { defaultModel } = get(); + const modelsWithDefault = enrichedModels.map((m) => ({ + ...m, + isDefault: m.name === defaultModel, + })); + + set({ models: modelsWithDefault }); + + // Auto-set first model as default if none set + if (!defaultModel && modelsWithDefault.length > 0) { + set({ defaultModel: modelsWithDefault[0].name }); + } + } + } catch (error: any) { + console.error('Failed to refresh models:', error); + set({ error: error.message || 'Failed to load models' }); + } finally { + set({ isLoading: false }); + } + }, + }), + { + name: 'model-settings', + partialize: (state) => ({ + defaultModel: state.defaultModel, + }), + } + ) +); diff --git a/src/renderer/styles/globals.css b/src/renderer/styles/globals.css index b369ee3..d8d5479 100644 --- a/src/renderer/styles/globals.css +++ b/src/renderer/styles/globals.css @@ -27,10 +27,12 @@ body { @apply bg-background text-foreground; - font-feature-settings: "rlig" 1, "calt" 1; - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', - 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', - sans-serif; + font-feature-settings: + 'rlig' 1, + 'calt' 1; + font-family: + -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', + 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; } diff --git a/src/shared/modelRegistry.json b/src/shared/modelRegistry.json new file mode 100644 index 0000000..aad4fdc --- /dev/null +++ b/src/shared/modelRegistry.json @@ -0,0 +1,228 @@ +{ + "models": [ + { + "id": "llama3.2-vision:11b", + "name": "llama3.2-vision:11b", + "displayName": "Llama 3.2 Vision 11B", + "description": "Meta's multimodal model with vision and language understanding. Great for analyzing web pages and images.", + "size": "7.9 GB", + "parameters": "11B", + "quantization": "Q4_0", + "capabilities": { + "vision": true, + "chat": true, + "completion": true + }, + "recommended": true, + "requiresGPU": false, + "minRAM": "16 GB", + "tags": ["vision", "multimodal", "recommended"], + "family": "llama", + "homepage": "https://ollama.com/library/llama3.2-vision" + }, + { + "id": "llava:13b", + "name": "llava:13b", + "displayName": "LLaVA 13B", + "description": "Large Language and Vision Assistant. Excellent for detailed image analysis and visual question answering.", + "size": "8.0 GB", + "parameters": "13B", + "capabilities": { + "vision": true, + "chat": true, + "completion": true + }, + "recommended": true, + "requiresGPU": false, + "minRAM": "16 GB", + "tags": ["vision", "multimodal", "detailed"], + "family": "llava", + "homepage": "https://ollama.com/library/llava" + }, + { + "id": "llava:7b", + "name": "llava:7b", + "displayName": "LLaVA 7B", + "description": "Balanced vision model with good performance on consumer hardware. Great starting point for vision tasks.", + "size": "4.7 GB", + "parameters": "7B", + "capabilities": { + "vision": true, + "chat": true, + "completion": true + }, + "recommended": true, + "requiresGPU": false, + "minRAM": "8 GB", + "tags": ["vision", "multimodal", "balanced"], + "family": "llava", + "homepage": "https://ollama.com/library/llava" + }, + { + "id": "bakllava:latest", + "name": "bakllava:latest", + "displayName": "BakLLaVA", + "description": "Fine-tuned LLaVA model with improved performance on visual reasoning tasks.", + "size": "4.7 GB", + "parameters": "7B", + "capabilities": { + "vision": true, + "chat": true, + "completion": true + }, + "requiresGPU": false, + "minRAM": "8 GB", + "tags": ["vision", "multimodal"], + "family": "llava", + "homepage": "https://ollama.com/library/bakllava" + }, + { + "id": "moondream:latest", + "name": "moondream:latest", + "displayName": "Moondream 2B", + "description": "Ultra-lightweight vision model. Fast inference, perfect for quick page analysis on any hardware.", + "size": "1.7 GB", + "parameters": "2B", + "capabilities": { + "vision": true, + "chat": true, + "completion": true + }, + "recommended": true, + "requiresGPU": false, + "minRAM": "4 GB", + "tags": ["vision", "multimodal", "lightweight", "fast"], + "family": "moondream", + "homepage": "https://ollama.com/library/moondream" + }, + { + "id": "llama3.2:3b", + "name": "llama3.2:3b", + "displayName": "Llama 3.2 3B", + "description": "Meta's efficient text-only model. Great for general conversation and text analysis without image support.", + "size": "2.0 GB", + "parameters": "3B", + "capabilities": { + "vision": false, + "chat": true, + "completion": true + }, + "recommended": true, + "requiresGPU": false, + "minRAM": "4 GB", + "tags": ["text-only", "lightweight", "recommended"], + "family": "llama", + "homepage": "https://ollama.com/library/llama3.2" + }, + { + "id": "llama3.2:1b", + "name": "llama3.2:1b", + "displayName": "Llama 3.2 1B", + "description": "Ultra-lightweight text model. Fastest option for basic text tasks on limited hardware.", + "size": "1.3 GB", + "parameters": "1B", + "capabilities": { + "vision": false, + "chat": true, + "completion": true + }, + "requiresGPU": false, + "minRAM": "2 GB", + "tags": ["text-only", "ultra-lightweight", "fast"], + "family": "llama", + "homepage": "https://ollama.com/library/llama3.2" + }, + { + "id": "llama3.1:8b", + "name": "llama3.1:8b", + "displayName": "Llama 3.1 8B", + "description": "Powerful text-only model with extended context. Excellent for detailed text analysis and conversation.", + "size": "4.7 GB", + "parameters": "8B", + "capabilities": { + "vision": false, + "chat": true, + "completion": true + }, + "recommended": true, + "requiresGPU": false, + "minRAM": "8 GB", + "tags": ["text-only", "powerful", "recommended"], + "family": "llama", + "homepage": "https://ollama.com/library/llama3.1" + }, + { + "id": "qwen2.5:7b", + "name": "qwen2.5:7b", + "displayName": "Qwen 2.5 7B", + "description": "Advanced text model with strong coding and reasoning capabilities. Great for technical tasks.", + "size": "4.7 GB", + "parameters": "7B", + "capabilities": { + "vision": false, + "chat": true, + "completion": true + }, + "requiresGPU": false, + "minRAM": "8 GB", + "tags": ["text-only", "coding", "reasoning"], + "family": "qwen", + "homepage": "https://ollama.com/library/qwen2.5" + }, + { + "id": "mistral:7b", + "name": "mistral:7b", + "displayName": "Mistral 7B", + "description": "High-performance text model with excellent instruction following. Great for diverse tasks.", + "size": "4.1 GB", + "parameters": "7B", + "capabilities": { + "vision": false, + "chat": true, + "completion": true + }, + "recommended": true, + "requiresGPU": false, + "minRAM": "8 GB", + "tags": ["text-only", "high-performance", "recommended"], + "family": "mistral", + "homepage": "https://ollama.com/library/mistral" + }, + { + "id": "phi3:mini", + "name": "phi3:mini", + "displayName": "Phi-3 Mini", + "description": "Microsoft's compact yet capable text model. Excellent quality-to-size ratio for general tasks.", + "size": "2.3 GB", + "parameters": "3.8B", + "capabilities": { + "vision": false, + "chat": true, + "completion": true + }, + "requiresGPU": false, + "minRAM": "4 GB", + "tags": ["text-only", "compact", "efficient"], + "family": "phi", + "homepage": "https://ollama.com/library/phi3" + }, + { + "id": "gemma2:9b", + "name": "gemma2:9b", + "displayName": "Gemma 2 9B", + "description": "Google's open model with strong reasoning. Great for analysis and conversation.", + "size": "5.5 GB", + "parameters": "9B", + "capabilities": { + "vision": false, + "chat": true, + "completion": true + }, + "requiresGPU": false, + "minRAM": "10 GB", + "tags": ["text-only", "reasoning"], + "family": "gemma", + "homepage": "https://ollama.com/library/gemma2" + } + ] +} diff --git a/src/shared/modelRegistry.ts b/src/shared/modelRegistry.ts new file mode 100644 index 0000000..edb9c08 --- /dev/null +++ b/src/shared/modelRegistry.ts @@ -0,0 +1,128 @@ +import type { ModelMetadata, ModelRegistry, OllamaModel, InstalledModelInfo } from './types'; +import modelRegistryData from './modelRegistry.json'; + +/** + * Get all models from the registry + */ +export function getAllModelsFromRegistry(): ModelMetadata[] { + return (modelRegistryData as ModelRegistry).models; +} + +/** + * Get recommended models from the registry + */ +export function getRecommendedModels(): ModelMetadata[] { + return getAllModelsFromRegistry().filter((model) => model.recommended); +} + +/** + * Get models by capability + */ +export function getModelsByCapability( + capability: 'vision' | 'chat' | 'completion' +): ModelMetadata[] { + return getAllModelsFromRegistry().filter((model) => model.capabilities[capability]); +} + +/** + * Get vision-capable models + */ +export function getVisionModels(): ModelMetadata[] { + return getModelsByCapability('vision'); +} + +/** + * Get text-only models + */ +export function getTextOnlyModels(): ModelMetadata[] { + return getAllModelsFromRegistry().filter((model) => !model.capabilities.vision); +} + +/** + * Find model metadata by name (supports partial matching) + */ +export function findModelMetadata(modelName: string): ModelMetadata | undefined { + const registry = getAllModelsFromRegistry(); + + // Exact match first + let metadata = registry.find((m) => m.name === modelName); + if (metadata) return metadata; + + // Try exact ID match + metadata = registry.find((m) => m.id === modelName); + if (metadata) return metadata; + + // Try base name match (without tag) + const baseName = modelName.split(':')[0]; + metadata = registry.find((m) => m.name.split(':')[0] === baseName); + if (metadata) return metadata; + + // Try family match + metadata = registry.find((m) => m.family && modelName.toLowerCase().includes(m.family)); + + return metadata; +} + +/** + * Check if a model supports vision + */ +export function supportsVision(modelName: string): boolean { + const metadata = findModelMetadata(modelName); + return metadata?.capabilities.vision ?? false; +} + +/** + * Enrich installed models with metadata from registry + */ +export function enrichInstalledModels(installedModels: OllamaModel[]): InstalledModelInfo[] { + return installedModels.map((model) => ({ + ...model, + metadata: findModelMetadata(model.name), + })); +} + +/** + * Get models available for download (not yet installed) + */ +export function getAvailableModels(installedModels: OllamaModel[]): ModelMetadata[] { + const installedNames = new Set(installedModels.map((m) => m.name)); + const installedBaseNames = new Set(installedModels.map((m) => m.name.split(':')[0])); + + return getAllModelsFromRegistry().filter((model) => { + // Check if exact name is installed + if (installedNames.has(model.name)) return false; + + // Check if base name is installed + const baseName = model.name.split(':')[0]; + if (installedBaseNames.has(baseName)) return false; + + return true; + }); +} + +/** + * Format model size for display + */ +export function formatModelSize(bytes: number): string { + const gb = bytes / (1024 * 1024 * 1024); + if (gb >= 1) { + return `${gb.toFixed(1)} GB`; + } + const mb = bytes / (1024 * 1024); + return `${mb.toFixed(0)} MB`; +} + +/** + * Get capability badges for a model + */ +export function getCapabilityBadges(metadata?: ModelMetadata): string[] { + if (!metadata) return []; + + const badges: string[] = []; + if (metadata.capabilities.vision) badges.push('Vision'); + if (metadata.capabilities.chat) badges.push('Chat'); + if (metadata.capabilities.completion) badges.push('Completion'); + if (metadata.capabilities.embedding) badges.push('Embeddings'); + + return badges; +} diff --git a/src/shared/types.ts b/src/shared/types.ts index 5cd0acc..189c7e0 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -33,3 +33,84 @@ export interface Tab { isSuspended?: boolean; lastActiveTime?: number; } + +// LLM/Ollama related types +export interface OllamaModel { + name: string; + size: number; + digest: string; + modified_at: string; + details?: { + format?: string; + family?: string; + parameter_size?: string; + quantization_level?: string; + }; +} + +export interface ModelCapabilities { + vision: boolean; + chat: boolean; + completion: boolean; + embedding?: boolean; +} + +export interface ModelMetadata { + id: string; + name: string; + displayName: string; + description: string; + size?: string; + parameters?: string; + quantization?: string; + capabilities: ModelCapabilities; + recommended?: boolean; + requiresGPU?: boolean; + minRAM?: string; + tags?: string[]; + family?: string; + homepage?: string; +} + +export interface ModelRegistry { + models: ModelMetadata[]; +} + +export interface InstalledModelInfo extends OllamaModel { + metadata?: ModelMetadata; + isDefault?: boolean; +} + +export interface PullProgress { + status: string; + completed?: number; + total?: number; + digest?: string; +} + +export interface ChatMessage { + role: 'system' | 'user' | 'assistant'; + content: string; + images?: string[]; + timestamp?: number; +} + +export interface Conversation { + id: string; + messages: ChatMessage[]; + model: string; + createdAt: number; + updatedAt: number; +} + +export interface GenerateOptions { + model: string; + prompt: string; + images?: string[]; + system?: string; +} + +export interface ChatOptions { + model: string; + messages: ChatMessage[]; +}