mirror of
https://github.com/usebruno/bruno.git
synced 2026-06-30 16:14:06 +00:00
feat(ai): Improve ai context (#8404)
This commit is contained in:
@@ -59,7 +59,7 @@ const PREVIEW_LABELS = {
|
||||
|
||||
const isValidType = (t) => SUGGESTIONS[t] !== undefined;
|
||||
|
||||
const AIAssist = ({ scriptType, currentScript, requestContext, docsContext, onApply }) => {
|
||||
const AIAssist = ({ scriptType, currentScript, requestContext, docsContext, variables, onApply }) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [prompt, setPrompt] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
@@ -114,7 +114,8 @@ const AIAssist = ({ scriptType, currentScript, requestContext, docsContext, onAp
|
||||
prompt: text,
|
||||
currentScript: currentScript || '',
|
||||
requestContext,
|
||||
docsContext
|
||||
docsContext,
|
||||
variables
|
||||
});
|
||||
if (result?.error) {
|
||||
setError(result.error);
|
||||
@@ -131,7 +132,7 @@ const AIAssist = ({ scriptType, currentScript, requestContext, docsContext, onAp
|
||||
setIsLoading(false);
|
||||
}
|
||||
},
|
||||
[prompt, isLoading, scriptType, currentScript, requestContext, docsContext]
|
||||
[prompt, isLoading, scriptType, currentScript, requestContext, docsContext, variables]
|
||||
);
|
||||
|
||||
const handleApply = useCallback(() => {
|
||||
|
||||
@@ -44,7 +44,7 @@ import {
|
||||
updateCollectionDocs
|
||||
} from 'providers/ReduxStore/slices/collections';
|
||||
import { findItemInCollection, isItemAFolder, isItemARequest } from 'utils/collections';
|
||||
import { getAiStatus } from 'utils/ai';
|
||||
import { buildAiVariablesPayload, getAiStatus } from 'utils/ai';
|
||||
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import DiffView from './DiffView';
|
||||
@@ -362,6 +362,16 @@ const AiChatSidebar = ({ collection }) => {
|
||||
};
|
||||
}, [aiContext]);
|
||||
|
||||
// Variables payload is collection-scoped — works for request, folder, and
|
||||
// collection chats alike. Each entry is { name, value, scope, secret }; the
|
||||
// model gets a name-only preview in the prompt and can call search_variables
|
||||
// to fetch values (secrets come back redacted).
|
||||
const aiVariables = useMemo(() => {
|
||||
if (aiContext?.kind === 'request') return buildAiVariablesPayload(collection, aiContext.item);
|
||||
if (aiContext?.kind === 'folder') return buildAiVariablesPayload(collection, aiContext.folder);
|
||||
return buildAiVariablesPayload(collection, null);
|
||||
}, [collection, aiContext]);
|
||||
|
||||
const chatsWithMessages = useMemo(() => {
|
||||
if (!collection) return [];
|
||||
return Object.entries(allChats)
|
||||
@@ -438,7 +448,7 @@ const AiChatSidebar = ({ collection }) => {
|
||||
if (textareaRef.current) textareaRef.current.style.height = 'auto';
|
||||
|
||||
try {
|
||||
await dispatch(sendAiMessage(activeTabUid, text, allContent, requestContext, selectedModel, contentType));
|
||||
await dispatch(sendAiMessage(activeTabUid, text, allContent, requestContext, selectedModel, contentType, aiVariables));
|
||||
setProcessingStage('applying');
|
||||
setTimeout(() => setProcessingStage(null), 500);
|
||||
} catch (err) {
|
||||
|
||||
@@ -23,7 +23,7 @@ import { saveRequest } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
import CodeEditor from 'components/CodeEditor';
|
||||
import AIAssist from 'components/AIAssist';
|
||||
import { buildDocsContextFromCollection } from 'utils/ai';
|
||||
import { buildAiVariablesPayload, buildDocsContextFromCollection } from 'utils/ai';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import EmptyAppState from '../AppView/EmptyAppState';
|
||||
import {
|
||||
@@ -190,6 +190,7 @@ const CollectionApp = ({ item, collection }) => {
|
||||
[collection?.name, collection?.pathname]
|
||||
);
|
||||
const docsContext = useMemo(() => buildDocsContextFromCollection(collection), [collection]);
|
||||
const aiVariables = useMemo(() => buildAiVariablesPayload(collection, null), [collection]);
|
||||
|
||||
const onEdit = useCallback(
|
||||
(value) => dispatch(updateAppCode({ code: value, itemUid: item.uid, collectionUid: collection.uid })),
|
||||
@@ -366,6 +367,7 @@ const CollectionApp = ({ item, collection }) => {
|
||||
scriptType="app-collection"
|
||||
currentScript={code || ''}
|
||||
docsContext={docsContext}
|
||||
variables={aiVariables}
|
||||
onApply={onEdit}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -10,7 +10,7 @@ import { saveCollectionSettings } from 'providers/ReduxStore/slices/collections/
|
||||
import Markdown from 'components/MarkDown';
|
||||
import CodeEditor from 'components/CodeEditor';
|
||||
import AIAssist from 'components/AIAssist';
|
||||
import { buildDocsContextFromCollection } from 'utils/ai';
|
||||
import { buildAiVariablesPayload, buildDocsContextFromCollection } from 'utils/ai';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { IconEdit, IconX, IconFileText } from '@tabler/icons';
|
||||
import Button from 'ui/Button/index';
|
||||
@@ -28,6 +28,7 @@ const Docs = ({ collection }) => {
|
||||
const docs = collection.draft?.root ? get(collection, 'draft.root.docs', '') : get(collection, 'root.docs', '');
|
||||
const preferences = useSelector((state) => state.app.preferences);
|
||||
const docsContext = useMemo(() => buildDocsContextFromCollection(collection), [collection]);
|
||||
const aiVariables = useMemo(() => buildAiVariablesPayload(collection, null), [collection]);
|
||||
|
||||
// StyledWrapper has overflow-y: auto — use null selector.
|
||||
// Preview mode: hook tracks wrapper scroll. Edit mode: CodeEditor's onScroll/initialScroll.
|
||||
@@ -101,7 +102,7 @@ const Docs = ({ collection }) => {
|
||||
initialScroll={scroll}
|
||||
onScroll={setScroll}
|
||||
/>
|
||||
<AIAssist scriptType="docs" currentScript={docs || ''} docsContext={docsContext} onApply={onEdit} />
|
||||
<AIAssist scriptType="docs" currentScript={docs || ''} docsContext={docsContext} variables={aiVariables} onApply={onEdit} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="pl-1">
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import React, { useEffect, useMemo, useRef } from 'react';
|
||||
import get from 'lodash/get';
|
||||
import find from 'lodash/find';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import CodeEditor from 'components/CodeEditor';
|
||||
import AIAssist from 'components/AIAssist';
|
||||
import { buildAiVariablesPayload } from 'utils/ai';
|
||||
import { updateCollectionRequestScript, updateCollectionResponseScript } from 'providers/ReduxStore/slices/collections';
|
||||
import { saveCollectionSettings } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { updateScriptPaneTab } from 'providers/ReduxStore/slices/tabs';
|
||||
@@ -101,6 +102,8 @@ const Script = ({ collection }) => {
|
||||
const hasPreRequestScriptError = items.some((i) => isItemARequest(i) && i.preRequestScriptErrorMessage);
|
||||
const hasPostResponseScriptError = items.some((i) => isItemARequest(i) && i.postResponseScriptErrorMessage);
|
||||
|
||||
const aiVariables = useMemo(() => buildAiVariablesPayload(collection, null), [collection]);
|
||||
|
||||
return (
|
||||
<StyledWrapper className="w-full flex flex-col h-full">
|
||||
<div className="text-xs mb-4 text-muted">
|
||||
@@ -144,6 +147,7 @@ const Script = ({ collection }) => {
|
||||
<AIAssist
|
||||
scriptType="pre-request"
|
||||
currentScript={requestScript || ''}
|
||||
variables={aiVariables}
|
||||
onApply={onRequestScriptEdit}
|
||||
/>
|
||||
</div>
|
||||
@@ -170,6 +174,7 @@ const Script = ({ collection }) => {
|
||||
<AIAssist
|
||||
scriptType="post-response"
|
||||
currentScript={responseScript || ''}
|
||||
variables={aiVariables}
|
||||
onApply={onResponseScriptEdit}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import React, { useRef } from 'react';
|
||||
import React, { useMemo, useRef } from 'react';
|
||||
import get from 'lodash/get';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import CodeEditor from 'components/CodeEditor';
|
||||
import AIAssist from 'components/AIAssist';
|
||||
import { buildAiVariablesPayload } from 'utils/ai';
|
||||
import { updateCollectionTests } from 'providers/ReduxStore/slices/collections';
|
||||
import { saveCollectionSettings } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
@@ -37,6 +38,8 @@ const Tests = ({ collection }) => {
|
||||
scriptPhase: 'test'
|
||||
});
|
||||
|
||||
const aiVariables = useMemo(() => buildAiVariablesPayload(collection, null), [collection]);
|
||||
|
||||
return (
|
||||
<StyledWrapper className="w-full flex flex-col h-full">
|
||||
<div className="text-xs mb-4 text-muted">These tests will run any time a request in this collection is sent.</div>
|
||||
@@ -56,7 +59,7 @@ const Tests = ({ collection }) => {
|
||||
initialScroll={testsScroll}
|
||||
onScroll={setTestsScroll}
|
||||
/>
|
||||
<AIAssist scriptType="tests" currentScript={tests || ''} onApply={onEdit} />
|
||||
<AIAssist scriptType="tests" currentScript={tests || ''} variables={aiVariables} onApply={onEdit} />
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
|
||||
@@ -10,7 +10,7 @@ import { saveRequest } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import Markdown from 'components/MarkDown';
|
||||
import CodeEditor from 'components/CodeEditor';
|
||||
import AIAssist from 'components/AIAssist';
|
||||
import { buildRequestContextFromItem } from 'utils/ai';
|
||||
import { buildAiContextPayload } from 'utils/ai';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { usePersistedState } from 'hooks/usePersistedState';
|
||||
import { useTrackScroll } from 'hooks/useTrackScroll';
|
||||
@@ -44,7 +44,10 @@ const Documentation = ({ item, collection }) => {
|
||||
};
|
||||
|
||||
const onSave = () => dispatch(saveRequest(item.uid, collection.uid));
|
||||
const requestContext = useMemo(() => buildRequestContextFromItem(item), [item]);
|
||||
const { requestContext, variables: aiVariables } = useMemo(
|
||||
() => buildAiContextPayload(item, collection),
|
||||
[item, collection]
|
||||
);
|
||||
|
||||
if (!item) {
|
||||
return null;
|
||||
@@ -74,6 +77,7 @@ const Documentation = ({ item, collection }) => {
|
||||
scriptType="docs"
|
||||
currentScript={docs || ''}
|
||||
requestContext={requestContext}
|
||||
variables={aiVariables}
|
||||
onApply={onEdit}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -10,7 +10,7 @@ import { saveFolderRoot } from 'providers/ReduxStore/slices/collections/actions'
|
||||
import Markdown from 'components/MarkDown';
|
||||
import CodeEditor from 'components/CodeEditor';
|
||||
import AIAssist from 'components/AIAssist';
|
||||
import { buildDocsContextFromFolder } from 'utils/ai';
|
||||
import { buildAiVariablesPayload, buildDocsContextFromFolder } from 'utils/ai';
|
||||
import Button from 'ui/Button';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { usePersistedState } from 'hooks/usePersistedState';
|
||||
@@ -46,6 +46,7 @@ const Documentation = ({ collection, folder }) => {
|
||||
|
||||
const onSave = () => dispatch(saveFolderRoot(collection.uid, folder.uid));
|
||||
const docsContext = useMemo(() => buildDocsContextFromFolder(collection, folder), [collection, folder]);
|
||||
const aiVariables = useMemo(() => buildAiVariablesPayload(collection, folder), [collection, folder]);
|
||||
|
||||
if (!folder) {
|
||||
return null;
|
||||
@@ -72,7 +73,7 @@ const Documentation = ({ collection, folder }) => {
|
||||
initialScroll={scroll}
|
||||
onScroll={setScroll}
|
||||
/>
|
||||
<AIAssist scriptType="docs" currentScript={docs || ''} docsContext={docsContext} onApply={onEdit} />
|
||||
<AIAssist scriptType="docs" currentScript={docs || ''} docsContext={docsContext} variables={aiVariables} onApply={onEdit} />
|
||||
</div>
|
||||
<div className="mt-6 flex-shrink-0">
|
||||
<Button type="submit" size="sm" onClick={onSave}>
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import React, { useEffect, useMemo, useRef } from 'react';
|
||||
import get from 'lodash/get';
|
||||
import find from 'lodash/find';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import CodeEditor from 'components/CodeEditor';
|
||||
import AIAssist from 'components/AIAssist';
|
||||
import { buildAiVariablesPayload } from 'utils/ai';
|
||||
import { updateFolderRequestScript, updateFolderResponseScript } from 'providers/ReduxStore/slices/collections';
|
||||
import { saveFolderRoot } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { updateScriptPaneTab } from 'providers/ReduxStore/slices/tabs';
|
||||
@@ -102,6 +103,8 @@ const Script = ({ collection, folder }) => {
|
||||
dispatch(saveFolderRoot(collection.uid, folder.uid));
|
||||
};
|
||||
|
||||
const aiVariables = useMemo(() => buildAiVariablesPayload(collection, folder), [collection, folder]);
|
||||
|
||||
const items = flattenItems(folder.items || []);
|
||||
const hasPreRequestScriptError = items.some((i) => isItemARequest(i) && i.preRequestScriptErrorMessage);
|
||||
const hasPostResponseScriptError = items.some((i) => isItemARequest(i) && i.postResponseScriptErrorMessage);
|
||||
@@ -149,6 +152,7 @@ const Script = ({ collection, folder }) => {
|
||||
<AIAssist
|
||||
scriptType="pre-request"
|
||||
currentScript={requestScript || ''}
|
||||
variables={aiVariables}
|
||||
onApply={onRequestScriptEdit}
|
||||
/>
|
||||
</div>
|
||||
@@ -175,6 +179,7 @@ const Script = ({ collection, folder }) => {
|
||||
<AIAssist
|
||||
scriptType="post-response"
|
||||
currentScript={responseScript || ''}
|
||||
variables={aiVariables}
|
||||
onApply={onResponseScriptEdit}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import React, { useRef } from 'react';
|
||||
import React, { useMemo, useRef } from 'react';
|
||||
import get from 'lodash/get';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import CodeEditor from 'components/CodeEditor';
|
||||
import AIAssist from 'components/AIAssist';
|
||||
import { buildAiVariablesPayload } from 'utils/ai';
|
||||
import { updateFolderTests } from 'providers/ReduxStore/slices/collections';
|
||||
import { saveFolderRoot } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
@@ -38,6 +39,8 @@ const Tests = ({ collection, folder }) => {
|
||||
scriptPhase: 'test'
|
||||
});
|
||||
|
||||
const aiVariables = useMemo(() => buildAiVariablesPayload(collection, folder), [collection, folder]);
|
||||
|
||||
return (
|
||||
<StyledWrapper className="w-full flex flex-col h-full">
|
||||
<div className="text-xs mb-4 text-muted">These tests will run any time a request in this collection is sent.</div>
|
||||
@@ -57,7 +60,7 @@ const Tests = ({ collection, folder }) => {
|
||||
initialScroll={testsScroll}
|
||||
onScroll={setTestsScroll}
|
||||
/>
|
||||
<AIAssist scriptType="tests" currentScript={tests || ''} onApply={onEdit} />
|
||||
<AIAssist scriptType="tests" currentScript={tests || ''} variables={aiVariables} onApply={onEdit} />
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useDispatch, useSelector } from 'react-redux';
|
||||
import CodeEditor from 'components/CodeEditor';
|
||||
import ToggleSwitch from 'components/ToggleSwitch';
|
||||
import AIAssist from 'components/AIAssist';
|
||||
import { buildRequestContextFromItem } from 'utils/ai';
|
||||
import { buildAiContextPayload } from 'utils/ai';
|
||||
import { updateAppCode, toggleAppMode } from 'providers/ReduxStore/slices/collections';
|
||||
import { saveRequest } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
@@ -26,7 +26,10 @@ const AppCodeEditor = ({ item, collection }) => {
|
||||
|
||||
const onSave = () => dispatch(saveRequest(item.uid, collection.uid));
|
||||
|
||||
const requestContext = useMemo(() => buildRequestContextFromItem(item), [item]);
|
||||
const { requestContext, variables: aiVariables } = useMemo(
|
||||
() => buildAiContextPayload(item, collection),
|
||||
[item, collection]
|
||||
);
|
||||
|
||||
return (
|
||||
<StyledWrapper className="w-full h-full flex flex-col">
|
||||
@@ -55,6 +58,7 @@ const AppCodeEditor = ({ item, collection }) => {
|
||||
scriptType="app-request"
|
||||
currentScript={code || ''}
|
||||
requestContext={requestContext}
|
||||
variables={aiVariables}
|
||||
onApply={onEdit}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -4,7 +4,7 @@ import find from 'lodash/find';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import CodeEditor from 'components/CodeEditor';
|
||||
import AIAssist from 'components/AIAssist';
|
||||
import { buildRequestContextFromItem } from 'utils/ai';
|
||||
import { buildAiContextPayload } from 'utils/ai';
|
||||
import { updateRequestScript, updateResponseScript } from 'providers/ReduxStore/slices/collections';
|
||||
import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { updateScriptPaneTab } from 'providers/ReduxStore/slices/tabs';
|
||||
@@ -95,7 +95,10 @@ const Script = ({ item, collection }) => {
|
||||
const onRun = () => dispatch(sendRequest(item, collection.uid));
|
||||
const onSave = () => dispatch(saveRequest(item.uid, collection.uid));
|
||||
|
||||
const requestContext = useMemo(() => buildRequestContextFromItem(item), [item]);
|
||||
const { requestContext, variables: aiVariables } = useMemo(
|
||||
() => buildAiContextPayload(item, collection),
|
||||
[item, collection]
|
||||
);
|
||||
|
||||
const hasPreRequestScript = requestScript && requestScript.trim().length > 0;
|
||||
const hasPostResponseScript = responseScript && responseScript.trim().length > 0;
|
||||
@@ -146,6 +149,7 @@ const Script = ({ item, collection }) => {
|
||||
scriptType="pre-request"
|
||||
currentScript={requestScript || ''}
|
||||
requestContext={requestContext}
|
||||
variables={aiVariables}
|
||||
onApply={onRequestScriptEdit}
|
||||
/>
|
||||
</div>
|
||||
@@ -175,6 +179,7 @@ const Script = ({ item, collection }) => {
|
||||
scriptType="post-response"
|
||||
currentScript={responseScript || ''}
|
||||
requestContext={requestContext}
|
||||
variables={aiVariables}
|
||||
onApply={onResponseScriptEdit}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,7 @@ import get from 'lodash/get';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import CodeEditor from 'components/CodeEditor';
|
||||
import AIAssist from 'components/AIAssist';
|
||||
import { buildRequestContextFromItem } from 'utils/ai';
|
||||
import { buildAiContextPayload } from 'utils/ai';
|
||||
import { updateRequestTests } from 'providers/ReduxStore/slices/collections';
|
||||
import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
@@ -38,7 +38,10 @@ const Tests = ({ item, collection }) => {
|
||||
scriptPhase: 'test'
|
||||
});
|
||||
|
||||
const requestContext = useMemo(() => buildRequestContextFromItem(item), [item]);
|
||||
const { requestContext, variables: aiVariables } = useMemo(
|
||||
() => buildAiContextPayload(item, collection),
|
||||
[item, collection]
|
||||
);
|
||||
|
||||
return (
|
||||
<div data-testid="test-script-editor" className="relative h-full">
|
||||
@@ -60,7 +63,7 @@ const Tests = ({ item, collection }) => {
|
||||
initialScroll={testsScroll}
|
||||
onScroll={setTestsScroll}
|
||||
/>
|
||||
<AIAssist scriptType="tests" currentScript={tests || ''} requestContext={requestContext} onApply={onEdit} />
|
||||
<AIAssist scriptType="tests" currentScript={tests || ''} requestContext={requestContext} variables={aiVariables} onApply={onEdit} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -240,7 +240,8 @@ export const sendAiMessage = (
|
||||
allContent,
|
||||
requestContext,
|
||||
model,
|
||||
contentType = 'app'
|
||||
contentType = 'app',
|
||||
variables = []
|
||||
) => async (dispatch, getState) => {
|
||||
const { ipcRenderer } = window;
|
||||
|
||||
@@ -376,6 +377,7 @@ export const sendAiMessage = (
|
||||
allContent: normalizedContent,
|
||||
contentType,
|
||||
requestContext,
|
||||
variables,
|
||||
requestId,
|
||||
model
|
||||
});
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import get from 'lodash/get';
|
||||
import { callIpc } from 'utils/common/ipc';
|
||||
import {
|
||||
findEnvironmentInCollection,
|
||||
flattenItems,
|
||||
getAllVariables,
|
||||
getFormattedCollectionOauth2Credentials,
|
||||
isItemAFolder,
|
||||
isItemARequest,
|
||||
sortItemsBySidebarOrder
|
||||
@@ -44,6 +47,14 @@ export const cancelAiAutocomplete = (requestId) => {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Lean request context - method/url/headers/params/body. Kept for callers
|
||||
* that don't need the response (autocomplete + legacy sparkle sites).
|
||||
*
|
||||
* Sensitive header/param values are NOT stripped here — that happens in the
|
||||
* backend formatter via `maskValue`. The renderer ships them verbatim so the
|
||||
* mask logic stays in one place (packages/bruno-electron/src/ipc/ai/context.js).
|
||||
*/
|
||||
export const buildRequestContextFromItem = (item) => {
|
||||
if (!item) return null;
|
||||
const req = item.draft ? item.draft.request : item.request;
|
||||
@@ -58,6 +69,146 @@ export const buildRequestContextFromItem = (item) => {
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Extended request context for chat + generation: adds the request's docs
|
||||
* field and the last response. The response is redacted shape-only on the
|
||||
* backend before being formatted into the prompt.
|
||||
*/
|
||||
export const buildAiRequestContext = (item) => {
|
||||
if (!item) return null;
|
||||
const req = item.draft ? item.draft.request : item.request;
|
||||
if (!req) return null;
|
||||
|
||||
return {
|
||||
url: req.url || '',
|
||||
method: req.method || 'GET',
|
||||
headers: Array.isArray(req.headers) ? req.headers : [],
|
||||
params: Array.isArray(req.params) ? req.params : [],
|
||||
body: req.body || null,
|
||||
docs: req.docs || null,
|
||||
responseStatus: get(item, 'response.status', null),
|
||||
responseData: get(item, 'response.data', null)
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Sensitive name patterns kept in sync with the backend (context.js). The
|
||||
* renderer uses these to redact secret values BEFORE sending over IPC so the
|
||||
* payload itself never carries them — a belt-and-suspenders measure on top
|
||||
* of the backend masking.
|
||||
*/
|
||||
const SENSITIVE_NAME_PATTERNS = [
|
||||
/api[_-]?key/i,
|
||||
// Catches refresh_token, id_token, csrfToken, plain TOKEN, etc. on top of
|
||||
// the specific access/auth-token forms below.
|
||||
/token/i,
|
||||
/access[_-]?token/i,
|
||||
/auth[_-]?token/i,
|
||||
/secret/i,
|
||||
/password/i,
|
||||
/^authorization$/i,
|
||||
/^cookie$/i
|
||||
];
|
||||
|
||||
const isSensitiveName = (name) => {
|
||||
if (!name) return false;
|
||||
return SENSITIVE_NAME_PATTERNS.some((re) => re.test(name));
|
||||
};
|
||||
|
||||
/**
|
||||
* Flat list of variables the model can search. Each entry:
|
||||
* { name, value, scope, secret }
|
||||
*
|
||||
* Values come from `getAllVariables()` so they match what `bru.*` returns at
|
||||
* runtime - important because the model's `search_variables` tool would
|
||||
* otherwise show a lower-precedence value for any name that's overridden by
|
||||
* a higher-precedence scope (e.g. a folder var hiding behind an env var).
|
||||
*
|
||||
* Scope + secret metadata is attached by walking each named source. A name
|
||||
* marked secret by ANY source stays secret in the output.
|
||||
*
|
||||
* - `secret: true` => value is replaced by `<redacted>` here, not sent in the
|
||||
* clear over IPC.
|
||||
* - The backend re-applies redaction in `formatVariableLine`, so even if a
|
||||
* secret slipped through here it wouldn't reach the provider.
|
||||
*/
|
||||
export const buildAiVariablesPayload = (collection, item) => {
|
||||
if (!collection) return [];
|
||||
|
||||
const REDACTED = '<redacted>';
|
||||
|
||||
// Authoritative values - same merge `bru.getEnvVar` / `bru.getVar` resolve.
|
||||
const resolved = getAllVariables(collection, item) || {};
|
||||
|
||||
// name -> { scope, secret } - last claim wins for scope (matches the spread
|
||||
// order in getAllVariables); secret is sticky-on once any source flags it.
|
||||
const meta = new Map();
|
||||
const claim = (name, scope, secret) => {
|
||||
if (!name) return;
|
||||
const existing = meta.get(name);
|
||||
const finalSecret = Boolean(secret) || Boolean(existing?.secret);
|
||||
meta.set(name, { scope, secret: finalSecret });
|
||||
};
|
||||
|
||||
// Global env - secrets tracked as a separate name list.
|
||||
const globalSecrets = new Set(collection.globalEnvSecrets || []);
|
||||
for (const name of Object.keys(collection.globalEnvironmentVariables || {})) {
|
||||
claim(name, 'global', globalSecrets.has(name));
|
||||
}
|
||||
|
||||
// Active environment - explicit `secret` flag per variable.
|
||||
const env = findEnvironmentInCollection(collection, collection.activeEnvironmentUid);
|
||||
if (env && Array.isArray(env.variables)) {
|
||||
for (const v of env.variables) {
|
||||
if (v?.name && v.enabled) claim(v.name, 'env', Boolean(v.secret));
|
||||
}
|
||||
}
|
||||
|
||||
// Runtime - set via bru.setVar() at runtime. No secret flag.
|
||||
for (const name of Object.keys(collection.runtimeVariables || {})) {
|
||||
claim(name, 'runtime', false);
|
||||
}
|
||||
|
||||
// OAuth2 credentials — always treat as secret. `getAllVariables` already
|
||||
// surfaces these via the same helper, so claiming here just stamps the
|
||||
// right scope/secret on names that would otherwise default to 'collection'.
|
||||
const oauth = getFormattedCollectionOauth2Credentials({ oauth2Credentials: collection?.oauth2Credentials });
|
||||
if (oauth) {
|
||||
for (const name of Object.keys(oauth)) {
|
||||
claim(name, 'oauth2', true);
|
||||
}
|
||||
}
|
||||
|
||||
const out = [];
|
||||
for (const name of Object.keys(resolved)) {
|
||||
if (name === 'pathParams' || name === 'maskedEnvVariables' || name === 'process') continue;
|
||||
const m = meta.get(name);
|
||||
// Default scope for names not claimed by any explicit source — these come
|
||||
// from collection/folder/request-level vars that don't carry a secret
|
||||
// flag of their own, so we rely on `isSensitiveName` to catch token-like
|
||||
// names by pattern.
|
||||
const scope = m?.scope || 'collection';
|
||||
const isSecret = Boolean(m?.secret) || isSensitiveName(name);
|
||||
const value = resolved[name];
|
||||
out.push({
|
||||
name,
|
||||
value: isSecret ? REDACTED : (value == null ? '' : String(value)),
|
||||
scope,
|
||||
secret: isSecret
|
||||
});
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
/**
|
||||
* Single entry point for chat + generation. Returns the same payload shape
|
||||
* for both so the backend formatters / tools behave identically.
|
||||
*/
|
||||
export const buildAiContextPayload = (item, collection) => ({
|
||||
requestContext: buildAiRequestContext(item),
|
||||
variables: collection ? buildAiVariablesPayload(collection, item) : []
|
||||
});
|
||||
|
||||
const summarizeDocsItems = (items = []) => {
|
||||
const folders = [];
|
||||
const requests = [];
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import {
|
||||
buildAiContextPayload,
|
||||
buildAiRequestContext,
|
||||
buildAiVariablesPayload,
|
||||
buildDocsContextFromCollection,
|
||||
buildDocsContextFromFolder,
|
||||
buildRequestContextFromItem
|
||||
@@ -115,4 +118,187 @@ describe('utils/ai', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildAiRequestContext', () => {
|
||||
it('includes docs and the last response on top of the lean request context', () => {
|
||||
const item = {
|
||||
request: {
|
||||
method: 'POST',
|
||||
url: '/widgets',
|
||||
headers: [{ name: 'X-Foo', value: 'bar', enabled: true }],
|
||||
params: [],
|
||||
body: { mode: 'json', json: '{}' },
|
||||
docs: 'Some docs'
|
||||
},
|
||||
response: { status: 201, data: { id: 'abc' } }
|
||||
};
|
||||
|
||||
expect(buildAiRequestContext(item)).toEqual({
|
||||
url: '/widgets',
|
||||
method: 'POST',
|
||||
headers: [{ name: 'X-Foo', value: 'bar', enabled: true }],
|
||||
params: [],
|
||||
body: { mode: 'json', json: '{}' },
|
||||
docs: 'Some docs',
|
||||
responseStatus: 201,
|
||||
responseData: { id: 'abc' }
|
||||
});
|
||||
});
|
||||
|
||||
it('returns null for an item with no request', () => {
|
||||
expect(buildAiRequestContext(null)).toBeNull();
|
||||
expect(buildAiRequestContext({})).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildAiVariablesPayload', () => {
|
||||
const variablesCollection = {
|
||||
activeEnvironmentUid: 'env-1',
|
||||
environments: [
|
||||
{
|
||||
uid: 'env-1',
|
||||
variables: [
|
||||
{ name: 'API_URL', value: 'https://x', enabled: true, secret: false },
|
||||
{ name: 'API_TOKEN', value: 'real-tok', enabled: true, secret: true },
|
||||
{ name: 'DISABLED', value: 'ignore', enabled: false, secret: false }
|
||||
]
|
||||
}
|
||||
],
|
||||
globalEnvironmentVariables: { GLOBAL_FOO: 'foo', GLOBAL_SECRET: 's' },
|
||||
globalEnvSecrets: ['GLOBAL_SECRET'],
|
||||
runtimeVariables: { runtimeKey: 'r1' }
|
||||
};
|
||||
|
||||
it('redacts secret env vars by the secret flag', () => {
|
||||
const result = buildAiVariablesPayload(variablesCollection, null);
|
||||
const token = result.find((v) => v.name === 'API_TOKEN');
|
||||
expect(token).toEqual({ name: 'API_TOKEN', value: '<redacted>', scope: 'env', secret: true });
|
||||
});
|
||||
|
||||
it('redacts global env vars listed in globalEnvSecrets', () => {
|
||||
const result = buildAiVariablesPayload(variablesCollection, null);
|
||||
const gs = result.find((v) => v.name === 'GLOBAL_SECRET');
|
||||
expect(gs).toEqual({ name: 'GLOBAL_SECRET', value: '<redacted>', scope: 'global', secret: true });
|
||||
});
|
||||
|
||||
it('drops disabled environment variables', () => {
|
||||
const result = buildAiVariablesPayload(variablesCollection, null);
|
||||
expect(result.find((v) => v.name === 'DISABLED')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('falls back to pattern-based redaction for names like *_token even without the secret flag', () => {
|
||||
const collectionWithRuntimeSecret = {
|
||||
activeEnvironmentUid: null,
|
||||
environments: [],
|
||||
globalEnvironmentVariables: {},
|
||||
globalEnvSecrets: [],
|
||||
runtimeVariables: { access_token: 'should-not-leak' }
|
||||
};
|
||||
const result = buildAiVariablesPayload(collectionWithRuntimeSecret, null);
|
||||
const v = result.find((x) => x.name === 'access_token');
|
||||
expect(v).toEqual({ name: 'access_token', value: '<redacted>', scope: 'runtime', secret: true });
|
||||
});
|
||||
|
||||
it('does not duplicate a name across scopes (env wins over global)', () => {
|
||||
const collectionWithCollision = {
|
||||
activeEnvironmentUid: 'env-1',
|
||||
environments: [
|
||||
{ uid: 'env-1', variables: [{ name: 'SHARED', value: 'env-val', enabled: true, secret: false }] }
|
||||
],
|
||||
globalEnvironmentVariables: { SHARED: 'global-val' },
|
||||
globalEnvSecrets: [],
|
||||
runtimeVariables: {}
|
||||
};
|
||||
const result = buildAiVariablesPayload(collectionWithCollision, null);
|
||||
const shared = result.filter((v) => v.name === 'SHARED');
|
||||
expect(shared).toHaveLength(1);
|
||||
expect(shared[0]).toEqual({ name: 'SHARED', value: 'env-val', scope: 'env', secret: false });
|
||||
});
|
||||
|
||||
it('lets runtime override env when both define the same name', () => {
|
||||
const collectionWithOverride = {
|
||||
activeEnvironmentUid: 'env-1',
|
||||
environments: [
|
||||
{ uid: 'env-1', variables: [{ name: 'API_URL', value: 'env-url', enabled: true, secret: false }] }
|
||||
],
|
||||
globalEnvironmentVariables: {},
|
||||
globalEnvSecrets: [],
|
||||
runtimeVariables: { API_URL: 'runtime-url' }
|
||||
};
|
||||
const result = buildAiVariablesPayload(collectionWithOverride, null);
|
||||
const shared = result.filter((v) => v.name === 'API_URL');
|
||||
expect(shared).toHaveLength(1);
|
||||
expect(shared[0]).toEqual({ name: 'API_URL', value: 'runtime-url', scope: 'runtime', secret: false });
|
||||
});
|
||||
|
||||
it('keeps secret stickiness across scopes — env-secret value stays redacted even if runtime overrides', () => {
|
||||
// If env declares API_TOKEN as secret and runtime overrides it, the
|
||||
// runtime value should still be redacted.
|
||||
const collectionWithSecretOverride = {
|
||||
activeEnvironmentUid: 'env-1',
|
||||
environments: [
|
||||
{ uid: 'env-1', variables: [{ name: 'API_TOKEN', value: 'env-tok', enabled: true, secret: true }] }
|
||||
],
|
||||
globalEnvironmentVariables: {},
|
||||
globalEnvSecrets: [],
|
||||
runtimeVariables: { API_TOKEN: 'runtime-tok' }
|
||||
};
|
||||
const result = buildAiVariablesPayload(collectionWithSecretOverride, null);
|
||||
const tok = result.find((v) => v.name === 'API_TOKEN');
|
||||
// scope tracks the source the user would actually hit; the value stays
|
||||
// redacted because env marked the name secret.
|
||||
expect(tok.value).toBe('<redacted>');
|
||||
expect(tok.secret).toBe(true);
|
||||
});
|
||||
|
||||
it('redacts OAuth2 credentials with scope = oauth2', () => {
|
||||
const collectionWithOauth = {
|
||||
activeEnvironmentUid: null,
|
||||
environments: [],
|
||||
globalEnvironmentVariables: {},
|
||||
globalEnvSecrets: [],
|
||||
runtimeVariables: {},
|
||||
oauth2Credentials: [
|
||||
{
|
||||
credentialsId: 'github',
|
||||
credentials: { access_token: 'real-tok', token_type: 'Bearer' }
|
||||
}
|
||||
]
|
||||
};
|
||||
const result = buildAiVariablesPayload(collectionWithOauth, null);
|
||||
const tok = result.find((v) => v.name === '$oauth2.github.access_token');
|
||||
expect(tok).toBeDefined();
|
||||
expect(tok.value).toBe('<redacted>');
|
||||
expect(tok.secret).toBe(true);
|
||||
expect(result.some((v) => v.name === '$oauth2.github.access_token' && v.scope === 'collection' && !v.secret)).toBe(false);
|
||||
});
|
||||
|
||||
it('redacts generic token names like refresh_token / id_token / TOKEN by pattern', () => {
|
||||
const collectionWithVariousTokens = {
|
||||
activeEnvironmentUid: null,
|
||||
environments: [],
|
||||
globalEnvironmentVariables: {},
|
||||
globalEnvSecrets: [],
|
||||
runtimeVariables: { refresh_token: 'r', id_token: 'i', csrfToken: 'c', TOKEN: 't' }
|
||||
};
|
||||
const result = buildAiVariablesPayload(collectionWithVariousTokens, null);
|
||||
for (const name of ['refresh_token', 'id_token', 'csrfToken', 'TOKEN']) {
|
||||
const v = result.find((x) => x.name === name);
|
||||
expect(v).toBeDefined();
|
||||
expect(v.value).toBe('<redacted>');
|
||||
expect(v.secret).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('returns an empty array when no collection is supplied', () => {
|
||||
expect(buildAiVariablesPayload(null, null)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildAiContextPayload', () => {
|
||||
it('combines request context and variables into a single payload', () => {
|
||||
const result = buildAiContextPayload(null, null);
|
||||
expect(result).toEqual({ requestContext: null, variables: [] });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -141,6 +141,7 @@ This means:
|
||||
- read_content(type): reads a section. type ∈ { 'app', 'tests', 'pre-request', 'post-response', 'docs' }. MUST be called before write_content for the same type.
|
||||
- write_content(type, content): writes complete new content. The content must be the ENTIRE file, not a diff. read_content must be called first for the same type.
|
||||
- read_response(): returns the redacted shape (keys + types) of the last response body. No parameters. Use it to learn paths and types — not to read actual values.
|
||||
- search_variables(query?): search environment / collection / global / runtime variables by name (case-insensitive substring). Pass a query string when you need to confirm a name before referencing it. Values come back redacted for secrets — never hard-code a returned value. Each result has a \`scope\` field — use it to pick the right runtime accessor: \`bru.getEnvVar\` for \`env\`, \`bru.getGlobalEnvVar\` for \`global\`, \`bru.getCollectionVar\` / \`bru.getFolderVar\` / \`bru.getRequestVar\` for \`collection\`, \`bru.getVar\` for \`runtime\`, and \`bru.getSecretVar\` for any value that came back redacted. Use this when the inline variables list is truncated.
|
||||
|
||||
### Rules
|
||||
- ALWAYS call read_content before write_content for the same type
|
||||
@@ -166,7 +167,8 @@ const TOOL_LABELS = {
|
||||
'post-response': 'Writing post-response script',
|
||||
'docs': 'Writing documentation'
|
||||
},
|
||||
read_response: { default: 'Reading response data' }
|
||||
read_response: { default: 'Reading response data' },
|
||||
search_variables: { default: 'Searching variables' }
|
||||
};
|
||||
|
||||
const buildSystemPrompt = (contentType, hasMultipleContent) => {
|
||||
|
||||
@@ -2,6 +2,13 @@ const { ipcMain } = require('electron');
|
||||
const { streamText, stepCountIs } = require('ai');
|
||||
const { z } = require('zod');
|
||||
const { CONTENT_TYPES, TOOL_LABELS, buildSystemPrompt, resolveContentType } = require('./chat-prompts');
|
||||
const {
|
||||
formatRequestContext,
|
||||
formatResponseShape,
|
||||
formatVariablesList,
|
||||
searchVariables,
|
||||
formatSearchVariablesResult
|
||||
} = require('./context');
|
||||
|
||||
const activeStreams = new Map();
|
||||
|
||||
@@ -13,159 +20,18 @@ const CONTENT_LABELS = {
|
||||
'docs': 'Documentation'
|
||||
};
|
||||
|
||||
// Replace every primitive value with a type-name placeholder so the model
|
||||
// sees the response *shape* without any real data. Customer responses can
|
||||
// contain PII / secrets / tokens — we keep keys, types, and array structure
|
||||
// intact so the AI can write correct property paths and assertions, but
|
||||
// strip the values themselves. The AI is told these are placeholders so it
|
||||
// doesn't hard-code them into generated code.
|
||||
const REDACTED_TRUNCATED = '<truncated>';
|
||||
const REDACTED_NULL = '<null>';
|
||||
const REDACTED_BY_TYPE = {
|
||||
string: '<string>',
|
||||
number: '<number>',
|
||||
boolean: '<boolean>',
|
||||
bigint: '<bigint>'
|
||||
};
|
||||
|
||||
const redactResponseValues = (data, depth = 0, maxDepth = 6) => {
|
||||
if (data === null) return REDACTED_NULL;
|
||||
if (data === undefined) return REDACTED_NULL;
|
||||
if (depth >= maxDepth) return REDACTED_TRUNCATED;
|
||||
|
||||
if (Array.isArray(data)) {
|
||||
if (data.length === 0) return [];
|
||||
// Cap sample size — long arrays only need a few items to convey shape.
|
||||
const sampleSize = Math.min(data.length, 3);
|
||||
const out = data.slice(0, sampleSize).map((item) => redactResponseValues(item, depth + 1, maxDepth));
|
||||
if (data.length > sampleSize) out.push(`<${data.length - sampleSize} more items>`);
|
||||
return out;
|
||||
}
|
||||
|
||||
if (typeof data === 'object') {
|
||||
const keys = Object.keys(data);
|
||||
const out = {};
|
||||
for (const key of keys.slice(0, 30)) {
|
||||
out[key] = redactResponseValues(data[key], depth + 1, maxDepth);
|
||||
}
|
||||
if (keys.length > 30) out['...'] = `<${keys.length - 30} more keys>`;
|
||||
return out;
|
||||
}
|
||||
|
||||
return REDACTED_BY_TYPE[typeof data] || '<unknown>';
|
||||
};
|
||||
|
||||
const REDACTION_NOTICE
|
||||
= 'Values are placeholders (`<string>`, `<number>`, …). The shape, keys, and types are accurate but no real data is shown. Reference fields by path in generated code — do not hard-code these placeholders as literal values.';
|
||||
|
||||
const SENSITIVE_HEADER_PATTERNS = [
|
||||
/^authorization$/i,
|
||||
/^proxy-authorization$/i,
|
||||
/^cookie$/i,
|
||||
/^set-cookie$/i,
|
||||
/^x-api-key$/i,
|
||||
/^x-auth-token$/i,
|
||||
/^x-access-token$/i,
|
||||
/^x-csrf-token$/i,
|
||||
/api[_-]?key/i,
|
||||
/access[_-]?token/i,
|
||||
/auth[_-]?token/i,
|
||||
/secret/i,
|
||||
/password/i
|
||||
];
|
||||
|
||||
const isSensitiveName = (name) => {
|
||||
if (!name) return false;
|
||||
return SENSITIVE_HEADER_PATTERNS.some((re) => re.test(name));
|
||||
};
|
||||
|
||||
const maskValue = (name, value) => (isSensitiveName(name) ? '<redacted>' : value);
|
||||
|
||||
const formatRequestContext = (ctx) => {
|
||||
if (!ctx) return '';
|
||||
const buildContextMessage = (contentType, allContent, requestContext, variables) => {
|
||||
const parts = [];
|
||||
|
||||
if (ctx.url || ctx.method) {
|
||||
parts.push(`**Request:** ${ctx.method || 'GET'} ${ctx.url || ''}`);
|
||||
}
|
||||
|
||||
const headers = (ctx.headers || []).filter((h) => h.enabled);
|
||||
if (headers.length > 0) {
|
||||
parts.push(`**Headers:**\n${headers.map((h) => ` ${h.name}: ${maskValue(h.name, h.value)}`).join('\n')}`);
|
||||
}
|
||||
|
||||
const params = (ctx.params || []).filter((p) => p.enabled);
|
||||
const query = params.filter((p) => p.type === 'query');
|
||||
const pathParams = params.filter((p) => p.type === 'path');
|
||||
if (query.length > 0) {
|
||||
parts.push(`**Query Parameters:**\n${query.map((p) => ` ${p.name}: ${maskValue(p.name, p.value)}`).join('\n')}`);
|
||||
}
|
||||
if (pathParams.length > 0) {
|
||||
parts.push(`**Path Parameters:**\n${pathParams.map((p) => ` ${p.name}: ${maskValue(p.name, p.value)}`).join('\n')}`);
|
||||
}
|
||||
|
||||
if (ctx.body && ctx.body.mode && ctx.body.mode !== 'none') {
|
||||
let content = '';
|
||||
switch (ctx.body.mode) {
|
||||
case 'json': content = ctx.body.json || ''; break;
|
||||
case 'text': content = ctx.body.text || ''; break;
|
||||
case 'xml': content = ctx.body.xml || ''; break;
|
||||
case 'sparql': content = ctx.body.sparql || ''; break;
|
||||
case 'formUrlEncoded': {
|
||||
const items = (ctx.body.formUrlEncoded || []).filter((p) => p.enabled);
|
||||
content = items.map((p) => ` ${p.name}: ${maskValue(p.name, p.value)}`).join('\n');
|
||||
break;
|
||||
}
|
||||
case 'multipartForm': {
|
||||
const items = (ctx.body.multipartForm || []).filter((p) => p.enabled);
|
||||
content = items.map((p) => ` ${p.name}: ${p.type === 'file' ? '[file]' : maskValue(p.name, p.value)}`).join('\n');
|
||||
break;
|
||||
}
|
||||
case 'graphql':
|
||||
content = ctx.body.graphql?.query || '';
|
||||
if (ctx.body.graphql?.variables) {
|
||||
content += `\n\nVariables:\n${ctx.body.graphql.variables}`;
|
||||
}
|
||||
break;
|
||||
default:
|
||||
content = '';
|
||||
}
|
||||
if (content) {
|
||||
parts.push(`**Body (${ctx.body.mode}):**\n\`\`\`\n${content}\n\`\`\``);
|
||||
}
|
||||
}
|
||||
|
||||
if (ctx.responseStatus) {
|
||||
parts.push(`**Last Response Status:** ${ctx.responseStatus}`);
|
||||
}
|
||||
if (ctx.responseData) {
|
||||
try {
|
||||
const parsed = typeof ctx.responseData === 'string' ? JSON.parse(ctx.responseData) : ctx.responseData;
|
||||
const redacted = redactResponseValues(parsed);
|
||||
if (redacted != null) {
|
||||
parts.push(`**Response Shape (values redacted — ${REDACTION_NOTICE}):**\n\`\`\`json\n${JSON.stringify(redacted, null, 2)}\n\`\`\``);
|
||||
}
|
||||
} catch {
|
||||
if (typeof ctx.responseData === 'string' && ctx.responseData.trim()) {
|
||||
parts.push(`**Response:** non-JSON, ${ctx.responseData.length} chars (call read_response() for a redacted view)`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (ctx.docs && ctx.docs.trim()) {
|
||||
parts.push(`**Documentation:**\n${ctx.docs.trim()}`);
|
||||
}
|
||||
|
||||
return parts.join('\n\n');
|
||||
};
|
||||
|
||||
const buildContextMessage = (contentType, allContent, requestContext) => {
|
||||
const parts = [];
|
||||
const ctx = formatRequestContext(requestContext);
|
||||
const ctx = formatRequestContext(requestContext, { includeResponse: true });
|
||||
if (ctx) {
|
||||
parts.push(`HTTP Request Context:\n${ctx}`);
|
||||
}
|
||||
|
||||
const varsStr = formatVariablesList(variables);
|
||||
if (varsStr) {
|
||||
parts.push(`Available Variables (names only — call search_variables(query) for a value):\n${varsStr}`);
|
||||
}
|
||||
|
||||
const activeLabel = CONTENT_LABELS[contentType] || 'Code';
|
||||
const activeContent = allContent[contentType] || '';
|
||||
if (activeContent.trim()) {
|
||||
@@ -203,6 +69,12 @@ const WRITE_PARAMS = z.object({
|
||||
content: z.string().describe('The complete new content for the section.')
|
||||
});
|
||||
const READ_RESPONSE_PARAMS = z.object({});
|
||||
const SEARCH_VARS_PARAMS = z.object({
|
||||
query: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Substring to match against variable names (case-insensitive). Omit to list the first 50 variables.')
|
||||
});
|
||||
|
||||
const registerChatIpc = ({ mainWindow, resolveModel, pickDefaultModelId, isAiEnabled }) => {
|
||||
ipcMain.on('renderer:ai-chat-stop', (_event, { requestId } = {}) => {
|
||||
@@ -214,7 +86,7 @@ const registerChatIpc = ({ mainWindow, resolveModel, pickDefaultModelId, isAiEna
|
||||
});
|
||||
|
||||
ipcMain.on('renderer:ai-chat-stream', async (_event, payload) => {
|
||||
const { messages, allContent, contentType, requestContext, requestId, model: modelId } = payload || {};
|
||||
const { messages, allContent, contentType, requestContext, variables, requestId, model: modelId } = payload || {};
|
||||
|
||||
const send = (channel, data) => {
|
||||
if (mainWindow?.webContents && !mainWindow.webContents.isDestroyed()) {
|
||||
@@ -310,45 +182,28 @@ const registerChatIpc = ({ mainWindow, resolveModel, pickDefaultModelId, isAiEna
|
||||
execute: async () => {
|
||||
const status = requestContext?.responseStatus;
|
||||
const data = requestContext?.responseData;
|
||||
if (!status && !data) {
|
||||
if (!status && data == null) {
|
||||
return '(No response available — the request has not been executed yet. The user needs to run the request first.)';
|
||||
}
|
||||
|
||||
const parts = [];
|
||||
if (status) parts.push(`Status: ${status}`);
|
||||
|
||||
if (data !== undefined && data !== null) {
|
||||
// Try to parse JSON so we can redact structurally. Non-JSON
|
||||
// payloads only get a type/length summary, we won't echo their
|
||||
// contents either, since they may contain sensitive text.
|
||||
let parsed = data;
|
||||
let parsedOk = false;
|
||||
if (typeof data === 'string') {
|
||||
try {
|
||||
parsed = JSON.parse(data); parsedOk = true;
|
||||
} catch { parsedOk = false; }
|
||||
} else if (typeof data === 'object') {
|
||||
parsedOk = true;
|
||||
}
|
||||
|
||||
if (parsedOk) {
|
||||
const redacted = redactResponseValues(parsed);
|
||||
parts.push(`Response Body (redacted shape):\n\`\`\`json\n${JSON.stringify(redacted, null, 2)}\n\`\`\``);
|
||||
parts.push(`Note: ${REDACTION_NOTICE}`);
|
||||
} else if (typeof data === 'string') {
|
||||
parts.push(`Response Body: non-JSON text payload, ${data.length} chars (contents withheld for user privacy)`);
|
||||
} else {
|
||||
parts.push('Response Body: opaque value (contents withheld for user privacy)');
|
||||
}
|
||||
const formatted = formatResponseShape(status, data);
|
||||
return formatted || '(empty response)';
|
||||
}
|
||||
},
|
||||
search_variables: {
|
||||
description: 'Search environment / collection / global / runtime variables by name (case-insensitive substring match). Use this when the user has many variables or you need to confirm a name before referencing it in code. Values are returned, but variables marked `secret` (or whose names match patterns like `*_token`, `*_secret`, `password`, etc.) come back as `<redacted>`. Each result has a `scope` field — use it to pick the right runtime accessor: `bru.getEnvVar` for `env`, `bru.getGlobalEnvVar` for `global`, `bru.getCollectionVar` / `bru.getFolderVar` / `bru.getRequestVar` for `collection`, `bru.getVar` for `runtime`, and `bru.getSecretVar` for any value that came back redacted. Never hard-code a returned value.',
|
||||
inputSchema: SEARCH_VARS_PARAMS,
|
||||
execute: async ({ query }) => {
|
||||
if (!Array.isArray(variables) || variables.length === 0) {
|
||||
return '(No variables available — the collection has no environment, runtime, or collection variables defined.)';
|
||||
}
|
||||
|
||||
return parts.join('\n') || '(empty response)';
|
||||
const result = searchVariables(variables, query);
|
||||
return formatSearchVariablesResult(result, query);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const allMessages = [
|
||||
{ role: 'user', content: buildContextMessage(effectiveType, normalizedContent, requestContext) },
|
||||
{ role: 'user', content: buildContextMessage(effectiveType, normalizedContent, requestContext, variables) },
|
||||
...messages.map((m) => ({ role: m.role, content: m.content }))
|
||||
];
|
||||
|
||||
|
||||
350
packages/bruno-electron/src/ipc/ai/context.js
Normal file
350
packages/bruno-electron/src/ipc/ai/context.js
Normal file
@@ -0,0 +1,350 @@
|
||||
/**
|
||||
* Shared context formatting + redaction primitives used by every AI surface
|
||||
* (chat sidebar, script generation, autocomplete).
|
||||
*
|
||||
* Everything that goes to a provider passes through this module so the rules
|
||||
* (which header names are sensitive, how response bodies are stripped, how a
|
||||
* variable marked `secret: true` appears) stay consistent across surfaces.
|
||||
*/
|
||||
|
||||
// --- Sensitive header / param / variable names ---------------------------
|
||||
|
||||
const SENSITIVE_HEADER_PATTERNS = [
|
||||
/^authorization$/i,
|
||||
/^proxy-authorization$/i,
|
||||
/^cookie$/i,
|
||||
/^set-cookie$/i,
|
||||
/^x-api-key$/i,
|
||||
/^x-auth-token$/i,
|
||||
/^x-access-token$/i,
|
||||
/^x-csrf-token$/i,
|
||||
/api[_-]?key/i,
|
||||
// Catches refresh_token, id_token, csrfToken, plain TOKEN, etc. on top of
|
||||
// the specific access/auth-token forms above.
|
||||
/token/i,
|
||||
/access[_-]?token/i,
|
||||
/auth[_-]?token/i,
|
||||
/secret/i,
|
||||
/password/i
|
||||
];
|
||||
|
||||
const REDACTED_VALUE = '<redacted>';
|
||||
|
||||
const isSensitiveName = (name) => {
|
||||
if (!name) return false;
|
||||
return SENSITIVE_HEADER_PATTERNS.some((re) => re.test(name));
|
||||
};
|
||||
|
||||
const maskValue = (name, value) => (isSensitiveName(name) ? REDACTED_VALUE : value);
|
||||
|
||||
// --- Response body shape redaction ---------------------------------------
|
||||
|
||||
const REDACTED_TRUNCATED = '<truncated>';
|
||||
const REDACTED_NULL = '<null>';
|
||||
const REDACTED_BY_TYPE = {
|
||||
string: '<string>',
|
||||
number: '<number>',
|
||||
boolean: '<boolean>',
|
||||
bigint: '<bigint>'
|
||||
};
|
||||
|
||||
const REDACTION_NOTICE = 'Values are placeholders (`<string>`, `<number>`, …). The shape, keys, and types are accurate but no real data is shown. Reference fields by path in generated code — do not hard-code these placeholders as literal values.';
|
||||
|
||||
const redactResponseValues = (data, depth = 0, maxDepth = 6) => {
|
||||
if (data === null || data === undefined) return REDACTED_NULL;
|
||||
if (depth >= maxDepth) return REDACTED_TRUNCATED;
|
||||
|
||||
if (Array.isArray(data)) {
|
||||
if (data.length === 0) return [];
|
||||
// Cap sample size — long arrays only need a few items to convey shape.
|
||||
const sampleSize = Math.min(data.length, 3);
|
||||
const out = data.slice(0, sampleSize).map((item) => redactResponseValues(item, depth + 1, maxDepth));
|
||||
if (data.length > sampleSize) out.push(`<${data.length - sampleSize} more items>`);
|
||||
return out;
|
||||
}
|
||||
|
||||
if (typeof data === 'object') {
|
||||
const keys = Object.keys(data);
|
||||
const out = {};
|
||||
for (const key of keys.slice(0, 30)) {
|
||||
out[key] = redactResponseValues(data[key], depth + 1, maxDepth);
|
||||
}
|
||||
if (keys.length > 30) out['...'] = `<${keys.length - 30} more keys>`;
|
||||
return out;
|
||||
}
|
||||
|
||||
return REDACTED_BY_TYPE[typeof data] || '<unknown>';
|
||||
};
|
||||
|
||||
const formatResponseShape = (status, data) => {
|
||||
if (!status && data == null) return '';
|
||||
const parts = [];
|
||||
if (status) parts.push(`**Last Response Status:** ${status}`);
|
||||
|
||||
if (data != null) {
|
||||
let parsed = data;
|
||||
let parsedOk = false;
|
||||
if (typeof data === 'string') {
|
||||
try {
|
||||
parsed = JSON.parse(data); parsedOk = true;
|
||||
} catch { parsedOk = false; }
|
||||
} else if (typeof data === 'object') {
|
||||
parsedOk = true;
|
||||
}
|
||||
|
||||
if (parsedOk) {
|
||||
const redacted = redactResponseValues(parsed);
|
||||
if (redacted != null) {
|
||||
parts.push(`**Response Shape (values redacted — ${REDACTION_NOTICE}):**\n\`\`\`json\n${JSON.stringify(redacted, null, 2)}\n\`\`\``);
|
||||
}
|
||||
} else if (typeof data === 'string' && data.trim()) {
|
||||
parts.push(`**Response:** non-JSON, ${data.length} chars (call read_response() for the redacted view)`);
|
||||
}
|
||||
}
|
||||
|
||||
return parts.join('\n\n');
|
||||
};
|
||||
|
||||
/**
|
||||
* Walk a JSON-shaped value and replace primitive values whose KEY is sensitive
|
||||
* (`password`, `*_token`, `secret`, etc.) with `<redacted>`. Keeps the shape
|
||||
* intact so the model can still see the body's structure and field names.
|
||||
*
|
||||
* Differs from `redactResponseValues` (which replaces ALL primitives with
|
||||
* type placeholders): we DO want the model to see non-sensitive request body
|
||||
* values so it can write code that references them correctly.
|
||||
*/
|
||||
const redactJsonBodyValues = (data, depth = 0, maxDepth = 8) => {
|
||||
if (data === null || data === undefined) return data;
|
||||
if (depth >= maxDepth) return REDACTED_TRUNCATED;
|
||||
if (Array.isArray(data)) return data.map((item) => redactJsonBodyValues(item, depth + 1, maxDepth));
|
||||
if (typeof data === 'object') {
|
||||
const out = {};
|
||||
for (const key of Object.keys(data)) {
|
||||
const val = data[key];
|
||||
if (isSensitiveName(key) && val !== null && typeof val !== 'object') {
|
||||
out[key] = REDACTED_VALUE;
|
||||
} else {
|
||||
out[key] = redactJsonBodyValues(val, depth + 1, maxDepth);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
return data;
|
||||
};
|
||||
|
||||
const redactJsonBodyString = (raw) => {
|
||||
if (typeof raw !== 'string' || !raw.trim()) return raw || '';
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
return JSON.stringify(redactJsonBodyValues(parsed), null, 2);
|
||||
} catch {
|
||||
// Not parseable JSON — return as-is. The renderer-side patterns + variable
|
||||
// redaction are the main line of defense for arbitrary text bodies.
|
||||
return raw;
|
||||
}
|
||||
};
|
||||
|
||||
// --- Request context (method/url/headers/params/body/+response) ---------
|
||||
|
||||
/**
|
||||
* Format the renderer-supplied requestContext as Markdown for the model.
|
||||
*
|
||||
* @param {object} ctx { url, method, headers, params, body, docs,
|
||||
* responseStatus?, responseData? }
|
||||
* @param {object} opts
|
||||
* includeBody - include body (default true)
|
||||
* bodyMaxChars - truncate body to this many chars (default null = full)
|
||||
* includeResponse - inline the redacted response shape (default false)
|
||||
* includeDocs - include the request's docs field (default true)
|
||||
*/
|
||||
const formatRequestContext = (ctx, opts = {}) => {
|
||||
const {
|
||||
includeBody = true,
|
||||
bodyMaxChars = null,
|
||||
includeResponse = false,
|
||||
includeDocs = true
|
||||
} = opts;
|
||||
if (!ctx) return '';
|
||||
const parts = [];
|
||||
|
||||
if (ctx.url || ctx.method) {
|
||||
parts.push(`**Request:** ${ctx.method || 'GET'} ${ctx.url || ''}`);
|
||||
}
|
||||
|
||||
const headers = (ctx.headers || []).filter((h) => h?.enabled && h?.name);
|
||||
if (headers.length) {
|
||||
parts.push(`**Headers:**\n${headers.map((h) => ` ${h.name}: ${maskValue(h.name, h.value ?? '')}`).join('\n')}`);
|
||||
}
|
||||
|
||||
const params = (ctx.params || []).filter((p) => p?.enabled && p?.name);
|
||||
const query = params.filter((p) => p.type === 'query' || !p.type);
|
||||
const pathParams = params.filter((p) => p.type === 'path');
|
||||
if (query.length) {
|
||||
parts.push(`**Query Parameters:**\n${query.map((p) => ` ${p.name}: ${maskValue(p.name, p.value ?? '')}`).join('\n')}`);
|
||||
}
|
||||
if (pathParams.length) {
|
||||
parts.push(`**Path Parameters:**\n${pathParams.map((p) => ` ${p.name}: ${maskValue(p.name, p.value ?? '')}`).join('\n')}`);
|
||||
}
|
||||
|
||||
const body = ctx.body;
|
||||
if (includeBody && body && body.mode && body.mode !== 'none') {
|
||||
let content = '';
|
||||
switch (body.mode) {
|
||||
// JSON bodies often contain fields like `password`, `client_secret`,
|
||||
// `refresh_token`. Redact by key so the model sees the structure but
|
||||
// not the secret values.
|
||||
case 'json': content = redactJsonBodyString(body.json || ''); break;
|
||||
case 'text': content = body.text || ''; break;
|
||||
case 'xml': content = body.xml || ''; break;
|
||||
case 'sparql': content = body.sparql || ''; break;
|
||||
case 'formUrlEncoded': {
|
||||
const items = (body.formUrlEncoded || []).filter((p) => p.enabled);
|
||||
content = items.map((p) => ` ${p.name}: ${maskValue(p.name, p.value ?? '')}`).join('\n');
|
||||
break;
|
||||
}
|
||||
case 'multipartForm': {
|
||||
const items = (body.multipartForm || []).filter((p) => p.enabled);
|
||||
content = items.map((p) => ` ${p.name}: ${p.type === 'file' ? '[file]' : maskValue(p.name, p.value ?? '')}`).join('\n');
|
||||
break;
|
||||
}
|
||||
case 'graphql':
|
||||
content = body.graphql?.query || '';
|
||||
if (body.graphql?.variables) {
|
||||
// GraphQL variables are stored as a JSON string in Bruno — same
|
||||
// key-based redaction applies.
|
||||
content += `\n\nVariables:\n${redactJsonBodyString(body.graphql.variables)}`;
|
||||
}
|
||||
break;
|
||||
default: content = '';
|
||||
}
|
||||
if (content) {
|
||||
const truncate = bodyMaxChars && content.length > bodyMaxChars;
|
||||
const shown = truncate ? content.slice(0, bodyMaxChars) + '…' : content;
|
||||
parts.push(`**Body (${body.mode}):**\n\`\`\`\n${shown}\n\`\`\``);
|
||||
}
|
||||
}
|
||||
|
||||
if (includeResponse) {
|
||||
const responseStr = formatResponseShape(ctx.responseStatus, ctx.responseData);
|
||||
if (responseStr) parts.push(responseStr);
|
||||
}
|
||||
|
||||
if (includeDocs && ctx.docs && typeof ctx.docs === 'string' && ctx.docs.trim()) {
|
||||
parts.push(`**Documentation:**\n${ctx.docs.trim()}`);
|
||||
}
|
||||
|
||||
return parts.join('\n\n');
|
||||
};
|
||||
|
||||
// --- Variables (env / global / collection / folder / request / runtime) -
|
||||
|
||||
/**
|
||||
* Variable record shape (what the renderer sends over IPC):
|
||||
* { name: string, value: string | null, scope: string, secret: boolean }
|
||||
*
|
||||
* - `scope` is informational ('env', 'global', 'collection', 'folder',
|
||||
* 'request', 'runtime', 'process', 'oauth2', ...). Used only for the
|
||||
* short list shown to the model.
|
||||
* - `secret: true` means the value is omitted from the renderer's payload
|
||||
* too — never trust the value field on a secret. The backend re-masks.
|
||||
*/
|
||||
|
||||
const isSecretVariable = (v) => Boolean(v && (v.secret || isSensitiveName(v.name)));
|
||||
|
||||
const variableValueForModel = (v) => {
|
||||
if (!v) return '';
|
||||
if (isSecretVariable(v)) return REDACTED_VALUE;
|
||||
if (v.value == null) return '';
|
||||
return String(v.value);
|
||||
};
|
||||
|
||||
const VAR_NAMES_PREVIEW_PER_SCOPE = 25;
|
||||
|
||||
/**
|
||||
* Inline preview shown in the prompt. Names + a small per-scope sample so
|
||||
* the model knows what's available without us dumping 500 env vars.
|
||||
*/
|
||||
const formatVariablesList = (variables) => {
|
||||
if (!Array.isArray(variables) || !variables.length) return '';
|
||||
|
||||
const byScope = new Map();
|
||||
for (const v of variables) {
|
||||
if (!v || !v.name) continue;
|
||||
const scope = v.scope || 'unknown';
|
||||
if (!byScope.has(scope)) byScope.set(scope, []);
|
||||
byScope.get(scope).push(v);
|
||||
}
|
||||
|
||||
const lines = [];
|
||||
for (const [scope, list] of byScope.entries()) {
|
||||
const total = list.length;
|
||||
const preview = list.slice(0, VAR_NAMES_PREVIEW_PER_SCOPE);
|
||||
const more = total > preview.length ? ` (+${total - preview.length} more — use search_variables to find them)` : '';
|
||||
const names = preview
|
||||
.map((v) => (isSecretVariable(v) ? `${v.name} (secret)` : v.name))
|
||||
.join(', ');
|
||||
lines.push(`- ${scope} (${total}): ${names}${more}`);
|
||||
}
|
||||
return lines.join('\n');
|
||||
};
|
||||
|
||||
const SEARCH_LIMIT = 50;
|
||||
|
||||
/**
|
||||
* Returns `{ items, totalMatched, limit }`. `totalMatched` is the number of
|
||||
* variables that matched the query BEFORE truncation, so the caller can tell
|
||||
* the model when there are more matches than were returned.
|
||||
*/
|
||||
const searchVariables = (variables, rawQuery, limit = SEARCH_LIMIT) => {
|
||||
if (!Array.isArray(variables) || !variables.length) {
|
||||
return { items: [], totalMatched: 0, limit };
|
||||
}
|
||||
const query = String(rawQuery || '').toLowerCase().trim();
|
||||
const filtered = query
|
||||
? variables.filter((v) => v?.name && v.name.toLowerCase().includes(query))
|
||||
: variables.slice();
|
||||
return { items: filtered.slice(0, limit), totalMatched: filtered.length, limit };
|
||||
};
|
||||
|
||||
const formatVariableLine = (v) => {
|
||||
const value = variableValueForModel(v);
|
||||
const tags = [v.scope || 'unknown'];
|
||||
if (isSecretVariable(v)) tags.push('secret');
|
||||
return ` ${v.name} = ${value} [${tags.join(', ')}]`;
|
||||
};
|
||||
|
||||
const formatSearchVariablesResult = ({ items, totalMatched, limit }, query) => {
|
||||
if (!items.length) {
|
||||
return query
|
||||
? `No variables match "${query}".`
|
||||
: 'No variables defined for this collection/environment.';
|
||||
}
|
||||
const lines = items.map(formatVariableLine);
|
||||
const heading = query
|
||||
? `Found ${items.length}${totalMatched > items.length ? ` of ${totalMatched}` : ''} variable(s) matching "${query}":`
|
||||
: `Variables (${items.length}${totalMatched > items.length ? ` of ${totalMatched}` : ''}):`;
|
||||
const trailer = totalMatched > items.length
|
||||
? `\n\n(${totalMatched - items.length} more match — narrow the query to see them.)`
|
||||
: '';
|
||||
return `${heading}\n${lines.join('\n')}${trailer}`;
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
// patterns + helpers
|
||||
SENSITIVE_HEADER_PATTERNS,
|
||||
REDACTED_VALUE,
|
||||
REDACTION_NOTICE,
|
||||
isSensitiveName,
|
||||
maskValue,
|
||||
// response shape
|
||||
redactResponseValues,
|
||||
formatResponseShape,
|
||||
// request context
|
||||
formatRequestContext,
|
||||
// variables
|
||||
isSecretVariable,
|
||||
formatVariablesList,
|
||||
searchVariables,
|
||||
formatSearchVariablesResult
|
||||
};
|
||||
243
packages/bruno-electron/src/ipc/ai/context.spec.js
Normal file
243
packages/bruno-electron/src/ipc/ai/context.spec.js
Normal file
@@ -0,0 +1,243 @@
|
||||
const {
|
||||
isSensitiveName,
|
||||
maskValue,
|
||||
redactResponseValues,
|
||||
formatResponseShape,
|
||||
formatRequestContext,
|
||||
formatVariablesList,
|
||||
searchVariables,
|
||||
formatSearchVariablesResult
|
||||
} = require('./context');
|
||||
|
||||
describe('ipc/ai/context', () => {
|
||||
describe('isSensitiveName', () => {
|
||||
it.each([
|
||||
['Authorization'],
|
||||
['Cookie'],
|
||||
['X-API-Key'],
|
||||
['api_key'],
|
||||
['accessToken'],
|
||||
['refresh_token'],
|
||||
['id_token'],
|
||||
['csrfToken'],
|
||||
['TOKEN'],
|
||||
['client_secret'],
|
||||
['password']
|
||||
])('flags %s as sensitive', (name) => {
|
||||
expect(isSensitiveName(name)).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['X-Trace-Id'],
|
||||
['Content-Type'],
|
||||
['User-Agent'],
|
||||
['email']
|
||||
])('does not flag %s', (name) => {
|
||||
expect(isSensitiveName(name)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('maskValue', () => {
|
||||
it('redacts the value when the name is sensitive', () => {
|
||||
expect(maskValue('Authorization', 'Bearer abc')).toBe('<redacted>');
|
||||
});
|
||||
it('passes the value through when the name is not sensitive', () => {
|
||||
expect(maskValue('X-Trace-Id', '123')).toBe('123');
|
||||
});
|
||||
});
|
||||
|
||||
describe('redactResponseValues', () => {
|
||||
it('replaces primitives with type placeholders, preserving keys', () => {
|
||||
expect(redactResponseValues({ id: 1, name: 'a', active: true })).toEqual({
|
||||
id: '<number>',
|
||||
name: '<string>',
|
||||
active: '<boolean>'
|
||||
});
|
||||
});
|
||||
|
||||
it('samples long arrays and reports the rest', () => {
|
||||
const out = redactResponseValues([1, 2, 3, 4, 5]);
|
||||
expect(out).toEqual(['<number>', '<number>', '<number>', '<2 more items>']);
|
||||
});
|
||||
|
||||
it('caps deep nesting with a placeholder', () => {
|
||||
// 8 levels deep — exceeds the default maxDepth of 6.
|
||||
const deep = { a: { b: { c: { d: { e: { f: { g: { h: 'leaf' } } } } } } } };
|
||||
const out = redactResponseValues(deep);
|
||||
expect(JSON.stringify(out)).toContain('<truncated>');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatResponseShape', () => {
|
||||
it('returns an empty string when neither status nor data is present', () => {
|
||||
expect(formatResponseShape(null, null)).toBe('');
|
||||
});
|
||||
|
||||
it('parses a JSON string body and emits a redacted shape block', () => {
|
||||
const out = formatResponseShape(200, JSON.stringify({ user: { id: 1, email: 'a@b' } }));
|
||||
expect(out).toContain('**Last Response Status:** 200');
|
||||
expect(out).toContain('"id": "<number>"');
|
||||
expect(out).toContain('"email": "<string>"');
|
||||
// Real values must not leak.
|
||||
expect(out).not.toContain('a@b');
|
||||
});
|
||||
|
||||
it('summarizes non-JSON string bodies without echoing them', () => {
|
||||
const out = formatResponseShape(200, 'plain text body');
|
||||
expect(out).toContain('non-JSON');
|
||||
expect(out).not.toContain('plain text body');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatRequestContext', () => {
|
||||
it('masks sensitive header / param values', () => {
|
||||
const out = formatRequestContext({
|
||||
method: 'GET',
|
||||
url: '/x',
|
||||
headers: [
|
||||
{ name: 'Authorization', value: 'Bearer xyz', enabled: true },
|
||||
{ name: 'X-Trace-Id', value: '123', enabled: true }
|
||||
],
|
||||
params: [{ name: 'api_key', value: 'secret-key', enabled: true, type: 'query' }],
|
||||
body: null
|
||||
});
|
||||
expect(out).toContain('Authorization: <redacted>');
|
||||
expect(out).toContain('X-Trace-Id: 123');
|
||||
expect(out).toContain('api_key: <redacted>');
|
||||
expect(out).not.toContain('Bearer xyz');
|
||||
expect(out).not.toContain('secret-key');
|
||||
});
|
||||
|
||||
it('redacts sensitive keys inside JSON bodies but keeps the shape', () => {
|
||||
const out = formatRequestContext({
|
||||
method: 'POST',
|
||||
url: '/login',
|
||||
headers: [],
|
||||
params: [],
|
||||
body: {
|
||||
mode: 'json',
|
||||
json: JSON.stringify({
|
||||
username: 'alice',
|
||||
password: 'hunter2',
|
||||
nested: { refresh_token: 'tok', safe: 'ok' }
|
||||
})
|
||||
}
|
||||
});
|
||||
expect(out).toContain('"username": "alice"');
|
||||
expect(out).toContain('"password": "<redacted>"');
|
||||
expect(out).toContain('"refresh_token": "<redacted>"');
|
||||
expect(out).toContain('"safe": "ok"');
|
||||
expect(out).not.toContain('hunter2');
|
||||
expect(out).not.toContain('"tok"');
|
||||
});
|
||||
|
||||
it('redacts sensitive keys inside GraphQL variables JSON', () => {
|
||||
const out = formatRequestContext({
|
||||
method: 'POST',
|
||||
url: '/g',
|
||||
headers: [],
|
||||
params: [],
|
||||
body: {
|
||||
mode: 'graphql',
|
||||
graphql: { query: 'mutation X', variables: '{"token": "abc", "id": 1}' }
|
||||
}
|
||||
});
|
||||
expect(out).toContain('"token": "<redacted>"');
|
||||
expect(out).toContain('"id": 1');
|
||||
expect(out).not.toContain('"abc"');
|
||||
});
|
||||
|
||||
it('includes the response shape only when opts.includeResponse is true', () => {
|
||||
const base = {
|
||||
method: 'GET',
|
||||
url: '/x',
|
||||
headers: [],
|
||||
params: [],
|
||||
body: null,
|
||||
responseStatus: 200,
|
||||
responseData: { id: 1 }
|
||||
};
|
||||
expect(formatRequestContext(base)).not.toContain('Response Shape');
|
||||
expect(formatRequestContext(base, { includeResponse: true })).toContain('Response Shape');
|
||||
});
|
||||
|
||||
it('truncates the body when bodyMaxChars is set', () => {
|
||||
const long = 'x'.repeat(1000);
|
||||
const out = formatRequestContext(
|
||||
{ method: 'GET', url: '/x', headers: [], params: [], body: { mode: 'text', text: long } },
|
||||
{ bodyMaxChars: 50 }
|
||||
);
|
||||
// The shown body should be 50 chars plus the ellipsis marker.
|
||||
expect(out).toContain('…');
|
||||
expect(out).not.toContain('x'.repeat(60));
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatVariablesList', () => {
|
||||
it('groups by scope and tags secret entries', () => {
|
||||
const out = formatVariablesList([
|
||||
{ name: 'API_URL', value: 'u', scope: 'env', secret: false },
|
||||
{ name: 'API_TOKEN', value: '<redacted>', scope: 'env', secret: true },
|
||||
{ name: 'runtimeKey', value: 'r', scope: 'runtime', secret: false }
|
||||
]);
|
||||
expect(out).toContain('env (2)');
|
||||
expect(out).toContain('API_TOKEN (secret)');
|
||||
expect(out).toContain('runtime (1)');
|
||||
expect(out).toContain('runtimeKey');
|
||||
});
|
||||
|
||||
it('returns an empty string for no variables', () => {
|
||||
expect(formatVariablesList([])).toBe('');
|
||||
expect(formatVariablesList(null)).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('searchVariables / formatSearchVariablesResult', () => {
|
||||
const vars = [
|
||||
{ name: 'API_URL', value: 'https://x', scope: 'env', secret: false },
|
||||
{ name: 'API_TOKEN', value: '<redacted>', scope: 'env', secret: true },
|
||||
{ name: 'runtimeKey', value: 'r1', scope: 'runtime', secret: false }
|
||||
];
|
||||
|
||||
it('returns case-insensitive substring matches with a totalMatched count', () => {
|
||||
const r = searchVariables(vars, 'api');
|
||||
expect(r.items.map((v) => v.name)).toEqual(['API_URL', 'API_TOKEN']);
|
||||
expect(r.totalMatched).toBe(2);
|
||||
});
|
||||
|
||||
it('returns all entries (up to the limit) for an empty query', () => {
|
||||
const r = searchVariables(vars, '');
|
||||
expect(r.items).toHaveLength(3);
|
||||
expect(r.totalMatched).toBe(3);
|
||||
});
|
||||
|
||||
it('truncates to the limit and reports the surplus in totalMatched', () => {
|
||||
const many = Array.from({ length: 60 }, (_, i) => ({
|
||||
name: 'token_' + i, value: 'v' + i, scope: 'env', secret: false
|
||||
}));
|
||||
const r = searchVariables(many, 'token', 50);
|
||||
expect(r.items).toHaveLength(50);
|
||||
expect(r.totalMatched).toBe(60);
|
||||
});
|
||||
|
||||
it('formats matches with scope + secret tags', () => {
|
||||
const out = formatSearchVariablesResult(searchVariables(vars, 'api'), 'api');
|
||||
expect(out).toContain('API_URL = https://x [env]');
|
||||
expect(out).toContain('API_TOKEN = <redacted> [env, secret]');
|
||||
});
|
||||
|
||||
it('says "no matches" when nothing matched the query', () => {
|
||||
expect(formatSearchVariablesResult(searchVariables(vars, 'zzz'), 'zzz'))
|
||||
.toBe('No variables match "zzz".');
|
||||
});
|
||||
|
||||
it('includes a trailer when limit was hit', () => {
|
||||
const many = Array.from({ length: 60 }, (_, i) => ({
|
||||
name: 'token_' + i, value: 'v' + i, scope: 'env', secret: false
|
||||
}));
|
||||
const out = formatSearchVariablesResult(searchVariables(many, 'token', 50), 'token');
|
||||
expect(out).toContain('Found 50 of 60');
|
||||
expect(out).toContain('(10 more match');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
const { ipcMain } = require('electron');
|
||||
const { generateText, streamText } = require('ai');
|
||||
const { generateText, streamText, stepCountIs } = require('ai');
|
||||
const { z } = require('zod');
|
||||
const { getPreferences } = require('../../store/preferences');
|
||||
const { aiKeyStore } = require('../../store/ai-keys');
|
||||
const {
|
||||
@@ -13,7 +14,17 @@ const {
|
||||
validateApiKeyForProvider,
|
||||
providerLabel
|
||||
} = require('./providers');
|
||||
const { SCRIPT_PROMPTS, SCRIPT_TYPES, buildScriptUserPrompt, stripCodeFences } = require('./script-prompts');
|
||||
const {
|
||||
SCRIPT_TYPES,
|
||||
buildScriptSystemPrompt,
|
||||
buildScriptUserPrompt,
|
||||
stripCodeFences
|
||||
} = require('./script-prompts');
|
||||
const {
|
||||
formatResponseShape,
|
||||
searchVariables,
|
||||
formatSearchVariablesResult
|
||||
} = require('./context');
|
||||
const registerChatIpc = require('./chat');
|
||||
|
||||
const activeStreams = new Map();
|
||||
@@ -150,7 +161,15 @@ const registerAiIpc = (mainWindow) => {
|
||||
});
|
||||
|
||||
ipcMain.handle('renderer:ai-generate-script', async (_event, params) => {
|
||||
const { scriptType, prompt, currentScript, requestContext, docsContext, model: requestedModel } = params || {};
|
||||
const {
|
||||
scriptType,
|
||||
prompt,
|
||||
currentScript,
|
||||
requestContext,
|
||||
docsContext,
|
||||
variables,
|
||||
model: requestedModel
|
||||
} = params || {};
|
||||
|
||||
if (!SCRIPT_TYPES.includes(scriptType)) {
|
||||
return { error: `Unknown scriptType: ${scriptType}` };
|
||||
@@ -171,14 +190,74 @@ const registerAiIpc = (mainWindow) => {
|
||||
return { error: err.message };
|
||||
}
|
||||
|
||||
// Generation runs through streamText so the model can call tools
|
||||
// (read_response, search_variables) when the inline context isn't enough.
|
||||
// We don't stream tokens back to the renderer — the sparkle UI shows a
|
||||
// spinner and applies the final code as a single blob — but the
|
||||
// step-based loop gives the model a chance to gather context first.
|
||||
const tools = {
|
||||
read_response: {
|
||||
description: 'Returns the redacted shape (keys + value types) of the last response for this request. Use it before writing tests/post-response code to learn property paths and types. Real values are stripped — reference fields at runtime, don\'t hard-code the placeholder strings.',
|
||||
inputSchema: z.object({}),
|
||||
execute: async () => {
|
||||
const status = requestContext?.responseStatus;
|
||||
const data = requestContext?.responseData;
|
||||
if (!status && data == null) {
|
||||
return '(No response available — the request has not been executed yet.)';
|
||||
}
|
||||
return formatResponseShape(status, data) || '(empty response)';
|
||||
}
|
||||
},
|
||||
search_variables: {
|
||||
description: 'Search environment / collection / global / runtime variables by name (case-insensitive substring). Pass a query to confirm a name before referencing it. Secret variables come back as `<redacted>`. Each result has a `scope` field — use it to pick the right runtime accessor: `bru.getEnvVar` for `env`, `bru.getGlobalEnvVar` for `global`, `bru.getCollectionVar` / `bru.getFolderVar` / `bru.getRequestVar` for `collection`, `bru.getVar` for `runtime`, and `bru.getSecretVar` for any value that came back redacted. Never hard-code a returned value.',
|
||||
inputSchema: z.object({
|
||||
query: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Substring to match against variable names. Omit to list the first 50.')
|
||||
}),
|
||||
execute: async ({ query }) => {
|
||||
if (!Array.isArray(variables) || variables.length === 0) {
|
||||
return '(No variables available — the collection has no environment, runtime, or collection variables defined.)';
|
||||
}
|
||||
const result = searchVariables(variables, query);
|
||||
return formatSearchVariablesResult(result, query);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
const { text } = await generateText({
|
||||
const result = streamText({
|
||||
model,
|
||||
system: SCRIPT_PROMPTS[scriptType],
|
||||
prompt: buildScriptUserPrompt({ userPrompt: prompt, currentScript, requestContext, docsContext, scriptType }),
|
||||
system: buildScriptSystemPrompt(scriptType),
|
||||
prompt: buildScriptUserPrompt({
|
||||
userPrompt: prompt,
|
||||
currentScript,
|
||||
requestContext,
|
||||
docsContext,
|
||||
variables,
|
||||
scriptType
|
||||
}),
|
||||
tools,
|
||||
// Cap tool-call iteration — the model gets a few chances to look
|
||||
// things up before it MUST produce the final script.
|
||||
stopWhen: stepCountIs(4),
|
||||
toolChoice: 'auto',
|
||||
maxOutputTokens: 2048
|
||||
});
|
||||
return { content: stripCodeFences(text), modelId };
|
||||
|
||||
let fullText = '';
|
||||
for await (const part of result.fullStream) {
|
||||
if (part.type === 'text-delta') {
|
||||
fullText += part.text;
|
||||
}
|
||||
}
|
||||
|
||||
const content = stripCodeFences(fullText);
|
||||
if (!content || !content.trim()) {
|
||||
return { error: 'No content was generated. Try rephrasing your prompt.' };
|
||||
}
|
||||
return { content, modelId };
|
||||
} catch (err) {
|
||||
console.error('AI generate-script error:', err);
|
||||
return { error: err.message || 'Failed to generate script' };
|
||||
|
||||
@@ -4,6 +4,7 @@ const BRUNO_API_REFERENCE = `## Bruno API Reference
|
||||
\`\`\`js
|
||||
bru.getEnvVar(key)
|
||||
bru.setEnvVar(key, value)
|
||||
bru.setEnvVar(key, value, { persist: true })
|
||||
bru.hasEnvVar(key)
|
||||
bru.deleteEnvVar(key)
|
||||
bru.getEnvName()
|
||||
@@ -290,43 +291,31 @@ const formatDocsContext = (ctx) => {
|
||||
return parts.join('\n\n');
|
||||
};
|
||||
|
||||
const formatRequestContext = (ctx) => {
|
||||
if (!ctx) return '';
|
||||
const parts = [];
|
||||
const { formatRequestContext, formatVariablesList } = require('./context');
|
||||
|
||||
if (ctx.url || ctx.method) {
|
||||
parts.push(`Request: ${ctx.method || 'GET'} ${ctx.url || ''}`);
|
||||
}
|
||||
|
||||
const headers = (ctx.headers || []).filter((h) => h?.enabled && h?.name);
|
||||
if (headers.length) {
|
||||
parts.push(`Headers:\n${headers.map((h) => ` ${h.name}: ${h.value ?? ''}`).join('\n')}`);
|
||||
}
|
||||
|
||||
const params = (ctx.params || []).filter((p) => p?.enabled && p?.name);
|
||||
if (params.length) {
|
||||
parts.push(`Params:\n${params.map((p) => ` ${p.name}: ${p.value ?? ''}`).join('\n')}`);
|
||||
}
|
||||
|
||||
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) parts.push(`Body (${body.mode}):\n${bodyText.slice(0, 2000)}`);
|
||||
}
|
||||
|
||||
return parts.join('\n\n');
|
||||
};
|
||||
|
||||
const buildScriptUserPrompt = ({ userPrompt, currentScript, requestContext, docsContext, scriptType }) => {
|
||||
const buildScriptUserPrompt = ({
|
||||
userPrompt,
|
||||
currentScript,
|
||||
requestContext,
|
||||
docsContext,
|
||||
variables,
|
||||
scriptType
|
||||
}) => {
|
||||
const sections = [];
|
||||
const docsContextStr = formatDocsContext(docsContext);
|
||||
if (docsContextStr) sections.push(`Documentation Context\n${docsContextStr}`);
|
||||
const contextStr = formatRequestContext(requestContext);
|
||||
|
||||
// Same redaction rules as the chat sidebar — sensitive headers/params masked,
|
||||
// response shape only (no real values). Body is sent in full so the model
|
||||
// can write code that references real keys.
|
||||
const contextStr = formatRequestContext(requestContext, { includeResponse: true });
|
||||
if (contextStr) sections.push(`HTTP Request Context\n${contextStr}`);
|
||||
|
||||
const varsStr = formatVariablesList(variables);
|
||||
if (varsStr) {
|
||||
sections.push(`Available Variables (names only — call search_variables(query) for a value)\n${varsStr}`);
|
||||
}
|
||||
|
||||
if (currentScript && currentScript.trim()) {
|
||||
let existingLabel = 'Existing Code';
|
||||
let fenceLang = 'js';
|
||||
@@ -355,9 +344,29 @@ const stripCodeFences = (text) => {
|
||||
return out.replace(/^\n+/, '');
|
||||
};
|
||||
|
||||
// Tool instructions appended to every script system prompt so the model knows
|
||||
// it can call `read_response` and `search_variables` instead of relying on a
|
||||
// possibly-truncated inline summary. Generation does NOT have write tools —
|
||||
// the model still returns the final script as its assistant text.
|
||||
const TOOL_INSTRUCTIONS = `## Available Tools
|
||||
|
||||
You may call these tools BEFORE producing the final code to gather context. Do not announce the tool calls in your final output — only the generated code goes back to the user.
|
||||
|
||||
- read_response(): returns the redacted shape (keys + value types) of the most recent response for this request. Use it when writing tests / post-response scripts that need to know which fields exist. Values are placeholders (\`<string>\`, \`<number>\`, …) — never hard-code them; reference fields at runtime via \`res.getBody()\` / \`res('path')\`.
|
||||
- search_variables(query?): search environment / collection / global / runtime variables by name (case-insensitive substring). Pass a query to confirm a name exists before referencing it in code. Variables marked \`secret\` come back as \`<redacted>\`. Each result has a \`scope\` field — use it to pick the right runtime accessor: \`bru.getEnvVar\` for \`env\`, \`bru.getGlobalEnvVar\` for \`global\`, \`bru.getCollectionVar\` / \`bru.getFolderVar\` / \`bru.getRequestVar\` for \`collection\`, \`bru.getVar\` for \`runtime\`, and \`bru.getSecretVar\` for any value that came back redacted. Never paste a returned value.
|
||||
|
||||
Only call a tool when the extra information would change the code you write. For greetings, simple boilerplate, or tasks fully covered by the inline context, skip the tools.`;
|
||||
|
||||
const buildScriptSystemPrompt = (scriptType) => {
|
||||
const base = SCRIPT_PROMPTS[scriptType];
|
||||
if (!base) return SCRIPT_PROMPTS.tests; // sensible fallback
|
||||
return `${base}\n\n${TOOL_INSTRUCTIONS}`;
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
SCRIPT_PROMPTS,
|
||||
SCRIPT_TYPES,
|
||||
buildScriptSystemPrompt,
|
||||
buildScriptUserPrompt,
|
||||
formatDocsContext,
|
||||
stripCodeFences
|
||||
|
||||
Reference in New Issue
Block a user