mirror of
https://github.com/usebruno/bruno.git
synced 2026-07-09 06:55:03 +00:00
feat: AI features into preferences and Redux store (#8178)
This commit is contained in:
@@ -45,6 +45,7 @@ const registerWorkspaceIpc = require('./ipc/workspace');
|
||||
const registerApiSpecIpc = require('./ipc/apiSpec');
|
||||
const registerGitIpc = require('./ipc/git');
|
||||
const registerOpenAPISyncIpc = require('./ipc/openapi-sync');
|
||||
const registerAiIpc = require('./ipc/ai');
|
||||
const collectionWatcher = require('./app/collection-watcher');
|
||||
const WorkspaceWatcher = require('./app/workspace-watcher');
|
||||
const ApiSpecWatcher = require('./app/apiSpecsWatcher');
|
||||
@@ -475,6 +476,7 @@ app.on('ready', async () => {
|
||||
registerSystemMonitorIpc(mainWindow, systemMonitor);
|
||||
registerGitIpc(mainWindow);
|
||||
registerOpenAPISyncIpc(mainWindow);
|
||||
registerAiIpc(mainWindow);
|
||||
|
||||
// Internal delegator
|
||||
ipcMain.handle('main:cache-clear', async () => {
|
||||
@@ -485,7 +487,7 @@ app.on('ready', async () => {
|
||||
// Quit the app once all windows are closed.
|
||||
//
|
||||
// We defer the actual exit until async cleanup (chokidar fsevents handles)
|
||||
// finishes — otherwise the main process exits while native watcher cleanup
|
||||
// finishes, otherwise the main process exits while native watcher cleanup
|
||||
// is mid-flight, and Chromium helper processes can detect the broken IPC
|
||||
// channel and abort(), producing the macOS "quit unexpectedly" dialog.
|
||||
let quitInProgress = false;
|
||||
|
||||
217
packages/bruno-electron/src/ipc/ai/index.js
Normal file
217
packages/bruno-electron/src/ipc/ai/index.js
Normal file
@@ -0,0 +1,217 @@
|
||||
const { ipcMain } = require('electron');
|
||||
const { generateText, streamText } = require('ai');
|
||||
const { getPreferences } = require('../../store/preferences');
|
||||
const { aiKeyStore } = require('../../store/ai-keys');
|
||||
const {
|
||||
PROVIDERS,
|
||||
MODEL_DEFINITIONS,
|
||||
listProviders,
|
||||
listModels,
|
||||
getModel,
|
||||
getAvailableModels,
|
||||
clearSdkCache
|
||||
} = require('./providers');
|
||||
|
||||
const activeStreams = new Map();
|
||||
|
||||
const getAiPrefs = () => getPreferences().ai || {};
|
||||
|
||||
const isEnabled = () => Boolean(getAiPrefs().enabled);
|
||||
|
||||
const buildStatus = () => {
|
||||
const aiPreferences = getAiPrefs();
|
||||
const hasApiKey = (providerId) => aiKeyStore.hasKey(providerId);
|
||||
|
||||
const providers = {};
|
||||
for (const provider of listProviders()) {
|
||||
providers[provider.id] = {
|
||||
...provider,
|
||||
enabled: Boolean(aiPreferences?.providers?.[provider.id]?.enabled),
|
||||
configured: hasApiKey(provider.id)
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
enabled: Boolean(aiPreferences.enabled),
|
||||
providers,
|
||||
models: listModels(),
|
||||
availableModels: getAvailableModels({ aiPreferences, hasApiKey })
|
||||
};
|
||||
};
|
||||
|
||||
const resolveModel = (modelId) => {
|
||||
if (!isEnabled()) {
|
||||
throw new Error('AI features are disabled. Enable them in Preferences > AI.');
|
||||
}
|
||||
return getModel(modelId, {
|
||||
aiPreferences: getAiPrefs(),
|
||||
getApiKey: (providerId) => aiKeyStore.getKey(providerId)
|
||||
});
|
||||
};
|
||||
|
||||
const registerAiIpc = (mainWindow) => {
|
||||
ipcMain.handle('renderer:get-ai-status', async () => buildStatus());
|
||||
|
||||
ipcMain.handle('renderer:set-ai-api-key', async (_event, { providerId, apiKey }) => {
|
||||
if (!PROVIDERS[providerId]) {
|
||||
throw new Error(`Unknown AI provider: ${providerId}`);
|
||||
}
|
||||
const trimmed = typeof apiKey === 'string' ? apiKey.trim() : '';
|
||||
if (!trimmed) {
|
||||
throw new Error('API key cannot be empty');
|
||||
}
|
||||
aiKeyStore.setKey(providerId, trimmed);
|
||||
clearSdkCache();
|
||||
return buildStatus();
|
||||
});
|
||||
|
||||
ipcMain.handle('renderer:clear-ai-api-key', async (_event, { providerId }) => {
|
||||
if (!PROVIDERS[providerId]) {
|
||||
throw new Error(`Unknown AI provider: ${providerId}`);
|
||||
}
|
||||
aiKeyStore.clearKey(providerId);
|
||||
clearSdkCache();
|
||||
return buildStatus();
|
||||
});
|
||||
|
||||
ipcMain.handle('renderer:get-ai-api-key', async (_event, { providerId }) => {
|
||||
if (!PROVIDERS[providerId]) {
|
||||
throw new Error(`Unknown AI provider: ${providerId}`);
|
||||
}
|
||||
return aiKeyStore.getKey(providerId) || '';
|
||||
});
|
||||
|
||||
ipcMain.handle('renderer:ai-test-provider', async (_event, { providerId }) => {
|
||||
if (!PROVIDERS[providerId]) {
|
||||
return { ok: false, error: `Unknown provider: ${providerId}` };
|
||||
}
|
||||
const apiKey = aiKeyStore.getKey(providerId);
|
||||
if (!apiKey) {
|
||||
return { ok: false, error: 'No API key configured' };
|
||||
}
|
||||
|
||||
const aiPrefs = getAiPrefs();
|
||||
const providerEnabled = aiPrefs?.providers?.[providerId]?.enabled;
|
||||
if (!providerEnabled) {
|
||||
return { ok: false, error: `${PROVIDERS[providerId].label} is disabled` };
|
||||
}
|
||||
|
||||
const probeModel = Object.entries(MODEL_DEFINITIONS)
|
||||
.find(([, def]) => def.provider === providerId);
|
||||
if (!probeModel) {
|
||||
return { ok: false, error: `No models registered for ${providerId}` };
|
||||
}
|
||||
|
||||
try {
|
||||
const model = resolveModel(probeModel[0]);
|
||||
await generateText({ model, prompt: 'ping', maxOutputTokens: 1 });
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
return { ok: false, error: err.message || 'Connection failed' };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('renderer:ai-generate-text', async (_event, params) => {
|
||||
const { model: modelId, system, prompt, maxTokens, temperature } = params || {};
|
||||
if (!modelId || !prompt) {
|
||||
return { error: 'model and prompt are required' };
|
||||
}
|
||||
|
||||
try {
|
||||
const model = resolveModel(modelId);
|
||||
const { text } = await generateText({
|
||||
model,
|
||||
system,
|
||||
prompt,
|
||||
maxOutputTokens: maxTokens ?? 1024,
|
||||
temperature: temperature ?? 0.3
|
||||
});
|
||||
return { text };
|
||||
} catch (err) {
|
||||
console.error('AI generate-text error:', err);
|
||||
return { error: err.message || 'Failed to generate text' };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.on('renderer:ai-stream-text', async (_event, params) => {
|
||||
const { streamId, model: modelId, system, messages, prompt, maxTokens, temperature } = params || {};
|
||||
if (!streamId) return;
|
||||
|
||||
const send = (channel, payload) => {
|
||||
if (mainWindow?.webContents && !mainWindow.webContents.isDestroyed()) {
|
||||
mainWindow.webContents.send(channel, payload);
|
||||
}
|
||||
};
|
||||
|
||||
if (activeStreams.has(streamId)) {
|
||||
send('main:ai-stream-error', { streamId, error: 'streamId is already active' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!modelId || (!messages && !prompt)) {
|
||||
send('main:ai-stream-error', { streamId, error: 'model and messages/prompt are required' });
|
||||
return;
|
||||
}
|
||||
|
||||
let model;
|
||||
try {
|
||||
model = resolveModel(modelId);
|
||||
} catch (err) {
|
||||
send('main:ai-stream-error', { streamId, error: err.message });
|
||||
return;
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
activeStreams.set(streamId, controller);
|
||||
|
||||
let fullText = '';
|
||||
try {
|
||||
const streamArgs = {
|
||||
model,
|
||||
system,
|
||||
maxOutputTokens: maxTokens ?? 2048,
|
||||
temperature: temperature ?? 0.7,
|
||||
abortSignal: controller.signal
|
||||
};
|
||||
if (messages) {
|
||||
streamArgs.messages = messages;
|
||||
} else {
|
||||
streamArgs.prompt = prompt;
|
||||
}
|
||||
const result = streamText(streamArgs);
|
||||
|
||||
for await (const part of result.fullStream) {
|
||||
if (controller.signal.aborted) break;
|
||||
if (part.type === 'text-delta') {
|
||||
fullText += part.text;
|
||||
send('main:ai-stream-chunk', { streamId, chunk: part.text, fullText });
|
||||
}
|
||||
}
|
||||
|
||||
if (controller.signal.aborted) {
|
||||
send('main:ai-stream-stopped', { streamId, fullText });
|
||||
} else {
|
||||
send('main:ai-stream-complete', { streamId, fullText });
|
||||
}
|
||||
} catch (err) {
|
||||
if (err?.name === 'AbortError') {
|
||||
send('main:ai-stream-stopped', { streamId, fullText });
|
||||
} else {
|
||||
console.error('AI stream-text error:', err);
|
||||
send('main:ai-stream-error', { streamId, error: err.message || 'Failed to stream' });
|
||||
}
|
||||
} finally {
|
||||
activeStreams.delete(streamId);
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.on('renderer:ai-stop-stream', (_event, { streamId } = {}) => {
|
||||
const controller = activeStreams.get(streamId);
|
||||
if (controller) {
|
||||
controller.abort();
|
||||
activeStreams.delete(streamId);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
module.exports = registerAiIpc;
|
||||
116
packages/bruno-electron/src/ipc/ai/providers.js
Normal file
116
packages/bruno-electron/src/ipc/ai/providers.js
Normal file
@@ -0,0 +1,116 @@
|
||||
const { createOpenAI } = require('@ai-sdk/openai');
|
||||
const { createAnthropic } = require('@ai-sdk/anthropic');
|
||||
|
||||
const PROVIDERS = {
|
||||
openai: {
|
||||
id: 'openai',
|
||||
label: 'OpenAI',
|
||||
apiKeyPlaceholder: 'sk-...',
|
||||
apiKeyHelpUrl: 'https://platform.openai.com/api-keys',
|
||||
createSdk: ({ apiKey }) => createOpenAI({ apiKey })
|
||||
},
|
||||
anthropic: {
|
||||
id: 'anthropic',
|
||||
label: 'Anthropic',
|
||||
apiKeyPlaceholder: 'sk-ant-...',
|
||||
apiKeyHelpUrl: 'https://console.anthropic.com/settings/keys',
|
||||
createSdk: ({ apiKey }) => createAnthropic({ apiKey })
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Model catalog. Each entry is keyed by a stable id used in preferences and IPC.
|
||||
* `modelId` is the value passed to the provider SDK; `id` is the Bruno-internal id.
|
||||
*/
|
||||
const MODEL_DEFINITIONS = {
|
||||
// OpenAI
|
||||
'gpt-4o-mini': { provider: 'openai', modelId: 'gpt-4o-mini', label: 'GPT-4o Mini' },
|
||||
'gpt-4o': { provider: 'openai', modelId: 'gpt-4o', label: 'GPT-4o' },
|
||||
'gpt-5': { provider: 'openai', modelId: 'gpt-5', label: 'GPT-5' },
|
||||
'gpt-5-mini': { provider: 'openai', modelId: 'gpt-5-mini', label: 'GPT-5 Mini' },
|
||||
// Anthropic
|
||||
'claude-opus-4-7': { provider: 'anthropic', modelId: 'claude-opus-4-7', label: 'Claude Opus 4.7' },
|
||||
'claude-sonnet-4-6': { provider: 'anthropic', modelId: 'claude-sonnet-4-6', label: 'Claude Sonnet 4.6' },
|
||||
'claude-haiku-4-5': { provider: 'anthropic', modelId: 'claude-haiku-4-5-20251001', label: 'Claude Haiku 4.5' }
|
||||
};
|
||||
|
||||
// Cache SDK instances. Keyed by `${providerId}:${apiKey}` so changing keys rebuilds the SDK.
|
||||
const sdkCache = new Map();
|
||||
|
||||
const getSdk = (providerId, apiKey) => {
|
||||
const cacheKey = `${providerId}:${apiKey}`;
|
||||
let sdk = sdkCache.get(cacheKey);
|
||||
if (!sdk) {
|
||||
const provider = PROVIDERS[providerId];
|
||||
if (!provider) throw new Error(`Unknown AI provider: ${providerId}`);
|
||||
sdk = provider.createSdk({ apiKey });
|
||||
sdkCache.set(cacheKey, sdk);
|
||||
}
|
||||
return sdk;
|
||||
};
|
||||
|
||||
const clearSdkCache = () => {
|
||||
sdkCache.clear();
|
||||
};
|
||||
|
||||
const listProviders = () => Object.values(PROVIDERS).map((p) => ({
|
||||
id: p.id,
|
||||
label: p.label,
|
||||
apiKeyPlaceholder: p.apiKeyPlaceholder,
|
||||
apiKeyHelpUrl: p.apiKeyHelpUrl
|
||||
}));
|
||||
|
||||
const listModels = () => Object.entries(MODEL_DEFINITIONS).map(([id, def]) => ({
|
||||
id,
|
||||
label: def.label,
|
||||
provider: def.provider
|
||||
}));
|
||||
|
||||
/**
|
||||
* Resolve a Bruno model id to a vercel-ai SDK model instance.
|
||||
* Throws if the provider isn't configured (no key) or the model is unknown.
|
||||
*/
|
||||
const getModel = (modelId, { aiPreferences, getApiKey }) => {
|
||||
const def = MODEL_DEFINITIONS[modelId];
|
||||
if (!def) throw new Error(`Unknown model: ${modelId}`);
|
||||
|
||||
const providerConfig = aiPreferences?.providers?.[def.provider];
|
||||
if (!providerConfig?.enabled) {
|
||||
throw new Error(`${PROVIDERS[def.provider].label} is not enabled. Enable it in Preferences > AI.`);
|
||||
}
|
||||
|
||||
const apiKey = getApiKey(def.provider);
|
||||
if (!apiKey) {
|
||||
throw new Error(`${PROVIDERS[def.provider].label} API key is not configured. Add it in Preferences > AI.`);
|
||||
}
|
||||
|
||||
return getSdk(def.provider, apiKey)(def.modelId);
|
||||
};
|
||||
|
||||
/**
|
||||
* List models that are usable right now (provider enabled + key configured + model not disabled).
|
||||
*/
|
||||
const getAvailableModels = ({ aiPreferences, hasApiKey }) => {
|
||||
const out = [];
|
||||
for (const [id, def] of Object.entries(MODEL_DEFINITIONS)) {
|
||||
const providerConfig = aiPreferences?.providers?.[def.provider];
|
||||
if (!providerConfig?.enabled) continue;
|
||||
if (!hasApiKey(def.provider)) continue;
|
||||
|
||||
const modelConfig = aiPreferences?.models?.[id];
|
||||
if (modelConfig?.enabled === false) continue;
|
||||
|
||||
out.push({ id, label: def.label, provider: def.provider });
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
PROVIDERS,
|
||||
MODEL_DEFINITIONS,
|
||||
listProviders,
|
||||
listModels,
|
||||
getModel,
|
||||
getAvailableModels,
|
||||
clearSdkCache
|
||||
};
|
||||
52
packages/bruno-electron/src/store/ai-keys.js
Normal file
52
packages/bruno-electron/src/store/ai-keys.js
Normal file
@@ -0,0 +1,52 @@
|
||||
const Store = require('electron-store');
|
||||
const { encryptString, decryptString } = require('../utils/encryption');
|
||||
|
||||
class AiKeyStore {
|
||||
constructor() {
|
||||
this.store = new Store({
|
||||
name: 'ai-keys',
|
||||
clearInvalidConfig: true
|
||||
});
|
||||
}
|
||||
|
||||
setKey(providerId, apiKey) {
|
||||
if (!apiKey) {
|
||||
this.clearKey(providerId);
|
||||
return;
|
||||
}
|
||||
const encrypted = encryptString(apiKey);
|
||||
this.store.set(`keys.${providerId}`, encrypted);
|
||||
}
|
||||
|
||||
getKey(providerId) {
|
||||
const encrypted = this.store.get(`keys.${providerId}`);
|
||||
if (!encrypted) return null;
|
||||
try {
|
||||
return decryptString(encrypted);
|
||||
} catch (err) {
|
||||
console.error(`Failed to decrypt AI key for ${providerId}:`, err.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
hasKey(providerId) {
|
||||
return Boolean(this.getKey(providerId));
|
||||
}
|
||||
|
||||
clearKey(providerId) {
|
||||
this.store.delete(`keys.${providerId}`);
|
||||
}
|
||||
|
||||
getKeyStatus() {
|
||||
const stored = this.store.get('keys', {}) || {};
|
||||
const status = {};
|
||||
for (const providerId of Object.keys(stored)) {
|
||||
status[providerId] = { configured: Boolean(this.getKey(providerId)) };
|
||||
}
|
||||
return status;
|
||||
}
|
||||
}
|
||||
|
||||
const aiKeyStore = new AiKeyStore();
|
||||
|
||||
module.exports = { aiKeyStore };
|
||||
@@ -68,6 +68,15 @@ const defaultPreferences = {
|
||||
sslSession: {
|
||||
enabled: false
|
||||
}
|
||||
},
|
||||
ai: {
|
||||
enabled: false,
|
||||
providers: {
|
||||
openai: { enabled: false },
|
||||
anthropic: { enabled: false }
|
||||
},
|
||||
models: {},
|
||||
defaultModel: ''
|
||||
}
|
||||
};
|
||||
|
||||
@@ -135,6 +144,12 @@ const preferencesSchema = Yup.object().shape({
|
||||
sslSession: Yup.object({
|
||||
enabled: Yup.boolean()
|
||||
})
|
||||
}).optional(),
|
||||
ai: Yup.object({
|
||||
enabled: Yup.boolean(),
|
||||
providers: Yup.object().optional(),
|
||||
models: Yup.object().optional(),
|
||||
defaultModel: Yup.string().max(200).nullable()
|
||||
}).optional()
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user