2025-08-22 23:42:34 +02:00
|
|
|
import React, { useState, useEffect } from 'react';
|
|
|
|
|
|
2026-03-20 08:15:59 +01:00
|
|
|
const BACKEND_API_URL_KEY = 'backendApiUrl';
|
|
|
|
|
const OLLAMA_API_URL_KEY = 'ollamaApiUrl';
|
2026-03-20 12:00:44 +01:00
|
|
|
const EMBED_MODEL_KEY = 'embedModel';
|
2025-08-22 23:42:34 +02:00
|
|
|
const MODEL_KEY = 'chatModel';
|
2025-08-23 16:45:46 +02:00
|
|
|
const STREAM_KEY = 'streamOutput';
|
2026-03-20 08:15:59 +01:00
|
|
|
const DEFAULT_BACKEND_API_URL = 'http://127.0.0.1:8000';
|
|
|
|
|
const DEFAULT_OLLAMA_API_URL = 'http://127.0.0.1:11434';
|
2026-03-20 12:00:44 +01:00
|
|
|
const DEFAULT_EMBED_MODEL = 'nomic-embed-text:latest';
|
|
|
|
|
const BGE_EMBED_MODEL = 'bge-m3:latest';
|
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-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-16 21:28:16 +02:00
|
|
|
export default function GeneralSettings({ onModelChange, onStreamOutputChange, onLibrariesPurged, onBackendApiUrlChange }) {
|
2026-03-20 08:15:59 +01:00
|
|
|
const [backendApiUrl, setBackendApiUrl] = useState('');
|
2025-08-22 23:42:34 +02:00
|
|
|
const [ollamaApiUrl, setOllamaApiUrl] = useState('');
|
2026-03-20 12:00:44 +01:00
|
|
|
const [embedModel, setEmbedModel] = useState(DEFAULT_EMBED_MODEL);
|
2025-08-22 23:42:34 +02:00
|
|
|
const [models, setModels] = useState([]);
|
|
|
|
|
const [selectedModel, setSelectedModel] = useState('');
|
2025-08-23 16:45:46 +02:00
|
|
|
const [streamOutput, setStreamOutput] = useState(false);
|
2026-03-20 08:46:55 +01:00
|
|
|
const [updateStatus, setUpdateStatus] = useState(DEFAULT_UPDATE_STATUS);
|
|
|
|
|
const [isCheckingForUpdates, setIsCheckingForUpdates] = useState(false);
|
2026-03-20 10:33:35 +01:00
|
|
|
const [isPurgingLibraries, setIsPurgingLibraries] = useState(false);
|
|
|
|
|
const [libraryPurgeStatus, setLibraryPurgeStatus] = useState({ tone: 'neutral', message: '' });
|
2025-08-22 23:42:34 +02:00
|
|
|
|
|
|
|
|
useEffect(() => {
|
2026-03-20 08:46:55 +01:00
|
|
|
let cancelled = false;
|
|
|
|
|
|
|
|
|
|
Promise.all([
|
|
|
|
|
window.electronAPI.getSettings(),
|
|
|
|
|
window.electronAPI.getUpdateStatus(),
|
|
|
|
|
]).then(([settings, status]) => {
|
|
|
|
|
if (cancelled) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-20 08:15:59 +01:00
|
|
|
setBackendApiUrl(resolveBackendApiUrl(settings));
|
2026-03-20 08:17:19 +01:00
|
|
|
setOllamaApiUrl(settings.ollamaApiUrl || DEFAULT_OLLAMA_API_URL);
|
2026-03-20 12:00:44 +01:00
|
|
|
setEmbedModel(settings.embedModel || DEFAULT_EMBED_MODEL);
|
2025-08-22 23:42:34 +02:00
|
|
|
setSelectedModel(settings.chatModel || '');
|
2025-08-23 16:45:46 +02:00
|
|
|
setStreamOutput(settings.streamOutput || false);
|
2026-03-20 08:46:55 +01:00
|
|
|
setUpdateStatus(status || DEFAULT_UPDATE_STATUS);
|
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-03-20 08:15:59 +01:00
|
|
|
if (backendApiUrl) {
|
|
|
|
|
fetch(backendApiUrl + '/models')
|
2025-08-22 23:42:34 +02:00
|
|
|
.then(r => r.json())
|
|
|
|
|
.then(data => {
|
|
|
|
|
const names = data.models?.map(m => m.name) || [];
|
|
|
|
|
setModels(names);
|
|
|
|
|
// If no model is selected or the selected model is no longer available, select the first one
|
|
|
|
|
if (!selectedModel || !names.includes(selectedModel)) {
|
|
|
|
|
const defaultModel = names[0] || '';
|
|
|
|
|
setSelectedModel(defaultModel);
|
|
|
|
|
window.electronAPI.setSetting(MODEL_KEY, defaultModel);
|
2026-04-16 21:28:16 +02:00
|
|
|
if (onModelChange) {
|
|
|
|
|
onModelChange(defaultModel);
|
|
|
|
|
}
|
2025-08-22 23:42:34 +02:00
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
.catch(err => console.error('Failed to load models', err));
|
|
|
|
|
}
|
2026-04-16 21:28:16 +02:00
|
|
|
}, [backendApiUrl, ollamaApiUrl, selectedModel, onModelChange]); // Depend on selectedModel to re-evaluate default selection
|
2026-03-20 08:15:59 +01:00
|
|
|
|
|
|
|
|
const handleBackendUrlChange = (e) => {
|
|
|
|
|
const newUrl = e.target.value;
|
|
|
|
|
setBackendApiUrl(newUrl);
|
|
|
|
|
window.electronAPI.setSetting(BACKEND_API_URL_KEY, newUrl);
|
2026-04-16 21:28:16 +02:00
|
|
|
if (onBackendApiUrlChange) {
|
|
|
|
|
onBackendApiUrlChange(newUrl);
|
|
|
|
|
}
|
2026-03-20 08:15:59 +01:00
|
|
|
};
|
2025-08-22 23:42:34 +02:00
|
|
|
|
2026-03-20 08:15:59 +01:00
|
|
|
const handleOllamaUrlChange = (e) => {
|
2025-08-22 23:42:34 +02:00
|
|
|
const newUrl = e.target.value;
|
|
|
|
|
setOllamaApiUrl(newUrl);
|
2026-03-20 08:15:59 +01:00
|
|
|
window.electronAPI.setSetting(OLLAMA_API_URL_KEY, newUrl);
|
2025-08-22 23:42:34 +02:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleModelChange = (e) => {
|
|
|
|
|
const newModel = e.target.value;
|
|
|
|
|
setSelectedModel(newModel);
|
|
|
|
|
window.electronAPI.setSetting(MODEL_KEY, newModel);
|
2025-08-23 16:45:46 +02:00
|
|
|
if (onModelChange) {
|
|
|
|
|
onModelChange(newModel);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-03-20 12:00:44 +01:00
|
|
|
const handleEmbedModelToggle = () => {
|
|
|
|
|
const nextModel = embedModel === BGE_EMBED_MODEL ? DEFAULT_EMBED_MODEL : BGE_EMBED_MODEL;
|
|
|
|
|
setEmbedModel(nextModel);
|
|
|
|
|
window.electronAPI.setSetting(EMBED_MODEL_KEY, nextModel);
|
|
|
|
|
};
|
|
|
|
|
|
2025-08-23 16:45:46 +02:00
|
|
|
const handleStreamToggle = () => {
|
|
|
|
|
const newStreamValue = !streamOutput;
|
|
|
|
|
setStreamOutput(newStreamValue);
|
|
|
|
|
window.electronAPI.setSetting(STREAM_KEY, newStreamValue);
|
|
|
|
|
if (onStreamOutputChange) {
|
|
|
|
|
onStreamOutputChange(newStreamValue);
|
|
|
|
|
}
|
2025-08-22 23:42:34 +02:00
|
|
|
};
|
|
|
|
|
|
2026-03-20 08:46:55 +01:00
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
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;
|
|
|
|
|
|
2025-08-22 23:42:34 +02:00
|
|
|
return (
|
|
|
|
|
<div className="settings-content-panel">
|
|
|
|
|
<div className="setting-section">
|
2026-03-20 08:15:59 +01:00
|
|
|
<h3>Heimgeist Backend URL</h3>
|
|
|
|
|
<input
|
|
|
|
|
type="text"
|
|
|
|
|
className="input"
|
|
|
|
|
value={backendApiUrl}
|
|
|
|
|
onChange={handleBackendUrlChange}
|
|
|
|
|
placeholder={`e.g., ${DEFAULT_BACKEND_API_URL}`}
|
|
|
|
|
/>
|
|
|
|
|
<p className="setting-description">Internal UI requests like chats, sessions, and databases go to this URL.</p>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="setting-section">
|
|
|
|
|
<h3>Ollama URL</h3>
|
2025-08-22 23:42:34 +02:00
|
|
|
<input
|
|
|
|
|
type="text"
|
|
|
|
|
className="input"
|
|
|
|
|
value={ollamaApiUrl}
|
2026-03-20 08:15:59 +01:00
|
|
|
onChange={handleOllamaUrlChange}
|
|
|
|
|
placeholder={`e.g., ${DEFAULT_OLLAMA_API_URL}`}
|
2025-08-22 23:42:34 +02:00
|
|
|
/>
|
2026-03-20 08:15:59 +01:00
|
|
|
<p className="setting-description">Heimgeist uses this URL to talk to Ollama for models and chat generation.</p>
|
2025-08-22 23:42:34 +02:00
|
|
|
</div>
|
2026-03-20 12:00:44 +01:00
|
|
|
<div className="setting-section">
|
|
|
|
|
<h3>Embedding Model</h3>
|
|
|
|
|
<div className="setting-switch-row">
|
|
|
|
|
<span className={"setting-switch-label" + (embedModel !== BGE_EMBED_MODEL ? " active" : "")}>
|
|
|
|
|
nomic
|
|
|
|
|
</span>
|
|
|
|
|
<label className="toggle-switch toggle-switch--binary-select">
|
|
|
|
|
<input
|
|
|
|
|
type="checkbox"
|
|
|
|
|
checked={embedModel === BGE_EMBED_MODEL}
|
|
|
|
|
onChange={handleEmbedModelToggle}
|
|
|
|
|
/>
|
|
|
|
|
<span className="slider"></span>
|
|
|
|
|
</label>
|
|
|
|
|
<span className={"setting-switch-label" + (embedModel === BGE_EMBED_MODEL ? " active" : "")}>
|
|
|
|
|
bge-m3
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
<p className="setting-description">
|
|
|
|
|
Heimgeist uses this model for web-search reranking and for building or rebuilding local database embeddings.
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
2025-08-22 23:42:34 +02:00
|
|
|
<div className="setting-section">
|
|
|
|
|
<h3>Chat Model</h3>
|
|
|
|
|
<select
|
|
|
|
|
className="select"
|
|
|
|
|
value={selectedModel}
|
|
|
|
|
onChange={handleModelChange}
|
|
|
|
|
>
|
|
|
|
|
{models.length === 0 && <option>— No models available —</option>}
|
|
|
|
|
{models.map(m => <option key={m} value={m}>{m}</option>)}
|
|
|
|
|
</select>
|
|
|
|
|
</div>
|
2025-08-23 16:45:46 +02:00
|
|
|
<div className="setting-section">
|
|
|
|
|
<h3>Stream Output</h3>
|
|
|
|
|
<label className="toggle-switch">
|
|
|
|
|
<input
|
|
|
|
|
type="checkbox"
|
|
|
|
|
checked={streamOutput}
|
|
|
|
|
onChange={handleStreamToggle}
|
|
|
|
|
/>
|
|
|
|
|
<span className="slider"></span>
|
|
|
|
|
</label>
|
|
|
|
|
</div>
|
2026-03-20 08:46:55 +01:00
|
|
|
<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>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
2026-03-20 10:33:35 +01: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.
|
|
|
|
|
</p>
|
|
|
|
|
{libraryPurgeStatus.message && (
|
|
|
|
|
<p className={`setting-status ${libraryPurgeStatus.tone}`}>
|
|
|
|
|
{libraryPurgeStatus.message}
|
|
|
|
|
</p>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
2025-08-22 23:42:34 +02:00
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|