feat: AI features into preferences and Redux store (#8178)

This commit is contained in:
naman-bruno
2026-06-04 13:27:55 +05:30
committed by GitHub
parent 8f80230708
commit dadd69b02d
14 changed files with 1439 additions and 6 deletions

View File

@@ -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;

View 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;

View 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
};

View 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 };

View File

@@ -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()
});