From 57479255036d1534b8348d7f7a05980488826281 Mon Sep 17 00:00:00 2001 From: Georg Bauer Date: Mon, 2 Mar 2026 13:35:42 +0100 Subject: [PATCH] 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 --- BDS_SEMANTIC_SIMILARITY.md | 45 ++- src/main/engine/GitEngine.ts | 2 +- src/main/engine/MediaEngine.ts | 48 ++- src/main/engine/ai/chat.ts | 34 +- src/main/engine/ai/providers.ts | 238 ++++++++++++- src/main/engine/ai/tasks.ts | 68 ++-- src/main/ipc/chatHandlers.ts | 233 ++++++++++++- src/main/ipc/handlers.ts | 13 + src/main/ipc/publishHandlers.ts | 5 + src/main/preload.ts | 18 + src/main/shared/electronApi.ts | 22 +- src/main/shared/i18n/locales/de.json | 4 +- src/main/shared/i18n/locales/en.json | 4 +- src/main/shared/i18n/locales/es.json | 4 +- src/main/shared/i18n/locales/fr.json | 4 +- src/main/shared/i18n/locales/it.json | 4 +- src/renderer/App.tsx | 8 +- .../components/ErrorModal/ErrorModal.tsx | 4 +- .../components/GitSidebar/GitSidebar.tsx | 6 +- .../components/SettingsView/SettingsView.css | 33 ++ .../components/SettingsView/SettingsView.tsx | 255 +++++++++++++- .../components/StatusBar/StatusBar.css | 13 + .../components/StatusBar/StatusBar.tsx | 30 +- src/renderer/i18n/locales/de.json | 28 ++ src/renderer/i18n/locales/en.json | 28 ++ src/renderer/i18n/locales/es.json | 28 ++ src/renderer/i18n/locales/fr.json | 36 +- src/renderer/i18n/locales/it.json | 36 +- tests/engine/ai-sdk-phase2.test.ts | 131 ++++++- tests/engine/lmstudio-provider.test.ts | 319 +++++++++++++++++ tests/engine/offline-mode.test.ts | 191 +++++++++++ tests/engine/offline-model-fallback.test.ts | 323 ++++++++++++++++++ tests/ipc/handlers.test.ts | 79 +++++ tests/renderer/components/GitSidebar.test.tsx | 26 ++ 34 files changed, 2215 insertions(+), 105 deletions(-) create mode 100644 tests/engine/lmstudio-provider.test.ts create mode 100644 tests/engine/offline-mode.test.ts create mode 100644 tests/engine/offline-model-fallback.test.ts diff --git a/BDS_SEMANTIC_SIMILARITY.md b/BDS_SEMANTIC_SIMILARITY.md index c0cd30b..82508c5 100644 --- a/BDS_SEMANTIC_SIMILARITY.md +++ b/BDS_SEMANTIC_SIMILARITY.md @@ -21,7 +21,7 @@ Requires threading `currentPostId` from `Editor.tsx` → `InsertModal` (currentl | Embeddings | Hugging Face Transformers.js | `@huggingface/transformers` | ONNX, local, no API key | | Vector index | USearch | `usearch` | HNSW, native C++ via N-API, prebuilt binaries | -**Embedding model:** `Xenova/all-MiniLM-L6-v2` — 384 dimensions, ~90 MB on disk, ~150–200 MB RAM, ~100ms/post inference, handles mixed DE/EN. +**Embedding model:** `multilingual-e5-small` — 384 dimensions, 512-token context, ~470 MB on disk, ~200–300 MB RAM, ~100ms/post inference. Natively multilingual (100+ languages incl. DE/EN) — critical for a mixed-language blog. `all-MiniLM-L6-v2` (~90 MB) was considered but is EN-trained with weak DE transfer; not suitable for nuanced cross-language similarity. **Why USearch over alternatives:** - `sqlite-vec` — requires `loadExtension()` on the SQLite driver; bDS uses `@libsql/client` which doesn't expose it. Eliminated. @@ -31,10 +31,12 @@ Requires threading `currentPostId` from `Editor.tsx` → `InsertModal` (currentl - **USearch** — prebuilt binaries via `prebuildify` (matches `sharp`, `@libsql/client` pattern), actively maintained, HNSW with SIMD, <1ms queries, binary persistence (~6 MB for 10k×384). **USearch specifics:** -- Keys are `BigUint64Array` — need a `Map` (numeric label → post UUID) persisted alongside the index +- Keys are `BigUint64Array` — need a `Map` (numeric label → post UUID) persisted in a small Drizzle table (`embedding_keys`) - `index.load()` loads everything into RAM (~6 MB). `index.save()` is a full rewrite. Fine for this scale. - No incremental flush / WAL — acceptable since mutations are one-at-a-time post edits +**Electron packaging risk:** USearch uses N-API, but verify that its `prebuildify` targets include the Electron ABI for all platforms (macOS arm64/x64, Windows x64/arm64, Linux x64) before committing. Spike this first — if binaries are missing, fall back to `vectra`. + --- ## Architecture @@ -42,11 +44,12 @@ Requires threading `currentPostId` from `Editor.tsx` → `InsertModal` (currentl ### Files on disk ``` -/.bds/ +{userData}/projects/{projectId}/ embeddings.usearch # USearch binary index - embeddings-keys.json # { [numericLabel]: postId } mapping ``` +The `bigint → postId` key mapping lives in a Drizzle table (`embedding_keys`), not a JSON file — avoids `bigint` JSON serialization issues and stays atomic with the existing DB. + ### Engine: `EmbeddingEngine` (`src/main/engine/EmbeddingEngine.ts`) Responsibilities: @@ -63,10 +66,16 @@ class EmbeddingEngine { async removePost(postId: string): Promise async findSimilar(postId: string, k?: number): Promise async getIndexingProgress(): Promise<{ indexed: number; total: number }> + async reindexAll(): Promise // after databaseRebuilt + async setProjectContext(projectId: string): Promise // load/unload on switch async save(): Promise } ``` +### Project switching + +The app supports multiple projects. On project switch (`setProjectContext`), the engine must save and unload the current index, then load (or create) the index for the new project. Each project has its own `embeddings.usearch` file and `embedding_keys` table rows. + ### IPC ``` @@ -74,9 +83,25 @@ embeddings:findSimilar(postId: string, k?: number) → SimilarPost[] embeddings:getProgress() → { indexed: number; total: number } ``` +### Embedding content + +Embed the raw markdown body of each post (title + content). Markdown's lightweight markup (headers, links, emphasis) adds minimal noise and preserves semantic structure well enough for transformer models. No stripping needed. + +**Chunking for long posts:** The model's 512-token context (~400 words) covers most posts. For posts exceeding 512 tokens: +1. Split into 512-token chunks with ~50 token overlap +2. Embed each chunk independently +3. Mean-pool the chunk vectors into a single 384-dim embedding +4. Store the single pooled vector in the index + +This keeps the index simple (one vector per post, one lookup per query) while preserving semantic coverage of long-form content. The overlap prevents losing context at chunk boundaries. + ### Hook into existing post lifecycle -Post create/update/delete events already exist in `PostEngine`. On post content change → call `embeddingEngine.embedPost()`. On delete → call `embeddingEngine.removePost()`. Save index after each mutation. +Post create/update/delete events already exist in `PostEngine`. On post content change → call `embeddingEngine.embedPost()`. On delete → call `embeddingEngine.removePost()`. + +Also listen for `databaseRebuilt` — emitted after `reconcileFromDisk()` (e.g., git sync). This replaces the entire DB, so individual post events don't fire. On `databaseRebuilt` → trigger a full reindex. + +Save strategy: debounce `index.save()` on a timer (e.g., 5s after last mutation). During bulk indexing, batch-save every N posts (e.g., 100) instead of after each one — avoids 10k full file rewrites. ### Initial indexing (10k+ posts) @@ -84,7 +109,7 @@ Post create/update/delete events already exist in `PostEngine`. On post content - Must run as a low-priority background task after app startup - Emit progress events so UI can show "Indexing 3,421 / 10,247…" - On git sync to new machine, file watchers fire for all posts → triggers full reindex automatically -- Model download (~90 MB) on first run — needs progress indicator or opt-in preference +- Model download (~470 MB) on first run — needs progress indicator or opt-in preference --- @@ -106,7 +131,7 @@ Post create/update/delete events already exist in `PostEngine`. On post content ## Implementation Steps 1. **Test + implement `EmbeddingEngine`** — model loading, embed, add/remove/query against USearch index, save/load persistence -2. **SQLite key map** — persist the `bigint → postId` mapping (simple JSON file or a small Drizzle table) +2. **Drizzle key map table** — `embedding_keys` table mapping `bigint` label → post UUID 3. **Wire into post lifecycle** — hook create/update/delete → embedding updates 4. **Background indexer** — on startup, diff indexed vs. existing posts, queue unindexed for background embedding with progress events 5. **IPC endpoints** — `findSimilar`, `getProgress` @@ -120,6 +145,6 @@ Post create/update/delete events already exist in `PostEngine`. On post content - Feature must be opt-in (model download + 17 min indexing is not a silent default) - No external API calls — fully local -- Model cached in `~/.cache/huggingface/`, index in project `.bds/` directory -- .bds/ directory inside project directory must be added to .gitignore (cache is kept local not versioned) -- Total added footprint: ~140 MB on disk (onnxruntime-node ~50 MB + model ~90 MB), ~200 MB RAM at runtime for model + index +- Model cached in `~/.cache/huggingface/`, index in internal project directory +- Total added footprint: ~520 MB on disk (onnxruntime-node ~50 MB + model ~470 MB), ~300 MB RAM at runtime for model + index +- Graceful degradation: if USearch native module fails to load (unsupported platform), disable the feature silently — never crash the app diff --git a/src/main/engine/GitEngine.ts b/src/main/engine/GitEngine.ts index 1dde68f..73e53eb 100644 --- a/src/main/engine/GitEngine.ts +++ b/src/main/engine/GitEngine.ts @@ -129,7 +129,7 @@ export interface GitLfsPruneResult { export interface GitActionResult { success: boolean; - code?: 'auth-required' | 'conflict' | 'network' | 'action-failed'; + code?: 'auth-required' | 'conflict' | 'network' | 'action-failed' | 'offline'; error?: string; guidance?: string[]; } diff --git a/src/main/engine/MediaEngine.ts b/src/main/engine/MediaEngine.ts index 2923d84..55ad51d 100644 --- a/src/main/engine/MediaEngine.ts +++ b/src/main/engine/MediaEngine.ts @@ -10,11 +10,12 @@ import { media, Media, NewMedia, postMedia } from '../database/schema'; import { stemText, stemQuery, SupportedLanguage } from './stemmer'; import { CliNotifier, NoopNotifier } from './CliNotifier'; -// Thumbnail sizes +// Thumbnail sizes — 'ai' is a dedicated JPEG thumbnail for vision-model input const THUMBNAIL_SIZES = { - small: { width: 150, height: 150 }, - medium: { width: 400, height: 400 }, - large: { width: 800, height: 800 }, + small: { width: 150, height: 150, ext: 'webp' as const, mime: 'image/webp' as const }, + medium: { width: 400, height: 400, ext: 'webp' as const, mime: 'image/webp' as const }, + large: { width: 800, height: 800, ext: 'webp' as const, mime: 'image/webp' as const }, + ai: { width: 448, height: 448, ext: 'jpg' as const, mime: 'image/jpeg' as const }, } as const; type ThumbnailSize = keyof typeof THUMBNAIL_SIZES; @@ -244,17 +245,26 @@ export class MediaEngine extends EventEmitter { // Dynamic import of sharp (it's a native module) const sharp = (await import('sharp')).default; - for (const [size, dimensions] of Object.entries(THUMBNAIL_SIZES) as [ThumbnailSize, { width: number; height: number }][]) { - const thumbnailPath = path.join(thumbnailSubDir, `${mediaId}-${size}.webp`); + for (const [size, config] of Object.entries(THUMBNAIL_SIZES) as [ThumbnailSize, (typeof THUMBNAIL_SIZES)[ThumbnailSize]][]) { + const thumbnailPath = path.join(thumbnailSubDir, `${mediaId}-${size}.${config.ext}`); - await sharp(sourcePath) - .resize(dimensions.width, dimensions.height, { - fit: 'inside', - withoutEnlargement: true, - }) - .webp({ quality: 80 }) - .toFile(thumbnailPath); + // AI thumbnail: exact 448×448 with black letterboxing for vision models. + // All others: fit inside bounding box, no upscaling. + const isAI = size === 'ai'; + let pipeline = sharp(sourcePath) + .resize(config.width, config.height, { + fit: isAI ? 'contain' : 'inside', + withoutEnlargement: !isAI, + background: { r: 0, g: 0, b: 0 }, + }); + if (config.ext === 'jpg') { + pipeline = pipeline.jpeg({ quality: 85 }); + } else { + pipeline = pipeline.webp({ quality: 80 }); + } + + await pipeline.toFile(thumbnailPath); thumbnails[size] = thumbnailPath; } @@ -276,10 +286,11 @@ export class MediaEngine extends EventEmitter { small: null, medium: null, large: null, + ai: null, }; for (const size of Object.keys(THUMBNAIL_SIZES) as ThumbnailSize[]) { - const thumbnailPath = path.join(thumbnailSubDir, `${mediaId}-${size}.webp`); + const thumbnailPath = path.join(thumbnailSubDir, `${mediaId}-${size}.${THUMBNAIL_SIZES[size].ext}`); try { await fs.access(thumbnailPath); result[size] = thumbnailPath; @@ -296,11 +307,12 @@ export class MediaEngine extends EventEmitter { */ async getThumbnailDataUrl(mediaId: string, size: ThumbnailSize = 'small'): Promise { const thumbnailSubDir = this.getThumbnailSubDir(mediaId); - const thumbnailPath = path.join(thumbnailSubDir, `${mediaId}-${size}.webp`); + const config = THUMBNAIL_SIZES[size]; + const thumbnailPath = path.join(thumbnailSubDir, `${mediaId}-${size}.${config.ext}`); try { const data = await fs.readFile(thumbnailPath); - return `data:image/webp;base64,${data.toString('base64')}`; + return `data:${config.mime};base64,${data.toString('base64')}`; } catch { return null; } @@ -313,7 +325,7 @@ export class MediaEngine extends EventEmitter { const thumbnailSubDir = this.getThumbnailSubDir(mediaId); for (const size of Object.keys(THUMBNAIL_SIZES) as ThumbnailSize[]) { - const thumbnailPath = path.join(thumbnailSubDir, `${mediaId}-${size}.webp`); + const thumbnailPath = path.join(thumbnailSubDir, `${mediaId}-${size}.${THUMBNAIL_SIZES[size].ext}`); try { await fs.unlink(thumbnailPath); } catch { @@ -1166,7 +1178,7 @@ export class MediaEngine extends EventEmitter { for (const item of imageMedia) { const thumbnails = await this.getThumbnailPaths(item.id); // Consider missing if any size is missing - if (!thumbnails.small || !thumbnails.medium || !thumbnails.large) { + if (!thumbnails.small || !thumbnails.medium || !thumbnails.large || !thumbnails.ai) { missingThumbnails.push(item); } } diff --git a/src/main/engine/ai/chat.ts b/src/main/engine/ai/chat.ts index fde347a..32f1e67 100644 --- a/src/main/engine/ai/chat.ts +++ b/src/main/engine/ai/chat.ts @@ -246,7 +246,21 @@ export class ChatService { const abortController = new AbortController(); this.abortControllers.set(conversationId, abortController); - const modelId = conversation.model || 'claude-sonnet-4'; + let modelId = conversation.model || 'claude-sonnet-4'; + + // In offline mode, swap to the configured offline chat model + if (this.providers.isOfflineMode()) { + if (!this.providers.isOllamaModel(modelId) && !this.providers.isLmstudioModel(modelId)) { + const offlineModel = await this.chatEngine.getSetting('offline_chat_model') + || this.providers.getFirstKnownLocalModelId(); + if (offlineModel) { + modelId = offlineModel; + } else { + return { success: false, error: 'No offline chat model configured. Set one in Settings → AI → Airplane Mode.' }; + } + } + } + const provider = this.providers.detectModelProvider(modelId); // Verify provider key is available @@ -271,9 +285,11 @@ export class ChatService { const aiMessages = dbMessagesToAIMessages(dbMessages); - // Build tools (skip for Ollama models unless capability override is set) + // Build tools (skip for Ollama/LM Studio models unless capability override is set) const isOllama = this.providers.isOllamaModel(modelId); - const skipTools = isOllama && !this.providers.ollamaModelSupportsTools(modelId); + const isLmstudio = this.providers.isLmstudioModel(modelId); + const skipTools = (isOllama && !this.providers.ollamaModelSupportsTools(modelId)) + || (isLmstudio && !this.providers.lmstudioModelSupportsTools(modelId)); const blogTools = skipTools ? {} : createBlogTools(this.blogToolDeps); const a2uiToolsRaw = skipTools ? {} : createA2UITools(); const allTools = { ...blogTools, ...a2uiToolsRaw }; @@ -447,6 +463,18 @@ export class ChatService { ? 'mistral-small-latest' : null; } + + // In offline mode, swap to the configured offline title model + if (this.providers.isOfflineMode()) { + const offlineModel = await this.chatEngine.getSetting('offline_title_model') + || this.providers.getFirstKnownLocalModelId(); + if (offlineModel) { + titleModel = offlineModel; + } else if (!titleModel || (!this.providers.isOllamaModel(titleModel) && !this.providers.isLmstudioModel(titleModel))) { + return; // No offline title model — skip title generation silently + } + } + if (!titleModel) return; const model = this.providers.resolveModel(titleModel); diff --git a/src/main/engine/ai/providers.ts b/src/main/engine/ai/providers.ts index 336dc58..006d3f0 100644 --- a/src/main/engine/ai/providers.ts +++ b/src/main/engine/ai/providers.ts @@ -29,9 +29,12 @@ export const ZEN_MODELS_URL = 'https://opencode.ai/zen/v1/models'; export const MISTRAL_MODELS_URL = 'https://api.mistral.ai/v1/models'; export const OLLAMA_BASE_URL = 'http://localhost:11434/v1'; export const OLLAMA_TAGS_URL = 'http://localhost:11434/api/tags'; +export const LMSTUDIO_BASE_URL = 'http://localhost:1234/v1'; +export const LMSTUDIO_MODELS_URL = 'http://localhost:1234/v1/models'; const MODEL_CACHE_TTL = 5 * 60 * 1000; // 5 minutes const OLLAMA_FETCH_TIMEOUT = 3000; // 3 s — fail fast when Ollama isn't running +const LMSTUDIO_FETCH_TIMEOUT = 3000; // 3 s — fail fast when LM Studio isn't running // --------------------------------------------------------------------------- // Gateway factory @@ -108,12 +111,28 @@ export class ProviderRegistry { private ollamaProvider: ReturnType | null = null; private ollamaModelIds = new Set(); private ollamaCapabilities = new Map(); + private lmstudioEnabled = false; + private lmstudioProvider: ReturnType | null = null; + private lmstudioModelIds = new Set(); + private lmstudioCapabilities = new Map(); private modelCatalogEngine = new ModelCatalogEngine(); + private _offlineMode = false; // Model cache private cachedModels: ChatModel[] | null = null; private cachedModelsAt = 0; + // ---- Offline / airplane mode ---- + + setOfflineMode(enabled: boolean): void { + this._offlineMode = enabled; + this.invalidateModelCache(); + } + + isOfflineMode(): boolean { + return this._offlineMode; + } + // ---- Key management ---- setOpencodeKey(key: string): void { @@ -203,33 +222,109 @@ export class ProviderRegistry { return this.ollamaCapabilities.get(modelId)?.vision ?? false; } + // ---- LM Studio management ---- + + setLmstudioEnabled(enabled: boolean): void { + this.lmstudioEnabled = enabled; + this.lmstudioProvider = null; + this.invalidateModelCache(); + } + + isLmstudioEnabled(): boolean { + return this.lmstudioEnabled; + } + + /** Register a model ID as belonging to LM Studio. */ + registerLmstudioModel(modelId: string): void { + this.lmstudioModelIds.add(modelId); + } + + /** Check whether a model ID was registered as an LM Studio model. */ + isLmstudioModel(modelId: string): boolean { + return this.lmstudioModelIds.has(modelId); + } + + /** Remove all registered LM Studio model IDs. */ + clearLmstudioModels(): void { + this.lmstudioModelIds.clear(); + } + + // ---- LM Studio model capability overrides ---- + + /** Get capability overrides for a specific LM Studio model (defaults to tools=false, vision=false). */ + getLmstudioModelCapabilities(modelId: string): { tools: boolean; vision: boolean } { + return this.lmstudioCapabilities.get(modelId) ?? { tools: false, vision: false }; + } + + /** Set capability overrides for a specific LM Studio model. */ + setLmstudioModelCapabilities(modelId: string, caps: { tools: boolean; vision: boolean }): void { + this.lmstudioCapabilities.set(modelId, caps); + this.invalidateModelCache(); + } + + /** Get all stored LM Studio capability overrides as a plain object. */ + getAllLmstudioModelCapabilities(): Record { + const result: Record = {}; + for (const [id, caps] of this.lmstudioCapabilities) { + result[id] = caps; + } + return result; + } + + /** Load LM Studio capability overrides from a serialized object (e.g. from settings DB). */ + loadLmstudioModelCapabilities(data: Record): void { + this.lmstudioCapabilities.clear(); + for (const [id, caps] of Object.entries(data)) { + this.lmstudioCapabilities.set(id, caps); + } + } + + /** Check whether an LM Studio model has tools capability enabled. */ + lmstudioModelSupportsTools(modelId: string): boolean { + return this.lmstudioCapabilities.get(modelId)?.tools ?? false; + } + + /** Check whether an LM Studio model has vision capability enabled. */ + lmstudioModelSupportsVision(modelId: string): boolean { + return this.lmstudioCapabilities.get(modelId)?.vision ?? false; + } + /** - * Detect the effective provider for a model ID, checking Ollama + * Detect the effective provider for a model ID, checking Ollama and LM Studio * registration first, then falling back to prefix-based detection. */ detectModelProvider(modelId: string): string { if (this.ollamaModelIds.has(modelId)) return 'ollama'; + if (this.lmstudioModelIds.has(modelId)) return 'lmstudio'; return detectProvider(modelId); } /** Check whether at least one provider key is configured. */ isReady(): boolean { - return !!(this.opencodeKey || this.mistralKey || this.ollamaEnabled); + if (this._offlineMode) { + return !!(this.ollamaEnabled || this.lmstudioEnabled); + } + return !!(this.opencodeKey || this.mistralKey || this.ollamaEnabled || this.lmstudioEnabled); } /** Check whether the key for a specific provider is set. */ isProviderKeySet(provider: string): boolean { - if (provider === 'mistral') return !!this.mistralKey; if (provider === 'ollama') return this.ollamaEnabled; + if (provider === 'lmstudio') return this.lmstudioEnabled; + // In offline mode, cloud providers are unavailable + if (this._offlineMode) return false; + if (provider === 'mistral') return !!this.mistralKey; return !!this.opencodeKey; } /** Returns status of all configured providers. */ - getProviderStatus(): { opencode: boolean; mistral: boolean; ollama: boolean } { + getProviderStatus(): { opencode: boolean; mistral: boolean; ollama: boolean; lmstudio: boolean; offlineMode: boolean } { return { opencode: !!this.opencodeKey, mistral: !!this.mistralKey, ollama: this.ollamaEnabled, + lmstudio: this.lmstudioEnabled, + offlineMode: this._offlineMode, }; } @@ -237,6 +332,11 @@ export class ProviderRegistry { /** Resolve a model ID to an AI SDK LanguageModel. */ resolveModel(modelId: string): LanguageModel { + // In offline mode, only local providers are allowed + if (this._offlineMode && !this.ollamaModelIds.has(modelId) && !this.lmstudioModelIds.has(modelId)) { + throw new Error(`Model '${modelId}' is not available offline. Switch to a local model or disable airplane mode.`); + } + // Check if this is a registered Ollama model first if (this.ollamaModelIds.has(modelId)) { if (!this.ollamaEnabled) { @@ -251,6 +351,20 @@ export class ProviderRegistry { return this.ollamaProvider.chat(modelId); } + // Check if this is a registered LM Studio model + if (this.lmstudioModelIds.has(modelId)) { + if (!this.lmstudioEnabled) { + throw new Error(`LM Studio not configured for model '${modelId}'`); + } + if (!this.lmstudioProvider) { + this.lmstudioProvider = createOpenAI({ + baseURL: LMSTUDIO_BASE_URL, + apiKey: 'lm-studio', // LM Studio doesn't need a real key + }); + } + return this.lmstudioProvider.chat(modelId); + } + const provider = detectProvider(modelId); if (provider === 'mistral') { @@ -285,18 +399,66 @@ export class ProviderRegistry { return this.modelCatalogEngine; } + /** + * Return the first known local model ID, or null if none registered. + * Used as automatic fallback when no explicit offline model is configured. + */ + getFirstKnownLocalModelId(): string | null { + for (const id of this.ollamaModelIds) return id; + for (const id of this.lmstudioModelIds) return id; + return null; + } + + /** + * Return the first known local vision-capable model ID, or null. + */ + getFirstKnownLocalVisionModelId(): string | null { + for (const id of this.ollamaModelIds) { + if (this.ollamaModelSupportsVision(id)) return id; + } + for (const id of this.lmstudioModelIds) { + if (this.lmstudioModelSupportsVision(id)) return id; + } + return null; + } + + /** + * Return models already known to belong to local providers (Ollama + LM Studio) + * from in-memory sets, without any network fetch. + */ + getKnownLocalModels(): ChatModel[] { + const models: ChatModel[] = []; + for (const id of this.ollamaModelIds) { + models.push({ id, name: id, provider: 'ollama', vision: this.ollamaModelSupportsVision(id) }); + } + for (const id of this.lmstudioModelIds) { + models.push({ id, name: id, provider: 'lmstudio', vision: this.lmstudioModelSupportsVision(id) }); + } + return models; + } + /** Get available models across all configured providers (cached 5 min). */ async getAvailableModels(): Promise { if (this.cachedModels && Date.now() - this.cachedModelsAt < MODEL_CACHE_TTL) { return this.cachedModels; } + // In offline mode, return known local models instantly — no network. + if (this._offlineMode) { + const local = this.getKnownLocalModels(); + if (local.length > 0) { + this.cachedModels = local; + this.cachedModelsAt = Date.now(); + } + return local; + } + const allModels: ChatModel[] = []; let fetched = false; const { vision: catalogVision, names: catalogNames } = await this.getCatalogLookups(); - // Fetch OpenCode models - if (this.opencodeKey) { + // Fetch OpenCode models (skip in offline mode) + if (this.opencodeKey && !this._offlineMode) { try { const models = await this.fetchModelsFromEndpoint( ZEN_MODELS_URL, @@ -311,8 +473,8 @@ export class ProviderRegistry { } } - // Fetch Mistral models - if (this.mistralKey) { + // Fetch Mistral models (skip in offline mode) + if (this.mistralKey && !this._offlineMode) { try { const models = await this.fetchModelsFromEndpoint( MISTRAL_MODELS_URL, @@ -339,6 +501,17 @@ export class ProviderRegistry { } } + // Fetch LM Studio models + if (this.lmstudioEnabled) { + try { + const models = await this.fetchLmstudioModels(); + allModels.push(...models); + if (models.length > 0) fetched = true; + } catch { + // LM Studio not running — skip silently + } + } + if (fetched && allModels.length > 0) { this.cachedModels = allModels; this.cachedModelsAt = Date.now(); @@ -393,6 +566,38 @@ export class ProviderRegistry { } } + // ---- LM Studio model listing ---- + + /** + * Fetch available models from LM Studio's OpenAI-compatible /v1/models endpoint. + * Returns ChatModel[] and registers the model IDs internally. + */ + async fetchLmstudioModels(): Promise { + try { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), LMSTUDIO_FETCH_TIMEOUT); + const response = await fetch(LMSTUDIO_MODELS_URL, { method: 'GET', signal: controller.signal }); + clearTimeout(timeout); + if (!response.ok) return []; + + const data = await response.json() as { data?: Array<{ id: string }> }; + if (!data.data || !Array.isArray(data.data)) return []; + + const models: ChatModel[] = data.data.map(m => ({ + id: m.id, + name: m.id, + provider: 'lmstudio', + vision: this.lmstudioModelSupportsVision(m.id), + })); + // Only replace registered IDs on successful fetch + this.clearLmstudioModels(); + for (const m of models) this.registerLmstudioModel(m.id); + return models; + } catch { + return []; + } + } + // ---- Ollama model listing ---- /** @@ -410,16 +615,15 @@ export class ProviderRegistry { const data = await response.json() as { models?: Array<{ name: string; details?: { family?: string } }> }; if (!data.models || !Array.isArray(data.models)) return []; + const models: ChatModel[] = data.models.map(m => ({ + id: m.name, + name: m.name, + provider: 'ollama', + vision: this.ollamaModelSupportsVision(m.name), + })); + // Only replace registered IDs on successful fetch this.clearOllamaModels(); - const models: ChatModel[] = data.models.map(m => { - this.registerOllamaModel(m.name); - return { - id: m.name, - name: m.name, - provider: 'ollama', - vision: this.ollamaModelSupportsVision(m.name), - }; - }); + for (const m of models) this.registerOllamaModel(m.id); return models; } catch { return []; diff --git a/src/main/engine/ai/tasks.ts b/src/main/engine/ai/tasks.ts index 8ba3357..2544d4e 100644 --- a/src/main/engine/ai/tasks.ts +++ b/src/main/engine/ai/tasks.ts @@ -9,6 +9,7 @@ import { generateText } from 'ai'; import type { ChatEngine } from '../ChatEngine'; import type { MediaEngine } from '../MediaEngine'; import { ProviderRegistry } from './providers'; +import { resolveSupportedRenderLanguage, translateRender } from '../../shared/i18n'; // --------------------------------------------------------------------------- // Types @@ -29,17 +30,6 @@ export interface ImageAnalysisResult { error?: string; } -// --------------------------------------------------------------------------- -// Language map for image analysis prompts -// --------------------------------------------------------------------------- - -const LANGUAGE_NAMES: Record = { - en: 'English', de: 'German', es: 'Spanish', fr: 'French', it: 'Italian', - pt: 'Portuguese', nl: 'Dutch', pl: 'Polish', ru: 'Russian', ja: 'Japanese', - zh: 'Chinese', ko: 'Korean', ar: 'Arabic', hi: 'Hindi', tr: 'Turkish', - sv: 'Swedish', da: 'Danish', no: 'Norwegian', fi: 'Finnish', cs: 'Czech', -}; - // --------------------------------------------------------------------------- // OneShotTasks // --------------------------------------------------------------------------- @@ -70,7 +60,7 @@ export class OneShotTasks { ): Promise { const provider = this.providers.detectModelProvider(modelId); if (!this.providers.isProviderKeySet(provider)) { - const providerLabel = provider === 'mistral' ? 'Mistral' : provider === 'ollama' ? 'Ollama' : 'OpenCode'; + const providerLabel = provider === 'mistral' ? 'Mistral' : provider === 'ollama' ? 'Ollama' : provider === 'lmstudio' ? 'LM Studio' : 'OpenCode'; return { success: false, error: `${providerLabel} API key not set` }; } @@ -194,6 +184,19 @@ Remember: Only suggest mappings from NEW items to EXISTING items. Consider langu ? 'mistral-large-latest' : null; } + + // In offline mode, swap to the configured offline image analysis model + if (this.providers.isOfflineMode()) { + const offlineModel = await this.chatEngine.getSetting('offline_image_analysis_model') + || this.providers.getFirstKnownLocalVisionModelId() + || this.providers.getFirstKnownLocalModelId(); + if (offlineModel) { + modelId = offlineModel; + } else if (!modelId || (!this.providers.isOllamaModel(modelId) && !this.providers.isLmstudioModel(modelId))) { + return { success: false, error: 'No offline image analysis model configured. Set one in Settings → AI → Airplane Mode.' }; + } + } + if (!modelId) { return { success: false, error: 'API key not configured. Please set an API key in Settings.' }; } @@ -205,23 +208,40 @@ Remember: Only suggest mappings from NEW items to EXISTING items. Consider langu return { success: false, error: `Cannot analyze this file type: ${mediaItem.mimeType}. Only images are supported.` }; } - // Get thumbnail - let dataUrl = await this.mediaEngine.getThumbnailDataUrl(mediaId, 'large'); - if (!dataUrl) dataUrl = await this.mediaEngine.getThumbnailDataUrl(mediaId, 'medium'); + // Get AI-optimised JPEG thumbnail (512px, pre-generated). + // Falls back to large/medium WebP thumbnails for older media items. + let dataUrl = await this.mediaEngine.getThumbnailDataUrl(mediaId, 'ai'); + let needsConversion = false; + if (!dataUrl) { + dataUrl = await this.mediaEngine.getThumbnailDataUrl(mediaId, 'large'); + needsConversion = true; + } + if (!dataUrl) { + dataUrl = await this.mediaEngine.getThumbnailDataUrl(mediaId, 'medium'); + needsConversion = true; + } if (!dataUrl) { return { success: false, error: 'Image thumbnail not available. Try regenerating thumbnails from Settings.' }; } const base64Data = dataUrl.replace(/^data:image\/\w+;base64,/, ''); - const languageName = LANGUAGE_NAMES[language] || language; - const systemPrompt = `Generate title, alt text, and caption for this image in ${languageName}. + let jpegBase64: string; + if (needsConversion) { + // Legacy path: convert WebP thumbnail to JPEG for model compatibility. + const sharp = (await import('sharp')).default; + const jpegBuffer = await sharp(Buffer.from(base64Data, 'base64')) + .jpeg({ quality: 85 }) + .toBuffer(); + jpegBase64 = jpegBuffer.toString('base64'); + } else { + // Fast path: AI thumbnail is already JPEG — use directly. + jpegBase64 = base64Data; + } -TITLE: A short, descriptive title for display in lists and search results (3-8 words). Should identify the main subject. -ALT: Describe ONLY what is visually present in the image. Be factual, neutral, and concise (5-12 words max). No interpretations, emotions, or "Image of" prefix. Example: "Red bicycle leaning against white brick wall" -CAPTION: Short, engaging blog caption (5-20 words). - -Respond with JSON only: {"title": "...", "alt": "...", "caption": "..."}`; + const renderLanguage = resolveSupportedRenderLanguage(language); + const systemPrompt = translateRender(renderLanguage, 'ai.imageAnalysis.system'); + const userPrompt = translateRender(renderLanguage, 'ai.imageAnalysis.user'); try { const model = this.providers.resolveModel(modelId); @@ -233,8 +253,8 @@ Respond with JSON only: {"title": "...", "alt": "...", "caption": "..."}`; messages: [{ role: 'user', content: [ - { type: 'image', image: `data:image/webp;base64,${base64Data}` }, - { type: 'text', text: 'Analyze and respond with JSON.' }, + { type: 'image', image: `data:image/jpeg;base64,${jpegBase64}` }, + { type: 'text', text: userPrompt }, ], }], maxOutputTokens: 200, diff --git a/src/main/ipc/chatHandlers.ts b/src/main/ipc/chatHandlers.ts index be81c5a..93741bb 100644 --- a/src/main/ipc/chatHandlers.ts +++ b/src/main/ipc/chatHandlers.ts @@ -4,7 +4,7 @@ * Uses ProviderRegistry, ChatService, and OneShotTasks. */ -import { ipcMain, BrowserWindow } from 'electron'; +import { ipcMain, BrowserWindow, net } from 'electron'; import { ChatEngine } from '../engine/ChatEngine'; import { SecureKeyStore } from '../engine/SecureKeyStore'; import { ProviderRegistry } from '../engine/ai/providers'; @@ -61,6 +61,14 @@ function getProviders(): ProviderRegistry { return providers; } +/** + * Check whether airplane (offline) mode is currently active. + * Exported so other handler modules can guard network operations. + */ +export function isOfflineModeActive(): boolean { + return getProviders().isOfflineMode(); +} + /** * Get the ChatService (lazy-init). */ @@ -124,6 +132,52 @@ async function ensureInitialized(): Promise { reg.loadOllamaModelCapabilities(caps); } } catch { /* ignore */ } + + // Restore known Ollama model IDs (so offline mode works without a fresh fetch) + try { + const ollamaIds = await getChatEngine().getSetting('ollama_known_model_ids'); + if (ollamaIds) { + for (const id of JSON.parse(ollamaIds) as string[]) reg.registerOllamaModel(id); + } + } catch { /* ignore */ } + + // Restore LM Studio enabled state from settings DB + try { + const lmstudioEnabled = await getChatEngine().getSetting('lmstudio_enabled'); + if (lmstudioEnabled === 'true') reg.setLmstudioEnabled(true); + } catch { /* ignore */ } + + // Restore LM Studio model capability overrides + try { + const lmCapsJson = await getChatEngine().getSetting('lmstudio_model_capabilities'); + if (lmCapsJson) { + const caps = JSON.parse(lmCapsJson) as Record; + reg.loadLmstudioModelCapabilities(caps); + } + } catch { /* ignore */ } + + // Restore known LM Studio model IDs (so offline mode works without a fresh fetch) + try { + const lmIds = await getChatEngine().getSetting('lmstudio_known_model_ids'); + if (lmIds) { + for (const id of JSON.parse(lmIds) as string[]) reg.registerLmstudioModel(id); + } + } catch { /* ignore */ } + + // Restore offline mode from settings or auto-detect via OS network status + try { + const savedOffline = await getChatEngine().getSetting('offline_mode'); + if (savedOffline === 'true') { + reg.setOfflineMode(true); + } else if (savedOffline === null || savedOffline === undefined) { + // No explicit preference saved — auto-detect using Electron net API + const online = net.isOnline(); + if (!online && (reg.getProviderStatus().ollama || reg.getProviderStatus().lmstudio)) { + reg.setOfflineMode(true); + await getChatEngine().setSetting('offline_mode', 'true'); + } + } + } catch { /* ignore */ } })(); } await initPromise; @@ -320,6 +374,169 @@ export function registerChatHandlers(): void { } }); + // ============ LM Studio (Local) ============ + + // Get LM Studio enabled state + ipcMain.handle('chat:getLmstudioEnabled', async () => { + try { + await ensureInitialized(); + return getProviders().isLmstudioEnabled(); + } catch (error) { + console.error('[Chat IPC] Error getting LM Studio enabled state:', error); + return false; + } + }); + + // Set LM Studio enabled state + ipcMain.handle('chat:setLmstudioEnabled', async (_, enabled: boolean) => { + try { + await ensureInitialized(); + const reg = getProviders(); + reg.setLmstudioEnabled(enabled); + + // Persist to settings DB + await getChatEngine().setSetting('lmstudio_enabled', enabled ? 'true' : 'false'); + return { success: true }; + } catch (error) { + console.error('[Chat IPC] Error setting LM Studio enabled state:', error); + return { success: false, error: (error as Error).message }; + } + }); + + // Get LM Studio models (probe local server) + ipcMain.handle('chat:getLmstudioModels', async () => { + try { + await ensureInitialized(); + return await getProviders().fetchLmstudioModels(); + } catch (error) { + console.error('[Chat IPC] Error fetching LM Studio models:', error); + return []; + } + }); + + // Get LM Studio model capability overrides + ipcMain.handle('chat:getLmstudioModelCapabilities', async () => { + try { + await ensureInitialized(); + return getProviders().getAllLmstudioModelCapabilities(); + } catch (error) { + console.error('[Chat IPC] Error getting LM Studio model capabilities:', error); + return {}; + } + }); + + // Set capability overrides for a single LM Studio model + ipcMain.handle('chat:setLmstudioModelCapabilities', async (_, modelId: string, caps: { tools: boolean; vision: boolean }) => { + try { + await ensureInitialized(); + const reg = getProviders(); + reg.setLmstudioModelCapabilities(modelId, caps); + + // Persist all capabilities to settings DB + const allCaps = reg.getAllLmstudioModelCapabilities(); + await getChatEngine().setSetting('lmstudio_model_capabilities', JSON.stringify(allCaps)); + return { success: true }; + } catch (error) { + console.error('[Chat IPC] Error setting LM Studio model capabilities:', error); + return { success: false, error: (error as Error).message }; + } + }); + + // ============ Offline / Airplane Mode ============ + + ipcMain.handle('chat:getOfflineMode', async () => { + try { + await ensureInitialized(); + return getProviders().isOfflineMode(); + } catch (error) { + console.error('[Chat IPC] Error getting offline mode:', error); + return false; + } + }); + + ipcMain.handle('chat:setOfflineMode', async (_, enabled: boolean) => { + try { + await ensureInitialized(); + const reg = getProviders(); + reg.setOfflineMode(enabled); + await getChatEngine().setSetting('offline_mode', enabled ? 'true' : 'false'); + return { success: true }; + } catch (error) { + console.error('[Chat IPC] Error setting offline mode:', error); + return { success: false, error: (error as Error).message }; + } + }); + + ipcMain.handle('chat:getKnownLocalModels', async () => { + try { + await ensureInitialized(); + return getProviders().getKnownLocalModels(); + } catch (error) { + console.error('[Chat IPC] Error getting known local models:', error); + return []; + } + }); + + ipcMain.handle('chat:getOfflineChatModel', async () => { + try { + const model = await getChatEngine().getSetting('offline_chat_model'); + return { success: true, modelId: model || null }; + } catch (error) { + console.error('[Chat IPC] Error getting offline chat model:', error); + return { success: false, modelId: null }; + } + }); + + ipcMain.handle('chat:setOfflineChatModel', async (_, modelId: string | null) => { + try { + await getChatEngine().setSetting('offline_chat_model', modelId ?? ''); + return { success: true }; + } catch (error) { + console.error('[Chat IPC] Error setting offline chat model:', error); + return { success: false, error: (error as Error).message }; + } + }); + + ipcMain.handle('chat:getOfflineTitleModel', async () => { + try { + const model = await getChatEngine().getSetting('offline_title_model'); + return { success: true, modelId: model || null }; + } catch (error) { + console.error('[Chat IPC] Error getting offline title model:', error); + return { success: false, modelId: null }; + } + }); + + ipcMain.handle('chat:setOfflineTitleModel', async (_, modelId: string | null) => { + try { + await getChatEngine().setSetting('offline_title_model', modelId ?? ''); + return { success: true }; + } catch (error) { + console.error('[Chat IPC] Error setting offline title model:', error); + return { success: false, error: (error as Error).message }; + } + }); + + ipcMain.handle('chat:getOfflineImageAnalysisModel', async () => { + try { + const model = await getChatEngine().getSetting('offline_image_analysis_model'); + return { success: true, modelId: model || null }; + } catch (error) { + console.error('[Chat IPC] Error getting offline image analysis model:', error); + return { success: false, modelId: null }; + } + }); + + ipcMain.handle('chat:setOfflineImageAnalysisModel', async (_, modelId: string | null) => { + try { + await getChatEngine().setSetting('offline_image_analysis_model', modelId ?? ''); + return { success: true }; + } catch (error) { + console.error('[Chat IPC] Error setting offline image analysis model:', error); + return { success: false, error: (error as Error).message }; + } + }); + // ============ Per-Purpose Model Preferences ============ // Get title generation model @@ -376,9 +593,21 @@ export function registerChatHandlers(): void { ipcMain.handle('chat:getAvailableModels', async () => { try { await ensureInitialized(); - const models = await getProviders().getAvailableModels(); + const reg = getProviders(); + const models = await reg.getAvailableModels(); const engine = getChatEngine(); const selectedModel = await engine.getSelectedModel(); + + // Persist known local model IDs so offline mode survives restarts + const ollamaModels = models.filter(m => m.provider === 'ollama').map(m => m.id); + const lmstudioModels = models.filter(m => m.provider === 'lmstudio').map(m => m.id); + if (ollamaModels.length > 0) { + await engine.setSetting('ollama_known_model_ids', JSON.stringify(ollamaModels)).catch(() => {}); + } + if (lmstudioModels.length > 0) { + await engine.setSetting('lmstudio_known_model_ids', JSON.stringify(lmstudioModels)).catch(() => {}); + } + return { success: true, models, selectedModel }; } catch (error) { console.error('[Chat IPC] Error getting models:', error); diff --git a/src/main/ipc/handlers.ts b/src/main/ipc/handlers.ts index 21f91f0..fe008b2 100644 --- a/src/main/ipc/handlers.ts +++ b/src/main/ipc/handlers.ts @@ -17,6 +17,7 @@ import { generateBlogmarkBookmarkletSource } from '../shared/blogmark'; import { registerMetadataDiffHandlers } from './metadataDiffHandlers'; import { registerBlogHandlers } from './blogHandlers'; import { registerPublishHandlers } from './publishHandlers'; +import { isOfflineModeActive } from './chatHandlers'; import type { EngineBundle } from '../engine/EngineBundle'; /** @@ -179,16 +180,25 @@ export function registerIpcHandlers(bundle: EngineBundle): void { }); safeHandle('git:remoteState', async (_, projectPath: string) => { + if (isOfflineModeActive()) { + return { ahead: 0, behind: 0 }; + } const engine = bundle.gitEngine; return engine.getRemoteState(projectPath); }); safeHandle('git:fetch', async (_, projectPath: string) => { + if (isOfflineModeActive()) { + return { success: false, code: 'offline' }; + } const engine = bundle.gitEngine; return engine.fetch(projectPath); }); safeHandle('git:pull', async (_, projectPath: string) => { + if (isOfflineModeActive()) { + return { success: false, code: 'offline' }; + } const engine = bundle.gitEngine; const beforeHead = await engine.getHeadCommit(projectPath); const pullResult = await engine.pull(projectPath); @@ -244,6 +254,9 @@ export function registerIpcHandlers(bundle: EngineBundle): void { }); safeHandle('git:push', async (_, projectPath: string) => { + if (isOfflineModeActive()) { + return { success: false, code: 'offline' }; + } const engine = bundle.gitEngine; return engine.push(projectPath); }); diff --git a/src/main/ipc/publishHandlers.ts b/src/main/ipc/publishHandlers.ts index f242dd3..4ceb14a 100644 --- a/src/main/ipc/publishHandlers.ts +++ b/src/main/ipc/publishHandlers.ts @@ -1,10 +1,15 @@ import type { PublishCredentials } from '../engine/PublishEngine'; import type { EngineBundle } from '../engine/EngineBundle'; +import { isOfflineModeActive } from './chatHandlers'; type SafeHandle = (channel: string, handler: (...args: any[]) => Promise) => void; export function registerPublishHandlers(safeHandle: SafeHandle, bundle: EngineBundle): void { safeHandle('publish:uploadSite', async (_event: unknown, credentials: PublishCredentials) => { + if (isOfflineModeActive()) { + throw new Error('Airplane mode is active. Disable it to upload the site.'); + } + const projectEngine = bundle.projectEngine; const project = await projectEngine.getActiveProject(); if (!project) { diff --git a/src/main/preload.ts b/src/main/preload.ts index 9820891..4b3263b 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -321,6 +321,24 @@ export const electronAPI: ElectronAPI = { getOllamaModelCapabilities: () => ipcRenderer.invoke('chat:getOllamaModelCapabilities'), setOllamaModelCapabilities: (modelId: string, caps: { tools: boolean; vision: boolean }) => ipcRenderer.invoke('chat:setOllamaModelCapabilities', modelId, caps), + // LM Studio (Local) + getLmstudioEnabled: () => ipcRenderer.invoke('chat:getLmstudioEnabled'), + setLmstudioEnabled: (enabled: boolean) => ipcRenderer.invoke('chat:setLmstudioEnabled', enabled), + getLmstudioModels: () => ipcRenderer.invoke('chat:getLmstudioModels'), + getLmstudioModelCapabilities: () => ipcRenderer.invoke('chat:getLmstudioModelCapabilities'), + setLmstudioModelCapabilities: (modelId: string, caps: { tools: boolean; vision: boolean }) => ipcRenderer.invoke('chat:setLmstudioModelCapabilities', modelId, caps), + + // Offline / Airplane Mode + getOfflineMode: () => ipcRenderer.invoke('chat:getOfflineMode'), + setOfflineMode: (enabled: boolean) => ipcRenderer.invoke('chat:setOfflineMode', enabled), + getOfflineChatModel: () => ipcRenderer.invoke('chat:getOfflineChatModel'), + setOfflineChatModel: (modelId: string | null) => ipcRenderer.invoke('chat:setOfflineChatModel', modelId), + getOfflineTitleModel: () => ipcRenderer.invoke('chat:getOfflineTitleModel'), + setOfflineTitleModel: (modelId: string | null) => ipcRenderer.invoke('chat:setOfflineTitleModel', modelId), + getOfflineImageAnalysisModel: () => ipcRenderer.invoke('chat:getOfflineImageAnalysisModel'), + setOfflineImageAnalysisModel: (modelId: string | null) => ipcRenderer.invoke('chat:setOfflineImageAnalysisModel', modelId), + getKnownLocalModels: () => ipcRenderer.invoke('chat:getKnownLocalModels'), + // Per-Purpose Model Preferences getTitleModel: () => ipcRenderer.invoke('chat:getTitleModel'), setTitleModel: (modelId: string | null) => ipcRenderer.invoke('chat:setTitleModel', modelId), diff --git a/src/main/shared/electronApi.ts b/src/main/shared/electronApi.ts index b9f8d43..581f531 100644 --- a/src/main/shared/electronApi.ts +++ b/src/main/shared/electronApi.ts @@ -384,7 +384,7 @@ export interface GitLfsPruneResult { export interface GitActionResult { success: boolean; - code?: 'auth-required' | 'conflict' | 'network' | 'action-failed'; + code?: 'auth-required' | 'conflict' | 'network' | 'action-failed' | 'offline'; error?: string; guidance?: string[]; } @@ -451,7 +451,7 @@ export interface ChatReadyStatus { ready: boolean; error?: string; backend?: string; - providers?: { opencode: boolean; mistral: boolean; ollama: boolean }; + providers?: { opencode: boolean; mistral: boolean; ollama: boolean; lmstudio: boolean; offlineMode: boolean }; } export interface ChatApiKeyStatus { @@ -839,6 +839,24 @@ export interface ElectronAPI { getOllamaModelCapabilities: () => Promise>; setOllamaModelCapabilities: (modelId: string, caps: { tools: boolean; vision: boolean }) => Promise<{ success: boolean; error?: string }>; + // LM Studio (local) + getLmstudioEnabled: () => Promise; + setLmstudioEnabled: (enabled: boolean) => Promise<{ success: boolean; error?: string }>; + getLmstudioModels: () => Promise; + getLmstudioModelCapabilities: () => Promise>; + setLmstudioModelCapabilities: (modelId: string, caps: { tools: boolean; vision: boolean }) => Promise<{ success: boolean; error?: string }>; + + // Offline / Airplane mode + getOfflineMode: () => Promise; + setOfflineMode: (enabled: boolean) => Promise<{ success: boolean; error?: string }>; + getOfflineChatModel: () => Promise<{ success: boolean; modelId?: string | null }>; + setOfflineChatModel: (modelId: string | null) => Promise<{ success: boolean; error?: string }>; + getOfflineTitleModel: () => Promise<{ success: boolean; modelId?: string | null }>; + setOfflineTitleModel: (modelId: string | null) => Promise<{ success: boolean; error?: string }>; + getOfflineImageAnalysisModel: () => Promise<{ success: boolean; modelId?: string | null }>; + setOfflineImageAnalysisModel: (modelId: string | null) => Promise<{ success: boolean; error?: string }>; + getKnownLocalModels: () => Promise; + // Settings getAvailableModels: () => Promise<{ success: boolean; models?: ChatModel[]; selectedModel?: string; error?: string }>; setDefaultModel: (modelId: string) => Promise<{ success: boolean; error?: string }>; diff --git a/src/main/shared/i18n/locales/de.json b/src/main/shared/i18n/locales/de.json index cf5e030..bafec48 100644 --- a/src/main/shared/i18n/locales/de.json +++ b/src/main/shared/i18n/locales/de.json @@ -78,5 +78,7 @@ "render.month.9": "Sept.", "render.month.10": "Oktober", "render.month.11": "Nov.", - "render.month.12": "Dezember" + "render.month.12": "Dezember", + "ai.imageAnalysis.system": "Du erzeugst Bild-Metadaten. Schreibe alle Werte auf Deutsch.\n\nRegeln:\n- \"title\": kurzer beschreibender Titel (3-8 Wörter)\n- \"alt\": sachliche Beschreibung des Sichtbaren (5-12 Wörter). Keine Interpretationen. Kein Präfix \"Bild von\".\n- \"caption\": ansprechende Blog-Bildunterschrift (5-20 Wörter)\n\nAntworte ausschließlich mit JSON: {\"title\": \"...\", \"alt\": \"...\", \"caption\": \"...\"}", + "ai.imageAnalysis.user": "Analysiere dieses Bild. Antworte mit JSON auf Deutsch." } diff --git a/src/main/shared/i18n/locales/en.json b/src/main/shared/i18n/locales/en.json index 002d152..eb16698 100644 --- a/src/main/shared/i18n/locales/en.json +++ b/src/main/shared/i18n/locales/en.json @@ -78,5 +78,7 @@ "render.month.9": "September", "render.month.10": "October", "render.month.11": "November", - "render.month.12": "December" + "render.month.12": "December", + "ai.imageAnalysis.system": "You generate image metadata. Write all values in English.\n\nRules:\n- \"title\": short descriptive title (3-8 words)\n- \"alt\": factual description of what is visible (5-12 words). No interpretations. No \"Image of\" prefix.\n- \"caption\": engaging blog caption (5-20 words)\n\nRespond with JSON only: {\"title\": \"...\", \"alt\": \"...\", \"caption\": \"...\"}", + "ai.imageAnalysis.user": "Analyze this image. Respond with JSON in English." } diff --git a/src/main/shared/i18n/locales/es.json b/src/main/shared/i18n/locales/es.json index 43e86da..a60da8b 100644 --- a/src/main/shared/i18n/locales/es.json +++ b/src/main/shared/i18n/locales/es.json @@ -78,5 +78,7 @@ "render.month.9": "septiembre", "render.month.10": "octubre", "render.month.11": "noviembre", - "render.month.12": "diciembre" + "render.month.12": "diciembre", + "ai.imageAnalysis.system": "Generas metadatos de imagen. Escribe todos los valores en español.\n\nReglas:\n- \"title\": título descriptivo corto (3-8 palabras)\n- \"alt\": descripción factual de lo visible (5-12 palabras). Sin interpretaciones. Sin prefijo \"Imagen de\".\n- \"caption\": pie de foto atractivo para blog (5-20 palabras)\n\nResponde solo con JSON: {\"title\": \"...\", \"alt\": \"...\", \"caption\": \"...\"}", + "ai.imageAnalysis.user": "Analiza esta imagen. Responde con JSON en español." } diff --git a/src/main/shared/i18n/locales/fr.json b/src/main/shared/i18n/locales/fr.json index d51ed1e..cb2b0dc 100644 --- a/src/main/shared/i18n/locales/fr.json +++ b/src/main/shared/i18n/locales/fr.json @@ -78,5 +78,7 @@ "render.month.9": "septembre", "render.month.10": "octobre", "render.month.11": "novembre", - "render.month.12": "décembre" + "render.month.12": "décembre", + "ai.imageAnalysis.system": "Tu génères des métadonnées d'image. Écris toutes les valeurs en français.\n\nRègles :\n- \"title\" : titre descriptif court (3-8 mots)\n- \"alt\" : description factuelle de ce qui est visible (5-12 mots). Pas d'interprétations. Pas de préfixe \"Image de\".\n- \"caption\" : légende de blog engageante (5-20 mots)\n\nRéponds uniquement en JSON : {\"title\": \"...\", \"alt\": \"...\", \"caption\": \"...\"}", + "ai.imageAnalysis.user": "Analyse cette image. Réponds en JSON en français." } diff --git a/src/main/shared/i18n/locales/it.json b/src/main/shared/i18n/locales/it.json index afb7ecb..c66b2dd 100644 --- a/src/main/shared/i18n/locales/it.json +++ b/src/main/shared/i18n/locales/it.json @@ -78,5 +78,7 @@ "render.month.9": "settembre", "render.month.10": "ottobre", "render.month.11": "novembre", - "render.month.12": "dicembre" + "render.month.12": "dicembre", + "ai.imageAnalysis.system": "Generi metadati per immagini. Scrivi tutti i valori in italiano.\n\nRegole:\n- \"title\": titolo descrittivo breve (3-8 parole)\n- \"alt\": descrizione fattuale di ciò che è visibile (5-12 parole). Nessuna interpretazione. Nessun prefisso \"Immagine di\".\n- \"caption\": didascalia blog coinvolgente (5-20 parole)\n\nRispondi solo con JSON: {\"title\": \"...\", \"alt\": \"...\", \"caption\": \"...\"}", + "ai.imageAnalysis.user": "Analizza questa immagine. Rispondi con JSON in italiano." } diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index fa5302a..dba8472 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -520,9 +520,13 @@ const App: React.FC = () => { return; } await window.electronAPI?.publish.uploadSite(prefs); - } catch (error) { + } catch (error: any) { console.error('Site upload failed:', error); - showToast.error(tr('app.uploadSiteFailed')); + if (error?.message?.includes('Airplane mode')) { + useAppStore.getState().showErrorModal({ message: tr('app.uploadSiteOfflineMode') }); + } else { + showToast.error(tr('app.uploadSiteFailed')); + } } }) || (() => {}) ); diff --git a/src/renderer/components/ErrorModal/ErrorModal.tsx b/src/renderer/components/ErrorModal/ErrorModal.tsx index 7c94557..2f1e3e7 100644 --- a/src/renderer/components/ErrorModal/ErrorModal.tsx +++ b/src/renderer/components/ErrorModal/ErrorModal.tsx @@ -15,9 +15,9 @@ interface ErrorModalProps { export const ErrorModal: React.FC = ({ 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 = ({ error, onClose }) => { } }, [onClose]); + if (!error) return null; + return (
diff --git a/src/renderer/components/GitSidebar/GitSidebar.tsx b/src/renderer/components/GitSidebar/GitSidebar.tsx index 670113b..0d22ad7 100644 --- a/src/renderer/components/GitSidebar/GitSidebar.tsx +++ b/src/renderer/components/GitSidebar/GitSidebar.tsx @@ -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(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; diff --git a/src/renderer/components/SettingsView/SettingsView.css b/src/renderer/components/SettingsView/SettingsView.css index faf0334..29ac505 100644 --- a/src/renderer/components/SettingsView/SettingsView.css +++ b/src/renderer/components/SettingsView/SettingsView.css @@ -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; +} diff --git a/src/renderer/components/SettingsView/SettingsView.tsx b/src/renderer/components/SettingsView/SettingsView.tsx index 76edc83..1476cbc 100644 --- a/src/renderer/components/SettingsView/SettingsView.tsx +++ b/src/renderer/components/SettingsView/SettingsView.tsx @@ -248,6 +248,14 @@ export const SettingsView: React.FC = () => { const [ollamaEnabled, setOllamaEnabled] = useState(false); const [ollamaCapabilities, setOllamaCapabilities] = useState>({}); const [ollamaModels, setOllamaModels] = useState<{id: string; name: string}[]>([]); + const [lmstudioEnabled, setLmstudioEnabled] = useState(false); + const [lmstudioCapabilities, setLmstudioCapabilities] = useState>({}); + 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 = () => { )} + +
+ + {lmstudioEnabled && ( + {t('settings.ai.configured')} + )} +
+ {lmstudioEnabled && lmstudioModels.length > 0 && ( +
+ {t('settings.ai.lmstudioCapabilitiesDescription')} + + + + + + + + + + {lmstudioModels.map(m => { + const caps = lmstudioCapabilities[m.id] ?? { tools: false, vision: false }; + return ( + + + + + + ); + })} + +
{t('settings.ai.lmstudioCapModel')}{t('settings.ai.lmstudioCapTools')}{t('settings.ai.lmstudioCapVision')}
{m.name} + handleLmstudioCapabilityToggle(m.id, 'tools', e.target.checked)} + /> + + handleLmstudioCapabilityToggle(m.id, 'vision', e.target.checked)} + /> +
+
+ )} +
+ + +
+ + {offlineModeEnabled && ( + {t('settings.ai.configured')} + )} +
+ {!ollamaEnabled && !lmstudioEnabled && ( + {t('settings.ai.offlineNoLocalProviders')} + )} + {offlineModeEnabled && (ollamaEnabled || lmstudioEnabled) && ( +
+
+ + {t('settings.ai.offlineChatModelDescription')} + {renderModelSelect('ai-offline-chat-model', offlineChatModel, handleOfflineChatModelChange, false, groupedLocalModels)} +
+
+ + {t('settings.ai.offlineTitleModelDescription')} + {renderModelSelect('ai-offline-title-model', offlineTitleModel, handleOfflineTitleModelChange, false, groupedLocalModels)} +
+
+ + {t('settings.ai.offlineImageAnalysisModelDescription')} + {renderModelSelect('ai-offline-image-model', offlineImageAnalysisModel, handleOfflineImageAnalysisModelChange, false, groupedLocalVisionModels)} +
+
+ )} +
+
- {renderModelSelect('ai-model', selectedModel, handleModelChange, !aiHasApiKey && !aiHasMistralKey && !ollamaEnabled)} + {renderModelSelect('ai-model', selectedModel, handleModelChange, !aiHasApiKey && !aiHasMistralKey && !ollamaEnabled && !lmstudioEnabled)}
+
{ if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggleOfflineMode(); } }} + > + +
+
{t('statusBar.ui')}