2026-04-16 22:03:20 +02:00
|
|
|
import React, { useEffect, useState } from 'react';
|
|
|
|
|
import {
|
|
|
|
|
AUDIO_INPUT_DEVICE_ID_KEY,
|
2026-04-17 04:43:28 +02:00
|
|
|
AUDIO_INPUT_LANGUAGE_KEY,
|
|
|
|
|
AUDIO_INPUT_LANGUAGE_OPTIONS,
|
2026-04-16 22:03:20 +02:00
|
|
|
ensureAudioInputPermission,
|
|
|
|
|
listAudioInputDevices,
|
|
|
|
|
supportsAudioInputCapture,
|
|
|
|
|
} from './audioInput';
|
2026-05-06 04:40:01 +02:00
|
|
|
import desktopApi from './desktop/desktopApi';
|
2025-08-22 23:42:34 +02:00
|
|
|
|
2026-03-20 12:00:44 +01:00
|
|
|
const EMBED_MODEL_KEY = 'embedModel';
|
2026-04-17 08:36:52 +02:00
|
|
|
const RERANK_MODEL_KEY = 'rerankModel';
|
2026-06-15 02:42:36 +02:00
|
|
|
const ENRICHMENT_MODEL_KEY = 'enrichmentModel';
|
2025-08-22 23:42:34 +02:00
|
|
|
const MODEL_KEY = 'chatModel';
|
2026-04-17 08:36:52 +02:00
|
|
|
const VISION_MODEL_KEY = 'visionModel';
|
|
|
|
|
const TRANSCRIPTION_MODEL_KEY = 'transcriptionModel';
|
2026-06-15 15:19:51 +02:00
|
|
|
const WORKFLOW_ROUTER_MODEL_KEY = 'workflowRouterModel';
|
2026-06-15 04:40:41 +02:00
|
|
|
const AUTO_DEEP_ENRICHMENT_KEY = 'autoDeepEnrichment';
|
2026-04-16 22:03:20 +02:00
|
|
|
const DEFAULT_AUDIO_INPUT_DEVICE_ID = '';
|
2026-04-17 04:43:28 +02:00
|
|
|
const DEFAULT_AUDIO_INPUT_LANGUAGE = '';
|
2026-03-20 08:15:59 +01:00
|
|
|
const DEFAULT_BACKEND_API_URL = 'http://127.0.0.1:8000';
|
2026-03-20 12:00:44 +01:00
|
|
|
const DEFAULT_EMBED_MODEL = 'nomic-embed-text:latest';
|
2026-06-15 02:42:36 +02:00
|
|
|
const DEFAULT_ENRICHMENT_MODEL = 'qwen3:4b';
|
2026-04-17 08:36:52 +02:00
|
|
|
const DEFAULT_TRANSCRIPTION_MODEL = 'base';
|
2026-03-20 08:46:55 +01:00
|
|
|
const DEFAULT_UPDATE_STATUS = {
|
|
|
|
|
state: 'idle',
|
|
|
|
|
message: '',
|
|
|
|
|
checkedAt: null,
|
|
|
|
|
localCommit: null,
|
|
|
|
|
remoteCommit: null,
|
|
|
|
|
};
|
2026-03-20 08:15:59 +01:00
|
|
|
|
|
|
|
|
function resolveBackendApiUrl(settings) {
|
|
|
|
|
return settings.backendApiUrl || settings.ollamaApiUrl || DEFAULT_BACKEND_API_URL;
|
|
|
|
|
}
|
2025-08-22 23:42:34 +02:00
|
|
|
|
2026-04-17 08:48:14 +02:00
|
|
|
function buildSelectOptions(values, currentValue, missingLabel, showMissingLabel = true) {
|
2026-04-17 08:36:52 +02:00
|
|
|
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,
|
2026-04-17 08:48:14 +02:00
|
|
|
label: showMissingLabel ? `${currentValue} (${missingLabel})` : currentValue,
|
2026-04-17 08:36:52 +02:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
return options;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-20 08:46:55 +01:00
|
|
|
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';
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-17 10:22:39 +02:00
|
|
|
function formatCommitTimestamp(value) {
|
|
|
|
|
if (!value) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const parsed = new Date(value);
|
|
|
|
|
if (Number.isNaN(parsed.getTime())) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return parsed.toLocaleString();
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-16 22:03:20 +02:00
|
|
|
export default function GeneralSettings({
|
2026-04-17 09:00:21 +02:00
|
|
|
panel,
|
2026-04-16 22:03:20 +02:00
|
|
|
onModelChange,
|
2026-04-17 08:36:52 +02:00
|
|
|
onVisionModelChange,
|
2026-06-15 15:19:51 +02:00
|
|
|
onWorkflowRouterModelChange,
|
2026-04-17 08:36:52 +02:00
|
|
|
onTranscriptionModelChange,
|
2026-04-16 22:03:20 +02:00
|
|
|
onLibrariesPurged,
|
|
|
|
|
onAudioInputDeviceChange,
|
2026-04-17 04:43:28 +02:00
|
|
|
onAudioInputLanguageChange,
|
2026-04-16 22:03:20 +02:00
|
|
|
}) {
|
2026-03-20 08:15:59 +01:00
|
|
|
const [backendApiUrl, setBackendApiUrl] = useState('');
|
2026-03-20 12:00:44 +01:00
|
|
|
const [embedModel, setEmbedModel] = useState(DEFAULT_EMBED_MODEL);
|
2026-04-17 08:36:52 +02:00
|
|
|
const [rerankModel, setRerankModel] = useState(DEFAULT_EMBED_MODEL);
|
2026-06-15 02:42:36 +02:00
|
|
|
const [enrichmentModel, setEnrichmentModel] = useState(DEFAULT_ENRICHMENT_MODEL);
|
2026-04-17 08:36:52 +02:00
|
|
|
const [chatModels, setChatModels] = useState([]);
|
|
|
|
|
const [embeddingModels, setEmbeddingModels] = useState([]);
|
|
|
|
|
const [visionModels, setVisionModels] = useState([]);
|
|
|
|
|
const [rerankingModels, setRerankingModels] = useState([]);
|
|
|
|
|
const [whisperModels, setWhisperModels] = useState([]);
|
2025-08-22 23:42:34 +02:00
|
|
|
const [selectedModel, setSelectedModel] = useState('');
|
2026-04-17 08:36:52 +02:00
|
|
|
const [visionModel, setVisionModel] = useState('');
|
|
|
|
|
const [transcriptionModel, setTranscriptionModel] = useState(DEFAULT_TRANSCRIPTION_MODEL);
|
2026-06-15 15:19:51 +02:00
|
|
|
const [workflowRouterModel, setWorkflowRouterModel] = useState('');
|
2026-06-15 04:40:41 +02:00
|
|
|
const [autoDeepEnrichment, setAutoDeepEnrichment] = useState(true);
|
2026-04-16 22:03:20 +02:00
|
|
|
const [audioInputDeviceId, setAudioInputDeviceId] = useState(DEFAULT_AUDIO_INPUT_DEVICE_ID);
|
2026-04-17 04:43:28 +02:00
|
|
|
const [audioInputLanguage, setAudioInputLanguage] = useState(DEFAULT_AUDIO_INPUT_LANGUAGE);
|
2026-04-16 22:03:20 +02:00
|
|
|
const [audioInputDevices, setAudioInputDevices] = useState([]);
|
|
|
|
|
const [isRefreshingAudioDevices, setIsRefreshingAudioDevices] = useState(false);
|
|
|
|
|
const [audioInputStatus, setAudioInputStatus] = useState({ tone: 'neutral', message: '' });
|
2026-03-20 08:46:55 +01:00
|
|
|
const [updateStatus, setUpdateStatus] = useState(DEFAULT_UPDATE_STATUS);
|
|
|
|
|
const [isCheckingForUpdates, setIsCheckingForUpdates] = useState(false);
|
2026-04-17 10:22:39 +02:00
|
|
|
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('');
|
2026-03-20 10:33:35 +01:00
|
|
|
const [isPurgingLibraries, setIsPurgingLibraries] = useState(false);
|
|
|
|
|
const [libraryPurgeStatus, setLibraryPurgeStatus] = useState({ tone: 'neutral', message: '' });
|
2026-04-17 08:39:57 +02:00
|
|
|
const [settingsHydrated, setSettingsHydrated] = useState(false);
|
2026-04-17 08:48:14 +02:00
|
|
|
const [isLoadingModelCatalog, setIsLoadingModelCatalog] = useState(false);
|
2026-04-16 22:03:20 +02:00
|
|
|
const audioInputSupported = supportsAudioInputCapture();
|
2025-08-22 23:42:34 +02:00
|
|
|
|
|
|
|
|
useEffect(() => {
|
2026-03-20 08:46:55 +01:00
|
|
|
let cancelled = false;
|
|
|
|
|
|
|
|
|
|
Promise.all([
|
2026-05-06 04:39:39 +02:00
|
|
|
desktopApi.getSettings(),
|
|
|
|
|
desktopApi.getUpdateStatus(),
|
2026-03-20 08:46:55 +01:00
|
|
|
]).then(([settings, status]) => {
|
|
|
|
|
if (cancelled) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-20 08:15:59 +01:00
|
|
|
setBackendApiUrl(resolveBackendApiUrl(settings));
|
2026-03-20 12:00:44 +01:00
|
|
|
setEmbedModel(settings.embedModel || DEFAULT_EMBED_MODEL);
|
2026-04-17 08:36:52 +02:00
|
|
|
setRerankModel(settings.rerankModel || settings.embedModel || DEFAULT_EMBED_MODEL);
|
2026-06-15 02:42:36 +02:00
|
|
|
setEnrichmentModel(settings.enrichmentModel || DEFAULT_ENRICHMENT_MODEL);
|
2025-08-22 23:42:34 +02:00
|
|
|
setSelectedModel(settings.chatModel || '');
|
2026-04-17 08:36:52 +02:00
|
|
|
setVisionModel(settings.visionModel || settings.chatModel || '');
|
|
|
|
|
setTranscriptionModel(settings.transcriptionModel || DEFAULT_TRANSCRIPTION_MODEL);
|
2026-06-15 15:19:51 +02:00
|
|
|
setWorkflowRouterModel(settings.workflowRouterModel || '');
|
2026-06-15 04:40:41 +02:00
|
|
|
setAutoDeepEnrichment(settings.autoDeepEnrichment !== false);
|
2026-04-16 22:03:20 +02:00
|
|
|
setAudioInputDeviceId(
|
|
|
|
|
typeof settings.audioInputDeviceId === 'string'
|
|
|
|
|
? settings.audioInputDeviceId
|
|
|
|
|
: DEFAULT_AUDIO_INPUT_DEVICE_ID
|
|
|
|
|
);
|
2026-04-17 04:43:28 +02:00
|
|
|
setAudioInputLanguage(
|
|
|
|
|
typeof settings.audioInputLanguage === 'string'
|
|
|
|
|
? settings.audioInputLanguage
|
|
|
|
|
: DEFAULT_AUDIO_INPUT_LANGUAGE
|
|
|
|
|
);
|
2026-03-20 08:46:55 +01:00
|
|
|
setUpdateStatus(status || DEFAULT_UPDATE_STATUS);
|
2026-04-17 08:40:22 +02:00
|
|
|
}).finally(() => {
|
|
|
|
|
if (!cancelled) {
|
|
|
|
|
setSettingsHydrated(true);
|
|
|
|
|
}
|
2025-08-22 23:42:34 +02:00
|
|
|
});
|
2026-03-20 08:46:55 +01:00
|
|
|
|
|
|
|
|
return () => {
|
|
|
|
|
cancelled = true;
|
|
|
|
|
};
|
2025-08-22 23:42:34 +02:00
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
2026-04-17 09:00:44 +02:00
|
|
|
if (panel !== 'AI Models') {
|
|
|
|
|
setIsLoadingModelCatalog(false);
|
|
|
|
|
return () => {};
|
|
|
|
|
}
|
2026-04-17 08:48:14 +02:00
|
|
|
if (!backendApiUrl) {
|
|
|
|
|
setIsLoadingModelCatalog(false);
|
|
|
|
|
return () => {};
|
2025-08-22 23:42:34 +02:00
|
|
|
}
|
2026-04-17 08:48:14 +02:00
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
};
|
2026-04-17 09:00:44 +02:00
|
|
|
}, [backendApiUrl, panel]);
|
2026-04-17 08:36:52 +02:00
|
|
|
|
|
|
|
|
useEffect(() => {
|
2026-04-17 09:00:44 +02:00
|
|
|
if (panel !== 'AI Models') {
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-04-17 08:39:57 +02:00
|
|
|
if (!settingsHydrated) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-04-17 08:36:52 +02:00
|
|
|
if (chatModels.length === 0) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const nextModel = chatModels.includes(selectedModel) ? selectedModel : chatModels[0];
|
|
|
|
|
if (nextModel === selectedModel) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
setSelectedModel(nextModel);
|
2026-05-06 04:39:39 +02:00
|
|
|
desktopApi.setSetting(MODEL_KEY, nextModel);
|
2026-04-17 08:36:52 +02:00
|
|
|
if (onModelChange) {
|
|
|
|
|
onModelChange(nextModel);
|
|
|
|
|
}
|
2026-04-17 10:48:48 +02:00
|
|
|
if (visionModels.includes(nextModel) && nextModel !== visionModel) {
|
|
|
|
|
setVisionModel(nextModel);
|
2026-05-06 04:39:39 +02:00
|
|
|
desktopApi.setSetting(VISION_MODEL_KEY, nextModel);
|
2026-04-17 10:48:48 +02:00
|
|
|
if (onVisionModelChange) {
|
|
|
|
|
onVisionModelChange(nextModel);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}, [chatModels, onModelChange, onVisionModelChange, panel, selectedModel, settingsHydrated, visionModel, visionModels]);
|
2026-04-17 08:36:52 +02:00
|
|
|
|
|
|
|
|
useEffect(() => {
|
2026-04-17 09:00:44 +02:00
|
|
|
if (panel !== 'AI Models') {
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-04-17 08:39:57 +02:00
|
|
|
if (!settingsHydrated) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-04-17 08:36:52 +02:00
|
|
|
if (visionModels.length === 0) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const nextModel = visionModels.includes(visionModel) ? visionModel : visionModels[0];
|
|
|
|
|
if (nextModel === visionModel) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
setVisionModel(nextModel);
|
2026-05-06 04:39:39 +02:00
|
|
|
desktopApi.setSetting(VISION_MODEL_KEY, nextModel);
|
2026-04-17 08:36:52 +02:00
|
|
|
if (onVisionModelChange) {
|
|
|
|
|
onVisionModelChange(nextModel);
|
|
|
|
|
}
|
2026-04-17 09:00:44 +02:00
|
|
|
}, [visionModels, visionModel, onVisionModelChange, panel, settingsHydrated]);
|
2026-04-17 08:36:52 +02:00
|
|
|
|
|
|
|
|
useEffect(() => {
|
2026-04-17 09:00:44 +02:00
|
|
|
if (panel !== 'AI Models') {
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-04-17 08:39:57 +02:00
|
|
|
if (!settingsHydrated) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-04-17 08:36:52 +02:00
|
|
|
if (embedModel) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const nextModel = embeddingModels[0] || DEFAULT_EMBED_MODEL;
|
|
|
|
|
setEmbedModel(nextModel);
|
2026-05-06 04:39:39 +02:00
|
|
|
desktopApi.setSetting(EMBED_MODEL_KEY, nextModel);
|
2026-04-17 09:00:44 +02:00
|
|
|
}, [embeddingModels, embedModel, panel, settingsHydrated]);
|
2026-04-17 08:36:52 +02:00
|
|
|
|
|
|
|
|
useEffect(() => {
|
2026-04-17 09:00:44 +02:00
|
|
|
if (panel !== 'AI Models') {
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-04-17 08:39:57 +02:00
|
|
|
if (!settingsHydrated) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-04-17 08:36:52 +02:00
|
|
|
if (rerankModel) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const nextModel = embedModel || rerankingModels[0] || DEFAULT_EMBED_MODEL;
|
|
|
|
|
setRerankModel(nextModel);
|
2026-05-06 04:39:39 +02:00
|
|
|
desktopApi.setSetting(RERANK_MODEL_KEY, nextModel);
|
2026-04-17 09:00:44 +02:00
|
|
|
}, [rerankingModels, rerankModel, embedModel, panel, settingsHydrated]);
|
2026-04-17 08:36:52 +02:00
|
|
|
|
2026-06-15 02:42:36 +02:00
|
|
|
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]);
|
|
|
|
|
|
2026-04-17 08:36:52 +02:00
|
|
|
useEffect(() => {
|
2026-04-17 09:00:44 +02:00
|
|
|
if (panel !== 'AI Models') {
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-04-17 08:39:57 +02:00
|
|
|
if (!settingsHydrated) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-04-17 08:36:52 +02:00
|
|
|
if (transcriptionModel) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const nextModel = whisperModels[0] || DEFAULT_TRANSCRIPTION_MODEL;
|
|
|
|
|
setTranscriptionModel(nextModel);
|
2026-05-06 04:39:39 +02:00
|
|
|
desktopApi.setSetting(TRANSCRIPTION_MODEL_KEY, nextModel);
|
2026-04-17 08:36:52 +02:00
|
|
|
if (onTranscriptionModelChange) {
|
|
|
|
|
onTranscriptionModelChange(nextModel);
|
|
|
|
|
}
|
2026-04-17 09:00:44 +02:00
|
|
|
}, [whisperModels, transcriptionModel, onTranscriptionModelChange, panel, settingsHydrated]);
|
2026-03-20 08:15:59 +01:00
|
|
|
|
2026-04-16 22:03:20 +02:00
|
|
|
useEffect(() => {
|
2026-04-17 09:06:19 +02:00
|
|
|
if (panel !== 'General') {
|
2026-04-17 09:00:44 +02:00
|
|
|
return () => {};
|
|
|
|
|
}
|
2026-04-16 22:03:20 +02:00
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
};
|
2026-04-17 09:00:44 +02:00
|
|
|
}, [audioInputSupported, panel]);
|
2026-04-16 22:03:20 +02:00
|
|
|
|
|
|
|
|
const handleModelChange = (event) => {
|
|
|
|
|
const newModel = event.target.value;
|
2025-08-22 23:42:34 +02:00
|
|
|
setSelectedModel(newModel);
|
2026-05-06 04:39:39 +02:00
|
|
|
desktopApi.setSetting(MODEL_KEY, newModel);
|
2025-08-23 16:45:46 +02:00
|
|
|
if (onModelChange) {
|
|
|
|
|
onModelChange(newModel);
|
|
|
|
|
}
|
2026-04-17 10:48:48 +02:00
|
|
|
if (visionModels.includes(newModel) && newModel !== visionModel) {
|
|
|
|
|
setVisionModel(newModel);
|
2026-05-06 04:39:39 +02:00
|
|
|
desktopApi.setSetting(VISION_MODEL_KEY, newModel);
|
2026-04-17 10:48:48 +02:00
|
|
|
if (onVisionModelChange) {
|
|
|
|
|
onVisionModelChange(newModel);
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-08-23 16:45:46 +02:00
|
|
|
};
|
|
|
|
|
|
2026-04-17 08:37:24 +02:00
|
|
|
const handleVisionModelChange = (event) => {
|
|
|
|
|
const newModel = event.target.value;
|
|
|
|
|
setVisionModel(newModel);
|
2026-05-06 04:39:39 +02:00
|
|
|
desktopApi.setSetting(VISION_MODEL_KEY, newModel);
|
2026-04-17 08:37:24 +02:00
|
|
|
if (onVisionModelChange) {
|
|
|
|
|
onVisionModelChange(newModel);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleEmbedModelChange = (event) => {
|
|
|
|
|
const nextModel = event.target.value;
|
2026-03-20 12:00:44 +01:00
|
|
|
setEmbedModel(nextModel);
|
2026-05-06 04:39:39 +02:00
|
|
|
desktopApi.setSetting(EMBED_MODEL_KEY, nextModel);
|
2026-03-20 12:00:44 +01:00
|
|
|
};
|
|
|
|
|
|
2026-04-17 08:37:24 +02:00
|
|
|
const handleRerankModelChange = (event) => {
|
|
|
|
|
const nextModel = event.target.value;
|
|
|
|
|
setRerankModel(nextModel);
|
2026-05-06 04:39:39 +02:00
|
|
|
desktopApi.setSetting(RERANK_MODEL_KEY, nextModel);
|
2026-04-17 08:37:24 +02:00
|
|
|
};
|
|
|
|
|
|
2026-06-15 02:42:36 +02:00
|
|
|
const handleEnrichmentModelChange = (event) => {
|
|
|
|
|
const nextModel = event.target.value;
|
|
|
|
|
setEnrichmentModel(nextModel);
|
|
|
|
|
desktopApi.setSetting(ENRICHMENT_MODEL_KEY, nextModel);
|
|
|
|
|
};
|
|
|
|
|
|
2026-04-17 08:37:24 +02:00
|
|
|
const handleTranscriptionModelChange = (event) => {
|
|
|
|
|
const nextModel = event.target.value;
|
|
|
|
|
setTranscriptionModel(nextModel);
|
2026-05-06 04:39:39 +02:00
|
|
|
desktopApi.setSetting(TRANSCRIPTION_MODEL_KEY, nextModel);
|
2026-04-17 08:37:24 +02:00
|
|
|
if (onTranscriptionModelChange) {
|
|
|
|
|
onTranscriptionModelChange(nextModel);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-06-15 15:19:51 +02:00
|
|
|
const handleWorkflowRouterModelChange = (event) => {
|
|
|
|
|
const nextModel = event.target.value;
|
|
|
|
|
setWorkflowRouterModel(nextModel);
|
|
|
|
|
desktopApi.setSetting(WORKFLOW_ROUTER_MODEL_KEY, nextModel);
|
|
|
|
|
onWorkflowRouterModelChange?.(nextModel);
|
|
|
|
|
};
|
|
|
|
|
|
2026-06-15 04:40:41 +02:00
|
|
|
const handleAutoDeepEnrichmentToggle = () => {
|
|
|
|
|
const nextValue = !autoDeepEnrichment;
|
|
|
|
|
setAutoDeepEnrichment(nextValue);
|
|
|
|
|
desktopApi.setSetting(AUTO_DEEP_ENRICHMENT_KEY, nextValue);
|
|
|
|
|
};
|
|
|
|
|
|
2026-04-16 22:03:20 +02:00
|
|
|
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);
|
2026-05-06 04:39:39 +02:00
|
|
|
desktopApi.setSetting(AUDIO_INPUT_DEVICE_ID_KEY, nextDeviceId);
|
2026-04-16 22:03:20 +02:00
|
|
|
if (onAudioInputDeviceChange) {
|
|
|
|
|
onAudioInputDeviceChange(nextDeviceId);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-04-17 04:43:28 +02:00
|
|
|
const handleAudioInputLanguageChange = (event) => {
|
|
|
|
|
const nextLanguage = event.target.value;
|
|
|
|
|
setAudioInputLanguage(nextLanguage);
|
2026-05-06 04:39:39 +02:00
|
|
|
desktopApi.setSetting(AUDIO_INPUT_LANGUAGE_KEY, nextLanguage);
|
2026-04-17 04:43:28 +02:00
|
|
|
if (onAudioInputLanguageChange) {
|
|
|
|
|
onAudioInputLanguageChange(nextLanguage);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-03-20 08:46:55 +01:00
|
|
|
const handleCheckForUpdates = async () => {
|
|
|
|
|
setIsCheckingForUpdates(true);
|
|
|
|
|
try {
|
2026-05-06 04:39:39 +02:00
|
|
|
const status = await desktopApi.checkForUpdates();
|
2026-03-20 08:46:55 +01:00
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-04-17 10:22:39 +02:00
|
|
|
const loadChangelogPage = async (page) => {
|
|
|
|
|
setIsLoadingChangelog(true);
|
|
|
|
|
setChangelogError('');
|
|
|
|
|
|
|
|
|
|
try {
|
2026-05-06 04:39:39 +02:00
|
|
|
const result = await desktopApi.getChangelogPage(page);
|
2026-04-17 10:22:39 +02:00
|
|
|
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);
|
|
|
|
|
};
|
|
|
|
|
|
2026-03-20 10:33:35 +01:00
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-03-20 08:46:55 +01:00
|
|
|
const updateCheckedAtLabel = updateStatus.checkedAt
|
|
|
|
|
? new Date(updateStatus.checkedAt).toLocaleString()
|
|
|
|
|
: null;
|
2026-04-16 22:03:20 +02:00
|
|
|
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';
|
2026-04-17 08:48:14 +02:00
|
|
|
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);
|
2026-06-15 02:42:36 +02:00
|
|
|
const enrichmentModelOptions = buildSelectOptions(chatModels, enrichmentModel, 'saved model unavailable', showMissingModelLabel);
|
2026-04-17 08:48:14 +02:00
|
|
|
const transcriptionModelOptions = buildSelectOptions(whisperModels, transcriptionModel, 'saved model unavailable', showMissingModelLabel);
|
2026-06-15 15:19:51 +02:00
|
|
|
const workflowRouterModelOptions = buildSelectOptions(chatModels, workflowRouterModel, 'saved model unavailable', showMissingModelLabel);
|
2026-03-20 08:46:55 +01:00
|
|
|
|
2026-04-17 09:02:13 +02:00
|
|
|
if (panel === 'AI Models') {
|
|
|
|
|
return (
|
|
|
|
|
<div className="settings-content-panel">
|
|
|
|
|
<div className="setting-section">
|
|
|
|
|
<h3>Chat Model</h3>
|
|
|
|
|
<select
|
|
|
|
|
className="select"
|
|
|
|
|
value={selectedModel}
|
|
|
|
|
onChange={handleModelChange}
|
2026-03-20 08:46:55 +01:00
|
|
|
>
|
2026-04-17 09:02:13 +02:00
|
|
|
{chatModelOptions.length === 0 && <option value="">{isLoadingModelCatalog ? 'Loading models…' : '— No chat models available —'}</option>}
|
|
|
|
|
{chatModelOptions.map(model => <option key={model.value} value={model.value}>{model.label}</option>)}
|
|
|
|
|
</select>
|
|
|
|
|
<p className="setting-description">
|
|
|
|
|
Heimgeist uses this model for normal text chat.
|
|
|
|
|
</p>
|
2026-03-20 08:46:55 +01:00
|
|
|
</div>
|
2026-06-15 15:19:51 +02:00
|
|
|
<div className="setting-section">
|
|
|
|
|
<h3>Workflow Router Model</h3>
|
|
|
|
|
<select className="select" value={workflowRouterModel} onChange={handleWorkflowRouterModelChange}>
|
|
|
|
|
<option value="">Use chat model</option>
|
|
|
|
|
{workflowRouterModelOptions.map(model => <option key={model.value} value={model.value}>{model.label}</option>)}
|
|
|
|
|
</select>
|
|
|
|
|
<p className="setting-description">
|
|
|
|
|
Automatic workflow selection uses this local Ollama model and falls back to the chat model when unavailable.
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
2026-04-17 09:02:13 +02:00
|
|
|
<div className="setting-section">
|
|
|
|
|
<h3>Vision Model</h3>
|
|
|
|
|
<select
|
|
|
|
|
className="select"
|
|
|
|
|
value={visionModel}
|
|
|
|
|
onChange={handleVisionModelChange}
|
|
|
|
|
>
|
|
|
|
|
{visionModelOptions.length === 0 && <option value="">{isLoadingModelCatalog ? 'Loading models…' : '— No vision models available —'}</option>}
|
|
|
|
|
{visionModelOptions.map(model => <option key={model.value} value={model.value}>{model.label}</option>)}
|
|
|
|
|
</select>
|
|
|
|
|
<p className="setting-description">
|
|
|
|
|
Heimgeist uses this model when a chat message includes image attachments.
|
2026-03-20 08:46:55 +01:00
|
|
|
</p>
|
2026-04-17 09:02:13 +02:00
|
|
|
</div>
|
|
|
|
|
<div className="setting-section">
|
|
|
|
|
<h3>Embedding Model</h3>
|
|
|
|
|
<select
|
|
|
|
|
className="select"
|
|
|
|
|
value={embedModel}
|
|
|
|
|
onChange={handleEmbedModelChange}
|
|
|
|
|
>
|
|
|
|
|
{embeddingModelOptions.length === 0 && <option value="">{isLoadingModelCatalog ? 'Loading models…' : '— No embedding models available —'}</option>}
|
|
|
|
|
{embeddingModelOptions.map(model => (
|
|
|
|
|
<option key={model.value} value={model.value}>{model.label}</option>
|
|
|
|
|
))}
|
|
|
|
|
</select>
|
|
|
|
|
<p className="setting-description">
|
|
|
|
|
Heimgeist uses this model for building or rebuilding local database embeddings.
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="setting-section">
|
|
|
|
|
<h3>Reranking Model</h3>
|
|
|
|
|
<select
|
|
|
|
|
className="select"
|
|
|
|
|
value={rerankModel}
|
|
|
|
|
onChange={handleRerankModelChange}
|
|
|
|
|
>
|
|
|
|
|
{rerankingModelOptions.length === 0 && <option value="">{isLoadingModelCatalog ? 'Loading models…' : '— No reranking models available —'}</option>}
|
|
|
|
|
{rerankingModelOptions.map(model => (
|
|
|
|
|
<option key={model.value} value={model.value}>{model.label}</option>
|
|
|
|
|
))}
|
|
|
|
|
</select>
|
|
|
|
|
<p className="setting-description">
|
|
|
|
|
Heimgeist currently uses an embedding-based reranker for web search, so this should generally be an embedding-capable Ollama model.
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
2026-06-15 02:42:36 +02:00
|
|
|
<div className="setting-section">
|
|
|
|
|
<h3>Knowledge Metadata Model</h3>
|
|
|
|
|
<select
|
|
|
|
|
className="select"
|
|
|
|
|
value={enrichmentModel}
|
|
|
|
|
onChange={handleEnrichmentModelChange}
|
|
|
|
|
>
|
|
|
|
|
{enrichmentModelOptions.length === 0 && <option value="">{isLoadingModelCatalog ? 'Loading models…' : '— No generation models available —'}</option>}
|
|
|
|
|
{enrichmentModelOptions.map(model => (
|
|
|
|
|
<option key={model.value} value={model.value}>{model.label}</option>
|
|
|
|
|
))}
|
|
|
|
|
</select>
|
|
|
|
|
<p className="setting-description">
|
|
|
|
|
Heimgeist uses this local model for summaries, keywords, entities, and optional Deep enrichment Q&A.
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
2026-04-17 09:02:13 +02:00
|
|
|
<div className="setting-section">
|
|
|
|
|
<h3>Transcription Model</h3>
|
|
|
|
|
<select
|
|
|
|
|
className="select"
|
|
|
|
|
value={transcriptionModel}
|
|
|
|
|
onChange={handleTranscriptionModelChange}
|
|
|
|
|
>
|
|
|
|
|
{transcriptionModelOptions.length === 0 && <option value="">{isLoadingModelCatalog ? 'Loading models…' : '— No Whisper models available —'}</option>}
|
|
|
|
|
{transcriptionModelOptions.map(model => (
|
|
|
|
|
<option key={model.value} value={model.value}>
|
|
|
|
|
{model.label}
|
|
|
|
|
</option>
|
|
|
|
|
))}
|
|
|
|
|
</select>
|
|
|
|
|
<p className="setting-description">
|
|
|
|
|
Heimgeist uses this Whisper model for microphone transcription.
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-17 09:06:19 +02:00
|
|
|
if (panel === 'General') {
|
2026-04-17 09:02:13 +02:00
|
|
|
return (
|
|
|
|
|
<div className="settings-content-panel">
|
|
|
|
|
<div className="setting-section">
|
|
|
|
|
<h3>Microphone</h3>
|
|
|
|
|
<div className="setting-control-row">
|
|
|
|
|
<select
|
|
|
|
|
className="select"
|
|
|
|
|
value={audioInputDeviceId}
|
|
|
|
|
onChange={handleAudioInputDeviceChange}
|
|
|
|
|
disabled={!audioInputSupported}
|
|
|
|
|
>
|
|
|
|
|
<option value={DEFAULT_AUDIO_INPUT_DEVICE_ID}>System default microphone</option>
|
|
|
|
|
{audioInputOptions.map(device => (
|
|
|
|
|
<option key={device.deviceId} value={device.deviceId}>
|
|
|
|
|
{device.label}
|
|
|
|
|
</option>
|
|
|
|
|
))}
|
|
|
|
|
</select>
|
|
|
|
|
<select
|
|
|
|
|
className="select"
|
|
|
|
|
value={audioInputLanguage}
|
|
|
|
|
onChange={handleAudioInputLanguageChange}
|
|
|
|
|
disabled={!audioInputSupported}
|
|
|
|
|
>
|
|
|
|
|
{AUDIO_INPUT_LANGUAGE_OPTIONS.map(language => (
|
|
|
|
|
<option key={language.value || 'auto'} value={language.value}>
|
|
|
|
|
{language.label}
|
|
|
|
|
</option>
|
|
|
|
|
))}
|
|
|
|
|
</select>
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
className="button"
|
|
|
|
|
onClick={() => refreshAudioDevices({ requestAccess: true })}
|
|
|
|
|
disabled={!audioInputSupported || isRefreshingAudioDevices}
|
|
|
|
|
>
|
|
|
|
|
{isRefreshingAudioDevices ? 'Working…' : audioDeviceRefreshLabel}
|
|
|
|
|
</button>
|
2026-03-20 08:46:55 +01:00
|
|
|
</div>
|
2026-04-17 09:02:13 +02:00
|
|
|
{audioInputStatus.message && (
|
|
|
|
|
<p className={`setting-status ${audioInputStatus.tone}`}>{audioInputStatus.message}</p>
|
|
|
|
|
)}
|
|
|
|
|
<p className="setting-description">
|
|
|
|
|
Microphone input is available in the chat composer. Heimgeist records locally and sends the clip to the configured Whisper model.
|
|
|
|
|
</p>
|
|
|
|
|
<p className="setting-description">
|
|
|
|
|
Whisper can auto-detect the spoken language, but you can force a fixed input language here when auto-detection drifts.
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
2026-03-20 08:46:55 +01:00
|
|
|
</div>
|
2026-04-17 09:02:13 +02:00
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (panel === 'Updates') {
|
|
|
|
|
return (
|
|
|
|
|
<div className="settings-content-panel">
|
|
|
|
|
<div className="setting-section">
|
|
|
|
|
<h3>Updates</h3>
|
|
|
|
|
<div className="setting-control-row">
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
className="button"
|
|
|
|
|
onClick={handleCheckForUpdates}
|
|
|
|
|
disabled={isCheckingForUpdates}
|
|
|
|
|
>
|
|
|
|
|
{isCheckingForUpdates ? 'Checking...' : 'Check for Update'}
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
<p className="setting-description">
|
|
|
|
|
Compares the local Git commit with remote <code>master</code>, pulls changes when needed, and restarts Heimgeist automatically. The same check also runs on every startup.
|
|
|
|
|
</p>
|
|
|
|
|
{updateStatus.message && (
|
|
|
|
|
<p className={`setting-status ${getStatusTone(updateStatus.state)}`}>
|
|
|
|
|
{updateStatus.message}
|
|
|
|
|
</p>
|
|
|
|
|
)}
|
|
|
|
|
{(updateStatus.localCommit || updateStatus.remoteCommit || updateCheckedAtLabel) && (
|
|
|
|
|
<div className="setting-meta">
|
|
|
|
|
{updateStatus.localCommit && <div>Local: <code>{shortCommit(updateStatus.localCommit)}</code></div>}
|
|
|
|
|
{updateStatus.remoteCommit && <div>Remote: <code>{shortCommit(updateStatus.remoteCommit)}</code></div>}
|
|
|
|
|
{updateCheckedAtLabel && <div>Last checked: {updateCheckedAtLabel}</div>}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
2026-04-17 10:22:39 +02:00
|
|
|
<div className="setting-control-row changelog-toggle-row">
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
className="button ghost"
|
|
|
|
|
onClick={handleToggleChangelog}
|
|
|
|
|
disabled={isLoadingChangelog && !isChangelogOpen}
|
|
|
|
|
>
|
|
|
|
|
{isChangelogOpen ? 'v Hide Changelog' : '> Show Changelog'}
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
{isChangelogOpen && (
|
|
|
|
|
<div className="changelog-panel">
|
|
|
|
|
{changelogError && (
|
|
|
|
|
<p className="setting-status error">{changelogError}</p>
|
|
|
|
|
)}
|
2026-04-17 10:23:40 +02:00
|
|
|
{isLoadingChangelog && (
|
2026-04-17 10:22:39 +02:00
|
|
|
<p className="setting-status neutral">Loading changelog...</p>
|
|
|
|
|
)}
|
2026-04-17 10:23:40 +02:00
|
|
|
{!isLoadingChangelog && !changelogError && changelogEntries.length === 0 && (
|
2026-04-17 10:22:39 +02:00
|
|
|
<p className="setting-description">No commits found.</p>
|
|
|
|
|
)}
|
2026-04-17 10:23:40 +02:00
|
|
|
{changelogEntries.length > 0 && (
|
2026-04-17 10:22:39 +02:00
|
|
|
<>
|
|
|
|
|
<ul className="changelog-list">
|
|
|
|
|
{changelogEntries.map((entry) => {
|
|
|
|
|
const committedAtLabel = formatCommitTimestamp(entry.committedAt);
|
|
|
|
|
return (
|
|
|
|
|
<li key={entry.hash} className="changelog-item">
|
|
|
|
|
<div className="changelog-message">{entry.message}</div>
|
|
|
|
|
<div className="changelog-item-meta">
|
|
|
|
|
<code>{shortCommit(entry.hash)}</code>
|
|
|
|
|
{committedAtLabel && <span>{committedAtLabel}</span>}
|
|
|
|
|
</div>
|
|
|
|
|
</li>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</ul>
|
|
|
|
|
<div className="setting-control-row changelog-pagination">
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
className="button ghost"
|
|
|
|
|
onClick={() => handleChangelogPageChange(changelogPage - 1)}
|
|
|
|
|
disabled={isLoadingChangelog || changelogPage <= 1}
|
|
|
|
|
>
|
|
|
|
|
Previous
|
|
|
|
|
</button>
|
|
|
|
|
<span className="changelog-page-label">Page {changelogPage}</span>
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
className="button ghost"
|
|
|
|
|
onClick={() => handleChangelogPageChange(changelogPage + 1)}
|
|
|
|
|
disabled={isLoadingChangelog || !changelogHasMore}
|
|
|
|
|
>
|
|
|
|
|
Next
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
2026-03-20 10:33:35 +01:00
|
|
|
</div>
|
2026-04-17 09:02:13 +02:00
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (panel === 'Advanced') {
|
|
|
|
|
return (
|
|
|
|
|
<div className="settings-content-panel">
|
2026-06-15 04:40:41 +02:00
|
|
|
<div className="setting-section">
|
|
|
|
|
<h3>Automatic Deep Enrichment</h3>
|
|
|
|
|
<label className="toggle-switch">
|
|
|
|
|
<input
|
|
|
|
|
type="checkbox"
|
|
|
|
|
checked={autoDeepEnrichment}
|
|
|
|
|
onChange={handleAutoDeepEnrichmentToggle}
|
|
|
|
|
/>
|
|
|
|
|
<span className="slider"></span>
|
|
|
|
|
</label>
|
|
|
|
|
<p className="setting-description">
|
|
|
|
|
After standard metadata is generated, Heimgeist automatically queues deep enrichment to add generated Q&A. Individual content can still be switched back to Standard.
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
2026-04-17 09:02:13 +02:00
|
|
|
<div className="setting-section danger-zone">
|
|
|
|
|
<h3>Purge Databases</h3>
|
|
|
|
|
<div className="setting-control-row">
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
className="button danger"
|
|
|
|
|
onClick={handlePurgeLibraries}
|
|
|
|
|
disabled={isPurgingLibraries || !backendApiUrl}
|
|
|
|
|
>
|
|
|
|
|
{isPurgingLibraries ? 'Purging...' : 'Delete All Databases'}
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
<p className="setting-description">
|
|
|
|
|
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.
|
2026-03-20 10:33:35 +01:00
|
|
|
</p>
|
2026-04-17 09:02:13 +02:00
|
|
|
{libraryPurgeStatus.message && (
|
|
|
|
|
<p className={`setting-status ${libraryPurgeStatus.tone}`}>
|
|
|
|
|
{libraryPurgeStatus.message}
|
|
|
|
|
</p>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
2026-03-20 10:33:35 +01:00
|
|
|
</div>
|
2026-04-17 09:02:13 +02:00
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return null;
|
2025-08-22 23:42:34 +02:00
|
|
|
}
|