diff --git a/src/main/index.ts b/src/main/index.ts index f2b99be..4c3d3de 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -16,6 +16,10 @@ import { setClaudeApiKey, getClaudeModel, setClaudeModel, + getOllamaBaseUrl, + setOllamaBaseUrl, + getOllamaModel, + setOllamaModel, // Connection management functions createConnection, editConnection, @@ -119,6 +123,7 @@ app.whenReady().then(() => { optimizer.watchWindowShortcuts(window) }) + // OpenAI handlers ipcMain.handle('getOpenAiKey', async () => { return (await getOpenAiKey()) ?? '' }) @@ -151,6 +156,7 @@ app.whenReady().then(() => { await setAiProvider(aiProvider) }) + // Claude handlers ipcMain.handle('getClaudeApiKey', async () => { return (await getClaudeApiKey()) ?? '' }) @@ -167,6 +173,70 @@ app.whenReady().then(() => { await setClaudeModel(claudeModel) }) + // Ollama handlers + ipcMain.handle('getOllamaBaseUrl', async () => { + return (await getOllamaBaseUrl()) ?? 'http://localhost:11434/v1/' + }) + + ipcMain.handle('setOllamaBaseUrl', async (_, ollamaBaseUrl) => { + await setOllamaBaseUrl(ollamaBaseUrl) + }) + + ipcMain.handle('getOllamaModel', async () => { + return (await getOllamaModel()) ?? '' + }) + + ipcMain.handle('setOllamaModel', async (_, ollamaModel) => { + await setOllamaModel(ollamaModel) + }) + + // Add handler to list available Ollama models + ipcMain.handle('getOllamaModels', async () => { + try { + const baseUrl = await getOllamaBaseUrl() || 'http://localhost:11434/v1/' + const response = await fetch(`${baseUrl}/api/tags`) + if (!response.ok) { + throw new Error(`Failed to fetch Ollama models: ${response.statusText}`) + } + const data = await response.json() + return { + success: true, + models: data.models || [] + } + } catch (error: any) { + return { + success: false, + error: error.message, + models: [] + } + } + }) + + // Test Ollama connection + ipcMain.handle('testOllamaConnection', async (_, baseUrl) => { + try { + const response = await fetch(`${baseUrl}/api/tags`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + }) + + if (!response.ok) { + throw new Error(`Connection failed: ${response.statusText}`) + } + + return { success: true } + } catch (error: any) { + return { + success: false, + error: error.message.includes('fetch') + ? 'Cannot connect to Ollama. Make sure Ollama is running and accessible.' + : error.message + } + } + }) + // Connection management handlers ipcMain.handle('createConnection', async (_, name, connectionMetadata) => { try { @@ -327,17 +397,21 @@ app.whenReady().then(() => { const aiProvider = await getAiProvider() const promptExtension = await getConnectionPromptExtension(name) - let apiKey: string + let apiKey: string = '' // Initialize with empty string let model: string | undefined - let openAiBaseUrl: string | undefined + let baseUrl: string | undefined if (aiProvider === 'openai') { apiKey = (await getOpenAiKey()) ?? '' model = await getOpenAiModel() - openAiBaseUrl = await getOpenAiBaseUrl() - } else { + baseUrl = await getOpenAiBaseUrl() + } else if (aiProvider === 'claude') { apiKey = (await getClaudeApiKey()) ?? '' model = await getClaudeModel() + } else if (aiProvider === 'ollama') { + apiKey = '' // Ollama doesn't require API key + model = await getOllamaModel() + baseUrl = await getOllamaBaseUrl() } const query = await generateQuery( @@ -347,7 +421,7 @@ app.whenReady().then(() => { apiKey, existingQuery, promptExtension ?? '', - openAiBaseUrl, + baseUrl, model ) return { @@ -416,4 +490,4 @@ app.on('window-all-closed', () => { }) // In this file you can include the rest of your app's specific main process -// code. You can also put them in separate files and require them here. +// code. You can also put them in separate files and require them here. \ No newline at end of file diff --git a/src/main/lib/ai.ts b/src/main/lib/ai.ts index ca2463f..a643b59 100644 --- a/src/main/lib/ai.ts +++ b/src/main/lib/ai.ts @@ -13,7 +13,7 @@ export type QueryResponse = { export async function generateQuery( input: string, connectionString: string, - aiProvider: 'openai' | 'claude', + aiProvider: 'openai' | 'claude' | 'ollama', apiKey: string, existingQuery: string, promptExtension: string, @@ -34,17 +34,22 @@ export async function generateQuery( if (aiProvider === 'openai') { const openai = createOpenAI({ - apiKey: apiKey, + apiKey, baseURL: openAiUrl || undefined }) modelToUse = model || 'gpt-4o' aiModel = openai(modelToUse) } else if (aiProvider === 'claude') { - const anthropic = createAnthropic({ - apiKey: apiKey - }) + const anthropic = createAnthropic({ apiKey }) modelToUse = model || 'claude-sonnet-4-20250514' aiModel = anthropic(modelToUse) + } else if (aiProvider === 'ollama') { + const openai = createOpenAI({ + apiKey: 'ollama-placeholder', // required by SDK, even if not used + baseURL: openAiUrl || 'http://localhost:11434/v1' + }) + modelToUse = model || 'llama3' + aiModel = openai(modelToUse) } else { throw new Error(`Unsupported AI provider: ${aiProvider}`) } @@ -61,7 +66,7 @@ export async function generateQuery( ${promptExtension.length > 0 ? `Extra information: ${promptExtension}` : ``} format the query in a way that is easy to read and understand. - ${dbType === 'postgres' ? 'wrap table names in double quotes' : ''} + ${dbType === 'postgres' ? (aiProvider === 'ollama' ? 'Wrap both table names and column names in double quotes, especially if they contain uppercase letters or special characters.' : 'Wrap table names in double quotes.') : ''} if the query results can be effectively visualized using a graph, specify which column should be used for the x-axis (domain) and which column(s) should be used for the y-axis (range). ` diff --git a/src/main/lib/state.ts b/src/main/lib/state.ts index 22fea1e..8d44b88 100644 --- a/src/main/lib/state.ts +++ b/src/main/lib/state.ts @@ -31,14 +31,17 @@ const favoritesSchema = z.object({ // Global settings schema (AI provider settings only) const globalSettingsSchema = z.object({ - aiProvider: z.enum(['openai', 'claude']).optional(), + aiProvider: z.enum(['openai', 'claude', 'ollama']).optional(), openAiKey: z.string().optional(), openAiBaseUrl: z.string().optional(), openAiModel: z.string().optional(), claudeApiKey: z.string().optional(), - claudeModel: z.string().optional() + claudeModel: z.string().optional(), + ollamaBaseUrl: z.string().optional(), + ollamaModel: z.string().optional() }) + // Connection settings schema const connectionSettingsSchema = z.object({ connectionString: z.string(), @@ -51,9 +54,12 @@ const defaultGlobalSettings: z.infer = { openAiBaseUrl: undefined, openAiModel: undefined, claudeApiKey: undefined, - claudeModel: undefined + claudeModel: undefined, + ollamaBaseUrl: undefined, + ollamaModel: undefined } + function rootDir() { // Allow tests to override the root directory if (process.env.SNAPQL_TEST_ROOT) { @@ -194,7 +200,7 @@ export async function getAiProvider() { return settings.aiProvider || 'openai' } -export async function setAiProvider(aiProvider: 'openai' | 'claude') { +export async function setAiProvider(aiProvider: 'openai' | 'claude' | 'ollama') { const settings = await getGlobalSettings() settings.aiProvider = aiProvider await setGlobalSettings(settings) @@ -221,6 +227,27 @@ export async function setClaudeModel(claudeModel: string) { settings.claudeModel = claudeModel await setGlobalSettings(settings) } +export async function getOllamaBaseUrl() { + const settings = await getGlobalSettings() + return settings.ollamaBaseUrl +} + +export async function setOllamaBaseUrl(ollamaBaseUrl: string) { + const settings = await getGlobalSettings() + settings.ollamaBaseUrl = ollamaBaseUrl + await setGlobalSettings(settings) +} + +export async function getOllamaModel() { + const settings = await getGlobalSettings() + return settings.ollamaModel +} + +export async function setOllamaModel(ollamaModel: string) { + const settings = await getGlobalSettings() + settings.ollamaModel = ollamaModel + await setGlobalSettings(settings) +} // Connection management export async function createConnection( diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts index f30be18..6066bcc 100644 --- a/src/preload/index.d.ts +++ b/src/preload/index.d.ts @@ -15,12 +15,19 @@ declare global { setOpenAiBaseUrl: (openAiBaseUrl: string) => Promise getOpenAiModel: () => Promise setOpenAiModel: (openAiModel: string) => Promise - getAiProvider: () => Promise<'openai' | 'claude'> - setAiProvider: (aiProvider: 'openai' | 'claude') => Promise + getAiProvider: () => Promise<'openai' | 'claude' | 'ollama'> + setAiProvider: (aiProvider: 'openai' | 'claude' | 'ollama') => Promise getClaudeApiKey: () => Promise setClaudeApiKey: (claudeApiKey: string) => Promise getClaudeModel: () => Promise setClaudeModel: (claudeModel: string) => Promise + getOllamaBaseUrl: () => Promise + setOllamaBaseUrl: (ollamaBaseUrl: string) => Promise + getOllamaModel: () => Promise + setOllamaModel: (ollamaModel: string) => Promise + // Add missing Ollama-related functions + getOllamaModels: () => Promise<{ success: boolean; models: any[]; error?: string }> + testOllamaConnection: (baseUrl: string) => Promise<{ success: boolean; error?: string }> // Connection management createConnection: (name: string, connectionMetadata: any) => Promise diff --git a/src/preload/index.ts b/src/preload/index.ts index 1863e30..743c4b1 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -17,7 +17,7 @@ try { setOpenAiModel: async (openAiModel: string) => await ipcRenderer.invoke('setOpenAiModel', openAiModel), getAiProvider: async () => await ipcRenderer.invoke('getAiProvider'), - setAiProvider: async (aiProvider: 'openai' | 'claude') => + setAiProvider: async (aiProvider: 'openai' | 'claude' | 'ollama') => await ipcRenderer.invoke('setAiProvider', aiProvider), getClaudeApiKey: async () => await ipcRenderer.invoke('getClaudeApiKey'), setClaudeApiKey: async (claudeApiKey: string) => @@ -25,6 +25,16 @@ try { getClaudeModel: async () => await ipcRenderer.invoke('getClaudeModel'), setClaudeModel: async (claudeModel: string) => await ipcRenderer.invoke('setClaudeModel', claudeModel), + getOllamaBaseUrl: async () => await ipcRenderer.invoke('getOllamaBaseUrl'), + setOllamaBaseUrl: async (ollamaBaseUrl: string) => + await ipcRenderer.invoke('setOllamaBaseUrl', ollamaBaseUrl), + getOllamaModel: async () => await ipcRenderer.invoke('getOllamaModel'), + setOllamaModel: async (ollamaModel: string) => + await ipcRenderer.invoke('setOllamaModel', ollamaModel), + // Add missing Ollama-related functions + getOllamaModels: async () => await ipcRenderer.invoke('getOllamaModels'), + testOllamaConnection: async (baseUrl: string) => + await ipcRenderer.invoke('testOllamaConnection', baseUrl), // Connection management createConnection: async (name: string, connectionMetadata: any) => @@ -65,4 +75,4 @@ try { }) } catch (error) { console.error(error) -} +} \ No newline at end of file diff --git a/src/renderer/src/components/Settings.tsx b/src/renderer/src/components/Settings.tsx index 0f31a49..b1a2c7d 100644 --- a/src/renderer/src/components/Settings.tsx +++ b/src/renderer/src/components/Settings.tsx @@ -16,13 +16,15 @@ import { useToast } from '../hooks/use-toast' import { ModeToggle } from './ui/mode-toggle' export const Settings = () => { - const [aiProvider, setAiProvider] = useState<'openai' | 'claude'>('openai') + const [aiProvider, setAiProvider] = useState<'openai' | 'claude' | 'ollama'>('openai') const [openAIApiKey, setOpenAIApiKey] = useState('') const [claudeApiKey, setClaudeApiKey] = useState('') const [apiKeyError, setApiKeyError] = useState(null) const [apiKeySuccess, setApiKeySuccess] = useState(null) const [isSavingApiKey, setIsSavingApiKey] = useState(false) const [openAIBaseUrl, setOpenAIBaseUrl] = useState('') + const [ollamaBaseUrl, setOllamaBaseUrl] = useState('') + const [ollamaModel, setOllamaModel] = useState('') const [baseUrlError, setBaseUrlError] = useState(null) const [baseUrlSuccess, setBaseUrlSuccess] = useState(null) const [isSavingBaseUrl, setIsSavingBaseUrl] = useState(false) @@ -39,8 +41,12 @@ export const Settings = () => { useEffect(() => { const loadSavedAiProvider = async () => { try { - const savedAiProvider = await window.context.getAiProvider() - setAiProvider(savedAiProvider) + if (window.context && typeof window.context.getAiProvider === 'function') { + const savedAiProvider = await window.context.getAiProvider() + setAiProvider(savedAiProvider) + } else { + throw new Error('window.context.getAiProvider is not defined') + } } catch (error: any) { console.error('Failed to load AI provider:', error) } @@ -123,7 +129,37 @@ export const Settings = () => { loadSavedClaudeModel() }, [toast]) - const updateAiProvider = async (provider: 'openai' | 'claude') => { + // Load the saved Ollama base URL when the component mounts + useEffect(() => { + const loadOllamaBaseUrl = async () => { + try { + if (window.context && typeof window.context.getOllamaBaseUrl === 'function') { + const url = await window.context.getOllamaBaseUrl() + if (url) setOllamaBaseUrl(url) + } else { + throw new Error('window.context.getOllamaBaseUrl is not defined') + } + } catch (error) { + console.error('Failed to load Ollama base URL:', error) + } + } + loadOllamaBaseUrl() + }, []) + + // Load the saved Ollama model when the component mounts + useEffect(() => { + const loadOllamaModel = async () => { + try { + const model = await window.context.getOllamaModel() + if (model) setOllamaModel(model) + } catch (error) { + console.error('Failed to load Ollama model:', error) + } + } + loadOllamaModel() + }, []) + + const updateAiProvider = async (provider: 'openai' | 'claude' | 'ollama') => { try { await window.context.setAiProvider(provider) setAiProvider(provider) @@ -140,14 +176,20 @@ export const Settings = () => { if (aiProvider === 'openai') { await window.context.setOpenAiKey(openAIApiKey) setApiKeySuccess('OpenAI API key saved successfully.') - } else { + } else if (aiProvider === 'claude') { await window.context.setClaudeApiKey(claudeApiKey) setApiKeySuccess('Claude API key saved successfully.') + } else if (aiProvider === 'ollama') { + // For Ollama, save the base URL and model + await window.context.setOllamaBaseUrl(ollamaBaseUrl) + if (ollamaModel) { + await window.context.setOllamaModel(ollamaModel) + } + setApiKeySuccess('Ollama configuration saved successfully.') } - setApiKeyError(null) } catch (error: any) { setApiKeyError( - `Failed to save the ${aiProvider === 'openai' ? 'OpenAI' : 'Claude'} API key: ` + + `Failed to save the ${aiProvider === 'openai' ? 'OpenAI' : aiProvider === 'claude' ? 'Claude' : 'Ollama'} configuration: ` + error.message ) } finally { @@ -178,14 +220,17 @@ export const Settings = () => { if (aiProvider === 'openai') { await window.context.setOpenAiModel(openAIModel) setModelSuccess('OpenAI model saved successfully.') - } else { + } else if (aiProvider === 'claude') { await window.context.setClaudeModel(claudeModel) setModelSuccess('Claude model saved successfully.') + } else if (aiProvider === 'ollama') { + await window.context.setOllamaModel(ollamaModel) + setModelSuccess('Ollama model saved successfully.') } setModelError(null) } catch (error: any) { setModelError( - `Failed to save the ${aiProvider === 'openai' ? 'OpenAI' : 'Claude'} model: ` + + `Failed to save the ${aiProvider === 'openai' ? 'OpenAI' : aiProvider === 'claude' ? 'Claude' : 'Ollama'} model: ` + error.message ) } finally { @@ -193,6 +238,21 @@ export const Settings = () => { } } + // Function to determine if save button should be disabled + const isSaveDisabled = () => { + if (isSavingApiKey) return true + + if (aiProvider === 'openai') { + return !openAIApiKey.trim() + } else if (aiProvider === 'claude') { + return !claudeApiKey.trim() + } else if (aiProvider === 'ollama') { + return !ollamaBaseUrl.trim() + } + + return true + } + return (
@@ -225,69 +285,109 @@ export const Settings = () => { OpenAI Claude + Ollama
-
- - - aiProvider === 'openai' - ? setOpenAIApiKey(e.target.value) - : setClaudeApiKey(e.target.value) - } - placeholder={aiProvider === 'openai' ? 'sk-...' : 'sk-ant-...'} - className="font-mono text-xs h-8" - /> -

- {aiProvider === 'openai' ? ( - <> - You can create an API key at{' '} - - OpenAI API Keys - - . - - ) : ( - <> - You can create an API key at{' '} - - Anthropic Console - - . - - )} -

- {apiKeyError &&

{apiKeyError}

} - {apiKeySuccess &&

{apiKeySuccess}

} -
+ {/* Conditional rendering based on AI provider */} + {aiProvider === 'ollama' ? ( + // Ollama configuration +
+
+ + setOllamaBaseUrl(e.target.value)} + placeholder="http://localhost:11434/v1/" + className="font-mono text-xs h-8" + /> +

+ URL where your Ollama instance is running. +

+
+ +
+ + setOllamaModel(e.target.value)} + placeholder="llama3" + className="font-mono text-xs h-8" + /> +

+ Model name to use. Leave empty to use default. +

+
+
+ ) : ( + // OpenAI and Claude API key configuration +
+ + + aiProvider === 'openai' + ? setOpenAIApiKey(e.target.value) + : setClaudeApiKey(e.target.value) + } + placeholder={aiProvider === 'openai' ? 'sk-...' : 'sk-ant-...'} + className="font-mono text-xs h-8" + /> +

