mirror of
https://github.com/usebruno/bruno.git
synced 2026-06-28 07:04:10 +00:00
feat: add support for prompt variables in the bruno app
This commit is contained in:
committed by
Bijin Bruno
parent
2d2a17c90f
commit
d28f2f32e9
@@ -96,6 +96,9 @@ const StyledWrapper = styled.div`
|
||||
.cm-variable-invalid {
|
||||
color: red;
|
||||
}
|
||||
.cm-variable-prompt {
|
||||
color: dodgerblue;
|
||||
}
|
||||
|
||||
.CodeMirror-search-hint {
|
||||
display: inline;
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import React, { useState } from 'react';
|
||||
import Portal from 'components/Portal';
|
||||
import Modal from 'components/Modal';
|
||||
|
||||
export default function PromptVariablesModal({ title = 'Input Required', prompts, onSubmit, onCancel }) {
|
||||
const [values, setValues] = useState({});
|
||||
|
||||
const handleChange = (prompt, value) => {
|
||||
setValues((prev) => ({ ...prev, [prompt]: value }));
|
||||
};
|
||||
|
||||
if (!prompts?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Portal>
|
||||
<Modal
|
||||
size="md"
|
||||
title={title}
|
||||
confirmText="Continue"
|
||||
cancelText="Cancel"
|
||||
handleConfirm={() => onSubmit(values)}
|
||||
handleCancel={onCancel}
|
||||
>
|
||||
{prompts.map((prompt) => (
|
||||
<div key={prompt} style={{ marginBottom: 16 }}>
|
||||
<label style={{ display: 'block', fontWeight: 500, marginBottom: 8 }}>{prompt}:</label>
|
||||
<input
|
||||
type="text"
|
||||
style={{ width: '100%', color: '#333', padding: '8px', borderRadius: '4px', border: '1px solid #ccc' }}
|
||||
value={values[prompt] || ''}
|
||||
onChange={(e) => handleChange(prompt, e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</Modal>
|
||||
</Portal>
|
||||
);
|
||||
}
|
||||
@@ -50,6 +50,9 @@ const StyledWrapper = styled.div`
|
||||
.cm-variable-invalid {
|
||||
color: red;
|
||||
}
|
||||
.cm-variable-prompt {
|
||||
color: blue;
|
||||
}
|
||||
|
||||
.CodeMirror-search-hint {
|
||||
display: inline;
|
||||
|
||||
@@ -237,6 +237,9 @@ const GlobalStyle = createGlobalStyle`
|
||||
.cm-variable-invalid {
|
||||
color: ${(props) => props.theme.codemirror.variable.invalid};
|
||||
}
|
||||
.cm-variable-prompt {
|
||||
color: ${(props) => props.theme.codemirror.variable.prompt};
|
||||
}
|
||||
}
|
||||
.CodeMirror-brunoVarInfo {
|
||||
color: ${(props) => props.theme.codemirror.variable.info.color};
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Provider } from 'react-redux';
|
||||
import { AppProvider } from 'providers/App';
|
||||
import { ToastProvider } from 'providers/Toaster';
|
||||
import { HotkeysProvider } from 'providers/Hotkeys';
|
||||
import { PromptVariablesProvider } from 'providers/PromptVariables';
|
||||
|
||||
import ReduxStore from 'providers/ReduxStore';
|
||||
import ThemeProvider from 'providers/Theme/index';
|
||||
@@ -44,11 +45,13 @@ function Main({ children }) {
|
||||
<Provider store={ReduxStore}>
|
||||
<ThemeProvider>
|
||||
<ToastProvider>
|
||||
<AppProvider>
|
||||
<HotkeysProvider>
|
||||
{children}
|
||||
</HotkeysProvider>
|
||||
</AppProvider>
|
||||
<PromptVariablesProvider>
|
||||
<AppProvider>
|
||||
<HotkeysProvider>
|
||||
{children}
|
||||
</HotkeysProvider>
|
||||
</AppProvider>
|
||||
</PromptVariablesProvider>
|
||||
</ToastProvider>
|
||||
</ThemeProvider>
|
||||
</Provider>
|
||||
@@ -57,5 +60,3 @@ function Main({ children }) {
|
||||
}
|
||||
|
||||
export default Main;
|
||||
|
||||
|
||||
|
||||
72
packages/bruno-app/src/providers/PromptVariables/index.js
Normal file
72
packages/bruno-app/src/providers/PromptVariables/index.js
Normal file
@@ -0,0 +1,72 @@
|
||||
import PromptVariablesModal from 'components/RequestPane/PromptVariables/PromptVariablesModal';
|
||||
import React, { createContext, useCallback, useState } from 'react';
|
||||
import { toast } from 'react-hot-toast';
|
||||
|
||||
const PromptVariablesContext = createContext();
|
||||
|
||||
export function PromptVariablesProvider({ children }) {
|
||||
const [modalState, setModalState] = useState({ open: false, prompts: [], resolve: null, reject: null });
|
||||
|
||||
const prompt = useCallback((prompts) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
setModalState({ open: true, prompts, resolve, reject });
|
||||
} catch (err) {
|
||||
console.error('PromptVariablesProvider: Error opening prompt modal:', err);
|
||||
toast.error('Prompt variable(s) detected, but prompt modal is not available. Please ensure PromptVariableProvider is mounted.');
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Expose globally for non-component code (e.g., Redux thunks)
|
||||
if (typeof window !== 'undefined') {
|
||||
window.promptForVariables = async (prompts) => {
|
||||
try {
|
||||
return await prompt(prompts);
|
||||
} catch (err) {
|
||||
if (err !== 'cancelled') console.error('window.promptForVariables encountered an error:', err);
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const handleSubmit = (values) => {
|
||||
try {
|
||||
modalState.resolve(values);
|
||||
} catch (err) {
|
||||
console.error('PromptVariablesProvider: Error resolving prompt values:', err);
|
||||
}
|
||||
setModalState({ open: false, prompts: [], resolve: null, reject: null });
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
try {
|
||||
modalState.reject('cancelled');
|
||||
} catch (err) {
|
||||
console.error('PromptVariablesProvider: Error rejecting prompt:', err);
|
||||
}
|
||||
setModalState({ open: false, prompts: [], resolve: null, reject: null });
|
||||
};
|
||||
|
||||
try {
|
||||
return (
|
||||
<PromptVariablesContext.Provider value={{ prompt }}>
|
||||
{children}
|
||||
{modalState.open && (
|
||||
<PromptVariablesModal
|
||||
title="Input Required"
|
||||
prompts={modalState.prompts}
|
||||
onSubmit={handleSubmit}
|
||||
onCancel={handleCancel}
|
||||
/>
|
||||
)}
|
||||
</PromptVariablesContext.Provider>
|
||||
);
|
||||
} catch (err) {
|
||||
console.error('PromptVariablesProvider: Error rendering provider or modal:', err);
|
||||
return children;
|
||||
}
|
||||
}
|
||||
|
||||
export default PromptVariablesProvider;
|
||||
@@ -1,5 +1,5 @@
|
||||
import { collectionSchema, environmentSchema, itemSchema } from '@usebruno/schema';
|
||||
import { parseQueryParams } from '@usebruno/common/utils';
|
||||
import { parseQueryParams, extractPromptVariables } from '@usebruno/common/utils';
|
||||
import cloneDeep from 'lodash/cloneDeep';
|
||||
import filter from 'lodash/filter';
|
||||
import find from 'lodash/find';
|
||||
@@ -397,6 +397,29 @@ export const sendRequest = (item, collectionUid) => (dispatch, getState) => {
|
||||
const requestUid = uuid();
|
||||
itemCopy.requestUid = requestUid;
|
||||
|
||||
// Ensure window contains promptForVariables function
|
||||
if (typeof window.promptForVariables === 'function') {
|
||||
// Attempt to extract unique prompt variables from anywhere in the requestExpand commentComment on line R260ResolvedCode has comments. Press enter to view.
|
||||
const uniquePrompts = extractPromptVariables(itemCopy.draft?.request ?? itemCopy.request);
|
||||
|
||||
if (uniquePrompts?.length > 0) {
|
||||
try {
|
||||
// Prompt user for values if any prompt variables are found
|
||||
let userValues = await window.promptForVariables(uniquePrompts);
|
||||
|
||||
// Populate runtimeVariables with user input for prompt variables
|
||||
for (const prompt of uniquePrompts) {
|
||||
collectionCopy.runtimeVariables[`?${prompt}`] = userValues[prompt] ?? '';
|
||||
}
|
||||
} catch (error) {
|
||||
if (error === 'cancelled') {
|
||||
return resolve(); // Resolve without error if user cancels prompt
|
||||
}
|
||||
reject(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await dispatch(
|
||||
updateResponsePaneScrollPosition({
|
||||
uid: state.tabs.activeTabUid,
|
||||
|
||||
@@ -287,6 +287,7 @@ const darkTheme = {
|
||||
variable: {
|
||||
valid: 'rgb(11 178 126)',
|
||||
invalid: '#f06f57',
|
||||
prompt: 'dodgerblue',
|
||||
info: {
|
||||
color: '#ce9178',
|
||||
bg: 'rgb(48,48,49)',
|
||||
|
||||
@@ -288,6 +288,7 @@ const lightTheme = {
|
||||
variable: {
|
||||
valid: '#047857',
|
||||
invalid: 'rgb(185, 28, 28)',
|
||||
prompt: 'dodgerblue',
|
||||
info: {
|
||||
color: 'rgb(52, 52, 52)',
|
||||
bg: 'white',
|
||||
|
||||
@@ -30,6 +30,12 @@ export const defineCodeMirrorBrunoVariablesMode = (_variables, mode, highlightPa
|
||||
while ((ch = stream.next()) != null) {
|
||||
if (ch === '}' && stream.peek() === '}') {
|
||||
stream.eat('}');
|
||||
|
||||
// Prompt variable: starts with '?'
|
||||
if (word.startsWith('?')) {
|
||||
return `variable-prompt`;
|
||||
}
|
||||
|
||||
// Check if it's a mock variable (starts with $) and exists in mockDataFunctions
|
||||
const isMockVariable = word.startsWith('$') && mockDataFunctions.hasOwnProperty(word.substring(1));
|
||||
const found = isMockVariable || pathFoundInVariables(word, variables);
|
||||
|
||||
@@ -25,7 +25,7 @@ const { NtlmClient } = require('axios-ntlm');
|
||||
const { addDigestInterceptor } = require('@usebruno/requests');
|
||||
const { getCACertificates } = require('@usebruno/requests');
|
||||
const { getOAuth2Token } = require('../utils/oauth2');
|
||||
const { encodeUrl, buildFormUrlEncodedPayload } = require('@usebruno/common').utils;
|
||||
const { encodeUrl, buildFormUrlEncodedPayload, extractPromptVariables } = require('@usebruno/common').utils;
|
||||
|
||||
const onConsoleLog = (type, args) => {
|
||||
console[type](...args);
|
||||
@@ -74,6 +74,38 @@ const runSingleRequest = async function (
|
||||
|
||||
request = await prepareRequest(item, collection);
|
||||
|
||||
// Detect prompt variables before proceeding
|
||||
const promptVars = extractPromptVariables(request);
|
||||
if (promptVars.length > 0) {
|
||||
const errorMsg = 'Prompt variables detected in request. CLI execution is not supported for requests with prompt variables.';
|
||||
console.log(chalk.red(stripExtension(relativeItemPathname)) + chalk.dim(` (${errorMsg})`));
|
||||
return {
|
||||
test: {
|
||||
filename: relativeItemPathname
|
||||
},
|
||||
request: {
|
||||
method: request.method,
|
||||
url: request.url,
|
||||
headers: request.headers,
|
||||
data: request.data
|
||||
},
|
||||
response: {
|
||||
status: 'skipped',
|
||||
statusText: errorMsg,
|
||||
data: null,
|
||||
responseTime: 0
|
||||
},
|
||||
error: null,
|
||||
status: 'skipped',
|
||||
skipped: true,
|
||||
assertionResults: [],
|
||||
testResults: [],
|
||||
preRequestTestResults: [],
|
||||
postResponseTestResults: [],
|
||||
shouldStopRunnerExecution
|
||||
};
|
||||
}
|
||||
|
||||
request.__bruno__executionMode = 'cli';
|
||||
|
||||
const scriptingConfig = get(brunoConfig, 'scripts', {});
|
||||
|
||||
@@ -11,3 +11,8 @@ export {
|
||||
export {
|
||||
patternHasher
|
||||
} from './template-hasher';
|
||||
|
||||
export {
|
||||
extractPromptVariables,
|
||||
extractPromptVariablesFromString
|
||||
} from './prompt-variables';
|
||||
|
||||
54
packages/bruno-common/src/utils/prompt-variables.spec.ts
Normal file
54
packages/bruno-common/src/utils/prompt-variables.spec.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { describe, expect, it } from '@jest/globals';
|
||||
|
||||
import { extractPromptVariables, extractPromptVariablesFromString } from './prompt-variables';
|
||||
|
||||
describe('prompt variable utils', () => {
|
||||
describe('extractPromptVariablesFromString', () => {
|
||||
it('should extract prompt variables', () => {
|
||||
expect(extractPromptVariablesFromString('Hello {{?world}}')).toEqual(['world']);
|
||||
expect(extractPromptVariablesFromString('No prompts here')).toEqual([]);
|
||||
expect(extractPromptVariablesFromString('Multiple {{?prompts}} in {{?one}} string')).toEqual(['prompts', 'one']);
|
||||
});
|
||||
|
||||
it('should deduplicate prompt variables', () => {
|
||||
// Strings
|
||||
expect(extractPromptVariables('{{?world}} prompt here Hello {{?world}}')).toEqual(['world']);
|
||||
expect(extractPromptVariables('Multiple {{?prompts}} in {{?one}} string plus another {{?one}}')).toEqual(['prompts', 'one']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractPromptVariables', () => {
|
||||
it('should extract prompt variables from strings', () => {
|
||||
expect(extractPromptVariables('Hello {{?world}}')).toEqual(['world']);
|
||||
expect(extractPromptVariables('No prompts here')).toEqual([]);
|
||||
expect(extractPromptVariables('Multiple {{?prompts}} in {{?one}} string')).toEqual(['prompts', 'one']);
|
||||
});
|
||||
|
||||
it('should extract prompt variables from objects', () => {
|
||||
expect(extractPromptVariables({ text: 'Hello {{?world}}' })).toEqual(['world']);
|
||||
expect(extractPromptVariables({ noPrompt: 'No prompt here' })).toEqual([]);
|
||||
expect(extractPromptVariables({ prompt1: 'Hello {{?world}}', prompt2: 'Another {{?test}}' })).toEqual(['world', 'test']);
|
||||
});
|
||||
|
||||
it('should extract prompt variables from arrays', () => {
|
||||
// Strings
|
||||
expect(extractPromptVariables(['No prompts here', 'Hello {{?world}}'])).toEqual(['world']);
|
||||
expect(extractPromptVariables(['Multiple {{?prompts}} in {{?one}} string', 'Another {{?test}} string'])).toEqual(['prompts', 'one', 'test']);
|
||||
|
||||
// Objects
|
||||
expect(extractPromptVariables([{ prompt: 'Hello {{?world}}', noprompt: 'No prompt here' }, { noprompt: '' }])).toEqual(['world']);
|
||||
|
||||
// Nested arrays
|
||||
expect(extractPromptVariables(['Prompt {{?here}}', ['Hello {{?world}}', 'Another {{?test}} string']])).toEqual(['here', 'world', 'test']);
|
||||
|
||||
// Mixed data types
|
||||
expect(extractPromptVariables([{ text: 'Multiple {{?prompts}} in {{?one}} string', noPrompt: 'No prompt here' }, ['Another {{?test}} string', { prompt: '{{?nested}}', no: 'prompt' }]])).toEqual(['prompts', 'one', 'test', 'nested']);
|
||||
});
|
||||
|
||||
it('should deduplicate prompt variables', () => {
|
||||
// Strings
|
||||
expect(extractPromptVariables(['{{?world}} prompt here', 'Hello {{?world}}'])).toEqual(['world']);
|
||||
expect(extractPromptVariables(['Multiple {{?prompts}} in {{?one}} string', 'Another {{?one}} string'])).toEqual(['prompts', 'one']);
|
||||
});
|
||||
});
|
||||
});
|
||||
45
packages/bruno-common/src/utils/prompt-variables.ts
Normal file
45
packages/bruno-common/src/utils/prompt-variables.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Extract prompt variables matching {{?<Prompt Text>}} from a string.
|
||||
* @param {string} str - The input string.
|
||||
* @returns {string[]} - An array of extracted prompt variables.
|
||||
*/
|
||||
export const extractPromptVariablesFromString = (str: string): string[] => {
|
||||
const regex = /{{\?([^}]+)}}/g;
|
||||
const prompts = new Set<string>();
|
||||
let match;
|
||||
while ((match = regex.exec(str)) !== null) {
|
||||
prompts.add(match[1].trim());
|
||||
}
|
||||
return Array.from(prompts);
|
||||
};
|
||||
|
||||
/**
|
||||
* Extract prompt variables from an object.
|
||||
* @param {*} obj - The input object.
|
||||
* @returns {string[]} - An array of extracted prompt variables.
|
||||
*/
|
||||
export function extractPromptVariables(obj: any): string[] {
|
||||
const prompts = new Set<string>();
|
||||
try {
|
||||
if (typeof obj === 'string') {
|
||||
// Extract prompt variables from strings
|
||||
const extracted = extractPromptVariablesFromString(obj);
|
||||
extracted.forEach((prompt) => prompts.add(prompt));
|
||||
} else if (Array.isArray(obj)) {
|
||||
// Recursively extract from array elements
|
||||
for (const item of obj) {
|
||||
const extracted = extractPromptVariables(item);
|
||||
extracted.forEach((prompt) => prompts.add(prompt));
|
||||
}
|
||||
} else if (typeof obj === 'object' && obj !== null) {
|
||||
// Recursively extract from object properties
|
||||
for (const key in obj) {
|
||||
const extracted = extractPromptVariables(obj[key]);
|
||||
extracted.forEach((prompt) => prompts.add(prompt));
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error extracting prompt variables:', error);
|
||||
}
|
||||
return Array.from(prompts);
|
||||
}
|
||||
@@ -11,6 +11,7 @@ const { each, get, extend, cloneDeep, merge } = require('lodash');
|
||||
const { NtlmClient } = require('axios-ntlm');
|
||||
const { VarsRuntime, AssertRuntime, ScriptRuntime, TestRuntime } = require('@usebruno/js');
|
||||
const { encodeUrl } = require('@usebruno/common').utils;
|
||||
const { extractPromptVariables } = require('@usebruno/common').utils;
|
||||
const { interpolateString } = require('./interpolate-string');
|
||||
const { resolveAwsV4Credentials, addAwsV4Interceptor } = require('./awsv4auth-helper');
|
||||
const { addDigestInterceptor } = require('@usebruno/requests');
|
||||
@@ -1066,6 +1067,27 @@ const registerNetworkIpc = (mainWindow) => {
|
||||
continue;
|
||||
}
|
||||
|
||||
const promptVars = extractPromptVariables(request);
|
||||
|
||||
if (promptVars.length > 0) {
|
||||
mainWindow.webContents.send('main:run-folder-event', {
|
||||
type: 'runner-request-skipped',
|
||||
error: 'Request has been skipped due to containing prompt variables',
|
||||
responseReceived: {
|
||||
status: 'skipped',
|
||||
statusText: 'Prompt variables detected in request. Runner execution is not supported for requests with prompt variables.',
|
||||
data: null,
|
||||
responseTime: 0,
|
||||
headers: null
|
||||
},
|
||||
...eventData
|
||||
});
|
||||
|
||||
currentRequestIndex++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
const request = await prepareRequest(item, collection, abortController);
|
||||
request.__bruno__executionMode = 'runner';
|
||||
|
||||
|
||||
Reference in New Issue
Block a user