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'; import desktopApi from './desktop/desktopApi'; const EMBED_MODEL_KEY = 'embedModel'; const RERANK_MODEL_KEY = 'rerankModel'; const ENRICHMENT_MODEL_KEY = 'enrichmentModel'; const MODEL_KEY = 'chatModel'; const VISION_MODEL_KEY = 'visionModel'; const TRANSCRIPTION_MODEL_KEY = 'transcriptionModel'; const WORKFLOW_ROUTER_MODEL_KEY = 'workflowRouterModel'; const WORKFLOW_SELECTION_MODE_KEY = 'workflowSelectionMode'; const AUTO_DEEP_ENRICHMENT_KEY = 'autoDeepEnrichment'; 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_ENRICHMENT_MODEL = 'qwen3:4b'; 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'; } function formatCommitTimestamp(value) { if (!value) { return null; } const parsed = new Date(value); if (Number.isNaN(parsed.getTime())) { return null; } return parsed.toLocaleString(); } export default function GeneralSettings({ panel, onModelChange, onVisionModelChange, onWorkflowRouterModelChange, onWorkflowSelectionModeChange, onTranscriptionModelChange, onLibrariesPurged, onAudioInputDeviceChange, onAudioInputLanguageChange, }) { const [backendApiUrl, setBackendApiUrl] = useState(''); const [embedModel, setEmbedModel] = useState(DEFAULT_EMBED_MODEL); const [rerankModel, setRerankModel] = useState(DEFAULT_EMBED_MODEL); const [enrichmentModel, setEnrichmentModel] = useState(DEFAULT_ENRICHMENT_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 [workflowRouterModel, setWorkflowRouterModel] = useState(''); const [workflowSelectionMode, setWorkflowSelectionMode] = useState('auto'); const [autoDeepEnrichment, setAutoDeepEnrichment] = useState(true); 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 [isChangelogOpen, setIsChangelogOpen] = useState(false); const [changelogEntries, setChangelogEntries] = useState([]); const [changelogPage, setChangelogPage] = useState(1); const [changelogHasMore, setChangelogHasMore] = useState(false); const [isLoadingChangelog, setIsLoadingChangelog] = useState(false); const [changelogError, setChangelogError] = useState(''); 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([ desktopApi.getSettings(), desktopApi.getUpdateStatus(), ]).then(([settings, status]) => { if (cancelled) { return; } setBackendApiUrl(resolveBackendApiUrl(settings)); setEmbedModel(settings.embedModel || DEFAULT_EMBED_MODEL); setRerankModel(settings.rerankModel || settings.embedModel || DEFAULT_EMBED_MODEL); setEnrichmentModel(settings.enrichmentModel || DEFAULT_ENRICHMENT_MODEL); setSelectedModel(settings.chatModel || ''); setVisionModel(settings.visionModel || settings.chatModel || ''); setTranscriptionModel(settings.transcriptionModel || DEFAULT_TRANSCRIPTION_MODEL); setWorkflowRouterModel(settings.workflowRouterModel || ''); setWorkflowSelectionMode(settings.workflowSelectionMode === 'manual' ? 'manual' : 'auto'); setAutoDeepEnrichment(settings.autoDeepEnrichment !== false); 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); desktopApi.setSetting(MODEL_KEY, nextModel); if (onModelChange) { onModelChange(nextModel); } if (visionModels.includes(nextModel) && nextModel !== visionModel) { setVisionModel(nextModel); desktopApi.setSetting(VISION_MODEL_KEY, nextModel); if (onVisionModelChange) { onVisionModelChange(nextModel); } } }, [chatModels, onModelChange, onVisionModelChange, panel, selectedModel, settingsHydrated, visionModel, visionModels]); 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); desktopApi.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); desktopApi.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); desktopApi.setSetting(RERANK_MODEL_KEY, nextModel); }, [rerankingModels, rerankModel, embedModel, panel, settingsHydrated]); useEffect(() => { if (panel !== 'AI Models' || !settingsHydrated || enrichmentModel) { return; } const nextModel = chatModels[0] || DEFAULT_ENRICHMENT_MODEL; setEnrichmentModel(nextModel); desktopApi.setSetting(ENRICHMENT_MODEL_KEY, nextModel); }, [chatModels, enrichmentModel, panel, settingsHydrated]); useEffect(() => { if (panel !== 'AI Models') { return; } if (!settingsHydrated) { return; } if (transcriptionModel) { return; } const nextModel = whisperModels[0] || DEFAULT_TRANSCRIPTION_MODEL; setTranscriptionModel(nextModel); desktopApi.setSetting(TRANSCRIPTION_MODEL_KEY, nextModel); if (onTranscriptionModelChange) { onTranscriptionModelChange(nextModel); } }, [whisperModels, transcriptionModel, onTranscriptionModelChange, panel, settingsHydrated]); useEffect(() => { if (panel !== 'General') { 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); desktopApi.setSetting(MODEL_KEY, newModel); if (onModelChange) { onModelChange(newModel); } if (visionModels.includes(newModel) && newModel !== visionModel) { setVisionModel(newModel); desktopApi.setSetting(VISION_MODEL_KEY, newModel); if (onVisionModelChange) { onVisionModelChange(newModel); } } }; const handleVisionModelChange = (event) => { const newModel = event.target.value; setVisionModel(newModel); desktopApi.setSetting(VISION_MODEL_KEY, newModel); if (onVisionModelChange) { onVisionModelChange(newModel); } }; const handleEmbedModelChange = (event) => { const nextModel = event.target.value; setEmbedModel(nextModel); desktopApi.setSetting(EMBED_MODEL_KEY, nextModel); }; const handleRerankModelChange = (event) => { const nextModel = event.target.value; setRerankModel(nextModel); desktopApi.setSetting(RERANK_MODEL_KEY, nextModel); }; const handleEnrichmentModelChange = (event) => { const nextModel = event.target.value; setEnrichmentModel(nextModel); desktopApi.setSetting(ENRICHMENT_MODEL_KEY, nextModel); }; const handleTranscriptionModelChange = (event) => { const nextModel = event.target.value; setTranscriptionModel(nextModel); desktopApi.setSetting(TRANSCRIPTION_MODEL_KEY, nextModel); if (onTranscriptionModelChange) { onTranscriptionModelChange(nextModel); } }; const handleWorkflowRouterModelChange = (event) => { const nextModel = event.target.value; setWorkflowRouterModel(nextModel); desktopApi.setSetting(WORKFLOW_ROUTER_MODEL_KEY, nextModel); onWorkflowRouterModelChange?.(nextModel); }; const handleWorkflowSelectionModeChange = (event) => { const nextMode = event.target.value === 'manual' ? 'manual' : 'auto'; setWorkflowSelectionMode(nextMode); desktopApi.setSetting(WORKFLOW_SELECTION_MODE_KEY, nextMode); onWorkflowSelectionModeChange?.(nextMode); }; const handleAutoDeepEnrichmentToggle = () => { const nextValue = !autoDeepEnrichment; setAutoDeepEnrichment(nextValue); desktopApi.setSetting(AUTO_DEEP_ENRICHMENT_KEY, nextValue); }; 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); desktopApi.setSetting(AUDIO_INPUT_DEVICE_ID_KEY, nextDeviceId); if (onAudioInputDeviceChange) { onAudioInputDeviceChange(nextDeviceId); } }; const handleAudioInputLanguageChange = (event) => { const nextLanguage = event.target.value; setAudioInputLanguage(nextLanguage); desktopApi.setSetting(AUDIO_INPUT_LANGUAGE_KEY, nextLanguage); if (onAudioInputLanguageChange) { onAudioInputLanguageChange(nextLanguage); } }; const handleCheckForUpdates = async () => { setIsCheckingForUpdates(true); try { const status = await desktopApi.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 loadChangelogPage = async (page) => { setIsLoadingChangelog(true); setChangelogError(''); try { const result = await desktopApi.getChangelogPage(page); setChangelogEntries(Array.isArray(result?.entries) ? result.entries : []); setChangelogPage(Number.isInteger(result?.page) ? result.page : page); setChangelogHasMore(Boolean(result?.hasMore)); } catch (error) { setChangelogError(`Changelog load failed: ${error.message || String(error)}`); } finally { setIsLoadingChangelog(false); } }; const handleToggleChangelog = async () => { if (isChangelogOpen) { setIsChangelogOpen(false); return; } setIsChangelogOpen(true); if (changelogEntries.length === 0 && !isLoadingChangelog) { await loadChangelogPage(1); } }; const handleChangelogPageChange = async (nextPage) => { if (isLoadingChangelog || nextPage < 1) { return; } if (nextPage > changelogPage && !changelogHasMore) { return; } await loadChangelogPage(nextPage); }; 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 enrichmentModelOptions = buildSelectOptions(chatModels, enrichmentModel, 'saved model unavailable', showMissingModelLabel); const transcriptionModelOptions = buildSelectOptions(whisperModels, transcriptionModel, 'saved model unavailable', showMissingModelLabel); const workflowRouterModelOptions = buildSelectOptions(chatModels, workflowRouterModel, 'saved model unavailable', showMissingModelLabel); if (panel === 'AI Models') { return (
Heimgeist uses this model for normal text chat.
Auto lets Heimgeist route each chat prompt from the enabled chat capabilities. Manual shows the Flow selector in the chat bar.
Automatic workflow selection uses this local Ollama model and falls back to the chat model when unavailable.
Heimgeist uses this model when a chat message includes image attachments.
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.
Heimgeist uses this local model for summaries, keywords, entities, and optional Deep enrichment Q&A.
Heimgeist uses this Whisper model for microphone transcription.
{audioInputStatus.message}
)}Microphone input is available in the chat composer. Heimgeist records locally and sends the clip to the configured Whisper model.
Whisper can auto-detect the spoken language, but you can force a fixed input language here when auto-detection drifts.
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)}{changelogError}
)} {isLoadingChangelog && (Loading changelog...
)} {!isLoadingChangelog && !changelogError && changelogEntries.length === 0 && (No commits found.
)} {changelogEntries.length > 0 && ( <>{shortCommit(entry.hash)}
{committedAtLabel && {committedAtLabel}}
After standard metadata is generated, Heimgeist automatically queues deep enrichment to add generated Q&A. Individual content can still be switched back to Standard.
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}
)}