import React, { useEffect, useState } from 'react'; import { AUDIO_INPUT_DEVICE_ID_KEY, AUDIO_INPUT_LANGUAGE_KEY, AUDIO_INPUT_LANGUAGE_OPTIONS, ensureAudioInputPermission, listAudioInputDevices, supportsAudioInputCapture, } from './audioInput'; const EMBED_MODEL_KEY = 'embedModel'; const RERANK_MODEL_KEY = 'rerankModel'; const MODEL_KEY = 'chatModel'; const VISION_MODEL_KEY = 'visionModel'; const TRANSCRIPTION_MODEL_KEY = 'transcriptionModel'; const DEFAULT_AUDIO_INPUT_DEVICE_ID = ''; const DEFAULT_AUDIO_INPUT_LANGUAGE = ''; const DEFAULT_BACKEND_API_URL = 'http://127.0.0.1:8000'; const DEFAULT_EMBED_MODEL = 'nomic-embed-text:latest'; const DEFAULT_TRANSCRIPTION_MODEL = 'base'; const DEFAULT_UPDATE_STATUS = { state: 'idle', message: '', checkedAt: null, localCommit: null, remoteCommit: null, }; function resolveBackendApiUrl(settings) { return settings.backendApiUrl || settings.ollamaApiUrl || DEFAULT_BACKEND_API_URL; } function buildSelectOptions(values, currentValue, missingLabel, showMissingLabel = true) { const uniqueValues = [...new Set((Array.isArray(values) ? values : []).filter(Boolean))]; const options = uniqueValues.map(value => ({ value, label: value })); if (currentValue && !uniqueValues.includes(currentValue)) { options.unshift({ value: currentValue, label: showMissingLabel ? `${currentValue} (${missingLabel})` : currentValue, }); } return options; } function shortCommit(commit) { return typeof commit === 'string' && commit.length > 7 ? commit.slice(0, 7) : commit || '—'; } function getStatusTone(state) { if (state === 'error') return 'error'; if (state === 'updated' || state === 'up-to-date') return 'success'; if (state === 'skipped' || state === 'unavailable') return 'warning'; return 'neutral'; } export default function GeneralSettings({ panel, onModelChange, onVisionModelChange, onTranscriptionModelChange, onLibrariesPurged, onAudioInputDeviceChange, onAudioInputLanguageChange, }) { const [backendApiUrl, setBackendApiUrl] = useState(''); const [embedModel, setEmbedModel] = useState(DEFAULT_EMBED_MODEL); const [rerankModel, setRerankModel] = useState(DEFAULT_EMBED_MODEL); const [chatModels, setChatModels] = useState([]); const [embeddingModels, setEmbeddingModels] = useState([]); const [visionModels, setVisionModels] = useState([]); const [rerankingModels, setRerankingModels] = useState([]); const [whisperModels, setWhisperModels] = useState([]); const [selectedModel, setSelectedModel] = useState(''); const [visionModel, setVisionModel] = useState(''); const [transcriptionModel, setTranscriptionModel] = useState(DEFAULT_TRANSCRIPTION_MODEL); const [audioInputDeviceId, setAudioInputDeviceId] = useState(DEFAULT_AUDIO_INPUT_DEVICE_ID); const [audioInputLanguage, setAudioInputLanguage] = useState(DEFAULT_AUDIO_INPUT_LANGUAGE); const [audioInputDevices, setAudioInputDevices] = useState([]); const [isRefreshingAudioDevices, setIsRefreshingAudioDevices] = useState(false); const [audioInputStatus, setAudioInputStatus] = useState({ tone: 'neutral', message: '' }); const [updateStatus, setUpdateStatus] = useState(DEFAULT_UPDATE_STATUS); const [isCheckingForUpdates, setIsCheckingForUpdates] = useState(false); const [isPurgingLibraries, setIsPurgingLibraries] = useState(false); const [libraryPurgeStatus, setLibraryPurgeStatus] = useState({ tone: 'neutral', message: '' }); const [settingsHydrated, setSettingsHydrated] = useState(false); const [isLoadingModelCatalog, setIsLoadingModelCatalog] = useState(false); const audioInputSupported = supportsAudioInputCapture(); useEffect(() => { let cancelled = false; Promise.all([ window.electronAPI.getSettings(), window.electronAPI.getUpdateStatus(), ]).then(([settings, status]) => { if (cancelled) { return; } setBackendApiUrl(resolveBackendApiUrl(settings)); setEmbedModel(settings.embedModel || DEFAULT_EMBED_MODEL); setRerankModel(settings.rerankModel || settings.embedModel || DEFAULT_EMBED_MODEL); setSelectedModel(settings.chatModel || ''); setVisionModel(settings.visionModel || settings.chatModel || ''); setTranscriptionModel(settings.transcriptionModel || DEFAULT_TRANSCRIPTION_MODEL); setAudioInputDeviceId( typeof settings.audioInputDeviceId === 'string' ? settings.audioInputDeviceId : DEFAULT_AUDIO_INPUT_DEVICE_ID ); setAudioInputLanguage( typeof settings.audioInputLanguage === 'string' ? settings.audioInputLanguage : DEFAULT_AUDIO_INPUT_LANGUAGE ); setUpdateStatus(status || DEFAULT_UPDATE_STATUS); }).finally(() => { if (!cancelled) { setSettingsHydrated(true); } }); return () => { cancelled = true; }; }, []); useEffect(() => { if (panel !== 'AI Models') { setIsLoadingModelCatalog(false); return () => {}; } if (!backendApiUrl) { setIsLoadingModelCatalog(false); return () => {}; } let cancelled = false; setIsLoadingModelCatalog(true); fetch(backendApiUrl + '/models') .then(r => r.json()) .then(data => { if (cancelled) { return; } setChatModels(Array.isArray(data.chat_models) ? data.chat_models : []); setEmbeddingModels(Array.isArray(data.embedding_models) ? data.embedding_models : []); setVisionModels(Array.isArray(data.vision_models) ? data.vision_models : []); setRerankingModels(Array.isArray(data.reranking_models) ? data.reranking_models : []); setWhisperModels(Array.isArray(data.whisper_models) ? data.whisper_models.map(model => model.name).filter(Boolean) : []); }) .catch(err => { if (!cancelled) { console.error('Failed to load models', err); } }) .finally(() => { if (!cancelled) { setIsLoadingModelCatalog(false); } }); return () => { cancelled = true; }; }, [backendApiUrl, panel]); useEffect(() => { if (panel !== 'AI Models') { return; } if (!settingsHydrated) { return; } if (chatModels.length === 0) { return; } const nextModel = chatModels.includes(selectedModel) ? selectedModel : chatModels[0]; if (nextModel === selectedModel) { return; } setSelectedModel(nextModel); window.electronAPI.setSetting(MODEL_KEY, nextModel); if (onModelChange) { onModelChange(nextModel); } }, [chatModels, selectedModel, onModelChange, panel, settingsHydrated]); useEffect(() => { if (panel !== 'AI Models') { return; } if (!settingsHydrated) { return; } if (visionModels.length === 0) { return; } const nextModel = visionModels.includes(visionModel) ? visionModel : visionModels[0]; if (nextModel === visionModel) { return; } setVisionModel(nextModel); window.electronAPI.setSetting(VISION_MODEL_KEY, nextModel); if (onVisionModelChange) { onVisionModelChange(nextModel); } }, [visionModels, visionModel, onVisionModelChange, panel, settingsHydrated]); useEffect(() => { if (panel !== 'AI Models') { return; } if (!settingsHydrated) { return; } if (embedModel) { return; } const nextModel = embeddingModels[0] || DEFAULT_EMBED_MODEL; setEmbedModel(nextModel); window.electronAPI.setSetting(EMBED_MODEL_KEY, nextModel); }, [embeddingModels, embedModel, panel, settingsHydrated]); useEffect(() => { if (panel !== 'AI Models') { return; } if (!settingsHydrated) { return; } if (rerankModel) { return; } const nextModel = embedModel || rerankingModels[0] || DEFAULT_EMBED_MODEL; setRerankModel(nextModel); window.electronAPI.setSetting(RERANK_MODEL_KEY, nextModel); }, [rerankingModels, rerankModel, embedModel, panel, settingsHydrated]); useEffect(() => { if (panel !== 'AI Models') { return; } if (!settingsHydrated) { return; } if (transcriptionModel) { return; } const nextModel = whisperModels[0] || DEFAULT_TRANSCRIPTION_MODEL; setTranscriptionModel(nextModel); window.electronAPI.setSetting(TRANSCRIPTION_MODEL_KEY, nextModel); if (onTranscriptionModelChange) { onTranscriptionModelChange(nextModel); } }, [whisperModels, transcriptionModel, onTranscriptionModelChange, panel, settingsHydrated]); useEffect(() => { if (panel !== 'Microphone') { return () => {}; } if (!audioInputSupported) { setAudioInputStatus({ tone: 'warning', message: 'Microphone capture is not available in this environment.', }); return () => {}; } let cancelled = false; const refreshDevices = async () => { try { const devices = await listAudioInputDevices(); if (cancelled) { return; } setAudioInputDevices(devices); if (devices.length === 0) { setAudioInputStatus({ tone: 'warning', message: 'No audio input devices found yet. Grant microphone access and refresh the list.', }); return; } if (!devices.some(device => device.hasLabel)) { setAudioInputStatus({ tone: 'neutral', message: 'Device names appear after microphone access has been granted once.', }); return; } setAudioInputStatus({ tone: 'neutral', message: '' }); } catch (error) { if (!cancelled) { setAudioInputStatus({ tone: 'error', message: `Could not list audio devices: ${error.message || String(error)}`, }); } } }; refreshDevices(); const mediaDevices = navigator.mediaDevices; if (mediaDevices?.addEventListener) { mediaDevices.addEventListener('devicechange', refreshDevices); } return () => { cancelled = true; if (mediaDevices?.removeEventListener) { mediaDevices.removeEventListener('devicechange', refreshDevices); } }; }, [audioInputSupported, panel]); const handleModelChange = (event) => { const newModel = event.target.value; setSelectedModel(newModel); window.electronAPI.setSetting(MODEL_KEY, newModel); if (onModelChange) { onModelChange(newModel); } }; const handleVisionModelChange = (event) => { const newModel = event.target.value; setVisionModel(newModel); window.electronAPI.setSetting(VISION_MODEL_KEY, newModel); if (onVisionModelChange) { onVisionModelChange(newModel); } }; const handleEmbedModelChange = (event) => { const nextModel = event.target.value; setEmbedModel(nextModel); window.electronAPI.setSetting(EMBED_MODEL_KEY, nextModel); }; const handleRerankModelChange = (event) => { const nextModel = event.target.value; setRerankModel(nextModel); window.electronAPI.setSetting(RERANK_MODEL_KEY, nextModel); }; const handleTranscriptionModelChange = (event) => { const nextModel = event.target.value; setTranscriptionModel(nextModel); window.electronAPI.setSetting(TRANSCRIPTION_MODEL_KEY, nextModel); if (onTranscriptionModelChange) { onTranscriptionModelChange(nextModel); } }; const refreshAudioDevices = async ({ requestAccess = false } = {}) => { if (!audioInputSupported) { return; } setIsRefreshingAudioDevices(true); try { if (requestAccess) { await ensureAudioInputPermission(); } const devices = await listAudioInputDevices(); setAudioInputDevices(devices); if (devices.length === 0) { setAudioInputStatus({ tone: 'warning', message: 'No audio input devices found yet. Check the OS microphone permission and refresh again.', }); } else if (!devices.some(device => device.hasLabel)) { setAudioInputStatus({ tone: 'neutral', message: 'Microphone access was requested. Refresh again if the system permission dialog just closed.', }); } else { setAudioInputStatus({ tone: 'success', message: 'Audio input devices refreshed.' }); } } catch (error) { setAudioInputStatus({ tone: 'error', message: `Microphone access failed: ${error.message || String(error)}`, }); } finally { setIsRefreshingAudioDevices(false); } }; const handleAudioInputDeviceChange = (event) => { const nextDeviceId = event.target.value; setAudioInputDeviceId(nextDeviceId); window.electronAPI.setSetting(AUDIO_INPUT_DEVICE_ID_KEY, nextDeviceId); if (onAudioInputDeviceChange) { onAudioInputDeviceChange(nextDeviceId); } }; const handleAudioInputLanguageChange = (event) => { const nextLanguage = event.target.value; setAudioInputLanguage(nextLanguage); window.electronAPI.setSetting(AUDIO_INPUT_LANGUAGE_KEY, nextLanguage); if (onAudioInputLanguageChange) { onAudioInputLanguageChange(nextLanguage); } }; const handleCheckForUpdates = async () => { setIsCheckingForUpdates(true); try { const status = await window.electronAPI.checkForUpdates(); setUpdateStatus(status || DEFAULT_UPDATE_STATUS); } catch (error) { setUpdateStatus({ state: 'error', message: `Update check failed: ${error.message || String(error)}`, checkedAt: new Date().toISOString(), localCommit: null, remoteCommit: null, }); } finally { setIsCheckingForUpdates(false); } }; const handlePurgeLibraries = async () => { const confirmed = window.confirm( 'Delete all Heimgeist databases, staged files, and indexes from local storage? Chat history will be kept.' ); if (!confirmed) { return; } setIsPurgingLibraries(true); setLibraryPurgeStatus({ tone: 'neutral', message: '' }); try { const response = await fetch(`${backendApiUrl}/libraries/purge`, { method: 'POST', }); const data = await response.json().catch(() => null); if (!response.ok) { throw new Error(data?.detail || `HTTP ${response.status}`); } const count = Number(data?.count) || 0; setLibraryPurgeStatus({ tone: 'success', message: count > 0 ? `Removed ${count} database${count === 1 ? '' : 's'} from local storage.` : 'No local databases were found to remove.', }); if (onLibrariesPurged) { await Promise.resolve(onLibrariesPurged()); } } catch (error) { setLibraryPurgeStatus({ tone: 'error', message: `Database purge failed: ${error.message || String(error)}`, }); } finally { setIsPurgingLibraries(false); } }; const updateCheckedAtLabel = updateStatus.checkedAt ? new Date(updateStatus.checkedAt).toLocaleString() : null; const selectedAudioDeviceMissing = Boolean( audioInputDeviceId && !audioInputDevices.some(device => device.deviceId === audioInputDeviceId) ); const audioInputOptions = selectedAudioDeviceMissing ? [ ...audioInputDevices, { deviceId: audioInputDeviceId, label: 'Saved device (currently unavailable)', hasLabel: true, }, ] : audioInputDevices; const audioDeviceRefreshLabel = audioInputDevices.some(device => device.hasLabel) ? 'Refresh devices' : 'Allow microphone access'; const showMissingModelLabel = !isLoadingModelCatalog; const chatModelOptions = buildSelectOptions(chatModels, selectedModel, 'saved model unavailable', showMissingModelLabel); const visionModelOptions = buildSelectOptions(visionModels, visionModel, 'saved model unavailable', showMissingModelLabel); const embeddingModelOptions = buildSelectOptions(embeddingModels, embedModel, 'saved model unavailable', showMissingModelLabel); const rerankingModelOptions = buildSelectOptions(rerankingModels, rerankModel, 'saved model unavailable', showMissingModelLabel); const transcriptionModelOptions = buildSelectOptions(whisperModels, transcriptionModel, 'saved model unavailable', showMissingModelLabel); return (
Internal UI requests like chats, sessions, and databases go to this URL.
Heimgeist uses this URL to talk to Ollama for models and chat generation.
Heimgeist uses this model for building or rebuilding local database embeddings.
Heimgeist currently uses an embedding-based reranker for web search, so this should generally be an embedding-capable Ollama model.
Enables microphone transcription in the chat composer. Heimgeist records locally and sends the clip to the local Whisper runtime.
{audioInputEnabled && ( <>{audioInputStatus.message}
)}Select the Whisper model Heimgeist should use for microphone transcription.
Whisper can auto-detect the spoken language, but you can force a fixed input language here when auto-detection drifts.
> )}Heimgeist uses this model for normal text chat.
Heimgeist uses this model when a chat message includes image attachments.
Compares the local Git commit with remote master, pulls changes when needed, and restarts Heimgeist automatically. The same check also runs on every startup.
{updateStatus.message}
)} {(updateStatus.localCommit || updateStatus.remoteCommit || updateCheckedAtLabel) && ({shortCommit(updateStatus.localCommit)}{shortCommit(updateStatus.remoteCommit)}Removes every local Heimgeist database, including staged files, corpora, and indexes. This is meant as a recovery action when the DB panel becomes unusable. Chat history stays intact.
{libraryPurgeStatus.message && ({libraryPurgeStatus.message}
)}