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:
@@ -129,7 +129,7 @@ export interface GitLfsPruneResult {
|
||||
|
||||
export interface GitActionResult {
|
||||
success: boolean;
|
||||
code?: 'auth-required' | 'conflict' | 'network' | 'action-failed';
|
||||
code?: 'auth-required' | 'conflict' | 'network' | 'action-failed' | 'offline';
|
||||
error?: string;
|
||||
guidance?: string[];
|
||||
}
|
||||
|
||||
@@ -10,11 +10,12 @@ import { media, Media, NewMedia, postMedia } from '../database/schema';
|
||||
import { stemText, stemQuery, SupportedLanguage } from './stemmer';
|
||||
import { CliNotifier, NoopNotifier } from './CliNotifier';
|
||||
|
||||
// Thumbnail sizes
|
||||
// Thumbnail sizes — 'ai' is a dedicated JPEG thumbnail for vision-model input
|
||||
const THUMBNAIL_SIZES = {
|
||||
small: { width: 150, height: 150 },
|
||||
medium: { width: 400, height: 400 },
|
||||
large: { width: 800, height: 800 },
|
||||
small: { width: 150, height: 150, ext: 'webp' as const, mime: 'image/webp' as const },
|
||||
medium: { width: 400, height: 400, ext: 'webp' as const, mime: 'image/webp' as const },
|
||||
large: { width: 800, height: 800, ext: 'webp' as const, mime: 'image/webp' as const },
|
||||
ai: { width: 448, height: 448, ext: 'jpg' as const, mime: 'image/jpeg' as const },
|
||||
} as const;
|
||||
|
||||
type ThumbnailSize = keyof typeof THUMBNAIL_SIZES;
|
||||
@@ -244,17 +245,26 @@ export class MediaEngine extends EventEmitter {
|
||||
// Dynamic import of sharp (it's a native module)
|
||||
const sharp = (await import('sharp')).default;
|
||||
|
||||
for (const [size, dimensions] of Object.entries(THUMBNAIL_SIZES) as [ThumbnailSize, { width: number; height: number }][]) {
|
||||
const thumbnailPath = path.join(thumbnailSubDir, `${mediaId}-${size}.webp`);
|
||||
for (const [size, config] of Object.entries(THUMBNAIL_SIZES) as [ThumbnailSize, (typeof THUMBNAIL_SIZES)[ThumbnailSize]][]) {
|
||||
const thumbnailPath = path.join(thumbnailSubDir, `${mediaId}-${size}.${config.ext}`);
|
||||
|
||||
await sharp(sourcePath)
|
||||
.resize(dimensions.width, dimensions.height, {
|
||||
fit: 'inside',
|
||||
withoutEnlargement: true,
|
||||
})
|
||||
.webp({ quality: 80 })
|
||||
.toFile(thumbnailPath);
|
||||
// AI thumbnail: exact 448×448 with black letterboxing for vision models.
|
||||
// All others: fit inside bounding box, no upscaling.
|
||||
const isAI = size === 'ai';
|
||||
let pipeline = sharp(sourcePath)
|
||||
.resize(config.width, config.height, {
|
||||
fit: isAI ? 'contain' : 'inside',
|
||||
withoutEnlargement: !isAI,
|
||||
background: { r: 0, g: 0, b: 0 },
|
||||
});
|
||||
|
||||
if (config.ext === 'jpg') {
|
||||
pipeline = pipeline.jpeg({ quality: 85 });
|
||||
} else {
|
||||
pipeline = pipeline.webp({ quality: 80 });
|
||||
}
|
||||
|
||||
await pipeline.toFile(thumbnailPath);
|
||||
thumbnails[size] = thumbnailPath;
|
||||
}
|
||||
|
||||
@@ -276,10 +286,11 @@ export class MediaEngine extends EventEmitter {
|
||||
small: null,
|
||||
medium: null,
|
||||
large: null,
|
||||
ai: null,
|
||||
};
|
||||
|
||||
for (const size of Object.keys(THUMBNAIL_SIZES) as ThumbnailSize[]) {
|
||||
const thumbnailPath = path.join(thumbnailSubDir, `${mediaId}-${size}.webp`);
|
||||
const thumbnailPath = path.join(thumbnailSubDir, `${mediaId}-${size}.${THUMBNAIL_SIZES[size].ext}`);
|
||||
try {
|
||||
await fs.access(thumbnailPath);
|
||||
result[size] = thumbnailPath;
|
||||
@@ -296,11 +307,12 @@ export class MediaEngine extends EventEmitter {
|
||||
*/
|
||||
async getThumbnailDataUrl(mediaId: string, size: ThumbnailSize = 'small'): Promise<string | null> {
|
||||
const thumbnailSubDir = this.getThumbnailSubDir(mediaId);
|
||||
const thumbnailPath = path.join(thumbnailSubDir, `${mediaId}-${size}.webp`);
|
||||
const config = THUMBNAIL_SIZES[size];
|
||||
const thumbnailPath = path.join(thumbnailSubDir, `${mediaId}-${size}.${config.ext}`);
|
||||
|
||||
try {
|
||||
const data = await fs.readFile(thumbnailPath);
|
||||
return `data:image/webp;base64,${data.toString('base64')}`;
|
||||
return `data:${config.mime};base64,${data.toString('base64')}`;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@@ -313,7 +325,7 @@ export class MediaEngine extends EventEmitter {
|
||||
const thumbnailSubDir = this.getThumbnailSubDir(mediaId);
|
||||
|
||||
for (const size of Object.keys(THUMBNAIL_SIZES) as ThumbnailSize[]) {
|
||||
const thumbnailPath = path.join(thumbnailSubDir, `${mediaId}-${size}.webp`);
|
||||
const thumbnailPath = path.join(thumbnailSubDir, `${mediaId}-${size}.${THUMBNAIL_SIZES[size].ext}`);
|
||||
try {
|
||||
await fs.unlink(thumbnailPath);
|
||||
} catch {
|
||||
@@ -1166,7 +1178,7 @@ export class MediaEngine extends EventEmitter {
|
||||
for (const item of imageMedia) {
|
||||
const thumbnails = await this.getThumbnailPaths(item.id);
|
||||
// Consider missing if any size is missing
|
||||
if (!thumbnails.small || !thumbnails.medium || !thumbnails.large) {
|
||||
if (!thumbnails.small || !thumbnails.medium || !thumbnails.large || !thumbnails.ai) {
|
||||
missingThumbnails.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -246,7 +246,21 @@ export class ChatService {
|
||||
const abortController = new AbortController();
|
||||
this.abortControllers.set(conversationId, abortController);
|
||||
|
||||
const modelId = conversation.model || 'claude-sonnet-4';
|
||||
let modelId = conversation.model || 'claude-sonnet-4';
|
||||
|
||||
// In offline mode, swap to the configured offline chat model
|
||||
if (this.providers.isOfflineMode()) {
|
||||
if (!this.providers.isOllamaModel(modelId) && !this.providers.isLmstudioModel(modelId)) {
|
||||
const offlineModel = await this.chatEngine.getSetting('offline_chat_model')
|
||||
|| this.providers.getFirstKnownLocalModelId();
|
||||
if (offlineModel) {
|
||||
modelId = offlineModel;
|
||||
} else {
|
||||
return { success: false, error: 'No offline chat model configured. Set one in Settings → AI → Airplane Mode.' };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const provider = this.providers.detectModelProvider(modelId);
|
||||
|
||||
// Verify provider key is available
|
||||
@@ -271,9 +285,11 @@ export class ChatService {
|
||||
|
||||
const aiMessages = dbMessagesToAIMessages(dbMessages);
|
||||
|
||||
// Build tools (skip for Ollama models unless capability override is set)
|
||||
// Build tools (skip for Ollama/LM Studio models unless capability override is set)
|
||||
const isOllama = this.providers.isOllamaModel(modelId);
|
||||
const skipTools = isOllama && !this.providers.ollamaModelSupportsTools(modelId);
|
||||
const isLmstudio = this.providers.isLmstudioModel(modelId);
|
||||
const skipTools = (isOllama && !this.providers.ollamaModelSupportsTools(modelId))
|
||||
|| (isLmstudio && !this.providers.lmstudioModelSupportsTools(modelId));
|
||||
const blogTools = skipTools ? {} : createBlogTools(this.blogToolDeps);
|
||||
const a2uiToolsRaw = skipTools ? {} : createA2UITools();
|
||||
const allTools = { ...blogTools, ...a2uiToolsRaw };
|
||||
@@ -447,6 +463,18 @@ export class ChatService {
|
||||
? 'mistral-small-latest'
|
||||
: null;
|
||||
}
|
||||
|
||||
// In offline mode, swap to the configured offline title model
|
||||
if (this.providers.isOfflineMode()) {
|
||||
const offlineModel = await this.chatEngine.getSetting('offline_title_model')
|
||||
|| this.providers.getFirstKnownLocalModelId();
|
||||
if (offlineModel) {
|
||||
titleModel = offlineModel;
|
||||
} else if (!titleModel || (!this.providers.isOllamaModel(titleModel) && !this.providers.isLmstudioModel(titleModel))) {
|
||||
return; // No offline title model — skip title generation silently
|
||||
}
|
||||
}
|
||||
|
||||
if (!titleModel) return;
|
||||
|
||||
const model = this.providers.resolveModel(titleModel);
|
||||
|
||||
@@ -29,9 +29,12 @@ export const ZEN_MODELS_URL = 'https://opencode.ai/zen/v1/models';
|
||||
export const MISTRAL_MODELS_URL = 'https://api.mistral.ai/v1/models';
|
||||
export const OLLAMA_BASE_URL = 'http://localhost:11434/v1';
|
||||
export const OLLAMA_TAGS_URL = 'http://localhost:11434/api/tags';
|
||||
export const LMSTUDIO_BASE_URL = 'http://localhost:1234/v1';
|
||||
export const LMSTUDIO_MODELS_URL = 'http://localhost:1234/v1/models';
|
||||
|
||||
const MODEL_CACHE_TTL = 5 * 60 * 1000; // 5 minutes
|
||||
const OLLAMA_FETCH_TIMEOUT = 3000; // 3 s — fail fast when Ollama isn't running
|
||||
const LMSTUDIO_FETCH_TIMEOUT = 3000; // 3 s — fail fast when LM Studio isn't running
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Gateway factory
|
||||
@@ -108,12 +111,28 @@ export class ProviderRegistry {
|
||||
private ollamaProvider: ReturnType<typeof createOpenAI> | null = null;
|
||||
private ollamaModelIds = new Set<string>();
|
||||
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 _offlineMode = false;
|
||||
|
||||
// Model cache
|
||||
private cachedModels: ChatModel[] | null = null;
|
||||
private cachedModelsAt = 0;
|
||||
|
||||
// ---- Offline / airplane mode ----
|
||||
|
||||
setOfflineMode(enabled: boolean): void {
|
||||
this._offlineMode = enabled;
|
||||
this.invalidateModelCache();
|
||||
}
|
||||
|
||||
isOfflineMode(): boolean {
|
||||
return this._offlineMode;
|
||||
}
|
||||
|
||||
// ---- Key management ----
|
||||
|
||||
setOpencodeKey(key: string): void {
|
||||
@@ -203,33 +222,109 @@ export class ProviderRegistry {
|
||||
return this.ollamaCapabilities.get(modelId)?.vision ?? false;
|
||||
}
|
||||
|
||||
// ---- LM Studio management ----
|
||||
|
||||
setLmstudioEnabled(enabled: boolean): void {
|
||||
this.lmstudioEnabled = enabled;
|
||||
this.lmstudioProvider = null;
|
||||
this.invalidateModelCache();
|
||||
}
|
||||
|
||||
isLmstudioEnabled(): boolean {
|
||||
return this.lmstudioEnabled;
|
||||
}
|
||||
|
||||
/** Register a model ID as belonging to LM Studio. */
|
||||
registerLmstudioModel(modelId: string): void {
|
||||
this.lmstudioModelIds.add(modelId);
|
||||
}
|
||||
|
||||
/** Check whether a model ID was registered as an LM Studio model. */
|
||||
isLmstudioModel(modelId: string): boolean {
|
||||
return this.lmstudioModelIds.has(modelId);
|
||||
}
|
||||
|
||||
/** Remove all registered LM Studio model IDs. */
|
||||
clearLmstudioModels(): void {
|
||||
this.lmstudioModelIds.clear();
|
||||
}
|
||||
|
||||
// ---- LM Studio model capability overrides ----
|
||||
|
||||
/** Get capability overrides for a specific LM Studio model (defaults to tools=false, vision=false). */
|
||||
getLmstudioModelCapabilities(modelId: string): { tools: boolean; vision: boolean } {
|
||||
return this.lmstudioCapabilities.get(modelId) ?? { tools: false, vision: false };
|
||||
}
|
||||
|
||||
/** Set capability overrides for a specific LM Studio model. */
|
||||
setLmstudioModelCapabilities(modelId: string, caps: { tools: boolean; vision: boolean }): void {
|
||||
this.lmstudioCapabilities.set(modelId, caps);
|
||||
this.invalidateModelCache();
|
||||
}
|
||||
|
||||
/** Get all stored LM Studio capability overrides as a plain object. */
|
||||
getAllLmstudioModelCapabilities(): Record<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.
|
||||
*/
|
||||
detectModelProvider(modelId: string): string {
|
||||
if (this.ollamaModelIds.has(modelId)) return 'ollama';
|
||||
if (this.lmstudioModelIds.has(modelId)) return 'lmstudio';
|
||||
return detectProvider(modelId);
|
||||
}
|
||||
|
||||
/** Check whether at least one provider key is configured. */
|
||||
isReady(): boolean {
|
||||
return !!(this.opencodeKey || this.mistralKey || this.ollamaEnabled);
|
||||
if (this._offlineMode) {
|
||||
return !!(this.ollamaEnabled || this.lmstudioEnabled);
|
||||
}
|
||||
return !!(this.opencodeKey || this.mistralKey || this.ollamaEnabled || this.lmstudioEnabled);
|
||||
}
|
||||
|
||||
/** Check whether the key for a specific provider is set. */
|
||||
isProviderKeySet(provider: string): boolean {
|
||||
if (provider === 'mistral') return !!this.mistralKey;
|
||||
if (provider === 'ollama') return this.ollamaEnabled;
|
||||
if (provider === 'lmstudio') return this.lmstudioEnabled;
|
||||
// In offline mode, cloud providers are unavailable
|
||||
if (this._offlineMode) return false;
|
||||
if (provider === 'mistral') return !!this.mistralKey;
|
||||
return !!this.opencodeKey;
|
||||
}
|
||||
|
||||
/** Returns status of all configured providers. */
|
||||
getProviderStatus(): { opencode: boolean; mistral: boolean; ollama: boolean } {
|
||||
getProviderStatus(): { opencode: boolean; mistral: boolean; ollama: boolean; lmstudio: boolean; offlineMode: boolean } {
|
||||
return {
|
||||
opencode: !!this.opencodeKey,
|
||||
mistral: !!this.mistralKey,
|
||||
ollama: this.ollamaEnabled,
|
||||
lmstudio: this.lmstudioEnabled,
|
||||
offlineMode: this._offlineMode,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -237,6 +332,11 @@ export class ProviderRegistry {
|
||||
|
||||
/** Resolve a model ID to an AI SDK LanguageModel. */
|
||||
resolveModel(modelId: string): LanguageModel {
|
||||
// In offline mode, only local providers are allowed
|
||||
if (this._offlineMode && !this.ollamaModelIds.has(modelId) && !this.lmstudioModelIds.has(modelId)) {
|
||||
throw new Error(`Model '${modelId}' is not available offline. Switch to a local model or disable airplane mode.`);
|
||||
}
|
||||
|
||||
// Check if this is a registered Ollama model first
|
||||
if (this.ollamaModelIds.has(modelId)) {
|
||||
if (!this.ollamaEnabled) {
|
||||
@@ -251,6 +351,20 @@ export class ProviderRegistry {
|
||||
return this.ollamaProvider.chat(modelId);
|
||||
}
|
||||
|
||||
// Check if this is a registered LM Studio model
|
||||
if (this.lmstudioModelIds.has(modelId)) {
|
||||
if (!this.lmstudioEnabled) {
|
||||
throw new Error(`LM Studio not configured for model '${modelId}'`);
|
||||
}
|
||||
if (!this.lmstudioProvider) {
|
||||
this.lmstudioProvider = createOpenAI({
|
||||
baseURL: LMSTUDIO_BASE_URL,
|
||||
apiKey: 'lm-studio', // LM Studio doesn't need a real key
|
||||
});
|
||||
}
|
||||
return this.lmstudioProvider.chat(modelId);
|
||||
}
|
||||
|
||||
const provider = detectProvider(modelId);
|
||||
|
||||
if (provider === 'mistral') {
|
||||
@@ -285,18 +399,66 @@ export class ProviderRegistry {
|
||||
return this.modelCatalogEngine;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the first known local model ID, or null if none registered.
|
||||
* Used as automatic fallback when no explicit offline model is configured.
|
||||
*/
|
||||
getFirstKnownLocalModelId(): string | null {
|
||||
for (const id of this.ollamaModelIds) return id;
|
||||
for (const id of this.lmstudioModelIds) return id;
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the first known local vision-capable model ID, or null.
|
||||
*/
|
||||
getFirstKnownLocalVisionModelId(): string | null {
|
||||
for (const id of this.ollamaModelIds) {
|
||||
if (this.ollamaModelSupportsVision(id)) return id;
|
||||
}
|
||||
for (const id of this.lmstudioModelIds) {
|
||||
if (this.lmstudioModelSupportsVision(id)) return id;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return models already known to belong to local providers (Ollama + LM Studio)
|
||||
* from in-memory sets, without any network fetch.
|
||||
*/
|
||||
getKnownLocalModels(): ChatModel[] {
|
||||
const models: ChatModel[] = [];
|
||||
for (const id of this.ollamaModelIds) {
|
||||
models.push({ id, name: id, provider: 'ollama', vision: this.ollamaModelSupportsVision(id) });
|
||||
}
|
||||
for (const id of this.lmstudioModelIds) {
|
||||
models.push({ id, name: id, provider: 'lmstudio', vision: this.lmstudioModelSupportsVision(id) });
|
||||
}
|
||||
return models;
|
||||
}
|
||||
|
||||
/** Get available models across all configured providers (cached 5 min). */
|
||||
async getAvailableModels(): Promise<ChatModel[]> {
|
||||
if (this.cachedModels && Date.now() - this.cachedModelsAt < MODEL_CACHE_TTL) {
|
||||
return this.cachedModels;
|
||||
}
|
||||
|
||||
// In offline mode, return known local models instantly — no network.
|
||||
if (this._offlineMode) {
|
||||
const local = this.getKnownLocalModels();
|
||||
if (local.length > 0) {
|
||||
this.cachedModels = local;
|
||||
this.cachedModelsAt = Date.now();
|
||||
}
|
||||
return local;
|
||||
}
|
||||
|
||||
const allModels: ChatModel[] = [];
|
||||
let fetched = false;
|
||||
const { vision: catalogVision, names: catalogNames } = await this.getCatalogLookups();
|
||||
|
||||
// Fetch OpenCode models
|
||||
if (this.opencodeKey) {
|
||||
// Fetch OpenCode models (skip in offline mode)
|
||||
if (this.opencodeKey && !this._offlineMode) {
|
||||
try {
|
||||
const models = await this.fetchModelsFromEndpoint(
|
||||
ZEN_MODELS_URL,
|
||||
@@ -311,8 +473,8 @@ export class ProviderRegistry {
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch Mistral models
|
||||
if (this.mistralKey) {
|
||||
// Fetch Mistral models (skip in offline mode)
|
||||
if (this.mistralKey && !this._offlineMode) {
|
||||
try {
|
||||
const models = await this.fetchModelsFromEndpoint(
|
||||
MISTRAL_MODELS_URL,
|
||||
@@ -339,6 +501,17 @@ export class ProviderRegistry {
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch LM Studio models
|
||||
if (this.lmstudioEnabled) {
|
||||
try {
|
||||
const models = await this.fetchLmstudioModels();
|
||||
allModels.push(...models);
|
||||
if (models.length > 0) fetched = true;
|
||||
} catch {
|
||||
// LM Studio not running — skip silently
|
||||
}
|
||||
}
|
||||
|
||||
if (fetched && allModels.length > 0) {
|
||||
this.cachedModels = allModels;
|
||||
this.cachedModelsAt = Date.now();
|
||||
@@ -393,6 +566,38 @@ export class ProviderRegistry {
|
||||
}
|
||||
}
|
||||
|
||||
// ---- LM Studio model listing ----
|
||||
|
||||
/**
|
||||
* Fetch available models from LM Studio's OpenAI-compatible /v1/models endpoint.
|
||||
* Returns ChatModel[] and registers the model IDs internally.
|
||||
*/
|
||||
async fetchLmstudioModels(): Promise<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 ----
|
||||
|
||||
/**
|
||||
@@ -410,16 +615,15 @@ export class ProviderRegistry {
|
||||
const data = await response.json() as { models?: Array<{ name: string; details?: { family?: string } }> };
|
||||
if (!data.models || !Array.isArray(data.models)) return [];
|
||||
|
||||
const models: ChatModel[] = data.models.map(m => ({
|
||||
id: m.name,
|
||||
name: m.name,
|
||||
provider: 'ollama',
|
||||
vision: this.ollamaModelSupportsVision(m.name),
|
||||
}));
|
||||
// Only replace registered IDs on successful fetch
|
||||
this.clearOllamaModels();
|
||||
const models: ChatModel[] = data.models.map(m => {
|
||||
this.registerOllamaModel(m.name);
|
||||
return {
|
||||
id: m.name,
|
||||
name: m.name,
|
||||
provider: 'ollama',
|
||||
vision: this.ollamaModelSupportsVision(m.name),
|
||||
};
|
||||
});
|
||||
for (const m of models) this.registerOllamaModel(m.id);
|
||||
return models;
|
||||
} catch {
|
||||
return [];
|
||||
|
||||
@@ -9,6 +9,7 @@ import { generateText } from 'ai';
|
||||
import type { ChatEngine } from '../ChatEngine';
|
||||
import type { MediaEngine } from '../MediaEngine';
|
||||
import { ProviderRegistry } from './providers';
|
||||
import { resolveSupportedRenderLanguage, translateRender } from '../../shared/i18n';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
@@ -29,17 +30,6 @@ export interface ImageAnalysisResult {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Language map for image analysis prompts
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const LANGUAGE_NAMES: Record<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
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -70,7 +60,7 @@ export class OneShotTasks {
|
||||
): Promise<TaxonomyAnalysisResult> {
|
||||
const provider = this.providers.detectModelProvider(modelId);
|
||||
if (!this.providers.isProviderKeySet(provider)) {
|
||||
const providerLabel = provider === 'mistral' ? 'Mistral' : provider === 'ollama' ? 'Ollama' : 'OpenCode';
|
||||
const providerLabel = provider === 'mistral' ? 'Mistral' : provider === 'ollama' ? 'Ollama' : provider === 'lmstudio' ? 'LM Studio' : 'OpenCode';
|
||||
return { success: false, error: `${providerLabel} API key not set` };
|
||||
}
|
||||
|
||||
@@ -194,6 +184,19 @@ Remember: Only suggest mappings from NEW items to EXISTING items. Consider langu
|
||||
? 'mistral-large-latest'
|
||||
: null;
|
||||
}
|
||||
|
||||
// In offline mode, swap to the configured offline image analysis model
|
||||
if (this.providers.isOfflineMode()) {
|
||||
const offlineModel = await this.chatEngine.getSetting('offline_image_analysis_model')
|
||||
|| this.providers.getFirstKnownLocalVisionModelId()
|
||||
|| this.providers.getFirstKnownLocalModelId();
|
||||
if (offlineModel) {
|
||||
modelId = offlineModel;
|
||||
} else if (!modelId || (!this.providers.isOllamaModel(modelId) && !this.providers.isLmstudioModel(modelId))) {
|
||||
return { success: false, error: 'No offline image analysis model configured. Set one in Settings → AI → Airplane Mode.' };
|
||||
}
|
||||
}
|
||||
|
||||
if (!modelId) {
|
||||
return { success: false, error: 'API key not configured. Please set an API key in Settings.' };
|
||||
}
|
||||
@@ -205,23 +208,40 @@ Remember: Only suggest mappings from NEW items to EXISTING items. Consider langu
|
||||
return { success: false, error: `Cannot analyze this file type: ${mediaItem.mimeType}. Only images are supported.` };
|
||||
}
|
||||
|
||||
// Get thumbnail
|
||||
let dataUrl = await this.mediaEngine.getThumbnailDataUrl(mediaId, 'large');
|
||||
if (!dataUrl) dataUrl = await this.mediaEngine.getThumbnailDataUrl(mediaId, 'medium');
|
||||
// Get AI-optimised JPEG thumbnail (512px, pre-generated).
|
||||
// Falls back to large/medium WebP thumbnails for older media items.
|
||||
let dataUrl = await this.mediaEngine.getThumbnailDataUrl(mediaId, 'ai');
|
||||
let needsConversion = false;
|
||||
if (!dataUrl) {
|
||||
dataUrl = await this.mediaEngine.getThumbnailDataUrl(mediaId, 'large');
|
||||
needsConversion = true;
|
||||
}
|
||||
if (!dataUrl) {
|
||||
dataUrl = await this.mediaEngine.getThumbnailDataUrl(mediaId, 'medium');
|
||||
needsConversion = true;
|
||||
}
|
||||
if (!dataUrl) {
|
||||
return { success: false, error: 'Image thumbnail not available. Try regenerating thumbnails from Settings.' };
|
||||
}
|
||||
|
||||
const base64Data = dataUrl.replace(/^data:image\/\w+;base64,/, '');
|
||||
const languageName = LANGUAGE_NAMES[language] || language;
|
||||
|
||||
const systemPrompt = `Generate title, alt text, and caption for this image in ${languageName}.
|
||||
let jpegBase64: string;
|
||||
if (needsConversion) {
|
||||
// Legacy path: convert WebP thumbnail to JPEG for model compatibility.
|
||||
const sharp = (await import('sharp')).default;
|
||||
const jpegBuffer = await sharp(Buffer.from(base64Data, 'base64'))
|
||||
.jpeg({ quality: 85 })
|
||||
.toBuffer();
|
||||
jpegBase64 = jpegBuffer.toString('base64');
|
||||
} else {
|
||||
// Fast path: AI thumbnail is already JPEG — use directly.
|
||||
jpegBase64 = base64Data;
|
||||
}
|
||||
|
||||
TITLE: A short, descriptive title for display in lists and search results (3-8 words). Should identify the main subject.
|
||||
ALT: Describe ONLY what is visually present in the image. Be factual, neutral, and concise (5-12 words max). No interpretations, emotions, or "Image of" prefix. Example: "Red bicycle leaning against white brick wall"
|
||||
CAPTION: Short, engaging blog caption (5-20 words).
|
||||
|
||||
Respond with JSON only: {"title": "...", "alt": "...", "caption": "..."}`;
|
||||
const renderLanguage = resolveSupportedRenderLanguage(language);
|
||||
const systemPrompt = translateRender(renderLanguage, 'ai.imageAnalysis.system');
|
||||
const userPrompt = translateRender(renderLanguage, 'ai.imageAnalysis.user');
|
||||
|
||||
try {
|
||||
const model = this.providers.resolveModel(modelId);
|
||||
@@ -233,8 +253,8 @@ Respond with JSON only: {"title": "...", "alt": "...", "caption": "..."}`;
|
||||
messages: [{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'image', image: `data:image/webp;base64,${base64Data}` },
|
||||
{ type: 'text', text: 'Analyze and respond with JSON.' },
|
||||
{ type: 'image', image: `data:image/jpeg;base64,${jpegBase64}` },
|
||||
{ type: 'text', text: userPrompt },
|
||||
],
|
||||
}],
|
||||
maxOutputTokens: 200,
|
||||
|
||||
Reference in New Issue
Block a user