Feature/lmstudio provider (#30)

* chore: just a plan update

* Add LM Studio as local AI provider (OpenAI-compatible, like Ollama)

* Convert WebP thumbnails to JPEG before image analysis for LM Studio compatibility

* Strengthen language enforcement in image analysis prompt for local models

* Use i18n localized prompts for image analysis instead of English instructions

* Add airplane mode (Flugmodus) with status bar toggle and offline model preferences

* Fix flightmode: persist model IDs, skip network when offline, airplane icon

* Auto-fallback to offline models in airplane mode for chat, title, and image analysis

* Auto-select first local model as offline fallback when no explicit offline model configured

* Block git fetch/pull/push and site upload in airplane mode

* fix: thumbnails optimized for AI

* fix: error handling in airplane mode

---------

Co-authored-by: hugo <hugoms@me.com>
This commit is contained in:
Georg Bauer
2026-03-02 13:35:42 +01:00
committed by GitHub
parent 4b4a9c1c8b
commit 5747925503
34 changed files with 2215 additions and 105 deletions

View File

@@ -15,9 +15,9 @@ interface ErrorModalProps {
export const ErrorModal: React.FC<ErrorModalProps> = ({ error, onClose }) => {
const { t: tr } = useI18n();
if (!error) return null;
const handleCopyStack = useCallback(async () => {
if (!error) return;
const textToCopy = `${error.title || tr('errorModal.error')}\n${error.message}\n\n${tr('errorModal.stackTrace')}:\n${error.stack || tr('errorModal.noStack')}`;
try {
await navigator.clipboard.writeText(textToCopy);
@@ -32,6 +32,8 @@ export const ErrorModal: React.FC<ErrorModalProps> = ({ error, onClose }) => {
}
}, [onClose]);
if (!error) return null;
return (
<div className="error-modal-backdrop" onClick={handleBackdropClick}>
<div className="error-modal">

View File

@@ -32,7 +32,7 @@ const mergeStatusFilesIncremental = (
export const GitSidebar: React.FC = () => {
const { t: tr } = useI18n();
const { activeProject, openTab, tabs, closeTab } = useAppStore();
const { activeProject, openTab, tabs, closeTab, showErrorModal } = useAppStore();
const [projectPath, setProjectPath] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [initializing, setInitializing] = useState(false);
@@ -390,6 +390,10 @@ export const GitSidebar: React.FC = () => {
recentCommitsToKeep: 2,
});
if (!result.success) {
if (result.code === 'offline') {
showErrorModal({ message: tr('gitSidebar.error.offlineMode') });
return;
}
setError(result.error || tr('gitSidebar.error.actionFailed', { action }));
setErrorGuidance('guidance' in result ? result.guidance || [] : []);
return;

View File

@@ -565,3 +565,36 @@
.ollama-caps-table input[type="checkbox"] {
margin: 0;
}
/* LM Studio model capabilities table */
.lmstudio-model-capabilities {
margin-top: 12px;
}
.lmstudio-model-capabilities .setting-description {
display: block;
margin-bottom: 8px;
}
.lmstudio-caps-table {
width: 100%;
border-collapse: collapse;
font-size: 0.9em;
}
.lmstudio-caps-table th,
.lmstudio-caps-table td {
padding: 4px 8px;
text-align: left;
border-bottom: 1px solid var(--pico-muted-border-color, #ccc);
}
.lmstudio-caps-table th:not(:first-child),
.lmstudio-caps-table td:not(:first-child) {
text-align: center;
width: 80px;
}
.lmstudio-caps-table input[type="checkbox"] {
margin: 0;
}

View File

@@ -248,6 +248,14 @@ export const SettingsView: React.FC = () => {
const [ollamaEnabled, setOllamaEnabled] = useState(false);
const [ollamaCapabilities, setOllamaCapabilities] = useState<Record<string, { tools: boolean; vision: boolean }>>({});
const [ollamaModels, setOllamaModels] = useState<{id: string; name: string}[]>([]);
const [lmstudioEnabled, setLmstudioEnabled] = useState(false);
const [lmstudioCapabilities, setLmstudioCapabilities] = useState<Record<string, { tools: boolean; vision: boolean }>>({});
const [lmstudioModels, setLmstudioModels] = useState<{id: string; name: string}[]>([]);
const [offlineModeEnabled, setOfflineModeEnabled] = useState(false);
const [offlineChatModel, setOfflineChatModel] = useState('');
const [offlineTitleModel, setOfflineTitleModel] = useState('');
const [offlineImageAnalysisModel, setOfflineImageAnalysisModel] = useState('');
const [knownLocalModels, setKnownLocalModels] = useState<{id: string; name: string; provider?: string; vision?: boolean}[]>([]);
const [titleModel, setTitleModel] = useState('claude-haiku-4-5');
const [imageAnalysisModel, setImageAnalysisModel] = useState('claude-sonnet-4-5');
const [availableModels, setAvailableModels] = useState<{id: string; name: string; provider?: string; vision?: boolean}[]>([]);
@@ -432,6 +440,20 @@ export const SettingsView: React.FC = () => {
if (models) setOllamaModels(models.map(m => ({ id: m.id, name: m.name })));
}
// Load LM Studio enabled state
const lmstudioState = await window.electronAPI?.chat.getLmstudioEnabled();
setLmstudioEnabled(!!lmstudioState);
// Load LM Studio model capabilities and models list
if (lmstudioState) {
const [lmCaps, lmModels] = await Promise.all([
window.electronAPI?.chat.getLmstudioModelCapabilities(),
window.electronAPI?.chat.getLmstudioModels(),
]);
if (lmCaps) setLmstudioCapabilities(lmCaps);
if (lmModels) setLmstudioModels(lmModels.map(m => ({ id: m.id, name: m.name })));
}
// Load per-purpose model preferences
const titleModelResult = await window.electronAPI?.chat.getTitleModel();
if (titleModelResult?.success && titleModelResult.modelId) {
@@ -442,6 +464,22 @@ export const SettingsView: React.FC = () => {
setImageAnalysisModel(imageModelResult.modelId);
}
// Load offline mode preferences
const offlineState = await window.electronAPI?.chat.getOfflineMode();
setOfflineModeEnabled(!!offlineState);
const offlineChat = await window.electronAPI?.chat.getOfflineChatModel();
if (offlineChat?.success && offlineChat.modelId) setOfflineChatModel(offlineChat.modelId);
const offlineTitle = await window.electronAPI?.chat.getOfflineTitleModel();
if (offlineTitle?.success && offlineTitle.modelId) setOfflineTitleModel(offlineTitle.modelId);
const offlineImage = await window.electronAPI?.chat.getOfflineImageAnalysisModel();
if (offlineImage?.success && offlineImage.modelId) setOfflineImageAnalysisModel(offlineImage.modelId);
// Load known local models (persisted, no network needed)
try {
const locals = await window.electronAPI?.chat.getKnownLocalModels();
if (locals && locals.length > 0) setKnownLocalModels(locals);
} catch { /* ignore */ }
// Load model catalog metadata
const catalogResult = await window.electronAPI?.chat.getModelCatalog();
if (catalogResult?.success && catalogResult.entries) {
@@ -553,7 +591,7 @@ export const SettingsView: React.FC = () => {
const projectKeywords = ['project', 'name', 'description', 'blog', 'site', 'url', 'public', 'path', 'folder', 'location', 'data', 'language', 'author', 'default', 'preview', 'max', 'posts', 'page', 'bookmarklet', 'blogmark'];
const editorKeywords = ['editor', 'mode', 'wysiwyg', 'markdown', 'preview', 'visual'];
const contentKeywords = ['content', 'categories', 'post', 'article', 'picture', 'aside', 'page'];
const aiKeywords = ['ai', 'assistant', 'chat', 'model', 'prompt', 'system', 'api', 'key', 'claude', 'gpt', 'opencode', 'ollama', 'local'];
const aiKeywords = ['ai', 'assistant', 'chat', 'model', 'prompt', 'system', 'api', 'key', 'claude', 'gpt', 'opencode', 'ollama', 'lmstudio', 'lm studio', 'local'];
const technologyKeywords = ['technology', 'python', 'runtime', 'worker', 'webworker', 'main thread', 'execution'];
const publishingKeywords = ['publishing', 'ssh', 'deploy', 'server', 'host', 'upload', 'scp', 'rsync'];
const dataKeywords = ['data', 'database', 'rebuild', 'maintenance', 'posts', 'media', 'scripts', 'links', 'folder', 'filesystem'];
@@ -1210,6 +1248,55 @@ export const SettingsView: React.FC = () => {
}
};
const handleLmstudioToggle = async (enabled: boolean) => {
try {
const result = await window.electronAPI?.chat.setLmstudioEnabled(enabled);
if (result?.success) {
setLmstudioEnabled(enabled);
showToast.success(t(enabled ? 'settings.toast.lmstudioEnabled' : 'settings.toast.lmstudioDisabled'));
// Refresh models after toggle
const modelsResult = await window.electronAPI?.chat.getAvailableModels();
if (modelsResult?.success && modelsResult.models) {
setAvailableModels(modelsResult.models);
setSelectedModel(modelsResult.selectedModel || '');
}
// Load LM Studio models and capabilities when enabling
if (enabled) {
const [caps, lmstudioModelsList] = await Promise.all([
window.electronAPI?.chat.getLmstudioModelCapabilities(),
window.electronAPI?.chat.getLmstudioModels(),
]);
if (caps) setLmstudioCapabilities(caps);
if (lmstudioModelsList) setLmstudioModels(lmstudioModelsList.map(m => ({ id: m.id, name: m.name })));
} else {
setLmstudioModels([]);
}
}
} catch (error) {
console.error('Failed to toggle LM Studio:', error);
}
};
const handleLmstudioCapabilityToggle = async (modelId: string, field: 'tools' | 'vision', value: boolean) => {
const current = lmstudioCapabilities[modelId] ?? { tools: false, vision: false };
const updated = { ...current, [field]: value };
try {
const result = await window.electronAPI?.chat.setLmstudioModelCapabilities(modelId, updated);
if (result?.success) {
setLmstudioCapabilities(prev => ({ ...prev, [modelId]: updated }));
// Refresh available models to reflect vision change
const modelsResult = await window.electronAPI?.chat.getAvailableModels();
if (modelsResult?.success && modelsResult.models) {
setAvailableModels(modelsResult.models);
}
}
} catch (error) {
console.error('Failed to update LM Studio model capabilities:', error);
}
};
const handleTitleModelChange = async (modelId: string) => {
try {
const result = await window.electronAPI?.chat.setTitleModel(modelId);
@@ -1232,6 +1319,45 @@ export const SettingsView: React.FC = () => {
}
};
const handleOfflineToggle = async (enabled: boolean) => {
try {
const result = await window.electronAPI?.chat.setOfflineMode(enabled);
if (result?.success) {
setOfflineModeEnabled(enabled);
showToast.success(t(enabled ? 'settings.toast.offlineEnabled' : 'settings.toast.offlineDisabled'));
}
} catch (error) {
console.error('Failed to toggle offline mode:', error);
}
};
const handleOfflineChatModelChange = async (modelId: string) => {
try {
const result = await window.electronAPI?.chat.setOfflineChatModel(modelId);
if (result?.success) setOfflineChatModel(modelId);
} catch (error) {
console.error('Failed to set offline chat model:', error);
}
};
const handleOfflineTitleModelChange = async (modelId: string) => {
try {
const result = await window.electronAPI?.chat.setOfflineTitleModel(modelId);
if (result?.success) setOfflineTitleModel(modelId);
} catch (error) {
console.error('Failed to set offline title model:', error);
}
};
const handleOfflineImageAnalysisModelChange = async (modelId: string) => {
try {
const result = await window.electronAPI?.chat.setOfflineImageAnalysisModel(modelId);
if (result?.success) setOfflineImageAnalysisModel(modelId);
} catch (error) {
console.error('Failed to set offline image analysis model:', error);
}
};
const handleModelChange = async (modelId: string) => {
try {
const result = await window.electronAPI?.chat.setDefaultModel(modelId);
@@ -1299,10 +1425,26 @@ export const SettingsView: React.FC = () => {
[availableModels, groupModelsByProvider]
);
// Local-only models (for offline / airplane mode selectors)
// Prefer knownLocalModels (persisted, always available) over filtering availableModels (needs network)
const localModelSource = useMemo(() => {
const fromAvailable = availableModels.filter(m => m.provider === 'ollama' || m.provider === 'lmstudio');
return fromAvailable.length > 0 ? fromAvailable : knownLocalModels;
}, [availableModels, knownLocalModels]);
const groupedLocalModels = useMemo(
() => groupModelsByProvider(localModelSource),
[localModelSource, groupModelsByProvider]
);
const groupedLocalVisionModels = useMemo(
() => groupModelsByProvider(localModelSource.filter(m => m.vision)),
[localModelSource, groupModelsByProvider]
);
const providerLabel = (provider: string) => {
if (provider === 'anthropic' || provider === 'openai' || provider === 'google' || provider === 'other') return t('settings.ai.providerOpenCode');
if (provider === 'mistral') return t('settings.ai.providerMistral');
if (provider === 'ollama') return t('settings.ai.providerOllama');
if (provider === 'lmstudio') return t('settings.ai.providerLmstudio');
return provider;
};
@@ -1472,17 +1614,120 @@ export const SettingsView: React.FC = () => {
)}
</SettingRow>
<SettingRow
id="ai-lmstudio"
label={t('settings.ai.lmstudioLabel')}
description={t('settings.ai.lmstudioDescription')}
>
<div className="setting-input-group">
<label className="toggle-label">
<input
id="ai-lmstudio"
type="checkbox"
checked={lmstudioEnabled}
onChange={(e) => handleLmstudioToggle(e.target.checked)}
/>
{t('settings.ai.lmstudioEnable')}
</label>
{lmstudioEnabled && (
<span className="setting-status-badge success">{t('settings.ai.configured')}</span>
)}
</div>
{lmstudioEnabled && lmstudioModels.length > 0 && (
<div className="lmstudio-model-capabilities">
<small className="setting-description">{t('settings.ai.lmstudioCapabilitiesDescription')}</small>
<table className="lmstudio-caps-table">
<thead>
<tr>
<th>{t('settings.ai.lmstudioCapModel')}</th>
<th>{t('settings.ai.lmstudioCapTools')}</th>
<th>{t('settings.ai.lmstudioCapVision')}</th>
</tr>
</thead>
<tbody>
{lmstudioModels.map(m => {
const caps = lmstudioCapabilities[m.id] ?? { tools: false, vision: false };
return (
<tr key={m.id}>
<td>{m.name}</td>
<td>
<input
type="checkbox"
checked={caps.tools}
onChange={(e) => handleLmstudioCapabilityToggle(m.id, 'tools', e.target.checked)}
/>
</td>
<td>
<input
type="checkbox"
checked={caps.vision}
onChange={(e) => handleLmstudioCapabilityToggle(m.id, 'vision', e.target.checked)}
/>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</SettingRow>
<SettingRow
id="ai-offline"
label={t('settings.ai.offlineLabel')}
description={t('settings.ai.offlineDescription')}
>
<div className="setting-input-group">
<label className="toggle-label">
<input
id="ai-offline"
type="checkbox"
checked={offlineModeEnabled}
onChange={(e) => handleOfflineToggle(e.target.checked)}
disabled={!ollamaEnabled && !lmstudioEnabled}
/>
{t('settings.ai.offlineEnable')}
</label>
{offlineModeEnabled && (
<span className="setting-status-badge success">{t('settings.ai.configured')}</span>
)}
</div>
{!ollamaEnabled && !lmstudioEnabled && (
<small className="setting-description">{t('settings.ai.offlineNoLocalProviders')}</small>
)}
{offlineModeEnabled && (ollamaEnabled || lmstudioEnabled) && (
<div className="offline-model-preferences">
<div className="setting-field">
<label htmlFor="ai-offline-chat-model">{t('settings.ai.offlineChatModel')}</label>
<small className="setting-description">{t('settings.ai.offlineChatModelDescription')}</small>
{renderModelSelect('ai-offline-chat-model', offlineChatModel, handleOfflineChatModelChange, false, groupedLocalModels)}
</div>
<div className="setting-field">
<label htmlFor="ai-offline-title-model">{t('settings.ai.offlineTitleModel')}</label>
<small className="setting-description">{t('settings.ai.offlineTitleModelDescription')}</small>
{renderModelSelect('ai-offline-title-model', offlineTitleModel, handleOfflineTitleModelChange, false, groupedLocalModels)}
</div>
<div className="setting-field">
<label htmlFor="ai-offline-image-model">{t('settings.ai.offlineImageAnalysisModel')}</label>
<small className="setting-description">{t('settings.ai.offlineImageAnalysisModelDescription')}</small>
{renderModelSelect('ai-offline-image-model', offlineImageAnalysisModel, handleOfflineImageAnalysisModelChange, false, groupedLocalVisionModels)}
</div>
</div>
)}
</SettingRow>
<SettingRow
id="ai-model"
label={t('settings.ai.defaultModelLabel')}
description={t('settings.ai.defaultModelDescription')}
>
<div className="setting-input-group">
{renderModelSelect('ai-model', selectedModel, handleModelChange, !aiHasApiKey && !aiHasMistralKey && !ollamaEnabled)}
{renderModelSelect('ai-model', selectedModel, handleModelChange, !aiHasApiKey && !aiHasMistralKey && !ollamaEnabled && !lmstudioEnabled)}
<button
className="secondary"
onClick={handleRefreshModelCatalog}
disabled={refreshingCatalog || (!aiHasApiKey && !aiHasMistralKey && !ollamaEnabled)}
disabled={refreshingCatalog || (!aiHasApiKey && !aiHasMistralKey && !ollamaEnabled && !lmstudioEnabled)}
title={t('settings.ai.refreshModelCatalog')}
>
{refreshingCatalog ? t('settings.ai.refreshing') : t('settings.ai.refreshModelCatalog')}
@@ -1514,7 +1759,7 @@ export const SettingsView: React.FC = () => {
label={t('settings.ai.titleModelLabel')}
description={t('settings.ai.titleModelDescription')}
>
{renderModelSelect('ai-title-model', titleModel, handleTitleModelChange, !aiHasApiKey && !aiHasMistralKey && !ollamaEnabled)}
{renderModelSelect('ai-title-model', titleModel, handleTitleModelChange, !aiHasApiKey && !aiHasMistralKey && !ollamaEnabled && !lmstudioEnabled)}
</SettingRow>
<SettingRow
@@ -1522,7 +1767,7 @@ export const SettingsView: React.FC = () => {
label={t('settings.ai.imageAnalysisModelLabel')}
description={t('settings.ai.imageAnalysisModelDescription')}
>
{renderModelSelect('ai-image-analysis-model', imageAnalysisModel, handleImageAnalysisModelChange, !aiHasApiKey && !aiHasMistralKey && !ollamaEnabled, groupedVisionModels)}
{renderModelSelect('ai-image-analysis-model', imageAnalysisModel, handleImageAnalysisModelChange, !aiHasApiKey && !aiHasMistralKey && !ollamaEnabled && !lmstudioEnabled, groupedVisionModels)}
</SettingRow>
<SettingRow

View File

@@ -123,6 +123,19 @@
gap: 4px;
}
.status-bar-item.offline-badge {
cursor: pointer;
opacity: 0.4;
font-size: 13px;
padding: 0 4px;
}
.status-bar-item.offline-badge.active {
background-color: var(--vscode-notificationsWarningIcon-foreground);
border-radius: 3px;
opacity: 1;
}
.status-bar-language-select {
background: transparent;
border: none;

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect, useCallback } from 'react';
import { useAppStore } from '../../store';
import { ProjectSelector } from '../ProjectSelector';
import { getRendererPicoTheme } from '../../utils/picoTheme';
@@ -27,6 +27,22 @@ export const StatusBar: React.FC = () => {
} = useAppStore();
const [selectedPostStatus, setSelectedPostStatus] = useState<string | null>(null);
const [offlineMode, setOfflineMode] = useState(false);
// Fetch offline mode state on mount
useEffect(() => {
window.electronAPI?.chat?.getOfflineMode().then(setOfflineMode).catch(() => {});
}, []);
const toggleOfflineMode = useCallback(async () => {
const newValue = !offlineMode;
try {
await window.electronAPI?.chat?.setOfflineMode(newValue);
setOfflineMode(newValue);
} catch {
// ignore
}
}, [offlineMode]);
// Fetch selected post status from database
useEffect(() => {
@@ -96,6 +112,18 @@ export const StatusBar: React.FC = () => {
<span>{t('statusBar.theme', { theme: activeTheme })}</span>
</div>
<div
className={`status-bar-item offline-badge${offlineMode ? ' active' : ''}`}
role="button"
tabIndex={0}
data-testid="statusbar-offline-toggle"
title={t('statusBar.offlineModeTooltip')}
onClick={toggleOfflineMode}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggleOfflineMode(); } }}
>
<span></span>
</div>
<div className="status-bar-item language-badge">
<span>{t('statusBar.ui')}</span>
<select