feat: integrate AIAssist for script editing (#8220)

This commit is contained in:
naman-bruno
2026-06-10 16:33:44 +05:30
committed by GitHub
parent ed5f5c21cf
commit 6791e0a674
11 changed files with 989 additions and 125 deletions

View File

@@ -0,0 +1,298 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
position: absolute;
top: 6px;
right: 6px;
z-index: 10;
.ai-assist-trigger {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border-radius: ${(props) => props.theme.border.radius.sm};
border: 1px solid transparent;
background: transparent;
color: ${(props) => props.theme.colors.text.muted};
cursor: pointer;
transition: color 0.15s ease, background-color 0.15s ease, border-color 0.15s ease;
opacity: 0.7;
&:hover,
&.open {
opacity: 1;
color: ${(props) => props.theme.colors.accent};
background: ${(props) => props.theme.colors.accent}10;
border-color: ${(props) => props.theme.input.border};
}
&:focus-visible {
outline: 2px solid ${(props) => props.theme.colors.accent}55;
outline-offset: 1px;
}
}
.ai-assist-popup {
position: absolute;
top: calc(100% + 4px);
right: 0;
width: 360px;
background: ${(props) => props.theme.bg};
border: 1px solid ${(props) => props.theme.input.border};
border-radius: ${(props) => props.theme.border.radius.md};
overflow: hidden;
}
.popup-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 8px 10px;
border-bottom: 1px solid ${(props) => props.theme.input.border};
}
.popup-title {
display: flex;
align-items: center;
gap: 6px;
font-size: 11px;
font-weight: 600;
color: ${(props) => props.theme.text};
text-transform: uppercase;
letter-spacing: 0.05em;
svg {
color: ${(props) => props.theme.colors.accent};
}
}
.popup-close {
display: inline-flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
border-radius: ${(props) => props.theme.border.radius.sm};
border: none;
background: transparent;
color: ${(props) => props.theme.colors.text.muted};
cursor: pointer;
transition: background-color 0.15s ease, color 0.15s ease;
&:hover {
background: ${(props) => props.theme.input.bg};
color: ${(props) => props.theme.text};
}
}
.popup-body {
padding: 10px;
display: flex;
flex-direction: column;
gap: 8px;
}
.popup-input {
width: 100%;
padding: 8px 10px;
font-size: 12px;
font-family: inherit;
line-height: 1.4;
border-radius: ${(props) => props.theme.border.radius.sm};
border: 1px solid ${(props) => props.theme.input.border};
background: ${(props) => props.theme.input.bg};
color: ${(props) => props.theme.text};
resize: vertical;
outline: none;
transition: border-color 0.15s ease;
&::placeholder {
color: ${(props) => props.theme.colors.text.muted};
opacity: 0.85;
}
&:focus {
border-color: ${(props) => props.theme.input.focusBorder};
}
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
}
.popup-suggestions {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.suggestion-chip {
padding: 3px 8px;
font-size: 11px;
border: 1px solid ${(props) => props.theme.input.border};
border-radius: 999px;
background: ${(props) => props.theme.input.bg};
color: ${(props) => props.theme.colors.text.muted};
cursor: pointer;
transition: color 0.15s ease, border-color 0.15s ease, background-color 0.15s ease;
&:hover:not(:disabled) {
color: ${(props) => props.theme.text};
border-color: ${(props) => props.theme.colors.accent}80;
background: ${(props) => props.theme.colors.accent}10;
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
.popup-error {
padding: 6px 8px;
font-size: 11px;
border-radius: ${(props) => props.theme.border.radius.sm};
color: ${(props) => props.theme.colors.text.danger};
background: ${(props) => props.theme.colors.bg.danger}15;
}
.popup-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 8px 10px;
border-top: 1px solid ${(props) => props.theme.input.border};
}
.popup-hint {
font-size: 11px;
color: ${(props) => props.theme.colors.text.muted};
}
.popup-loading {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: ${(props) => props.theme.colors.text.muted};
}
.loading-spinner {
width: 12px;
height: 12px;
border: 2px solid ${(props) => props.theme.input.border};
border-top-color: ${(props) => props.theme.colors.accent};
border-radius: 50%;
animation: ai-assist-spin 0.7s linear infinite;
}
@keyframes ai-assist-spin {
to { transform: rotate(360deg); }
}
.btn-generate {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 5px 12px;
font-size: 12px;
font-weight: 500;
border-radius: ${(props) => props.theme.border.radius.sm};
border: 1px solid ${(props) => props.theme.colors.accent};
background: ${(props) => props.theme.colors.accent};
color: white;
cursor: pointer;
transition: opacity 0.15s ease;
&:hover:not(:disabled) {
opacity: 0.88;
}
&:disabled {
opacity: 0.45;
cursor: not-allowed;
}
}
.btn-secondary {
padding: 5px 12px;
font-size: 12px;
border-radius: ${(props) => props.theme.border.radius.sm};
border: 1px solid ${(props) => props.theme.input.border};
background: transparent;
color: ${(props) => props.theme.text};
cursor: pointer;
transition: background-color 0.15s ease;
&:hover:not(:disabled) {
background: ${(props) => props.theme.input.bg};
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
.preview-section {
display: flex;
flex-direction: column;
gap: 6px;
}
.preview-label {
font-size: 11px;
color: ${(props) => props.theme.colors.text.muted};
text-transform: uppercase;
letter-spacing: 0.05em;
}
.preview-code {
max-height: 220px;
overflow: auto;
padding: 8px 10px;
font-family: ${(props) => props.theme.font.monospace || 'monospace'};
font-size: 11.5px;
line-height: 1.5;
color: ${(props) => props.theme.text};
background: ${(props) => props.theme.input.bg};
border: 1px solid ${(props) => props.theme.input.border};
border-radius: ${(props) => props.theme.border.radius.sm};
white-space: pre;
}
.preview-modes {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 11px;
color: ${(props) => props.theme.colors.text.muted};
}
.preview-mode-btn {
padding: 2px 6px;
border-radius: ${(props) => props.theme.border.radius.sm};
border: 1px solid transparent;
background: transparent;
color: ${(props) => props.theme.colors.text.muted};
cursor: pointer;
font-size: 11px;
&.active {
color: ${(props) => props.theme.text};
border-color: ${(props) => props.theme.input.border};
background: ${(props) => props.theme.input.bg};
}
&:hover:not(.active) {
color: ${(props) => props.theme.text};
}
}
`;
export default StyledWrapper;

View File

@@ -0,0 +1,232 @@
import React, { useCallback, useMemo, useRef, useState } from 'react';
import { useSelector } from 'react-redux';
import get from 'lodash/get';
import { IconStars, IconX, IconArrowBackUp } from '@tabler/icons';
import { aiGenerateScript } from 'utils/ai';
import StyledWrapper from './StyledWrapper';
const SUGGESTIONS = {
'tests': [
{ label: 'Status 200', prompt: 'Add a test asserting the response status code is 200' },
{ label: 'JSON body', prompt: 'Add tests validating the JSON response body structure and key fields' },
{ label: 'Headers', prompt: 'Add a test checking the content-type response header' },
{ label: 'Response time', prompt: 'Add a test asserting the response time is below 1000ms' }
],
'pre-request': [
{ label: 'Auth header', prompt: 'Set an Authorization header from an environment token variable' },
{ label: 'Timestamp', prompt: 'Set a variable named "timestamp" containing the current epoch ms' },
{ label: 'Random ID', prompt: 'Set a variable named "requestId" containing a random UUID-style id' }
],
'post-response': [
{ label: 'Save token', prompt: 'Extract a token from the response body and save it to an environment variable' },
{ label: 'Save id', prompt: 'Extract the primary id from the response body and save it to a variable' },
{ label: 'Log response', prompt: 'Log the response status and a short summary of the body' }
]
};
const TITLES = {
'tests': 'Generate Tests',
'pre-request': 'Generate Pre-Request Script',
'post-response': 'Generate Post-Response Script'
};
const isValidType = (t) => SUGGESTIONS[t] !== undefined;
const AIAssist = ({ scriptType, currentScript, requestContext, onApply }) => {
const [isOpen, setIsOpen] = useState(false);
const [prompt, setPrompt] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState(null);
const [generated, setGenerated] = useState(null);
const buttonRef = useRef(null);
const focusOnMount = useCallback((el) => {
el?.focus();
}, []);
const preferences = useSelector((state) => state.app.preferences);
const isAiEnabled = get(preferences, 'ai.enabled', false);
const suggestions = useMemo(() => SUGGESTIONS[scriptType] || [], [scriptType]);
const title = TITLES[scriptType] || 'Generate with AI';
const close = useCallback(() => {
setIsOpen(false);
setError(null);
}, []);
const attachPopup = useCallback((el) => {
if (!el) return undefined;
const onDocMouseDown = (e) => {
if (!el.contains(e.target) && !buttonRef.current?.contains(e.target)) {
close();
}
};
const onKey = (e) => {
if (e.key === 'Escape') close();
};
document.addEventListener('mousedown', onDocMouseDown);
document.addEventListener('keydown', onKey);
return () => {
document.removeEventListener('mousedown', onDocMouseDown);
document.removeEventListener('keydown', onKey);
};
}, [close]);
const handleGenerate = useCallback(
async (overridePrompt) => {
const text = (overridePrompt ?? prompt).trim();
if (!text || isLoading) return;
setIsLoading(true);
setError(null);
try {
const result = await aiGenerateScript({
scriptType,
prompt: text,
currentScript: currentScript || '',
requestContext
});
if (result?.error) {
setError(result.error);
return;
}
if (result?.content) {
setGenerated(result.content);
} else {
setError('No content was generated. Try rephrasing your prompt.');
}
} catch (err) {
setError(err?.message || 'Failed to generate script');
} finally {
setIsLoading(false);
}
},
[prompt, isLoading, scriptType, currentScript, requestContext]
);
const handleApply = useCallback(() => {
if (generated == null) return;
onApply(generated);
setGenerated(null);
setPrompt('');
close();
}, [generated, onApply, close]);
const handleBackToPrompt = useCallback(() => {
setGenerated(null);
setError(null);
}, []);
if (!isAiEnabled || !isValidType(scriptType)) return null;
return (
<StyledWrapper>
<button
ref={buttonRef}
className={`ai-assist-trigger ${isOpen ? 'open' : ''}`}
onClick={() => setIsOpen((v) => !v)}
title={title}
type="button"
aria-label={title}
>
<IconStars size={14} strokeWidth={1.75} />
</button>
{isOpen && (
<div ref={attachPopup} className="ai-assist-popup" role="dialog" aria-label={title}>
<div className="popup-header">
<span className="popup-title">
<IconStars size={12} strokeWidth={1.75} />
{title}
</span>
<button className="popup-close" onClick={close} type="button" aria-label="Close">
<IconX size={14} />
</button>
</div>
{generated == null ? (
<>
<div className="popup-body">
<textarea
ref={focusOnMount}
className="popup-input"
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
handleGenerate();
}
}}
placeholder="Describe what you want to generate..."
rows={3}
disabled={isLoading}
/>
{!isLoading && !prompt && suggestions.length > 0 && (
<div className="popup-suggestions">
{suggestions.map((s) => (
<button
key={s.label}
className="suggestion-chip"
type="button"
onClick={() => handleGenerate(s.prompt)}
disabled={isLoading}
>
{s.label}
</button>
))}
</div>
)}
{error && <div className="popup-error">{error}</div>}
</div>
<div className="popup-footer">
{isLoading ? (
<span className="popup-loading">
<span className="loading-spinner" />
Generating...
</span>
) : (
<span className="popup-hint"> + Enter to generate</span>
)}
<button
className="btn-generate"
type="button"
onClick={() => handleGenerate()}
disabled={!prompt.trim() || isLoading}
>
Generate
</button>
</div>
</>
) : (
<>
<div className="popup-body">
<div className="preview-section">
<span className="preview-label">Preview · replaces current script</span>
<pre className="preview-code">{generated}</pre>
</div>
</div>
<div className="popup-footer">
<button className="btn-secondary" type="button" onClick={handleBackToPrompt}>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
<IconArrowBackUp size={12} /> Back
</span>
</button>
<button className="btn-generate" type="button" onClick={handleApply}>
Apply
</button>
</div>
</>
)}
</div>
)}
</StyledWrapper>
);
};
export default AIAssist;

View File

@@ -3,6 +3,7 @@ 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 { updateCollectionRequestScript, updateCollectionResponseScript } from 'providers/ReduxStore/slices/collections';
import { saveCollectionSettings } from 'providers/ReduxStore/slices/collections/actions';
import { updateScriptPaneTab } from 'providers/ReduxStore/slices/tabs';
@@ -108,39 +109,53 @@ const Script = ({ collection }) => {
</TabsList>
<TabsContent value="pre-request" className="mt-2" dataTestId="collection-pre-request-script-editor">
<CodeEditor
ref={preRequestEditorRef}
collection={collection}
docKey="collection-script:pre-request"
value={requestScript || ''}
theme={displayedTheme}
onEdit={onRequestScriptEdit}
mode="javascript"
onSave={handleSave}
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
showHintsFor={['req', 'bru']}
initialScroll={preReqScroll}
onScroll={setPreReqScroll}
/>
<div className="relative h-full">
<CodeEditor
ref={preRequestEditorRef}
collection={collection}
docKey="collection-script:pre-request"
value={requestScript || ''}
theme={displayedTheme}
onEdit={onRequestScriptEdit}
mode="javascript"
onSave={handleSave}
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
showHintsFor={['req', 'bru']}
initialScroll={preReqScroll}
onScroll={setPreReqScroll}
/>
<AIAssist
scriptType="pre-request"
currentScript={requestScript || ''}
onApply={onRequestScriptEdit}
/>
</div>
</TabsContent>
<TabsContent value="post-response" className="mt-2" dataTestId="collection-post-response-script-editor">
<CodeEditor
ref={postResponseEditorRef}
collection={collection}
docKey="collection-script:post-response"
value={responseScript || ''}
theme={displayedTheme}
onEdit={onResponseScriptEdit}
mode="javascript"
onSave={handleSave}
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
showHintsFor={['req', 'res', 'bru']}
initialScroll={postResScroll}
onScroll={setPostResScroll}
/>
<div className="relative h-full">
<CodeEditor
ref={postResponseEditorRef}
collection={collection}
docKey="collection-script:post-response"
value={responseScript || ''}
theme={displayedTheme}
onEdit={onResponseScriptEdit}
mode="javascript"
onSave={handleSave}
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
showHintsFor={['req', 'res', 'bru']}
initialScroll={postResScroll}
onScroll={setPostResScroll}
/>
<AIAssist
scriptType="post-response"
currentScript={responseScript || ''}
onApply={onResponseScriptEdit}
/>
</div>
</TabsContent>
</Tabs>

View File

@@ -2,6 +2,7 @@ import React, { 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 { updateCollectionTests } from 'providers/ReduxStore/slices/collections';
import { saveCollectionSettings } from 'providers/ReduxStore/slices/collections/actions';
import { useTheme } from 'providers/Theme';
@@ -32,21 +33,24 @@ const Tests = ({ 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>
<CodeEditor
ref={testsEditorRef}
collection={collection}
docKey="collection-tests"
value={tests || ''}
theme={displayedTheme}
onEdit={onEdit}
mode="javascript"
onSave={handleSave}
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
showHintsFor={['req', 'res', 'bru']}
initialScroll={testsScroll}
onScroll={setTestsScroll}
/>
<div className="relative h-full">
<CodeEditor
ref={testsEditorRef}
collection={collection}
docKey="collection-tests"
value={tests || ''}
theme={displayedTheme}
onEdit={onEdit}
mode="javascript"
onSave={handleSave}
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
showHintsFor={['req', 'res', 'bru']}
initialScroll={testsScroll}
onScroll={setTestsScroll}
/>
<AIAssist scriptType="tests" currentScript={tests || ''} onApply={onEdit} />
</div>
<div className="mt-6">
<Button type="submit" size="sm" onClick={handleSave}>

View File

@@ -3,6 +3,7 @@ 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 { updateFolderRequestScript, updateFolderResponseScript } from 'providers/ReduxStore/slices/collections';
import { saveFolderRoot } from 'providers/ReduxStore/slices/collections/actions';
import { updateScriptPaneTab } from 'providers/ReduxStore/slices/tabs';
@@ -111,39 +112,53 @@ const Script = ({ collection, folder }) => {
</TabsList>
<TabsContent value="pre-request" className="mt-2" dataTestId="folder-pre-request-script-editor">
<CodeEditor
ref={preRequestEditorRef}
collection={collection}
docKey="folder-script:pre-request"
value={requestScript || ''}
theme={displayedTheme}
onEdit={onRequestScriptEdit}
mode="javascript"
onSave={handleSave}
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
showHintsFor={['req', 'bru']}
initialScroll={preReqScroll}
onScroll={setPreReqScroll}
/>
<div className="relative h-full">
<CodeEditor
ref={preRequestEditorRef}
collection={collection}
docKey="folder-script:pre-request"
value={requestScript || ''}
theme={displayedTheme}
onEdit={onRequestScriptEdit}
mode="javascript"
onSave={handleSave}
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
showHintsFor={['req', 'bru']}
initialScroll={preReqScroll}
onScroll={setPreReqScroll}
/>
<AIAssist
scriptType="pre-request"
currentScript={requestScript || ''}
onApply={onRequestScriptEdit}
/>
</div>
</TabsContent>
<TabsContent value="post-response" className="mt-2" dataTestId="folder-post-response-script-editor">
<CodeEditor
ref={postResponseEditorRef}
collection={collection}
docKey="folder-script:post-response"
value={responseScript || ''}
theme={displayedTheme}
onEdit={onResponseScriptEdit}
mode="javascript"
onSave={handleSave}
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
showHintsFor={['req', 'res', 'bru']}
initialScroll={postResScroll}
onScroll={setPostResScroll}
/>
<div className="relative h-full">
<CodeEditor
ref={postResponseEditorRef}
collection={collection}
docKey="folder-script:post-response"
value={responseScript || ''}
theme={displayedTheme}
onEdit={onResponseScriptEdit}
mode="javascript"
onSave={handleSave}
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
showHintsFor={['req', 'res', 'bru']}
initialScroll={postResScroll}
onScroll={setPostResScroll}
/>
<AIAssist
scriptType="post-response"
currentScript={responseScript || ''}
onApply={onResponseScriptEdit}
/>
</div>
</TabsContent>
</Tabs>

View File

@@ -2,6 +2,7 @@ import React, { 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 { updateFolderTests } from 'providers/ReduxStore/slices/collections';
import { saveFolderRoot } from 'providers/ReduxStore/slices/collections/actions';
import { useTheme } from 'providers/Theme';
@@ -33,21 +34,24 @@ const Tests = ({ 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>
<CodeEditor
ref={testsEditorRef}
collection={collection}
docKey="folder-tests"
value={tests || ''}
theme={displayedTheme}
onEdit={onEdit}
mode="javascript"
onSave={handleSave}
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
showHintsFor={['req', 'res', 'bru']}
initialScroll={testsScroll}
onScroll={setTestsScroll}
/>
<div className="relative h-full">
<CodeEditor
ref={testsEditorRef}
collection={collection}
docKey="folder-tests"
value={tests || ''}
theme={displayedTheme}
onEdit={onEdit}
mode="javascript"
onSave={handleSave}
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
showHintsFor={['req', 'res', 'bru']}
initialScroll={testsScroll}
onScroll={setTestsScroll}
/>
<AIAssist scriptType="tests" currentScript={tests || ''} onApply={onEdit} />
</div>
<div className="mt-6">
<Button type="submit" size="sm" onClick={handleSave}>

View File

@@ -1,8 +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 { buildRequestContextFromItem } 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';
@@ -78,6 +80,8 @@ 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 hasPreRequestScript = requestScript && requestScript.trim().length > 0;
const hasPostResponseScript = responseScript && responseScript.trim().length > 0;
@@ -104,41 +108,57 @@ const Script = ({ item, collection }) => {
</TabsList>
<TabsContent value="pre-request" className="mt-2" dataTestId="pre-request-script-editor">
<CodeEditor
ref={preRequestEditorRef}
collection={collection}
docKey="script:pre-request"
value={requestScript || ''}
theme={displayedTheme}
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
onEdit={onRequestScriptEdit}
mode="javascript"
onRun={onRun}
onSave={onSave}
showHintsFor={['req', 'bru']}
initialScroll={preReqScroll}
onScroll={setPreReqScroll}
/>
<div className="relative h-full">
<CodeEditor
ref={preRequestEditorRef}
collection={collection}
docKey="script:pre-request"
value={requestScript || ''}
theme={displayedTheme}
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
onEdit={onRequestScriptEdit}
mode="javascript"
onRun={onRun}
onSave={onSave}
showHintsFor={['req', 'bru']}
initialScroll={preReqScroll}
onScroll={setPreReqScroll}
/>
<AIAssist
scriptType="pre-request"
currentScript={requestScript || ''}
requestContext={requestContext}
onApply={onRequestScriptEdit}
/>
</div>
</TabsContent>
<TabsContent value="post-response" className="mt-2" dataTestId="post-response-script-editor">
<CodeEditor
ref={postResponseEditorRef}
collection={collection}
docKey="script:post-response"
value={responseScript || ''}
theme={displayedTheme}
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
onEdit={onResponseScriptEdit}
mode="javascript"
onRun={onRun}
onSave={onSave}
showHintsFor={['req', 'res', 'bru']}
initialScroll={postResScroll}
onScroll={setPostResScroll}
/>
<div className="relative h-full">
<CodeEditor
ref={postResponseEditorRef}
collection={collection}
docKey="script:post-response"
value={responseScript || ''}
theme={displayedTheme}
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
onEdit={onResponseScriptEdit}
mode="javascript"
onRun={onRun}
onSave={onSave}
showHintsFor={['req', 'res', 'bru']}
initialScroll={postResScroll}
onScroll={setPostResScroll}
/>
<AIAssist
scriptType="post-response"
currentScript={responseScript || ''}
requestContext={requestContext}
onApply={onResponseScriptEdit}
/>
</div>
</TabsContent>
</Tabs>
</div>

View File

@@ -1,7 +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 { buildRequestContextFromItem } from 'utils/ai';
import { updateRequestTests } from 'providers/ReduxStore/slices/collections';
import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import { useTheme } from 'providers/Theme';
@@ -29,8 +31,10 @@ const Tests = ({ item, collection }) => {
const onRun = () => dispatch(sendRequest(item, collection.uid));
const onSave = () => dispatch(saveRequest(item.uid, collection.uid));
const requestContext = useMemo(() => buildRequestContextFromItem(item), [item]);
return (
<div data-testid="test-script-editor">
<div data-testid="test-script-editor" className="relative h-full">
<CodeEditor
ref={testsEditorRef}
collection={collection}
@@ -47,6 +51,7 @@ const Tests = ({ item, collection }) => {
initialScroll={testsScroll}
onScroll={setTestsScroll}
/>
<AIAssist scriptType="tests" currentScript={tests || ''} requestContext={requestContext} onApply={onEdit} />
</div>
);
};

View File

@@ -24,6 +24,23 @@ export const testAiProvider = ({ providerId }) =>
export const aiGenerateText = (params) =>
callIpc('renderer:ai-generate-text', params);
export const aiGenerateScript = (params) =>
callIpc('renderer:ai-generate-script', params);
export const buildRequestContextFromItem = (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
};
};
/**
* Start a streaming generation. Subscribes to the corresponding `main:ai-stream-*`
* channels filtered by streamId. Returns a handle with `.stop()` and a promise

View File

@@ -11,6 +11,7 @@ const {
getAvailableModels,
clearSdkCache
} = require('./providers');
const { SCRIPT_PROMPTS, SCRIPT_TYPES, buildScriptUserPrompt, stripCodeFences } = require('./script-prompts');
const activeStreams = new Map();
@@ -49,6 +50,16 @@ const resolveModel = (modelId) => {
});
};
const pickDefaultModelId = () => {
const aiPreferences = getAiPrefs();
const hasApiKey = (providerId) => aiKeyStore.hasKey(providerId);
const available = getAvailableModels({ aiPreferences, hasApiKey });
if (available.length === 0) return null;
const preferred = aiPreferences.defaultModel;
if (preferred && available.some((m) => m.id === preferred)) return preferred;
return available[0].id;
};
const registerAiIpc = (mainWindow) => {
ipcMain.handle('renderer:get-ai-status', async () => buildStatus());
@@ -133,6 +144,42 @@ const registerAiIpc = (mainWindow) => {
}
});
ipcMain.handle('renderer:ai-generate-script', async (_event, params) => {
const { scriptType, prompt, currentScript, requestContext, model: requestedModel } = params || {};
if (!SCRIPT_TYPES.includes(scriptType)) {
return { error: `Unknown scriptType: ${scriptType}` };
}
if (!prompt || !prompt.trim()) {
return { error: 'Prompt is required' };
}
const modelId = requestedModel || pickDefaultModelId();
if (!modelId) {
return { error: 'No AI model available. Configure a provider in Preferences > AI.' };
}
let model;
try {
model = resolveModel(modelId);
} catch (err) {
return { error: err.message };
}
try {
const { text } = await generateText({
model,
system: SCRIPT_PROMPTS[scriptType],
prompt: buildScriptUserPrompt({ userPrompt: prompt, currentScript, requestContext }),
maxOutputTokens: 2048
});
return { content: stripCodeFences(text), modelId };
} catch (err) {
console.error('AI generate-script error:', err);
return { error: err.message || 'Failed to generate script' };
}
});
ipcMain.on('renderer:ai-stream-text', async (_event, params) => {
const { streamId, model: modelId, system, messages, prompt, maxTokens, temperature } = params || {};
if (!streamId) return;

View File

@@ -0,0 +1,207 @@
const BRUNO_API_REFERENCE = `## Bruno API Reference
### bru environment & variables
\`\`\`js
bru.getEnvVar(key)
bru.setEnvVar(key, value)
bru.setEnvVar(key, value, { persist: true })
bru.hasEnvVar(key)
bru.deleteEnvVar(key)
bru.getEnvName()
bru.getGlobalEnvVar(key)
bru.setGlobalEnvVar(key, value)
bru.getVar(key) // runtime var
bru.setVar(key, value)
bru.hasVar(key)
bru.deleteVar(key)
bru.getCollectionVar(key)
bru.getFolderVar(key)
bru.getRequestVar(key)
bru.getSecretVar(key)
bru.getProcessEnv(key)
\`\`\`
### bru utilities
\`\`\`js
bru.cwd()
bru.getCollectionName()
bru.interpolate(strOrObj)
await bru.sleep(ms)
bru.visualize(htmlString)
bru.setNextRequest(requestName)
await bru.sendRequest({ url, method, headers, body })
\`\`\`
### req request object (available in pre-request, post-response, tests)
\`\`\`js
req.url, req.method, req.headers, req.body
req.getUrl() / req.setUrl(url)
req.getMethod() / req.setMethod(method)
req.getHeaders() / req.setHeaders(headers)
req.getHeader(name) / req.setHeader(name, value)
req.getBody() / req.setBody(data)
req.getTimeout() / req.setTimeout(ms)
\`\`\`
### res response object (available in post-response and tests only)
\`\`\`js
res.status, res.statusText, res.headers, res.body, res.responseTime
res.getStatus()
res.getStatusText()
res.getHeaders()
res.getHeader(name)
res.getBody()
res.setBody(data)
res.getResponseTime()
res('data.user.name') // jsonpath-style query
\`\`\`
### Chai assertions (tests only)
\`\`\`js
expect(x).to.equal(y)
expect(x).to.eql(y)
expect(x).to.be.a('string')
expect(x).to.have.property('p')
expect(x).to.include(y)
expect(x).to.have.lengthOf(n)
expect(x).to.be.true / .false / .null
expect(x).to.be.above(n) / .below(n)
expect(x).to.match(/regex/)
expect(x).to.exist
\`\`\`
`;
const COMMON_OUTPUT_RULES = `## Output Rules
Return ONLY raw JavaScript code that can be executed directly. No markdown fences, no backticks, no commentary, no preamble. Begin with the first line of code.
If existing code was provided, return the COMPLETE updated script (your output replaces the entire file). Preserve any existing logic the user did not ask you to remove.`;
const SCRIPT_PROMPTS = {
'tests': `You are an AI assistant that writes test scripts for the Bruno API client.
${BRUNO_API_REFERENCE}
## Tests Context
Tests run AFTER the response is received. Available globals: \`bru\`, \`req\`, \`res\`, \`test\`, \`expect\`.
Wrap each assertion in a \`test()\` block:
\`\`\`js
test("status code is 200", function() {
expect(res.getStatus()).to.equal(200);
});
\`\`\`
Common patterns:
- Status: \`expect(res.getStatus()).to.equal(200)\`
- Header: \`expect(res.getHeader('content-type')).to.include('application/json')\`
- Body field: \`expect(res.getBody()).to.have.property('id')\`
- JSON path: \`expect(res('data.user.id')).to.exist\`
- Response time: \`expect(res.getResponseTime()).to.be.below(1000)\`
Do NOT use \`bru.setEnvVar\` or other side-effecting calls inside tests — keep tests pure assertions.
${COMMON_OUTPUT_RULES}`,
'pre-request': `You are an AI assistant that writes pre-request scripts for the Bruno API client.
${BRUNO_API_REFERENCE}
## Pre-Request Context
Pre-request scripts run BEFORE the HTTP request is sent. Available globals: \`bru\`, \`req\`.
Common use cases:
- Set headers: \`req.setHeader('Authorization', 'Bearer ' + bru.getEnvVar('token'))\`
- Compute variables: \`bru.setVar('timestamp', Date.now())\`
- Modify the URL, body, or method
- Conditional logic before sending
The \`res\` object is NOT available here — the response does not yet exist. Do NOT use \`test()\` or \`expect()\` — those belong in the Tests tab.
${COMMON_OUTPUT_RULES}`,
'post-response': `You are an AI assistant that writes post-response scripts for the Bruno API client.
${BRUNO_API_REFERENCE}
## Post-Response Context
Post-response scripts run AFTER the HTTP response is received, before tests. Available globals: \`bru\`, \`req\`, \`res\`.
Common use cases:
- Extract data: \`bru.setEnvVar('token', res('data.token'))\`
- Log: \`console.log('Status:', res.getStatus())\`
- Conditional follow-up logic based on the response
Do NOT use \`test()\` or \`expect()\` — those belong in the Tests tab.
${COMMON_OUTPUT_RULES}`
};
const SCRIPT_TYPES = Object.keys(SCRIPT_PROMPTS);
const formatRequestContext = (ctx) => {
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}: ${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 }) => {
const sections = [];
const contextStr = formatRequestContext(requestContext);
if (contextStr) sections.push(`HTTP Request Context\n${contextStr}`);
if (currentScript && currentScript.trim()) {
sections.push(`Existing Code\n\`\`\`js\n${currentScript}\n\`\`\``);
}
sections.push(`User Request\n${userPrompt}`);
return sections.join('\n\n');
};
const stripCodeFences = (text) => {
if (!text) return '';
let out = text.trim();
// strip leading code fence with optional language
out = out.replace(/^```[\w-]*\n?/, '');
// strip trailing code fence
out = out.replace(/\n?```\s*$/, '');
// drop "Here is..." style preambles
out = out.replace(/^(?:Here(?:'s| is| are)[^\n]*\n)+/i, '');
return out.replace(/^\n+/, '');
};
module.exports = {
SCRIPT_PROMPTS,
SCRIPT_TYPES,
buildScriptUserPrompt,
stripCodeFences
};