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:
@@ -21,7 +21,7 @@ Requires threading `currentPostId` from `Editor.tsx` → `InsertModal` (currentl
|
|||||||
| Embeddings | Hugging Face Transformers.js | `@huggingface/transformers` | ONNX, local, no API key |
|
| Embeddings | Hugging Face Transformers.js | `@huggingface/transformers` | ONNX, local, no API key |
|
||||||
| Vector index | USearch | `usearch` | HNSW, native C++ via N-API, prebuilt binaries |
|
| 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:**
|
**Why USearch over alternatives:**
|
||||||
- `sqlite-vec` — requires `loadExtension()` on the SQLite driver; bDS uses `@libsql/client` which doesn't expose it. Eliminated.
|
- `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** — 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:**
|
**USearch specifics:**
|
||||||
- Keys are `BigUint64Array` — need a `Map<bigint, string>` (numeric label → post UUID) persisted alongside the index
|
- Keys are `BigUint64Array` — need a `Map<bigint, string>` (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.
|
- `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
|
- 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
|
## Architecture
|
||||||
@@ -42,11 +44,12 @@ Requires threading `currentPostId` from `Editor.tsx` → `InsertModal` (currentl
|
|||||||
### Files on disk
|
### Files on disk
|
||||||
|
|
||||||
```
|
```
|
||||||
<project-dir>/.bds/
|
{userData}/projects/{projectId}/
|
||||||
embeddings.usearch # USearch binary index
|
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`)
|
### Engine: `EmbeddingEngine` (`src/main/engine/EmbeddingEngine.ts`)
|
||||||
|
|
||||||
Responsibilities:
|
Responsibilities:
|
||||||
@@ -63,10 +66,16 @@ class EmbeddingEngine {
|
|||||||
async removePost(postId: string): Promise<void>
|
async removePost(postId: string): Promise<void>
|
||||||
async findSimilar(postId: string, k?: number): Promise<SimilarPost[]>
|
async findSimilar(postId: string, k?: number): Promise<SimilarPost[]>
|
||||||
async getIndexingProgress(): Promise<{ indexed: number; total: number }>
|
async getIndexingProgress(): Promise<{ indexed: number; total: number }>
|
||||||
|
async reindexAll(): Promise<void> // after databaseRebuilt
|
||||||
|
async setProjectContext(projectId: string): Promise<void> // load/unload on switch
|
||||||
async save(): Promise<void>
|
async save(): Promise<void>
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### 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
|
### IPC
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -74,9 +83,25 @@ embeddings:findSimilar(postId: string, k?: number) → SimilarPost[]
|
|||||||
embeddings:getProgress() → { indexed: number; total: number }
|
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
|
### 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)
|
### 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
|
- Must run as a low-priority background task after app startup
|
||||||
- Emit progress events so UI can show "Indexing 3,421 / 10,247…"
|
- 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
|
- 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
|
## Implementation Steps
|
||||||
|
|
||||||
1. **Test + implement `EmbeddingEngine`** — model loading, embed, add/remove/query against USearch index, save/load persistence
|
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
|
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
|
4. **Background indexer** — on startup, diff indexed vs. existing posts, queue unindexed for background embedding with progress events
|
||||||
5. **IPC endpoints** — `findSimilar`, `getProgress`
|
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)
|
- Feature must be opt-in (model download + 17 min indexing is not a silent default)
|
||||||
- No external API calls — fully local
|
- No external API calls — fully local
|
||||||
- Model cached in `~/.cache/huggingface/`, index in project `.bds/` directory
|
- Model cached in `~/.cache/huggingface/`, index in internal project directory
|
||||||
- .bds/ directory inside project directory must be added to .gitignore (cache is kept local not versioned)
|
- Total added footprint: ~520 MB on disk (onnxruntime-node ~50 MB + model ~470 MB), ~300 MB RAM at runtime for model + index
|
||||||
- Total added footprint: ~140 MB on disk (onnxruntime-node ~50 MB + model ~90 MB), ~200 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
|
||||||
|
|||||||
@@ -129,7 +129,7 @@ export interface GitLfsPruneResult {
|
|||||||
|
|
||||||
export interface GitActionResult {
|
export interface GitActionResult {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
code?: 'auth-required' | 'conflict' | 'network' | 'action-failed';
|
code?: 'auth-required' | 'conflict' | 'network' | 'action-failed' | 'offline';
|
||||||
error?: string;
|
error?: string;
|
||||||
guidance?: string[];
|
guidance?: string[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,11 +10,12 @@ import { media, Media, NewMedia, postMedia } from '../database/schema';
|
|||||||
import { stemText, stemQuery, SupportedLanguage } from './stemmer';
|
import { stemText, stemQuery, SupportedLanguage } from './stemmer';
|
||||||
import { CliNotifier, NoopNotifier } from './CliNotifier';
|
import { CliNotifier, NoopNotifier } from './CliNotifier';
|
||||||
|
|
||||||
// Thumbnail sizes
|
// Thumbnail sizes — 'ai' is a dedicated JPEG thumbnail for vision-model input
|
||||||
const THUMBNAIL_SIZES = {
|
const THUMBNAIL_SIZES = {
|
||||||
small: { width: 150, height: 150 },
|
small: { width: 150, height: 150, ext: 'webp' as const, mime: 'image/webp' as const },
|
||||||
medium: { width: 400, height: 400 },
|
medium: { width: 400, height: 400, ext: 'webp' as const, mime: 'image/webp' as const },
|
||||||
large: { width: 800, height: 800 },
|
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;
|
} as const;
|
||||||
|
|
||||||
type ThumbnailSize = keyof typeof THUMBNAIL_SIZES;
|
type ThumbnailSize = keyof typeof THUMBNAIL_SIZES;
|
||||||
@@ -244,17 +245,26 @@ export class MediaEngine extends EventEmitter {
|
|||||||
// Dynamic import of sharp (it's a native module)
|
// Dynamic import of sharp (it's a native module)
|
||||||
const sharp = (await import('sharp')).default;
|
const sharp = (await import('sharp')).default;
|
||||||
|
|
||||||
for (const [size, dimensions] of Object.entries(THUMBNAIL_SIZES) as [ThumbnailSize, { width: number; height: number }][]) {
|
for (const [size, config] of Object.entries(THUMBNAIL_SIZES) as [ThumbnailSize, (typeof THUMBNAIL_SIZES)[ThumbnailSize]][]) {
|
||||||
const thumbnailPath = path.join(thumbnailSubDir, `${mediaId}-${size}.webp`);
|
const thumbnailPath = path.join(thumbnailSubDir, `${mediaId}-${size}.${config.ext}`);
|
||||||
|
|
||||||
await sharp(sourcePath)
|
// AI thumbnail: exact 448×448 with black letterboxing for vision models.
|
||||||
.resize(dimensions.width, dimensions.height, {
|
// All others: fit inside bounding box, no upscaling.
|
||||||
fit: 'inside',
|
const isAI = size === 'ai';
|
||||||
withoutEnlargement: true,
|
let pipeline = sharp(sourcePath)
|
||||||
})
|
.resize(config.width, config.height, {
|
||||||
.webp({ quality: 80 })
|
fit: isAI ? 'contain' : 'inside',
|
||||||
.toFile(thumbnailPath);
|
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;
|
thumbnails[size] = thumbnailPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -276,10 +286,11 @@ export class MediaEngine extends EventEmitter {
|
|||||||
small: null,
|
small: null,
|
||||||
medium: null,
|
medium: null,
|
||||||
large: null,
|
large: null,
|
||||||
|
ai: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
for (const size of Object.keys(THUMBNAIL_SIZES) as ThumbnailSize[]) {
|
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 {
|
try {
|
||||||
await fs.access(thumbnailPath);
|
await fs.access(thumbnailPath);
|
||||||
result[size] = thumbnailPath;
|
result[size] = thumbnailPath;
|
||||||
@@ -296,11 +307,12 @@ export class MediaEngine extends EventEmitter {
|
|||||||
*/
|
*/
|
||||||
async getThumbnailDataUrl(mediaId: string, size: ThumbnailSize = 'small'): Promise<string | null> {
|
async getThumbnailDataUrl(mediaId: string, size: ThumbnailSize = 'small'): Promise<string | null> {
|
||||||
const thumbnailSubDir = this.getThumbnailSubDir(mediaId);
|
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 {
|
try {
|
||||||
const data = await fs.readFile(thumbnailPath);
|
const data = await fs.readFile(thumbnailPath);
|
||||||
return `data:image/webp;base64,${data.toString('base64')}`;
|
return `data:${config.mime};base64,${data.toString('base64')}`;
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -313,7 +325,7 @@ export class MediaEngine extends EventEmitter {
|
|||||||
const thumbnailSubDir = this.getThumbnailSubDir(mediaId);
|
const thumbnailSubDir = this.getThumbnailSubDir(mediaId);
|
||||||
|
|
||||||
for (const size of Object.keys(THUMBNAIL_SIZES) as ThumbnailSize[]) {
|
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 {
|
try {
|
||||||
await fs.unlink(thumbnailPath);
|
await fs.unlink(thumbnailPath);
|
||||||
} catch {
|
} catch {
|
||||||
@@ -1166,7 +1178,7 @@ export class MediaEngine extends EventEmitter {
|
|||||||
for (const item of imageMedia) {
|
for (const item of imageMedia) {
|
||||||
const thumbnails = await this.getThumbnailPaths(item.id);
|
const thumbnails = await this.getThumbnailPaths(item.id);
|
||||||
// Consider missing if any size is missing
|
// 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);
|
missingThumbnails.push(item);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -246,7 +246,21 @@ export class ChatService {
|
|||||||
const abortController = new AbortController();
|
const abortController = new AbortController();
|
||||||
this.abortControllers.set(conversationId, 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);
|
const provider = this.providers.detectModelProvider(modelId);
|
||||||
|
|
||||||
// Verify provider key is available
|
// Verify provider key is available
|
||||||
@@ -271,9 +285,11 @@ export class ChatService {
|
|||||||
|
|
||||||
const aiMessages = dbMessagesToAIMessages(dbMessages);
|
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 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 blogTools = skipTools ? {} : createBlogTools(this.blogToolDeps);
|
||||||
const a2uiToolsRaw = skipTools ? {} : createA2UITools();
|
const a2uiToolsRaw = skipTools ? {} : createA2UITools();
|
||||||
const allTools = { ...blogTools, ...a2uiToolsRaw };
|
const allTools = { ...blogTools, ...a2uiToolsRaw };
|
||||||
@@ -447,6 +463,18 @@ export class ChatService {
|
|||||||
? 'mistral-small-latest'
|
? 'mistral-small-latest'
|
||||||
: null;
|
: 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;
|
if (!titleModel) return;
|
||||||
|
|
||||||
const model = this.providers.resolveModel(titleModel);
|
const model = this.providers.resolveModel(titleModel);
|
||||||
|
|||||||
@@ -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 MISTRAL_MODELS_URL = 'https://api.mistral.ai/v1/models';
|
||||||
export const OLLAMA_BASE_URL = 'http://localhost:11434/v1';
|
export const OLLAMA_BASE_URL = 'http://localhost:11434/v1';
|
||||||
export const OLLAMA_TAGS_URL = 'http://localhost:11434/api/tags';
|
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 MODEL_CACHE_TTL = 5 * 60 * 1000; // 5 minutes
|
||||||
const OLLAMA_FETCH_TIMEOUT = 3000; // 3 s — fail fast when Ollama isn't running
|
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
|
// Gateway factory
|
||||||
@@ -108,12 +111,28 @@ export class ProviderRegistry {
|
|||||||
private ollamaProvider: ReturnType<typeof createOpenAI> | null = null;
|
private ollamaProvider: ReturnType<typeof createOpenAI> | null = null;
|
||||||
private ollamaModelIds = new Set<string>();
|
private ollamaModelIds = new Set<string>();
|
||||||
private ollamaCapabilities = new Map<string, { tools: boolean; vision: boolean }>();
|
private ollamaCapabilities = new Map<string, { tools: boolean; vision: boolean }>();
|
||||||
|
private lmstudioEnabled = false;
|
||||||
|
private lmstudioProvider: ReturnType<typeof createOpenAI> | null = null;
|
||||||
|
private lmstudioModelIds = new Set<string>();
|
||||||
|
private lmstudioCapabilities = new Map<string, { tools: boolean; vision: boolean }>();
|
||||||
private modelCatalogEngine = new ModelCatalogEngine();
|
private modelCatalogEngine = new ModelCatalogEngine();
|
||||||
|
private _offlineMode = false;
|
||||||
|
|
||||||
// Model cache
|
// Model cache
|
||||||
private cachedModels: ChatModel[] | null = null;
|
private cachedModels: ChatModel[] | null = null;
|
||||||
private cachedModelsAt = 0;
|
private cachedModelsAt = 0;
|
||||||
|
|
||||||
|
// ---- Offline / airplane mode ----
|
||||||
|
|
||||||
|
setOfflineMode(enabled: boolean): void {
|
||||||
|
this._offlineMode = enabled;
|
||||||
|
this.invalidateModelCache();
|
||||||
|
}
|
||||||
|
|
||||||
|
isOfflineMode(): boolean {
|
||||||
|
return this._offlineMode;
|
||||||
|
}
|
||||||
|
|
||||||
// ---- Key management ----
|
// ---- Key management ----
|
||||||
|
|
||||||
setOpencodeKey(key: string): void {
|
setOpencodeKey(key: string): void {
|
||||||
@@ -203,33 +222,109 @@ export class ProviderRegistry {
|
|||||||
return this.ollamaCapabilities.get(modelId)?.vision ?? false;
|
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<string, { tools: boolean; vision: boolean }> {
|
||||||
|
const result: Record<string, { tools: boolean; vision: boolean }> = {};
|
||||||
|
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<string, { tools: boolean; vision: boolean }>): 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.
|
* registration first, then falling back to prefix-based detection.
|
||||||
*/
|
*/
|
||||||
detectModelProvider(modelId: string): string {
|
detectModelProvider(modelId: string): string {
|
||||||
if (this.ollamaModelIds.has(modelId)) return 'ollama';
|
if (this.ollamaModelIds.has(modelId)) return 'ollama';
|
||||||
|
if (this.lmstudioModelIds.has(modelId)) return 'lmstudio';
|
||||||
return detectProvider(modelId);
|
return detectProvider(modelId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Check whether at least one provider key is configured. */
|
/** Check whether at least one provider key is configured. */
|
||||||
isReady(): boolean {
|
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. */
|
/** Check whether the key for a specific provider is set. */
|
||||||
isProviderKeySet(provider: string): boolean {
|
isProviderKeySet(provider: string): boolean {
|
||||||
if (provider === 'mistral') return !!this.mistralKey;
|
|
||||||
if (provider === 'ollama') return this.ollamaEnabled;
|
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;
|
return !!this.opencodeKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Returns status of all configured providers. */
|
/** 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 {
|
return {
|
||||||
opencode: !!this.opencodeKey,
|
opencode: !!this.opencodeKey,
|
||||||
mistral: !!this.mistralKey,
|
mistral: !!this.mistralKey,
|
||||||
ollama: this.ollamaEnabled,
|
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. */
|
/** Resolve a model ID to an AI SDK LanguageModel. */
|
||||||
resolveModel(modelId: string): 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
|
// Check if this is a registered Ollama model first
|
||||||
if (this.ollamaModelIds.has(modelId)) {
|
if (this.ollamaModelIds.has(modelId)) {
|
||||||
if (!this.ollamaEnabled) {
|
if (!this.ollamaEnabled) {
|
||||||
@@ -251,6 +351,20 @@ export class ProviderRegistry {
|
|||||||
return this.ollamaProvider.chat(modelId);
|
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);
|
const provider = detectProvider(modelId);
|
||||||
|
|
||||||
if (provider === 'mistral') {
|
if (provider === 'mistral') {
|
||||||
@@ -285,18 +399,66 @@ export class ProviderRegistry {
|
|||||||
return this.modelCatalogEngine;
|
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). */
|
/** Get available models across all configured providers (cached 5 min). */
|
||||||
async getAvailableModels(): Promise<ChatModel[]> {
|
async getAvailableModels(): Promise<ChatModel[]> {
|
||||||
if (this.cachedModels && Date.now() - this.cachedModelsAt < MODEL_CACHE_TTL) {
|
if (this.cachedModels && Date.now() - this.cachedModelsAt < MODEL_CACHE_TTL) {
|
||||||
return this.cachedModels;
|
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[] = [];
|
const allModels: ChatModel[] = [];
|
||||||
let fetched = false;
|
let fetched = false;
|
||||||
const { vision: catalogVision, names: catalogNames } = await this.getCatalogLookups();
|
const { vision: catalogVision, names: catalogNames } = await this.getCatalogLookups();
|
||||||
|
|
||||||
// Fetch OpenCode models
|
// Fetch OpenCode models (skip in offline mode)
|
||||||
if (this.opencodeKey) {
|
if (this.opencodeKey && !this._offlineMode) {
|
||||||
try {
|
try {
|
||||||
const models = await this.fetchModelsFromEndpoint(
|
const models = await this.fetchModelsFromEndpoint(
|
||||||
ZEN_MODELS_URL,
|
ZEN_MODELS_URL,
|
||||||
@@ -311,8 +473,8 @@ export class ProviderRegistry {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch Mistral models
|
// Fetch Mistral models (skip in offline mode)
|
||||||
if (this.mistralKey) {
|
if (this.mistralKey && !this._offlineMode) {
|
||||||
try {
|
try {
|
||||||
const models = await this.fetchModelsFromEndpoint(
|
const models = await this.fetchModelsFromEndpoint(
|
||||||
MISTRAL_MODELS_URL,
|
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) {
|
if (fetched && allModels.length > 0) {
|
||||||
this.cachedModels = allModels;
|
this.cachedModels = allModels;
|
||||||
this.cachedModelsAt = Date.now();
|
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<ChatModel[]> {
|
||||||
|
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 ----
|
// ---- Ollama model listing ----
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -410,16 +615,15 @@ export class ProviderRegistry {
|
|||||||
const data = await response.json() as { models?: Array<{ name: string; details?: { family?: string } }> };
|
const data = await response.json() as { models?: Array<{ name: string; details?: { family?: string } }> };
|
||||||
if (!data.models || !Array.isArray(data.models)) return [];
|
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();
|
this.clearOllamaModels();
|
||||||
const models: ChatModel[] = data.models.map(m => {
|
for (const m of models) this.registerOllamaModel(m.id);
|
||||||
this.registerOllamaModel(m.name);
|
|
||||||
return {
|
|
||||||
id: m.name,
|
|
||||||
name: m.name,
|
|
||||||
provider: 'ollama',
|
|
||||||
vision: this.ollamaModelSupportsVision(m.name),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
return models;
|
return models;
|
||||||
} catch {
|
} catch {
|
||||||
return [];
|
return [];
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { generateText } from 'ai';
|
|||||||
import type { ChatEngine } from '../ChatEngine';
|
import type { ChatEngine } from '../ChatEngine';
|
||||||
import type { MediaEngine } from '../MediaEngine';
|
import type { MediaEngine } from '../MediaEngine';
|
||||||
import { ProviderRegistry } from './providers';
|
import { ProviderRegistry } from './providers';
|
||||||
|
import { resolveSupportedRenderLanguage, translateRender } from '../../shared/i18n';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Types
|
// Types
|
||||||
@@ -29,17 +30,6 @@ export interface ImageAnalysisResult {
|
|||||||
error?: string;
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Language map for image analysis prompts
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
const LANGUAGE_NAMES: Record<string, string> = {
|
|
||||||
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
|
// OneShotTasks
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -70,7 +60,7 @@ export class OneShotTasks {
|
|||||||
): Promise<TaxonomyAnalysisResult> {
|
): Promise<TaxonomyAnalysisResult> {
|
||||||
const provider = this.providers.detectModelProvider(modelId);
|
const provider = this.providers.detectModelProvider(modelId);
|
||||||
if (!this.providers.isProviderKeySet(provider)) {
|
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` };
|
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'
|
? 'mistral-large-latest'
|
||||||
: null;
|
: 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) {
|
if (!modelId) {
|
||||||
return { success: false, error: 'API key not configured. Please set an API key in Settings.' };
|
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.` };
|
return { success: false, error: `Cannot analyze this file type: ${mediaItem.mimeType}. Only images are supported.` };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get thumbnail
|
// Get AI-optimised JPEG thumbnail (512px, pre-generated).
|
||||||
let dataUrl = await this.mediaEngine.getThumbnailDataUrl(mediaId, 'large');
|
// Falls back to large/medium WebP thumbnails for older media items.
|
||||||
if (!dataUrl) dataUrl = await this.mediaEngine.getThumbnailDataUrl(mediaId, 'medium');
|
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) {
|
if (!dataUrl) {
|
||||||
return { success: false, error: 'Image thumbnail not available. Try regenerating thumbnails from Settings.' };
|
return { success: false, error: 'Image thumbnail not available. Try regenerating thumbnails from Settings.' };
|
||||||
}
|
}
|
||||||
|
|
||||||
const base64Data = dataUrl.replace(/^data:image\/\w+;base64,/, '');
|
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.
|
const renderLanguage = resolveSupportedRenderLanguage(language);
|
||||||
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"
|
const systemPrompt = translateRender(renderLanguage, 'ai.imageAnalysis.system');
|
||||||
CAPTION: Short, engaging blog caption (5-20 words).
|
const userPrompt = translateRender(renderLanguage, 'ai.imageAnalysis.user');
|
||||||
|
|
||||||
Respond with JSON only: {"title": "...", "alt": "...", "caption": "..."}`;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const model = this.providers.resolveModel(modelId);
|
const model = this.providers.resolveModel(modelId);
|
||||||
@@ -233,8 +253,8 @@ Respond with JSON only: {"title": "...", "alt": "...", "caption": "..."}`;
|
|||||||
messages: [{
|
messages: [{
|
||||||
role: 'user',
|
role: 'user',
|
||||||
content: [
|
content: [
|
||||||
{ type: 'image', image: `data:image/webp;base64,${base64Data}` },
|
{ type: 'image', image: `data:image/jpeg;base64,${jpegBase64}` },
|
||||||
{ type: 'text', text: 'Analyze and respond with JSON.' },
|
{ type: 'text', text: userPrompt },
|
||||||
],
|
],
|
||||||
}],
|
}],
|
||||||
maxOutputTokens: 200,
|
maxOutputTokens: 200,
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
* Uses ProviderRegistry, ChatService, and OneShotTasks.
|
* Uses ProviderRegistry, ChatService, and OneShotTasks.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { ipcMain, BrowserWindow } from 'electron';
|
import { ipcMain, BrowserWindow, net } from 'electron';
|
||||||
import { ChatEngine } from '../engine/ChatEngine';
|
import { ChatEngine } from '../engine/ChatEngine';
|
||||||
import { SecureKeyStore } from '../engine/SecureKeyStore';
|
import { SecureKeyStore } from '../engine/SecureKeyStore';
|
||||||
import { ProviderRegistry } from '../engine/ai/providers';
|
import { ProviderRegistry } from '../engine/ai/providers';
|
||||||
@@ -61,6 +61,14 @@ function getProviders(): ProviderRegistry {
|
|||||||
return providers;
|
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).
|
* Get the ChatService (lazy-init).
|
||||||
*/
|
*/
|
||||||
@@ -124,6 +132,52 @@ async function ensureInitialized(): Promise<void> {
|
|||||||
reg.loadOllamaModelCapabilities(caps);
|
reg.loadOllamaModelCapabilities(caps);
|
||||||
}
|
}
|
||||||
} catch { /* ignore */ }
|
} 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<string, { tools: boolean; vision: boolean }>;
|
||||||
|
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;
|
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 ============
|
// ============ Per-Purpose Model Preferences ============
|
||||||
|
|
||||||
// Get title generation model
|
// Get title generation model
|
||||||
@@ -376,9 +593,21 @@ export function registerChatHandlers(): void {
|
|||||||
ipcMain.handle('chat:getAvailableModels', async () => {
|
ipcMain.handle('chat:getAvailableModels', async () => {
|
||||||
try {
|
try {
|
||||||
await ensureInitialized();
|
await ensureInitialized();
|
||||||
const models = await getProviders().getAvailableModels();
|
const reg = getProviders();
|
||||||
|
const models = await reg.getAvailableModels();
|
||||||
const engine = getChatEngine();
|
const engine = getChatEngine();
|
||||||
const selectedModel = await engine.getSelectedModel();
|
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 };
|
return { success: true, models, selectedModel };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[Chat IPC] Error getting models:', error);
|
console.error('[Chat IPC] Error getting models:', error);
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { generateBlogmarkBookmarkletSource } from '../shared/blogmark';
|
|||||||
import { registerMetadataDiffHandlers } from './metadataDiffHandlers';
|
import { registerMetadataDiffHandlers } from './metadataDiffHandlers';
|
||||||
import { registerBlogHandlers } from './blogHandlers';
|
import { registerBlogHandlers } from './blogHandlers';
|
||||||
import { registerPublishHandlers } from './publishHandlers';
|
import { registerPublishHandlers } from './publishHandlers';
|
||||||
|
import { isOfflineModeActive } from './chatHandlers';
|
||||||
import type { EngineBundle } from '../engine/EngineBundle';
|
import type { EngineBundle } from '../engine/EngineBundle';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -179,16 +180,25 @@ export function registerIpcHandlers(bundle: EngineBundle): void {
|
|||||||
});
|
});
|
||||||
|
|
||||||
safeHandle('git:remoteState', async (_, projectPath: string) => {
|
safeHandle('git:remoteState', async (_, projectPath: string) => {
|
||||||
|
if (isOfflineModeActive()) {
|
||||||
|
return { ahead: 0, behind: 0 };
|
||||||
|
}
|
||||||
const engine = bundle.gitEngine;
|
const engine = bundle.gitEngine;
|
||||||
return engine.getRemoteState(projectPath);
|
return engine.getRemoteState(projectPath);
|
||||||
});
|
});
|
||||||
|
|
||||||
safeHandle('git:fetch', async (_, projectPath: string) => {
|
safeHandle('git:fetch', async (_, projectPath: string) => {
|
||||||
|
if (isOfflineModeActive()) {
|
||||||
|
return { success: false, code: 'offline' };
|
||||||
|
}
|
||||||
const engine = bundle.gitEngine;
|
const engine = bundle.gitEngine;
|
||||||
return engine.fetch(projectPath);
|
return engine.fetch(projectPath);
|
||||||
});
|
});
|
||||||
|
|
||||||
safeHandle('git:pull', async (_, projectPath: string) => {
|
safeHandle('git:pull', async (_, projectPath: string) => {
|
||||||
|
if (isOfflineModeActive()) {
|
||||||
|
return { success: false, code: 'offline' };
|
||||||
|
}
|
||||||
const engine = bundle.gitEngine;
|
const engine = bundle.gitEngine;
|
||||||
const beforeHead = await engine.getHeadCommit(projectPath);
|
const beforeHead = await engine.getHeadCommit(projectPath);
|
||||||
const pullResult = await engine.pull(projectPath);
|
const pullResult = await engine.pull(projectPath);
|
||||||
@@ -244,6 +254,9 @@ export function registerIpcHandlers(bundle: EngineBundle): void {
|
|||||||
});
|
});
|
||||||
|
|
||||||
safeHandle('git:push', async (_, projectPath: string) => {
|
safeHandle('git:push', async (_, projectPath: string) => {
|
||||||
|
if (isOfflineModeActive()) {
|
||||||
|
return { success: false, code: 'offline' };
|
||||||
|
}
|
||||||
const engine = bundle.gitEngine;
|
const engine = bundle.gitEngine;
|
||||||
return engine.push(projectPath);
|
return engine.push(projectPath);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,10 +1,15 @@
|
|||||||
import type { PublishCredentials } from '../engine/PublishEngine';
|
import type { PublishCredentials } from '../engine/PublishEngine';
|
||||||
import type { EngineBundle } from '../engine/EngineBundle';
|
import type { EngineBundle } from '../engine/EngineBundle';
|
||||||
|
import { isOfflineModeActive } from './chatHandlers';
|
||||||
|
|
||||||
type SafeHandle = (channel: string, handler: (...args: any[]) => Promise<any>) => void;
|
type SafeHandle = (channel: string, handler: (...args: any[]) => Promise<any>) => void;
|
||||||
|
|
||||||
export function registerPublishHandlers(safeHandle: SafeHandle, bundle: EngineBundle): void {
|
export function registerPublishHandlers(safeHandle: SafeHandle, bundle: EngineBundle): void {
|
||||||
safeHandle('publish:uploadSite', async (_event: unknown, credentials: PublishCredentials) => {
|
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 projectEngine = bundle.projectEngine;
|
||||||
const project = await projectEngine.getActiveProject();
|
const project = await projectEngine.getActiveProject();
|
||||||
if (!project) {
|
if (!project) {
|
||||||
|
|||||||
@@ -321,6 +321,24 @@ export const electronAPI: ElectronAPI = {
|
|||||||
getOllamaModelCapabilities: () => ipcRenderer.invoke('chat:getOllamaModelCapabilities'),
|
getOllamaModelCapabilities: () => ipcRenderer.invoke('chat:getOllamaModelCapabilities'),
|
||||||
setOllamaModelCapabilities: (modelId: string, caps: { tools: boolean; vision: boolean }) => ipcRenderer.invoke('chat:setOllamaModelCapabilities', modelId, caps),
|
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
|
// Per-Purpose Model Preferences
|
||||||
getTitleModel: () => ipcRenderer.invoke('chat:getTitleModel'),
|
getTitleModel: () => ipcRenderer.invoke('chat:getTitleModel'),
|
||||||
setTitleModel: (modelId: string | null) => ipcRenderer.invoke('chat:setTitleModel', modelId),
|
setTitleModel: (modelId: string | null) => ipcRenderer.invoke('chat:setTitleModel', modelId),
|
||||||
|
|||||||
@@ -384,7 +384,7 @@ export interface GitLfsPruneResult {
|
|||||||
|
|
||||||
export interface GitActionResult {
|
export interface GitActionResult {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
code?: 'auth-required' | 'conflict' | 'network' | 'action-failed';
|
code?: 'auth-required' | 'conflict' | 'network' | 'action-failed' | 'offline';
|
||||||
error?: string;
|
error?: string;
|
||||||
guidance?: string[];
|
guidance?: string[];
|
||||||
}
|
}
|
||||||
@@ -451,7 +451,7 @@ export interface ChatReadyStatus {
|
|||||||
ready: boolean;
|
ready: boolean;
|
||||||
error?: string;
|
error?: string;
|
||||||
backend?: string;
|
backend?: string;
|
||||||
providers?: { opencode: boolean; mistral: boolean; ollama: boolean };
|
providers?: { opencode: boolean; mistral: boolean; ollama: boolean; lmstudio: boolean; offlineMode: boolean };
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ChatApiKeyStatus {
|
export interface ChatApiKeyStatus {
|
||||||
@@ -839,6 +839,24 @@ export interface ElectronAPI {
|
|||||||
getOllamaModelCapabilities: () => Promise<Record<string, { tools: boolean; vision: boolean }>>;
|
getOllamaModelCapabilities: () => Promise<Record<string, { tools: boolean; vision: boolean }>>;
|
||||||
setOllamaModelCapabilities: (modelId: string, caps: { tools: boolean; vision: boolean }) => Promise<{ success: boolean; error?: string }>;
|
setOllamaModelCapabilities: (modelId: string, caps: { tools: boolean; vision: boolean }) => Promise<{ success: boolean; error?: string }>;
|
||||||
|
|
||||||
|
// LM Studio (local)
|
||||||
|
getLmstudioEnabled: () => Promise<boolean>;
|
||||||
|
setLmstudioEnabled: (enabled: boolean) => Promise<{ success: boolean; error?: string }>;
|
||||||
|
getLmstudioModels: () => Promise<ChatModel[]>;
|
||||||
|
getLmstudioModelCapabilities: () => Promise<Record<string, { tools: boolean; vision: boolean }>>;
|
||||||
|
setLmstudioModelCapabilities: (modelId: string, caps: { tools: boolean; vision: boolean }) => Promise<{ success: boolean; error?: string }>;
|
||||||
|
|
||||||
|
// Offline / Airplane mode
|
||||||
|
getOfflineMode: () => Promise<boolean>;
|
||||||
|
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<ChatModel[]>;
|
||||||
|
|
||||||
// Settings
|
// Settings
|
||||||
getAvailableModels: () => Promise<{ success: boolean; models?: ChatModel[]; selectedModel?: string; error?: string }>;
|
getAvailableModels: () => Promise<{ success: boolean; models?: ChatModel[]; selectedModel?: string; error?: string }>;
|
||||||
setDefaultModel: (modelId: string) => Promise<{ success: boolean; error?: string }>;
|
setDefaultModel: (modelId: string) => Promise<{ success: boolean; error?: string }>;
|
||||||
|
|||||||
@@ -78,5 +78,7 @@
|
|||||||
"render.month.9": "Sept.",
|
"render.month.9": "Sept.",
|
||||||
"render.month.10": "Oktober",
|
"render.month.10": "Oktober",
|
||||||
"render.month.11": "Nov.",
|
"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."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -78,5 +78,7 @@
|
|||||||
"render.month.9": "September",
|
"render.month.9": "September",
|
||||||
"render.month.10": "October",
|
"render.month.10": "October",
|
||||||
"render.month.11": "November",
|
"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."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -78,5 +78,7 @@
|
|||||||
"render.month.9": "septiembre",
|
"render.month.9": "septiembre",
|
||||||
"render.month.10": "octubre",
|
"render.month.10": "octubre",
|
||||||
"render.month.11": "noviembre",
|
"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."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -78,5 +78,7 @@
|
|||||||
"render.month.9": "septembre",
|
"render.month.9": "septembre",
|
||||||
"render.month.10": "octobre",
|
"render.month.10": "octobre",
|
||||||
"render.month.11": "novembre",
|
"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."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -78,5 +78,7 @@
|
|||||||
"render.month.9": "settembre",
|
"render.month.9": "settembre",
|
||||||
"render.month.10": "ottobre",
|
"render.month.10": "ottobre",
|
||||||
"render.month.11": "novembre",
|
"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."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -520,9 +520,13 @@ const App: React.FC = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await window.electronAPI?.publish.uploadSite(prefs);
|
await window.electronAPI?.publish.uploadSite(prefs);
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
console.error('Site upload failed:', error);
|
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'));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}) || (() => {})
|
}) || (() => {})
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -15,9 +15,9 @@ interface ErrorModalProps {
|
|||||||
|
|
||||||
export const ErrorModal: React.FC<ErrorModalProps> = ({ error, onClose }) => {
|
export const ErrorModal: React.FC<ErrorModalProps> = ({ error, onClose }) => {
|
||||||
const { t: tr } = useI18n();
|
const { t: tr } = useI18n();
|
||||||
if (!error) return null;
|
|
||||||
|
|
||||||
const handleCopyStack = useCallback(async () => {
|
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')}`;
|
const textToCopy = `${error.title || tr('errorModal.error')}\n${error.message}\n\n${tr('errorModal.stackTrace')}:\n${error.stack || tr('errorModal.noStack')}`;
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(textToCopy);
|
await navigator.clipboard.writeText(textToCopy);
|
||||||
@@ -32,6 +32,8 @@ export const ErrorModal: React.FC<ErrorModalProps> = ({ error, onClose }) => {
|
|||||||
}
|
}
|
||||||
}, [onClose]);
|
}, [onClose]);
|
||||||
|
|
||||||
|
if (!error) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="error-modal-backdrop" onClick={handleBackdropClick}>
|
<div className="error-modal-backdrop" onClick={handleBackdropClick}>
|
||||||
<div className="error-modal">
|
<div className="error-modal">
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ const mergeStatusFilesIncremental = (
|
|||||||
|
|
||||||
export const GitSidebar: React.FC = () => {
|
export const GitSidebar: React.FC = () => {
|
||||||
const { t: tr } = useI18n();
|
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 [projectPath, setProjectPath] = useState<string | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [initializing, setInitializing] = useState(false);
|
const [initializing, setInitializing] = useState(false);
|
||||||
@@ -390,6 +390,10 @@ export const GitSidebar: React.FC = () => {
|
|||||||
recentCommitsToKeep: 2,
|
recentCommitsToKeep: 2,
|
||||||
});
|
});
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
|
if (result.code === 'offline') {
|
||||||
|
showErrorModal({ message: tr('gitSidebar.error.offlineMode') });
|
||||||
|
return;
|
||||||
|
}
|
||||||
setError(result.error || tr('gitSidebar.error.actionFailed', { action }));
|
setError(result.error || tr('gitSidebar.error.actionFailed', { action }));
|
||||||
setErrorGuidance('guidance' in result ? result.guidance || [] : []);
|
setErrorGuidance('guidance' in result ? result.guidance || [] : []);
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -565,3 +565,36 @@
|
|||||||
.ollama-caps-table input[type="checkbox"] {
|
.ollama-caps-table input[type="checkbox"] {
|
||||||
margin: 0;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -248,6 +248,14 @@ export const SettingsView: React.FC = () => {
|
|||||||
const [ollamaEnabled, setOllamaEnabled] = useState(false);
|
const [ollamaEnabled, setOllamaEnabled] = useState(false);
|
||||||
const [ollamaCapabilities, setOllamaCapabilities] = useState<Record<string, { tools: boolean; vision: boolean }>>({});
|
const [ollamaCapabilities, setOllamaCapabilities] = useState<Record<string, { tools: boolean; vision: boolean }>>({});
|
||||||
const [ollamaModels, setOllamaModels] = useState<{id: string; name: string}[]>([]);
|
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 [titleModel, setTitleModel] = useState('claude-haiku-4-5');
|
||||||
const [imageAnalysisModel, setImageAnalysisModel] = useState('claude-sonnet-4-5');
|
const [imageAnalysisModel, setImageAnalysisModel] = useState('claude-sonnet-4-5');
|
||||||
const [availableModels, setAvailableModels] = useState<{id: string; name: string; provider?: string; vision?: boolean}[]>([]);
|
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 })));
|
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
|
// Load per-purpose model preferences
|
||||||
const titleModelResult = await window.electronAPI?.chat.getTitleModel();
|
const titleModelResult = await window.electronAPI?.chat.getTitleModel();
|
||||||
if (titleModelResult?.success && titleModelResult.modelId) {
|
if (titleModelResult?.success && titleModelResult.modelId) {
|
||||||
@@ -442,6 +464,22 @@ export const SettingsView: React.FC = () => {
|
|||||||
setImageAnalysisModel(imageModelResult.modelId);
|
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
|
// Load model catalog metadata
|
||||||
const catalogResult = await window.electronAPI?.chat.getModelCatalog();
|
const catalogResult = await window.electronAPI?.chat.getModelCatalog();
|
||||||
if (catalogResult?.success && catalogResult.entries) {
|
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 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 editorKeywords = ['editor', 'mode', 'wysiwyg', 'markdown', 'preview', 'visual'];
|
||||||
const contentKeywords = ['content', 'categories', 'post', 'article', 'picture', 'aside', 'page'];
|
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 technologyKeywords = ['technology', 'python', 'runtime', 'worker', 'webworker', 'main thread', 'execution'];
|
||||||
const publishingKeywords = ['publishing', 'ssh', 'deploy', 'server', 'host', 'upload', 'scp', 'rsync'];
|
const publishingKeywords = ['publishing', 'ssh', 'deploy', 'server', 'host', 'upload', 'scp', 'rsync'];
|
||||||
const dataKeywords = ['data', 'database', 'rebuild', 'maintenance', 'posts', 'media', 'scripts', 'links', 'folder', 'filesystem'];
|
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) => {
|
const handleTitleModelChange = async (modelId: string) => {
|
||||||
try {
|
try {
|
||||||
const result = await window.electronAPI?.chat.setTitleModel(modelId);
|
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) => {
|
const handleModelChange = async (modelId: string) => {
|
||||||
try {
|
try {
|
||||||
const result = await window.electronAPI?.chat.setDefaultModel(modelId);
|
const result = await window.electronAPI?.chat.setDefaultModel(modelId);
|
||||||
@@ -1299,10 +1425,26 @@ export const SettingsView: React.FC = () => {
|
|||||||
[availableModels, groupModelsByProvider]
|
[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) => {
|
const providerLabel = (provider: string) => {
|
||||||
if (provider === 'anthropic' || provider === 'openai' || provider === 'google' || provider === 'other') return t('settings.ai.providerOpenCode');
|
if (provider === 'anthropic' || provider === 'openai' || provider === 'google' || provider === 'other') return t('settings.ai.providerOpenCode');
|
||||||
if (provider === 'mistral') return t('settings.ai.providerMistral');
|
if (provider === 'mistral') return t('settings.ai.providerMistral');
|
||||||
if (provider === 'ollama') return t('settings.ai.providerOllama');
|
if (provider === 'ollama') return t('settings.ai.providerOllama');
|
||||||
|
if (provider === 'lmstudio') return t('settings.ai.providerLmstudio');
|
||||||
return provider;
|
return provider;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1472,17 +1614,120 @@ export const SettingsView: React.FC = () => {
|
|||||||
)}
|
)}
|
||||||
</SettingRow>
|
</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
|
<SettingRow
|
||||||
id="ai-model"
|
id="ai-model"
|
||||||
label={t('settings.ai.defaultModelLabel')}
|
label={t('settings.ai.defaultModelLabel')}
|
||||||
description={t('settings.ai.defaultModelDescription')}
|
description={t('settings.ai.defaultModelDescription')}
|
||||||
>
|
>
|
||||||
<div className="setting-input-group">
|
<div className="setting-input-group">
|
||||||
{renderModelSelect('ai-model', selectedModel, handleModelChange, !aiHasApiKey && !aiHasMistralKey && !ollamaEnabled)}
|
{renderModelSelect('ai-model', selectedModel, handleModelChange, !aiHasApiKey && !aiHasMistralKey && !ollamaEnabled && !lmstudioEnabled)}
|
||||||
<button
|
<button
|
||||||
className="secondary"
|
className="secondary"
|
||||||
onClick={handleRefreshModelCatalog}
|
onClick={handleRefreshModelCatalog}
|
||||||
disabled={refreshingCatalog || (!aiHasApiKey && !aiHasMistralKey && !ollamaEnabled)}
|
disabled={refreshingCatalog || (!aiHasApiKey && !aiHasMistralKey && !ollamaEnabled && !lmstudioEnabled)}
|
||||||
title={t('settings.ai.refreshModelCatalog')}
|
title={t('settings.ai.refreshModelCatalog')}
|
||||||
>
|
>
|
||||||
{refreshingCatalog ? t('settings.ai.refreshing') : 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')}
|
label={t('settings.ai.titleModelLabel')}
|
||||||
description={t('settings.ai.titleModelDescription')}
|
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>
|
||||||
|
|
||||||
<SettingRow
|
<SettingRow
|
||||||
@@ -1522,7 +1767,7 @@ export const SettingsView: React.FC = () => {
|
|||||||
label={t('settings.ai.imageAnalysisModelLabel')}
|
label={t('settings.ai.imageAnalysisModelLabel')}
|
||||||
description={t('settings.ai.imageAnalysisModelDescription')}
|
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>
|
||||||
|
|
||||||
<SettingRow
|
<SettingRow
|
||||||
|
|||||||
@@ -123,6 +123,19 @@
|
|||||||
gap: 4px;
|
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 {
|
.status-bar-language-select {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: none;
|
border: none;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
import { useAppStore } from '../../store';
|
import { useAppStore } from '../../store';
|
||||||
import { ProjectSelector } from '../ProjectSelector';
|
import { ProjectSelector } from '../ProjectSelector';
|
||||||
import { getRendererPicoTheme } from '../../utils/picoTheme';
|
import { getRendererPicoTheme } from '../../utils/picoTheme';
|
||||||
@@ -27,6 +27,22 @@ export const StatusBar: React.FC = () => {
|
|||||||
} = useAppStore();
|
} = useAppStore();
|
||||||
|
|
||||||
const [selectedPostStatus, setSelectedPostStatus] = useState<string | null>(null);
|
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
|
// Fetch selected post status from database
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -96,6 +112,18 @@ export const StatusBar: React.FC = () => {
|
|||||||
<span>{t('statusBar.theme', { theme: activeTheme })}</span>
|
<span>{t('statusBar.theme', { theme: activeTheme })}</span>
|
||||||
</div>
|
</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">
|
<div className="status-bar-item language-badge">
|
||||||
<span>{t('statusBar.ui')}</span>
|
<span>{t('statusBar.ui')}</span>
|
||||||
<select
|
<select
|
||||||
|
|||||||
@@ -34,6 +34,7 @@
|
|||||||
"app.sitemapGenerationFailed": "Sitemap-Erstellung fehlgeschlagen",
|
"app.sitemapGenerationFailed": "Sitemap-Erstellung fehlgeschlagen",
|
||||||
"app.calendarRegenerationFailed": "Kalender-Neuerstellung fehlgeschlagen",
|
"app.calendarRegenerationFailed": "Kalender-Neuerstellung fehlgeschlagen",
|
||||||
"app.uploadSiteFailed": "Website-Upload fehlgeschlagen",
|
"app.uploadSiteFailed": "Website-Upload fehlgeschlagen",
|
||||||
|
"app.uploadSiteOfflineMode": "Website-Upload ist im Flugmodus nicht verfügbar.",
|
||||||
"app.uploadSiteNoCredentials": "Bitte konfigurieren Sie zuerst die SSH-Zugangsdaten in den Einstellungen.",
|
"app.uploadSiteNoCredentials": "Bitte konfigurieren Sie zuerst die SSH-Zugangsdaten in den Einstellungen.",
|
||||||
"app.previewOpenFailed": "Ausgewählte Beitragsvorschau konnte nicht geöffnet werden",
|
"app.previewOpenFailed": "Ausgewählte Beitragsvorschau konnte nicht geöffnet werden",
|
||||||
"app.metadataDiff": "Metadaten-Diff",
|
"app.metadataDiff": "Metadaten-Diff",
|
||||||
@@ -314,6 +315,7 @@
|
|||||||
"gitSidebar.error.loadRepoStatus": "Repository-Status konnte nicht geladen werden.",
|
"gitSidebar.error.loadRepoStatus": "Repository-Status konnte nicht geladen werden.",
|
||||||
"gitSidebar.error.initFailed": "Git-Repository konnte nicht initialisiert werden.",
|
"gitSidebar.error.initFailed": "Git-Repository konnte nicht initialisiert werden.",
|
||||||
"gitSidebar.error.actionFailed": "Fehler beim {action}.",
|
"gitSidebar.error.actionFailed": "Fehler beim {action}.",
|
||||||
|
"gitSidebar.error.offlineMode": "Diese Aktion ist im Flugmodus nicht verfügbar.",
|
||||||
"gitSidebar.error.commitFailed": "Änderungen konnten nicht committet werden.",
|
"gitSidebar.error.commitFailed": "Änderungen konnten nicht committet werden.",
|
||||||
"gitSidebar.progress.preparingInit": "Repository-Initialisierung wird vorbereitet...",
|
"gitSidebar.progress.preparingInit": "Repository-Initialisierung wird vorbereitet...",
|
||||||
"gitSidebar.progress.pushingRemote": "Commits werden zum Remote übertragen... das kann bei großen Uploads eine Weile dauern.",
|
"gitSidebar.progress.pushingRemote": "Commits werden zum Remote übertragen... das kann bei großen Uploads eine Weile dauern.",
|
||||||
@@ -742,6 +744,7 @@
|
|||||||
"settings.ai.providerOpenCode": "OpenCode",
|
"settings.ai.providerOpenCode": "OpenCode",
|
||||||
"settings.ai.providerMistral": "Mistral",
|
"settings.ai.providerMistral": "Mistral",
|
||||||
"settings.ai.providerOllama": "Ollama (Lokal)",
|
"settings.ai.providerOllama": "Ollama (Lokal)",
|
||||||
|
"settings.ai.providerLmstudio": "LM Studio (Lokal)",
|
||||||
"settings.ai.providerOther": "Andere",
|
"settings.ai.providerOther": "Andere",
|
||||||
"settings.ai.ollamaLabel": "Ollama (Lokale Modelle)",
|
"settings.ai.ollamaLabel": "Ollama (Lokale Modelle)",
|
||||||
"settings.ai.ollamaDescription": "Verbinde dich mit einer lokal laufenden Ollama-Instanz, um lokale KI-Modelle zu verwenden.",
|
"settings.ai.ollamaDescription": "Verbinde dich mit einer lokal laufenden Ollama-Instanz, um lokale KI-Modelle zu verwenden.",
|
||||||
@@ -756,6 +759,28 @@
|
|||||||
"settings.toast.modelCatalogRefreshFailed": "Modellkatalog konnte nicht aktualisiert werden",
|
"settings.toast.modelCatalogRefreshFailed": "Modellkatalog konnte nicht aktualisiert werden",
|
||||||
"settings.toast.ollamaEnabled": "Ollama aktiviert",
|
"settings.toast.ollamaEnabled": "Ollama aktiviert",
|
||||||
"settings.toast.ollamaDisabled": "Ollama deaktiviert",
|
"settings.toast.ollamaDisabled": "Ollama deaktiviert",
|
||||||
|
"settings.ai.lmstudioLabel": "LM Studio (Lokale Modelle)",
|
||||||
|
"settings.ai.lmstudioDescription": "Verbinde dich mit einer lokal laufenden LM Studio-Instanz, um lokale KI-Modelle zu verwenden.",
|
||||||
|
"settings.ai.lmstudioEnable": "LM Studio aktivieren",
|
||||||
|
"settings.ai.lmstudioCapabilitiesDescription": "Fähigkeiten für jedes LM Studio-Modell konfigurieren. Tools für Funktionsaufrufe oder Vision für Bildanalyse aktivieren.",
|
||||||
|
"settings.ai.lmstudioCapModel": "Modell",
|
||||||
|
"settings.ai.lmstudioCapTools": "Tools",
|
||||||
|
"settings.ai.lmstudioCapVision": "Vision",
|
||||||
|
"settings.toast.lmstudioEnabled": "LM Studio aktiviert",
|
||||||
|
"settings.toast.lmstudioDisabled": "LM Studio deaktiviert",
|
||||||
|
"settings.ai.offlineLabel": "Flugmodus",
|
||||||
|
"settings.ai.offlineDescription": "Wenn aktiviert, werden nur lokal gehostete Modelle (Ollama, LM Studio) verwendet. Cloud-Anbieter werden deaktiviert.",
|
||||||
|
"settings.ai.offlineEnable": "Flugmodus aktivieren",
|
||||||
|
"settings.ai.offlineChatModel": "Offline-Chat-Modell",
|
||||||
|
"settings.ai.offlineChatModelDescription": "Modell für Chat-Gespräche im Flugmodus.",
|
||||||
|
"settings.ai.offlineTitleModel": "Offline-Titelmodell",
|
||||||
|
"settings.ai.offlineTitleModelDescription": "Modell für die Titelgenerierung im Flugmodus.",
|
||||||
|
"settings.ai.offlineImageAnalysisModel": "Offline-Bildanalysemodell",
|
||||||
|
"settings.ai.offlineImageAnalysisModelDescription": "Modell für die Bildanalyse im Flugmodus.",
|
||||||
|
"settings.ai.offlineNoLocalProviders": "Keine lokalen Anbieter aktiviert. Aktiviere zuerst Ollama oder LM Studio.",
|
||||||
|
"settings.ai.offlineNoLocalModels": "Keine lokalen Modelle verfügbar",
|
||||||
|
"settings.toast.offlineEnabled": "Flugmodus aktiviert",
|
||||||
|
"settings.toast.offlineDisabled": "Flugmodus deaktiviert",
|
||||||
"settings.publishing.sshHostDescription": "Hostname oder IP-Adresse des SSH-Servers.",
|
"settings.publishing.sshHostDescription": "Hostname oder IP-Adresse des SSH-Servers.",
|
||||||
"settings.publishing.sshUsernameDescription": "Benutzername deines SSH-Kontos.",
|
"settings.publishing.sshUsernameDescription": "Benutzername deines SSH-Kontos.",
|
||||||
"settings.publishing.sshRemotePathDescription": "Das Zielverzeichnis auf dem Remote-Server, in das dein Blog veröffentlicht wird.",
|
"settings.publishing.sshRemotePathDescription": "Das Zielverzeichnis auf dem Remote-Server, in das dein Blog veröffentlicht wird.",
|
||||||
@@ -891,6 +916,9 @@
|
|||||||
"statusBar.theme": "Theme: {theme}",
|
"statusBar.theme": "Theme: {theme}",
|
||||||
"statusBar.ui": "UI",
|
"statusBar.ui": "UI",
|
||||||
"statusBar.uiLanguage": "UI-Sprache",
|
"statusBar.uiLanguage": "UI-Sprache",
|
||||||
|
"statusBar.offlineMode": "Flugmodus",
|
||||||
|
"statusBar.offlineModeActive": "Flugmodus (aktiv)",
|
||||||
|
"statusBar.offlineModeTooltip": "Klicken zum Umschalten des Flugmodus",
|
||||||
"windowTitleBar.toggleSidebar": "Seitenleiste umschalten",
|
"windowTitleBar.toggleSidebar": "Seitenleiste umschalten",
|
||||||
"windowTitleBar.hideSidebar": "Seitenleiste ausblenden (Ctrl+B)",
|
"windowTitleBar.hideSidebar": "Seitenleiste ausblenden (Ctrl+B)",
|
||||||
"windowTitleBar.showSidebar": "Seitenleiste anzeigen (Ctrl+B)",
|
"windowTitleBar.showSidebar": "Seitenleiste anzeigen (Ctrl+B)",
|
||||||
|
|||||||
@@ -34,6 +34,7 @@
|
|||||||
"app.sitemapGenerationFailed": "Sitemap generation failed",
|
"app.sitemapGenerationFailed": "Sitemap generation failed",
|
||||||
"app.calendarRegenerationFailed": "Calendar regeneration failed",
|
"app.calendarRegenerationFailed": "Calendar regeneration failed",
|
||||||
"app.uploadSiteFailed": "Site upload failed",
|
"app.uploadSiteFailed": "Site upload failed",
|
||||||
|
"app.uploadSiteOfflineMode": "Site upload is blocked while airplane mode is active.",
|
||||||
"app.uploadSiteNoCredentials": "Please configure SSH publishing credentials in Settings first.",
|
"app.uploadSiteNoCredentials": "Please configure SSH publishing credentials in Settings first.",
|
||||||
"app.previewOpenFailed": "Failed to open selected post preview",
|
"app.previewOpenFailed": "Failed to open selected post preview",
|
||||||
"app.metadataDiff": "Metadata Diff",
|
"app.metadataDiff": "Metadata Diff",
|
||||||
@@ -314,6 +315,7 @@
|
|||||||
"gitSidebar.error.loadRepoStatus": "Unable to load repository status.",
|
"gitSidebar.error.loadRepoStatus": "Unable to load repository status.",
|
||||||
"gitSidebar.error.initFailed": "Failed to initialize git repository.",
|
"gitSidebar.error.initFailed": "Failed to initialize git repository.",
|
||||||
"gitSidebar.error.actionFailed": "Failed to {action}.",
|
"gitSidebar.error.actionFailed": "Failed to {action}.",
|
||||||
|
"gitSidebar.error.offlineMode": "This action is blocked while airplane mode is active.",
|
||||||
"gitSidebar.error.commitFailed": "Failed to commit changes.",
|
"gitSidebar.error.commitFailed": "Failed to commit changes.",
|
||||||
"gitSidebar.progress.preparingInit": "Preparing repository initialization...",
|
"gitSidebar.progress.preparingInit": "Preparing repository initialization...",
|
||||||
"gitSidebar.progress.pushingRemote": "Pushing commits to remote... this can take a while for large uploads.",
|
"gitSidebar.progress.pushingRemote": "Pushing commits to remote... this can take a while for large uploads.",
|
||||||
@@ -742,6 +744,7 @@
|
|||||||
"settings.ai.providerOpenCode": "OpenCode",
|
"settings.ai.providerOpenCode": "OpenCode",
|
||||||
"settings.ai.providerMistral": "Mistral",
|
"settings.ai.providerMistral": "Mistral",
|
||||||
"settings.ai.providerOllama": "Ollama (Local)",
|
"settings.ai.providerOllama": "Ollama (Local)",
|
||||||
|
"settings.ai.providerLmstudio": "LM Studio (Local)",
|
||||||
"settings.ai.providerOther": "Other",
|
"settings.ai.providerOther": "Other",
|
||||||
"settings.ai.ollamaLabel": "Ollama (Local Models)",
|
"settings.ai.ollamaLabel": "Ollama (Local Models)",
|
||||||
"settings.ai.ollamaDescription": "Connect to a locally running Ollama instance to use local AI models.",
|
"settings.ai.ollamaDescription": "Connect to a locally running Ollama instance to use local AI models.",
|
||||||
@@ -756,6 +759,28 @@
|
|||||||
"settings.toast.modelCatalogRefreshFailed": "Failed to refresh model catalog",
|
"settings.toast.modelCatalogRefreshFailed": "Failed to refresh model catalog",
|
||||||
"settings.toast.ollamaEnabled": "Ollama enabled",
|
"settings.toast.ollamaEnabled": "Ollama enabled",
|
||||||
"settings.toast.ollamaDisabled": "Ollama disabled",
|
"settings.toast.ollamaDisabled": "Ollama disabled",
|
||||||
|
"settings.ai.lmstudioLabel": "LM Studio (Local Models)",
|
||||||
|
"settings.ai.lmstudioDescription": "Connect to a locally running LM Studio instance to use local AI models.",
|
||||||
|
"settings.ai.lmstudioEnable": "Enable LM Studio",
|
||||||
|
"settings.ai.lmstudioCapabilitiesDescription": "Configure capabilities for each LM Studio model. Enable tools for function calling or vision for image analysis.",
|
||||||
|
"settings.ai.lmstudioCapModel": "Model",
|
||||||
|
"settings.ai.lmstudioCapTools": "Tools",
|
||||||
|
"settings.ai.lmstudioCapVision": "Vision",
|
||||||
|
"settings.toast.lmstudioEnabled": "LM Studio enabled",
|
||||||
|
"settings.toast.lmstudioDisabled": "LM Studio disabled",
|
||||||
|
"settings.ai.offlineLabel": "Airplane Mode",
|
||||||
|
"settings.ai.offlineDescription": "When enabled, only locally hosted models (Ollama, LM Studio) are used. Cloud providers are disabled.",
|
||||||
|
"settings.ai.offlineEnable": "Enable Airplane Mode",
|
||||||
|
"settings.ai.offlineChatModel": "Offline Chat Model",
|
||||||
|
"settings.ai.offlineChatModelDescription": "Model used for chat conversations when in airplane mode.",
|
||||||
|
"settings.ai.offlineTitleModel": "Offline Title Model",
|
||||||
|
"settings.ai.offlineTitleModelDescription": "Model used for title generation when in airplane mode.",
|
||||||
|
"settings.ai.offlineImageAnalysisModel": "Offline Image Analysis Model",
|
||||||
|
"settings.ai.offlineImageAnalysisModelDescription": "Model used for image analysis when in airplane mode.",
|
||||||
|
"settings.ai.offlineNoLocalProviders": "No local providers enabled. Enable Ollama or LM Studio first.",
|
||||||
|
"settings.ai.offlineNoLocalModels": "No local models available",
|
||||||
|
"settings.toast.offlineEnabled": "Airplane mode enabled",
|
||||||
|
"settings.toast.offlineDisabled": "Airplane mode disabled",
|
||||||
"settings.publishing.sshHostDescription": "The SSH server hostname or IP address.",
|
"settings.publishing.sshHostDescription": "The SSH server hostname or IP address.",
|
||||||
"settings.publishing.sshUsernameDescription": "Your SSH account username.",
|
"settings.publishing.sshUsernameDescription": "Your SSH account username.",
|
||||||
"settings.publishing.sshRemotePathDescription": "The destination directory on the remote server where your blog will be published.",
|
"settings.publishing.sshRemotePathDescription": "The destination directory on the remote server where your blog will be published.",
|
||||||
@@ -891,6 +916,9 @@
|
|||||||
"statusBar.theme": "Theme: {theme}",
|
"statusBar.theme": "Theme: {theme}",
|
||||||
"statusBar.ui": "UI",
|
"statusBar.ui": "UI",
|
||||||
"statusBar.uiLanguage": "UI language",
|
"statusBar.uiLanguage": "UI language",
|
||||||
|
"statusBar.offlineMode": "Airplane Mode",
|
||||||
|
"statusBar.offlineModeActive": "Airplane Mode (active)",
|
||||||
|
"statusBar.offlineModeTooltip": "Click to toggle airplane mode",
|
||||||
"windowTitleBar.toggleSidebar": "Toggle Sidebar",
|
"windowTitleBar.toggleSidebar": "Toggle Sidebar",
|
||||||
"windowTitleBar.hideSidebar": "Hide Sidebar (Ctrl+B)",
|
"windowTitleBar.hideSidebar": "Hide Sidebar (Ctrl+B)",
|
||||||
"windowTitleBar.showSidebar": "Show Sidebar (Ctrl+B)",
|
"windowTitleBar.showSidebar": "Show Sidebar (Ctrl+B)",
|
||||||
|
|||||||
@@ -34,6 +34,7 @@
|
|||||||
"app.sitemapGenerationFailed": "La generación del sitemap falló",
|
"app.sitemapGenerationFailed": "La generación del sitemap falló",
|
||||||
"app.calendarRegenerationFailed": "La regeneración del calendario falló",
|
"app.calendarRegenerationFailed": "La regeneración del calendario falló",
|
||||||
"app.uploadSiteFailed": "Error al subir el sitio",
|
"app.uploadSiteFailed": "Error al subir el sitio",
|
||||||
|
"app.uploadSiteOfflineMode": "La subida del sitio no está disponible en modo avión.",
|
||||||
"app.uploadSiteNoCredentials": "Configure primero las credenciales SSH en Configuración.",
|
"app.uploadSiteNoCredentials": "Configure primero las credenciales SSH en Configuración.",
|
||||||
"app.previewOpenFailed": "No se pudo abrir la vista previa de la entrada seleccionada",
|
"app.previewOpenFailed": "No se pudo abrir la vista previa de la entrada seleccionada",
|
||||||
"app.metadataDiff": "Diferencia de Metadatos",
|
"app.metadataDiff": "Diferencia de Metadatos",
|
||||||
@@ -314,6 +315,7 @@
|
|||||||
"gitSidebar.error.loadRepoStatus": "No se pudo cargar el estado del repositorio.",
|
"gitSidebar.error.loadRepoStatus": "No se pudo cargar el estado del repositorio.",
|
||||||
"gitSidebar.error.initFailed": "No se pudo inicializar el repositorio Git.",
|
"gitSidebar.error.initFailed": "No se pudo inicializar el repositorio Git.",
|
||||||
"gitSidebar.error.actionFailed": "No se pudo {action}.",
|
"gitSidebar.error.actionFailed": "No se pudo {action}.",
|
||||||
|
"gitSidebar.error.offlineMode": "Esta acción no está disponible en modo avión.",
|
||||||
"gitSidebar.error.commitFailed": "No se pudieron confirmar los cambios.",
|
"gitSidebar.error.commitFailed": "No se pudieron confirmar los cambios.",
|
||||||
"gitSidebar.progress.preparingInit": "Preparando inicialización del repositorio...",
|
"gitSidebar.progress.preparingInit": "Preparando inicialización del repositorio...",
|
||||||
"gitSidebar.progress.pushingRemote": "Enviando commits al remoto... esto puede tardar con cargas grandes.",
|
"gitSidebar.progress.pushingRemote": "Enviando commits al remoto... esto puede tardar con cargas grandes.",
|
||||||
@@ -742,6 +744,7 @@
|
|||||||
"settings.ai.providerOpenCode": "OpenCode",
|
"settings.ai.providerOpenCode": "OpenCode",
|
||||||
"settings.ai.providerMistral": "Mistral",
|
"settings.ai.providerMistral": "Mistral",
|
||||||
"settings.ai.providerOllama": "Ollama (Local)",
|
"settings.ai.providerOllama": "Ollama (Local)",
|
||||||
|
"settings.ai.providerLmstudio": "LM Studio (Local)",
|
||||||
"settings.ai.providerOther": "Otro",
|
"settings.ai.providerOther": "Otro",
|
||||||
"settings.ai.ollamaLabel": "Ollama (Modelos locales)",
|
"settings.ai.ollamaLabel": "Ollama (Modelos locales)",
|
||||||
"settings.ai.ollamaDescription": "Conéctate a una instancia local de Ollama para usar modelos de IA locales.",
|
"settings.ai.ollamaDescription": "Conéctate a una instancia local de Ollama para usar modelos de IA locales.",
|
||||||
@@ -756,6 +759,28 @@
|
|||||||
"settings.toast.modelCatalogRefreshFailed": "No se pudo actualizar el catálogo",
|
"settings.toast.modelCatalogRefreshFailed": "No se pudo actualizar el catálogo",
|
||||||
"settings.toast.ollamaEnabled": "Ollama activado",
|
"settings.toast.ollamaEnabled": "Ollama activado",
|
||||||
"settings.toast.ollamaDisabled": "Ollama desactivado",
|
"settings.toast.ollamaDisabled": "Ollama desactivado",
|
||||||
|
"settings.ai.lmstudioLabel": "LM Studio (Modelos locales)",
|
||||||
|
"settings.ai.lmstudioDescription": "Conéctate a una instancia local de LM Studio para usar modelos de IA locales.",
|
||||||
|
"settings.ai.lmstudioEnable": "Activar LM Studio",
|
||||||
|
"settings.ai.lmstudioCapabilitiesDescription": "Configurar las capacidades de cada modelo LM Studio. Activar herramientas para llamadas a funciones o visión para análisis de imágenes.",
|
||||||
|
"settings.ai.lmstudioCapModel": "Modelo",
|
||||||
|
"settings.ai.lmstudioCapTools": "Herramientas",
|
||||||
|
"settings.ai.lmstudioCapVision": "Visión",
|
||||||
|
"settings.toast.lmstudioEnabled": "LM Studio activado",
|
||||||
|
"settings.toast.lmstudioDisabled": "LM Studio desactivado",
|
||||||
|
"settings.ai.offlineLabel": "Modo avión",
|
||||||
|
"settings.ai.offlineDescription": "Cuando está activado, solo se usan modelos alojados localmente (Ollama, LM Studio). Los proveedores en la nube se desactivan.",
|
||||||
|
"settings.ai.offlineEnable": "Activar modo avión",
|
||||||
|
"settings.ai.offlineChatModel": "Modelo de chat sin conexión",
|
||||||
|
"settings.ai.offlineChatModelDescription": "Modelo usado para conversaciones en modo avión.",
|
||||||
|
"settings.ai.offlineTitleModel": "Modelo de título sin conexión",
|
||||||
|
"settings.ai.offlineTitleModelDescription": "Modelo usado para generar títulos en modo avión.",
|
||||||
|
"settings.ai.offlineImageAnalysisModel": "Modelo de análisis de imagen sin conexión",
|
||||||
|
"settings.ai.offlineImageAnalysisModelDescription": "Modelo usado para el análisis de imágenes en modo avión.",
|
||||||
|
"settings.ai.offlineNoLocalProviders": "No hay proveedores locales activados. Activa primero Ollama o LM Studio.",
|
||||||
|
"settings.ai.offlineNoLocalModels": "No hay modelos locales disponibles",
|
||||||
|
"settings.toast.offlineEnabled": "Modo avión activado",
|
||||||
|
"settings.toast.offlineDisabled": "Modo avión desactivado",
|
||||||
"settings.publishing.sshHostDescription": "Nombre de host o IP del servidor SSH.",
|
"settings.publishing.sshHostDescription": "Nombre de host o IP del servidor SSH.",
|
||||||
"settings.publishing.sshUsernameDescription": "Nombre de usuario de SSH.",
|
"settings.publishing.sshUsernameDescription": "Nombre de usuario de SSH.",
|
||||||
"settings.publishing.sshRemotePathDescription": "El directorio de destino en el servidor remoto donde se publicará tu blog.",
|
"settings.publishing.sshRemotePathDescription": "El directorio de destino en el servidor remoto donde se publicará tu blog.",
|
||||||
@@ -891,6 +916,9 @@
|
|||||||
"statusBar.theme": "Tema: {theme}",
|
"statusBar.theme": "Tema: {theme}",
|
||||||
"statusBar.ui": "UI",
|
"statusBar.ui": "UI",
|
||||||
"statusBar.uiLanguage": "Idioma de la interfaz",
|
"statusBar.uiLanguage": "Idioma de la interfaz",
|
||||||
|
"statusBar.offlineMode": "Modo avión",
|
||||||
|
"statusBar.offlineModeActive": "Modo avión (activo)",
|
||||||
|
"statusBar.offlineModeTooltip": "Haz clic para activar/desactivar el modo avión",
|
||||||
"windowTitleBar.toggleSidebar": "Alternar barra lateral",
|
"windowTitleBar.toggleSidebar": "Alternar barra lateral",
|
||||||
"windowTitleBar.hideSidebar": "Ocultar barra lateral",
|
"windowTitleBar.hideSidebar": "Ocultar barra lateral",
|
||||||
"windowTitleBar.showSidebar": "Mostrar barra lateral",
|
"windowTitleBar.showSidebar": "Mostrar barra lateral",
|
||||||
|
|||||||
@@ -32,8 +32,11 @@
|
|||||||
"app.databaseRebuildFailed": "Échec de la reconstruction de la base de données",
|
"app.databaseRebuildFailed": "Échec de la reconstruction de la base de données",
|
||||||
"app.textReindexFailed": "Échec de la réindexation du texte",
|
"app.textReindexFailed": "Échec de la réindexation du texte",
|
||||||
"app.sitemapGenerationFailed": "Échec de la génération du sitemap",
|
"app.sitemapGenerationFailed": "Échec de la génération du sitemap",
|
||||||
"app.calendarRegenerationFailed": "Échec de la régénération du calendrier", "app.uploadSiteFailed": "Échec de la publication du site",
|
"app.calendarRegenerationFailed": "Échec de la régénération du calendrier",
|
||||||
"app.uploadSiteNoCredentials": "Veuillez d'abord configurer les identifiants SSH dans les paramètres.", "app.previewOpenFailed": "Impossible d’ouvrir l’aperçu de l’article sélectionné",
|
"app.uploadSiteFailed": "Échec de la publication du site",
|
||||||
|
"app.uploadSiteOfflineMode": "La publication du site est bloquée en mode avion.",
|
||||||
|
"app.uploadSiteNoCredentials": "Veuillez d'abord configurer les identifiants SSH dans les paramètres.",
|
||||||
|
"app.previewOpenFailed": "Impossible d’ouvrir l’aperçu de l’article sélectionné",
|
||||||
"app.metadataDiff": "Diff Métadonnées",
|
"app.metadataDiff": "Diff Métadonnées",
|
||||||
"app.importComplete": "Import terminé : {posts} articles, {media} fichiers média",
|
"app.importComplete": "Import terminé : {posts} articles, {media} fichiers média",
|
||||||
"siteValidation.tabTitle": "Validation du site",
|
"siteValidation.tabTitle": "Validation du site",
|
||||||
@@ -312,6 +315,7 @@
|
|||||||
"gitSidebar.error.loadRepoStatus": "Impossible de charger l’état du dépôt.",
|
"gitSidebar.error.loadRepoStatus": "Impossible de charger l’état du dépôt.",
|
||||||
"gitSidebar.error.initFailed": "Impossible d’initialiser le dépôt Git.",
|
"gitSidebar.error.initFailed": "Impossible d’initialiser le dépôt Git.",
|
||||||
"gitSidebar.error.actionFailed": "Échec de {action}.",
|
"gitSidebar.error.actionFailed": "Échec de {action}.",
|
||||||
|
"gitSidebar.error.offlineMode": "Cette action est bloquée en mode avion.",
|
||||||
"gitSidebar.error.commitFailed": "Impossible de valider les modifications.",
|
"gitSidebar.error.commitFailed": "Impossible de valider les modifications.",
|
||||||
"gitSidebar.progress.preparingInit": "Préparation de l’initialisation du dépôt...",
|
"gitSidebar.progress.preparingInit": "Préparation de l’initialisation du dépôt...",
|
||||||
"gitSidebar.progress.pushingRemote": "Envoi des commits vers le distant... cela peut prendre un moment pour les gros envois.",
|
"gitSidebar.progress.pushingRemote": "Envoi des commits vers le distant... cela peut prendre un moment pour les gros envois.",
|
||||||
@@ -740,6 +744,7 @@
|
|||||||
"settings.ai.providerOpenCode": "OpenCode",
|
"settings.ai.providerOpenCode": "OpenCode",
|
||||||
"settings.ai.providerMistral": "Mistral",
|
"settings.ai.providerMistral": "Mistral",
|
||||||
"settings.ai.providerOllama": "Ollama (Local)",
|
"settings.ai.providerOllama": "Ollama (Local)",
|
||||||
|
"settings.ai.providerLmstudio": "LM Studio (Local)",
|
||||||
"settings.ai.providerOther": "Autre",
|
"settings.ai.providerOther": "Autre",
|
||||||
"settings.ai.ollamaLabel": "Ollama (Modèles locaux)",
|
"settings.ai.ollamaLabel": "Ollama (Modèles locaux)",
|
||||||
"settings.ai.ollamaDescription": "Connectez-vous à une instance Ollama locale pour utiliser des modèles d'IA locaux.",
|
"settings.ai.ollamaDescription": "Connectez-vous à une instance Ollama locale pour utiliser des modèles d'IA locaux.",
|
||||||
@@ -754,6 +759,28 @@
|
|||||||
"settings.toast.modelCatalogRefreshFailed": "Échec de l'actualisation du catalogue",
|
"settings.toast.modelCatalogRefreshFailed": "Échec de l'actualisation du catalogue",
|
||||||
"settings.toast.ollamaEnabled": "Ollama activé",
|
"settings.toast.ollamaEnabled": "Ollama activé",
|
||||||
"settings.toast.ollamaDisabled": "Ollama désactivé",
|
"settings.toast.ollamaDisabled": "Ollama désactivé",
|
||||||
|
"settings.ai.lmstudioLabel": "LM Studio (Modèles locaux)",
|
||||||
|
"settings.ai.lmstudioDescription": "Connectez-vous à une instance LM Studio locale pour utiliser des modèles d'IA locaux.",
|
||||||
|
"settings.ai.lmstudioEnable": "Activer LM Studio",
|
||||||
|
"settings.ai.lmstudioCapabilitiesDescription": "Configurer les capacités de chaque modèle LM Studio. Activer les outils pour les appels de fonctions ou la vision pour l'analyse d'images.",
|
||||||
|
"settings.ai.lmstudioCapModel": "Modèle",
|
||||||
|
"settings.ai.lmstudioCapTools": "Outils",
|
||||||
|
"settings.ai.lmstudioCapVision": "Vision",
|
||||||
|
"settings.toast.lmstudioEnabled": "LM Studio activé",
|
||||||
|
"settings.toast.lmstudioDisabled": "LM Studio désactivé",
|
||||||
|
"settings.ai.offlineLabel": "Mode avion",
|
||||||
|
"settings.ai.offlineDescription": "Lorsqu'il est activé, seuls les modèles hébergés localement (Ollama, LM Studio) sont utilisés. Les fournisseurs cloud sont désactivés.",
|
||||||
|
"settings.ai.offlineEnable": "Activer le mode avion",
|
||||||
|
"settings.ai.offlineChatModel": "Modèle de chat hors ligne",
|
||||||
|
"settings.ai.offlineChatModelDescription": "Modèle utilisé pour les conversations en mode avion.",
|
||||||
|
"settings.ai.offlineTitleModel": "Modèle de titre hors ligne",
|
||||||
|
"settings.ai.offlineTitleModelDescription": "Modèle utilisé pour la génération de titres en mode avion.",
|
||||||
|
"settings.ai.offlineImageAnalysisModel": "Modèle d'analyse d'image hors ligne",
|
||||||
|
"settings.ai.offlineImageAnalysisModelDescription": "Modèle utilisé pour l'analyse d'images en mode avion.",
|
||||||
|
"settings.ai.offlineNoLocalProviders": "Aucun fournisseur local activé. Activez d'abord Ollama ou LM Studio.",
|
||||||
|
"settings.ai.offlineNoLocalModels": "Aucun modèle local disponible",
|
||||||
|
"settings.toast.offlineEnabled": "Mode avion activé",
|
||||||
|
"settings.toast.offlineDisabled": "Mode avion désactivé",
|
||||||
"settings.publishing.sshHostDescription": "Nom d'hôte ou IP du serveur SSH.",
|
"settings.publishing.sshHostDescription": "Nom d'hôte ou IP du serveur SSH.",
|
||||||
"settings.publishing.sshUsernameDescription": "Nom d'utilisateur SSH.",
|
"settings.publishing.sshUsernameDescription": "Nom d'utilisateur SSH.",
|
||||||
"settings.publishing.sshRemotePathDescription": "Le répertoire de destination sur le serveur distant où votre blog sera publié.",
|
"settings.publishing.sshRemotePathDescription": "Le répertoire de destination sur le serveur distant où votre blog sera publié.",
|
||||||
@@ -889,6 +916,9 @@
|
|||||||
"statusBar.theme": "Thème : {theme}",
|
"statusBar.theme": "Thème : {theme}",
|
||||||
"statusBar.ui": "UI",
|
"statusBar.ui": "UI",
|
||||||
"statusBar.uiLanguage": "Langue de l’interface",
|
"statusBar.uiLanguage": "Langue de l’interface",
|
||||||
|
"statusBar.offlineMode": "Mode avion",
|
||||||
|
"statusBar.offlineModeActive": "Mode avion (actif)",
|
||||||
|
"statusBar.offlineModeTooltip": "Cliquer pour basculer le mode avion",
|
||||||
"windowTitleBar.toggleSidebar": "Basculer la barre latérale",
|
"windowTitleBar.toggleSidebar": "Basculer la barre latérale",
|
||||||
"windowTitleBar.hideSidebar": "Masquer la barre latérale",
|
"windowTitleBar.hideSidebar": "Masquer la barre latérale",
|
||||||
"windowTitleBar.showSidebar": "Afficher la barre latérale",
|
"windowTitleBar.showSidebar": "Afficher la barre latérale",
|
||||||
@@ -1005,9 +1035,7 @@
|
|||||||
"importAnalysis.usedIn": "Utilisé dans : {items}{more}",
|
"importAnalysis.usedIn": "Utilisé dans : {items}{more}",
|
||||||
"importAnalysis.moreSuffix": ", +{count} de plus",
|
"importAnalysis.moreSuffix": ", +{count} de plus",
|
||||||
"importAnalysis.noParameters": "(aucun paramètre)",
|
"importAnalysis.noParameters": "(aucun paramètre)",
|
||||||
|
|
||||||
"sidebar.nav.mcp": "Serveur MCP",
|
"sidebar.nav.mcp": "Serveur MCP",
|
||||||
|
|
||||||
"settings.mcp.title": "Serveur MCP",
|
"settings.mcp.title": "Serveur MCP",
|
||||||
"settings.mcp.description": "Configurez le serveur Model Context Protocol qui permet aux agents de programmation IA d'interagir avec votre blog.",
|
"settings.mcp.description": "Configurez le serveur Model Context Protocol qui permet aux agents de programmation IA d'interagir avec votre blog.",
|
||||||
"settings.mcp.statusLabel": "État du serveur",
|
"settings.mcp.statusLabel": "État du serveur",
|
||||||
|
|||||||
@@ -32,8 +32,11 @@
|
|||||||
"app.databaseRebuildFailed": "Ricostruzione database non riuscita",
|
"app.databaseRebuildFailed": "Ricostruzione database non riuscita",
|
||||||
"app.textReindexFailed": "Reindicizzazione testo non riuscita",
|
"app.textReindexFailed": "Reindicizzazione testo non riuscita",
|
||||||
"app.sitemapGenerationFailed": "Generazione sitemap non riuscita",
|
"app.sitemapGenerationFailed": "Generazione sitemap non riuscita",
|
||||||
"app.calendarRegenerationFailed": "Rigenerazione del calendario non riuscita", "app.uploadSiteFailed": "Caricamento del sito non riuscito",
|
"app.calendarRegenerationFailed": "Rigenerazione del calendario non riuscita",
|
||||||
"app.uploadSiteNoCredentials": "Configurare prima le credenziali SSH nelle impostazioni.", "app.previewOpenFailed": "Impossibile aprire l’anteprima del post selezionato",
|
"app.uploadSiteFailed": "Caricamento del sito non riuscito",
|
||||||
|
"app.uploadSiteOfflineMode": "Il caricamento del sito non è disponibile in modalità aereo.",
|
||||||
|
"app.uploadSiteNoCredentials": "Configurare prima le credenziali SSH nelle impostazioni.",
|
||||||
|
"app.previewOpenFailed": "Impossibile aprire l’anteprima del post selezionato",
|
||||||
"app.metadataDiff": "Diff Metadati",
|
"app.metadataDiff": "Diff Metadati",
|
||||||
"app.importComplete": "Import completato: {posts} post, {media} file multimediali",
|
"app.importComplete": "Import completato: {posts} post, {media} file multimediali",
|
||||||
"siteValidation.tabTitle": "Validazione sito",
|
"siteValidation.tabTitle": "Validazione sito",
|
||||||
@@ -312,6 +315,7 @@
|
|||||||
"gitSidebar.error.loadRepoStatus": "Impossibile caricare lo stato del repository.",
|
"gitSidebar.error.loadRepoStatus": "Impossibile caricare lo stato del repository.",
|
||||||
"gitSidebar.error.initFailed": "Impossibile inizializzare il repository Git.",
|
"gitSidebar.error.initFailed": "Impossibile inizializzare il repository Git.",
|
||||||
"gitSidebar.error.actionFailed": "Impossibile {action}.",
|
"gitSidebar.error.actionFailed": "Impossibile {action}.",
|
||||||
|
"gitSidebar.error.offlineMode": "Questa azione non è disponibile in modalità aereo.",
|
||||||
"gitSidebar.error.commitFailed": "Impossibile eseguire il commit delle modifiche.",
|
"gitSidebar.error.commitFailed": "Impossibile eseguire il commit delle modifiche.",
|
||||||
"gitSidebar.progress.preparingInit": "Preparazione inizializzazione repository...",
|
"gitSidebar.progress.preparingInit": "Preparazione inizializzazione repository...",
|
||||||
"gitSidebar.progress.pushingRemote": "Invio dei commit al remoto... può richiedere tempo per upload grandi.",
|
"gitSidebar.progress.pushingRemote": "Invio dei commit al remoto... può richiedere tempo per upload grandi.",
|
||||||
@@ -740,6 +744,7 @@
|
|||||||
"settings.ai.providerOpenCode": "OpenCode",
|
"settings.ai.providerOpenCode": "OpenCode",
|
||||||
"settings.ai.providerMistral": "Mistral",
|
"settings.ai.providerMistral": "Mistral",
|
||||||
"settings.ai.providerOllama": "Ollama (Locale)",
|
"settings.ai.providerOllama": "Ollama (Locale)",
|
||||||
|
"settings.ai.providerLmstudio": "LM Studio (Locale)",
|
||||||
"settings.ai.providerOther": "Altro",
|
"settings.ai.providerOther": "Altro",
|
||||||
"settings.ai.ollamaLabel": "Ollama (Modelli locali)",
|
"settings.ai.ollamaLabel": "Ollama (Modelli locali)",
|
||||||
"settings.ai.ollamaDescription": "Connettiti a un'istanza Ollama locale per utilizzare modelli IA locali.",
|
"settings.ai.ollamaDescription": "Connettiti a un'istanza Ollama locale per utilizzare modelli IA locali.",
|
||||||
@@ -754,6 +759,28 @@
|
|||||||
"settings.toast.modelCatalogRefreshFailed": "Aggiornamento del catalogo non riuscito",
|
"settings.toast.modelCatalogRefreshFailed": "Aggiornamento del catalogo non riuscito",
|
||||||
"settings.toast.ollamaEnabled": "Ollama attivato",
|
"settings.toast.ollamaEnabled": "Ollama attivato",
|
||||||
"settings.toast.ollamaDisabled": "Ollama disattivato",
|
"settings.toast.ollamaDisabled": "Ollama disattivato",
|
||||||
|
"settings.ai.lmstudioLabel": "LM Studio (Modelli locali)",
|
||||||
|
"settings.ai.lmstudioDescription": "Connettiti a un'istanza LM Studio locale per utilizzare modelli IA locali.",
|
||||||
|
"settings.ai.lmstudioEnable": "Attiva LM Studio",
|
||||||
|
"settings.ai.lmstudioCapabilitiesDescription": "Configura le capacità per ogni modello LM Studio. Attiva gli strumenti per le chiamate a funzioni o la visione per l'analisi delle immagini.",
|
||||||
|
"settings.ai.lmstudioCapModel": "Modello",
|
||||||
|
"settings.ai.lmstudioCapTools": "Strumenti",
|
||||||
|
"settings.ai.lmstudioCapVision": "Visione",
|
||||||
|
"settings.toast.lmstudioEnabled": "LM Studio attivato",
|
||||||
|
"settings.toast.lmstudioDisabled": "LM Studio disattivato",
|
||||||
|
"settings.ai.offlineLabel": "Modalità aereo",
|
||||||
|
"settings.ai.offlineDescription": "Quando attivato, vengono utilizzati solo i modelli ospitati localmente (Ollama, LM Studio). I provider cloud sono disabilitati.",
|
||||||
|
"settings.ai.offlineEnable": "Attiva modalità aereo",
|
||||||
|
"settings.ai.offlineChatModel": "Modello chat offline",
|
||||||
|
"settings.ai.offlineChatModelDescription": "Modello utilizzato per le conversazioni in modalità aereo.",
|
||||||
|
"settings.ai.offlineTitleModel": "Modello titolo offline",
|
||||||
|
"settings.ai.offlineTitleModelDescription": "Modello utilizzato per la generazione dei titoli in modalità aereo.",
|
||||||
|
"settings.ai.offlineImageAnalysisModel": "Modello analisi immagini offline",
|
||||||
|
"settings.ai.offlineImageAnalysisModelDescription": "Modello utilizzato per l'analisi delle immagini in modalità aereo.",
|
||||||
|
"settings.ai.offlineNoLocalProviders": "Nessun provider locale attivato. Attiva prima Ollama o LM Studio.",
|
||||||
|
"settings.ai.offlineNoLocalModels": "Nessun modello locale disponibile",
|
||||||
|
"settings.toast.offlineEnabled": "Modalità aereo attivata",
|
||||||
|
"settings.toast.offlineDisabled": "Modalità aereo disattivata",
|
||||||
"settings.publishing.sshHostDescription": "Hostname o IP del server SSH.",
|
"settings.publishing.sshHostDescription": "Hostname o IP del server SSH.",
|
||||||
"settings.publishing.sshUsernameDescription": "Nome utente SSH.",
|
"settings.publishing.sshUsernameDescription": "Nome utente SSH.",
|
||||||
"settings.publishing.sshRemotePathDescription": "La directory di destinazione sul server remoto in cui verrà pubblicato il tuo blog.",
|
"settings.publishing.sshRemotePathDescription": "La directory di destinazione sul server remoto in cui verrà pubblicato il tuo blog.",
|
||||||
@@ -889,6 +916,9 @@
|
|||||||
"statusBar.theme": "Tema: {theme}",
|
"statusBar.theme": "Tema: {theme}",
|
||||||
"statusBar.ui": "UI",
|
"statusBar.ui": "UI",
|
||||||
"statusBar.uiLanguage": "Lingua interfaccia",
|
"statusBar.uiLanguage": "Lingua interfaccia",
|
||||||
|
"statusBar.offlineMode": "Modalità aereo",
|
||||||
|
"statusBar.offlineModeActive": "Modalità aereo (attiva)",
|
||||||
|
"statusBar.offlineModeTooltip": "Clicca per attivare/disattivare la modalità aereo",
|
||||||
"windowTitleBar.toggleSidebar": "Mostra/Nascondi barra laterale",
|
"windowTitleBar.toggleSidebar": "Mostra/Nascondi barra laterale",
|
||||||
"windowTitleBar.hideSidebar": "Nascondi barra laterale",
|
"windowTitleBar.hideSidebar": "Nascondi barra laterale",
|
||||||
"windowTitleBar.showSidebar": "Mostra barra laterale",
|
"windowTitleBar.showSidebar": "Mostra barra laterale",
|
||||||
@@ -1005,9 +1035,7 @@
|
|||||||
"importAnalysis.usedIn": "Usato in: {items}{more}",
|
"importAnalysis.usedIn": "Usato in: {items}{more}",
|
||||||
"importAnalysis.moreSuffix": ", +{count} altri",
|
"importAnalysis.moreSuffix": ", +{count} altri",
|
||||||
"importAnalysis.noParameters": "(nessun parametro)",
|
"importAnalysis.noParameters": "(nessun parametro)",
|
||||||
|
|
||||||
"sidebar.nav.mcp": "Server MCP",
|
"sidebar.nav.mcp": "Server MCP",
|
||||||
|
|
||||||
"settings.mcp.title": "Server MCP",
|
"settings.mcp.title": "Server MCP",
|
||||||
"settings.mcp.description": "Configura il server Model Context Protocol che permette agli agenti di programmazione IA di interagire con il tuo blog.",
|
"settings.mcp.description": "Configura il server Model Context Protocol che permette agli agenti di programmazione IA di interagire con il tuo blog.",
|
||||||
"settings.mcp.statusLabel": "Stato del server",
|
"settings.mcp.statusLabel": "Stato del server",
|
||||||
|
|||||||
@@ -140,13 +140,15 @@ describe('ProviderRegistry', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('getProviderStatus() reports all providers', () => {
|
it('getProviderStatus() reports all providers', () => {
|
||||||
expect(registry.getProviderStatus()).toEqual({ opencode: false, mistral: false, ollama: false });
|
expect(registry.getProviderStatus()).toEqual({ opencode: false, mistral: false, ollama: false, lmstudio: false, offlineMode: false });
|
||||||
registry.setOpencodeKey('test');
|
registry.setOpencodeKey('test');
|
||||||
expect(registry.getProviderStatus()).toEqual({ opencode: true, mistral: false, ollama: false });
|
expect(registry.getProviderStatus()).toEqual({ opencode: true, mistral: false, ollama: false, lmstudio: false, offlineMode: false });
|
||||||
registry.setMistralKey('test2');
|
registry.setMistralKey('test2');
|
||||||
expect(registry.getProviderStatus()).toEqual({ opencode: true, mistral: true, ollama: false });
|
expect(registry.getProviderStatus()).toEqual({ opencode: true, mistral: true, ollama: false, lmstudio: false, offlineMode: false });
|
||||||
registry.setOllamaEnabled(true);
|
registry.setOllamaEnabled(true);
|
||||||
expect(registry.getProviderStatus()).toEqual({ opencode: true, mistral: true, ollama: true });
|
expect(registry.getProviderStatus()).toEqual({ opencode: true, mistral: true, ollama: true, lmstudio: false, offlineMode: false });
|
||||||
|
registry.setLmstudioEnabled(true);
|
||||||
|
expect(registry.getProviderStatus()).toEqual({ opencode: true, mistral: true, ollama: true, lmstudio: true, offlineMode: false });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('isProviderKeySet() checks per-provider', () => {
|
it('isProviderKeySet() checks per-provider', () => {
|
||||||
@@ -444,6 +446,120 @@ describe('OneShotTasks', () => {
|
|||||||
expect(result.error).toContain('thumbnail');
|
expect(result.error).toContain('thumbnail');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('uses pre-generated AI JPEG thumbnail without sharp conversion', async () => {
|
||||||
|
registry.setOpencodeKey('test-key');
|
||||||
|
chatEngine.getSetting.mockResolvedValue('claude-sonnet-4');
|
||||||
|
mediaEngine.getMedia.mockResolvedValue({
|
||||||
|
id: 'media-1',
|
||||||
|
mimeType: 'image/jpeg',
|
||||||
|
filename: 'photo.jpg',
|
||||||
|
});
|
||||||
|
// Tiny valid JPEG — simulates the pre-generated 'ai' thumbnail
|
||||||
|
const jpegBase64 = '/9j/2wBDAAYEBQYFBAYGBQYHBwYIChAKCgkJChQODwwQFxQYGBcUFhYaHSUfGhsjHBYWICwgIyYnKSopGR8tMC0oMCUoKSj/2wBDAQcHBwoIChMKChMoGhYaKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCj/wAARCAACAAIDASIAAhEBAxEB/8QAFAABAAAAAAAAAAAAAAAAAAAAAP/EABQQAQAAAAAAAAAAAAAAAAAAAAD/xAAUAQEAAAAAAAAAAAAAAAAAAAAA/8QAFBEBAAAAAAAAAAAAAAAAAAAAAP/aAAwDAQACEQMRAD8AAA//2Q==';
|
||||||
|
// Return JPEG for 'ai' size, null for others
|
||||||
|
mediaEngine.getThumbnailDataUrl.mockImplementation(async (_id: string, size: string) => {
|
||||||
|
if (size === 'ai') return `data:image/jpeg;base64,${jpegBase64}`;
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
let capturedBody: any = null;
|
||||||
|
globalThis.fetch = vi.fn().mockImplementation(async (url: string, init: any) => {
|
||||||
|
if (init?.body) {
|
||||||
|
capturedBody = JSON.parse(init.body);
|
||||||
|
}
|
||||||
|
return new Response(JSON.stringify({
|
||||||
|
id: 'msg_test',
|
||||||
|
type: 'message',
|
||||||
|
role: 'assistant',
|
||||||
|
content: [{ type: 'text', text: '{"title": "Test", "alt": "Test image", "caption": "A test"}' }],
|
||||||
|
model: 'claude-sonnet-4',
|
||||||
|
stop_reason: 'end_turn',
|
||||||
|
usage: { input_tokens: 100, output_tokens: 30, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 },
|
||||||
|
}), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await tasks.analyzeMediaImage('media-1', 'en');
|
||||||
|
// Check the image was sent as JPEG, not WebP
|
||||||
|
if (capturedBody?.messages) {
|
||||||
|
const userMsg = capturedBody.messages.find((m: any) => m.role === 'user');
|
||||||
|
if (userMsg?.content) {
|
||||||
|
const imagePart = userMsg.content.find((p: any) => p.type === 'image_url');
|
||||||
|
if (imagePart?.image_url?.url) {
|
||||||
|
expect(imagePart.image_url.url).toMatch(/^data:image\/jpeg;base64,/);
|
||||||
|
expect(imagePart.image_url.url).not.toMatch(/^data:image\/webp;base64,/);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Also verify it succeeded (may fail on response parsing but the format check is key)
|
||||||
|
if (result.success) {
|
||||||
|
expect(result.title).toBe('Test');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends localized prompts based on project language', async () => {
|
||||||
|
registry.setOpencodeKey('test-key');
|
||||||
|
chatEngine.getSetting.mockResolvedValue('claude-sonnet-4');
|
||||||
|
mediaEngine.getMedia.mockResolvedValue({
|
||||||
|
id: 'media-1',
|
||||||
|
mimeType: 'image/jpeg',
|
||||||
|
filename: 'photo.jpg',
|
||||||
|
});
|
||||||
|
// Tiny valid JPEG — simulates the pre-generated 'ai' thumbnail
|
||||||
|
const jpegBase64 = '/9j/2wBDAAYEBQYFBAYGBQYHBwYIChAKCgkJChQODwwQFxQYGBcUFhYaHSUfGhsjHBYWICwgIyYnKSopGR8tMC0oMCUoKSj/2wBDAQcHBwoIChMKChMoGhYaKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCj/wAARCAACAAIDASIAAhEBAxEB/8QAFAABAAAAAAAAAAAAAAAAAAAAAP/EABQQAQAAAAAAAAAAAAAAAAAAAAD/xAAUAQEAAAAAAAAAAAAAAAAAAAAA/8QAFBEBAAAAAAAAAAAAAAAAAAAAAP/aAAwDAQACEQMRAD8AAA//2Q==';
|
||||||
|
mediaEngine.getThumbnailDataUrl.mockImplementation(async (_id: string, size: string) => {
|
||||||
|
if (size === 'ai') return `data:image/jpeg;base64,${jpegBase64}`;
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
let capturedBody: any = null;
|
||||||
|
globalThis.fetch = vi.fn().mockImplementation(async (_url: string, init: any) => {
|
||||||
|
if (init?.body) {
|
||||||
|
capturedBody = JSON.parse(init.body);
|
||||||
|
}
|
||||||
|
return new Response(JSON.stringify({
|
||||||
|
id: 'msg_test',
|
||||||
|
type: 'message',
|
||||||
|
role: 'assistant',
|
||||||
|
content: [{ type: 'text', text: '{"title": "Testbild", "alt": "Rotes Quadrat", "caption": "Ein Test"}' }],
|
||||||
|
model: 'claude-sonnet-4',
|
||||||
|
stop_reason: 'end_turn',
|
||||||
|
usage: { input_tokens: 100, output_tokens: 30, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 },
|
||||||
|
}), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await tasks.analyzeMediaImage('media-1', 'de');
|
||||||
|
// System prompt should be in German (from i18n), not contain English instructions
|
||||||
|
if (capturedBody) {
|
||||||
|
const systemMsg = capturedBody.messages?.find((m: any) => m.role === 'system')
|
||||||
|
?? capturedBody.system;
|
||||||
|
const systemText = typeof systemMsg === 'string' ? systemMsg
|
||||||
|
: Array.isArray(systemMsg) ? systemMsg.map((p: any) => p.text).join('')
|
||||||
|
: systemMsg?.content ?? '';
|
||||||
|
expect(systemText).toContain('Deutsch');
|
||||||
|
expect(systemText).not.toContain('English');
|
||||||
|
// User message should also be in German
|
||||||
|
const userMsg = capturedBody.messages?.find((m: any) => m.role === 'user');
|
||||||
|
if (userMsg?.content) {
|
||||||
|
const textPart = Array.isArray(userMsg.content)
|
||||||
|
? userMsg.content.find((p: any) => p.type === 'text')
|
||||||
|
: null;
|
||||||
|
if (textPart?.text) {
|
||||||
|
expect(textPart.text).toContain('Deutsch');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it('falls back to claude-sonnet-4-5 when no image analysis model is configured', async () => {
|
it('falls back to claude-sonnet-4-5 when no image analysis model is configured', async () => {
|
||||||
registry.setOpencodeKey('test-key');
|
registry.setOpencodeKey('test-key');
|
||||||
chatEngine.getSetting.mockResolvedValue(null);
|
chatEngine.getSetting.mockResolvedValue(null);
|
||||||
@@ -452,7 +568,12 @@ describe('OneShotTasks', () => {
|
|||||||
mimeType: 'image/jpeg',
|
mimeType: 'image/jpeg',
|
||||||
filename: 'photo.jpg',
|
filename: 'photo.jpg',
|
||||||
});
|
});
|
||||||
mediaEngine.getThumbnailDataUrl.mockResolvedValue('data:image/webp;base64,abc123');
|
// Tiny valid JPEG — simulates the pre-generated 'ai' thumbnail
|
||||||
|
const jpegBase64 = '/9j/2wBDAAYEBQYFBAYGBQYHBwYIChAKCgkJChQODwwQFxQYGBcUFhYaHSUfGhsjHBYWICwgIyYnKSopGR8tMC0oMCUoKSj/2wBDAQcHBwoIChMKChMoGhYaKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCj/wAARCAACAAIDASIAAhEBAxEB/8QAFAABAAAAAAAAAAAAAAAAAAAAAP/EABQQAQAAAAAAAAAAAAAAAAAAAAD/xAAUAQEAAAAAAAAAAAAAAAAAAAAA/8QAFBEBAAAAAAAAAAAAAAAAAAAAAP/aAAwDAQACEQMRAD8AAA//2Q==';
|
||||||
|
mediaEngine.getThumbnailDataUrl.mockImplementation(async (_id: string, size: string) => {
|
||||||
|
if (size === 'ai') return `data:image/jpeg;base64,${jpegBase64}`;
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
// Verify the method selects the right model by checking it attempts
|
// Verify the method selects the right model by checking it attempts
|
||||||
// to call the resolver (which hits the network). We mock fetch to
|
// to call the resolver (which hits the network). We mock fetch to
|
||||||
|
|||||||
319
tests/engine/lmstudio-provider.test.ts
Normal file
319
tests/engine/lmstudio-provider.test.ts
Normal file
@@ -0,0 +1,319 @@
|
|||||||
|
/**
|
||||||
|
* Tests for LM Studio provider integration in ProviderRegistry.
|
||||||
|
*
|
||||||
|
* LM Studio provides an OpenAI-compatible API at http://localhost:1234/v1
|
||||||
|
* with a standard /v1/models endpoint for model listing.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { ProviderRegistry, LMSTUDIO_BASE_URL, LMSTUDIO_MODELS_URL } from '../../src/main/engine/ai/providers';
|
||||||
|
|
||||||
|
// Mock ModelCatalogEngine — no DB in unit tests
|
||||||
|
vi.mock('../../src/main/engine/ModelCatalogEngine', () => ({
|
||||||
|
ModelCatalogEngine: class {
|
||||||
|
getAll = vi.fn().mockResolvedValue([]);
|
||||||
|
getContextWindow = vi.fn().mockResolvedValue(null);
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('LM Studio provider support', () => {
|
||||||
|
let registry: ProviderRegistry;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
registry = new ProviderRegistry();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Constants ----
|
||||||
|
|
||||||
|
it('exports LM Studio URL constants', () => {
|
||||||
|
expect(LMSTUDIO_BASE_URL).toBe('http://localhost:1234/v1');
|
||||||
|
expect(LMSTUDIO_MODELS_URL).toBe('http://localhost:1234/v1/models');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- LM Studio enable/disable ----
|
||||||
|
|
||||||
|
it('is not enabled by default', () => {
|
||||||
|
expect(registry.isLmstudioEnabled()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('can be enabled and disabled', () => {
|
||||||
|
registry.setLmstudioEnabled(true);
|
||||||
|
expect(registry.isLmstudioEnabled()).toBe(true);
|
||||||
|
registry.setLmstudioEnabled(false);
|
||||||
|
expect(registry.isLmstudioEnabled()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('enabling LM Studio invalidates model cache', () => {
|
||||||
|
// Populate cache
|
||||||
|
registry['cachedModels'] = [{ id: 'test', name: 'test', provider: 'other' }];
|
||||||
|
registry['cachedModelsAt'] = Date.now();
|
||||||
|
|
||||||
|
registry.setLmstudioEnabled(true);
|
||||||
|
|
||||||
|
expect(registry['cachedModels']).toBeNull();
|
||||||
|
expect(registry['cachedModelsAt']).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Provider status ----
|
||||||
|
|
||||||
|
it('getProviderStatus includes lmstudio field', () => {
|
||||||
|
const status = registry.getProviderStatus();
|
||||||
|
expect(status).toHaveProperty('lmstudio');
|
||||||
|
expect(status.lmstudio).toBe(false);
|
||||||
|
|
||||||
|
registry.setLmstudioEnabled(true);
|
||||||
|
expect(registry.getProviderStatus().lmstudio).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- isReady includes lmstudio ----
|
||||||
|
|
||||||
|
it('isReady returns true when only LM Studio is enabled', () => {
|
||||||
|
expect(registry.isReady()).toBe(false);
|
||||||
|
registry.setLmstudioEnabled(true);
|
||||||
|
expect(registry.isReady()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- isProviderKeySet for lmstudio ----
|
||||||
|
|
||||||
|
it('isProviderKeySet returns lmstudio enabled state for provider "lmstudio"', () => {
|
||||||
|
expect(registry.isProviderKeySet('lmstudio')).toBe(false);
|
||||||
|
registry.setLmstudioEnabled(true);
|
||||||
|
expect(registry.isProviderKeySet('lmstudio')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- resolveModel for lmstudio ----
|
||||||
|
|
||||||
|
it('resolveModel creates an OpenAI-compatible model for LM Studio models', () => {
|
||||||
|
registry.setLmstudioEnabled(true);
|
||||||
|
registry.registerLmstudioModel('lmstudio-community/Meta-Llama-3-8B');
|
||||||
|
|
||||||
|
const model = registry.resolveModel('lmstudio-community/Meta-Llama-3-8B');
|
||||||
|
expect(model).toBeDefined();
|
||||||
|
expect(model.modelId).toBe('lmstudio-community/Meta-Llama-3-8B');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolveModel throws when LM Studio is disabled', () => {
|
||||||
|
registry.registerLmstudioModel('lmstudio-community/Meta-Llama-3-8B');
|
||||||
|
expect(() => registry.resolveModel('lmstudio-community/Meta-Llama-3-8B')).toThrow(/not configured/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- LM Studio model registration ----
|
||||||
|
|
||||||
|
it('tracks registered LM Studio model IDs', () => {
|
||||||
|
expect(registry.isLmstudioModel('some-model')).toBe(false);
|
||||||
|
registry.registerLmstudioModel('some-model');
|
||||||
|
expect(registry.isLmstudioModel('some-model')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clearLmstudioModels removes all registered models', () => {
|
||||||
|
registry.registerLmstudioModel('model-a');
|
||||||
|
registry.registerLmstudioModel('model-b');
|
||||||
|
registry.clearLmstudioModels();
|
||||||
|
expect(registry.isLmstudioModel('model-a')).toBe(false);
|
||||||
|
expect(registry.isLmstudioModel('model-b')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- detectModelProvider ----
|
||||||
|
|
||||||
|
it('detectModelProvider returns "lmstudio" for registered LM Studio models', () => {
|
||||||
|
registry.registerLmstudioModel('some-local-model');
|
||||||
|
expect(registry.detectModelProvider('some-local-model')).toBe('lmstudio');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detectModelProvider returns "ollama" for registered Ollama models (not lmstudio)', () => {
|
||||||
|
registry.registerOllamaModel('llama3:latest');
|
||||||
|
registry.registerLmstudioModel('some-model');
|
||||||
|
expect(registry.detectModelProvider('llama3:latest')).toBe('ollama');
|
||||||
|
expect(registry.detectModelProvider('some-model')).toBe('lmstudio');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- fetchLmstudioModels ----
|
||||||
|
|
||||||
|
it('fetchLmstudioModels calls the LM Studio models endpoint', async () => {
|
||||||
|
const mockFetch = vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
data: [
|
||||||
|
{ id: 'lmstudio-community/Meta-Llama-3-8B' },
|
||||||
|
{ id: 'TheBloke/Mistral-7B-v0.1-GGUF' },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
globalThis.fetch = mockFetch;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const models = await registry.fetchLmstudioModels();
|
||||||
|
expect(mockFetch).toHaveBeenCalledWith(
|
||||||
|
LMSTUDIO_MODELS_URL,
|
||||||
|
expect.objectContaining({ method: 'GET', signal: expect.any(AbortSignal) }),
|
||||||
|
);
|
||||||
|
expect(models).toHaveLength(2);
|
||||||
|
expect(models[0]).toMatchObject({ id: 'lmstudio-community/Meta-Llama-3-8B', provider: 'lmstudio' });
|
||||||
|
expect(models[1]).toMatchObject({ id: 'TheBloke/Mistral-7B-v0.1-GGUF', provider: 'lmstudio' });
|
||||||
|
} finally {
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fetchLmstudioModels returns empty array on network error', async () => {
|
||||||
|
const mockFetch = vi.fn().mockRejectedValue(new Error('ECONNREFUSED'));
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
globalThis.fetch = mockFetch;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const models = await registry.fetchLmstudioModels();
|
||||||
|
expect(models).toEqual([]);
|
||||||
|
} finally {
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fetchLmstudioModels registers returned models', async () => {
|
||||||
|
const mockFetch = vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
data: [{ id: 'my-local-model' }],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
globalThis.fetch = mockFetch;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await registry.fetchLmstudioModels();
|
||||||
|
expect(registry.isLmstudioModel('my-local-model')).toBe(true);
|
||||||
|
} finally {
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- getAvailableModels includes LM Studio when enabled ----
|
||||||
|
|
||||||
|
it('getAvailableModels includes LM Studio models when enabled', async () => {
|
||||||
|
registry.setLmstudioEnabled(true);
|
||||||
|
|
||||||
|
const mockFetch = vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
data: [{ id: 'my-local-model' }],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
globalThis.fetch = mockFetch;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const models = await registry.getAvailableModels();
|
||||||
|
const lmModels = models.filter(m => m.provider === 'lmstudio');
|
||||||
|
expect(lmModels.length).toBeGreaterThanOrEqual(1);
|
||||||
|
expect(lmModels[0].id).toBe('my-local-model');
|
||||||
|
} finally {
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getAvailableModels excludes LM Studio models when disabled', async () => {
|
||||||
|
registry.setLmstudioEnabled(false);
|
||||||
|
|
||||||
|
const mockFetch = vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ data: [{ id: 'my-local-model' }] }),
|
||||||
|
});
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
globalThis.fetch = mockFetch;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const models = await registry.getAvailableModels();
|
||||||
|
const lmModels = models.filter(m => m.provider === 'lmstudio');
|
||||||
|
expect(lmModels).toHaveLength(0);
|
||||||
|
} finally {
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- LM Studio model capability overrides ----
|
||||||
|
|
||||||
|
describe('model capability overrides', () => {
|
||||||
|
it('returns default capabilities (tools=false, vision=false) for unknown model', () => {
|
||||||
|
const caps = registry.getLmstudioModelCapabilities('unknown-model');
|
||||||
|
expect(caps).toEqual({ tools: false, vision: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stores and retrieves capability overrides for a model', () => {
|
||||||
|
registry.setLmstudioModelCapabilities('my-model', { tools: true, vision: false });
|
||||||
|
expect(registry.getLmstudioModelCapabilities('my-model')).toEqual({ tools: true, vision: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stores vision capability override', () => {
|
||||||
|
registry.setLmstudioModelCapabilities('vision-model', { tools: false, vision: true });
|
||||||
|
expect(registry.getLmstudioModelCapabilities('vision-model')).toEqual({ tools: false, vision: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('supports both capabilities enabled', () => {
|
||||||
|
registry.setLmstudioModelCapabilities('full-model', { tools: true, vision: true });
|
||||||
|
expect(registry.getLmstudioModelCapabilities('full-model')).toEqual({ tools: true, vision: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getAllLmstudioModelCapabilities returns all stored overrides', () => {
|
||||||
|
registry.setLmstudioModelCapabilities('model-a', { tools: true, vision: false });
|
||||||
|
registry.setLmstudioModelCapabilities('model-b', { tools: false, vision: true });
|
||||||
|
const all = registry.getAllLmstudioModelCapabilities();
|
||||||
|
expect(all).toEqual({
|
||||||
|
'model-a': { tools: true, vision: false },
|
||||||
|
'model-b': { tools: false, vision: true },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getAllLmstudioModelCapabilities returns empty object when no overrides', () => {
|
||||||
|
expect(registry.getAllLmstudioModelCapabilities()).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('loadLmstudioModelCapabilities restores from serialized JSON', () => {
|
||||||
|
const data = { 'my-model': { tools: true, vision: false } };
|
||||||
|
registry.loadLmstudioModelCapabilities(data);
|
||||||
|
expect(registry.getLmstudioModelCapabilities('my-model')).toEqual({ tools: true, vision: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lmstudioModelSupportsTools returns false by default', () => {
|
||||||
|
expect(registry.lmstudioModelSupportsTools('unknown')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lmstudioModelSupportsTools returns true when override is set', () => {
|
||||||
|
registry.setLmstudioModelCapabilities('my-model', { tools: true, vision: false });
|
||||||
|
expect(registry.lmstudioModelSupportsTools('my-model')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lmstudioModelSupportsVision returns false by default', () => {
|
||||||
|
expect(registry.lmstudioModelSupportsVision('unknown')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lmstudioModelSupportsVision returns true when override is set', () => {
|
||||||
|
registry.setLmstudioModelCapabilities('vision-model', { tools: false, vision: true });
|
||||||
|
expect(registry.lmstudioModelSupportsVision('vision-model')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fetchLmstudioModels applies vision overrides to returned models', async () => {
|
||||||
|
registry.setLmstudioModelCapabilities('vision-model', { tools: false, vision: true });
|
||||||
|
|
||||||
|
const mockFetch = vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
data: [
|
||||||
|
{ id: 'text-model' },
|
||||||
|
{ id: 'vision-model' },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
globalThis.fetch = mockFetch;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const models = await registry.fetchLmstudioModels();
|
||||||
|
expect(models).toHaveLength(2);
|
||||||
|
expect(models.find(m => m.id === 'text-model')?.vision).toBe(false);
|
||||||
|
expect(models.find(m => m.id === 'vision-model')?.vision).toBe(true);
|
||||||
|
} finally {
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
191
tests/engine/offline-mode.test.ts
Normal file
191
tests/engine/offline-mode.test.ts
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
/**
|
||||||
|
* Offline / airplane mode tests.
|
||||||
|
*
|
||||||
|
* Verifies ProviderRegistry offline mode behavior:
|
||||||
|
* - toggles on/off
|
||||||
|
* - restricts model resolution to local providers only
|
||||||
|
* - restricts available models to local only
|
||||||
|
* - isReady reflects local provider state
|
||||||
|
* - getProviderStatus includes offline state
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { ProviderRegistry } from '../../src/main/engine/ai/providers';
|
||||||
|
|
||||||
|
describe('ProviderRegistry offline mode', () => {
|
||||||
|
let registry: ProviderRegistry;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
registry = new ProviderRegistry();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------- toggle ----------
|
||||||
|
|
||||||
|
it('starts with offline mode disabled', () => {
|
||||||
|
expect(registry.isOfflineMode()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('can enable and disable offline mode', () => {
|
||||||
|
registry.setOfflineMode(true);
|
||||||
|
expect(registry.isOfflineMode()).toBe(true);
|
||||||
|
registry.setOfflineMode(false);
|
||||||
|
expect(registry.isOfflineMode()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------- isReady in offline mode ----------
|
||||||
|
|
||||||
|
it('isReady returns false in offline mode when no local provider enabled', () => {
|
||||||
|
registry.setOpencodeKey('test-key');
|
||||||
|
registry.setOfflineMode(true);
|
||||||
|
expect(registry.isReady()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('isReady returns true in offline mode when Ollama enabled', () => {
|
||||||
|
registry.setOfflineMode(true);
|
||||||
|
registry.setOllamaEnabled(true);
|
||||||
|
expect(registry.isReady()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('isReady returns true in offline mode when LM Studio enabled', () => {
|
||||||
|
registry.setOfflineMode(true);
|
||||||
|
registry.setLmstudioEnabled(true);
|
||||||
|
expect(registry.isReady()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------- resolveModel in offline mode ----------
|
||||||
|
|
||||||
|
it('resolveModel throws for cloud models when offline', () => {
|
||||||
|
registry.setOpencodeKey('test-key');
|
||||||
|
registry.setOfflineMode(true);
|
||||||
|
expect(() => registry.resolveModel('claude-sonnet-4')).toThrow('offline');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolveModel throws for mistral models when offline', () => {
|
||||||
|
registry.setMistralKey('test-key');
|
||||||
|
registry.setOfflineMode(true);
|
||||||
|
expect(() => registry.resolveModel('mistral-large-latest')).toThrow('offline');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolveModel succeeds for Ollama model when offline and Ollama enabled', () => {
|
||||||
|
registry.setOllamaEnabled(true);
|
||||||
|
registry.registerOllamaModel('llama3');
|
||||||
|
registry.setOfflineMode(true);
|
||||||
|
const model = registry.resolveModel('llama3');
|
||||||
|
expect(model).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolveModel succeeds for LM Studio model when offline and LM Studio enabled', () => {
|
||||||
|
registry.setLmstudioEnabled(true);
|
||||||
|
registry.registerLmstudioModel('gemma-3-12b-it');
|
||||||
|
registry.setOfflineMode(true);
|
||||||
|
const model = registry.resolveModel('gemma-3-12b-it');
|
||||||
|
expect(model).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------- getAvailableModels filtering ----------
|
||||||
|
|
||||||
|
it('getAvailableModels returns only local models when offline', async () => {
|
||||||
|
registry.setOpencodeKey('test-key');
|
||||||
|
registry.setOllamaEnabled(true);
|
||||||
|
registry.registerOllamaModel('llama3:latest');
|
||||||
|
registry.setOfflineMode(true);
|
||||||
|
|
||||||
|
// No fetch should happen at all in offline mode
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
globalThis.fetch = vi.fn().mockImplementation(async (url: string) => {
|
||||||
|
throw new Error(`Unexpected fetch to ${url} in offline mode`);
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const models = await registry.getAvailableModels();
|
||||||
|
// Only local model should appear
|
||||||
|
expect(models.length).toBe(1);
|
||||||
|
expect(models.every(m => m.provider === 'ollama' || m.provider === 'lmstudio')).toBe(true);
|
||||||
|
// fetch should NOT have been called
|
||||||
|
expect(globalThis.fetch).not.toHaveBeenCalled();
|
||||||
|
} finally {
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------- getKnownLocalModels ----------
|
||||||
|
|
||||||
|
it('getKnownLocalModels returns registered Ollama and LM Studio models', () => {
|
||||||
|
registry.setOllamaEnabled(true);
|
||||||
|
registry.setLmstudioEnabled(true);
|
||||||
|
registry.registerOllamaModel('llama3:latest');
|
||||||
|
registry.registerLmstudioModel('gemma-3-12b-it');
|
||||||
|
|
||||||
|
const models = registry.getKnownLocalModels();
|
||||||
|
expect(models.length).toBe(2);
|
||||||
|
expect(models.find(m => m.id === 'llama3:latest')?.provider).toBe('ollama');
|
||||||
|
expect(models.find(m => m.id === 'gemma-3-12b-it')?.provider).toBe('lmstudio');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getKnownLocalModels returns empty when no models registered', () => {
|
||||||
|
const models = registry.getKnownLocalModels();
|
||||||
|
expect(models.length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------- getFirstKnownLocalModelId ----------
|
||||||
|
|
||||||
|
it('getFirstKnownLocalModelId returns first registered model', () => {
|
||||||
|
registry.setOllamaEnabled(true);
|
||||||
|
registry.registerOllamaModel('llama3:latest');
|
||||||
|
registry.registerOllamaModel('mistral:latest');
|
||||||
|
expect(registry.getFirstKnownLocalModelId()).toBe('llama3:latest');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getFirstKnownLocalModelId returns null when no models registered', () => {
|
||||||
|
expect(registry.getFirstKnownLocalModelId()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getFirstKnownLocalModelId falls back to LM Studio when no Ollama models', () => {
|
||||||
|
registry.setLmstudioEnabled(true);
|
||||||
|
registry.registerLmstudioModel('gemma-3-12b-it');
|
||||||
|
expect(registry.getFirstKnownLocalModelId()).toBe('gemma-3-12b-it');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------- getProviderStatus includes offline ----------
|
||||||
|
|
||||||
|
it('getProviderStatus includes offlineMode field', () => {
|
||||||
|
registry.setOfflineMode(true);
|
||||||
|
const status = registry.getProviderStatus();
|
||||||
|
expect(status.offlineMode).toBe(true);
|
||||||
|
|
||||||
|
registry.setOfflineMode(false);
|
||||||
|
expect(registry.getProviderStatus().offlineMode).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------- isProviderKeySet in offline mode ----------
|
||||||
|
|
||||||
|
it('isProviderKeySet returns false for cloud providers when offline', () => {
|
||||||
|
registry.setOpencodeKey('test-key');
|
||||||
|
registry.setMistralKey('test-key');
|
||||||
|
registry.setOfflineMode(true);
|
||||||
|
expect(registry.isProviderKeySet('anthropic')).toBe(false);
|
||||||
|
expect(registry.isProviderKeySet('openai')).toBe(false);
|
||||||
|
expect(registry.isProviderKeySet('mistral')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('isProviderKeySet returns true for local providers when offline and enabled', () => {
|
||||||
|
registry.setOllamaEnabled(true);
|
||||||
|
registry.setLmstudioEnabled(true);
|
||||||
|
registry.setOfflineMode(true);
|
||||||
|
expect(registry.isProviderKeySet('ollama')).toBe(true);
|
||||||
|
expect(registry.isProviderKeySet('lmstudio')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------- model cache invalidation on toggle ----------
|
||||||
|
|
||||||
|
it('invalidates model cache on offline mode toggle', () => {
|
||||||
|
// Pre-populate cache
|
||||||
|
registry.setOpencodeKey('test-key');
|
||||||
|
registry.setOfflineMode(true);
|
||||||
|
// Toggling should have invalidated
|
||||||
|
expect(registry.isOfflineMode()).toBe(true);
|
||||||
|
// Toggle back
|
||||||
|
registry.setOfflineMode(false);
|
||||||
|
expect(registry.isOfflineMode()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
323
tests/engine/offline-model-fallback.test.ts
Normal file
323
tests/engine/offline-model-fallback.test.ts
Normal file
@@ -0,0 +1,323 @@
|
|||||||
|
/**
|
||||||
|
* Offline model fallback tests.
|
||||||
|
*
|
||||||
|
* Verifies that OneShotTasks.analyzeMediaImage(), ChatService.sendMessage(),
|
||||||
|
* and ChatService.generateConversationTitle() automatically fall back to
|
||||||
|
* the configured offline model when airplane mode is active.
|
||||||
|
*
|
||||||
|
* Strategy: spy on resolveModel to capture which model ID is passed,
|
||||||
|
* then let it throw to short-circuit the actual AI call — the engine's
|
||||||
|
* try/catch returns { success: false } which is fine for our assertions.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { OneShotTasks } from '../../src/main/engine/ai/tasks';
|
||||||
|
import { ChatService } from '../../src/main/engine/ai/chat';
|
||||||
|
import { ProviderRegistry } from '../../src/main/engine/ai/providers';
|
||||||
|
|
||||||
|
// Tiny valid 2x2 JPEG (base64) — avoids sharp "corrupt header" error
|
||||||
|
// eslint-disable-next-line max-len
|
||||||
|
const TINY_JPEG_B64 = '/9j/2wBDAAYEBQYFBAYGBQYHBwYIChAKCgkJChQODwwQFxQYGBcUFhYaHSUfGhsjHBYWICwgIyYnKSopGR8tMC0oMCUoKSj/2wBDAQcHBwoIChMKChMoGhYaKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCj/wAARCAACAAIDASIAAhEBAxEB/8QAFAABAAAAAAAAAAAAAAAAAAAAAP/EABQQAQAAAAAAAAAAAAAAAAAAAAD/xAAUAQEAAAAAAAAAAAAAAAAAAAAA/8QAFBEBAAAAAAAAAAAAAAAAAAAAAP/aAAwDAQACEQMRAD8AAA//2Q==';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Shared mock helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function createMockChatEngine(settings: Record<string, string | null> = {}) {
|
||||||
|
return {
|
||||||
|
getSetting: vi.fn(async (key: string) => settings[key] ?? null),
|
||||||
|
getConversation: vi.fn(),
|
||||||
|
addMessage: vi.fn(async (msg: unknown) => ({ id: 'msg-1', ...msg as Record<string, unknown> })),
|
||||||
|
getDefaultSystemPrompt: vi.fn(async () => 'You are a helpful assistant'),
|
||||||
|
updateConversation: vi.fn(),
|
||||||
|
} as unknown as InstanceType<typeof import('../../src/main/engine/ChatEngine').ChatEngine>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createMockMediaEngine() {
|
||||||
|
return {
|
||||||
|
getMedia: vi.fn(async () => ({
|
||||||
|
id: 'media-1',
|
||||||
|
mimeType: 'image/jpeg',
|
||||||
|
filename: 'test.jpg',
|
||||||
|
})),
|
||||||
|
getThumbnailDataUrl: vi.fn(async () => `data:image/jpeg;base64,${TINY_JPEG_B64}`),
|
||||||
|
} as unknown as InstanceType<typeof import('../../src/main/engine/MediaEngine').MediaEngine>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mock the ModelCatalogEngine returned by ProviderRegistry */
|
||||||
|
function mockModelCatalog(registry: ProviderRegistry): void {
|
||||||
|
vi.spyOn(registry, 'getModelCatalogEngine').mockReturnValue({
|
||||||
|
getContextWindow: vi.fn(async () => 8192),
|
||||||
|
} as never);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// OneShotTasks — analyzeMediaImage offline fallback
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('OneShotTasks offline model fallback', () => {
|
||||||
|
let registry: ProviderRegistry;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
registry = new ProviderRegistry();
|
||||||
|
registry.setOllamaEnabled(true);
|
||||||
|
registry.registerOllamaModel('llava:latest');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('analyzeMediaImage uses offline_image_analysis_model when airplane mode is on', async () => {
|
||||||
|
registry.setOpencodeKey('test-key');
|
||||||
|
registry.setOfflineMode(true);
|
||||||
|
|
||||||
|
const chatEngine = createMockChatEngine({
|
||||||
|
chat_image_analysis_model: 'claude-sonnet-4-5', // cloud model
|
||||||
|
offline_image_analysis_model: 'llava:latest', // local model
|
||||||
|
});
|
||||||
|
const tasks = new OneShotTasks(registry, chatEngine, createMockMediaEngine());
|
||||||
|
|
||||||
|
// resolveModel spy — let it throw to short-circuit the generateText call
|
||||||
|
const resolveModelSpy = vi.spyOn(registry, 'resolveModel')
|
||||||
|
.mockImplementation(() => { throw new Error('mock-stop'); });
|
||||||
|
|
||||||
|
await tasks.analyzeMediaImage('media-1', 'en');
|
||||||
|
|
||||||
|
expect(resolveModelSpy).toHaveBeenCalledWith('llava:latest');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('analyzeMediaImage auto-falls back to first local model when no offline model configured', async () => {
|
||||||
|
registry.setOpencodeKey('test-key');
|
||||||
|
registry.setOfflineMode(true);
|
||||||
|
|
||||||
|
const chatEngine = createMockChatEngine({
|
||||||
|
chat_image_analysis_model: 'claude-sonnet-4-5',
|
||||||
|
// No offline_image_analysis_model set — should auto-pick llava:latest
|
||||||
|
});
|
||||||
|
const tasks = new OneShotTasks(registry, chatEngine, createMockMediaEngine());
|
||||||
|
|
||||||
|
const resolveModelSpy = vi.spyOn(registry, 'resolveModel')
|
||||||
|
.mockImplementation(() => { throw new Error('mock-stop'); });
|
||||||
|
|
||||||
|
await tasks.analyzeMediaImage('media-1', 'en');
|
||||||
|
|
||||||
|
expect(resolveModelSpy).toHaveBeenCalledWith('llava:latest');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('analyzeMediaImage returns error when offline with no local models at all', async () => {
|
||||||
|
const emptyRegistry = new ProviderRegistry();
|
||||||
|
emptyRegistry.setOpencodeKey('test-key');
|
||||||
|
emptyRegistry.setOfflineMode(true);
|
||||||
|
|
||||||
|
const chatEngine = createMockChatEngine({
|
||||||
|
chat_image_analysis_model: 'claude-sonnet-4-5',
|
||||||
|
});
|
||||||
|
const tasks = new OneShotTasks(emptyRegistry, chatEngine, createMockMediaEngine());
|
||||||
|
|
||||||
|
const result = await tasks.analyzeMediaImage('media-1', 'en');
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.error).toContain('offline');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('analyzeMediaImage uses default model when NOT offline', async () => {
|
||||||
|
registry.setOpencodeKey('test-key');
|
||||||
|
// Offline mode is OFF
|
||||||
|
|
||||||
|
const chatEngine = createMockChatEngine({
|
||||||
|
chat_image_analysis_model: 'claude-sonnet-4-5',
|
||||||
|
offline_image_analysis_model: 'llava:latest',
|
||||||
|
});
|
||||||
|
const tasks = new OneShotTasks(registry, chatEngine, createMockMediaEngine());
|
||||||
|
|
||||||
|
const resolveModelSpy = vi.spyOn(registry, 'resolveModel')
|
||||||
|
.mockImplementation(() => { throw new Error('mock-stop'); });
|
||||||
|
|
||||||
|
await tasks.analyzeMediaImage('media-1', 'en');
|
||||||
|
|
||||||
|
// Should use the regular model, NOT the offline one
|
||||||
|
expect(resolveModelSpy).toHaveBeenCalledWith('claude-sonnet-4-5');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// ChatService — sendMessage offline fallback
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('ChatService offline model fallback', () => {
|
||||||
|
let registry: ProviderRegistry;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
registry = new ProviderRegistry();
|
||||||
|
registry.setOllamaEnabled(true);
|
||||||
|
registry.registerOllamaModel('llama3:latest');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sendMessage swaps cloud model for offline_chat_model when airplane mode is on', async () => {
|
||||||
|
registry.setOpencodeKey('test-key');
|
||||||
|
registry.setOfflineMode(true);
|
||||||
|
|
||||||
|
const chatEngine = createMockChatEngine({
|
||||||
|
offline_chat_model: 'llama3:latest',
|
||||||
|
});
|
||||||
|
chatEngine.getConversation = vi.fn(async () => ({
|
||||||
|
id: 'conv-1',
|
||||||
|
title: 'Test',
|
||||||
|
model: 'claude-sonnet-4', // cloud model on conversation
|
||||||
|
createdAt: new Date(),
|
||||||
|
messages: [],
|
||||||
|
}));
|
||||||
|
|
||||||
|
const service = new ChatService(
|
||||||
|
chatEngine,
|
||||||
|
registry,
|
||||||
|
{} as never,
|
||||||
|
() => null,
|
||||||
|
);
|
||||||
|
mockModelCatalog(registry);
|
||||||
|
|
||||||
|
// resolveModel spy — let it throw to short-circuit
|
||||||
|
const resolveModelSpy = vi.spyOn(registry, 'resolveModel')
|
||||||
|
.mockImplementation(() => { throw new Error('mock-stop'); });
|
||||||
|
|
||||||
|
const result = await service.sendMessage('conv-1', 'Hello', {});
|
||||||
|
|
||||||
|
// Model swap should have happened before resolveModel was called
|
||||||
|
expect(resolveModelSpy).toHaveBeenCalledWith('llama3:latest');
|
||||||
|
expect(result.success).toBe(false); // throws mock-stop in try/catch
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sendMessage auto-falls back to first local model when no offline_chat_model configured', async () => {
|
||||||
|
registry.setOpencodeKey('test-key');
|
||||||
|
registry.setOfflineMode(true);
|
||||||
|
|
||||||
|
const chatEngine = createMockChatEngine({
|
||||||
|
// No offline_chat_model — should auto-pick llama3:latest
|
||||||
|
});
|
||||||
|
chatEngine.getConversation = vi.fn(async () => ({
|
||||||
|
id: 'conv-1',
|
||||||
|
title: 'Test',
|
||||||
|
model: 'claude-sonnet-4',
|
||||||
|
createdAt: new Date(),
|
||||||
|
messages: [],
|
||||||
|
}));
|
||||||
|
|
||||||
|
const service = new ChatService(
|
||||||
|
chatEngine,
|
||||||
|
registry,
|
||||||
|
{} as never,
|
||||||
|
() => null,
|
||||||
|
);
|
||||||
|
mockModelCatalog(registry);
|
||||||
|
|
||||||
|
const resolveModelSpy = vi.spyOn(registry, 'resolveModel')
|
||||||
|
.mockImplementation(() => { throw new Error('mock-stop'); });
|
||||||
|
|
||||||
|
await service.sendMessage('conv-1', 'Hello', {});
|
||||||
|
|
||||||
|
expect(resolveModelSpy).toHaveBeenCalledWith('llama3:latest');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sendMessage returns error when offline with no local models at all', async () => {
|
||||||
|
const emptyRegistry = new ProviderRegistry();
|
||||||
|
emptyRegistry.setOpencodeKey('test-key');
|
||||||
|
emptyRegistry.setOfflineMode(true);
|
||||||
|
|
||||||
|
const chatEngine = createMockChatEngine({});
|
||||||
|
chatEngine.getConversation = vi.fn(async () => ({
|
||||||
|
id: 'conv-1',
|
||||||
|
title: 'Test',
|
||||||
|
model: 'claude-sonnet-4',
|
||||||
|
createdAt: new Date(),
|
||||||
|
messages: [],
|
||||||
|
}));
|
||||||
|
|
||||||
|
const service = new ChatService(
|
||||||
|
chatEngine,
|
||||||
|
emptyRegistry,
|
||||||
|
{} as never,
|
||||||
|
() => null,
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await service.sendMessage('conv-1', 'Hello', {});
|
||||||
|
|
||||||
|
// With no local providers enabled, isReady() returns false
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sendMessage keeps local model when conversation already uses local model and offline', async () => {
|
||||||
|
registry.setOfflineMode(true);
|
||||||
|
|
||||||
|
const chatEngine = createMockChatEngine({});
|
||||||
|
chatEngine.getConversation = vi.fn(async () => ({
|
||||||
|
id: 'conv-1',
|
||||||
|
title: 'Test',
|
||||||
|
model: 'llama3:latest', // already a local model
|
||||||
|
createdAt: new Date(),
|
||||||
|
messages: [],
|
||||||
|
}));
|
||||||
|
|
||||||
|
const service = new ChatService(
|
||||||
|
chatEngine,
|
||||||
|
registry,
|
||||||
|
{} as never,
|
||||||
|
() => null,
|
||||||
|
);
|
||||||
|
mockModelCatalog(registry);
|
||||||
|
|
||||||
|
const resolveModelSpy = vi.spyOn(registry, 'resolveModel')
|
||||||
|
.mockImplementation(() => { throw new Error('mock-stop'); });
|
||||||
|
|
||||||
|
await service.sendMessage('conv-1', 'Hello', {});
|
||||||
|
|
||||||
|
// Should use the local model directly, no swap needed
|
||||||
|
expect(resolveModelSpy).toHaveBeenCalledWith('llama3:latest');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// ChatService — generateConversationTitle offline fallback
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('ChatService title generation offline fallback', () => {
|
||||||
|
let registry: ProviderRegistry;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
registry = new ProviderRegistry();
|
||||||
|
registry.setOllamaEnabled(true);
|
||||||
|
registry.registerOllamaModel('llama3:latest');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('title generation silently skips when offline with no offline_title_model', async () => {
|
||||||
|
registry.setOpencodeKey('test-key');
|
||||||
|
registry.setOfflineMode(true);
|
||||||
|
|
||||||
|
const chatEngine = createMockChatEngine({
|
||||||
|
chat_title_model: 'claude-haiku-4-5', // cloud model
|
||||||
|
// No offline_title_model set
|
||||||
|
});
|
||||||
|
chatEngine.getConversation = vi.fn(async () => ({
|
||||||
|
id: 'conv-1',
|
||||||
|
title: 'Test',
|
||||||
|
model: 'llama3:latest', // local model for chat
|
||||||
|
createdAt: new Date(),
|
||||||
|
messages: [],
|
||||||
|
}));
|
||||||
|
|
||||||
|
const service = new ChatService(
|
||||||
|
chatEngine,
|
||||||
|
registry,
|
||||||
|
{} as never,
|
||||||
|
() => null,
|
||||||
|
);
|
||||||
|
mockModelCatalog(registry);
|
||||||
|
|
||||||
|
// resolveModel used for chat model; title generation should be skipped
|
||||||
|
const resolveModelSpy = vi.spyOn(registry, 'resolveModel')
|
||||||
|
.mockImplementation(() => { throw new Error('mock-stop'); });
|
||||||
|
|
||||||
|
await service.sendMessage('conv-1', 'Hello', {});
|
||||||
|
|
||||||
|
// resolveModel should only be called once — for the chat model, not for title
|
||||||
|
// (title generation is skipped silently when offline with no offline_title_model)
|
||||||
|
const calls = resolveModelSpy.mock.calls;
|
||||||
|
expect(calls.some(c => c[0] === 'claude-haiku-4-5')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -333,6 +333,11 @@ vi.mock('fs/promises', () => ({
|
|||||||
unlink: vi.fn(),
|
unlink: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
let mockOfflineMode = false;
|
||||||
|
vi.mock('../../src/main/ipc/chatHandlers', () => ({
|
||||||
|
isOfflineModeActive: vi.fn(() => mockOfflineMode),
|
||||||
|
}));
|
||||||
|
|
||||||
// Helper to invoke a registered handler
|
// Helper to invoke a registered handler
|
||||||
async function invokeHandler(channel: string, ...args: any[]): Promise<any> {
|
async function invokeHandler(channel: string, ...args: any[]): Promise<any> {
|
||||||
const handler = registeredHandlers.get(channel);
|
const handler = registeredHandlers.get(channel);
|
||||||
@@ -383,6 +388,7 @@ describe('IPC Handlers', () => {
|
|||||||
registeredHandlers.clear();
|
registeredHandlers.clear();
|
||||||
mockGeneratedFileHashStore.clear();
|
mockGeneratedFileHashStore.clear();
|
||||||
resetMockCounters();
|
resetMockCounters();
|
||||||
|
mockOfflineMode = false;
|
||||||
|
|
||||||
// Create a real BlogGenerationEngine with mock engines for blog handler tests
|
// Create a real BlogGenerationEngine with mock engines for blog handler tests
|
||||||
const { BlogGenerationEngine } = await import('../../src/main/engine/BlogGenerationEngine');
|
const { BlogGenerationEngine } = await import('../../src/main/engine/BlogGenerationEngine');
|
||||||
@@ -513,6 +519,64 @@ describe('IPC Handlers', () => {
|
|||||||
behind: 1,
|
behind: 1,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should return zeroed state when offline mode is active', async () => {
|
||||||
|
mockOfflineMode = true;
|
||||||
|
|
||||||
|
const result = await invokeHandler('git:remoteState', '/repo');
|
||||||
|
|
||||||
|
expect(mockGitEngine.getRemoteState).not.toHaveBeenCalled();
|
||||||
|
expect(result).toEqual({ ahead: 0, behind: 0 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('offline mode blocks network git operations', () => {
|
||||||
|
it('should block git:fetch when offline mode is active', async () => {
|
||||||
|
mockOfflineMode = true;
|
||||||
|
|
||||||
|
const result = await invokeHandler('git:fetch', '/repo');
|
||||||
|
|
||||||
|
expect(mockGitEngine.fetch).not.toHaveBeenCalled();
|
||||||
|
expect(result).toEqual({ success: false, code: 'offline' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should block git:pull when offline mode is active', async () => {
|
||||||
|
mockOfflineMode = true;
|
||||||
|
|
||||||
|
const result = await invokeHandler('git:pull', '/repo');
|
||||||
|
|
||||||
|
expect(mockGitEngine.pull).not.toHaveBeenCalled();
|
||||||
|
expect(result).toEqual({ success: false, code: 'offline' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should block git:push when offline mode is active', async () => {
|
||||||
|
mockOfflineMode = true;
|
||||||
|
|
||||||
|
const result = await invokeHandler('git:push', '/repo');
|
||||||
|
|
||||||
|
expect(mockGitEngine.push).not.toHaveBeenCalled();
|
||||||
|
expect(result).toEqual({ success: false, code: 'offline' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow git:fetch when offline mode is inactive', async () => {
|
||||||
|
mockOfflineMode = false;
|
||||||
|
mockGitEngine.fetch.mockResolvedValue({ success: true });
|
||||||
|
|
||||||
|
const result = await invokeHandler('git:fetch', '/repo');
|
||||||
|
|
||||||
|
expect(mockGitEngine.fetch).toHaveBeenCalledWith('/repo');
|
||||||
|
expect(result).toEqual({ success: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow git:commitAll regardless of offline mode', async () => {
|
||||||
|
mockOfflineMode = true;
|
||||||
|
mockGitEngine.commitAll.mockResolvedValue({ success: true });
|
||||||
|
|
||||||
|
const result = await invokeHandler('git:commitAll', '/repo', 'test commit');
|
||||||
|
|
||||||
|
expect(mockGitEngine.commitAll).toHaveBeenCalledWith('/repo', 'test commit');
|
||||||
|
expect(result).toEqual({ success: true });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('git:diffContent', () => {
|
describe('git:diffContent', () => {
|
||||||
@@ -738,6 +802,21 @@ describe('IPC Handlers', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ============ Publish Handlers ============
|
||||||
|
describe('Publish Handlers', () => {
|
||||||
|
describe('publish:uploadSite offline guard', () => {
|
||||||
|
it('should throw when offline mode is active', async () => {
|
||||||
|
mockOfflineMode = true;
|
||||||
|
|
||||||
|
await expect(invokeHandler('publish:uploadSite', {
|
||||||
|
sshHost: 'example.com',
|
||||||
|
sshUser: 'deploy',
|
||||||
|
sshRemotePath: '/var/www',
|
||||||
|
})).rejects.toThrow('Airplane mode');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// ============ Project Handlers ============
|
// ============ Project Handlers ============
|
||||||
describe('Project Handlers', () => {
|
describe('Project Handlers', () => {
|
||||||
describe('projects:create', () => {
|
describe('projects:create', () => {
|
||||||
|
|||||||
@@ -1098,4 +1098,30 @@ describe('GitSidebar', () => {
|
|||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it.each(['fetch', 'pull', 'push'] as const)('shows error modal instead of inline error when %s returns offline code', async (action) => {
|
||||||
|
(window as any).electronAPI.git.getRepoState = vi.fn().mockResolvedValue({
|
||||||
|
isRepo: true,
|
||||||
|
rootPath: '/repo/path',
|
||||||
|
currentBranch: 'main',
|
||||||
|
hasRemote: true,
|
||||||
|
});
|
||||||
|
(window as any).electronAPI.git[action] = vi.fn().mockResolvedValue({ success: false, code: 'offline' });
|
||||||
|
|
||||||
|
render(<GitSidebar />);
|
||||||
|
|
||||||
|
const button = await screen.findByRole('button', { name: new RegExp(`^${action}$`, 'i') });
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(button);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Should set errorModal in the store
|
||||||
|
const store = getStore();
|
||||||
|
expect(store.errorModal).not.toBeNull();
|
||||||
|
expect(store.errorModal!.message).toBe('This action is blocked while airplane mode is active.');
|
||||||
|
|
||||||
|
// Should NOT show inline error in the sidebar
|
||||||
|
expect(screen.queryByText('This action is blocked while airplane mode is active.')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user