diff --git a/package-lock.json b/package-lock.json index c83299f43..bfe8ce05c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -67,6 +67,84 @@ "dev": true, "license": "MIT" }, + "node_modules/@ai-sdk/anthropic": { + "version": "3.0.15", + "resolved": "https://registry.npmjs.org/@ai-sdk/anthropic/-/anthropic-3.0.15.tgz", + "integrity": "sha512-FCNy6pABPe5Qb1VPbdLLIi/XkQN2g/fKUcl1GcXxIU3Ofr+vOND8cyZfH20cMODR523FSGfwswJoJic8skr8qg==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "3.0.4", + "@ai-sdk/provider-utils": "4.0.8" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/gateway": { + "version": "3.0.16", + "resolved": "https://registry.npmjs.org/@ai-sdk/gateway/-/gateway-3.0.16.tgz", + "integrity": "sha512-OOY5CfRJiHvh/8np2vs1RQaCZ5hWv2qOeEmmeiABXK3gLQHUVnCO+1hhoLsZdHM5iElu6M407dAOfyvTsKJqcQ==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "3.0.4", + "@ai-sdk/provider-utils": "4.0.8", + "@vercel/oidc": "3.1.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/openai": { + "version": "3.0.12", + "resolved": "https://registry.npmjs.org/@ai-sdk/openai/-/openai-3.0.12.tgz", + "integrity": "sha512-zqLWEKuaKnjXhu7xCw1jgz/+yTbd3F7EtgU4T2Q8BAo8OJC5wZv14l+kwM7Jai7M1/2Y2T/zBkrfiIu+7NsvfQ==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "3.0.4", + "@ai-sdk/provider-utils": "4.0.8" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/provider": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-3.0.4.tgz", + "integrity": "sha512-5KXyBOSEX+l67elrEa+wqo/LSsSTtrPj9Uoh3zMbe/ceQX4ucHI3b9nUEfNkGF3Ry1svv90widAt+aiKdIJasQ==", + "license": "Apache-2.0", + "dependencies": { + "json-schema": "^0.4.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@ai-sdk/provider-utils": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-4.0.8.tgz", + "integrity": "sha512-ns9gN7MmpI8vTRandzgz+KK/zNMLzhrriiKECMt4euLtQFSBgNfydtagPOX4j4pS1/3KvHF6RivhT3gNQgBZsg==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "3.0.4", + "@standard-schema/spec": "^1.1.0", + "eventsource-parser": "^3.0.6" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, "node_modules/@alloc/quick-lru": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", @@ -9616,6 +9694,15 @@ "dev": true, "license": "MIT" }, + "node_modules/@opentelemetry/api": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", + "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", + "license": "Apache-2.0", + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/@parcel/watcher": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.0.tgz", @@ -11290,6 +11377,12 @@ "node": ">=18.0.0" } }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, "node_modules/@storybook/addon-webpack5-compiler-babel": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/@storybook/addon-webpack5-compiler-babel/-/addon-webpack5-compiler-babel-4.0.0.tgz", @@ -13412,6 +13505,15 @@ "resolved": "packages/bruno-toml", "link": true }, + "node_modules/@vercel/oidc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@vercel/oidc/-/oidc-3.1.0.tgz", + "integrity": "sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w==", + "license": "Apache-2.0", + "engines": { + "node": ">= 20" + } + }, "node_modules/@vitest/expect": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", @@ -14085,6 +14187,24 @@ "node": ">= 14" } }, + "node_modules/ai": { + "version": "6.0.39", + "resolved": "https://registry.npmjs.org/ai/-/ai-6.0.39.tgz", + "integrity": "sha512-hF05gF4H+IxuilA8kNANVVHQXduTJsJaH74jmlmy8mcQt3NZgPYe2zZNyGBV4DPDYTUDt1h31hbLgQqJTn5LGA==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/gateway": "3.0.16", + "@ai-sdk/provider": "3.0.4", + "@ai-sdk/provider-utils": "4.0.8", + "@opentelemetry/api": "1.9.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, "node_modules/ajv": { "version": "8.17.1", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", @@ -19033,6 +19153,15 @@ "bare-events": "^2.7.0" } }, + "node_modules/eventsource-parser": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.8.tgz", + "integrity": "sha512-70QWGkr4snxr0OXLRWsFLeRBIRPuQOvt4s8QYjmUlmlkyTZkRqS7EDVRZtzU3TiyDbXSzaOeF0XUKy8PchzukQ==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/evp_bytestokey": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/evp_bytestokey/-/evp_bytestokey-1.0.3.tgz", @@ -23421,6 +23550,12 @@ "node": "*" } }, + "node_modules/json-schema": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", + "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==", + "license": "(AFL-2.1 OR BSD-3-Clause)" + }, "node_modules/json-schema-traverse": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", @@ -32765,6 +32900,15 @@ "node": ">= 14" } }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "packages/bruno-app": { "name": "@usebruno/app", "version": "2.0.0", @@ -35035,6 +35179,8 @@ "name": "bruno", "version": "2.0.0", "dependencies": { + "@ai-sdk/anthropic": "3.0.15", + "@ai-sdk/openai": "3.0.12", "@aws-sdk/credential-providers": "3.1019.0", "@grpc/grpc-js": "^1.13.2", "@grpc/proto-loader": "^0.7.13", @@ -35049,6 +35195,7 @@ "@usebruno/schema": "0.7.0", "about-window": "^1.15.2", "adm-zip": "^0.5.16", + "ai": "6.0.39", "archiver": "^7.0.1", "aws4-axios": "^3.3.15", "axios": "1.13.6", @@ -35081,7 +35228,8 @@ "socks-proxy-agent": "^8.0.2", "tough-cookie": "^6.0.0", "uuid": "^10.0.0", - "yup": "^0.32.11" + "yup": "^0.32.11", + "zod": "^4.1.8" }, "devDependencies": { "electron": "~37.6.1", diff --git a/packages/bruno-app/src/components/Preferences/AI/ProviderCard.js b/packages/bruno-app/src/components/Preferences/AI/ProviderCard.js new file mode 100644 index 000000000..5946c7b97 --- /dev/null +++ b/packages/bruno-app/src/components/Preferences/AI/ProviderCard.js @@ -0,0 +1,334 @@ +import { useEffect, useRef, useState } from 'react'; +import { + IconAlertCircle, + IconBolt, + IconCheck, + IconChevronDown, + IconEye, + IconEyeOff, + IconLoader2, + IconPencil, + IconTrash, + IconX +} from '@tabler/icons'; +import toast from 'react-hot-toast'; +import { clearAiApiKey, getAiApiKey, setAiApiKey, testAiProvider } from 'utils/ai'; + +const OpenAiLogo = (props) => ( + + + +); + +const AnthropicLogo = (props) => ( + + + +); + +const PROVIDER_LOGOS = { + openai: OpenAiLogo, + anthropic: AnthropicLogo +}; + +const stopBubble = (e) => e.stopPropagation(); + +const ProviderCard = ({ + provider, + providerEnabled, + providerToggle, + models, + isModelEnabled, + onToggleModel, + onStatusChange +}) => { + const Logo = PROVIDER_LOGOS[provider.id]; + + const [expanded, setExpanded] = useState(false); + const [keyDraft, setKeyDraft] = useState(''); + const [editing, setEditing] = useState(false); + const [showKey, setShowKey] = useState(false); + const [saving, setSaving] = useState(false); + const [testing, setTesting] = useState(false); + const [feedback, setFeedback] = useState(null); + + const prev = useRef({ enabled: providerEnabled }); + useEffect(() => { + const was = prev.current; + if (!was.enabled && providerEnabled) { + setExpanded(true); + } else if (was.enabled && !providerEnabled) { + setExpanded(false); + } + prev.current = { enabled: providerEnabled }; + }, [providerEnabled]); + + const isEditing = editing || !provider.configured; + + const handleSave = async () => { + const trimmed = keyDraft.trim(); + if (!trimmed) return; + setSaving(true); + setFeedback(null); + try { + const status = await setAiApiKey({ providerId: provider.id, apiKey: trimmed }); + onStatusChange?.(status); + setKeyDraft(''); + setShowKey(false); + setEditing(false); + setFeedback({ type: 'success', message: 'API key saved' }); + } catch (err) { + setFeedback({ type: 'error', message: err.message || 'Failed to save API key' }); + } finally { + setSaving(false); + } + }; + + const handleClear = async () => { + setFeedback(null); + try { + const status = await clearAiApiKey({ providerId: provider.id }); + onStatusChange?.(status); + setEditing(false); + setKeyDraft(''); + toast.success(`${provider.label} API key removed`); + } catch (err) { + toast.error(err.message || 'Failed to clear API key'); + } + }; + + const handleTest = async () => { + setTesting(true); + setFeedback(null); + try { + const result = await testAiProvider({ providerId: provider.id }); + if (result.ok) { + setFeedback({ type: 'success', message: 'Connection successful' }); + } else { + setFeedback({ type: 'error', message: result.error || 'Connection failed' }); + } + } catch (err) { + setFeedback({ type: 'error', message: err.message || 'Connection failed' }); + } finally { + setTesting(false); + } + }; + + const handleCancelEdit = () => { + setEditing(false); + setKeyDraft(''); + setShowKey(false); + setFeedback(null); + }; + + const handleStartEdit = async () => { + setEditing(true); + setFeedback(null); + try { + const current = await getAiApiKey({ providerId: provider.id }); + setKeyDraft(current || ''); + } catch (err) { + // If we can't fetch it (decrypt failure etc.), leave the field empty. + setKeyDraft(''); + } + }; + + const handleKeyDown = (e) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + if (keyDraft.trim() && !saving) handleSave(); + } else if (e.key === 'Escape' && provider.configured) { + e.preventDefault(); + handleCancelEdit(); + } + }; + + const enabledModelsCount = models.filter((m) => isModelEnabled(m.id)).length; + + return ( +
+
setExpanded(!expanded)} + role="button" + tabIndex={0} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + setExpanded(!expanded); + } + }} + > +
+ {Logo ? : null} + {provider.label} +
+
+ + + {provider.configured + ? `${enabledModelsCount}/${models.length} models` + : 'Not configured'} + + + {providerToggle} + + + + +
+
+ +
+
+
+ {/* API key */} +
+
+ API Key +
+ + {!isEditing ? ( +
+ •••••••••••••••• +
+ + + +
+
+ ) : ( +
+
+ setKeyDraft(e.target.value)} + onKeyDown={handleKeyDown} + onClick={stopBubble} + autoFocus + data-testid={`ai-provider-${provider.id}-key-input`} + /> + +
+ + {provider.configured && ( + + )} +
+ )} + + {feedback && ( +
+ {feedback.type === 'success' ? : } + {feedback.message} +
+ )} +
+ + {/* Models */} + {models.length > 0 && ( +
+
+ Models + {!provider.configured && ( + + + Add an API key to enable + + )} +
+
+ {models.map((model) => { + const enabled = isModelEnabled(model.id); + const disabled = !provider.configured || !providerEnabled; + return ( + + ); + })} +
+
+ )} +
+
+
+
+ ); +}; + +export default ProviderCard; diff --git a/packages/bruno-app/src/components/Preferences/AI/StyledWrapper.js b/packages/bruno-app/src/components/Preferences/AI/StyledWrapper.js new file mode 100644 index 000000000..b131588dd --- /dev/null +++ b/packages/bruno-app/src/components/Preferences/AI/StyledWrapper.js @@ -0,0 +1,243 @@ +import styled from 'styled-components'; + +const StyledWrapper = styled.div` + color: ${(props) => props.theme.text}; + + .ai-master { + border: 1px solid ${(props) => props.theme.input.border}; + border-radius: ${(props) => props.theme.border.radius.md}; + background: ${(props) => props.theme.input.bg}; + } + + .ai-master-icon { + color: ${(props) => props.theme.colors.accent}; + } + + .ai-master-summary { + color: ${(props) => props.theme.colors.text.muted}; + } + + .ai-section-header { + color: ${(props) => props.theme.colors.text.muted}; + } + + .ai-empty-notice { + color: ${(props) => props.theme.colors.text.muted}; + background: ${(props) => props.theme.input.bg}; + border: 1px dashed ${(props) => props.theme.input.border}; + border-radius: ${(props) => props.theme.border.radius.md}; + } + + .provider-row { + border: 1px solid ${(props) => props.theme.input.border}; + border-radius: ${(props) => props.theme.border.radius.md}; + background: ${(props) => props.theme.input.bg}; + overflow: hidden; + transition: border-color 0.15s ease; + + &.expanded { + border-color: ${(props) => props.theme.colors.accent}80; + } + } + + .provider-header { + transition: background-color 0.15s ease; + + &:hover { + background: ${(props) => props.theme.colors.accent}08; + } + } + + .provider-logo { + color: ${(props) => props.theme.text}; + } + + .provider-status { + color: ${(props) => props.theme.colors.text.muted}; + + &.configured { + color: ${(props) => props.theme.colors.text.green}; + } + } + + .status-dot { + background: ${(props) => props.theme.input.border}; + + &.configured { + background: ${(props) => props.theme.colors.text.green}; + box-shadow: 0 0 0 2px ${(props) => props.theme.colors.text.green}25; + } + } + + .chevron { + color: ${(props) => props.theme.colors.text.muted}; + transition: transform 0.2s ease; + + &.expanded { + transform: rotate(180deg); + } + } + + /* Smooth expand/collapse using grid-template-rows trick */ + .provider-body-wrapper { + display: grid; + grid-template-rows: 0fr; + transition: grid-template-rows 0.2s ease; + + &.open { + grid-template-rows: 1fr; + } + } + + .provider-body-inner { + overflow: hidden; + min-height: 0; + } + + .provider-body { + border-top: 1px solid ${(props) => props.theme.input.border}; + } + + .key-section-label { + color: ${(props) => props.theme.colors.text.muted}; + } + + .key-input { + font-family: ${(props) => props.theme.font.monospace || 'monospace'}; + border-radius: ${(props) => props.theme.border.radius.sm}; + background-color: ${(props) => props.theme.input.bg}; + border: 1px solid ${(props) => props.theme.input.border}; + color: ${(props) => props.theme.text}; + + &::placeholder { + color: ${(props) => props.theme.colors.text.muted}; + opacity: 0.7; + } + + &:focus { + outline: none; + border-color: ${(props) => props.theme.input.focusBorder}; + } + } + + .key-eye-btn { + border-radius: ${(props) => props.theme.border.radius.sm}; + color: ${(props) => props.theme.colors.text.muted}; + transition: background-color 0.15s ease, color 0.15s ease; + + &:hover { + color: ${(props) => props.theme.text}; + background: ${(props) => props.theme.colors.accent}10; + } + } + + .key-display-row { + border: 1px solid ${(props) => props.theme.input.border}; + border-radius: ${(props) => props.theme.border.radius.sm}; + background: ${(props) => props.theme.input.bg}; + } + + .key-display-mask { + font-family: ${(props) => props.theme.font.monospace || 'monospace'}; + color: ${(props) => props.theme.colors.text.muted}; + letter-spacing: 1px; + } + + .btn-primary { + border-radius: ${(props) => props.theme.border.radius.sm}; + border: 1px solid ${(props) => props.theme.colors.accent}; + background: ${(props) => props.theme.colors.accent}; + color: white; + transition: opacity 0.15s ease; + + &:hover:not(:disabled) { + opacity: 0.88; + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } + } + + .btn-icon { + border-radius: ${(props) => props.theme.border.radius.sm}; + border: none; + background: transparent; + color: ${(props) => props.theme.colors.text.muted}; + transition: background-color 0.15s ease, color 0.15s ease; + + &:hover:not(:disabled) { + background: ${(props) => props.theme.colors.accent}10; + color: ${(props) => props.theme.text}; + } + + &.danger:hover:not(:disabled) { + color: ${(props) => props.theme.colors.text.danger}; + background: ${(props) => props.theme.colors.bg.danger}15; + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } + } + + .feedback { + border-radius: ${(props) => props.theme.border.radius.sm}; + + &.success { + color: ${(props) => props.theme.colors.text.green}; + background: ${(props) => props.theme.colors.text.green}10; + } + + &.error { + color: ${(props) => props.theme.colors.text.danger}; + background: ${(props) => props.theme.colors.bg.danger}15; + } + } + + .models-label-row { + color: ${(props) => props.theme.colors.text.muted}; + } + + .model-chip { + border-radius: ${(props) => props.theme.border.radius.sm}; + border: 1px solid transparent; + transition: background-color 0.15s ease, border-color 0.15s ease; + + &:hover:not(.disabled) { + background: ${(props) => props.theme.colors.accent}08; + } + + &.selected { + border-color: ${(props) => props.theme.input.border}; + background: ${(props) => props.theme.colors.accent}06; + } + + &.disabled { + opacity: 0.45; + cursor: not-allowed; + + input, + label { + cursor: not-allowed; + } + } + } + + .keyless-hint { + color: ${(props) => props.theme.colors.text.muted}; + } + + @keyframes spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } + } + + .spin { + animation: spin 1s linear infinite; + } +`; + +export default StyledWrapper; diff --git a/packages/bruno-app/src/components/Preferences/AI/index.js b/packages/bruno-app/src/components/Preferences/AI/index.js new file mode 100644 index 000000000..11a025241 --- /dev/null +++ b/packages/bruno-app/src/components/Preferences/AI/index.js @@ -0,0 +1,202 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import get from 'lodash/get'; +import debounce from 'lodash/debounce'; +import { useFormik } from 'formik'; +import { useDispatch, useSelector } from 'react-redux'; +import * as Yup from 'yup'; +import toast from 'react-hot-toast'; +import { IconStars } from '@tabler/icons'; +import { savePreferences } from 'providers/ReduxStore/slices/app'; +import ToggleSwitch from 'components/ToggleSwitch'; +import { getAiStatus } from 'utils/ai'; +import ProviderCard from './ProviderCard'; +import StyledWrapper from './StyledWrapper'; + +const aiPreferencesSchema = Yup.object().shape({ + enabled: Yup.boolean(), + providers: Yup.object(), + models: Yup.object(), + defaultModel: Yup.string().max(200).nullable() +}); + +const AI = () => { + const dispatch = useDispatch(); + const preferences = useSelector((state) => state.app.preferences); + const [status, setStatus] = useState(null); + const [statusError, setStatusError] = useState(null); + + const refreshStatus = useCallback(async () => { + try { + const next = await getAiStatus(); + setStatus(next); + setStatusError(null); + } catch (err) { + setStatusError(err.message || 'Failed to load AI status'); + } + }, []); + + useEffect(() => { + refreshStatus(); + }, [refreshStatus]); + + const providerIds = status ? Object.keys(status.providers) : []; + + const formik = useFormik({ + enableReinitialize: true, + initialValues: { + enabled: get(preferences, 'ai.enabled', false), + providers: providerIds.reduce((acc, id) => { + acc[id] = { enabled: get(preferences, `ai.providers.${id}.enabled`, false) }; + return acc; + }, {}), + models: get(preferences, 'ai.models', {}), + defaultModel: get(preferences, 'ai.defaultModel', '') + }, + validationSchema: aiPreferencesSchema, + onSubmit: () => {} + }); + + const handleSave = useCallback( + (values) => { + dispatch( + savePreferences({ + ...preferences, + ai: { + enabled: values.enabled, + providers: values.providers, + models: values.models, + defaultModel: values.defaultModel || '' + } + }) + ).catch((err) => { + console.error('Failed to save AI preferences:', err); + toast.error('Failed to save AI preferences'); + }); + }, + [dispatch, preferences] + ); + + const handleSaveRef = useRef(handleSave); + handleSaveRef.current = handleSave; + + const debouncedSave = useCallback( + debounce((values) => { + aiPreferencesSchema + .validate(values, { abortEarly: true }) + .then((validated) => handleSaveRef.current(validated)) + .catch(() => {}); + }, 400), + [] + ); + + useEffect(() => { + if (formik.dirty && formik.isValid) { + debouncedSave(formik.values); + } + }, [formik.values, formik.dirty, formik.isValid, debouncedSave]); + + useEffect(() => () => debouncedSave.flush(), [debouncedSave]); + + const modelsByProvider = useMemo(() => { + const grouped = {}; + (status?.models || []).forEach((model) => { + if (!grouped[model.provider]) grouped[model.provider] = []; + grouped[model.provider].push(model); + }); + return grouped; + }, [status]); + + const isModelEnabled = (modelId) => get(formik.values, `models.${modelId}.enabled`, true); + + const handleToggleModel = (modelId, next) => { + formik.setFieldValue(`models.${modelId}.enabled`, next); + }; + + const summary = useMemo(() => { + if (!status || !formik.values.enabled) return 'Turn on to configure providers and models'; + const usableProviders = Object.values(status.providers).filter( + (p) => p.configured && formik.values.providers?.[p.id]?.enabled + ); + if (usableProviders.length === 0) return 'Add a provider to get started'; + // Count models live from formik + current key status, not the electron-side + // snapshot which lags behind toggle changes during the save debounce window. + const totalEnabledModels = (status.models || []).filter((m) => { + if (!formik.values.providers?.[m.provider]?.enabled) return false; + if (!status.providers?.[m.provider]?.configured) return false; + return isModelEnabled(m.id); + }).length; + const plural = (n, s) => `${n} ${s}${n === 1 ? '' : 's'}`; + return `${plural(usableProviders.length, 'provider')} · ${plural(totalEnabledModels, 'model')} ready`; + }, [status, formik.values.enabled, formik.values.providers, formik.values.models]); + + return ( + +
AI
+ +
+
+
+ + AI Features +
+ {summary} +
+ formik.setFieldValue('enabled', !formik.values.enabled)} + /> +
+ + {statusError && ( +
+ {statusError} +
+ )} + + {!formik.values.enabled && !statusError && ( +
+ Bring your own API key. Bruno talks to providers directly, your keys never leave your machine. +
+ )} + + {formik.values.enabled && status && ( + <> +
+ Providers +
+
+ {providerIds.map((id) => { + const provider = status.providers[id]; + const providerEnabled = get(formik.values, `providers.${id}.enabled`, false); + + const providerToggle = ( + + formik.setFieldValue(`providers.${id}.enabled`, !providerEnabled)} + /> + ); + + return ( + setStatus(next)} + /> + ); + })} +
+ + )} +
+ ); +}; + +export default AI; diff --git a/packages/bruno-app/src/components/Preferences/index.js b/packages/bruno-app/src/components/Preferences/index.js index 88a17be43..2dc5bbe8f 100644 --- a/packages/bruno-app/src/components/Preferences/index.js +++ b/packages/bruno-app/src/components/Preferences/index.js @@ -10,7 +10,8 @@ import { IconKeyboard, IconZoomQuestion, IconSquareLetterB, - IconDatabase + IconDatabase, + IconStars } from '@tabler/icons'; import Support from './Support'; @@ -20,6 +21,7 @@ import Proxy from './ProxySettings'; import Display from './Display'; import Keybindings from './Keybindings'; import Beta from './Beta'; +import AI from './AI'; import StyledWrapper from './StyledWrapper'; import Cache from './Cache/index'; @@ -64,6 +66,10 @@ const Preferences = () => { return ; } + case 'ai': { + return ; + } + case 'support': { return ; } @@ -98,6 +104,10 @@ const Preferences = () => { Keybindings +
setTab('ai')}> + + AI +
setTab('cache')}> Cache diff --git a/packages/bruno-app/src/components/ToggleSwitch/index.js b/packages/bruno-app/src/components/ToggleSwitch/index.js index 299d77582..655e10b8b 100644 --- a/packages/bruno-app/src/components/ToggleSwitch/index.js +++ b/packages/bruno-app/src/components/ToggleSwitch/index.js @@ -1,10 +1,12 @@ +import { useId } from 'react'; import { Checkbox, Inner, Label, Switch, SwitchButton } from './StyledWrapper'; const ToggleSwitch = ({ isOn, handleToggle, size = 'm', activeColor, ...props }) => { + const id = useId(); return ( - {}} /> -