diff --git a/src/main/engine/ai/chat.ts b/src/main/engine/ai/chat.ts index ca88a81..fde347a 100644 --- a/src/main/engine/ai/chat.ts +++ b/src/main/engine/ai/chat.ts @@ -16,7 +16,7 @@ import type { BrowserWindow } from 'electron'; import type { ChatEngine, ChatMessageData } from '../ChatEngine'; import { isRenderTool, generateFromToolCall } from '../../a2ui/generator'; import type { A2UIServerMessage } from '../../a2ui/types'; -import { ProviderRegistry, detectProvider } from './providers'; +import { ProviderRegistry } from './providers'; import { createBlogTools, type BlogToolDeps } from './blog-tools'; import { createA2UITools } from './a2ui-tools'; @@ -247,11 +247,11 @@ export class ChatService { this.abortControllers.set(conversationId, abortController); const modelId = conversation.model || 'claude-sonnet-4'; - const provider = detectProvider(modelId); + const provider = this.providers.detectModelProvider(modelId); // Verify provider key is available if (!this.providers.isProviderKeySet(provider)) { - const providerLabel = provider === 'mistral' ? 'Mistral' : 'OpenCode'; + const providerLabel = provider === 'mistral' ? 'Mistral' : provider === 'ollama' ? 'Ollama' : 'OpenCode'; return { success: false, error: `The model '${modelId}' requires a ${providerLabel} API key. Configure it in Settings.` }; } @@ -271,10 +271,13 @@ export class ChatService { const aiMessages = dbMessagesToAIMessages(dbMessages); - // Build tools - const blogTools = createBlogTools(this.blogToolDeps); - const a2uiToolsRaw = createA2UITools(); + // Build tools (skip for Ollama models unless capability override is set) + const isOllama = this.providers.isOllamaModel(modelId); + const skipTools = isOllama && !this.providers.ollamaModelSupportsTools(modelId); + const blogTools = skipTools ? {} : createBlogTools(this.blogToolDeps); + const a2uiToolsRaw = skipTools ? {} : createA2UITools(); const allTools = { ...blogTools, ...a2uiToolsRaw }; + const hasTools = Object.keys(allTools).length > 0; // Get context window for truncation const contextWindow = await this.providers.getModelCatalogEngine().getContextWindow(modelId) ?? 150_000; @@ -301,12 +304,14 @@ export class ChatService { try { // --- streamText: the AI SDK replaces our entire SSE/accumulator/tool-loop --- + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- tools may be empty for Ollama models + const toolsOption = hasTools ? allTools : undefined as any; const result = streamText({ model, system: systemPrompt, messages: truncatedMessages, - tools: allTools, - stopWhen: stepCountIs(MAX_TOOL_ROUNDS), + tools: toolsOption, + stopWhen: hasTools ? stepCountIs(MAX_TOOL_ROUNDS) : undefined, abortSignal: abortController.signal, maxRetries: 3, providerOptions, @@ -435,7 +440,7 @@ export class ChatService { let titleModel = await this.chatEngine.getSetting('chat_title_model'); // Fallback chain: setting → haiku → mistral-small - if (!titleModel || !this.providers.isProviderKeySet(detectProvider(titleModel))) { + if (!titleModel || !this.providers.isProviderKeySet(this.providers.detectModelProvider(titleModel))) { titleModel = this.providers.getOpencodeKey() ? 'claude-haiku-4-5' : this.providers.getMistralKey() diff --git a/src/main/engine/ai/providers.ts b/src/main/engine/ai/providers.ts index 3aacd80..336dc58 100644 --- a/src/main/engine/ai/providers.ts +++ b/src/main/engine/ai/providers.ts @@ -27,8 +27,11 @@ import type { ChatModel } from '../../shared/electronApi'; export const ZEN_BASE_URL = 'https://opencode.ai/zen/v1'; export const ZEN_MODELS_URL = 'https://opencode.ai/zen/v1/models'; export const MISTRAL_MODELS_URL = 'https://api.mistral.ai/v1/models'; +export const OLLAMA_BASE_URL = 'http://localhost:11434/v1'; +export const OLLAMA_TAGS_URL = 'http://localhost:11434/api/tags'; const MODEL_CACHE_TTL = 5 * 60 * 1000; // 5 minutes +const OLLAMA_FETCH_TIMEOUT = 3000; // 3 s — fail fast when Ollama isn't running // --------------------------------------------------------------------------- // Gateway factory @@ -99,8 +102,12 @@ export function detectProvider(modelId: string): string { export class ProviderRegistry { private opencodeKey = ''; private mistralKey = ''; + private ollamaEnabled = false; private opencodeGateway: Provider | null = null; private mistralProvider: ReturnType | null = null; + private ollamaProvider: ReturnType | null = null; + private ollamaModelIds = new Set(); + private ollamaCapabilities = new Map(); private modelCatalogEngine = new ModelCatalogEngine(); // Model cache @@ -129,22 +136,100 @@ export class ProviderRegistry { return this.mistralKey; } + // ---- Ollama management ---- + + setOllamaEnabled(enabled: boolean): void { + this.ollamaEnabled = enabled; + this.ollamaProvider = null; + this.invalidateModelCache(); + } + + isOllamaEnabled(): boolean { + return this.ollamaEnabled; + } + + /** Register a model ID as belonging to Ollama. */ + registerOllamaModel(modelId: string): void { + this.ollamaModelIds.add(modelId); + } + + /** Check whether a model ID was registered as an Ollama model. */ + isOllamaModel(modelId: string): boolean { + return this.ollamaModelIds.has(modelId); + } + + /** Remove all registered Ollama model IDs. */ + clearOllamaModels(): void { + this.ollamaModelIds.clear(); + } + + // ---- Ollama model capability overrides ---- + + /** Get capability overrides for a specific Ollama model (defaults to tools=false, vision=false). */ + getOllamaModelCapabilities(modelId: string): { tools: boolean; vision: boolean } { + return this.ollamaCapabilities.get(modelId) ?? { tools: false, vision: false }; + } + + /** Set capability overrides for a specific Ollama model. */ + setOllamaModelCapabilities(modelId: string, caps: { tools: boolean; vision: boolean }): void { + this.ollamaCapabilities.set(modelId, caps); + this.invalidateModelCache(); + } + + /** Get all stored capability overrides as a plain object. */ + getAllOllamaModelCapabilities(): Record { + const result: Record = {}; + for (const [id, caps] of this.ollamaCapabilities) { + result[id] = caps; + } + return result; + } + + /** Load capability overrides from a serialized object (e.g. from settings DB). */ + loadOllamaModelCapabilities(data: Record): void { + this.ollamaCapabilities.clear(); + for (const [id, caps] of Object.entries(data)) { + this.ollamaCapabilities.set(id, caps); + } + } + + /** Check whether an Ollama model has tools capability enabled. */ + ollamaModelSupportsTools(modelId: string): boolean { + return this.ollamaCapabilities.get(modelId)?.tools ?? false; + } + + /** Check whether an Ollama model has vision capability enabled. */ + ollamaModelSupportsVision(modelId: string): boolean { + return this.ollamaCapabilities.get(modelId)?.vision ?? false; + } + + /** + * Detect the effective provider for a model ID, checking Ollama + * registration first, then falling back to prefix-based detection. + */ + detectModelProvider(modelId: string): string { + if (this.ollamaModelIds.has(modelId)) return 'ollama'; + return detectProvider(modelId); + } + /** Check whether at least one provider key is configured. */ isReady(): boolean { - return !!(this.opencodeKey || this.mistralKey); + return !!(this.opencodeKey || this.mistralKey || this.ollamaEnabled); } /** Check whether the key for a specific provider is set. */ isProviderKeySet(provider: string): boolean { if (provider === 'mistral') return !!this.mistralKey; + if (provider === 'ollama') return this.ollamaEnabled; return !!this.opencodeKey; } /** Returns status of all configured providers. */ - getProviderStatus(): { opencode: boolean; mistral: boolean } { + getProviderStatus(): { opencode: boolean; mistral: boolean; ollama: boolean } { return { opencode: !!this.opencodeKey, mistral: !!this.mistralKey, + ollama: this.ollamaEnabled, }; } @@ -152,6 +237,20 @@ export class ProviderRegistry { /** Resolve a model ID to an AI SDK LanguageModel. */ resolveModel(modelId: string): LanguageModel { + // Check if this is a registered Ollama model first + if (this.ollamaModelIds.has(modelId)) { + if (!this.ollamaEnabled) { + throw new Error(`Ollama not configured for model '${modelId}'`); + } + if (!this.ollamaProvider) { + this.ollamaProvider = createOpenAI({ + baseURL: OLLAMA_BASE_URL, + apiKey: 'ollama', // Ollama doesn't need a real key + }); + } + return this.ollamaProvider.chat(modelId); + } + const provider = detectProvider(modelId); if (provider === 'mistral') { @@ -229,6 +328,17 @@ export class ProviderRegistry { } } + // Fetch Ollama models + if (this.ollamaEnabled) { + try { + const models = await this.fetchOllamaModels(); + allModels.push(...models); + if (models.length > 0) fetched = true; + } catch { + // Ollama not running — skip silently + } + } + if (fetched && allModels.length > 0) { this.cachedModels = allModels; this.cachedModelsAt = Date.now(); @@ -283,6 +393,39 @@ export class ProviderRegistry { } } + // ---- Ollama model listing ---- + + /** + * Fetch available models from Ollama's /api/tags endpoint. + * Returns ChatModel[] and registers the model IDs internally. + */ + async fetchOllamaModels(): Promise { + try { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), OLLAMA_FETCH_TIMEOUT); + const response = await fetch(OLLAMA_TAGS_URL, { method: 'GET', signal: controller.signal }); + clearTimeout(timeout); + if (!response.ok) return []; + + const data = await response.json() as { models?: Array<{ name: string; details?: { family?: string } }> }; + if (!data.models || !Array.isArray(data.models)) return []; + + this.clearOllamaModels(); + const models: ChatModel[] = data.models.map(m => { + this.registerOllamaModel(m.name); + return { + id: m.name, + name: m.name, + provider: 'ollama', + vision: this.ollamaModelSupportsVision(m.name), + }; + }); + return models; + } catch { + return []; + } + } + // ---- Private helpers ---- private async fetchModelsFromEndpoint( diff --git a/src/main/engine/ai/tasks.ts b/src/main/engine/ai/tasks.ts index 719986f..8ba3357 100644 --- a/src/main/engine/ai/tasks.ts +++ b/src/main/engine/ai/tasks.ts @@ -8,7 +8,7 @@ import { generateText } from 'ai'; import type { ChatEngine } from '../ChatEngine'; import type { MediaEngine } from '../MediaEngine'; -import { ProviderRegistry, detectProvider } from './providers'; +import { ProviderRegistry } from './providers'; // --------------------------------------------------------------------------- // Types @@ -68,9 +68,9 @@ export class OneShotTasks { tags: Array<{ name: string; slug: string; existsInProject: boolean }>, modelId: string, ): Promise { - const provider = detectProvider(modelId); + const provider = this.providers.detectModelProvider(modelId); if (!this.providers.isProviderKeySet(provider)) { - const providerLabel = provider === 'mistral' ? 'Mistral' : 'OpenCode'; + const providerLabel = provider === 'mistral' ? 'Mistral' : provider === 'ollama' ? 'Ollama' : 'OpenCode'; return { success: false, error: `${providerLabel} API key not set` }; } @@ -187,7 +187,7 @@ Remember: Only suggest mappings from NEW items to EXISTING items. Consider langu ): Promise { // Determine model with smart fallback let modelId = await this.chatEngine.getSetting('chat_image_analysis_model'); - if (!modelId || !this.providers.isProviderKeySet(detectProvider(modelId))) { + if (!modelId || !this.providers.isProviderKeySet(this.providers.detectModelProvider(modelId))) { modelId = this.providers.getOpencodeKey() ? 'claude-sonnet-4-5' : this.providers.getMistralKey() diff --git a/src/main/ipc/chatHandlers.ts b/src/main/ipc/chatHandlers.ts index 0b7ef5c..be81c5a 100644 --- a/src/main/ipc/chatHandlers.ts +++ b/src/main/ipc/chatHandlers.ts @@ -109,6 +109,21 @@ async function ensureInitialized(): Promise { const mistralKey = await keyStore.retrieve('mistral_api_key'); if (mistralKey) reg.setMistralKey(mistralKey); } catch { /* ignore */ } + + // Restore Ollama enabled state from settings DB + try { + const ollamaEnabled = await getChatEngine().getSetting('ollama_enabled'); + if (ollamaEnabled === 'true') reg.setOllamaEnabled(true); + } catch { /* ignore */ } + + // Restore Ollama model capability overrides + try { + const capsJson = await getChatEngine().getSetting('ollama_model_capabilities'); + if (capsJson) { + const caps = JSON.parse(capsJson) as Record; + reg.loadOllamaModelCapabilities(caps); + } + } catch { /* ignore */ } })(); } await initPromise; @@ -237,6 +252,74 @@ export function registerChatHandlers(): void { } }); + // ============ Ollama (Local) ============ + + // Get Ollama enabled state + ipcMain.handle('chat:getOllamaEnabled', async () => { + try { + await ensureInitialized(); + return getProviders().isOllamaEnabled(); + } catch (error) { + console.error('[Chat IPC] Error getting Ollama enabled state:', error); + return false; + } + }); + + // Set Ollama enabled state + ipcMain.handle('chat:setOllamaEnabled', async (_, enabled: boolean) => { + try { + await ensureInitialized(); + const reg = getProviders(); + reg.setOllamaEnabled(enabled); + + // Persist to settings DB + await getChatEngine().setSetting('ollama_enabled', enabled ? 'true' : 'false'); + return { success: true }; + } catch (error) { + console.error('[Chat IPC] Error setting Ollama enabled state:', error); + return { success: false, error: (error as Error).message }; + } + }); + + // Get Ollama models (probe local server) + ipcMain.handle('chat:getOllamaModels', async () => { + try { + await ensureInitialized(); + return await getProviders().fetchOllamaModels(); + } catch (error) { + console.error('[Chat IPC] Error fetching Ollama models:', error); + return []; + } + }); + + // Get Ollama model capability overrides + ipcMain.handle('chat:getOllamaModelCapabilities', async () => { + try { + await ensureInitialized(); + return getProviders().getAllOllamaModelCapabilities(); + } catch (error) { + console.error('[Chat IPC] Error getting Ollama model capabilities:', error); + return {}; + } + }); + + // Set capability overrides for a single Ollama model + ipcMain.handle('chat:setOllamaModelCapabilities', async (_, modelId: string, caps: { tools: boolean; vision: boolean }) => { + try { + await ensureInitialized(); + const reg = getProviders(); + reg.setOllamaModelCapabilities(modelId, caps); + + // Persist all capabilities to settings DB + const allCaps = reg.getAllOllamaModelCapabilities(); + await getChatEngine().setSetting('ollama_model_capabilities', JSON.stringify(allCaps)); + return { success: true }; + } catch (error) { + console.error('[Chat IPC] Error setting Ollama model capabilities:', error); + return { success: false, error: (error as Error).message }; + } + }); + // ============ Per-Purpose Model Preferences ============ // Get title generation model diff --git a/src/main/preload.ts b/src/main/preload.ts index 1ba9d79..9820891 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -314,6 +314,13 @@ export const electronAPI: ElectronAPI = { setMistralApiKey: (apiKey: string) => ipcRenderer.invoke('chat:setMistralApiKey', apiKey), getMistralApiKey: () => ipcRenderer.invoke('chat:getMistralApiKey'), + // Ollama (Local) + getOllamaEnabled: () => ipcRenderer.invoke('chat:getOllamaEnabled'), + setOllamaEnabled: (enabled: boolean) => ipcRenderer.invoke('chat:setOllamaEnabled', enabled), + getOllamaModels: () => ipcRenderer.invoke('chat:getOllamaModels'), + getOllamaModelCapabilities: () => ipcRenderer.invoke('chat:getOllamaModelCapabilities'), + setOllamaModelCapabilities: (modelId: string, caps: { tools: boolean; vision: boolean }) => ipcRenderer.invoke('chat:setOllamaModelCapabilities', modelId, caps), + // Per-Purpose Model Preferences getTitleModel: () => ipcRenderer.invoke('chat:getTitleModel'), setTitleModel: (modelId: string | null) => ipcRenderer.invoke('chat:setTitleModel', modelId), diff --git a/src/main/shared/electronApi.ts b/src/main/shared/electronApi.ts index a54b176..b9f8d43 100644 --- a/src/main/shared/electronApi.ts +++ b/src/main/shared/electronApi.ts @@ -451,7 +451,7 @@ export interface ChatReadyStatus { ready: boolean; error?: string; backend?: string; - providers?: { opencode: boolean; mistral: boolean }; + providers?: { opencode: boolean; mistral: boolean; ollama: boolean }; } export interface ChatApiKeyStatus { @@ -832,6 +832,13 @@ export interface ElectronAPI { setMistralApiKey: (apiKey: string) => Promise<{ success: boolean; error?: string }>; getMistralApiKey: () => Promise; + // Ollama (local) + getOllamaEnabled: () => Promise; + setOllamaEnabled: (enabled: boolean) => Promise<{ success: boolean; error?: string }>; + getOllamaModels: () => Promise; + getOllamaModelCapabilities: () => Promise>; + setOllamaModelCapabilities: (modelId: string, caps: { tools: boolean; vision: boolean }) => Promise<{ success: boolean; error?: string }>; + // Settings getAvailableModels: () => Promise<{ success: boolean; models?: ChatModel[]; selectedModel?: string; error?: string }>; setDefaultModel: (modelId: string) => Promise<{ success: boolean; error?: string }>; diff --git a/src/renderer/components/SettingsView/SettingsView.css b/src/renderer/components/SettingsView/SettingsView.css index f4e2f2b..faf0334 100644 --- a/src/renderer/components/SettingsView/SettingsView.css +++ b/src/renderer/components/SettingsView/SettingsView.css @@ -532,3 +532,36 @@ gap: 8px; margin-top: 8px; } + +/* Ollama model capabilities table */ +.ollama-model-capabilities { + margin-top: 12px; +} + +.ollama-model-capabilities .setting-description { + display: block; + margin-bottom: 8px; +} + +.ollama-caps-table { + width: 100%; + border-collapse: collapse; + font-size: 0.9em; +} + +.ollama-caps-table th, +.ollama-caps-table td { + padding: 4px 8px; + text-align: left; + border-bottom: 1px solid var(--pico-muted-border-color, #ccc); +} + +.ollama-caps-table th:not(:first-child), +.ollama-caps-table td:not(:first-child) { + text-align: center; + width: 80px; +} + +.ollama-caps-table input[type="checkbox"] { + margin: 0; +} diff --git a/src/renderer/components/SettingsView/SettingsView.tsx b/src/renderer/components/SettingsView/SettingsView.tsx index fbcfcf8..76edc83 100644 --- a/src/renderer/components/SettingsView/SettingsView.tsx +++ b/src/renderer/components/SettingsView/SettingsView.tsx @@ -245,6 +245,9 @@ export const SettingsView: React.FC = () => { const [aiHasMistralKey, setAiHasMistralKey] = useState(false); const [aiMistralKeyMasked, setAiMistralKeyMasked] = useState(''); const [newMistralKey, setNewMistralKey] = useState(''); + const [ollamaEnabled, setOllamaEnabled] = useState(false); + const [ollamaCapabilities, setOllamaCapabilities] = useState>({}); + const [ollamaModels, setOllamaModels] = useState<{id: string; name: string}[]>([]); const [titleModel, setTitleModel] = useState('claude-haiku-4-5'); const [imageAnalysisModel, setImageAnalysisModel] = useState('claude-sonnet-4-5'); const [availableModels, setAvailableModels] = useState<{id: string; name: string; provider?: string; vision?: boolean}[]>([]); @@ -415,6 +418,20 @@ export const SettingsView: React.FC = () => { setAiMistralKeyMasked(mistralKeyResult.maskedKey || ''); } + // Load Ollama enabled state + const ollamaState = await window.electronAPI?.chat.getOllamaEnabled(); + setOllamaEnabled(!!ollamaState); + + // Load Ollama model capabilities and models list + if (ollamaState) { + const [caps, models] = await Promise.all([ + window.electronAPI?.chat.getOllamaModelCapabilities(), + window.electronAPI?.chat.getOllamaModels(), + ]); + if (caps) setOllamaCapabilities(caps); + if (models) setOllamaModels(models.map(m => ({ id: m.id, name: m.name }))); + } + // Load per-purpose model preferences const titleModelResult = await window.electronAPI?.chat.getTitleModel(); if (titleModelResult?.success && titleModelResult.modelId) { @@ -536,7 +553,7 @@ export const SettingsView: React.FC = () => { const projectKeywords = ['project', 'name', 'description', 'blog', 'site', 'url', 'public', 'path', 'folder', 'location', 'data', 'language', 'author', 'default', 'preview', 'max', 'posts', 'page', 'bookmarklet', 'blogmark']; const editorKeywords = ['editor', 'mode', 'wysiwyg', 'markdown', 'preview', 'visual']; const contentKeywords = ['content', 'categories', 'post', 'article', 'picture', 'aside', 'page']; - const aiKeywords = ['ai', 'assistant', 'chat', 'model', 'prompt', 'system', 'api', 'key', 'claude', 'gpt', 'opencode']; + const aiKeywords = ['ai', 'assistant', 'chat', 'model', 'prompt', 'system', 'api', 'key', 'claude', 'gpt', 'opencode', 'ollama', 'local']; const technologyKeywords = ['technology', 'python', 'runtime', 'worker', 'webworker', 'main thread', 'execution']; const publishingKeywords = ['publishing', 'ssh', 'deploy', 'server', 'host', 'upload', 'scp', 'rsync']; const dataKeywords = ['data', 'database', 'rebuild', 'maintenance', 'posts', 'media', 'scripts', 'links', 'folder', 'filesystem']; @@ -1144,6 +1161,55 @@ export const SettingsView: React.FC = () => { } }; + const handleOllamaToggle = async (enabled: boolean) => { + try { + const result = await window.electronAPI?.chat.setOllamaEnabled(enabled); + if (result?.success) { + setOllamaEnabled(enabled); + showToast.success(t(enabled ? 'settings.toast.ollamaEnabled' : 'settings.toast.ollamaDisabled')); + + // Refresh models after toggle + const modelsResult = await window.electronAPI?.chat.getAvailableModels(); + if (modelsResult?.success && modelsResult.models) { + setAvailableModels(modelsResult.models); + setSelectedModel(modelsResult.selectedModel || ''); + } + + // Load Ollama models and capabilities when enabling + if (enabled) { + const [caps, ollamaModelsList] = await Promise.all([ + window.electronAPI?.chat.getOllamaModelCapabilities(), + window.electronAPI?.chat.getOllamaModels(), + ]); + if (caps) setOllamaCapabilities(caps); + if (ollamaModelsList) setOllamaModels(ollamaModelsList.map(m => ({ id: m.id, name: m.name }))); + } else { + setOllamaModels([]); + } + } + } catch (error) { + console.error('Failed to toggle Ollama:', error); + } + }; + + const handleOllamaCapabilityToggle = async (modelId: string, field: 'tools' | 'vision', value: boolean) => { + const current = ollamaCapabilities[modelId] ?? { tools: false, vision: false }; + const updated = { ...current, [field]: value }; + try { + const result = await window.electronAPI?.chat.setOllamaModelCapabilities(modelId, updated); + if (result?.success) { + setOllamaCapabilities(prev => ({ ...prev, [modelId]: updated })); + // Refresh available models to reflect vision change + const modelsResult = await window.electronAPI?.chat.getAvailableModels(); + if (modelsResult?.success && modelsResult.models) { + setAvailableModels(modelsResult.models); + } + } + } catch (error) { + console.error('Failed to update Ollama model capabilities:', error); + } + }; + const handleTitleModelChange = async (modelId: string) => { try { const result = await window.electronAPI?.chat.setTitleModel(modelId); @@ -1236,6 +1302,7 @@ export const SettingsView: React.FC = () => { const providerLabel = (provider: string) => { if (provider === 'anthropic' || provider === 'openai' || provider === 'google' || provider === 'other') return t('settings.ai.providerOpenCode'); if (provider === 'mistral') return t('settings.ai.providerMistral'); + if (provider === 'ollama') return t('settings.ai.providerOllama'); return provider; }; @@ -1346,17 +1413,76 @@ export const SettingsView: React.FC = () => { )} + +
+ + {ollamaEnabled && ( + {t('settings.ai.configured')} + )} +
+ {ollamaEnabled && ollamaModels.length > 0 && ( +
+ {t('settings.ai.ollamaCapabilitiesDescription')} + + + + + + + + + + {ollamaModels.map(m => { + const caps = ollamaCapabilities[m.id] ?? { tools: false, vision: false }; + return ( + + + + + + ); + })} + +
{t('settings.ai.ollamaCapModel')}{t('settings.ai.ollamaCapTools')}{t('settings.ai.ollamaCapVision')}
{m.name} + handleOllamaCapabilityToggle(m.id, 'tools', e.target.checked)} + /> + + handleOllamaCapabilityToggle(m.id, 'vision', e.target.checked)} + /> +
+
+ )} +
+
- {renderModelSelect('ai-model', selectedModel, handleModelChange, !aiHasApiKey && !aiHasMistralKey)} + {renderModelSelect('ai-model', selectedModel, handleModelChange, !aiHasApiKey && !aiHasMistralKey && !ollamaEnabled)}