added embedding model selection

This commit is contained in:
2026-03-20 12:00:44 +01:00
parent afaaa1cfb2
commit 6be438519c
18 changed files with 513 additions and 100 deletions

View File

@@ -127,6 +127,7 @@ const COLOR_SCHEME_KEY = 'colorScheme';
const WEBSEARCH_URL_KEY = 'websearch.searxUrl';
const WEBSEARCH_ENGINES_KEY = 'websearch.engines';
const CHAT_LIBRARY_MAP_KEY = 'chat.libraryBySession';
const DEFAULT_SEARX_URL = 'http://127.0.0.1:8888';
// Initial API value will be set by useEffect after settings are loaded
let API = import.meta.env.VITE_API_URL ?? 'http://127.0.0.1:8000';
@@ -137,6 +138,13 @@ function resolveBackendApiUrl(settings) {
return settings.backendApiUrl || settings.ollamaApiUrl || API;
}
function migrateLegacySearxUrl(value) {
const trimmed = typeof value === 'string' ? value.trim() : '';
if (!trimmed) return DEFAULT_SEARX_URL;
if (trimmed === 'http://localhost:8888') return DEFAULT_SEARX_URL;
return trimmed;
}
export default function App() {
const [chatSessions, setChatSessions] = useState([])
const [activeSessionId, setActiveSessionId] = useState(null)
@@ -169,7 +177,8 @@ export default function App() {
const [backendApiUrl, setBackendApiUrl] = useState(API); // State for Heimgeist backend URL
const [colorScheme, setColorScheme] = useState('Default'); // State for color scheme
const [streamOutput, setStreamOutput] = useState(false);
const [searxUrl, setSearxUrl] = useState(localStorage.getItem(WEBSEARCH_URL_KEY) || 'http://localhost:8888');
const [startupTaskMessage, setStartupTaskMessage] = useState('');
const [searxUrl, setSearxUrl] = useState(() => migrateLegacySearxUrl(localStorage.getItem(WEBSEARCH_URL_KEY)));
const [searxEngines, setSearxEngines] = useState(() => {
try {
const raw = localStorage.getItem(WEBSEARCH_ENGINES_KEY);
@@ -191,6 +200,8 @@ export default function App() {
const [loading, setLoading] = useState(true); // Loading state for initial session fetch
const [unreadSessions, setUnreadSessions] = useState([]); // Track unread messages
const [scrollPositions, setScrollPositions] = useState({}); // Store scroll positions for each session
const [settingsLoaded, setSettingsLoaded] = useState(false);
const startupOllamaCheckRanRef = useRef(false);
// Editing state for user messages
const [editingMessageIndex, setEditingMessageIndex] = useState(null);
const [editText, setEditText] = useState('');
@@ -253,6 +264,20 @@ export default function App() {
return String(error)
}
async function expectBackendJson(response) {
const data = await response.json().catch(() => null)
if (response.ok) return data
const detail = typeof data?.detail === 'string'
? data.detail
: (typeof data?.message === 'string' ? data.message : '')
throw new Error(detail || `HTTP ${response.status}`)
}
async function fetchStartupOllamaStatus() {
const response = await fetch(`${backendApiUrl}/ollama/startup-status`)
return expectBackendJson(response)
}
async function fetchLocalLibraryContext(slug, prompt, signal) {
if (!slug) return { contextBlock: null, sources: [] }
@@ -705,6 +730,8 @@ async function regenerateFromIndex(index, overrideUserText = null) {
setStreamOutput(settings.streamOutput || false);
setScrollPositions(settings.scrollPositions || {}); // Load scroll positions
applyColorScheme(settings.colorScheme || 'Default'); // Apply initial scheme
}).finally(() => {
setSettingsLoaded(true);
});
const handleFocus = () => {
@@ -724,6 +751,68 @@ async function regenerateFromIndex(index, overrideUserText = null) {
};
}, [activeSidebarMode]);
useEffect(() => {
if (!settingsLoaded || !backendApiUrl || startupOllamaCheckRanRef.current) return
startupOllamaCheckRanRef.current = true
let cancelled = false
;(async () => {
let actionStarted = false
try {
let status = await fetchStartupOllamaStatus()
if (cancelled) return
if (!status?.ollama_running && status?.can_manage_locally) {
const confirmed = window.confirm(
`Ollama is not running at ${status.ollama_url}. Start it in the background now with "ollama serve"?`
)
if (cancelled) return
if (confirmed) {
actionStarted = true
setStartupTaskMessage('Starting Ollama in the background...')
const response = await fetch(`${backendApiUrl}/ollama/start`, { method: 'POST' })
status = await expectBackendJson(response)
if (cancelled) return
}
}
if (status?.ollama_running && !status?.embedding_model_available && status?.can_manage_locally) {
const confirmed = window.confirm(
`The selected embedding model "${status.selected_embed_model}" is not installed in Ollama. Pull it now?`
)
if (cancelled) return
if (confirmed) {
actionStarted = true
setStartupTaskMessage(`Pulling ${status.selected_embed_model} in Ollama...`)
const response = await fetch(`${backendApiUrl}/ollama/pull`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model: status.selected_embed_model })
})
await expectBackendJson(response)
if (cancelled) return
}
}
} catch (error) {
if (!cancelled) {
console.warn('startup Ollama check failed', error)
if (actionStarted) {
window.alert(`Startup action failed: ${getErrorText(error)}`)
}
}
} finally {
if (!cancelled) {
setStartupTaskMessage('')
}
}
})()
return () => {
cancelled = true
}
}, [backendApiUrl, settingsLoaded]);
// Apply color scheme whenever it changes
useEffect(() => {
applyColorScheme(colorScheme);
@@ -1785,6 +1874,11 @@ async function createNewChat() {
<div className="resizer" onMouseDown={startResizing}></div>
</div>
<div className="main-content">
{startupTaskMessage && (
<div className="startup-task-banner" role="status" aria-live="polite">
{startupTaskMessage}
</div>
)}
{activeSidebarMode === 'chats' && (
<>
<div className="header">

View File

@@ -2,10 +2,13 @@ import React, { useState, useEffect } from 'react';
const BACKEND_API_URL_KEY = 'backendApiUrl';
const OLLAMA_API_URL_KEY = 'ollamaApiUrl';
const EMBED_MODEL_KEY = 'embedModel';
const MODEL_KEY = 'chatModel';
const STREAM_KEY = 'streamOutput';
const DEFAULT_BACKEND_API_URL = 'http://127.0.0.1:8000';
const DEFAULT_OLLAMA_API_URL = 'http://127.0.0.1:11434';
const DEFAULT_EMBED_MODEL = 'nomic-embed-text:latest';
const BGE_EMBED_MODEL = 'bge-m3:latest';
const DEFAULT_UPDATE_STATUS = {
state: 'idle',
message: '',
@@ -32,6 +35,7 @@ function getStatusTone(state) {
export default function GeneralSettings({ onModelChange, onStreamOutputChange, onLibrariesPurged }) {
const [backendApiUrl, setBackendApiUrl] = useState('');
const [ollamaApiUrl, setOllamaApiUrl] = useState('');
const [embedModel, setEmbedModel] = useState(DEFAULT_EMBED_MODEL);
const [models, setModels] = useState([]);
const [selectedModel, setSelectedModel] = useState('');
const [streamOutput, setStreamOutput] = useState(false);
@@ -53,6 +57,7 @@ export default function GeneralSettings({ onModelChange, onStreamOutputChange, o
setBackendApiUrl(resolveBackendApiUrl(settings));
setOllamaApiUrl(settings.ollamaApiUrl || DEFAULT_OLLAMA_API_URL);
setEmbedModel(settings.embedModel || DEFAULT_EMBED_MODEL);
setSelectedModel(settings.chatModel || '');
setStreamOutput(settings.streamOutput || false);
setUpdateStatus(status || DEFAULT_UPDATE_STATUS);
@@ -102,6 +107,12 @@ export default function GeneralSettings({ onModelChange, onStreamOutputChange, o
}
};
const handleEmbedModelToggle = () => {
const nextModel = embedModel === BGE_EMBED_MODEL ? DEFAULT_EMBED_MODEL : BGE_EMBED_MODEL;
setEmbedModel(nextModel);
window.electronAPI.setSetting(EMBED_MODEL_KEY, nextModel);
};
const handleStreamToggle = () => {
const newStreamValue = !streamOutput;
setStreamOutput(newStreamValue);
@@ -199,6 +210,28 @@ export default function GeneralSettings({ onModelChange, onStreamOutputChange, o
/>
<p className="setting-description">Heimgeist uses this URL to talk to Ollama for models and chat generation.</p>
</div>
<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>
<div className="setting-section">
<h3>Chat Model</h3>
<select

View File

@@ -38,7 +38,7 @@ return (
className="input"
value={searxUrl}
onChange={e => setSearxUrl(e.target.value)}
placeholder="e.g., http://localhost:8888"
placeholder="e.g., http://127.0.0.1:8888"
/>
</div>
@@ -59,4 +59,4 @@ return (
</div>
</div>
);
}
}

View File

@@ -546,6 +546,42 @@ textarea.input {
flex-wrap: wrap;
}
.setting-switch-row {
display: inline-flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.setting-switch-label {
color: var(--muted);
font-weight: 600;
letter-spacing: 0.01em;
transition: color 0.2s ease;
}
.setting-switch-label.active {
color: var(--text);
}
.toggle-switch--binary-select .slider {
background-color: var(--input-bg);
border-color: var(--border);
}
.toggle-switch--binary-select .slider:before {
background-color: var(--text);
}
.toggle-switch--binary-select input:checked + .slider {
background-color: var(--input-bg);
border-color: var(--border);
}
.toggle-switch--binary-select input:checked + .slider:before {
background-color: var(--text);
}
.range-input {
width: min(360px, 100%);
accent-color: var(--accent);
@@ -798,6 +834,16 @@ input:checked + .slider:before {
background-color: var(--panel);
}
.startup-task-banner {
margin: 16px 16px 0;
padding: 12px 14px;
border: 1px solid var(--border);
border-radius: 10px;
background: var(--panel);
color: var(--text);
line-height: 1.5;
}
/* Spinner Styles */
.spinner {
border: 3px solid rgba(255, 255, 255, 0.3);