feat: ollama support
This commit is contained in:
@@ -16,7 +16,7 @@ import type { BrowserWindow } from 'electron';
|
|||||||
import type { ChatEngine, ChatMessageData } from '../ChatEngine';
|
import type { ChatEngine, ChatMessageData } from '../ChatEngine';
|
||||||
import { isRenderTool, generateFromToolCall } from '../../a2ui/generator';
|
import { isRenderTool, generateFromToolCall } from '../../a2ui/generator';
|
||||||
import type { A2UIServerMessage } from '../../a2ui/types';
|
import type { A2UIServerMessage } from '../../a2ui/types';
|
||||||
import { ProviderRegistry, detectProvider } from './providers';
|
import { ProviderRegistry } from './providers';
|
||||||
import { createBlogTools, type BlogToolDeps } from './blog-tools';
|
import { createBlogTools, type BlogToolDeps } from './blog-tools';
|
||||||
import { createA2UITools } from './a2ui-tools';
|
import { createA2UITools } from './a2ui-tools';
|
||||||
|
|
||||||
@@ -247,11 +247,11 @@ export class ChatService {
|
|||||||
this.abortControllers.set(conversationId, abortController);
|
this.abortControllers.set(conversationId, abortController);
|
||||||
|
|
||||||
const modelId = conversation.model || 'claude-sonnet-4';
|
const modelId = conversation.model || 'claude-sonnet-4';
|
||||||
const provider = detectProvider(modelId);
|
const provider = this.providers.detectModelProvider(modelId);
|
||||||
|
|
||||||
// Verify provider key is available
|
// Verify provider key is available
|
||||||
if (!this.providers.isProviderKeySet(provider)) {
|
if (!this.providers.isProviderKeySet(provider)) {
|
||||||
const providerLabel = provider === 'mistral' ? 'Mistral' : 'OpenCode';
|
const providerLabel = provider === 'mistral' ? 'Mistral' : provider === 'ollama' ? 'Ollama' : 'OpenCode';
|
||||||
return { success: false, error: `The model '${modelId}' requires a ${providerLabel} API key. Configure it in Settings.` };
|
return { success: false, error: `The model '${modelId}' requires a ${providerLabel} API key. Configure it in Settings.` };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -271,10 +271,13 @@ export class ChatService {
|
|||||||
|
|
||||||
const aiMessages = dbMessagesToAIMessages(dbMessages);
|
const aiMessages = dbMessagesToAIMessages(dbMessages);
|
||||||
|
|
||||||
// Build tools
|
// Build tools (skip for Ollama models unless capability override is set)
|
||||||
const blogTools = createBlogTools(this.blogToolDeps);
|
const isOllama = this.providers.isOllamaModel(modelId);
|
||||||
const a2uiToolsRaw = createA2UITools();
|
const skipTools = isOllama && !this.providers.ollamaModelSupportsTools(modelId);
|
||||||
|
const blogTools = skipTools ? {} : createBlogTools(this.blogToolDeps);
|
||||||
|
const a2uiToolsRaw = skipTools ? {} : createA2UITools();
|
||||||
const allTools = { ...blogTools, ...a2uiToolsRaw };
|
const allTools = { ...blogTools, ...a2uiToolsRaw };
|
||||||
|
const hasTools = Object.keys(allTools).length > 0;
|
||||||
|
|
||||||
// Get context window for truncation
|
// Get context window for truncation
|
||||||
const contextWindow = await this.providers.getModelCatalogEngine().getContextWindow(modelId) ?? 150_000;
|
const contextWindow = await this.providers.getModelCatalogEngine().getContextWindow(modelId) ?? 150_000;
|
||||||
@@ -301,12 +304,14 @@ export class ChatService {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// --- streamText: the AI SDK replaces our entire SSE/accumulator/tool-loop ---
|
// --- streamText: the AI SDK replaces our entire SSE/accumulator/tool-loop ---
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- tools may be empty for Ollama models
|
||||||
|
const toolsOption = hasTools ? allTools : undefined as any;
|
||||||
const result = streamText({
|
const result = streamText({
|
||||||
model,
|
model,
|
||||||
system: systemPrompt,
|
system: systemPrompt,
|
||||||
messages: truncatedMessages,
|
messages: truncatedMessages,
|
||||||
tools: allTools,
|
tools: toolsOption,
|
||||||
stopWhen: stepCountIs(MAX_TOOL_ROUNDS),
|
stopWhen: hasTools ? stepCountIs(MAX_TOOL_ROUNDS) : undefined,
|
||||||
abortSignal: abortController.signal,
|
abortSignal: abortController.signal,
|
||||||
maxRetries: 3,
|
maxRetries: 3,
|
||||||
providerOptions,
|
providerOptions,
|
||||||
@@ -435,7 +440,7 @@ export class ChatService {
|
|||||||
let titleModel = await this.chatEngine.getSetting('chat_title_model');
|
let titleModel = await this.chatEngine.getSetting('chat_title_model');
|
||||||
|
|
||||||
// Fallback chain: setting → haiku → mistral-small
|
// Fallback chain: setting → haiku → mistral-small
|
||||||
if (!titleModel || !this.providers.isProviderKeySet(detectProvider(titleModel))) {
|
if (!titleModel || !this.providers.isProviderKeySet(this.providers.detectModelProvider(titleModel))) {
|
||||||
titleModel = this.providers.getOpencodeKey()
|
titleModel = this.providers.getOpencodeKey()
|
||||||
? 'claude-haiku-4-5'
|
? 'claude-haiku-4-5'
|
||||||
: this.providers.getMistralKey()
|
: this.providers.getMistralKey()
|
||||||
|
|||||||
@@ -27,8 +27,11 @@ import type { ChatModel } from '../../shared/electronApi';
|
|||||||
export const ZEN_BASE_URL = 'https://opencode.ai/zen/v1';
|
export const ZEN_BASE_URL = 'https://opencode.ai/zen/v1';
|
||||||
export const ZEN_MODELS_URL = 'https://opencode.ai/zen/v1/models';
|
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_TAGS_URL = 'http://localhost:11434/api/tags';
|
||||||
|
|
||||||
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
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Gateway factory
|
// Gateway factory
|
||||||
@@ -99,8 +102,12 @@ export function detectProvider(modelId: string): string {
|
|||||||
export class ProviderRegistry {
|
export class ProviderRegistry {
|
||||||
private opencodeKey = '';
|
private opencodeKey = '';
|
||||||
private mistralKey = '';
|
private mistralKey = '';
|
||||||
|
private ollamaEnabled = false;
|
||||||
private opencodeGateway: Provider | null = null;
|
private opencodeGateway: Provider | null = null;
|
||||||
private mistralProvider: ReturnType<typeof createMistral> | null = null;
|
private mistralProvider: ReturnType<typeof createMistral> | null = null;
|
||||||
|
private ollamaProvider: ReturnType<typeof createOpenAI> | null = null;
|
||||||
|
private ollamaModelIds = new Set<string>();
|
||||||
|
private ollamaCapabilities = new Map<string, { tools: boolean; vision: boolean }>();
|
||||||
private modelCatalogEngine = new ModelCatalogEngine();
|
private modelCatalogEngine = new ModelCatalogEngine();
|
||||||
|
|
||||||
// Model cache
|
// Model cache
|
||||||
@@ -129,22 +136,100 @@ export class ProviderRegistry {
|
|||||||
return this.mistralKey;
|
return this.mistralKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Ollama management ----
|
||||||
|
|
||||||
|
setOllamaEnabled(enabled: boolean): void {
|
||||||
|
this.ollamaEnabled = enabled;
|
||||||
|
this.ollamaProvider = null;
|
||||||
|
this.invalidateModelCache();
|
||||||
|
}
|
||||||
|
|
||||||
|
isOllamaEnabled(): boolean {
|
||||||
|
return this.ollamaEnabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Register a model ID as belonging to Ollama. */
|
||||||
|
registerOllamaModel(modelId: string): void {
|
||||||
|
this.ollamaModelIds.add(modelId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Check whether a model ID was registered as an Ollama model. */
|
||||||
|
isOllamaModel(modelId: string): boolean {
|
||||||
|
return this.ollamaModelIds.has(modelId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remove all registered Ollama model IDs. */
|
||||||
|
clearOllamaModels(): void {
|
||||||
|
this.ollamaModelIds.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Ollama model capability overrides ----
|
||||||
|
|
||||||
|
/** Get capability overrides for a specific Ollama model (defaults to tools=false, vision=false). */
|
||||||
|
getOllamaModelCapabilities(modelId: string): { tools: boolean; vision: boolean } {
|
||||||
|
return this.ollamaCapabilities.get(modelId) ?? { tools: false, vision: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Set capability overrides for a specific Ollama model. */
|
||||||
|
setOllamaModelCapabilities(modelId: string, caps: { tools: boolean; vision: boolean }): void {
|
||||||
|
this.ollamaCapabilities.set(modelId, caps);
|
||||||
|
this.invalidateModelCache();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get all stored capability overrides as a plain object. */
|
||||||
|
getAllOllamaModelCapabilities(): Record<string, { tools: boolean; vision: boolean }> {
|
||||||
|
const result: Record<string, { tools: boolean; vision: boolean }> = {};
|
||||||
|
for (const [id, caps] of this.ollamaCapabilities) {
|
||||||
|
result[id] = caps;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Load capability overrides from a serialized object (e.g. from settings DB). */
|
||||||
|
loadOllamaModelCapabilities(data: Record<string, { tools: boolean; vision: boolean }>): void {
|
||||||
|
this.ollamaCapabilities.clear();
|
||||||
|
for (const [id, caps] of Object.entries(data)) {
|
||||||
|
this.ollamaCapabilities.set(id, caps);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Check whether an Ollama model has tools capability enabled. */
|
||||||
|
ollamaModelSupportsTools(modelId: string): boolean {
|
||||||
|
return this.ollamaCapabilities.get(modelId)?.tools ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Check whether an Ollama model has vision capability enabled. */
|
||||||
|
ollamaModelSupportsVision(modelId: string): boolean {
|
||||||
|
return this.ollamaCapabilities.get(modelId)?.vision ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect the effective provider for a model ID, checking Ollama
|
||||||
|
* registration first, then falling back to prefix-based detection.
|
||||||
|
*/
|
||||||
|
detectModelProvider(modelId: string): string {
|
||||||
|
if (this.ollamaModelIds.has(modelId)) return 'ollama';
|
||||||
|
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);
|
return !!(this.opencodeKey || this.mistralKey || this.ollamaEnabled);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 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 === 'mistral') return !!this.mistralKey;
|
||||||
|
if (provider === 'ollama') return this.ollamaEnabled;
|
||||||
return !!this.opencodeKey;
|
return !!this.opencodeKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Returns status of all configured providers. */
|
/** Returns status of all configured providers. */
|
||||||
getProviderStatus(): { opencode: boolean; mistral: boolean } {
|
getProviderStatus(): { opencode: boolean; mistral: boolean; ollama: boolean } {
|
||||||
return {
|
return {
|
||||||
opencode: !!this.opencodeKey,
|
opencode: !!this.opencodeKey,
|
||||||
mistral: !!this.mistralKey,
|
mistral: !!this.mistralKey,
|
||||||
|
ollama: this.ollamaEnabled,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,6 +237,20 @@ 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 {
|
||||||
|
// Check if this is a registered Ollama model first
|
||||||
|
if (this.ollamaModelIds.has(modelId)) {
|
||||||
|
if (!this.ollamaEnabled) {
|
||||||
|
throw new Error(`Ollama not configured for model '${modelId}'`);
|
||||||
|
}
|
||||||
|
if (!this.ollamaProvider) {
|
||||||
|
this.ollamaProvider = createOpenAI({
|
||||||
|
baseURL: OLLAMA_BASE_URL,
|
||||||
|
apiKey: 'ollama', // Ollama doesn't need a real key
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return this.ollamaProvider.chat(modelId);
|
||||||
|
}
|
||||||
|
|
||||||
const provider = detectProvider(modelId);
|
const provider = detectProvider(modelId);
|
||||||
|
|
||||||
if (provider === 'mistral') {
|
if (provider === 'mistral') {
|
||||||
@@ -229,6 +328,17 @@ export class ProviderRegistry {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fetch Ollama models
|
||||||
|
if (this.ollamaEnabled) {
|
||||||
|
try {
|
||||||
|
const models = await this.fetchOllamaModels();
|
||||||
|
allModels.push(...models);
|
||||||
|
if (models.length > 0) fetched = true;
|
||||||
|
} catch {
|
||||||
|
// Ollama 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();
|
||||||
@@ -283,6 +393,39 @@ export class ProviderRegistry {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Ollama model listing ----
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch available models from Ollama's /api/tags endpoint.
|
||||||
|
* Returns ChatModel[] and registers the model IDs internally.
|
||||||
|
*/
|
||||||
|
async fetchOllamaModels(): Promise<ChatModel[]> {
|
||||||
|
try {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeout = setTimeout(() => controller.abort(), OLLAMA_FETCH_TIMEOUT);
|
||||||
|
const response = await fetch(OLLAMA_TAGS_URL, { method: 'GET', signal: controller.signal });
|
||||||
|
clearTimeout(timeout);
|
||||||
|
if (!response.ok) return [];
|
||||||
|
|
||||||
|
const data = await response.json() as { models?: Array<{ name: string; details?: { family?: string } }> };
|
||||||
|
if (!data.models || !Array.isArray(data.models)) return [];
|
||||||
|
|
||||||
|
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),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return models;
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---- Private helpers ----
|
// ---- Private helpers ----
|
||||||
|
|
||||||
private async fetchModelsFromEndpoint(
|
private async fetchModelsFromEndpoint(
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
import { generateText } from 'ai';
|
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, detectProvider } from './providers';
|
import { ProviderRegistry } from './providers';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Types
|
// Types
|
||||||
@@ -68,9 +68,9 @@ export class OneShotTasks {
|
|||||||
tags: Array<{ name: string; slug: string; existsInProject: boolean }>,
|
tags: Array<{ name: string; slug: string; existsInProject: boolean }>,
|
||||||
modelId: string,
|
modelId: string,
|
||||||
): Promise<TaxonomyAnalysisResult> {
|
): Promise<TaxonomyAnalysisResult> {
|
||||||
const provider = detectProvider(modelId);
|
const provider = this.providers.detectModelProvider(modelId);
|
||||||
if (!this.providers.isProviderKeySet(provider)) {
|
if (!this.providers.isProviderKeySet(provider)) {
|
||||||
const providerLabel = provider === 'mistral' ? 'Mistral' : 'OpenCode';
|
const providerLabel = provider === 'mistral' ? 'Mistral' : provider === 'ollama' ? 'Ollama' : 'OpenCode';
|
||||||
return { success: false, error: `${providerLabel} API key not set` };
|
return { success: false, error: `${providerLabel} API key not set` };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -187,7 +187,7 @@ Remember: Only suggest mappings from NEW items to EXISTING items. Consider langu
|
|||||||
): Promise<ImageAnalysisResult> {
|
): Promise<ImageAnalysisResult> {
|
||||||
// Determine model with smart fallback
|
// Determine model with smart fallback
|
||||||
let modelId = await this.chatEngine.getSetting('chat_image_analysis_model');
|
let modelId = await this.chatEngine.getSetting('chat_image_analysis_model');
|
||||||
if (!modelId || !this.providers.isProviderKeySet(detectProvider(modelId))) {
|
if (!modelId || !this.providers.isProviderKeySet(this.providers.detectModelProvider(modelId))) {
|
||||||
modelId = this.providers.getOpencodeKey()
|
modelId = this.providers.getOpencodeKey()
|
||||||
? 'claude-sonnet-4-5'
|
? 'claude-sonnet-4-5'
|
||||||
: this.providers.getMistralKey()
|
: this.providers.getMistralKey()
|
||||||
|
|||||||
@@ -109,6 +109,21 @@ async function ensureInitialized(): Promise<void> {
|
|||||||
const mistralKey = await keyStore.retrieve('mistral_api_key');
|
const mistralKey = await keyStore.retrieve('mistral_api_key');
|
||||||
if (mistralKey) reg.setMistralKey(mistralKey);
|
if (mistralKey) reg.setMistralKey(mistralKey);
|
||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
|
|
||||||
|
// Restore Ollama enabled state from settings DB
|
||||||
|
try {
|
||||||
|
const ollamaEnabled = await getChatEngine().getSetting('ollama_enabled');
|
||||||
|
if (ollamaEnabled === 'true') reg.setOllamaEnabled(true);
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
|
||||||
|
// Restore Ollama model capability overrides
|
||||||
|
try {
|
||||||
|
const capsJson = await getChatEngine().getSetting('ollama_model_capabilities');
|
||||||
|
if (capsJson) {
|
||||||
|
const caps = JSON.parse(capsJson) as Record<string, { tools: boolean; vision: boolean }>;
|
||||||
|
reg.loadOllamaModelCapabilities(caps);
|
||||||
|
}
|
||||||
|
} catch { /* ignore */ }
|
||||||
})();
|
})();
|
||||||
}
|
}
|
||||||
await initPromise;
|
await initPromise;
|
||||||
@@ -237,6 +252,74 @@ export function registerChatHandlers(): void {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ============ Ollama (Local) ============
|
||||||
|
|
||||||
|
// Get Ollama enabled state
|
||||||
|
ipcMain.handle('chat:getOllamaEnabled', async () => {
|
||||||
|
try {
|
||||||
|
await ensureInitialized();
|
||||||
|
return getProviders().isOllamaEnabled();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Chat IPC] Error getting Ollama enabled state:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set Ollama enabled state
|
||||||
|
ipcMain.handle('chat:setOllamaEnabled', async (_, enabled: boolean) => {
|
||||||
|
try {
|
||||||
|
await ensureInitialized();
|
||||||
|
const reg = getProviders();
|
||||||
|
reg.setOllamaEnabled(enabled);
|
||||||
|
|
||||||
|
// Persist to settings DB
|
||||||
|
await getChatEngine().setSetting('ollama_enabled', enabled ? 'true' : 'false');
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Chat IPC] Error setting Ollama enabled state:', error);
|
||||||
|
return { success: false, error: (error as Error).message };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get Ollama models (probe local server)
|
||||||
|
ipcMain.handle('chat:getOllamaModels', async () => {
|
||||||
|
try {
|
||||||
|
await ensureInitialized();
|
||||||
|
return await getProviders().fetchOllamaModels();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Chat IPC] Error fetching Ollama models:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get Ollama model capability overrides
|
||||||
|
ipcMain.handle('chat:getOllamaModelCapabilities', async () => {
|
||||||
|
try {
|
||||||
|
await ensureInitialized();
|
||||||
|
return getProviders().getAllOllamaModelCapabilities();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Chat IPC] Error getting Ollama model capabilities:', error);
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set capability overrides for a single Ollama model
|
||||||
|
ipcMain.handle('chat:setOllamaModelCapabilities', async (_, modelId: string, caps: { tools: boolean; vision: boolean }) => {
|
||||||
|
try {
|
||||||
|
await ensureInitialized();
|
||||||
|
const reg = getProviders();
|
||||||
|
reg.setOllamaModelCapabilities(modelId, caps);
|
||||||
|
|
||||||
|
// Persist all capabilities to settings DB
|
||||||
|
const allCaps = reg.getAllOllamaModelCapabilities();
|
||||||
|
await getChatEngine().setSetting('ollama_model_capabilities', JSON.stringify(allCaps));
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Chat IPC] Error setting Ollama model capabilities:', 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
|
||||||
|
|||||||
@@ -314,6 +314,13 @@ export const electronAPI: ElectronAPI = {
|
|||||||
setMistralApiKey: (apiKey: string) => ipcRenderer.invoke('chat:setMistralApiKey', apiKey),
|
setMistralApiKey: (apiKey: string) => ipcRenderer.invoke('chat:setMistralApiKey', apiKey),
|
||||||
getMistralApiKey: () => ipcRenderer.invoke('chat:getMistralApiKey'),
|
getMistralApiKey: () => ipcRenderer.invoke('chat:getMistralApiKey'),
|
||||||
|
|
||||||
|
// Ollama (Local)
|
||||||
|
getOllamaEnabled: () => ipcRenderer.invoke('chat:getOllamaEnabled'),
|
||||||
|
setOllamaEnabled: (enabled: boolean) => ipcRenderer.invoke('chat:setOllamaEnabled', enabled),
|
||||||
|
getOllamaModels: () => ipcRenderer.invoke('chat:getOllamaModels'),
|
||||||
|
getOllamaModelCapabilities: () => ipcRenderer.invoke('chat:getOllamaModelCapabilities'),
|
||||||
|
setOllamaModelCapabilities: (modelId: string, caps: { tools: boolean; vision: boolean }) => ipcRenderer.invoke('chat:setOllamaModelCapabilities', modelId, caps),
|
||||||
|
|
||||||
// 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),
|
||||||
|
|||||||
@@ -451,7 +451,7 @@ export interface ChatReadyStatus {
|
|||||||
ready: boolean;
|
ready: boolean;
|
||||||
error?: string;
|
error?: string;
|
||||||
backend?: string;
|
backend?: string;
|
||||||
providers?: { opencode: boolean; mistral: boolean };
|
providers?: { opencode: boolean; mistral: boolean; ollama: boolean };
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ChatApiKeyStatus {
|
export interface ChatApiKeyStatus {
|
||||||
@@ -832,6 +832,13 @@ export interface ElectronAPI {
|
|||||||
setMistralApiKey: (apiKey: string) => Promise<{ success: boolean; error?: string }>;
|
setMistralApiKey: (apiKey: string) => Promise<{ success: boolean; error?: string }>;
|
||||||
getMistralApiKey: () => Promise<ChatApiKeyStatus>;
|
getMistralApiKey: () => Promise<ChatApiKeyStatus>;
|
||||||
|
|
||||||
|
// Ollama (local)
|
||||||
|
getOllamaEnabled: () => Promise<boolean>;
|
||||||
|
setOllamaEnabled: (enabled: boolean) => Promise<{ success: boolean; error?: string }>;
|
||||||
|
getOllamaModels: () => Promise<ChatModel[]>;
|
||||||
|
getOllamaModelCapabilities: () => Promise<Record<string, { tools: boolean; vision: boolean }>>;
|
||||||
|
setOllamaModelCapabilities: (modelId: string, caps: { tools: boolean; vision: boolean }) => Promise<{ success: boolean; error?: string }>;
|
||||||
|
|
||||||
// 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 }>;
|
||||||
|
|||||||
@@ -532,3 +532,36 @@
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Ollama model capabilities table */
|
||||||
|
.ollama-model-capabilities {
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ollama-model-capabilities .setting-description {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ollama-caps-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ollama-caps-table th,
|
||||||
|
.ollama-caps-table td {
|
||||||
|
padding: 4px 8px;
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid var(--pico-muted-border-color, #ccc);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ollama-caps-table th:not(:first-child),
|
||||||
|
.ollama-caps-table td:not(:first-child) {
|
||||||
|
text-align: center;
|
||||||
|
width: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ollama-caps-table input[type="checkbox"] {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|||||||
@@ -245,6 +245,9 @@ export const SettingsView: React.FC = () => {
|
|||||||
const [aiHasMistralKey, setAiHasMistralKey] = useState(false);
|
const [aiHasMistralKey, setAiHasMistralKey] = useState(false);
|
||||||
const [aiMistralKeyMasked, setAiMistralKeyMasked] = useState('');
|
const [aiMistralKeyMasked, setAiMistralKeyMasked] = useState('');
|
||||||
const [newMistralKey, setNewMistralKey] = useState('');
|
const [newMistralKey, setNewMistralKey] = useState('');
|
||||||
|
const [ollamaEnabled, setOllamaEnabled] = useState(false);
|
||||||
|
const [ollamaCapabilities, setOllamaCapabilities] = useState<Record<string, { tools: boolean; vision: boolean }>>({});
|
||||||
|
const [ollamaModels, setOllamaModels] = useState<{id: string; name: string}[]>([]);
|
||||||
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}[]>([]);
|
||||||
@@ -415,6 +418,20 @@ export const SettingsView: React.FC = () => {
|
|||||||
setAiMistralKeyMasked(mistralKeyResult.maskedKey || '');
|
setAiMistralKeyMasked(mistralKeyResult.maskedKey || '');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Load Ollama enabled state
|
||||||
|
const ollamaState = await window.electronAPI?.chat.getOllamaEnabled();
|
||||||
|
setOllamaEnabled(!!ollamaState);
|
||||||
|
|
||||||
|
// Load Ollama model capabilities and models list
|
||||||
|
if (ollamaState) {
|
||||||
|
const [caps, models] = await Promise.all([
|
||||||
|
window.electronAPI?.chat.getOllamaModelCapabilities(),
|
||||||
|
window.electronAPI?.chat.getOllamaModels(),
|
||||||
|
]);
|
||||||
|
if (caps) setOllamaCapabilities(caps);
|
||||||
|
if (models) setOllamaModels(models.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) {
|
||||||
@@ -536,7 +553,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'];
|
const aiKeywords = ['ai', 'assistant', 'chat', 'model', 'prompt', 'system', 'api', 'key', 'claude', 'gpt', 'opencode', 'ollama', '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'];
|
||||||
@@ -1144,6 +1161,55 @@ export const SettingsView: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleOllamaToggle = async (enabled: boolean) => {
|
||||||
|
try {
|
||||||
|
const result = await window.electronAPI?.chat.setOllamaEnabled(enabled);
|
||||||
|
if (result?.success) {
|
||||||
|
setOllamaEnabled(enabled);
|
||||||
|
showToast.success(t(enabled ? 'settings.toast.ollamaEnabled' : 'settings.toast.ollamaDisabled'));
|
||||||
|
|
||||||
|
// Refresh models after toggle
|
||||||
|
const modelsResult = await window.electronAPI?.chat.getAvailableModels();
|
||||||
|
if (modelsResult?.success && modelsResult.models) {
|
||||||
|
setAvailableModels(modelsResult.models);
|
||||||
|
setSelectedModel(modelsResult.selectedModel || '');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load Ollama models and capabilities when enabling
|
||||||
|
if (enabled) {
|
||||||
|
const [caps, ollamaModelsList] = await Promise.all([
|
||||||
|
window.electronAPI?.chat.getOllamaModelCapabilities(),
|
||||||
|
window.electronAPI?.chat.getOllamaModels(),
|
||||||
|
]);
|
||||||
|
if (caps) setOllamaCapabilities(caps);
|
||||||
|
if (ollamaModelsList) setOllamaModels(ollamaModelsList.map(m => ({ id: m.id, name: m.name })));
|
||||||
|
} else {
|
||||||
|
setOllamaModels([]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to toggle Ollama:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOllamaCapabilityToggle = async (modelId: string, field: 'tools' | 'vision', value: boolean) => {
|
||||||
|
const current = ollamaCapabilities[modelId] ?? { tools: false, vision: false };
|
||||||
|
const updated = { ...current, [field]: value };
|
||||||
|
try {
|
||||||
|
const result = await window.electronAPI?.chat.setOllamaModelCapabilities(modelId, updated);
|
||||||
|
if (result?.success) {
|
||||||
|
setOllamaCapabilities(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 Ollama 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);
|
||||||
@@ -1236,6 +1302,7 @@ export const SettingsView: React.FC = () => {
|
|||||||
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');
|
||||||
return provider;
|
return provider;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1346,17 +1413,76 @@ export const SettingsView: React.FC = () => {
|
|||||||
)}
|
)}
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
|
|
||||||
|
<SettingRow
|
||||||
|
id="ai-ollama"
|
||||||
|
label={t('settings.ai.ollamaLabel')}
|
||||||
|
description={t('settings.ai.ollamaDescription')}
|
||||||
|
>
|
||||||
|
<div className="setting-input-group">
|
||||||
|
<label className="toggle-label">
|
||||||
|
<input
|
||||||
|
id="ai-ollama"
|
||||||
|
type="checkbox"
|
||||||
|
checked={ollamaEnabled}
|
||||||
|
onChange={(e) => handleOllamaToggle(e.target.checked)}
|
||||||
|
/>
|
||||||
|
{t('settings.ai.ollamaEnable')}
|
||||||
|
</label>
|
||||||
|
{ollamaEnabled && (
|
||||||
|
<span className="setting-status-badge success">{t('settings.ai.configured')}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{ollamaEnabled && ollamaModels.length > 0 && (
|
||||||
|
<div className="ollama-model-capabilities">
|
||||||
|
<small className="setting-description">{t('settings.ai.ollamaCapabilitiesDescription')}</small>
|
||||||
|
<table className="ollama-caps-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{t('settings.ai.ollamaCapModel')}</th>
|
||||||
|
<th>{t('settings.ai.ollamaCapTools')}</th>
|
||||||
|
<th>{t('settings.ai.ollamaCapVision')}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{ollamaModels.map(m => {
|
||||||
|
const caps = ollamaCapabilities[m.id] ?? { tools: false, vision: false };
|
||||||
|
return (
|
||||||
|
<tr key={m.id}>
|
||||||
|
<td>{m.name}</td>
|
||||||
|
<td>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={caps.tools}
|
||||||
|
onChange={(e) => handleOllamaCapabilityToggle(m.id, 'tools', e.target.checked)}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={caps.vision}
|
||||||
|
onChange={(e) => handleOllamaCapabilityToggle(m.id, 'vision', e.target.checked)}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</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)}
|
{renderModelSelect('ai-model', selectedModel, handleModelChange, !aiHasApiKey && !aiHasMistralKey && !ollamaEnabled)}
|
||||||
<button
|
<button
|
||||||
className="secondary"
|
className="secondary"
|
||||||
onClick={handleRefreshModelCatalog}
|
onClick={handleRefreshModelCatalog}
|
||||||
disabled={refreshingCatalog || (!aiHasApiKey && !aiHasMistralKey)}
|
disabled={refreshingCatalog || (!aiHasApiKey && !aiHasMistralKey && !ollamaEnabled)}
|
||||||
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')}
|
||||||
@@ -1388,7 +1514,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)}
|
{renderModelSelect('ai-title-model', titleModel, handleTitleModelChange, !aiHasApiKey && !aiHasMistralKey && !ollamaEnabled)}
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
|
|
||||||
<SettingRow
|
<SettingRow
|
||||||
@@ -1396,7 +1522,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, groupedVisionModels)}
|
{renderModelSelect('ai-image-analysis-model', imageAnalysisModel, handleImageAnalysisModelChange, !aiHasApiKey && !aiHasMistralKey && !ollamaEnabled, groupedVisionModels)}
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
|
|
||||||
<SettingRow
|
<SettingRow
|
||||||
|
|||||||
@@ -741,11 +741,21 @@
|
|||||||
"settings.ai.imageAnalysisModelDescription": "Modell für die automatische Bildanalyse (Titel, Alt-Text, Bildunterschrift).",
|
"settings.ai.imageAnalysisModelDescription": "Modell für die automatische Bildanalyse (Titel, Alt-Text, Bildunterschrift).",
|
||||||
"settings.ai.providerOpenCode": "OpenCode",
|
"settings.ai.providerOpenCode": "OpenCode",
|
||||||
"settings.ai.providerMistral": "Mistral",
|
"settings.ai.providerMistral": "Mistral",
|
||||||
|
"settings.ai.providerOllama": "Ollama (Lokal)",
|
||||||
"settings.ai.providerOther": "Andere",
|
"settings.ai.providerOther": "Andere",
|
||||||
|
"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.ollamaEnable": "Ollama aktivieren",
|
||||||
|
"settings.ai.ollamaCapabilitiesDescription": "Fähigkeiten für jedes Ollama-Modell konfigurieren. Tools für Funktionsaufrufe oder Vision für Bildanalyse aktivieren.",
|
||||||
|
"settings.ai.ollamaCapModel": "Modell",
|
||||||
|
"settings.ai.ollamaCapTools": "Tools",
|
||||||
|
"settings.ai.ollamaCapVision": "Vision",
|
||||||
"chat.providerKeyMissing": "Das Modell '{{model}}' benötigt einen {{provider}} API-Schlüssel. Konfiguriere ihn in den Einstellungen.",
|
"chat.providerKeyMissing": "Das Modell '{{model}}' benötigt einen {{provider}} API-Schlüssel. Konfiguriere ihn in den Einstellungen.",
|
||||||
"settings.toast.modelCatalogRefreshed": "Modellkatalog aktualisiert ({{count}} Modelle)",
|
"settings.toast.modelCatalogRefreshed": "Modellkatalog aktualisiert ({{count}} Modelle)",
|
||||||
"settings.toast.modelCatalogUpToDate": "Modellkatalog ist bereits aktuell",
|
"settings.toast.modelCatalogUpToDate": "Modellkatalog ist bereits aktuell",
|
||||||
"settings.toast.modelCatalogRefreshFailed": "Modellkatalog konnte nicht aktualisiert werden",
|
"settings.toast.modelCatalogRefreshFailed": "Modellkatalog konnte nicht aktualisiert werden",
|
||||||
|
"settings.toast.ollamaEnabled": "Ollama aktiviert",
|
||||||
|
"settings.toast.ollamaDisabled": "Ollama 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.",
|
||||||
|
|||||||
@@ -741,11 +741,21 @@
|
|||||||
"settings.ai.imageAnalysisModelDescription": "Model used for automatic image analysis (title, alt text, caption).",
|
"settings.ai.imageAnalysisModelDescription": "Model used for automatic image analysis (title, alt text, caption).",
|
||||||
"settings.ai.providerOpenCode": "OpenCode",
|
"settings.ai.providerOpenCode": "OpenCode",
|
||||||
"settings.ai.providerMistral": "Mistral",
|
"settings.ai.providerMistral": "Mistral",
|
||||||
|
"settings.ai.providerOllama": "Ollama (Local)",
|
||||||
"settings.ai.providerOther": "Other",
|
"settings.ai.providerOther": "Other",
|
||||||
|
"settings.ai.ollamaLabel": "Ollama (Local Models)",
|
||||||
|
"settings.ai.ollamaDescription": "Connect to a locally running Ollama instance to use local AI models.",
|
||||||
|
"settings.ai.ollamaEnable": "Enable Ollama",
|
||||||
|
"settings.ai.ollamaCapabilitiesDescription": "Configure capabilities for each Ollama model. Enable tools for function calling or vision for image analysis.",
|
||||||
|
"settings.ai.ollamaCapModel": "Model",
|
||||||
|
"settings.ai.ollamaCapTools": "Tools",
|
||||||
|
"settings.ai.ollamaCapVision": "Vision",
|
||||||
"chat.providerKeyMissing": "The model '{{model}}' requires a {{provider}} API key. Configure it in Settings.",
|
"chat.providerKeyMissing": "The model '{{model}}' requires a {{provider}} API key. Configure it in Settings.",
|
||||||
"settings.toast.modelCatalogRefreshed": "Model catalog updated ({{count}} models)",
|
"settings.toast.modelCatalogRefreshed": "Model catalog updated ({{count}} models)",
|
||||||
"settings.toast.modelCatalogUpToDate": "Model catalog already up to date",
|
"settings.toast.modelCatalogUpToDate": "Model catalog already up to date",
|
||||||
"settings.toast.modelCatalogRefreshFailed": "Failed to refresh model catalog",
|
"settings.toast.modelCatalogRefreshFailed": "Failed to refresh model catalog",
|
||||||
|
"settings.toast.ollamaEnabled": "Ollama enabled",
|
||||||
|
"settings.toast.ollamaDisabled": "Ollama 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.",
|
||||||
|
|||||||
@@ -741,11 +741,21 @@
|
|||||||
"settings.ai.imageAnalysisModelDescription": "Modelo utilizado para el análisis automático de imágenes (título, texto alternativo, leyenda).",
|
"settings.ai.imageAnalysisModelDescription": "Modelo utilizado para el análisis automático de imágenes (título, texto alternativo, leyenda).",
|
||||||
"settings.ai.providerOpenCode": "OpenCode",
|
"settings.ai.providerOpenCode": "OpenCode",
|
||||||
"settings.ai.providerMistral": "Mistral",
|
"settings.ai.providerMistral": "Mistral",
|
||||||
|
"settings.ai.providerOllama": "Ollama (Local)",
|
||||||
"settings.ai.providerOther": "Otro",
|
"settings.ai.providerOther": "Otro",
|
||||||
|
"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.ollamaEnable": "Activar Ollama",
|
||||||
|
"settings.ai.ollamaCapabilitiesDescription": "Configurar las capacidades de cada modelo Ollama. Activar herramientas para llamadas a funciones o visión para análisis de imágenes.",
|
||||||
|
"settings.ai.ollamaCapModel": "Modelo",
|
||||||
|
"settings.ai.ollamaCapTools": "Herramientas",
|
||||||
|
"settings.ai.ollamaCapVision": "Visión",
|
||||||
"chat.providerKeyMissing": "El modelo '{{model}}' requiere una clave API de {{provider}}. Configúrela en Ajustes.",
|
"chat.providerKeyMissing": "El modelo '{{model}}' requiere una clave API de {{provider}}. Configúrela en Ajustes.",
|
||||||
"settings.toast.modelCatalogRefreshed": "Catálogo actualizado ({{count}} modelos)",
|
"settings.toast.modelCatalogRefreshed": "Catálogo actualizado ({{count}} modelos)",
|
||||||
"settings.toast.modelCatalogUpToDate": "El catálogo ya está actualizado",
|
"settings.toast.modelCatalogUpToDate": "El catálogo ya está actualizado",
|
||||||
"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.ollamaDisabled": "Ollama 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.",
|
||||||
|
|||||||
@@ -739,11 +739,21 @@
|
|||||||
"settings.ai.imageAnalysisModelDescription": "Modèle utilisé pour l'analyse automatique d'images (titre, texte alternatif, légende).",
|
"settings.ai.imageAnalysisModelDescription": "Modèle utilisé pour l'analyse automatique d'images (titre, texte alternatif, légende).",
|
||||||
"settings.ai.providerOpenCode": "OpenCode",
|
"settings.ai.providerOpenCode": "OpenCode",
|
||||||
"settings.ai.providerMistral": "Mistral",
|
"settings.ai.providerMistral": "Mistral",
|
||||||
|
"settings.ai.providerOllama": "Ollama (Local)",
|
||||||
"settings.ai.providerOther": "Autre",
|
"settings.ai.providerOther": "Autre",
|
||||||
|
"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.ollamaEnable": "Activer Ollama",
|
||||||
|
"settings.ai.ollamaCapabilitiesDescription": "Configurer les capacités de chaque modèle Ollama. Activer les outils pour les appels de fonctions ou la vision pour l'analyse d'images.",
|
||||||
|
"settings.ai.ollamaCapModel": "Modèle",
|
||||||
|
"settings.ai.ollamaCapTools": "Outils",
|
||||||
|
"settings.ai.ollamaCapVision": "Vision",
|
||||||
"chat.providerKeyMissing": "Le modèle '{{model}}' nécessite une clé API {{provider}}. Configurez-la dans les paramètres.",
|
"chat.providerKeyMissing": "Le modèle '{{model}}' nécessite une clé API {{provider}}. Configurez-la dans les paramètres.",
|
||||||
"settings.toast.modelCatalogRefreshed": "Catalogue mis à jour ({{count}} modèles)",
|
"settings.toast.modelCatalogRefreshed": "Catalogue mis à jour ({{count}} modèles)",
|
||||||
"settings.toast.modelCatalogUpToDate": "Le catalogue est déjà à jour",
|
"settings.toast.modelCatalogUpToDate": "Le catalogue est déjà à jour",
|
||||||
"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.ollamaDisabled": "Ollama 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é.",
|
||||||
|
|||||||
@@ -739,11 +739,21 @@
|
|||||||
"settings.ai.imageAnalysisModelDescription": "Modello utilizzato per l'analisi automatica delle immagini (titolo, testo alternativo, didascalia).",
|
"settings.ai.imageAnalysisModelDescription": "Modello utilizzato per l'analisi automatica delle immagini (titolo, testo alternativo, didascalia).",
|
||||||
"settings.ai.providerOpenCode": "OpenCode",
|
"settings.ai.providerOpenCode": "OpenCode",
|
||||||
"settings.ai.providerMistral": "Mistral",
|
"settings.ai.providerMistral": "Mistral",
|
||||||
|
"settings.ai.providerOllama": "Ollama (Locale)",
|
||||||
"settings.ai.providerOther": "Altro",
|
"settings.ai.providerOther": "Altro",
|
||||||
|
"settings.ai.ollamaLabel": "Ollama (Modelli locali)",
|
||||||
|
"settings.ai.ollamaDescription": "Connettiti a un'istanza Ollama locale per utilizzare modelli IA locali.",
|
||||||
|
"settings.ai.ollamaEnable": "Attiva Ollama",
|
||||||
|
"settings.ai.ollamaCapabilitiesDescription": "Configura le capacità per ogni modello Ollama. Attiva gli strumenti per le chiamate a funzioni o la visione per l'analisi delle immagini.",
|
||||||
|
"settings.ai.ollamaCapModel": "Modello",
|
||||||
|
"settings.ai.ollamaCapTools": "Strumenti",
|
||||||
|
"settings.ai.ollamaCapVision": "Visione",
|
||||||
"chat.providerKeyMissing": "Il modello '{{model}}' richiede una chiave API {{provider}}. Configurala nelle Impostazioni.",
|
"chat.providerKeyMissing": "Il modello '{{model}}' richiede una chiave API {{provider}}. Configurala nelle Impostazioni.",
|
||||||
"settings.toast.modelCatalogRefreshed": "Catalogo aggiornato ({{count}} modelli)",
|
"settings.toast.modelCatalogRefreshed": "Catalogo aggiornato ({{count}} modelli)",
|
||||||
"settings.toast.modelCatalogUpToDate": "Il catalogo è già aggiornato",
|
"settings.toast.modelCatalogUpToDate": "Il catalogo è già aggiornato",
|
||||||
"settings.toast.modelCatalogRefreshFailed": "Aggiornamento del catalogo non riuscito",
|
"settings.toast.modelCatalogRefreshFailed": "Aggiornamento del catalogo non riuscito",
|
||||||
|
"settings.toast.ollamaEnabled": "Ollama attivato",
|
||||||
|
"settings.toast.ollamaDisabled": "Ollama disattivato",
|
||||||
"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.",
|
||||||
|
|||||||
@@ -139,12 +139,14 @@ describe('ProviderRegistry', () => {
|
|||||||
expect(registry.isReady()).toBe(true);
|
expect(registry.isReady()).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('getProviderStatus() reports both providers', () => {
|
it('getProviderStatus() reports all providers', () => {
|
||||||
expect(registry.getProviderStatus()).toEqual({ opencode: false, mistral: false });
|
expect(registry.getProviderStatus()).toEqual({ opencode: false, mistral: false, ollama: false });
|
||||||
registry.setOpencodeKey('test');
|
registry.setOpencodeKey('test');
|
||||||
expect(registry.getProviderStatus()).toEqual({ opencode: true, mistral: false });
|
expect(registry.getProviderStatus()).toEqual({ opencode: true, mistral: false, ollama: false });
|
||||||
registry.setMistralKey('test2');
|
registry.setMistralKey('test2');
|
||||||
expect(registry.getProviderStatus()).toEqual({ opencode: true, mistral: true });
|
expect(registry.getProviderStatus()).toEqual({ opencode: true, mistral: true, ollama: false });
|
||||||
|
registry.setOllamaEnabled(true);
|
||||||
|
expect(registry.getProviderStatus()).toEqual({ opencode: true, mistral: true, ollama: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('isProviderKeySet() checks per-provider', () => {
|
it('isProviderKeySet() checks per-provider', () => {
|
||||||
|
|||||||
315
tests/engine/ollama-provider.test.ts
Normal file
315
tests/engine/ollama-provider.test.ts
Normal file
@@ -0,0 +1,315 @@
|
|||||||
|
/**
|
||||||
|
* Tests for Ollama provider integration in ProviderRegistry.
|
||||||
|
*
|
||||||
|
* Ollama provides an OpenAI-compatible API at http://localhost:11434/v1
|
||||||
|
* and a native /api/tags endpoint for model listing.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
import { ProviderRegistry, detectProvider, OLLAMA_BASE_URL, OLLAMA_TAGS_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('Ollama provider support', () => {
|
||||||
|
let registry: ProviderRegistry;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
registry = new ProviderRegistry();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Constants ----
|
||||||
|
|
||||||
|
it('exports Ollama URL constants', () => {
|
||||||
|
expect(OLLAMA_BASE_URL).toBe('http://localhost:11434/v1');
|
||||||
|
expect(OLLAMA_TAGS_URL).toBe('http://localhost:11434/api/tags');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- detectProvider ----
|
||||||
|
|
||||||
|
it('detectProvider returns "ollama" for ollama-prefixed model IDs', () => {
|
||||||
|
// Ollama model IDs don't have a fixed prefix — they're arbitrary names.
|
||||||
|
// detectProvider won't match them. Instead, ProviderRegistry tracks which
|
||||||
|
// models came from Ollama separately. detectProvider returns 'other' for unknown.
|
||||||
|
expect(detectProvider('llama3:latest')).toBe('other');
|
||||||
|
expect(detectProvider('qwen2.5-coder:7b')).toBe('other');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Ollama enable/disable ----
|
||||||
|
|
||||||
|
it('is not enabled by default', () => {
|
||||||
|
expect(registry.isOllamaEnabled()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('can be enabled and disabled', () => {
|
||||||
|
registry.setOllamaEnabled(true);
|
||||||
|
expect(registry.isOllamaEnabled()).toBe(true);
|
||||||
|
registry.setOllamaEnabled(false);
|
||||||
|
expect(registry.isOllamaEnabled()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('enabling Ollama invalidates model cache', () => {
|
||||||
|
// Populate cache
|
||||||
|
registry['cachedModels'] = [{ id: 'test', name: 'test', provider: 'other' }];
|
||||||
|
registry['cachedModelsAt'] = Date.now();
|
||||||
|
|
||||||
|
registry.setOllamaEnabled(true);
|
||||||
|
|
||||||
|
expect(registry['cachedModels']).toBeNull();
|
||||||
|
expect(registry['cachedModelsAt']).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Provider status ----
|
||||||
|
|
||||||
|
it('getProviderStatus includes ollama field', () => {
|
||||||
|
const status = registry.getProviderStatus();
|
||||||
|
expect(status).toHaveProperty('ollama');
|
||||||
|
expect(status.ollama).toBe(false);
|
||||||
|
|
||||||
|
registry.setOllamaEnabled(true);
|
||||||
|
expect(registry.getProviderStatus().ollama).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- isReady includes ollama ----
|
||||||
|
|
||||||
|
it('isReady returns true when only Ollama is enabled', () => {
|
||||||
|
expect(registry.isReady()).toBe(false);
|
||||||
|
registry.setOllamaEnabled(true);
|
||||||
|
expect(registry.isReady()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- isProviderKeySet for ollama ----
|
||||||
|
|
||||||
|
it('isProviderKeySet returns ollama enabled state for provider "ollama"', () => {
|
||||||
|
expect(registry.isProviderKeySet('ollama')).toBe(false);
|
||||||
|
registry.setOllamaEnabled(true);
|
||||||
|
expect(registry.isProviderKeySet('ollama')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- resolveModel for ollama ----
|
||||||
|
|
||||||
|
it('resolveModel creates an OpenAI-compatible model for Ollama models', () => {
|
||||||
|
registry.setOllamaEnabled(true);
|
||||||
|
registry.registerOllamaModel('llama3:latest');
|
||||||
|
|
||||||
|
const model = registry.resolveModel('llama3:latest');
|
||||||
|
expect(model).toBeDefined();
|
||||||
|
expect(model.modelId).toBe('llama3:latest');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolveModel throws when Ollama is disabled', () => {
|
||||||
|
registry.registerOllamaModel('llama3:latest');
|
||||||
|
expect(() => registry.resolveModel('llama3:latest')).toThrow(/not configured/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Ollama model registration ----
|
||||||
|
|
||||||
|
it('tracks registered Ollama model IDs', () => {
|
||||||
|
expect(registry.isOllamaModel('llama3:latest')).toBe(false);
|
||||||
|
registry.registerOllamaModel('llama3:latest');
|
||||||
|
expect(registry.isOllamaModel('llama3:latest')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clearOllamaModels removes all registered models', () => {
|
||||||
|
registry.registerOllamaModel('llama3:latest');
|
||||||
|
registry.registerOllamaModel('qwen2.5-coder:7b');
|
||||||
|
registry.clearOllamaModels();
|
||||||
|
expect(registry.isOllamaModel('llama3:latest')).toBe(false);
|
||||||
|
expect(registry.isOllamaModel('qwen2.5-coder:7b')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- fetchOllamaModels ----
|
||||||
|
|
||||||
|
it('fetchOllamaModels calls the Ollama tags endpoint', async () => {
|
||||||
|
const mockFetch = vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
models: [
|
||||||
|
{ name: 'llama3:latest', details: { family: 'llama' } },
|
||||||
|
{ name: 'qwen2.5-coder:7b', details: { family: 'qwen2' } },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
globalThis.fetch = mockFetch;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const models = await registry.fetchOllamaModels();
|
||||||
|
expect(mockFetch).toHaveBeenCalledWith(
|
||||||
|
OLLAMA_TAGS_URL,
|
||||||
|
expect.objectContaining({ method: 'GET', signal: expect.any(AbortSignal) }),
|
||||||
|
);
|
||||||
|
expect(models).toHaveLength(2);
|
||||||
|
expect(models[0]).toMatchObject({ id: 'llama3:latest', name: 'llama3:latest', provider: 'ollama' });
|
||||||
|
expect(models[1]).toMatchObject({ id: 'qwen2.5-coder:7b', name: 'qwen2.5-coder:7b', provider: 'ollama' });
|
||||||
|
} finally {
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fetchOllamaModels 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.fetchOllamaModels();
|
||||||
|
expect(models).toEqual([]);
|
||||||
|
} finally {
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fetchOllamaModels registers returned models', async () => {
|
||||||
|
const mockFetch = vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
models: [{ name: 'llama3:latest', details: {} }],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
globalThis.fetch = mockFetch;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await registry.fetchOllamaModels();
|
||||||
|
expect(registry.isOllamaModel('llama3:latest')).toBe(true);
|
||||||
|
} finally {
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- getAvailableModels includes Ollama when enabled ----
|
||||||
|
|
||||||
|
it('getAvailableModels includes Ollama models when enabled', async () => {
|
||||||
|
registry.setOllamaEnabled(true);
|
||||||
|
|
||||||
|
const mockFetch = vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
models: [{ name: 'llama3:latest', details: {} }],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
globalThis.fetch = mockFetch;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const models = await registry.getAvailableModels();
|
||||||
|
const ollamaModels = models.filter(m => m.provider === 'ollama');
|
||||||
|
expect(ollamaModels.length).toBeGreaterThanOrEqual(1);
|
||||||
|
expect(ollamaModels[0].id).toBe('llama3:latest');
|
||||||
|
} finally {
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getAvailableModels excludes Ollama models when disabled', async () => {
|
||||||
|
registry.setOllamaEnabled(false);
|
||||||
|
|
||||||
|
const mockFetch = vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ models: [{ name: 'llama3:latest', details: {} }] }),
|
||||||
|
});
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
globalThis.fetch = mockFetch;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const models = await registry.getAvailableModels();
|
||||||
|
const ollamaModels = models.filter(m => m.provider === 'ollama');
|
||||||
|
expect(ollamaModels).toHaveLength(0);
|
||||||
|
} finally {
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Ollama model capability overrides ----
|
||||||
|
|
||||||
|
describe('model capability overrides', () => {
|
||||||
|
it('returns default capabilities (tools=false, vision=false) for unknown model', () => {
|
||||||
|
const caps = registry.getOllamaModelCapabilities('unknown-model');
|
||||||
|
expect(caps).toEqual({ tools: false, vision: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stores and retrieves capability overrides for a model', () => {
|
||||||
|
registry.setOllamaModelCapabilities('llama3:latest', { tools: true, vision: false });
|
||||||
|
expect(registry.getOllamaModelCapabilities('llama3:latest')).toEqual({ tools: true, vision: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stores vision capability override', () => {
|
||||||
|
registry.setOllamaModelCapabilities('llava:latest', { tools: false, vision: true });
|
||||||
|
expect(registry.getOllamaModelCapabilities('llava:latest')).toEqual({ tools: false, vision: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('supports both capabilities enabled', () => {
|
||||||
|
registry.setOllamaModelCapabilities('qwen2.5:latest', { tools: true, vision: true });
|
||||||
|
expect(registry.getOllamaModelCapabilities('qwen2.5:latest')).toEqual({ tools: true, vision: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getAllOllamaModelCapabilities returns all stored overrides', () => {
|
||||||
|
registry.setOllamaModelCapabilities('model-a', { tools: true, vision: false });
|
||||||
|
registry.setOllamaModelCapabilities('model-b', { tools: false, vision: true });
|
||||||
|
const all = registry.getAllOllamaModelCapabilities();
|
||||||
|
expect(all).toEqual({
|
||||||
|
'model-a': { tools: true, vision: false },
|
||||||
|
'model-b': { tools: false, vision: true },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getAllOllamaModelCapabilities returns empty object when no overrides', () => {
|
||||||
|
expect(registry.getAllOllamaModelCapabilities()).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('loadOllamaModelCapabilities restores from serialized JSON', () => {
|
||||||
|
const data = { 'llama3:latest': { tools: true, vision: false } };
|
||||||
|
registry.loadOllamaModelCapabilities(data);
|
||||||
|
expect(registry.getOllamaModelCapabilities('llama3:latest')).toEqual({ tools: true, vision: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ollamaModelSupportsTools returns false by default', () => {
|
||||||
|
expect(registry.ollamaModelSupportsTools('unknown')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ollamaModelSupportsTools returns true when override is set', () => {
|
||||||
|
registry.setOllamaModelCapabilities('qwen2.5:latest', { tools: true, vision: false });
|
||||||
|
expect(registry.ollamaModelSupportsTools('qwen2.5:latest')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ollamaModelSupportsVision returns false by default', () => {
|
||||||
|
expect(registry.ollamaModelSupportsVision('unknown')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ollamaModelSupportsVision returns true when override is set', () => {
|
||||||
|
registry.setOllamaModelCapabilities('llava:latest', { tools: false, vision: true });
|
||||||
|
expect(registry.ollamaModelSupportsVision('llava:latest')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fetchOllamaModels applies vision overrides to returned models', async () => {
|
||||||
|
registry.setOllamaModelCapabilities('llava:latest', { tools: false, vision: true });
|
||||||
|
|
||||||
|
const mockFetch = vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
models: [
|
||||||
|
{ name: 'llama3:latest', details: {} },
|
||||||
|
{ name: 'llava:latest', details: {} },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
globalThis.fetch = mockFetch;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const models = await registry.fetchOllamaModels();
|
||||||
|
expect(models).toHaveLength(2);
|
||||||
|
expect(models.find(m => m.id === 'llama3:latest')?.vision).toBe(false);
|
||||||
|
expect(models.find(m => m.id === 'llava:latest')?.vision).toBe(true);
|
||||||
|
} finally {
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user