mirror of
https://github.com/usebruno/bruno.git
synced 2026-07-08 14:35:03 +00:00
feat: AI features into preferences and Redux store (#8178)
This commit is contained in:
334
packages/bruno-app/src/components/Preferences/AI/ProviderCard.js
Normal file
334
packages/bruno-app/src/components/Preferences/AI/ProviderCard.js
Normal file
@@ -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) => (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" {...props}>
|
||||
<path d="M22.2819 9.8211a5.9847 5.9847 0 0 0-.5157-4.9108 6.0462 6.0462 0 0 0-6.5098-2.9A6.0651 6.0651 0 0 0 4.9807 4.1818a5.9847 5.9847 0 0 0-3.9977 2.9 6.0462 6.0462 0 0 0 .7427 7.0966 5.98 5.98 0 0 0 .511 4.9107 6.051 6.051 0 0 0 6.5146 2.9001A5.9847 5.9847 0 0 0 13.2599 24a6.0557 6.0557 0 0 0 5.7718-4.2058 5.9894 5.9894 0 0 0 3.9977-2.9001 6.0557 6.0557 0 0 0-.7475-7.0729zm-9.022 12.6081a4.4755 4.4755 0 0 1-2.8764-1.0408l.1419-.0804 4.7783-2.7582a.7948.7948 0 0 0 .3927-.6813v-6.7369l2.02 1.1686a.071.071 0 0 1 .038.052v5.5826a4.504 4.504 0 0 1-4.4945 4.4944zm-9.6607-4.1254a4.4708 4.4708 0 0 1-.5346-3.0137l.142.0852 4.783 2.7582a.7712.7712 0 0 0 .7806 0l5.8428-3.3685v2.3324a.0804.0804 0 0 1-.0332.0615L9.74 19.9502a4.4992 4.4992 0 0 1-6.1408-1.6464zM2.3408 7.8956a4.485 4.485 0 0 1 2.3655-1.9728V11.6a.7664.7664 0 0 0 .3879.6765l5.8144 3.3543-2.0201 1.1685a.0757.0757 0 0 1-.071 0l-4.8303-2.7865A4.504 4.504 0 0 1 2.3408 7.8956zm16.5963 3.8558L13.1038 8.364 15.1192 7.2a.0757.0757 0 0 1 .071 0l4.8303 2.7913a4.4944 4.4944 0 0 1-.6765 8.1042v-5.6772a.79.79 0 0 0-.407-.667zm2.0107-3.0231l-.142-.0852-4.7735-2.7818a.7759.7759 0 0 0-.7854 0L9.409 9.2297V6.8974a.0662.0662 0 0 1 .0284-.0615l4.8303-2.7866a4.4992 4.4992 0 0 1 6.6802 4.66zM8.3065 12.863l-2.02-1.1638a.0804.0804 0 0 1-.038-.0567V6.0742a4.4992 4.4992 0 0 1 7.3757-3.4537l-.142.0805L8.704 5.459a.7948.7948 0 0 0-.3927.6813zm1.0976-2.3654l2.602-1.4998 2.6069 1.4998v2.9994l-2.5974 1.4997-2.6067-1.4997Z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const AnthropicLogo = (props) => (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" {...props}>
|
||||
<path d="M17.304 3.541h-3.672l6.696 16.918h3.672l-6.696-16.918Zm-10.608 0L0 20.459h3.744l1.368-3.584h6.624l1.368 3.584h3.744L10.152 3.54H6.696Zm.432 10.418 2.208-5.784 2.208 5.784H7.128Z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
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 (
|
||||
<div className={`provider-row ${expanded ? 'expanded' : ''}`} data-testid={`ai-provider-${provider.id}`}>
|
||||
<div
|
||||
className="provider-header flex items-center justify-between gap-3 px-3 py-2.5 cursor-pointer select-none"
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
setExpanded(!expanded);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2.5 min-w-0 flex-1">
|
||||
{Logo ? <Logo className="provider-logo w-[18px] h-[18px] flex-shrink-0" /> : null}
|
||||
<span className="font-semibold text-[12.5px]">{provider.label}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2.5 flex-shrink-0">
|
||||
<span className={`provider-status inline-flex items-center gap-1.5 text-[11px] ${provider.configured ? 'configured' : ''}`}>
|
||||
<span className={`status-dot w-[7px] h-[7px] rounded-full ${provider.configured ? 'configured' : ''}`} />
|
||||
{provider.configured
|
||||
? `${enabledModelsCount}/${models.length} models`
|
||||
: 'Not configured'}
|
||||
</span>
|
||||
<span className="flex items-center" onClick={stopBubble}>
|
||||
{providerToggle}
|
||||
</span>
|
||||
<span className={`chevron flex items-center ${expanded ? 'expanded' : ''}`}>
|
||||
<IconChevronDown size={16} strokeWidth={1.5} />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`provider-body-wrapper ${expanded ? 'open' : ''}`}>
|
||||
<div className="provider-body-inner">
|
||||
<div className="provider-body flex flex-col gap-3.5 px-3 pt-3 pb-3">
|
||||
{/* API key */}
|
||||
<div>
|
||||
<div className="key-section-label flex items-center justify-between gap-2 text-[11px] mb-1">
|
||||
<span>API Key</span>
|
||||
</div>
|
||||
|
||||
{!isEditing ? (
|
||||
<div
|
||||
className="key-display-row flex items-center justify-between gap-2 h-8 box-border pl-2.5 pr-0.5"
|
||||
onClick={stopBubble}
|
||||
>
|
||||
<span className="key-display-mask text-xs">••••••••••••••••</span>
|
||||
<div className="flex items-center gap-0.5">
|
||||
<button
|
||||
type="button"
|
||||
className="btn-icon w-7 h-7 box-border inline-flex items-center justify-center cursor-pointer"
|
||||
onClick={handleTest}
|
||||
disabled={testing || !providerEnabled}
|
||||
title="Test connection"
|
||||
aria-label="Test connection"
|
||||
>
|
||||
{testing ? <IconLoader2 size={15} className="spin" /> : <IconBolt size={15} />}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-icon w-7 h-7 box-border inline-flex items-center justify-center cursor-pointer"
|
||||
onClick={handleStartEdit}
|
||||
title="Replace key"
|
||||
aria-label="Replace key"
|
||||
>
|
||||
<IconPencil size={15} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-icon danger w-7 h-7 box-border inline-flex items-center justify-center cursor-pointer"
|
||||
onClick={handleClear}
|
||||
title="Remove key"
|
||||
aria-label="Remove key"
|
||||
>
|
||||
<IconTrash size={15} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-1.5" onClick={stopBubble}>
|
||||
<div className="relative flex-1 flex items-center">
|
||||
<input
|
||||
id={`api-key-${provider.id}`}
|
||||
type={showKey ? 'text' : 'password'}
|
||||
className="key-input w-full h-8 box-border text-xs leading-none pl-2.5 pr-8"
|
||||
placeholder={provider.apiKeyPlaceholder}
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
spellCheck="false"
|
||||
value={keyDraft}
|
||||
onChange={(e) => setKeyDraft(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onClick={stopBubble}
|
||||
autoFocus
|
||||
data-testid={`ai-provider-${provider.id}-key-input`}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="key-eye-btn absolute right-1 p-1 inline-flex items-center cursor-pointer"
|
||||
onClick={() => setShowKey(!showKey)}
|
||||
tabIndex={-1}
|
||||
aria-label={showKey ? 'Hide API key' : 'Show API key'}
|
||||
>
|
||||
{showKey ? <IconEyeOff size={14} /> : <IconEye size={14} />}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-primary h-8 box-border px-3 text-xs font-medium inline-flex items-center justify-center gap-1 cursor-pointer"
|
||||
disabled={saving || !keyDraft.trim()}
|
||||
onClick={handleSave}
|
||||
data-testid={`ai-provider-${provider.id}-save`}
|
||||
>
|
||||
{saving ? <IconLoader2 size={13} className="spin" /> : <IconCheck size={13} />}
|
||||
Save
|
||||
</button>
|
||||
{provider.configured && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn-icon w-7 h-7 box-border inline-flex items-center justify-center cursor-pointer"
|
||||
onClick={handleCancelEdit}
|
||||
title="Cancel"
|
||||
>
|
||||
<IconX size={15} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{feedback && (
|
||||
<div
|
||||
className={`feedback flex items-center gap-1.5 text-[11px] px-2 py-1 mt-1.5 ${feedback.type}`}
|
||||
role="status"
|
||||
>
|
||||
{feedback.type === 'success' ? <IconCheck size={12} /> : <IconAlertCircle size={12} />}
|
||||
{feedback.message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Models */}
|
||||
{models.length > 0 && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="models-label-row flex items-center justify-between text-[11px]">
|
||||
<span>Models</span>
|
||||
{!provider.configured && (
|
||||
<span className="keyless-hint flex items-center gap-1.5 text-[11px] py-1">
|
||||
<IconAlertCircle size={12} />
|
||||
Add an API key to enable
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-1">
|
||||
{models.map((model) => {
|
||||
const enabled = isModelEnabled(model.id);
|
||||
const disabled = !provider.configured || !providerEnabled;
|
||||
return (
|
||||
<label
|
||||
key={model.id}
|
||||
className={`model-chip flex items-center gap-2 px-2.5 py-1.5 cursor-pointer select-none ${enabled && !disabled ? 'selected' : ''} ${disabled ? 'disabled' : ''}`}
|
||||
onClick={stopBubble}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="cursor-pointer m-0"
|
||||
checked={enabled}
|
||||
disabled={disabled}
|
||||
onChange={() => onToggleModel(model.id, !enabled)}
|
||||
/>
|
||||
<span className="text-xs">{model.label}</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProviderCard;
|
||||
@@ -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;
|
||||
202
packages/bruno-app/src/components/Preferences/AI/index.js
Normal file
202
packages/bruno-app/src/components/Preferences/AI/index.js
Normal file
@@ -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 (
|
||||
<StyledWrapper className="w-full flex flex-col text-xs min-h-0 max-h-[calc(100%-30px)]">
|
||||
<div className="section-header">AI</div>
|
||||
|
||||
<div className="ai-master flex items-center justify-between gap-4 px-3.5 py-3 mb-4">
|
||||
<div className="flex flex-col gap-0.5 min-w-0">
|
||||
<div className="flex items-center gap-2 text-[13px] font-semibold">
|
||||
<IconStars size={15} strokeWidth={1.75} className="ai-master-icon" />
|
||||
<span>AI Features</span>
|
||||
</div>
|
||||
<span className="ai-master-summary text-[11px]">{summary}</span>
|
||||
</div>
|
||||
<ToggleSwitch
|
||||
size="m"
|
||||
isOn={formik.values.enabled}
|
||||
handleToggle={() => formik.setFieldValue('enabled', !formik.values.enabled)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{statusError && (
|
||||
<div className="ai-empty-notice px-3.5 py-3 text-xs" role="alert">
|
||||
{statusError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!formik.values.enabled && !statusError && (
|
||||
<div className="ai-empty-notice px-3.5 py-3 text-xs">
|
||||
Bring your own API key. Bruno talks to providers directly, your keys never leave your machine.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{formik.values.enabled && status && (
|
||||
<>
|
||||
<div className="ai-section-header text-[11px] font-medium uppercase tracking-wider mt-[18px] mb-2">
|
||||
Providers
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{providerIds.map((id) => {
|
||||
const provider = status.providers[id];
|
||||
const providerEnabled = get(formik.values, `providers.${id}.enabled`, false);
|
||||
|
||||
const providerToggle = (
|
||||
<ToggleSwitch
|
||||
size="s"
|
||||
isOn={providerEnabled}
|
||||
handleToggle={() =>
|
||||
formik.setFieldValue(`providers.${id}.enabled`, !providerEnabled)}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<ProviderCard
|
||||
key={id}
|
||||
provider={provider}
|
||||
providerEnabled={providerEnabled}
|
||||
providerToggle={providerToggle}
|
||||
models={modelsByProvider[id] || []}
|
||||
isModelEnabled={isModelEnabled}
|
||||
onToggleModel={handleToggleModel}
|
||||
onStatusChange={(next) => setStatus(next)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default AI;
|
||||
@@ -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 <Beta />;
|
||||
}
|
||||
|
||||
case 'ai': {
|
||||
return <AI />;
|
||||
}
|
||||
|
||||
case 'support': {
|
||||
return <Support />;
|
||||
}
|
||||
@@ -98,6 +104,10 @@ const Preferences = () => {
|
||||
<IconKeyboard size={16} strokeWidth={1.5} />
|
||||
Keybindings
|
||||
</div>
|
||||
<div className={getTabClassname('ai')} role="tab" onClick={() => setTab('ai')}>
|
||||
<IconStars size={16} strokeWidth={1.5} />
|
||||
AI
|
||||
</div>
|
||||
<div className={getTabClassname('cache')} role="tab" onClick={() => setTab('cache')}>
|
||||
<IconDatabase size={16} strokeWidth={1.5} />
|
||||
Cache
|
||||
|
||||
@@ -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 (
|
||||
<Switch size={size} {...props} onClick={handleToggle}>
|
||||
<Checkbox checked={isOn} id="toggle-switch" type="checkbox" size={size} activeColor={activeColor} onChange={() => {}} />
|
||||
<Label htmlFor="toggle-switch">
|
||||
<Checkbox checked={isOn} id={id} type="checkbox" size={size} activeColor={activeColor} onChange={() => {}} />
|
||||
<Label htmlFor={id}>
|
||||
<Inner size={size} />
|
||||
<SwitchButton size={size} />
|
||||
</Label>
|
||||
|
||||
Reference in New Issue
Block a user