mirror of
https://github.com/usebruno/bruno.git
synced 2026-06-11 09:51:30 +00:00
feat: AI features into preferences and Redux store (#8048)
This commit is contained in:
354
package-lock.json
generated
354
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
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>
|
||||
|
||||
@@ -59,6 +59,15 @@ const initialState = {
|
||||
sslSession: {
|
||||
enabled: false
|
||||
}
|
||||
},
|
||||
ai: {
|
||||
enabled: false,
|
||||
providers: {
|
||||
openai: { enabled: false },
|
||||
anthropic: { enabled: false }
|
||||
},
|
||||
models: {},
|
||||
defaultModel: ''
|
||||
}
|
||||
},
|
||||
generateCode: {
|
||||
|
||||
79
packages/bruno-app/src/utils/ai/index.js
Normal file
79
packages/bruno-app/src/utils/ai/index.js
Normal file
@@ -0,0 +1,79 @@
|
||||
import { callIpc } from 'utils/common/ipc';
|
||||
|
||||
/**
|
||||
* Renderer-side wrapper around the AI IPC channels.
|
||||
*
|
||||
* The renderer never sees raw API keys, set/clear operations are write-only
|
||||
* and status reads only return whether a provider has a key on disk.
|
||||
*/
|
||||
|
||||
export const getAiStatus = () => callIpc('renderer:get-ai-status');
|
||||
|
||||
export const setAiApiKey = ({ providerId, apiKey }) =>
|
||||
callIpc('renderer:set-ai-api-key', { providerId, apiKey });
|
||||
|
||||
export const clearAiApiKey = ({ providerId }) =>
|
||||
callIpc('renderer:clear-ai-api-key', { providerId });
|
||||
|
||||
export const getAiApiKey = ({ providerId }) =>
|
||||
callIpc('renderer:get-ai-api-key', { providerId });
|
||||
|
||||
export const testAiProvider = ({ providerId }) =>
|
||||
callIpc('renderer:ai-test-provider', { providerId });
|
||||
|
||||
export const aiGenerateText = (params) =>
|
||||
callIpc('renderer:ai-generate-text', params);
|
||||
|
||||
/**
|
||||
* Start a streaming generation. Subscribes to the corresponding `main:ai-stream-*`
|
||||
* channels filtered by streamId. Returns a handle with `.stop()` and a promise
|
||||
* that resolves with the final text or rejects on error.
|
||||
*/
|
||||
export const aiStreamText = (params, handlers = {}) => {
|
||||
const { ipcRenderer } = window;
|
||||
if (!ipcRenderer) {
|
||||
return { stop: () => {}, done: Promise.reject(new Error('IPC not available')) };
|
||||
}
|
||||
|
||||
const streamId = params.streamId || `stream-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
const subs = [];
|
||||
|
||||
const done = new Promise((resolve, reject) => {
|
||||
const cleanup = () => subs.forEach((unsub) => unsub());
|
||||
|
||||
const onMatch = (channel, handler) => {
|
||||
const unsub = ipcRenderer.on(channel, (payload) => {
|
||||
if (payload?.streamId !== streamId) return;
|
||||
handler(payload);
|
||||
});
|
||||
subs.push(unsub);
|
||||
};
|
||||
|
||||
onMatch('main:ai-stream-chunk', (payload) => {
|
||||
handlers.onChunk?.(payload);
|
||||
});
|
||||
onMatch('main:ai-stream-complete', (payload) => {
|
||||
handlers.onComplete?.(payload);
|
||||
cleanup();
|
||||
resolve(payload);
|
||||
});
|
||||
onMatch('main:ai-stream-stopped', (payload) => {
|
||||
handlers.onStopped?.(payload);
|
||||
cleanup();
|
||||
resolve(payload);
|
||||
});
|
||||
onMatch('main:ai-stream-error', (payload) => {
|
||||
handlers.onError?.(payload);
|
||||
cleanup();
|
||||
reject(new Error(payload.error));
|
||||
});
|
||||
});
|
||||
|
||||
ipcRenderer.send('renderer:ai-stream-text', { ...params, streamId });
|
||||
|
||||
return {
|
||||
streamId,
|
||||
stop: () => ipcRenderer.send('renderer:ai-stop-stream', { streamId }),
|
||||
done
|
||||
};
|
||||
};
|
||||
@@ -30,6 +30,8 @@
|
||||
]
|
||||
},
|
||||
"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",
|
||||
@@ -44,6 +46,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",
|
||||
@@ -76,7 +79,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"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"dmg-license": "^1.0.11"
|
||||
|
||||
@@ -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,12 +476,13 @@ app.on('ready', async () => {
|
||||
registerSystemMonitorIpc(mainWindow, systemMonitor);
|
||||
registerGitIpc(mainWindow);
|
||||
registerOpenAPISyncIpc(mainWindow);
|
||||
registerAiIpc(mainWindow);
|
||||
});
|
||||
|
||||
// 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