diff --git a/package-lock.json b/package-lock.json index 3041648..660927a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "blogging-desktop-server", - "version": "1.0.0", + "version": "1.0.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "blogging-desktop-server", - "version": "1.0.0", + "version": "1.0.1", "license": "MIT", "dependencies": { "@ai-sdk/anthropic": "^3.0.50", diff --git a/package.json b/package.json index 9914443..aaef5c8 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "blogging-desktop-server", "productName": "Blogging Desktop Server", - "version": "1.0.0", + "version": "1.0.1", "description": "A desktop blogging application with offline-first capabilities and cloud sync", "main": "dist/main/main.js", "scripts": { diff --git a/src/main/engine/ai/chat.ts b/src/main/engine/ai/chat.ts index cfed921..721c84e 100644 --- a/src/main/engine/ai/chat.ts +++ b/src/main/engine/ai/chat.ts @@ -294,8 +294,10 @@ export class ChatService { // Build tools (skip for Ollama/LM Studio models unless capability override is set) const isOllama = this.providers.isOllamaModel(modelId); const isLmstudio = this.providers.isLmstudioModel(modelId); + const isGenericOpenAI = this.providers.isGenericOpenAIModel(modelId); const skipTools = (isOllama && !this.providers.ollamaModelSupportsTools(modelId)) - || (isLmstudio && !this.providers.lmstudioModelSupportsTools(modelId)); + || (isLmstudio && !this.providers.lmstudioModelSupportsTools(modelId)) + || (isGenericOpenAI && !this.providers.genericOpenAIModelSupportsTools(modelId)); const blogTools = skipTools ? {} : createBlogTools(this.blogToolDeps); const a2uiToolsRaw = skipTools ? {} : createA2UITools(); const allTools = { ...blogTools, ...a2uiToolsRaw }; diff --git a/src/main/engine/ai/providers.ts b/src/main/engine/ai/providers.ts index b18c743..5e578c6 100644 --- a/src/main/engine/ai/providers.ts +++ b/src/main/engine/ai/providers.ts @@ -31,10 +31,12 @@ 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'; +export const GENERIC_OPENAI_MODELS_PATH = '/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 +const GENERIC_OPENAI_FETCH_TIMEOUT = 10000; // 10 s for generic endpoints // --------------------------------------------------------------------------- // Gateway factory @@ -123,6 +125,12 @@ export class ProviderRegistry { private lmstudioProvider: ReturnType | null = null; private lmstudioModelIds = new Set(); private lmstudioCapabilities = new Map(); + private genericOpenAIEnabled = false; + private genericOpenAIBaseURL = ''; + private genericOpenAIApiKey = ''; + private genericOpenAIProvider: ReturnType | null = null; + private genericOpenAIModelIds = new Set(); + private genericOpenAICapabilities = new Map(); private modelCatalogEngine = new ModelCatalogEngine(); private _offlineMode = false; @@ -297,9 +305,94 @@ export class ProviderRegistry { return this.lmstudioCapabilities.get(modelId)?.vision ?? false; } + // ---- Generic OpenAI-compatible endpoint management ---- + + setGenericOpenAIEnabled(enabled: boolean): void { + this.genericOpenAIEnabled = enabled; + this.genericOpenAIProvider = null; + this.invalidateModelCache(); + } + + isGenericOpenAIEnabled(): boolean { + return this.genericOpenAIEnabled; + } + + setGenericOpenAIBaseURL(baseURL: string): void { + this.genericOpenAIBaseURL = this.normalizeGenericOpenAIBaseURL(baseURL); + this.genericOpenAIProvider = null; + this.invalidateModelCache(); + } + + getGenericOpenAIBaseURL(): string { + return this.genericOpenAIBaseURL; + } + + setGenericOpenAIApiKey(apiKey: string): void { + this.genericOpenAIApiKey = apiKey; + this.genericOpenAIProvider = null; + this.invalidateModelCache(); + } + + getGenericOpenAIApiKey(): string { + return this.genericOpenAIApiKey; + } + + /** Register a model ID as belonging to the generic OpenAI endpoint. */ + registerGenericOpenAIModel(modelId: string): void { + this.genericOpenAIModelIds.add(modelId); + } + + /** Check whether a model ID was registered as a generic OpenAI model. */ + isGenericOpenAIModel(modelId: string): boolean { + return this.genericOpenAIModelIds.has(modelId); + } + + /** Remove all registered generic OpenAI model IDs. */ + clearGenericOpenAIModels(): void { + this.genericOpenAIModelIds.clear(); + } + + /** Get capability overrides for a specific generic OpenAI model. */ + getGenericOpenAIModelCapabilities(modelId: string): { tools: boolean; vision: boolean } { + return this.genericOpenAICapabilities.get(modelId) ?? { tools: false, vision: false }; + } + + /** Set capability overrides for a specific generic OpenAI model. */ + setGenericOpenAIModelCapabilities(modelId: string, caps: { tools: boolean; vision: boolean }): void { + this.genericOpenAICapabilities.set(modelId, caps); + this.invalidateModelCache(); + } + + /** Get all stored generic OpenAI capability overrides. */ + getAllGenericOpenAIModelCapabilities(): Record { + const result: Record = {}; + for (const [id, caps] of this.genericOpenAICapabilities) { + result[id] = caps; + } + return result; + } + + /** Load generic OpenAI capability overrides from serialized object. */ + loadGenericOpenAIModelCapabilities(data: Record): void { + this.genericOpenAICapabilities.clear(); + for (const [id, caps] of Object.entries(data)) { + this.genericOpenAICapabilities.set(id, caps); + } + } + + /** Check whether a generic OpenAI model has tools capability enabled. */ + genericOpenAIModelSupportsTools(modelId: string): boolean { + return this.genericOpenAICapabilities.get(modelId)?.tools ?? false; + } + + /** Check whether a generic OpenAI model has vision capability enabled. */ + genericOpenAIModelSupportsVision(modelId: string): boolean { + return this.genericOpenAICapabilities.get(modelId)?.vision ?? false; + } + /** - * Detect the effective provider for a model ID, checking Ollama and LM Studio - * registration first, then falling back to prefix-based detection. + * Detect the effective provider for a model ID, checking Ollama, LM Studio, + * and generic OpenAI registration first, then falling back to prefix-based detection. */ detectModelProvider(modelId: string): string { if (this.ollamaModelIds.has(modelId)) { @@ -308,6 +401,9 @@ export class ProviderRegistry { if (this.lmstudioModelIds.has(modelId)) { return 'lmstudio'; } + if (this.genericOpenAIModelIds.has(modelId)) { + return 'generic-openai'; + } return detectProvider(modelId); } @@ -316,7 +412,7 @@ export class ProviderRegistry { if (this._offlineMode) { return !!(this.ollamaEnabled || this.lmstudioEnabled); } - return !!(this.opencodeKey || this.mistralKey || this.ollamaEnabled || this.lmstudioEnabled); + return !!(this.opencodeKey || this.mistralKey || this.ollamaEnabled || this.lmstudioEnabled || this.genericOpenAIEnabled); } /** Check whether the key for a specific provider is set. */ @@ -327,6 +423,9 @@ export class ProviderRegistry { if (provider === 'lmstudio') { return this.lmstudioEnabled; } + if (provider === 'generic-openai') { + return this.genericOpenAIEnabled && Boolean(this.genericOpenAIBaseURL); + } // In offline mode, cloud providers are unavailable if (this._offlineMode) { return false; @@ -338,12 +437,13 @@ export class ProviderRegistry { } /** Returns status of all configured providers. */ - getProviderStatus(): { opencode: boolean; mistral: boolean; ollama: boolean; lmstudio: boolean; offlineMode: boolean } { + getProviderStatus(): { opencode: boolean; mistral: boolean; ollama: boolean; lmstudio: boolean; genericOpenAI: boolean; offlineMode: boolean } { return { opencode: !!this.opencodeKey, mistral: !!this.mistralKey, ollama: this.ollamaEnabled, lmstudio: this.lmstudioEnabled, + genericOpenAI: this.genericOpenAIEnabled, offlineMode: this._offlineMode, }; } @@ -385,6 +485,20 @@ export class ProviderRegistry { return this.lmstudioProvider.chat(modelId); } + // Check if this is a registered generic OpenAI model + if (this.genericOpenAIModelIds.has(modelId)) { + if (!this.genericOpenAIEnabled || !this.genericOpenAIBaseURL) { + throw new Error(`Generic OpenAI endpoint not configured for model '${modelId}'`); + } + if (!this.genericOpenAIProvider) { + this.genericOpenAIProvider = createOpenAI({ + baseURL: this.genericOpenAIBaseURL, + apiKey: this.genericOpenAIApiKey || 'dummy-key', + }); + } + return this.genericOpenAIProvider.chat(modelId); + } + const provider = detectProvider(modelId); if (provider === 'mistral') { @@ -544,6 +658,19 @@ export class ProviderRegistry { } } + // Fetch generic OpenAI-compatible endpoint models + if (this.genericOpenAIEnabled && this.genericOpenAIBaseURL && !this._offlineMode) { + try { + const models = await this.fetchGenericOpenAIModels(); + allModels.push(...models); + if (models.length > 0) { + fetched = true; + } + } catch { + // Generic OpenAI endpoint not available — skip silently + } + } + if (fetched && allModels.length > 0) { this.cachedModels = allModels; this.cachedModelsAt = Date.now(); @@ -678,6 +805,78 @@ export class ProviderRegistry { } } + // ---- Generic OpenAI-compatible endpoint model listing ---- + + /** + * Fetch available models from a generic OpenAI-compatible /v1/models endpoint. + * Returns ChatModel[] and registers the model IDs internally. + */ + async fetchGenericOpenAIModels(): Promise { + const normalizedBaseURL = this.normalizeGenericOpenAIBaseURL(this.genericOpenAIBaseURL); + if (!normalizedBaseURL) { + return []; + } + + try { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), GENERIC_OPENAI_FETCH_TIMEOUT); + + const headers: Record = {}; + if (this.genericOpenAIApiKey) { + headers.Authorization = `Bearer ${this.genericOpenAIApiKey}`; + } + + const response = await fetch(`${normalizedBaseURL}${GENERIC_OPENAI_MODELS_PATH}`, { + method: 'GET', + headers, + 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: 'generic-openai', + vision: this.genericOpenAIModelSupportsVision(m.id), + })); + + // Only replace registered IDs on successful fetch + this.clearGenericOpenAIModels(); + for (const m of models) { + this.registerGenericOpenAIModel(m.id); + } + return models; + } catch { + return []; + } + } + + /** + * Validate generic OpenAI endpoint configuration by fetching models. + */ + async validateGenericOpenAIConfig(): Promise<{ isValid: boolean; models: ChatModel[]; error?: string }> { + if (!this.genericOpenAIBaseURL) { + return { isValid: false, models: [], error: 'Base URL is required' }; + } + + try { + const models = await this.fetchGenericOpenAIModels(); + return { isValid: true, models }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + return { isValid: false, models: [], error: errorMessage }; + } + } + // ---- Private helpers ---- private async fetchModelsFromEndpoint( @@ -725,6 +924,14 @@ export class ProviderRegistry { return { vision, names }; } + private normalizeGenericOpenAIBaseURL(baseURL: string): string { + const trimmed = baseURL.trim().replace(/\/+$/, ''); + if (!trimmed) { + return ''; + } + return trimmed.endsWith('/v1') ? trimmed : `${trimmed}/v1`; + } + private async getModelsFromCatalog(): Promise { try { const catalog = await this.modelCatalogEngine.getAll(); diff --git a/src/main/engine/mcp-view-builder.ts b/src/main/engine/mcp-view-builder.ts index a416967..da98d4b 100644 --- a/src/main/engine/mcp-view-builder.ts +++ b/src/main/engine/mcp-view-builder.ts @@ -101,7 +101,7 @@ const SHARED_JS = `\ document.getElementById("status").textContent = "Error: App bundle not loaded"; document.getElementById("status").className = "status status-error"; document.getElementById("status").style.display = "block"; throw new Error("App bundle not loaded"); } - const app = new App({ name: "bDS Review", version: "1.0.0" }); + const app = new App({ name: "bDS Review", version: "1.0.1" }); let currentData = null; diff --git a/src/main/ipc/chatHandlers.ts b/src/main/ipc/chatHandlers.ts index a9fbca3..ff65548 100644 --- a/src/main/ipc/chatHandlers.ts +++ b/src/main/ipc/chatHandlers.ts @@ -132,6 +132,13 @@ async function ensureInitialized(): Promise { } } catch { /* ignore */ } + try { + const genericOpenAIKey = await keyStore.retrieve('generic_openai_api_key'); + if (genericOpenAIKey) { + reg.setGenericOpenAIApiKey(genericOpenAIKey); + } + } catch { /* ignore */ } + // Restore Ollama enabled state from settings DB try { const ollamaEnabled = await getChatEngine().getSetting('ollama_enabled'); @@ -167,6 +174,21 @@ async function ensureInitialized(): Promise { } } catch { /* ignore */ } + // Restore generic OpenAI enabled state and base URL from settings DB + try { + const genericOpenAIEnabled = await getChatEngine().getSetting('generic_openai_enabled'); + if (genericOpenAIEnabled === 'true') { + reg.setGenericOpenAIEnabled(true); + } + } catch { /* ignore */ } + + try { + const genericOpenAIBaseURL = await getChatEngine().getSetting('generic_openai_base_url'); + if (genericOpenAIBaseURL) { + reg.setGenericOpenAIBaseURL(genericOpenAIBaseURL); + } + } catch { /* ignore */ } + // Restore LM Studio model capability overrides try { const lmCapsJson = await getChatEngine().getSetting('lmstudio_model_capabilities'); @@ -176,6 +198,15 @@ async function ensureInitialized(): Promise { } } catch { /* ignore */ } + // Restore generic OpenAI model capability overrides + try { + const genericCapsJson = await getChatEngine().getSetting('generic_openai_model_capabilities'); + if (genericCapsJson) { + const caps = JSON.parse(genericCapsJson) as Record; + reg.loadGenericOpenAIModelCapabilities(caps); + } + } catch { /* ignore */ } + // Restore known LM Studio model IDs (so offline mode works without a fresh fetch) try { const lmIds = await getChatEngine().getSetting('lmstudio_known_model_ids'); @@ -186,6 +217,16 @@ async function ensureInitialized(): Promise { } } catch { /* ignore */ } + // Restore known generic OpenAI model IDs for provider routing before a refresh + try { + const genericIds = await getChatEngine().getSetting('generic_openai_known_model_ids'); + if (genericIds) { + for (const id of JSON.parse(genericIds) as string[]) { + reg.registerGenericOpenAIModel(id); + } + } + } catch { /* ignore */ } + // Restore offline mode from settings or auto-detect via OS network status try { const savedOffline = await getChatEngine().getSetting('offline_mode'); @@ -468,6 +509,147 @@ export function registerChatHandlers(): void { } }); + // ============ Generic OpenAI-compatible Endpoint ============ + + // Get generic OpenAI enabled state + ipcMain.handle('chat:getGenericOpenAIEnabled', async () => { + try { + await ensureInitialized(); + return getProviders().isGenericOpenAIEnabled(); + } catch (error) { + console.error('[Chat IPC] Error getting generic OpenAI enabled state:', error); + return false; + } + }); + + // Set generic OpenAI enabled state + ipcMain.handle('chat:setGenericOpenAIEnabled', async (_, enabled: boolean) => { + try { + await ensureInitialized(); + const reg = getProviders(); + reg.setGenericOpenAIEnabled(enabled); + await getChatEngine().setSetting('generic_openai_enabled', enabled ? 'true' : 'false'); + return { success: true }; + } catch (error) { + console.error('[Chat IPC] Error setting generic OpenAI enabled state:', error); + return { success: false, error: (error as Error).message }; + } + }); + + // Get generic OpenAI base URL + ipcMain.handle('chat:getGenericOpenAIBaseURL', async () => { + try { + await ensureInitialized(); + return getProviders().getGenericOpenAIBaseURL(); + } catch (error) { + console.error('[Chat IPC] Error getting generic OpenAI base URL:', error); + return ''; + } + }); + + // Set generic OpenAI base URL + ipcMain.handle('chat:setGenericOpenAIBaseURL', async (_, baseURL: string) => { + try { + await ensureInitialized(); + const reg = getProviders(); + reg.setGenericOpenAIBaseURL(baseURL); + await getChatEngine().setSetting('generic_openai_base_url', baseURL); + return { success: true }; + } catch (error) { + console.error('[Chat IPC] Error setting generic OpenAI base URL:', error); + return { success: false, error: (error as Error).message }; + } + }); + + // Get generic OpenAI API key (masked) + ipcMain.handle('chat:getGenericOpenAIApiKey', async () => { + try { + await ensureInitialized(); + const key = getProviders().getGenericOpenAIApiKey(); + if (!key) { + return { hasKey: false, maskedKey: '' }; + } + const masked = '•'.repeat(Math.max(0, key.length - 4)) + key.slice(-4); + return { hasKey: true, maskedKey: masked }; + } catch (error) { + console.error('[Chat IPC] Error getting generic OpenAI API key:', error); + return { hasKey: false, maskedKey: '' }; + } + }); + + // Set generic OpenAI API key + ipcMain.handle('chat:setGenericOpenAIApiKey', async (_, apiKey: string) => { + try { + await ensureInitialized(); + const reg = getProviders(); + const previousKey = reg.getGenericOpenAIApiKey(); + reg.setGenericOpenAIApiKey(apiKey); + + // Persist to encrypted storage — roll back in-memory key on failure + try { + await getSecureKeyStore().store('generic_openai_api_key', apiKey); + } catch (storeError) { + reg.setGenericOpenAIApiKey(previousKey); + throw storeError; + } + + return { success: true }; + } catch (error) { + console.error('[Chat IPC] Error setting generic OpenAI API key:', error); + return { success: false, error: (error as Error).message }; + } + }); + + // Validate generic OpenAI configuration + ipcMain.handle('chat:validateGenericOpenAIConfig', async () => { + try { + await ensureInitialized(); + return await getProviders().validateGenericOpenAIConfig(); + } catch (error) { + console.error('[Chat IPC] Error validating generic OpenAI config:', error); + return { isValid: false, models: [], error: (error as Error).message }; + } + }); + + // Fetch generic OpenAI models + ipcMain.handle('chat:getGenericOpenAIModels', async () => { + try { + await ensureInitialized(); + return await getProviders().fetchGenericOpenAIModels(); + } catch (error) { + console.error('[Chat IPC] Error fetching generic OpenAI models:', error); + return []; + } + }); + + // Get generic OpenAI model capability overrides + ipcMain.handle('chat:getGenericOpenAIModelCapabilities', async () => { + try { + await ensureInitialized(); + return getProviders().getAllGenericOpenAIModelCapabilities(); + } catch (error) { + console.error('[Chat IPC] Error getting generic OpenAI model capabilities:', error); + return {}; + } + }); + + // Set capability override for a single generic OpenAI model + ipcMain.handle('chat:setGenericOpenAIModelCapabilities', async (_, modelId: string, caps: { tools: boolean; vision: boolean }) => { + try { + await ensureInitialized(); + const reg = getProviders(); + reg.setGenericOpenAIModelCapabilities(modelId, caps); + + // Persist all capabilities to settings DB + const allCaps = reg.getAllGenericOpenAIModelCapabilities(); + await getChatEngine().setSetting('generic_openai_model_capabilities', JSON.stringify(allCaps)); + return { success: true }; + } catch (error) { + console.error('[Chat IPC] Error setting generic OpenAI model capabilities:', error); + return { success: false, error: (error as Error).message }; + } + }); + // ============ Offline / Airplane Mode ============ ipcMain.handle('chat:getOfflineMode', async () => { @@ -627,12 +809,16 @@ export function registerChatHandlers(): void { // Persist known local model IDs so offline mode survives restarts const ollamaModels = models.filter(m => m.provider === 'ollama').map(m => m.id); const lmstudioModels = models.filter(m => m.provider === 'lmstudio').map(m => m.id); + const genericOpenAIModels = models.filter(m => m.provider === 'generic-openai').map(m => m.id); if (ollamaModels.length > 0) { await engine.setSetting('ollama_known_model_ids', JSON.stringify(ollamaModels)).catch(() => {}); } if (lmstudioModels.length > 0) { await engine.setSetting('lmstudio_known_model_ids', JSON.stringify(lmstudioModels)).catch(() => {}); } + if (genericOpenAIModels.length > 0) { + await engine.setSetting('generic_openai_known_model_ids', JSON.stringify(genericOpenAIModels)).catch(() => {}); + } return { success: true, models, selectedModel }; } catch (error) { diff --git a/src/main/preload.ts b/src/main/preload.ts index 2d7471a..733ada3 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -357,6 +357,18 @@ export const electronAPI: ElectronAPI = { getLmstudioModelCapabilities: () => ipcRenderer.invoke('chat:getLmstudioModelCapabilities'), setLmstudioModelCapabilities: (modelId: string, caps: { tools: boolean; vision: boolean }) => ipcRenderer.invoke('chat:setLmstudioModelCapabilities', modelId, caps), + // Generic OpenAI-compatible Endpoint + getGenericOpenAIEnabled: () => ipcRenderer.invoke('chat:getGenericOpenAIEnabled'), + setGenericOpenAIEnabled: (enabled: boolean) => ipcRenderer.invoke('chat:setGenericOpenAIEnabled', enabled), + getGenericOpenAIBaseURL: () => ipcRenderer.invoke('chat:getGenericOpenAIBaseURL'), + setGenericOpenAIBaseURL: (baseURL: string) => ipcRenderer.invoke('chat:setGenericOpenAIBaseURL', baseURL), + getGenericOpenAIApiKey: () => ipcRenderer.invoke('chat:getGenericOpenAIApiKey'), + setGenericOpenAIApiKey: (apiKey: string) => ipcRenderer.invoke('chat:setGenericOpenAIApiKey', apiKey), + validateGenericOpenAIConfig: () => ipcRenderer.invoke('chat:validateGenericOpenAIConfig'), + getGenericOpenAIModels: () => ipcRenderer.invoke('chat:getGenericOpenAIModels'), + getGenericOpenAIModelCapabilities: () => ipcRenderer.invoke('chat:getGenericOpenAIModelCapabilities'), + setGenericOpenAIModelCapabilities: (modelId: string, caps: { tools: boolean; vision: boolean }) => ipcRenderer.invoke('chat:setGenericOpenAIModelCapabilities', modelId, caps), + // Offline / Airplane Mode getOfflineMode: () => ipcRenderer.invoke('chat:getOfflineMode'), setOfflineMode: (enabled: boolean) => ipcRenderer.invoke('chat:setOfflineMode', enabled), diff --git a/src/main/shared/electronApi.ts b/src/main/shared/electronApi.ts index 292f2ab..d118691 100644 --- a/src/main/shared/electronApi.ts +++ b/src/main/shared/electronApi.ts @@ -495,7 +495,7 @@ export interface ChatReadyStatus { ready: boolean; error?: string; backend?: string; - providers?: { opencode: boolean; mistral: boolean; ollama: boolean; lmstudio: boolean; offlineMode: boolean }; + providers?: { opencode: boolean; mistral: boolean; ollama: boolean; lmstudio: boolean; genericOpenAI: boolean; offlineMode: boolean }; } export interface ChatApiKeyStatus { @@ -1022,6 +1022,18 @@ export interface ElectronAPI { getLmstudioModelCapabilities: () => Promise>; setLmstudioModelCapabilities: (modelId: string, caps: { tools: boolean; vision: boolean }) => Promise<{ success: boolean; error?: string }>; + // Generic OpenAI-compatible endpoint + getGenericOpenAIEnabled: () => Promise; + setGenericOpenAIEnabled: (enabled: boolean) => Promise<{ success: boolean; error?: string }>; + getGenericOpenAIBaseURL: () => Promise; + setGenericOpenAIBaseURL: (baseURL: string) => Promise<{ success: boolean; error?: string }>; + getGenericOpenAIApiKey: () => Promise; + setGenericOpenAIApiKey: (apiKey: string) => Promise<{ success: boolean; error?: string }>; + validateGenericOpenAIConfig: () => Promise<{ isValid: boolean; models: ChatModel[]; error?: string }>; + getGenericOpenAIModels: () => Promise; + getGenericOpenAIModelCapabilities: () => Promise>; + setGenericOpenAIModelCapabilities: (modelId: string, caps: { tools: boolean; vision: boolean }) => Promise<{ success: boolean; error?: string }>; + // Offline / Airplane mode getOfflineMode: () => Promise; setOfflineMode: (enabled: boolean) => Promise<{ success: boolean; error?: string }>; diff --git a/src/renderer/components/SettingsView/SettingsView.tsx b/src/renderer/components/SettingsView/SettingsView.tsx index 96d8874..4e5e18b 100644 --- a/src/renderer/components/SettingsView/SettingsView.tsx +++ b/src/renderer/components/SettingsView/SettingsView.tsx @@ -253,6 +253,13 @@ export const SettingsView: React.FC = () => { const [lmstudioEnabled, setLmstudioEnabled] = useState(false); const [lmstudioCapabilities, setLmstudioCapabilities] = useState>({}); const [lmstudioModels, setLmstudioModels] = useState<{id: string; name: string}[]>([]); + const [genericOpenAIEnabled, setGenericOpenAIEnabled] = useState(false); + const [genericOpenAIBaseURL, setGenericOpenAIBaseURL] = useState(''); + const [genericOpenAIApiKeyMasked, setGenericOpenAIApiKeyMasked] = useState(''); + const [hasGenericOpenAIApiKey, setHasGenericOpenAIApiKey] = useState(false); + const [newGenericOpenAIApiKey, setNewGenericOpenAIApiKey] = useState(''); + const [genericOpenAIModels, setGenericOpenAIModels] = useState<{id: string; name: string}[]>([]); + const [genericOpenAICapabilities, setGenericOpenAICapabilities] = useState>({}); const [offlineModeEnabled, setOfflineModeEnabled] = useState(false); const [offlineChatModel, setOfflineChatModel] = useState(''); const [offlineTitleModel, setOfflineTitleModel] = useState(''); @@ -464,6 +471,33 @@ export const SettingsView: React.FC = () => { if (lmModels) setLmstudioModels(lmModels.map(m => ({ id: m.id, name: m.name }))); } + // Load generic OpenAI enabled state + const genericOpenAIState = await window.electronAPI?.chat.getGenericOpenAIEnabled(); + setGenericOpenAIEnabled(!!genericOpenAIState); + + // Load generic OpenAI base URL + const genericOpenAIBaseURLResult = await window.electronAPI?.chat.getGenericOpenAIBaseURL(); + if (genericOpenAIBaseURLResult) { + setGenericOpenAIBaseURL(genericOpenAIBaseURLResult); + } + + // Load generic OpenAI API key status + const genericOpenAIApiKeyResult = await window.electronAPI?.chat.getGenericOpenAIApiKey(); + if (genericOpenAIApiKeyResult) { + setHasGenericOpenAIApiKey(genericOpenAIApiKeyResult.hasKey); + setGenericOpenAIApiKeyMasked(genericOpenAIApiKeyResult.maskedKey || ''); + } + + // Load generic OpenAI model capabilities and models list + if (genericOpenAIState) { + const [genCaps, genModels] = await Promise.all([ + window.electronAPI?.chat.getGenericOpenAIModelCapabilities(), + window.electronAPI?.chat.getGenericOpenAIModels(), + ]); + if (genCaps) setGenericOpenAICapabilities(genCaps); + if (genModels) setGenericOpenAIModels(genModels.map(m => ({ id: m.id, name: m.name }))); + } + // Load per-purpose model preferences const titleModelResult = await window.electronAPI?.chat.getTitleModel(); if (titleModelResult?.success && titleModelResult.modelId) { @@ -1340,6 +1374,116 @@ export const SettingsView: React.FC = () => { } }; + // Generic OpenAI handlers + const handleGenericOpenAIToggle = async (enabled: boolean) => { + try { + const result = await window.electronAPI?.chat.setGenericOpenAIEnabled(enabled); + if (result?.success) { + setGenericOpenAIEnabled(enabled); + showToast.success(t(enabled ? 'settings.toast.genericOpenAIEnabled' : 'settings.toast.genericOpenAIDisabled')); + + // Refresh models after toggle + const modelsResult = await window.electronAPI?.chat.getAvailableModels(); + if (modelsResult?.success && modelsResult.models) { + setAvailableModels(modelsResult.models); + setSelectedModel(modelsResult.selectedModel || ''); + } + + // Load generic OpenAI models and capabilities when enabling + if (enabled) { + const [caps, genModelsList] = await Promise.all([ + window.electronAPI?.chat.getGenericOpenAIModelCapabilities(), + window.electronAPI?.chat.getGenericOpenAIModels(), + ]); + if (caps) setGenericOpenAICapabilities(caps); + if (genModelsList) setGenericOpenAIModels(genModelsList.map(m => ({ id: m.id, name: m.name }))); + } else { + setGenericOpenAIModels([]); + } + } + } catch (error) { + console.error('Failed to toggle generic OpenAI:', error); + } + }; + + const handleSaveGenericOpenAIBaseURL = async () => { + try { + const result = await window.electronAPI?.chat.setGenericOpenAIBaseURL(genericOpenAIBaseURL); + if (!result?.success) { + throw new Error(result?.error || 'Failed to save generic OpenAI base URL'); + } + const storedBaseURL = await window.electronAPI?.chat.getGenericOpenAIBaseURL(); + if (typeof storedBaseURL === 'string') { + setGenericOpenAIBaseURL(storedBaseURL); + } + + const [caps, genModelsList, modelsResult] = await Promise.all([ + window.electronAPI?.chat.getGenericOpenAIModelCapabilities(), + window.electronAPI?.chat.getGenericOpenAIModels(), + window.electronAPI?.chat.getAvailableModels(), + ]); + setGenericOpenAICapabilities(caps || {}); + setGenericOpenAIModels((genModelsList || []).map(m => ({ id: m.id, name: m.name }))); + if (modelsResult?.success && modelsResult.models) { + setAvailableModels(modelsResult.models); + setSelectedModel(modelsResult.selectedModel || ''); + } + + showToast.success(t('settings.toast.genericOpenAISettingsSaved')); + } catch (error) { + console.error('Failed to save generic OpenAI base URL:', error); + showToast.error(t('settings.toast.genericOpenAISettingsSaveFailed')); + } + }; + + const handleSaveGenericOpenAIApiKey = async () => { + if (!newGenericOpenAIApiKey.trim()) return; + try { + const trimmedKey = newGenericOpenAIApiKey.trim(); + const result = await window.electronAPI?.chat.setGenericOpenAIApiKey(trimmedKey); + if (!result?.success) { + throw new Error(result?.error || 'Failed to save generic OpenAI API key'); + } + setHasGenericOpenAIApiKey(true); + setGenericOpenAIApiKeyMasked('•'.repeat(Math.max(0, trimmedKey.length - 4)) + trimmedKey.slice(-4)); + setNewGenericOpenAIApiKey(''); + showToast.success(t('settings.toast.apiKeySaved')); + + const [caps, genModelsList, modelsResult] = await Promise.all([ + window.electronAPI?.chat.getGenericOpenAIModelCapabilities(), + window.electronAPI?.chat.getGenericOpenAIModels(), + window.electronAPI?.chat.getAvailableModels(), + ]); + setGenericOpenAICapabilities(caps || {}); + setGenericOpenAIModels((genModelsList || []).map(m => ({ id: m.id, name: m.name }))); + if (modelsResult?.success && modelsResult.models) { + setAvailableModels(modelsResult.models); + setSelectedModel(modelsResult.selectedModel || ''); + } + } catch (error) { + console.error('Failed to save generic OpenAI API key:', error); + showToast.error(t('settings.toast.apiKeySaveFailed')); + } + }; + + const handleGenericOpenAICapabilityToggle = async (modelId: string, field: 'tools' | 'vision', value: boolean) => { + const current = genericOpenAICapabilities[modelId] ?? { tools: false, vision: false }; + const updated = { ...current, [field]: value }; + try { + const result = await window.electronAPI?.chat.setGenericOpenAIModelCapabilities(modelId, updated); + if (result?.success) { + setGenericOpenAICapabilities(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 generic OpenAI model capabilities:', error); + } + }; + const handleTitleModelChange = async (modelId: string) => { try { const result = await window.electronAPI?.chat.setTitleModel(modelId); @@ -1488,6 +1632,7 @@ export const SettingsView: React.FC = () => { if (provider === 'mistral') return t('settings.ai.providerMistral'); if (provider === 'ollama') return t('settings.ai.providerOllama'); if (provider === 'lmstudio') return t('settings.ai.providerLmstudio'); + if (provider === 'generic-openai') return t('settings.ai.providerGenericOpenAI'); return provider; }; @@ -1716,6 +1861,117 @@ export const SettingsView: React.FC = () => { )} + +
+ + {genericOpenAIEnabled && ( + {t('settings.ai.configured')} + )} +
+ {genericOpenAIEnabled && ( +
+
+ + {t('settings.ai.genericOpenAIBaseUrlDescription')} + setGenericOpenAIBaseURL(e.target.value)} + placeholder="https://api.example.com/v1" + /> + +
+
+ + {t('settings.ai.genericOpenAIApiKeyDescription')} + {hasGenericOpenAIApiKey ? ( + <> + + {t('settings.ai.configured')} +
+ +
+ + ) : ( + <> + setNewGenericOpenAIApiKey(e.target.value)} + placeholder={t('chat.apiKeyPlaceholder')} + /> + + + )} +
+ {genericOpenAIEnabled && genericOpenAIModels.length > 0 && ( +
+ {t('settings.ai.genericOpenAICapabilitiesDescription')} + + + + + + + + + + {genericOpenAIModels.map(m => { + const caps = genericOpenAICapabilities[m.id] ?? { tools: false, vision: false }; + return ( + + + + + + ); + })} + +
{t('settings.ai.genericOpenAICapModel')}{t('settings.ai.genericOpenAICapTools')}{t('settings.ai.genericOpenAICapVision')}
{m.name} + handleGenericOpenAICapabilityToggle(m.id, 'tools', e.target.checked)} + /> + + handleGenericOpenAICapabilityToggle(m.id, 'vision', e.target.checked)} + /> +
+
+ )} +
+ )} +
+ { }); it('getProviderStatus() reports all providers', () => { - expect(registry.getProviderStatus()).toEqual({ opencode: false, mistral: false, ollama: false, lmstudio: false, offlineMode: false }); + expect(registry.getProviderStatus()).toEqual({ opencode: false, mistral: false, ollama: false, lmstudio: false, genericOpenAI: false, offlineMode: false }); registry.setOpencodeKey('test'); - expect(registry.getProviderStatus()).toEqual({ opencode: true, mistral: false, ollama: false, lmstudio: false, offlineMode: false }); + expect(registry.getProviderStatus()).toEqual({ opencode: true, mistral: false, ollama: false, lmstudio: false, genericOpenAI: false, offlineMode: false }); registry.setMistralKey('test2'); - expect(registry.getProviderStatus()).toEqual({ opencode: true, mistral: true, ollama: false, lmstudio: false, offlineMode: false }); + expect(registry.getProviderStatus()).toEqual({ opencode: true, mistral: true, ollama: false, lmstudio: false, genericOpenAI: false, offlineMode: false }); registry.setOllamaEnabled(true); - expect(registry.getProviderStatus()).toEqual({ opencode: true, mistral: true, ollama: true, lmstudio: false, offlineMode: false }); + expect(registry.getProviderStatus()).toEqual({ opencode: true, mistral: true, ollama: true, lmstudio: false, genericOpenAI: false, offlineMode: false }); registry.setLmstudioEnabled(true); - expect(registry.getProviderStatus()).toEqual({ opencode: true, mistral: true, ollama: true, lmstudio: true, offlineMode: false }); + expect(registry.getProviderStatus()).toEqual({ opencode: true, mistral: true, ollama: true, lmstudio: true, genericOpenAI: false, offlineMode: false }); }); it('isProviderKeySet() checks per-provider', () => { diff --git a/tests/engine/generic-openai-chat-service.test.ts b/tests/engine/generic-openai-chat-service.test.ts new file mode 100644 index 0000000..83623a8 --- /dev/null +++ b/tests/engine/generic-openai-chat-service.test.ts @@ -0,0 +1,106 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { mockStreamText, mockGenerateText } = vi.hoisted(() => ({ + mockStreamText: vi.fn(), + mockGenerateText: vi.fn(), +})); + +vi.mock('ai', async () => { + const actual = await vi.importActual('ai'); + return { + ...actual, + streamText: mockStreamText, + generateText: mockGenerateText, + stepCountIs: vi.fn(() => undefined), + }; +}); + +vi.mock('../../src/main/engine/ModelCatalogEngine', () => ({ + ModelCatalogEngine: class { + getAll = vi.fn().mockResolvedValue([]); + getContextWindow = vi.fn().mockResolvedValue(8192); + }, +})); + +import { ChatService } from '../../src/main/engine/ai/chat'; +import { ProviderRegistry } from '../../src/main/engine/ai/providers'; + +function createChatEngine() { + return { + getConversation: vi.fn(async () => ({ + id: 'conv-1', + title: 'Untitled', + model: 'generic-model', + createdAt: new Date(), + messages: [], + })), + addMessage: vi.fn(async () => undefined), + getDefaultSystemPrompt: vi.fn(async () => 'You are a helpful assistant'), + getSetting: vi.fn(async (key: string) => { + if (key === 'chat_title_model') { + return 'generic-model'; + } + return null; + }), + updateConversation: vi.fn(async () => undefined), + } as any; +} + +describe('ChatService generic OpenAI endpoint support', () => { + let registry: ProviderRegistry; + let chatEngine: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + + mockStreamText.mockResolvedValue({ + response: Promise.resolve(), + usage: Promise.resolve(undefined), + text: Promise.resolve('assistant reply'), + }); + mockGenerateText.mockResolvedValue({ text: 'Generic Title' }); + + registry = new ProviderRegistry(); + registry.setGenericOpenAIEnabled(true); + registry.setGenericOpenAIBaseURL('http://localhost:4000/v1'); + registry.registerGenericOpenAIModel('generic-model'); + vi.spyOn(registry, 'resolveModel').mockReturnValue({ modelId: 'generic-model' } as any); + + chatEngine = createChatEngine(); + }); + + it('skips tools for generic models when tools capability is disabled', async () => { + registry.setGenericOpenAIModelCapabilities('generic-model', { tools: false, vision: false }); + + const service = new ChatService(chatEngine, registry, { + postEngine: {} as any, + mediaEngine: {} as any, + postMediaEngine: {} as any, + }, () => null); + + const result = await service.sendMessage('conv-1', 'Hello'); + + expect(result.success).toBe(true); + expect(mockStreamText).toHaveBeenCalledWith(expect.objectContaining({ + tools: undefined, + })); + }); + + it('generates a title with the configured generic endpoint title model', async () => { + registry.setGenericOpenAIModelCapabilities('generic-model', { tools: false, vision: false }); + + const service = new ChatService(chatEngine, registry, { + postEngine: {} as any, + mediaEngine: {} as any, + postMediaEngine: {} as any, + }, () => null); + + await (service as any).generateConversationTitle('conv-1', 'Hello'); + + expect(mockGenerateText).toHaveBeenCalledWith(expect.objectContaining({ + model: expect.anything(), + prompt: 'Topic: Hello', + })); + expect(chatEngine.updateConversation).toHaveBeenCalledWith('conv-1', { title: 'Generic Title' }); + }); +}); \ No newline at end of file diff --git a/tests/engine/generic-openai-provider.test.ts b/tests/engine/generic-openai-provider.test.ts new file mode 100644 index 0000000..8f031de --- /dev/null +++ b/tests/engine/generic-openai-provider.test.ts @@ -0,0 +1,58 @@ +/** + * Tests for generic OpenAI-compatible endpoint support in ProviderRegistry. + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { ProviderRegistry } from '../../src/main/engine/ai/providers'; + +vi.mock('../../src/main/engine/ModelCatalogEngine', () => ({ + ModelCatalogEngine: class { + getAll = vi.fn().mockResolvedValue([]); + getContextWindow = vi.fn().mockResolvedValue(null); + }, +})); + +describe('generic OpenAI-compatible provider support', () => { + let registry: ProviderRegistry; + + beforeEach(() => { + registry = new ProviderRegistry(); + }); + + it('fetchGenericOpenAIModels does not duplicate the v1 path when base URL already ends with /v1', async () => { + registry.setGenericOpenAIEnabled(true); + registry.setGenericOpenAIBaseURL('http://localhost:4000/v1'); + + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + data: [{ id: 'custom-model' }], + }), + }); + const originalFetch = globalThis.fetch; + globalThis.fetch = mockFetch; + + try { + const models = await registry.fetchGenericOpenAIModels(); + expect(mockFetch).toHaveBeenCalledWith( + 'http://localhost:4000/v1/models', + expect.objectContaining({ method: 'GET', signal: expect.any(AbortSignal) }), + ); + expect(models).toHaveLength(1); + expect(models[0]).toMatchObject({ id: 'custom-model', provider: 'generic-openai' }); + } finally { + globalThis.fetch = originalFetch; + } + }); + + it('does not treat generic endpoint models as local when airplane mode is active', () => { + registry.setGenericOpenAIEnabled(true); + registry.setGenericOpenAIBaseURL('http://localhost:4000/v1'); + registry.registerGenericOpenAIModel('custom-model'); + registry.setOfflineMode(true); + + expect(registry.isReady()).toBe(false); + expect(registry.getKnownLocalModels()).toEqual([]); + expect(() => registry.resolveModel('custom-model')).toThrow(/not available offline/i); + }); +}); \ No newline at end of file diff --git a/tests/ipc/chatHandlersKeychain.test.ts b/tests/ipc/chatHandlersKeychain.test.ts index 51d56c9..6c2d978 100644 --- a/tests/ipc/chatHandlersKeychain.test.ts +++ b/tests/ipc/chatHandlersKeychain.test.ts @@ -24,9 +24,11 @@ const secureKeyStoreInstances: Array> = []; // Per-test overrides for SecureKeyStore mock behavior let secureKeyStoreRetrieveResult: string | null = 'encrypted-stored-key'; +let secureKeyStoreRetrieveByKey = new Map(); let secureKeyStoreStoreError: Error | null = null; let secureKeyStoreRetrieveError: Error | null = null; let secureKeyStoreCleanupError: Error | null = null; +let chatEngineSettingValues = new Map(); vi.mock('electron', () => ({ BrowserWindow: { @@ -55,7 +57,7 @@ vi.mock('../../src/main/engine/ChatEngine', () => ({ ChatEngine: class { constructor() { const instance = { - getSetting: vi.fn(async () => null), + getSetting: vi.fn(async (key: string) => chatEngineSettingValues.get(key) ?? null), setSetting: vi.fn(async () => undefined), deleteSetting: vi.fn(async () => undefined), getSelectedModel: vi.fn(async () => 'gpt-5'), @@ -75,8 +77,11 @@ vi.mock('../../src/main/engine/SecureKeyStore', () => ({ store: vi.fn(async (_key: string, _value: string) => { if (secureKeyStoreStoreError) throw secureKeyStoreStoreError; }), - retrieve: vi.fn(async () => { + retrieve: vi.fn(async (key: string) => { if (secureKeyStoreRetrieveError) throw secureKeyStoreRetrieveError; + if (secureKeyStoreRetrieveByKey.has(key)) { + return secureKeyStoreRetrieveByKey.get(key) ?? null; + } return secureKeyStoreRetrieveResult; }), remove: vi.fn(async () => undefined), @@ -98,9 +103,17 @@ vi.mock('../../src/main/engine/ai/providers', () => ({ getOpencodeKey: vi.fn(() => 'abc12345'), setMistralKey: vi.fn(), getMistralKey: vi.fn(() => ''), + setGenericOpenAIEnabled: vi.fn(), + isGenericOpenAIEnabled: vi.fn(() => false), + setGenericOpenAIBaseURL: vi.fn(), + getGenericOpenAIBaseURL: vi.fn(() => ''), + setGenericOpenAIApiKey: vi.fn(), + getGenericOpenAIApiKey: vi.fn(() => ''), + loadGenericOpenAIModelCapabilities: vi.fn(), + registerGenericOpenAIModel: vi.fn(), isReady: vi.fn(() => true), isProviderKeySet: vi.fn(() => true), - getProviderStatus: vi.fn(() => ({ opencode: true, mistral: false })), + getProviderStatus: vi.fn(() => ({ opencode: true, mistral: false, ollama: false, lmstudio: false, genericOpenAI: false, offlineMode: false })), resolveModel: vi.fn(), getAvailableModels: vi.fn(async () => []), validateOpencodeKey: vi.fn(async () => ({ isValid: true, models: [] })), @@ -141,9 +154,11 @@ describe('chatHandlers keychain integration', () => { providerRegistryInstances.length = 0; secureKeyStoreInstances.length = 0; secureKeyStoreRetrieveResult = 'encrypted-stored-key'; + secureKeyStoreRetrieveByKey = new Map(); secureKeyStoreStoreError = null; secureKeyStoreRetrieveError = null; secureKeyStoreCleanupError = null; + chatEngineSettingValues = new Map(); vi.resetModules(); }); @@ -282,6 +297,42 @@ describe('chatHandlers keychain integration', () => { expect(registry.setOpencodeKey).toHaveBeenCalledWith('encrypted-stored-key'); }); + it('restores generic endpoint settings from storage on init', async () => { + chatEngineSettingValues = new Map([ + ['generic_openai_enabled', 'true'], + ['generic_openai_base_url', 'http://localhost:4000/v1'], + ['generic_openai_model_capabilities', JSON.stringify({ + 'custom-model': { tools: true, vision: false }, + })], + ['generic_openai_known_model_ids', JSON.stringify(['custom-model'])], + ]); + secureKeyStoreRetrieveByKey = new Map([ + ['opencode_api_key', 'encrypted-stored-key'], + ['mistral_api_key', null], + ['generic_openai_api_key', 'generic-secret'], + ]); + + const mod = await import('../../src/main/ipc/chatHandlers'); + const mockBundle = { postEngine: {}, mediaEngine: {}, postMediaEngine: {} }; + mod.initializeChatHandlers(() => mainWindowMock as never, mockBundle as any); + mod.registerChatHandlers(); + + const handler = registeredHandlers.get('chat:checkReady'); + await handler!(undefined); + + const registry = providerRegistryInstances[0]; + expect(registry.setGenericOpenAIEnabled).toHaveBeenCalledWith(true); + expect(registry.setGenericOpenAIBaseURL).toHaveBeenCalledWith('http://localhost:4000/v1'); + expect(registry.setGenericOpenAIApiKey).toHaveBeenCalledWith('generic-secret'); + expect(registry.loadGenericOpenAIModelCapabilities).toHaveBeenCalledWith({ + 'custom-model': { tools: true, vision: false }, + }); + expect(registry.registerGenericOpenAIModel).toHaveBeenCalledWith('custom-model'); + + const keyStore = secureKeyStoreInstances[0]; + expect(keyStore.retrieve).toHaveBeenCalledWith('generic_openai_api_key'); + }); + it('returns error and rolls back in-memory key when store() throws on chat:setApiKey', async () => { secureKeyStoreStoreError = new Error('encryption unavailable'); diff --git a/tests/renderer/components/SettingsView.test.tsx b/tests/renderer/components/SettingsView.test.tsx index 198cba5..63c5cee 100644 --- a/tests/renderer/components/SettingsView.test.tsx +++ b/tests/renderer/components/SettingsView.test.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { describe, it, expect, beforeEach, vi } from 'vitest'; -import { render, screen, fireEvent, waitFor, act } from '@testing-library/react'; +import { render, screen, fireEvent, waitFor, act, within } from '@testing-library/react'; import { SettingsView } from '../../../src/renderer/components/SettingsView/SettingsView'; import { useAppStore } from '../../../src/renderer/store'; @@ -24,6 +24,8 @@ describe('MCPAgentButton uninstall', () => { app: { getDefaultProjectPath: vi.fn().mockResolvedValue('/repo') }, meta: { getCategories: vi.fn().mockResolvedValue(['article']), + getPublishingPreferences: vi.fn().mockResolvedValue(null), + setPublishingPreferences: vi.fn().mockResolvedValue({}), getProjectMetadata: vi.fn().mockResolvedValue({ maxPostsPerPage: 75, publicUrl: 'https://example.com', @@ -36,6 +38,17 @@ describe('MCPAgentButton uninstall', () => { getApiKey: vi.fn().mockResolvedValue({ hasKey: false, maskedKey: '' }), getAvailableModels: vi.fn().mockResolvedValue({ success: true, models: [], selectedModel: '' }), getMistralApiKey: vi.fn().mockResolvedValue({ hasKey: false, maskedKey: '' }), + getOllamaEnabled: vi.fn().mockResolvedValue(false), + getLmstudioEnabled: vi.fn().mockResolvedValue(false), + getGenericOpenAIEnabled: vi.fn().mockResolvedValue(false), + getGenericOpenAIBaseURL: vi.fn().mockResolvedValue(''), + getGenericOpenAIApiKey: vi.fn().mockResolvedValue({ hasKey: false, maskedKey: '' }), + getGenericOpenAIModelCapabilities: vi.fn().mockResolvedValue({}), + getGenericOpenAIModels: vi.fn().mockResolvedValue([]), + getOfflineMode: vi.fn().mockResolvedValue(false), + getOfflineChatModel: vi.fn().mockResolvedValue({ success: true, modelId: null }), + getOfflineTitleModel: vi.fn().mockResolvedValue({ success: true, modelId: null }), + getOfflineImageAnalysisModel: vi.fn().mockResolvedValue({ success: true, modelId: null }), getTitleModel: vi.fn().mockResolvedValue({ success: true, modelId: 'claude-haiku-4-5' }), getImageAnalysisModel: vi.fn().mockResolvedValue({ success: true, modelId: 'claude-sonnet-4-5' }), getModelCatalog: vi.fn().mockResolvedValue({ success: true, entries: [] }), @@ -114,6 +127,8 @@ describe('SettingsView Diff Preferences', () => { meta: { ...(window as any).electronAPI?.meta, getCategories: vi.fn().mockResolvedValue(['article', 'picture', 'aside', 'page']), + getPublishingPreferences: vi.fn().mockResolvedValue(null), + setPublishingPreferences: vi.fn().mockResolvedValue({}), getProjectMetadata: vi.fn().mockResolvedValue({ maxPostsPerPage: 75, publicUrl: 'https://example.com', @@ -131,6 +146,17 @@ describe('SettingsView Diff Preferences', () => { getSystemPrompt: vi.fn().mockResolvedValue({ success: true, prompt: '' }), getApiKey: vi.fn().mockResolvedValue({ hasKey: false, maskedKey: '' }), getAvailableModels: vi.fn().mockResolvedValue({ success: true, models: [], selectedModel: '' }), + getOllamaEnabled: vi.fn().mockResolvedValue(false), + getLmstudioEnabled: vi.fn().mockResolvedValue(false), + getGenericOpenAIEnabled: vi.fn().mockResolvedValue(false), + getGenericOpenAIBaseURL: vi.fn().mockResolvedValue(''), + getGenericOpenAIApiKey: vi.fn().mockResolvedValue({ hasKey: false, maskedKey: '' }), + getGenericOpenAIModelCapabilities: vi.fn().mockResolvedValue({}), + getGenericOpenAIModels: vi.fn().mockResolvedValue([]), + getOfflineMode: vi.fn().mockResolvedValue(false), + getOfflineChatModel: vi.fn().mockResolvedValue({ success: true, modelId: null }), + getOfflineTitleModel: vi.fn().mockResolvedValue({ success: true, modelId: null }), + getOfflineImageAnalysisModel: vi.fn().mockResolvedValue({ success: true, modelId: null }), }, templates: { ...(window as any).electronAPI?.templates, @@ -395,3 +421,101 @@ describe('SettingsView Diff Preferences', () => { ); }); }); + +describe('SettingsView generic endpoint refresh', () => { + beforeEach(() => { + vi.clearAllMocks(); + useAppStore.setState({ + activeProject: { + id: 'project-1', + name: 'Test Project', + slug: 'test-project', + isActive: true, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }, + gitDiffPreferences: { + wordWrap: true, + viewStyle: 'inline', + hideUnchangedRegions: false, + }, + }); + + (window as any).electronAPI = { + ...(window as any).electronAPI, + app: { + ...(window as any).electronAPI?.app, + getDefaultProjectPath: vi.fn().mockResolvedValue('/repo/path'), + }, + meta: { + ...(window as any).electronAPI?.meta, + getCategories: vi.fn().mockResolvedValue(['article']), + getPublishingPreferences: vi.fn().mockResolvedValue(null), + setPublishingPreferences: vi.fn().mockResolvedValue({}), + getProjectMetadata: vi.fn().mockResolvedValue({ + maxPostsPerPage: 75, + publicUrl: 'https://example.com', + categorySettings: { article: { renderInLists: true, showTitle: true } }, + }), + updateProjectMetadata: vi.fn().mockResolvedValue({}), + }, + chat: { + ...(window as any).electronAPI?.chat, + getSystemPrompt: vi.fn().mockResolvedValue({ success: true, prompt: '' }), + getApiKey: vi.fn().mockResolvedValue({ hasKey: false, maskedKey: '' }), + getAvailableModels: vi.fn().mockResolvedValue({ success: true, models: [], selectedModel: '' }), + getMistralApiKey: vi.fn().mockResolvedValue({ hasKey: false, maskedKey: '' }), + getTitleModel: vi.fn().mockResolvedValue({ success: true, modelId: null }), + getImageAnalysisModel: vi.fn().mockResolvedValue({ success: true, modelId: null }), + getModelCatalog: vi.fn().mockResolvedValue({ success: true, entries: [] }), + getOllamaEnabled: vi.fn().mockResolvedValue(false), + getLmstudioEnabled: vi.fn().mockResolvedValue(false), + getGenericOpenAIEnabled: vi.fn().mockResolvedValue(true), + getGenericOpenAIBaseURL: vi.fn().mockResolvedValue('http://localhost:4000/v1'), + getGenericOpenAIApiKey: vi.fn().mockResolvedValue({ hasKey: false, maskedKey: '' }), + getGenericOpenAIModelCapabilities: vi.fn().mockResolvedValue({}), + getGenericOpenAIModels: vi.fn() + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([{ id: 'generic-model', name: 'Generic Model' }]), + setGenericOpenAIBaseURL: vi.fn().mockResolvedValue({ success: true }), + getOfflineMode: vi.fn().mockResolvedValue(false), + getOfflineChatModel: vi.fn().mockResolvedValue({ success: true, modelId: null }), + getOfflineTitleModel: vi.fn().mockResolvedValue({ success: true, modelId: null }), + getOfflineImageAnalysisModel: vi.fn().mockResolvedValue({ success: true, modelId: null }), + }, + templates: { + ...(window as any).electronAPI?.templates, + getEnabledByKind: vi.fn().mockResolvedValue([]), + }, + projects: { + ...(window as any).electronAPI?.projects, + update: vi.fn().mockResolvedValue({}), + }, + mcp: { + ...(window as any).electronAPI?.mcp, + getAgents: vi.fn().mockResolvedValue([]), + isConfigured: vi.fn().mockResolvedValue(false), + getPort: vi.fn().mockResolvedValue(4124), + }, + }; + }); + + it('reloads generic models after saving the generic endpoint base URL', async () => { + render(); + + const baseUrlInput = await screen.findByLabelText(/base url/i); + const field = baseUrlInput.closest('.setting-field'); + expect(field).not.toBeNull(); + + const saveButton = within(field as HTMLElement).getByRole('button', { name: /save/i }); + + await act(async () => { + fireEvent.click(saveButton); + }); + + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect((window as any).electronAPI.chat.getAvailableModels).toHaveBeenCalledTimes(2); + expect((window as any).electronAPI.chat.getGenericOpenAIModels).toHaveBeenCalledTimes(2); + }); +});