+ {aiProvider === 'openai' ? ( + <> + You can create an API key at{' '} + + OpenAI API Keys + + . + + ) : ( + <> + You can create an API key at{' '} + + Anthropic Console + + . + + )} +

+
+ )} + + {apiKeyError &&

{apiKeyError}

} + {apiKeySuccess &&

{apiKeySuccess}

} @@ -342,13 +442,29 @@ export const Settings = () => { + value={ + aiProvider === 'openai' + ? openAIModel + : aiProvider === 'claude' + ? claudeModel + : ollamaModel + } + onChange={(e) => { + if (aiProvider === 'openai') { + setOpenAIModel(e.target.value) + } else if (aiProvider === 'claude') { + setClaudeModel(e.target.value) + } else { + setOllamaModel(e.target.value) + } + }} + placeholder={ aiProvider === 'openai' - ? setOpenAIModel(e.target.value) - : setClaudeModel(e.target.value) + ? 'gpt-4o' + : aiProvider === 'claude' + ? 'claude-sonnet-4-20250514' + : 'llama3' } - placeholder={aiProvider === 'openai' ? 'gpt-4o' : 'claude-sonnet-4-20250514'} className="font-mono text-xs h-8" autoComplete="off" /> @@ -358,11 +474,15 @@ export const Settings = () => { Model ID to use for query generation. Leave empty to use gpt-4o (default). Examples: gpt-4, gpt-3.5-turbo, gpt-4o-mini. - ) : ( + ) : aiProvider === 'claude' ? ( <> Model ID to use for query generation. Leave empty to use claude-sonnet-4-20250514 (default). + ) : ( + <> + Model name to use for query generation. Examples: llama3, codellama, mistral. + )}

{modelError &&

{modelError}

}