mirror of
https://github.com/usebruno/bruno.git
synced 2026-06-25 05:35:41 +00:00
feat(ai): implement AI autocomplete (#8300)
This commit is contained in:
@@ -45,6 +45,15 @@ const StyledWrapper = styled.div`
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.cm-ghost-text-ai {
|
||||
opacity: 0.45;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
font-style: italic;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
/* Removes the glow outline around the folded json */
|
||||
.CodeMirror-foldmarker {
|
||||
text-shadow: none;
|
||||
|
||||
@@ -6,9 +6,12 @@
|
||||
*/
|
||||
|
||||
import React, { createRef } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { debounce, isEqual } from 'lodash';
|
||||
import { defineCodeMirrorBrunoVariablesMode } from 'utils/common/codemirror';
|
||||
import { setupAutoComplete, showRootHints } from 'utils/codemirror/autocomplete';
|
||||
import { setupAiAutocomplete } from 'utils/codemirror/aiGhostText';
|
||||
import { buildAutocompleteContext } from 'utils/ai';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import * as jsonlint from '@prantlf/jsonlint';
|
||||
import { JSHINT } from 'jshint';
|
||||
@@ -260,6 +263,24 @@ class CodeEditor extends React.Component {
|
||||
autoCompleteOptions
|
||||
);
|
||||
|
||||
// AI ghost-text autocomplete (script editors only). Stays inert until
|
||||
// the user has both enabled AI and configured a provider.
|
||||
if (this.props.scriptType) {
|
||||
this.aiAutocompleteCleanup = setupAiAutocomplete(editor, {
|
||||
scriptType: this.props.scriptType,
|
||||
isEnabled: () => {
|
||||
const ai = this.props.aiPreferences;
|
||||
return Boolean(ai?.enabled) && ai?.autocomplete?.enabled !== false;
|
||||
},
|
||||
getTriggerMode: () => this.props.aiPreferences?.autocomplete?.triggerMode || 'debounced',
|
||||
getContext: () => buildAutocompleteContext({
|
||||
item: this.props.item,
|
||||
collection: this.props.collection,
|
||||
scriptType: this.props.scriptType
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
setupLinkAware(editor);
|
||||
|
||||
// Setup lint error tooltip on line number hover
|
||||
@@ -392,6 +413,7 @@ class CodeEditor extends React.Component {
|
||||
});
|
||||
}
|
||||
|
||||
this.aiAutocompleteCleanup?.();
|
||||
this.editor?._destroyLinkAware?.();
|
||||
this.editor.off('change', this._onEdit);
|
||||
|
||||
@@ -470,7 +492,15 @@ class CodeEditor extends React.Component {
|
||||
|
||||
const CodeEditorWithPersistenceScope = React.forwardRef((props, ref) => {
|
||||
const persistenceScope = usePersistenceScope();
|
||||
return <CodeEditor {...props} persistenceScope={persistenceScope} ref={ref} />;
|
||||
const aiPreferences = useSelector((state) => state.app.preferences?.ai);
|
||||
return (
|
||||
<CodeEditor
|
||||
{...props}
|
||||
persistenceScope={persistenceScope}
|
||||
aiPreferences={aiPreferences}
|
||||
ref={ref}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
CodeEditorWithPersistenceScope.displayName = 'CodeEditor';
|
||||
|
||||
@@ -137,6 +137,7 @@ const Script = ({ collection }) => {
|
||||
font={get(preferences, 'font.codeFont', 'default')}
|
||||
fontSize={get(preferences, 'font.codeFontSize')}
|
||||
showHintsFor={['req', 'bru']}
|
||||
scriptType="pre-request"
|
||||
initialScroll={preReqScroll}
|
||||
onScroll={setPreReqScroll}
|
||||
/>
|
||||
@@ -162,6 +163,7 @@ const Script = ({ collection }) => {
|
||||
font={get(preferences, 'font.codeFont', 'default')}
|
||||
fontSize={get(preferences, 'font.codeFontSize')}
|
||||
showHintsFor={['req', 'res', 'bru']}
|
||||
scriptType="post-response"
|
||||
initialScroll={postResScroll}
|
||||
onScroll={setPostResScroll}
|
||||
/>
|
||||
|
||||
@@ -142,6 +142,7 @@ const Script = ({ collection, folder }) => {
|
||||
font={get(preferences, 'font.codeFont', 'default')}
|
||||
fontSize={get(preferences, 'font.codeFontSize')}
|
||||
showHintsFor={['req', 'bru']}
|
||||
scriptType="pre-request"
|
||||
initialScroll={preReqScroll}
|
||||
onScroll={setPreReqScroll}
|
||||
/>
|
||||
@@ -167,6 +168,7 @@ const Script = ({ collection, folder }) => {
|
||||
font={get(preferences, 'font.codeFont', 'default')}
|
||||
fontSize={get(preferences, 'font.codeFontSize')}
|
||||
showHintsFor={['req', 'res', 'bru']}
|
||||
scriptType="post-response"
|
||||
initialScroll={postResScroll}
|
||||
onScroll={setPostResScroll}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
import { IconChevronDown } from '@tabler/icons';
|
||||
import ToggleSwitch from 'components/ToggleSwitch';
|
||||
|
||||
/**
|
||||
* Autocomplete tab content. Sibling of the Configuration tab inside
|
||||
* Preferences > AI.
|
||||
*
|
||||
* - master AI off → notice only; the whole card is hidden
|
||||
* - no provider configured → notice in the card body, controls disabled
|
||||
* - no enabled model → notice in the card body, controls disabled
|
||||
* - everything on → fully interactive
|
||||
*/
|
||||
|
||||
const TRIGGER_MODES = [
|
||||
{
|
||||
value: 'aggressive',
|
||||
label: 'Aggressive',
|
||||
description: 'Suggest after every keystroke'
|
||||
},
|
||||
{
|
||||
value: 'debounced',
|
||||
label: 'Debounced',
|
||||
description: 'Suggest after you pause typing (default)'
|
||||
},
|
||||
{
|
||||
value: 'manual',
|
||||
label: 'Manual',
|
||||
description: 'Only on ⌘+\\ / Ctrl+\\'
|
||||
}
|
||||
];
|
||||
|
||||
const AutocompletePane = ({
|
||||
aiEnabled,
|
||||
enabled,
|
||||
model,
|
||||
triggerMode,
|
||||
availableModels,
|
||||
hasConfiguredProvider,
|
||||
onToggleEnabled,
|
||||
onChangeModel,
|
||||
onChangeTriggerMode
|
||||
}) => {
|
||||
if (!aiEnabled) {
|
||||
return (
|
||||
<div className="autocomplete-tab flex flex-col gap-3">
|
||||
<div className="ai-empty-notice px-3.5 py-3 text-xs">
|
||||
Turn on AI in the Configuration tab to use autocomplete.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const hasUsableModel = availableModels.length > 0;
|
||||
const isInteractive = enabled && hasUsableModel;
|
||||
const activeTrigger = TRIGGER_MODES.find((m) => m.value === (triggerMode || 'debounced'));
|
||||
|
||||
// Surface the most actionable blocker first when the user can't actually
|
||||
// get suggestions yet.
|
||||
let blockerMessage = null;
|
||||
if (!hasConfiguredProvider) {
|
||||
blockerMessage = 'Add a provider API key in the Configuration tab to enable autocomplete.';
|
||||
} else if (!hasUsableModel) {
|
||||
blockerMessage = 'No models are available. Enable a model on its provider card in Configuration.';
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="autocomplete-tab flex flex-col gap-3">
|
||||
<div className="autocomplete-card">
|
||||
<div className="autocomplete-header flex items-center justify-between gap-3 px-3.5 py-3">
|
||||
<div className="flex flex-col gap-0.5 min-w-0">
|
||||
<span className="text-[12.5px] font-semibold">Inline Autocomplete</span>
|
||||
<span className="autocomplete-sub text-[11px]">
|
||||
Ghost-text suggestions in Pre-Request, Post-Response, and Tests scripts
|
||||
</span>
|
||||
</div>
|
||||
<ToggleSwitch
|
||||
size="m"
|
||||
isOn={enabled}
|
||||
handleToggle={() => onToggleEnabled(!enabled)}
|
||||
data-testid="ai-autocomplete-enabled-toggle"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`autocomplete-card ${enabled ? '' : 'dimmed'}`}>
|
||||
{blockerMessage && (
|
||||
<div className="autocomplete-blocker px-3.5 py-3 text-[11px]">
|
||||
{blockerMessage}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="autocomplete-row flex items-center justify-between gap-3 px-3.5 py-3">
|
||||
<div className="flex flex-col gap-0.5 min-w-0">
|
||||
<span className="text-[11.5px] font-medium">Model</span>
|
||||
<span className="autocomplete-sub text-[10.5px]">
|
||||
{hasUsableModel
|
||||
? 'Lightweight models are recommended for speed'
|
||||
: 'No model available yet'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="model-select-wrap relative inline-flex items-center">
|
||||
<select
|
||||
className="model-select"
|
||||
value={model || ''}
|
||||
disabled={!isInteractive}
|
||||
onChange={(e) => onChangeModel(e.target.value)}
|
||||
aria-label="Autocomplete model"
|
||||
data-testid="ai-autocomplete-model-select"
|
||||
>
|
||||
<option value="">Auto (fastest available)</option>
|
||||
{availableModels.map((m) => (
|
||||
<option key={m.id} value={m.id}>{m.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<IconChevronDown size={12} strokeWidth={1.75} className="model-select-chevron" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="autocomplete-row flex items-center justify-between gap-3 px-3.5 py-3">
|
||||
<div className="flex flex-col gap-0.5 min-w-0">
|
||||
<span className="text-[11.5px] font-medium">Trigger</span>
|
||||
<span className="autocomplete-sub text-[10.5px]">
|
||||
{activeTrigger?.description}
|
||||
</span>
|
||||
</div>
|
||||
<div className="trigger-pills inline-flex" role="radiogroup" aria-label="Trigger mode">
|
||||
{TRIGGER_MODES.map((m) => {
|
||||
const isSelected = (triggerMode || 'debounced') === m.value;
|
||||
return (
|
||||
<button
|
||||
key={m.value}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={isSelected}
|
||||
className={`trigger-pill ${isSelected ? 'selected' : ''}`}
|
||||
disabled={!isInteractive}
|
||||
onClick={() => onChangeTriggerMode(m.value)}
|
||||
data-testid={`ai-autocomplete-trigger-${m.value}`}
|
||||
>
|
||||
{m.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="autocomplete-row px-3.5 py-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-[11.5px] font-medium">Keymap</span>
|
||||
<div className="autocomplete-keymap text-[10.5px]">
|
||||
<kbd>Tab</kbd> accept · <kbd>Esc</kbd> dismiss · <kbd>⌘</kbd>+<kbd>\</kbd> trigger
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AutocompletePane;
|
||||
@@ -3,6 +3,46 @@ import styled from 'styled-components';
|
||||
const StyledWrapper = styled.div`
|
||||
color: ${(props) => props.theme.text};
|
||||
|
||||
.ai-tabs {
|
||||
border-bottom: 1px solid ${(props) => props.theme.input.border};
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.ai-tab {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 7px 12px;
|
||||
margin-bottom: -1px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
cursor: pointer;
|
||||
transition: color 0.15s ease, border-color 0.15s ease;
|
||||
|
||||
&:hover:not(.active) {
|
||||
color: ${(props) => props.theme.text};
|
||||
}
|
||||
|
||||
&.active {
|
||||
color: ${(props) => props.theme.text};
|
||||
border-bottom-color: ${(props) => props.theme.colors.accent};
|
||||
}
|
||||
|
||||
svg {
|
||||
color: inherit;
|
||||
}
|
||||
}
|
||||
|
||||
.ai-tab-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.ai-master {
|
||||
border: 1px solid ${(props) => props.theme.input.border};
|
||||
border-radius: ${(props) => props.theme.border.radius.md};
|
||||
@@ -230,6 +270,115 @@ const StyledWrapper = styled.div`
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
}
|
||||
|
||||
.autocomplete-card {
|
||||
border: 1px solid ${(props) => props.theme.input.border};
|
||||
border-radius: ${(props) => props.theme.border.radius.md};
|
||||
background: ${(props) => props.theme.input.bg};
|
||||
}
|
||||
|
||||
.autocomplete-sub {
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
}
|
||||
|
||||
.autocomplete-card.dimmed {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.autocomplete-row + .autocomplete-row {
|
||||
border-top: 1px dashed ${(props) => props.theme.input.border};
|
||||
}
|
||||
|
||||
.autocomplete-blocker {
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
background: ${(props) => props.theme.input.bg};
|
||||
border-bottom: 1px solid ${(props) => props.theme.input.border};
|
||||
}
|
||||
|
||||
.model-select {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
padding: 4px 24px 4px 8px;
|
||||
font-size: 11.5px;
|
||||
font-family: inherit;
|
||||
border-radius: ${(props) => props.theme.border.radius.sm};
|
||||
border: 1px solid ${(props) => props.theme.input.border};
|
||||
background: ${(props) => props.theme.bg};
|
||||
color: ${(props) => props.theme.text};
|
||||
cursor: pointer;
|
||||
min-width: 160px;
|
||||
transition: border-color 0.15s ease;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
border-color: ${(props) => props.theme.colors.accent}80;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: ${(props) => props.theme.input.focusBorder};
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.model-select-chevron {
|
||||
position: absolute;
|
||||
right: 6px;
|
||||
pointer-events: none;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
}
|
||||
|
||||
.trigger-pills {
|
||||
border: 1px solid ${(props) => props.theme.input.border};
|
||||
border-radius: ${(props) => props.theme.border.radius.sm};
|
||||
padding: 2px;
|
||||
background: ${(props) => props.theme.bg};
|
||||
}
|
||||
|
||||
.trigger-pill {
|
||||
padding: 3px 9px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
border: 1px solid transparent;
|
||||
border-radius: ${(props) => props.theme.border.radius.sm};
|
||||
background: transparent;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
cursor: pointer;
|
||||
transition: color 0.15s ease, background-color 0.15s ease, border-color 0.15s ease;
|
||||
|
||||
&:hover:not(:disabled):not(.selected) {
|
||||
color: ${(props) => props.theme.text};
|
||||
}
|
||||
|
||||
&.selected {
|
||||
color: ${(props) => props.theme.text};
|
||||
background: ${(props) => props.theme.colors.accent}10;
|
||||
border-color: ${(props) => props.theme.colors.accent}55;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.autocomplete-keymap {
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
|
||||
kbd {
|
||||
display: inline-block;
|
||||
padding: 0 4px;
|
||||
margin: 0 1px;
|
||||
font-family: ${(props) => props.theme.font.monospace || 'monospace'};
|
||||
font-size: 10px;
|
||||
line-height: 1.5;
|
||||
color: ${(props) => props.theme.text};
|
||||
background: ${(props) => props.theme.bg};
|
||||
border: 1px solid ${(props) => props.theme.input.border};
|
||||
border-radius: 3px;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
|
||||
@@ -5,18 +5,24 @@ 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 { IconSettings, IconTerminal2 } 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 AutocompletePane from './AutocompletePane';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
|
||||
const aiPreferencesSchema = Yup.object().shape({
|
||||
enabled: Yup.boolean(),
|
||||
providers: Yup.object(),
|
||||
models: Yup.object(),
|
||||
defaultModel: Yup.string().max(200).nullable()
|
||||
defaultModel: Yup.string().max(200).nullable(),
|
||||
autocomplete: Yup.object().shape({
|
||||
enabled: Yup.boolean(),
|
||||
model: Yup.string().max(200).nullable(),
|
||||
triggerMode: Yup.string().oneOf(['aggressive', 'debounced', 'manual']).nullable()
|
||||
})
|
||||
});
|
||||
|
||||
const AI = () => {
|
||||
@@ -24,6 +30,7 @@ const AI = () => {
|
||||
const preferences = useSelector((state) => state.app.preferences);
|
||||
const [status, setStatus] = useState(null);
|
||||
const [statusError, setStatusError] = useState(null);
|
||||
const [activeTab, setActiveTab] = useState('config');
|
||||
|
||||
const refreshStatus = useCallback(async () => {
|
||||
try {
|
||||
@@ -50,7 +57,12 @@ const AI = () => {
|
||||
return acc;
|
||||
}, {}),
|
||||
models: get(preferences, 'ai.models', {}),
|
||||
defaultModel: get(preferences, 'ai.defaultModel', '')
|
||||
defaultModel: get(preferences, 'ai.defaultModel', ''),
|
||||
autocomplete: {
|
||||
enabled: get(preferences, 'ai.autocomplete.enabled', true),
|
||||
model: get(preferences, 'ai.autocomplete.model', ''),
|
||||
triggerMode: get(preferences, 'ai.autocomplete.triggerMode', 'debounced')
|
||||
}
|
||||
},
|
||||
validationSchema: aiPreferencesSchema,
|
||||
onSubmit: () => {}
|
||||
@@ -65,7 +77,12 @@ const AI = () => {
|
||||
enabled: values.enabled,
|
||||
providers: values.providers,
|
||||
models: values.models,
|
||||
defaultModel: values.defaultModel || ''
|
||||
defaultModel: values.defaultModel || '',
|
||||
autocomplete: {
|
||||
enabled: values.autocomplete?.enabled !== false,
|
||||
model: values.autocomplete?.model || '',
|
||||
triggerMode: values.autocomplete?.triggerMode || 'debounced'
|
||||
}
|
||||
}
|
||||
})
|
||||
).catch((err) => {
|
||||
@@ -112,40 +129,42 @@ const AI = () => {
|
||||
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) => {
|
||||
const usableModels = useMemo(() => {
|
||||
if (!status) return [];
|
||||
return (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]);
|
||||
});
|
||||
}, [status, 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 className="ai-tabs flex items-center gap-1" role="tablist" aria-label="AI preferences">
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={activeTab === 'config'}
|
||||
className={`ai-tab ${activeTab === 'config' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('config')}
|
||||
data-testid="ai-tab-config"
|
||||
>
|
||||
<IconSettings size={14} strokeWidth={1.5} />
|
||||
Configuration
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={activeTab === 'autocomplete'}
|
||||
className={`ai-tab ${activeTab === 'autocomplete' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('autocomplete')}
|
||||
data-testid="ai-tab-autocomplete"
|
||||
>
|
||||
<IconTerminal2 size={14} strokeWidth={1.5} />
|
||||
Autocomplete
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{statusError && (
|
||||
@@ -154,46 +173,84 @@ const AI = () => {
|
||||
</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.
|
||||
{activeTab === 'config' && (
|
||||
<div className="ai-tab-panel" role="tabpanel">
|
||||
<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">
|
||||
<span className="text-[13px] font-semibold">AI Features</span>
|
||||
<span className="ai-master-summary text-[11px]">
|
||||
Turn on to configure providers and models. Your keys stay local.
|
||||
</span>
|
||||
</div>
|
||||
<ToggleSwitch
|
||||
size="m"
|
||||
isOn={formik.values.enabled}
|
||||
handleToggle={() => formik.setFieldValue('enabled', !formik.values.enabled)}
|
||||
/>
|
||||
</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 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>
|
||||
</>
|
||||
)}
|
||||
</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>
|
||||
</>
|
||||
{activeTab === 'autocomplete' && (
|
||||
<div className="ai-tab-panel" role="tabpanel">
|
||||
<AutocompletePane
|
||||
aiEnabled={formik.values.enabled}
|
||||
enabled={formik.values.autocomplete?.enabled !== false}
|
||||
model={formik.values.autocomplete?.model || ''}
|
||||
triggerMode={formik.values.autocomplete?.triggerMode || 'debounced'}
|
||||
availableModels={usableModels}
|
||||
hasConfiguredProvider={Boolean(
|
||||
status && Object.entries(status.providers || {}).some(
|
||||
([providerId, p]) => p?.configured && formik.values.providers?.[providerId]?.enabled
|
||||
)
|
||||
)}
|
||||
onToggleEnabled={(next) => formik.setFieldValue('autocomplete.enabled', next)}
|
||||
onChangeModel={(next) => formik.setFieldValue('autocomplete.model', next)}
|
||||
onChangeTriggerMode={(next) => formik.setFieldValue('autocomplete.triggerMode', next)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</StyledWrapper>
|
||||
);
|
||||
|
||||
@@ -127,6 +127,7 @@ const Script = ({ item, collection }) => {
|
||||
<CodeEditor
|
||||
ref={preRequestEditorRef}
|
||||
collection={collection}
|
||||
item={item}
|
||||
docKey="script:pre-request"
|
||||
value={requestScript || ''}
|
||||
theme={displayedTheme}
|
||||
@@ -137,6 +138,7 @@ const Script = ({ item, collection }) => {
|
||||
onRun={onRun}
|
||||
onSave={onSave}
|
||||
showHintsFor={['req', 'bru']}
|
||||
scriptType="pre-request"
|
||||
initialScroll={preReqScroll}
|
||||
onScroll={setPreReqScroll}
|
||||
/>
|
||||
@@ -154,6 +156,7 @@ const Script = ({ item, collection }) => {
|
||||
<CodeEditor
|
||||
ref={postResponseEditorRef}
|
||||
collection={collection}
|
||||
item={item}
|
||||
docKey="script:post-response"
|
||||
value={responseScript || ''}
|
||||
theme={displayedTheme}
|
||||
@@ -164,6 +167,7 @@ const Script = ({ item, collection }) => {
|
||||
onRun={onRun}
|
||||
onSave={onSave}
|
||||
showHintsFor={['req', 'res', 'bru']}
|
||||
scriptType="post-response"
|
||||
initialScroll={postResScroll}
|
||||
onScroll={setPostResScroll}
|
||||
/>
|
||||
|
||||
@@ -45,6 +45,7 @@ const Tests = ({ item, collection }) => {
|
||||
<CodeEditor
|
||||
ref={testsEditorRef}
|
||||
collection={collection}
|
||||
item={item}
|
||||
docKey="tests"
|
||||
value={tests || ''}
|
||||
theme={displayedTheme}
|
||||
@@ -55,6 +56,7 @@ const Tests = ({ item, collection }) => {
|
||||
onRun={onRun}
|
||||
onSave={onSave}
|
||||
showHintsFor={['req', 'res', 'bru']}
|
||||
scriptType="tests"
|
||||
initialScroll={testsScroll}
|
||||
onScroll={setTestsScroll}
|
||||
/>
|
||||
|
||||
@@ -68,7 +68,12 @@ const initialState = {
|
||||
anthropic: { enabled: false }
|
||||
},
|
||||
models: {},
|
||||
defaultModel: ''
|
||||
defaultModel: '',
|
||||
autocomplete: {
|
||||
enabled: true,
|
||||
model: '',
|
||||
triggerMode: 'debounced'
|
||||
}
|
||||
}
|
||||
},
|
||||
generateCode: {
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { callIpc } from 'utils/common/ipc';
|
||||
import { isItemAFolder, isItemARequest, sortItemsBySidebarOrder } from 'utils/collections';
|
||||
import {
|
||||
flattenItems,
|
||||
getAllVariables,
|
||||
isItemAFolder,
|
||||
isItemARequest,
|
||||
sortItemsBySidebarOrder
|
||||
} from 'utils/collections';
|
||||
|
||||
/**
|
||||
* Renderer-side wrapper around the AI IPC channels.
|
||||
@@ -28,6 +34,16 @@ export const aiGenerateText = (params) =>
|
||||
export const aiGenerateScript = (params) =>
|
||||
callIpc('renderer:ai-generate-script', params);
|
||||
|
||||
export const aiAutocomplete = (params) =>
|
||||
callIpc('renderer:ai-autocomplete', params);
|
||||
|
||||
export const cancelAiAutocomplete = (requestId) => {
|
||||
const { ipcRenderer } = window;
|
||||
if (ipcRenderer && requestId) {
|
||||
ipcRenderer.send('renderer:ai-autocomplete-cancel', { requestId });
|
||||
}
|
||||
};
|
||||
|
||||
export const buildRequestContextFromItem = (item) => {
|
||||
if (!item) return null;
|
||||
const req = item.draft ? item.draft.request : item.request;
|
||||
@@ -102,6 +118,74 @@ export const buildDocsContextFromFolder = (collection, folder) => {
|
||||
};
|
||||
};
|
||||
|
||||
// Variable scopes the autocomplete model is told about. We ship NAMES only —
|
||||
// values stay on the client. The model uses these to pick real keys for
|
||||
// bru.getEnvVar / bru.getVar / bru.interpolate instead of inventing placeholders.
|
||||
const SKIPPED_VAR_KEYS = new Set(['pathParams', 'maskedEnvVariables', 'process']);
|
||||
|
||||
const namesFrom = (obj) => {
|
||||
if (!obj || typeof obj !== 'object') return [];
|
||||
return Object.keys(obj).filter((k) => !SKIPPED_VAR_KEYS.has(k));
|
||||
};
|
||||
|
||||
/**
|
||||
* Build the variable-name lists the model will see. We don't separate by
|
||||
* scope on the client because `getAllVariables` already merges them — the
|
||||
* model just needs the union of names to pick from.
|
||||
*/
|
||||
const collectVariableNames = (collection, item) => {
|
||||
const all = getAllVariables(collection, item);
|
||||
const names = namesFrom(all);
|
||||
const processEnv = all?.process?.env ? Object.keys(all.process.env) : [];
|
||||
return {
|
||||
variables: names,
|
||||
processEnv: processEnv
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Last N non-empty sibling scripts of the same type in the collection,
|
||||
* excluding the current item. Used as style/idiom reference for the model.
|
||||
*/
|
||||
const collectSiblingScripts = (collection, currentItem, scriptType, maxCount = 3) => {
|
||||
if (!collection || !scriptType) return [];
|
||||
|
||||
const all = flattenItems(collection.items || []);
|
||||
const out = [];
|
||||
for (const it of all) {
|
||||
if (!isItemARequest(it)) continue;
|
||||
if (currentItem && it.uid === currentItem.uid) continue;
|
||||
|
||||
const req = it.draft ? it.draft.request : it.request;
|
||||
if (!req) continue;
|
||||
|
||||
let script;
|
||||
if (scriptType === 'tests') script = req.tests;
|
||||
else if (scriptType === 'pre-request') script = req.script?.req;
|
||||
else if (scriptType === 'post-response') script = req.script?.res;
|
||||
|
||||
if (typeof script === 'string' && script.trim().length > 0) {
|
||||
out.push({ name: it.name, type: scriptType, script });
|
||||
// Sliding window of the most-recent N. flattenItems walks the tree
|
||||
// top-to-bottom so shifting from the front keeps the last N matches.
|
||||
if (out.length > maxCount) out.shift();
|
||||
}
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
/**
|
||||
* Build everything the autocomplete IPC needs about the current request +
|
||||
* its collection environment. Called per request from the editor.
|
||||
*/
|
||||
export const buildAutocompleteContext = ({ item, collection, scriptType }) => {
|
||||
return {
|
||||
requestContext: item ? buildRequestContextFromItem(item) : null,
|
||||
variableNames: collection ? collectVariableNames(collection, item) : null,
|
||||
siblingScripts: collection ? collectSiblingScripts(collection, item, scriptType) : []
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Start a streaming generation. Subscribes to the corresponding `main:ai-stream-*`
|
||||
* channels filtered by streamId. Returns a handle with `.stop()` and a promise
|
||||
|
||||
380
packages/bruno-app/src/utils/codemirror/aiGhostText.js
Normal file
380
packages/bruno-app/src/utils/codemirror/aiGhostText.js
Normal file
@@ -0,0 +1,380 @@
|
||||
import { aiAutocomplete, cancelAiAutocomplete } from 'utils/ai';
|
||||
|
||||
/**
|
||||
* Inline (ghost-text) AI autocomplete for CodeMirror 5 script editors.
|
||||
*
|
||||
* Suggestion is rendered as a faded bookmark widget at the cursor:
|
||||
* Tab accept the whole suggestion
|
||||
* Esc dismiss
|
||||
* Cmd/Ctrl+\ manually trigger (and only way to trigger in manual mode)
|
||||
*
|
||||
* Trigger modes from preferences:
|
||||
* aggressive -> debounce 100ms (feels keystroke-level but coalesces bursts)
|
||||
* debounced -> debounce 750ms (user pause before request fires)
|
||||
* manual -> no auto-trigger, hotkey only
|
||||
*
|
||||
* A short-lived in-flight request is cancelled on every new keystroke so we
|
||||
* don't burn provider tokens on completions the user has already typed past.
|
||||
*/
|
||||
|
||||
const TRIGGER_DELAYS = {
|
||||
aggressive: 100,
|
||||
debounced: 750,
|
||||
manual: null
|
||||
};
|
||||
|
||||
// Per-editor state. WeakMap so it GC's with the editor.
|
||||
const STATE = new WeakMap();
|
||||
|
||||
const getState = (cm) => {
|
||||
let s = STATE.get(cm);
|
||||
if (!s) {
|
||||
s = {
|
||||
widget: null,
|
||||
suggestion: '',
|
||||
anchor: null, // CodeMirror position where the suggestion was placed
|
||||
timer: null,
|
||||
inflightId: null,
|
||||
// Completion-reuse cache: if the user types characters matching the
|
||||
// suggestion start, we trim the suggestion instead of refetching.
|
||||
reusePrefix: null,
|
||||
reuseSuggestion: null,
|
||||
// Local result cache by (prefix.slice(-300), suffix.slice(0, 100)).
|
||||
local: new Map(),
|
||||
destroyed: false
|
||||
};
|
||||
STATE.set(cm, s);
|
||||
}
|
||||
return s;
|
||||
};
|
||||
|
||||
const newRequestId = () => `ac-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
|
||||
const createGhostElement = (text) => {
|
||||
const span = document.createElement('span');
|
||||
span.className = 'cm-ghost-text cm-ghost-text-ai';
|
||||
// A bare \n inside an inline span doesn't wrap — emit one <br> per newline
|
||||
// so multi-line ghost text stacks under the cursor line.
|
||||
const lines = text.split('\n');
|
||||
lines.forEach((line, i) => {
|
||||
if (i > 0) span.appendChild(document.createElement('br'));
|
||||
span.appendChild(document.createTextNode(line));
|
||||
});
|
||||
return span;
|
||||
};
|
||||
|
||||
const clearGhost = (cm, opts = {}) => {
|
||||
const s = getState(cm);
|
||||
if (s.widget) {
|
||||
s.widget.clear();
|
||||
s.widget = null;
|
||||
}
|
||||
s.suggestion = '';
|
||||
s.anchor = null;
|
||||
if (opts.clearReuse) {
|
||||
s.reusePrefix = null;
|
||||
s.reuseSuggestion = null;
|
||||
}
|
||||
};
|
||||
|
||||
const showGhost = (cm, suggestion) => {
|
||||
if (!suggestion) return;
|
||||
clearGhost(cm);
|
||||
const s = getState(cm);
|
||||
const cursor = cm.getCursor();
|
||||
const widget = createGhostElement(suggestion);
|
||||
s.widget = cm.setBookmark(cursor, { widget, insertLeft: true });
|
||||
s.suggestion = suggestion;
|
||||
s.anchor = { line: cursor.line, ch: cursor.ch };
|
||||
};
|
||||
|
||||
const LOCAL_CACHE_MAX = 50;
|
||||
const cacheKey = (prefix, suffix) => `${prefix.slice(-300)}<>${suffix.slice(0, 100)}`;
|
||||
|
||||
const localCacheGet = (s, prefix, suffix) => {
|
||||
const key = cacheKey(prefix, suffix);
|
||||
if (s.local.has(key)) return s.local.get(key);
|
||||
|
||||
// Prefix-based lookup — if the user has typed chars matching a previous
|
||||
// suggestion start, return the trimmed remainder.
|
||||
for (const [k, v] of s.local) {
|
||||
const [cachedPrefix] = k.split('<>');
|
||||
if (prefix.startsWith(cachedPrefix) && prefix.length > cachedPrefix.length) {
|
||||
const extra = prefix.slice(cachedPrefix.length);
|
||||
if (v.startsWith(extra)) {
|
||||
const remainder = v.slice(extra.length);
|
||||
if (remainder) return remainder;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const localCacheSet = (s, prefix, suffix, suggestion) => {
|
||||
const key = cacheKey(prefix, suffix);
|
||||
if (s.local.has(key)) s.local.delete(key);
|
||||
s.local.set(key, suggestion);
|
||||
while (s.local.size > LOCAL_CACHE_MAX) {
|
||||
s.local.delete(s.local.keys().next().value);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Snapshot of editor state at the moment a request was queued. Used to
|
||||
* verify the suggestion still applies when it arrives (cursor hasn't moved
|
||||
* away from the position we asked about).
|
||||
*/
|
||||
const snapshotPosition = (cm) => {
|
||||
const cursor = cm.getCursor();
|
||||
return { line: cursor.line, ch: cursor.ch, idx: cm.indexFromPos(cursor) };
|
||||
};
|
||||
|
||||
const fetchAndShow = async (cm, options) => {
|
||||
if (!options.isEnabled?.()) return;
|
||||
|
||||
const code = cm.getValue();
|
||||
const cursor = cm.getCursor();
|
||||
const cursorIdx = cm.indexFromPos(cursor);
|
||||
const prefix = code.slice(0, cursorIdx);
|
||||
const suffix = code.slice(cursorIdx);
|
||||
|
||||
if (prefix.length < 3) return;
|
||||
|
||||
// Skip if there's meaningful text after the cursor on the same line —
|
||||
// ghost-text overlap with real code reads confusingly.
|
||||
const restOfLine = cm.getLine(cursor.line).slice(cursor.ch).trim();
|
||||
if (restOfLine && !/^[)\]}"',;\s]*$/.test(restOfLine)) return;
|
||||
|
||||
const s = getState(cm);
|
||||
|
||||
// Local cache (instant)
|
||||
const local = localCacheGet(s, prefix, suffix);
|
||||
if (local) {
|
||||
showGhost(cm, local);
|
||||
s.reusePrefix = prefix;
|
||||
s.reuseSuggestion = local;
|
||||
return;
|
||||
}
|
||||
|
||||
const ctx = options.getContext?.() || {};
|
||||
const requestId = newRequestId();
|
||||
const snapshot = snapshotPosition(cm);
|
||||
|
||||
// Cancel any prior in-flight before issuing a new one.
|
||||
if (s.inflightId) cancelAiAutocomplete(s.inflightId);
|
||||
s.inflightId = requestId;
|
||||
|
||||
try {
|
||||
const result = await aiAutocomplete({
|
||||
requestId,
|
||||
scriptType: options.scriptType,
|
||||
prefix,
|
||||
suffix,
|
||||
requestContext: ctx.requestContext || null,
|
||||
variableNames: ctx.variableNames || null,
|
||||
siblingScripts: ctx.siblingScripts || []
|
||||
});
|
||||
|
||||
if (s.inflightId !== requestId || s.destroyed) return;
|
||||
s.inflightId = null;
|
||||
|
||||
if (!result || result.cancelled || result.disabled) return;
|
||||
|
||||
if (result.error) {
|
||||
// Stay quiet — autocomplete errors shouldn't toast on every keystroke.
|
||||
return;
|
||||
}
|
||||
|
||||
const suggestion = result.suggestion || '';
|
||||
if (!suggestion) return;
|
||||
|
||||
// Bail if the cursor moved while we were waiting.
|
||||
const nowIdx = cm.indexFromPos(cm.getCursor());
|
||||
if (nowIdx !== snapshot.idx) return;
|
||||
|
||||
localCacheSet(s, prefix, suffix, suggestion);
|
||||
s.reusePrefix = prefix;
|
||||
s.reuseSuggestion = suggestion;
|
||||
showGhost(cm, suggestion);
|
||||
} catch (_err) {
|
||||
// Network/IPC errors during typing aren't surfaced.
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* If the user has typed characters that match the start of the last shown
|
||||
* suggestion, trim the suggestion and re-render instantly — no API call.
|
||||
*/
|
||||
const tryReuse = (cm) => {
|
||||
const s = getState(cm);
|
||||
if (!s.reusePrefix || !s.reuseSuggestion) return false;
|
||||
|
||||
const cursor = cm.getCursor();
|
||||
const cursorIdx = cm.indexFromPos(cursor);
|
||||
const prefix = cm.getValue().slice(0, cursorIdx);
|
||||
|
||||
if (!prefix.startsWith(s.reusePrefix)) {
|
||||
s.reusePrefix = null;
|
||||
s.reuseSuggestion = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
const typed = prefix.slice(s.reusePrefix.length);
|
||||
if (typed.length === 0) return false;
|
||||
|
||||
if (s.reuseSuggestion.startsWith(typed)) {
|
||||
const remainder = s.reuseSuggestion.slice(typed.length);
|
||||
if (remainder) {
|
||||
showGhost(cm, remainder);
|
||||
// Update reuse anchor so subsequent typing keeps narrowing.
|
||||
s.reusePrefix = prefix;
|
||||
s.reuseSuggestion = remainder;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Drift — the user typed something inconsistent with the suggestion.
|
||||
s.reusePrefix = null;
|
||||
s.reuseSuggestion = null;
|
||||
return false;
|
||||
};
|
||||
|
||||
const scheduleFetch = (cm, options, immediate = false) => {
|
||||
const s = getState(cm);
|
||||
if (s.timer) {
|
||||
clearTimeout(s.timer);
|
||||
s.timer = null;
|
||||
}
|
||||
|
||||
const mode = options.getTriggerMode?.() || 'debounced';
|
||||
if (mode === 'manual' && !immediate) return;
|
||||
|
||||
const delay = immediate ? 0 : (TRIGGER_DELAYS[mode] ?? TRIGGER_DELAYS.debounced);
|
||||
s.timer = setTimeout(() => {
|
||||
s.timer = null;
|
||||
fetchAndShow(cm, options);
|
||||
}, delay);
|
||||
};
|
||||
|
||||
const acceptAll = (cm) => {
|
||||
const s = getState(cm);
|
||||
if (!s.suggestion) return false;
|
||||
const text = s.suggestion;
|
||||
const cursor = cm.getCursor();
|
||||
clearGhost(cm, { clearReuse: true });
|
||||
cm.replaceRange(text, cursor, cursor, '+ai-autocomplete');
|
||||
return true;
|
||||
};
|
||||
|
||||
const dismiss = (cm) => {
|
||||
const s = getState(cm);
|
||||
if (!s.suggestion) return false;
|
||||
clearGhost(cm, { clearReuse: true });
|
||||
if (s.inflightId) {
|
||||
cancelAiAutocomplete(s.inflightId);
|
||||
s.inflightId = null;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
/**
|
||||
* Wire up ghost-text AI autocomplete on a CodeMirror 5 editor.
|
||||
*
|
||||
* options:
|
||||
* scriptType 'tests' | 'pre-request' | 'post-response'
|
||||
* getContext() → { requestContext, variableNames, siblingScripts }
|
||||
* isEnabled() → boolean (AI master + autocomplete + has-model gate)
|
||||
* getTriggerMode() → 'aggressive' | 'debounced' | 'manual'
|
||||
*
|
||||
* Returns a cleanup function that detaches every handler and clears state.
|
||||
*/
|
||||
export const setupAiAutocomplete = (editor, options) => {
|
||||
if (!editor || !options || !options.scriptType) {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
// Drop any pending in-flight request, the answer it'd return is about a
|
||||
// cursor position we've already left. Avoids wasted provider calls and
|
||||
// stale-response races in debounced mode.
|
||||
const abortInflight = (cm) => {
|
||||
const s = getState(cm);
|
||||
if (s.inflightId) {
|
||||
cancelAiAutocomplete(s.inflightId);
|
||||
s.inflightId = null;
|
||||
}
|
||||
};
|
||||
|
||||
const onChange = (cm, changeObj) => {
|
||||
// Ignore our own programmatic inserts so they don't re-fire ghost text.
|
||||
if (changeObj.origin === '+ai-autocomplete') return;
|
||||
if (changeObj.origin !== '+input' && changeObj.origin !== '+delete' && changeObj.origin !== 'paste') {
|
||||
abortInflight(cm);
|
||||
clearGhost(cm);
|
||||
return;
|
||||
}
|
||||
// Try reuse first — if it works, no network round-trip needed.
|
||||
if (tryReuse(cm)) return;
|
||||
abortInflight(cm);
|
||||
clearGhost(cm);
|
||||
scheduleFetch(cm, options);
|
||||
};
|
||||
|
||||
const onCursorActivity = (cm) => {
|
||||
const s = getState(cm);
|
||||
if (!s.widget || !s.anchor) return;
|
||||
const cursor = cm.getCursor();
|
||||
if (cursor.line !== s.anchor.line || cursor.ch !== s.anchor.ch) {
|
||||
clearGhost(cm, { clearReuse: true });
|
||||
}
|
||||
};
|
||||
|
||||
const onBlur = (cm) => {
|
||||
abortInflight(cm);
|
||||
clearGhost(cm, { clearReuse: true });
|
||||
};
|
||||
|
||||
const onKeyDown = (cm, event) => {
|
||||
const s = getState(cm);
|
||||
|
||||
// Manual trigger: Cmd/Ctrl + \
|
||||
if ((event.metaKey || event.ctrlKey) && event.key === '\\') {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
clearGhost(cm);
|
||||
scheduleFetch(cm, options, true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!s.suggestion) return;
|
||||
|
||||
if (event.key === 'Tab' && !event.shiftKey && !event.altKey) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
acceptAll(cm);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
dismiss(cm);
|
||||
}
|
||||
};
|
||||
|
||||
editor.on('change', onChange);
|
||||
editor.on('cursorActivity', onCursorActivity);
|
||||
editor.on('blur', onBlur);
|
||||
editor.on('keydown', onKeyDown);
|
||||
|
||||
return () => {
|
||||
const s = getState(editor);
|
||||
s.destroyed = true;
|
||||
if (s.timer) clearTimeout(s.timer);
|
||||
if (s.inflightId) cancelAiAutocomplete(s.inflightId);
|
||||
clearGhost(editor, { clearReuse: true });
|
||||
editor.off('change', onChange);
|
||||
editor.off('cursorActivity', onCursorActivity);
|
||||
editor.off('blur', onBlur);
|
||||
editor.off('keydown', onKeyDown);
|
||||
};
|
||||
};
|
||||
@@ -46,6 +46,7 @@ const registerApiSpecIpc = require('./ipc/apiSpec');
|
||||
const registerGitIpc = require('./ipc/git');
|
||||
const registerOpenAPISyncIpc = require('./ipc/openapi-sync');
|
||||
const registerAiIpc = require('./ipc/ai');
|
||||
const registerAiAutocompleteIpc = require('./ipc/ai/autocomplete');
|
||||
const { registerMountIpc } = require('./ipc/mount');
|
||||
const collectionWatcher = require('./app/collection-watcher');
|
||||
const WorkspaceWatcher = require('./app/workspace-watcher');
|
||||
@@ -478,6 +479,7 @@ app.on('ready', async () => {
|
||||
registerGitIpc(mainWindow);
|
||||
registerOpenAPISyncIpc(mainWindow);
|
||||
registerAiIpc(mainWindow);
|
||||
registerAiAutocompleteIpc(mainWindow);
|
||||
registerMountIpc();
|
||||
|
||||
// Internal delegator
|
||||
|
||||
170
packages/bruno-electron/src/ipc/ai/autocomplete-prompts.js
Normal file
170
packages/bruno-electron/src/ipc/ai/autocomplete-prompts.js
Normal file
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* Prompts for inline (ghost-text) autocomplete in script editors.
|
||||
*
|
||||
* Separate from script-prompts.js because the surfaces differ:
|
||||
* - script-prompts.js: full-script generation from a user instruction.
|
||||
* - this file: cursor-position completion from prefix/suffix, no user
|
||||
* instruction. Output is appended verbatim at the cursor.
|
||||
*/
|
||||
|
||||
const BRUNO_API_SUMMARY = `## Bruno runtime APIs (available inside scripts)
|
||||
|
||||
bru: env/global/collection/folder/request/runtime vars — get/set/has/delete,
|
||||
interpolate(strOrObj), sleep(ms), sendRequest(cfg), setNextRequest(name),
|
||||
cookies, utils.minifyJson/Xml, getEnvName, getCollectionName, cwd
|
||||
|
||||
req: url, method, headers, body, timeout. getUrl/setUrl, getHeader/setHeader,
|
||||
getBody/setBody, getMethod/setMethod, getTimeout/setTimeout. Available in
|
||||
pre-request, post-response and tests.
|
||||
|
||||
res: status, headers, body, responseTime. getStatus, getStatusText,
|
||||
getHeader, getHeaders, getBody, getResponseTime, getSize. res('json.path')
|
||||
for JSONPath-style queries. Available in post-response and tests.
|
||||
|
||||
test/expect: Chai-style assertions inside test("name", () => { ... }) blocks.
|
||||
Available in tests only.`;
|
||||
|
||||
const SCRIPT_CONTEXTS = {
|
||||
'tests': `Tests run AFTER the response. Globals: bru, req, res, test, expect. Assertions go inside test("name", () => { ... }) blocks. Don't call bru.setEnvVar / setVar — keep tests pure.`,
|
||||
'pre-request': `Pre-request scripts run BEFORE the HTTP request is sent. Globals: bru, req. res is NOT available. Don't use test() / expect() — those belong in the Tests tab.`,
|
||||
'post-response': `Post-response scripts run AFTER the response is received, before tests. Globals: bru, req, res. Don't use test() / expect() — those belong in the Tests tab.`
|
||||
};
|
||||
|
||||
const SCRIPT_TYPE_LABELS = {
|
||||
'tests': 'a Bruno tests script',
|
||||
'pre-request': 'a Bruno pre-request script',
|
||||
'post-response': 'a Bruno post-response script'
|
||||
};
|
||||
|
||||
const buildSystemPrompt = (scriptType) => {
|
||||
const label = SCRIPT_TYPE_LABELS[scriptType] || 'a Bruno script';
|
||||
const context = SCRIPT_CONTEXTS[scriptType] || '';
|
||||
return `You are an inline code-completion engine for ${label}.
|
||||
|
||||
${BRUNO_API_SUMMARY}
|
||||
|
||||
## Context for ${scriptType}
|
||||
${context}
|
||||
|
||||
## Output rules
|
||||
|
||||
- Continue the code from the cursor marker \`<CURSOR>\` exactly where it is.
|
||||
- Output ONLY the characters that should be inserted at the cursor — no markdown, no fences, no commentary, no leading newline.
|
||||
- Match the surrounding indentation and quote style.
|
||||
- Stop at a natural break (end of statement, end of block) — do not rewrite code that already exists after the cursor.
|
||||
- Prefer real variable names from the provided lists over placeholders.
|
||||
- Return an empty string if you have nothing useful to add.`;
|
||||
};
|
||||
|
||||
const truncateLines = (text, maxLines) => {
|
||||
if (!text) return '';
|
||||
const lines = text.split('\n');
|
||||
if (lines.length <= maxLines) return text;
|
||||
return lines.slice(-maxLines).join('\n');
|
||||
};
|
||||
|
||||
const truncateLinesFromStart = (text, maxLines) => {
|
||||
if (!text) return '';
|
||||
const lines = text.split('\n');
|
||||
if (lines.length <= maxLines) return text;
|
||||
return lines.slice(0, maxLines).join('\n');
|
||||
};
|
||||
|
||||
const formatRequestContext = (ctx) => {
|
||||
if (!ctx) return '';
|
||||
const parts = [];
|
||||
if (ctx.url || ctx.method) {
|
||||
parts.push(`${ctx.method || 'GET'} ${ctx.url || ''}`.trim());
|
||||
}
|
||||
|
||||
const headers = (ctx.headers || []).filter((h) => h?.enabled && h?.name).slice(0, 12);
|
||||
if (headers.length) {
|
||||
parts.push(`Headers: ${headers.map((h) => h.name).join(', ')}`);
|
||||
}
|
||||
|
||||
const body = ctx.body;
|
||||
if (body && body.mode && body.mode !== 'none') {
|
||||
let bodyText = '';
|
||||
if (body.mode === 'json') bodyText = body.json || '';
|
||||
else if (body.mode === 'text') bodyText = body.text || '';
|
||||
else if (body.mode === 'xml') bodyText = body.xml || '';
|
||||
else if (body.mode === 'graphql') bodyText = body.graphql?.query || '';
|
||||
if (bodyText) {
|
||||
const trimmed = bodyText.slice(0, 400);
|
||||
parts.push(`Body (${body.mode}):\n${trimmed}${bodyText.length > 400 ? '…' : ''}`);
|
||||
}
|
||||
}
|
||||
|
||||
return parts.join('\n');
|
||||
};
|
||||
|
||||
const formatVariableNames = (vars) => {
|
||||
if (!vars || typeof vars !== 'object') return '';
|
||||
const entries = Object.entries(vars)
|
||||
.filter(([, names]) => Array.isArray(names) && names.length)
|
||||
.map(([scope, names]) => `${scope}: ${names.slice(0, 40).join(', ')}`);
|
||||
return entries.join('\n');
|
||||
};
|
||||
|
||||
const formatSiblingScripts = (siblings) => {
|
||||
if (!Array.isArray(siblings) || !siblings.length) return '';
|
||||
return siblings
|
||||
.map((s) => {
|
||||
if (!s || !s.script || !s.script.trim()) return null;
|
||||
const code = s.script.slice(0, 400);
|
||||
return `// ${s.name || 'sibling'} (${s.type || 'script'})\n${code}${s.script.length > 400 ? '\n// …' : ''}`;
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join('\n\n');
|
||||
};
|
||||
|
||||
const buildUserPrompt = ({ prefix, suffix, scriptType, requestContext, variableNames, siblingScripts }) => {
|
||||
// Keep recent context in the prefix (last 80 lines) and a short forward window (30 lines).
|
||||
// Cloud LLMs can handle more, but trimming keeps latency low and the model focused.
|
||||
const trimmedPrefix = truncateLines(prefix || '', 80);
|
||||
const trimmedSuffix = truncateLinesFromStart(suffix || '', 30);
|
||||
|
||||
const sections = [];
|
||||
|
||||
const reqStr = formatRequestContext(requestContext);
|
||||
if (reqStr) sections.push(`### Request being scripted\n${reqStr}`);
|
||||
|
||||
const varStr = formatVariableNames(variableNames);
|
||||
if (varStr) sections.push(`### Available variable names (use these in bru.getEnvVar / bru.getVar / interpolate)\n${varStr}`);
|
||||
|
||||
const siblingStr = formatSiblingScripts(siblingScripts);
|
||||
if (siblingStr) sections.push(`### Sibling ${scriptType} scripts in the same collection (style reference)\n${siblingStr}`);
|
||||
|
||||
sections.push(`### Cursor\n\`\`\`javascript\n${trimmedPrefix}<CURSOR>${trimmedSuffix}\n\`\`\``);
|
||||
sections.push('Continue from <CURSOR>. Output only the insertion.');
|
||||
|
||||
return sections.join('\n\n');
|
||||
};
|
||||
|
||||
// Stop sequences for cloud chat models — cut suggestions once they wander
|
||||
// into prose or start a new top-level construct.
|
||||
const STOP_SEQUENCES = ['```', '\n\ntest(', '\n\n// User:', '\n\nAssistant:'];
|
||||
|
||||
// Strip leftover fences, language tags, and quote/prose preambles a model may
|
||||
// emit despite the prompt. Never collapses internal whitespace — keeps indent.
|
||||
const cleanSuggestion = (raw) => {
|
||||
if (!raw) return '';
|
||||
let out = raw;
|
||||
// Drop leading fences and language tag
|
||||
out = out.replace(/^```[\w-]*\n?/, '');
|
||||
// Drop trailing fence
|
||||
out = out.replace(/\n?```\s*$/, '');
|
||||
// Drop "Here is..." style preambles on a single leading line
|
||||
out = out.replace(/^(?:Here(?:'s| is| are)[^\n]*\n)/i, '');
|
||||
// If model echoed the <CURSOR> marker, keep only what comes after it
|
||||
const cursorIdx = out.indexOf('<CURSOR>');
|
||||
if (cursorIdx >= 0) out = out.slice(cursorIdx + '<CURSOR>'.length);
|
||||
return out;
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
buildSystemPrompt,
|
||||
buildUserPrompt,
|
||||
STOP_SEQUENCES,
|
||||
cleanSuggestion
|
||||
};
|
||||
BIN
packages/bruno-electron/src/ipc/ai/autocomplete.js
Normal file
BIN
packages/bruno-electron/src/ipc/ai/autocomplete.js
Normal file
Binary file not shown.
@@ -80,7 +80,12 @@ const defaultPreferences = {
|
||||
anthropic: { enabled: false }
|
||||
},
|
||||
models: {},
|
||||
defaultModel: ''
|
||||
defaultModel: '',
|
||||
autocomplete: {
|
||||
enabled: true,
|
||||
model: '',
|
||||
triggerMode: 'debounced'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -157,7 +162,12 @@ const preferencesSchema = Yup.object().shape({
|
||||
enabled: Yup.boolean(),
|
||||
providers: Yup.object().optional(),
|
||||
models: Yup.object().optional(),
|
||||
defaultModel: Yup.string().max(200).nullable()
|
||||
defaultModel: Yup.string().max(200).nullable(),
|
||||
autocomplete: Yup.object({
|
||||
enabled: Yup.boolean(),
|
||||
model: Yup.string().max(200).nullable(),
|
||||
triggerMode: Yup.string().oneOf(['aggressive', 'debounced', 'manual']).nullable()
|
||||
}).optional()
|
||||
}).optional()
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user