mirror of
https://github.com/usebruno/bruno.git
synced 2026-07-07 14:08:38 +00:00
refactor(AI): enhance active item retrieval logic and improve UI styling (#8477)
* refactor(AI): enhance active item retrieval logic and improve UI styling * fix --------- Co-authored-by: Utkarsh <utkarsh@usebruno.com>
This commit is contained in:
@@ -200,6 +200,10 @@ const StyledWrapper = styled.div`
|
||||
}
|
||||
}
|
||||
|
||||
.diff-lines {
|
||||
min-width: max-content;
|
||||
}
|
||||
|
||||
.diff-line {
|
||||
padding: 0 8px 0 4px;
|
||||
white-space: pre;
|
||||
@@ -224,7 +228,6 @@ const StyledWrapper = styled.div`
|
||||
|
||||
.line-content {
|
||||
flex: 1;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
&.added {
|
||||
|
||||
@@ -194,7 +194,11 @@ const DiffView = ({ originalCode, newCode, onAccept, onReject, status, contentTy
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isExpanded && <div className="diff-content">{renderHunks()}</div>}
|
||||
{isExpanded && (
|
||||
<div className="diff-content">
|
||||
<div className="diff-lines">{renderHunks()}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button className="diff-toggle" onClick={() => setIsExpanded((v) => !v)}>
|
||||
{isExpanded ? (
|
||||
|
||||
@@ -481,6 +481,7 @@ const StyledWrapper = styled.div`
|
||||
.prose.markdown-body {
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
background: none;
|
||||
|
||||
.cursor {
|
||||
display: inline-block;
|
||||
|
||||
@@ -43,7 +43,7 @@ import {
|
||||
updateCollectionTests,
|
||||
updateCollectionDocs
|
||||
} from 'providers/ReduxStore/slices/collections';
|
||||
import { findItemInCollection, isItemAFolder, isItemARequest } from 'utils/collections';
|
||||
import { findItemInCollection, findItemInCollectionByPathname, isItemAFolder, isItemARequest } from 'utils/collections';
|
||||
import { buildAiVariablesPayload, getAiStatus } from 'utils/ai';
|
||||
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
@@ -188,7 +188,13 @@ const AiChatSidebar = ({ collection }) => {
|
||||
const aiEnabled = get(preferences, 'ai.enabled', false);
|
||||
|
||||
const focusedTab = find(tabs, (t) => t.uid === activeTabUid);
|
||||
const activeItem = focusedTab && collection ? findItemInCollection(collection, activeTabUid) : null;
|
||||
|
||||
const activeItem = useMemo(() => {
|
||||
if (!focusedTab || !collection) return null;
|
||||
const found = findItemInCollection(collection, activeTabUid);
|
||||
if (found) return found;
|
||||
return focusedTab.pathname ? findItemInCollectionByPathname(collection, focusedTab.pathname) : null;
|
||||
}, [focusedTab, collection, activeTabUid]);
|
||||
|
||||
const aiContext = useMemo(() => {
|
||||
if (!focusedTab || !collection) return null;
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
pointer-events: none;
|
||||
|
||||
.app-preview-slot {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
visibility: hidden;
|
||||
|
||||
&.active {
|
||||
visibility: visible;
|
||||
pointer-events: auto;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
@@ -0,0 +1,90 @@
|
||||
import React, { useMemo, useRef } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import find from 'lodash/find';
|
||||
import get from 'lodash/get';
|
||||
import { findItemInCollection, findItemInCollectionByPathname } from 'utils/collections';
|
||||
import { ScopedPersistenceProvider } from 'hooks/usePersistedState/PersistedScopeProvider';
|
||||
import TabPanelErrorBoundary from 'components/RequestTabPanel/TabPanelErrorBoundary';
|
||||
import AppView from 'components/AppView';
|
||||
import CollectionApp from 'components/CollectionApp';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
|
||||
const APP_CAPABLE_TAB_TYPES = new Set([
|
||||
'app',
|
||||
'request',
|
||||
'http-request',
|
||||
'graphql-request',
|
||||
'grpc-request',
|
||||
'ws-request'
|
||||
]);
|
||||
|
||||
const AppPreviewKeepAlive = () => {
|
||||
const tabs = useSelector((state) => state.tabs.tabs);
|
||||
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
|
||||
const collections = useSelector((state) => state.collections.collections);
|
||||
|
||||
const everActiveRef = useRef(new Set());
|
||||
|
||||
const appTabs = useMemo(() => {
|
||||
const out = [];
|
||||
for (const tab of tabs) {
|
||||
if (tab.type && !APP_CAPABLE_TAB_TYPES.has(tab.type)) continue;
|
||||
const collection = find(collections, (c) => c.uid === tab.collectionUid);
|
||||
// File-mode collections render everything through FileEditor.
|
||||
if (!collection || collection.fileMode) continue;
|
||||
let item = findItemInCollection(collection, tab.uid);
|
||||
if (!item && tab.pathname) {
|
||||
item = findItemInCollectionByPathname(collection, tab.pathname);
|
||||
}
|
||||
if (!item || item.partial || item.loading) continue;
|
||||
|
||||
if (item.type === 'app') {
|
||||
out.push({ tabUid: tab.uid, collection, item, kind: 'standalone' });
|
||||
continue;
|
||||
}
|
||||
|
||||
const appEnabled = item.draft ? get(item, 'draft.app.enabled', false) : get(item, 'app.enabled', false);
|
||||
if (appEnabled) {
|
||||
const code = item.draft ? get(item, 'draft.app.code', '') : get(item, 'app.code', '');
|
||||
out.push({ tabUid: tab.uid, collection, item, kind: 'request', code });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}, [tabs, collections]);
|
||||
|
||||
const validUids = new Set(appTabs.map((t) => t.tabUid));
|
||||
for (const uid of [...everActiveRef.current]) {
|
||||
if (!validUids.has(uid)) everActiveRef.current.delete(uid);
|
||||
}
|
||||
if (validUids.has(activeTabUid)) everActiveRef.current.add(activeTabUid);
|
||||
|
||||
const mounted = appTabs.filter((t) => everActiveRef.current.has(t.tabUid));
|
||||
if (!mounted.length) return null;
|
||||
|
||||
return (
|
||||
<StyledWrapper data-testid="app-preview-keepalive">
|
||||
{mounted.map(({ tabUid, collection, item, kind, code }) => {
|
||||
const isActive = tabUid === activeTabUid;
|
||||
return (
|
||||
<div
|
||||
key={tabUid}
|
||||
className={`app-preview-slot ${isActive ? 'active' : ''}`}
|
||||
aria-hidden={!isActive}
|
||||
>
|
||||
<TabPanelErrorBoundary tabUid={tabUid}>
|
||||
<ScopedPersistenceProvider scope={tabUid}>
|
||||
{kind === 'standalone' ? (
|
||||
<CollectionApp item={item} collection={collection} />
|
||||
) : (
|
||||
<AppView item={item} collection={collection} code={code} />
|
||||
)}
|
||||
</ScopedPersistenceProvider>
|
||||
</TabPanelErrorBoundary>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default AppPreviewKeepAlive;
|
||||
@@ -0,0 +1,145 @@
|
||||
import '@testing-library/jest-dom';
|
||||
import React from 'react';
|
||||
import { render, screen, act } from '@testing-library/react';
|
||||
import { Provider } from 'react-redux';
|
||||
import { configureStore, createSlice } from '@reduxjs/toolkit';
|
||||
import AppPreviewKeepAlive from './index';
|
||||
|
||||
jest.mock('components/AppView', () => ({ item }) => (
|
||||
<div data-testid="mock-app-view">{item.name}</div>
|
||||
));
|
||||
jest.mock('components/CollectionApp', () => ({ item }) => (
|
||||
<div data-testid="mock-collection-app">{item.name}</div>
|
||||
));
|
||||
jest.mock('components/RequestTabPanel/TabPanelErrorBoundary', () => ({ children }) => children);
|
||||
jest.mock('hooks/usePersistedState/PersistedScopeProvider', () => ({
|
||||
ScopedPersistenceProvider: ({ children }) => children
|
||||
}));
|
||||
|
||||
const makeStore = ({ tabs, activeTabUid, collections }) => {
|
||||
const tabsSlice = createSlice({
|
||||
name: 'tabs',
|
||||
initialState: { tabs, activeTabUid },
|
||||
reducers: {
|
||||
focusTab: (state, action) => {
|
||||
state.activeTabUid = action.payload;
|
||||
},
|
||||
closeTab: (state, action) => {
|
||||
state.tabs = state.tabs.filter((t) => t.uid !== action.payload);
|
||||
}
|
||||
}
|
||||
});
|
||||
const collectionsSlice = createSlice({
|
||||
name: 'collections',
|
||||
initialState: { collections },
|
||||
reducers: {}
|
||||
});
|
||||
return {
|
||||
store: configureStore({
|
||||
reducer: { tabs: tabsSlice.reducer, collections: collectionsSlice.reducer }
|
||||
}),
|
||||
actions: tabsSlice.actions
|
||||
};
|
||||
};
|
||||
|
||||
const requestAppItem = {
|
||||
uid: 'item-1',
|
||||
name: 'My Request App',
|
||||
type: 'http-request',
|
||||
app: { enabled: true, code: '<div>hi</div>' },
|
||||
request: {}
|
||||
};
|
||||
|
||||
const plainRequestItem = {
|
||||
uid: 'item-2',
|
||||
name: 'Plain Request',
|
||||
type: 'http-request',
|
||||
request: {}
|
||||
};
|
||||
|
||||
const standaloneAppItem = {
|
||||
uid: 'item-3',
|
||||
name: 'Standalone App',
|
||||
type: 'app'
|
||||
};
|
||||
|
||||
const collection = {
|
||||
uid: 'coll-1',
|
||||
items: [requestAppItem, plainRequestItem, standaloneAppItem]
|
||||
};
|
||||
|
||||
const tabFor = (item) => ({ uid: item.uid, collectionUid: 'coll-1', type: item.type, pathname: '' });
|
||||
|
||||
describe('AppPreviewKeepAlive', () => {
|
||||
it('renders nothing when no app tab has been activated', () => {
|
||||
const { store } = makeStore({
|
||||
tabs: [tabFor(plainRequestItem)],
|
||||
activeTabUid: plainRequestItem.uid,
|
||||
collections: [collection]
|
||||
});
|
||||
render(<Provider store={store}><AppPreviewKeepAlive /></Provider>);
|
||||
expect(screen.queryByTestId('app-preview-keepalive')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('mounts the active app tab and keeps it mounted (hidden) after switching away', () => {
|
||||
const { store, actions } = makeStore({
|
||||
tabs: [tabFor(requestAppItem), tabFor(plainRequestItem)],
|
||||
activeTabUid: requestAppItem.uid,
|
||||
collections: [collection]
|
||||
});
|
||||
render(<Provider store={store}><AppPreviewKeepAlive /></Provider>);
|
||||
|
||||
const slot = screen.getByTestId('mock-app-view').closest('.app-preview-slot');
|
||||
expect(slot).toHaveClass('active');
|
||||
|
||||
act(() => store.dispatch(actions.focusTab(plainRequestItem.uid)));
|
||||
|
||||
// Still mounted — that's the whole point — but no longer the active slot.
|
||||
const hiddenSlot = screen.getByTestId('mock-app-view').closest('.app-preview-slot');
|
||||
expect(hiddenSlot).not.toHaveClass('active');
|
||||
expect(hiddenSlot).toHaveAttribute('aria-hidden', 'true');
|
||||
});
|
||||
|
||||
it('does not mount app tabs that were never activated', () => {
|
||||
const { store } = makeStore({
|
||||
tabs: [tabFor(requestAppItem), tabFor(standaloneAppItem)],
|
||||
activeTabUid: standaloneAppItem.uid,
|
||||
collections: [collection]
|
||||
});
|
||||
render(<Provider store={store}><AppPreviewKeepAlive /></Provider>);
|
||||
expect(screen.getByTestId('mock-collection-app')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('mock-app-view')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('unmounts a kept-alive app when its tab closes', () => {
|
||||
const { store, actions } = makeStore({
|
||||
tabs: [tabFor(requestAppItem), tabFor(plainRequestItem)],
|
||||
activeTabUid: requestAppItem.uid,
|
||||
collections: [collection]
|
||||
});
|
||||
render(<Provider store={store}><AppPreviewKeepAlive /></Provider>);
|
||||
expect(screen.getByTestId('mock-app-view')).toBeInTheDocument();
|
||||
|
||||
act(() => store.dispatch(actions.focusTab(plainRequestItem.uid)));
|
||||
act(() => store.dispatch(actions.closeTab(requestAppItem.uid)));
|
||||
|
||||
expect(screen.queryByTestId('mock-app-view')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('ignores special tab types that resolve to app items via pathname', () => {
|
||||
const exampleTab = {
|
||||
uid: 'ex-1',
|
||||
collectionUid: 'coll-1',
|
||||
type: 'response-example',
|
||||
pathname: '',
|
||||
itemUid: requestAppItem.uid
|
||||
};
|
||||
const { store } = makeStore({
|
||||
tabs: [exampleTab],
|
||||
activeTabUid: exampleTab.uid,
|
||||
collections: [collection]
|
||||
});
|
||||
render(<Provider store={store}><AppPreviewKeepAlive /></Provider>);
|
||||
expect(screen.queryByTestId('app-preview-keepalive')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -36,6 +36,7 @@ const REQUEST_CTX_BOOTSTRAP = `<script>
|
||||
var SENTINEL = ${JSON.stringify(SENTINEL)};
|
||||
var pending = new Map();
|
||||
var nextRequestId = 0;
|
||||
var initialized = false;
|
||||
|
||||
function sendToHost(payload) {
|
||||
try { console.log(SENTINEL + JSON.stringify(payload)); } catch (e) {}
|
||||
@@ -48,6 +49,10 @@ const REQUEST_CTX_BOOTSTRAP = `<script>
|
||||
testResults: [],
|
||||
variables: {},
|
||||
|
||||
// Called once when the host delivers the initial state. ctx data arrives
|
||||
// asynchronously AFTER page load, so apps must do their first render here
|
||||
// (or in the granular on* callbacks), not at DOMContentLoaded.
|
||||
onInit: null,
|
||||
onThemeChange: null,
|
||||
onResponseUpdate: null,
|
||||
onResultsUpdate: null,
|
||||
@@ -87,6 +92,12 @@ const REQUEST_CTX_BOOTSTRAP = `<script>
|
||||
ctx.assertionResults = msg.assertionResults || [];
|
||||
ctx.testResults = msg.testResults || [];
|
||||
ctx.variables = msg.variables || {};
|
||||
if (!initialized) {
|
||||
initialized = true;
|
||||
if (typeof ctx.onInit === 'function') {
|
||||
try { ctx.onInit(ctx); } catch (e) { sendToHost({ type: 'log', args: ['onInit error: ' + (e && e.message)] }); }
|
||||
}
|
||||
}
|
||||
break;
|
||||
case 'theme':
|
||||
applyTheme(msg.theme);
|
||||
|
||||
@@ -56,6 +56,7 @@ const COLLECTION_CTX_BOOTSTRAP = `<script>
|
||||
var SENTINEL = ${JSON.stringify(SENTINEL)};
|
||||
var pending = new Map();
|
||||
var nextReplyId = 0;
|
||||
var initialized = false;
|
||||
|
||||
function sendToHost(payload) {
|
||||
try { console.log(SENTINEL + JSON.stringify(payload)); } catch (e) {}
|
||||
@@ -74,8 +75,10 @@ const COLLECTION_CTX_BOOTSTRAP = `<script>
|
||||
variables: {},
|
||||
collection: null,
|
||||
|
||||
onInit: null,
|
||||
onThemeChange: null,
|
||||
onVariablesUpdate: null,
|
||||
onCollectionUpdate: null,
|
||||
|
||||
listRequests: function () {
|
||||
return awaitReply('listRequests');
|
||||
@@ -108,6 +111,12 @@ const COLLECTION_CTX_BOOTSTRAP = `<script>
|
||||
applyTheme(msg.theme);
|
||||
ctx.variables = msg.variables || {};
|
||||
ctx.collection = msg.collection || null;
|
||||
if (!initialized) {
|
||||
initialized = true;
|
||||
if (typeof ctx.onInit === 'function') {
|
||||
try { ctx.onInit(ctx); } catch (e) { sendToHost({ type: 'log', args: ['onInit error: ' + (e && e.message)] }); }
|
||||
}
|
||||
}
|
||||
break;
|
||||
case 'theme':
|
||||
applyTheme(msg.theme);
|
||||
@@ -119,6 +128,7 @@ const COLLECTION_CTX_BOOTSTRAP = `<script>
|
||||
break;
|
||||
case 'collection':
|
||||
ctx.collection = msg.collection || null;
|
||||
if (typeof ctx.onCollectionUpdate === 'function') ctx.onCollectionUpdate(ctx.collection);
|
||||
break;
|
||||
case 'reply': {
|
||||
var entry = pending.get(msg.replyId);
|
||||
|
||||
@@ -53,12 +53,18 @@ const aiPreferencesSchema = Yup.object().shape({
|
||||
})
|
||||
});
|
||||
|
||||
let lastActiveSubTab = 'config';
|
||||
|
||||
const AI = () => {
|
||||
const dispatch = useDispatch();
|
||||
const preferences = useSelector((state) => state.app.preferences);
|
||||
const [status, setStatus] = useState(null);
|
||||
const [statusError, setStatusError] = useState(null);
|
||||
const [activeTab, setActiveTab] = useState('config');
|
||||
const [activeTab, setActiveTabState] = useState(lastActiveSubTab);
|
||||
const setActiveTab = useCallback((tab) => {
|
||||
lastActiveSubTab = tab;
|
||||
setActiveTabState(tab);
|
||||
}, []);
|
||||
|
||||
const refreshStatus = useCallback(async () => {
|
||||
try {
|
||||
|
||||
@@ -52,7 +52,7 @@ const AppCodeEditor = ({ item, collection }) => {
|
||||
fontSize={get(preferences, 'font.codeFontSize')}
|
||||
onEdit={onEdit}
|
||||
onSave={onSave}
|
||||
mode="javascript"
|
||||
mode="htmlmixed"
|
||||
/>
|
||||
<AIAssist
|
||||
scriptType="app-request"
|
||||
|
||||
@@ -21,8 +21,6 @@ import CollectionSettings from 'components/CollectionSettings';
|
||||
import { DocExplorer } from '@usebruno/graphql-docs';
|
||||
|
||||
import FileEditor from 'components/FileEditor';
|
||||
import AppView from 'components/AppView';
|
||||
import CollectionApp from 'components/CollectionApp';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import FolderSettings from 'components/FolderSettings';
|
||||
import { getGlobalEnvironmentVariables, getGlobalEnvironmentVariablesMasked } from 'utils/collections/index';
|
||||
@@ -548,28 +546,10 @@ const RequestTabPanel = () => {
|
||||
);
|
||||
}
|
||||
|
||||
// Standalone app item (collection- or folder-level). Renders as its own tab
|
||||
// with a Code/Preview toggle and its own ctx API surface.
|
||||
if (item.type === 'app') {
|
||||
return (
|
||||
<ScopedPersistenceProvider scope={focusedTab.uid}>
|
||||
<StyledWrapper className="flex flex-col flex-grow relative overflow-hidden">
|
||||
<CollectionApp item={item} collection={collection} />
|
||||
</StyledWrapper>
|
||||
</ScopedPersistenceProvider>
|
||||
);
|
||||
}
|
||||
|
||||
const appEnabled = item.draft ? get(item, 'draft.app.enabled', false) : get(item, 'app.enabled', false);
|
||||
if (appEnabled) {
|
||||
const appCode = item.draft ? get(item, 'draft.app.code', '') : get(item, 'app.code', '');
|
||||
return (
|
||||
<ScopedPersistenceProvider scope={focusedTab.uid}>
|
||||
<StyledWrapper className="flex flex-col flex-grow relative overflow-hidden">
|
||||
<AppView item={item} collection={collection} code={appCode} />
|
||||
</StyledWrapper>
|
||||
</ScopedPersistenceProvider>
|
||||
);
|
||||
const appEnabled = item.type !== 'app'
|
||||
&& (item.draft ? get(item, 'draft.app.enabled', false) : get(item, 'app.enabled', false));
|
||||
if (item.type === 'app' || appEnabled) {
|
||||
return <StyledWrapper className="flex flex-col flex-grow relative overflow-hidden" data-testid="app-tab-placeholder" />;
|
||||
}
|
||||
|
||||
const renderQueryUrl = () => {
|
||||
|
||||
@@ -3,6 +3,7 @@ import classnames from 'classnames';
|
||||
import ManageWorkspace from 'components/ManageWorkspace';
|
||||
import RequestTabs from 'components/RequestTabs';
|
||||
import RequestTabPanel from 'components/RequestTabPanel';
|
||||
import AppPreviewKeepAlive from 'components/AppPreviewKeepAlive';
|
||||
import AiChatSidebar from 'components/AiChatSidebar';
|
||||
import Sidebar from 'components/Sidebar';
|
||||
import StatusBar from 'components/StatusBar';
|
||||
@@ -26,6 +27,9 @@ import SaveTransientRequest from 'components/SaveTransientRequest';
|
||||
|
||||
require('codemirror/mode/javascript/javascript');
|
||||
require('codemirror/mode/xml/xml');
|
||||
// css + htmlmixed power the app code editors (apps are HTML/CSS/JS documents)
|
||||
require('codemirror/mode/css/css');
|
||||
require('codemirror/mode/htmlmixed/htmlmixed');
|
||||
require('codemirror/mode/sparql/sparql');
|
||||
require('codemirror/addon/comment/comment');
|
||||
require('codemirror/addon/dialog/dialog');
|
||||
@@ -160,9 +164,12 @@ export default function Main() {
|
||||
) : (
|
||||
<>
|
||||
<RequestTabs />
|
||||
<TabPanelErrorBoundary key={activeTabUid} tabUid={activeTabUid}>
|
||||
<RequestTabPanel key={activeTabUid} />
|
||||
</TabPanelErrorBoundary>
|
||||
<div className="relative flex flex-col flex-grow overflow-hidden">
|
||||
<TabPanelErrorBoundary key={activeTabUid} tabUid={activeTabUid}>
|
||||
<RequestTabPanel key={activeTabUid} />
|
||||
</TabPanelErrorBoundary>
|
||||
<AppPreviewKeepAlive />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
|
||||
@@ -51,6 +51,7 @@ ${context}
|
||||
- Continue the code from the cursor marker \`<CURSOR>\` exactly where it is.
|
||||
- Output ONLY the characters that should be inserted at the cursor — no markdown, no fences, no commentary, no leading newline.
|
||||
- Match the surrounding indentation and quote style.
|
||||
- If the cursor is at the end of a \`//\` comment line and you are generating CODE (not finishing the comment's text), begin your output with a newline — anything emitted on the comment line itself would be commented out. A comment like \`// test that status is 200\` is an instruction: put the implementing code on the following line(s).
|
||||
- Stop at a natural break (end of statement, end of block) — do not rewrite code that already exists after the cursor.
|
||||
- Prefer real variable names from the provided lists over placeholders.
|
||||
- Return an empty string if you have nothing useful to add.`;
|
||||
@@ -165,9 +166,35 @@ const cleanSuggestion = (raw) => {
|
||||
return out;
|
||||
};
|
||||
|
||||
// --- Comment-line guard ----------------------------------------------------
|
||||
// Models often ignore the "start with a newline after a comment" rule and
|
||||
// emit code directly at the cursor, which lands inside the comment. Detect
|
||||
// the case deterministically and prepend the newline ourselves.
|
||||
|
||||
const CODE_START_RE = /^\s*(?:const\s|let\s|var\s|function[\s(]|async\s|await\s|if\s*\(|for\s*\(|while\s*\(|switch\s*\(|try\s*\{|return[\s;(]|throw\s|new\s|test\s*\(|describe\s*\(|expect\s*\(|bru\.|req\.|res[.(]|console\.|JSON\.|Object\.|Array\.|Promise\.|[{}]|[\w$]+\s*\(|[\w$]+\s*[+\-*/]?=[^=])/;
|
||||
|
||||
// Strip string literals so `//` inside a URL ('https://…') isn't mistaken
|
||||
// for a comment marker.
|
||||
const stripStringLiterals = (line) =>
|
||||
line.replace(/'(?:[^'\\]|\\.)*'|"(?:[^"\\]|\\.)*"|`(?:[^`\\]|\\.)*`/g, '""');
|
||||
|
||||
/**
|
||||
* If the cursor sits after a `//` comment and the suggestion starts with code,
|
||||
* prepend a newline so the code lands on its own line instead of inside the
|
||||
* comment. Suggestions that continue the comment's prose are left untouched.
|
||||
*/
|
||||
const ensureNewlineAfterComment = (prefix, suggestion) => {
|
||||
if (!suggestion || /^[\r\n]/.test(suggestion)) return suggestion;
|
||||
const lastLine = String(prefix || '').split('\n').pop();
|
||||
if (!stripStringLiterals(lastLine).includes('//')) return suggestion;
|
||||
if (!CODE_START_RE.test(suggestion)) return suggestion;
|
||||
return '\n' + suggestion;
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
buildSystemPrompt,
|
||||
buildUserPrompt,
|
||||
STOP_SEQUENCES,
|
||||
cleanSuggestion
|
||||
cleanSuggestion,
|
||||
ensureNewlineAfterComment
|
||||
};
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
const { ensureNewlineAfterComment, cleanSuggestion } = require('./autocomplete-prompts');
|
||||
|
||||
describe('ensureNewlineAfterComment', () => {
|
||||
it('prepends a newline when code is suggested at the end of a comment line', () => {
|
||||
const prefix = 'const a = 1;\n// test that status is 200';
|
||||
const out = ensureNewlineAfterComment(prefix, 'test("status is 200", function() {');
|
||||
expect(out).toBe('\ntest("status is 200", function() {');
|
||||
});
|
||||
|
||||
it('handles trailing comments after code on the same line', () => {
|
||||
const prefix = 'doWork(); // then save the token';
|
||||
expect(ensureNewlineAfterComment(prefix, 'bru.setEnvVar(\'token\', res(\'data.token\'));'))
|
||||
.toBe('\nbru.setEnvVar(\'token\', res(\'data.token\'));');
|
||||
});
|
||||
|
||||
it('leaves prose continuations of the comment untouched', () => {
|
||||
const prefix = '// test that the response';
|
||||
expect(ensureNewlineAfterComment(prefix, ' body contains a user id'))
|
||||
.toBe(' body contains a user id');
|
||||
});
|
||||
|
||||
it('leaves suggestions that already start with a newline untouched', () => {
|
||||
const prefix = '// add a status assertion';
|
||||
expect(ensureNewlineAfterComment(prefix, '\nexpect(res.getStatus()).to.equal(200);'))
|
||||
.toBe('\nexpect(res.getStatus()).to.equal(200);');
|
||||
});
|
||||
|
||||
it('does nothing on ordinary code lines', () => {
|
||||
const prefix = 'const token = ';
|
||||
expect(ensureNewlineAfterComment(prefix, 'bru.getEnvVar(\'token\');'))
|
||||
.toBe('bru.getEnvVar(\'token\');');
|
||||
});
|
||||
|
||||
it('ignores // inside string literals (URLs)', () => {
|
||||
const prefix = 'req.setUrl(\'https://api.example.com\');';
|
||||
expect(ensureNewlineAfterComment(prefix, 'req.setHeader("Accept", "application/json");'))
|
||||
.toBe('req.setHeader("Accept", "application/json");');
|
||||
});
|
||||
|
||||
it('handles empty suggestions', () => {
|
||||
expect(ensureNewlineAfterComment('// hi', '')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('cleanSuggestion', () => {
|
||||
it('keeps indentation while stripping fences', () => {
|
||||
expect(cleanSuggestion('```js\n const a = 1;\n```')).toBe(' const a = 1;');
|
||||
});
|
||||
});
|
||||
Binary file not shown.
@@ -99,20 +99,58 @@ Post-response scripts run AFTER the response is received, before tests. Availabl
|
||||
3. Use the request context (URL, method, headers, body, params) for accurate docs
|
||||
4. Write the COMPLETE documentation when using write_content`,
|
||||
|
||||
'app': `You are an AI assistant that helps users build small in-Bruno apps tied to an HTTP request.
|
||||
'app': `You are an AI assistant that helps users build small in-Bruno apps.
|
||||
|
||||
An app is a single HTML/CSS/JS document rendered inside Bruno. It can:
|
||||
- Call \`ctx.sendRequest({ variables })\` to execute the current request
|
||||
- Read \`ctx.response\` for the last response (and subscribe via \`ctx.onResponseUpdate\`)
|
||||
- Use \`ctx.variables\` and \`ctx.setRuntimeVariable(key, value)\`
|
||||
- List or run other requests with \`ctx.listRequests()\` / \`ctx.runRequest(pathname)\`
|
||||
An app is a self-contained HTML/CSS/JS document rendered inside a sandboxed <webview> in Bruno. The user's code is injected into the body of a generated HTML document at runtime. Plain HTML, CSS, and JavaScript only — no bundler, no module imports, no JSX. Output can be a bare HTML fragment or a full \`<html>\` document.
|
||||
|
||||
Before any user script runs, the host provides a global \`window.ctx\`. The surface depends on where the app lives:
|
||||
|
||||
### Request-level app — the app you edit via read_content/write_content('app') is ALWAYS this kind
|
||||
\`\`\`js
|
||||
ctx.theme // 'light' | 'dark' — also reflected as a class on document.body
|
||||
ctx.response // { status, statusText, headers, data, size, duration, timeline } | null
|
||||
ctx.assertionResults // array of assertion result objects
|
||||
ctx.testResults // array of test result objects
|
||||
ctx.variables // merged env + global + collection + runtime variables (read-only snapshot)
|
||||
|
||||
ctx.sendRequest(overrides?) // executes THIS request; returns Promise<response>; overrides may carry { variables: {...} }
|
||||
ctx.setRuntimeVariable(key, value) // persist a runtime variable on the collection
|
||||
ctx.log(...args) // forwarded to the Bruno devtools console
|
||||
|
||||
ctx.onInit = (ctx) => { ... } // called ONCE when the initial state arrives — do the first render here
|
||||
ctx.onThemeChange = (theme) => { ... }
|
||||
ctx.onResponseUpdate = (response) => { ... }
|
||||
ctx.onResultsUpdate = ({ assertionResults, testResults }) => { ... }
|
||||
ctx.onVariablesUpdate = (variables) => { ... }
|
||||
\`\`\`
|
||||
|
||||
### Collection-/folder-level app (edited from the collection's own app editor — mentioned here only so you can answer questions about it)
|
||||
\`\`\`js
|
||||
ctx.theme / ctx.variables / ctx.setRuntimeVariable / ctx.log / ctx.onInit // as above
|
||||
ctx.collection // { name, pathname } | null
|
||||
ctx.listRequests() // Promise<Array<{ uid, name, pathname, type, method, url }>>
|
||||
ctx.runRequest(pathname, overrides?) // run a request by pathname; returns Promise<response>
|
||||
ctx.onThemeChange / ctx.onVariablesUpdate / ctx.onCollectionUpdate
|
||||
\`\`\`
|
||||
There is NO \`ctx.response\` / \`ctx.sendRequest\` / \`ctx.assertionResults\` / \`ctx.testResults\` at collection level. Reference requests by the \`pathname\` from \`ctx.listRequests()\`, not by name.
|
||||
|
||||
## RULES
|
||||
1. Generate a single self-contained HTML document (inline styles and scripts are fine — no external CDN)
|
||||
2. Keep the UI clean, readable, and accessible — neutral styling, no heavy gradients
|
||||
3. Write the COMPLETE document when using write_content`
|
||||
2. Use ONLY the \`ctx\` APIs listed above for the app's level — do not invent \`ctx\` methods, do not use \`fetch\` to call the API directly, and do not rely on Bruno internals beyond \`ctx\`
|
||||
3. CRITICAL: ctx data (\`ctx.response\`, \`ctx.variables\`, \`ctx.collection\`, …) is delivered asynchronously AFTER the page loads — reading it at the top level or in \`DOMContentLoaded\` yields null/empty. Do the initial render inside \`ctx.onInit = (ctx) => { ... }\` and react to later changes via the \`on*\` callbacks
|
||||
4. Always handle loading and error states around \`ctx.sendRequest\` / \`ctx.runRequest\`; bind UI updates to the \`on*\` callbacks instead of polling
|
||||
5. Theme changes toggle a \`light\`/\`dark\` class on \`document.body\` — style both states
|
||||
6. Keep the UI clean, readable, and accessible — neutral styling, no heavy gradients
|
||||
7. Write the COMPLETE document when using write_content`
|
||||
};
|
||||
|
||||
const SCOPE_GUARD = `## Scope
|
||||
|
||||
You are Bruno's built-in assistant. You ONLY help with the user's API workspace: requests and responses, authentication, environments and variables, pre-request/post-response scripts, tests, API documentation, Bruno apps, and debugging API calls.
|
||||
|
||||
If the user asks about anything unrelated — general knowledge, current events, people, politics, math homework, or programming tasks with no connection to this workspace — do NOT answer the question and do NOT call any tools. Reply with one short, friendly sentence saying you can only help with this API workspace, optionally suggesting something relevant you CAN do for the current request. Never generate or write content for an out-of-scope request, even if the user insists.
|
||||
`;
|
||||
|
||||
const TOOL_INSTRUCTIONS = `
|
||||
## How to respond
|
||||
|
||||
@@ -141,6 +179,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.
|
||||
- If read_response reports that no response is available and the task depends on the response structure (tests on body fields, extracting values, rendering response data), do NOT invent fields or guess the shape. Ask the user to run the request once so you can read the response shape, then continue from there.
|
||||
- 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
|
||||
@@ -174,7 +213,7 @@ const TOOL_LABELS = {
|
||||
const buildSystemPrompt = (contentType, hasMultipleContent) => {
|
||||
const base = SYSTEM_PROMPTS[contentType] || SYSTEM_PROMPTS.app;
|
||||
const hint = `\nThe user's active tab is '${contentType || 'app'}' — use that as the type for read_content / write_content unless they specify otherwise.`;
|
||||
let prompt = TOOL_INSTRUCTIONS + hint + '\n\n' + base;
|
||||
let prompt = SCOPE_GUARD + TOOL_INSTRUCTIONS + hint + '\n\n' + base;
|
||||
if (hasMultipleContent) {
|
||||
prompt += '\n\nNote: The user may ask you to modify other content types too (app, tests, pre-request, post-response, docs). The context message shows all available content.';
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ const {
|
||||
formatSearchVariablesResult
|
||||
} = require('./context');
|
||||
const { getPreferences } = require('../../store/preferences');
|
||||
const { isBuiltInModelId } = require('./providers');
|
||||
|
||||
const getSecurityPrefs = () => get(getPreferences(), 'ai.security', null);
|
||||
|
||||
@@ -238,8 +239,9 @@ const registerChatIpc = ({ mainWindow, resolveModel, pickDefaultModelId, isAiEna
|
||||
system: buildSystemPrompt(effectiveType, hasMultiple),
|
||||
messages: allMessages,
|
||||
tools,
|
||||
stopWhen: stepCountIs(5),
|
||||
stopWhen: stepCountIs(8),
|
||||
toolChoice: 'auto',
|
||||
maxOutputTokens: isBuiltInModelId(effectiveModelId) ? 16000 : undefined,
|
||||
abortSignal: controller.signal
|
||||
});
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ const {
|
||||
getAvailableModels,
|
||||
clearSdkCache,
|
||||
isKnownProviderId,
|
||||
isBuiltInModelId,
|
||||
validateApiKeyForProvider,
|
||||
providerLabel
|
||||
} = require('./providers');
|
||||
@@ -18,6 +19,7 @@ const {
|
||||
SCRIPT_TYPES,
|
||||
buildScriptSystemPrompt,
|
||||
buildScriptUserPrompt,
|
||||
parseDecline,
|
||||
stripCodeFences
|
||||
} = require('./script-prompts');
|
||||
const {
|
||||
@@ -29,6 +31,13 @@ const registerChatIpc = require('./chat');
|
||||
|
||||
const activeStreams = new Map();
|
||||
|
||||
const SCRIPT_MAX_OUTPUT_TOKENS = {
|
||||
'app-request': 16000,
|
||||
'app-collection': 16000,
|
||||
'docs': 8000
|
||||
};
|
||||
const DEFAULT_SCRIPT_MAX_OUTPUT_TOKENS = 4096;
|
||||
|
||||
const getAiPrefs = () => getPreferences().ai || {};
|
||||
|
||||
const getSecurityPrefs = () => getAiPrefs().security || null;
|
||||
@@ -251,9 +260,14 @@ const registerAiIpc = (mainWindow) => {
|
||||
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),
|
||||
stopWhen: stepCountIs(6),
|
||||
toolChoice: 'auto',
|
||||
maxOutputTokens: 2048,
|
||||
// Custom OpenAI-compatible models may be small local models that
|
||||
// reject a max_tokens above their context window, let the server
|
||||
// apply its own default for those instead of forcing a big cap.
|
||||
maxOutputTokens: isBuiltInModelId(modelId)
|
||||
? (SCRIPT_MAX_OUTPUT_TOKENS[scriptType] || DEFAULT_SCRIPT_MAX_OUTPUT_TOKENS)
|
||||
: undefined,
|
||||
abortSignal: controller?.signal
|
||||
});
|
||||
|
||||
@@ -269,6 +283,14 @@ const registerAiIpc = (mainWindow) => {
|
||||
return { stopped: true };
|
||||
}
|
||||
|
||||
// The model declines out-of-scope prompts (or ones missing required
|
||||
// context, e.g. "no response yet") via a sentinel line instead of
|
||||
// emitting unrelated code that would get applied to the user's file.
|
||||
const declineReason = parseDecline(fullText);
|
||||
if (declineReason) {
|
||||
return { error: declineReason, declined: true };
|
||||
}
|
||||
|
||||
const content = stripCodeFences(fullText);
|
||||
if (!content || !content.trim()) {
|
||||
return { error: 'No content was generated. Try rephrasing your prompt.' };
|
||||
|
||||
@@ -174,6 +174,8 @@ const resolveModelDefinition = (modelId, aiPreferences) => {
|
||||
return null;
|
||||
};
|
||||
|
||||
const isBuiltInModelId = (modelId) => Boolean(MODEL_DEFINITIONS[modelId]);
|
||||
|
||||
const providerLabel = (providerId, aiPreferences) => {
|
||||
if (PROVIDERS[providerId]) return PROVIDERS[providerId].label;
|
||||
const endpointId = endpointIdFromProviderId(providerId);
|
||||
@@ -270,6 +272,7 @@ module.exports = {
|
||||
providerIdFromEndpointId,
|
||||
getCompatEndpoint,
|
||||
isKnownProviderId,
|
||||
isBuiltInModelId,
|
||||
validateApiKeyForProvider,
|
||||
providerLabel
|
||||
};
|
||||
|
||||
@@ -74,11 +74,18 @@ expect(x).to.exist
|
||||
\`\`\`
|
||||
`;
|
||||
|
||||
const DECLINE_PREFIX = 'BRUNO_AI_DECLINE:';
|
||||
|
||||
const DECLINE_RULE = `If the request cannot be fulfilled as this content type — it is off-topic (general knowledge, trivia, anything unrelated to this API workspace), asks for something this editor cannot contain, or depends on information you do not have — do NOT generate placeholder or unrelated content. Instead output exactly one line and nothing else:
|
||||
${DECLINE_PREFIX} <one short sentence explaining why, or what the user should do first>`;
|
||||
|
||||
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.`;
|
||||
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.
|
||||
|
||||
${DECLINE_RULE}`;
|
||||
|
||||
const SCRIPT_PROMPTS = {
|
||||
'tests': `You are an AI assistant that writes test scripts for the Bruno API client.
|
||||
@@ -161,6 +168,7 @@ ctx.sendRequest(overrides?) // returns Promise<response>; overrides may c
|
||||
ctx.setRuntimeVariable(key, value) // persist a runtime variable on the collection
|
||||
ctx.log(...args) // forwarded to the Bruno devtools console
|
||||
|
||||
ctx.onInit = (ctx) => { ... } // called ONCE when the initial state arrives — do the first render here
|
||||
ctx.onThemeChange = (theme) => { ... }
|
||||
ctx.onResponseUpdate = (response) => { ... }
|
||||
ctx.onResultsUpdate = ({ assertionResults, testResults }) => { ... }
|
||||
@@ -171,6 +179,7 @@ Theme changes automatically add a \`light\` or \`dark\` class on \`document.body
|
||||
|
||||
## Best Practices
|
||||
|
||||
- CRITICAL: ctx data (\`ctx.response\`, \`ctx.variables\`, …) is delivered asynchronously AFTER the page loads. Reading it at the top level or in a \`DOMContentLoaded\` handler yields null/empty values. Do the initial render inside \`ctx.onInit\` and react to later changes via the granular \`on*\` callbacks.
|
||||
- Use modern JavaScript (async/await). Always handle loading and error states around \`ctx.sendRequest\`.
|
||||
- Bind UI updates to the \`on*\` callbacks so the app reacts to host updates without polling.
|
||||
- Do not rely on Bruno internals beyond \`ctx\`. Do not invent endpoints — the request URL/method is provided as HTTP Request Context.
|
||||
@@ -180,7 +189,9 @@ Theme changes automatically add a \`light\` or \`dark\` class on \`document.body
|
||||
|
||||
Return ONLY the raw HTML/CSS/JS for the app. No code fences, no commentary, no preamble. Begin with the first line of code (either a tag like \`<div>\` / \`<style>\` / \`<!DOCTYPE html>\`, or a \`<script>\` block).
|
||||
|
||||
If existing app code was provided, return the COMPLETE updated app (your output replaces the entire file). Preserve any existing markup or logic the user did not ask you to remove.`,
|
||||
If existing app code was provided, return the COMPLETE updated app (your output replaces the entire file). Preserve any existing markup or logic the user did not ask you to remove.
|
||||
|
||||
${DECLINE_RULE}`,
|
||||
|
||||
'app-collection': `You are an AI assistant that writes Bruno App code attached to a collection or folder.
|
||||
|
||||
@@ -200,8 +211,10 @@ ctx.runRequest(pathname, overrides?) // runs a single request by its pathname;
|
||||
ctx.setRuntimeVariable(key, value) // persist a runtime variable on the collection
|
||||
ctx.log(...args) // forwarded to the Bruno devtools console
|
||||
|
||||
ctx.onInit = (ctx) => { ... } // called ONCE when the initial state arrives — do the first render here
|
||||
ctx.onThemeChange = (theme) => { ... }
|
||||
ctx.onVariablesUpdate = (variables) => { ... }
|
||||
ctx.onCollectionUpdate = (collection) => { ... }
|
||||
\`\`\`
|
||||
|
||||
A collection-level app is NOT bound to a single request — use \`ctx.listRequests()\` to discover what is available and \`ctx.runRequest(pathname)\` to execute one. There is no \`ctx.response\` / \`ctx.sendRequest\` / \`ctx.assertionResults\` / \`ctx.testResults\` here — those exist only on request-level apps.
|
||||
@@ -210,6 +223,7 @@ Theme changes automatically add a \`light\` or \`dark\` class on \`document.body
|
||||
|
||||
## Best Practices
|
||||
|
||||
- CRITICAL: ctx data (\`ctx.collection\`, \`ctx.variables\`, …) is delivered asynchronously AFTER the page loads. Reading it at the top level or in a \`DOMContentLoaded\` handler yields null/empty values. Do the initial render inside \`ctx.onInit\` and react to later changes via the granular \`on*\` callbacks. (\`ctx.listRequests()\` / \`ctx.runRequest()\` return promises and are safe to call any time.)
|
||||
- Use modern JavaScript (async/await). Always handle loading and error states around \`ctx.runRequest\` and \`ctx.listRequests\`.
|
||||
- Reference requests by the \`pathname\` returned from \`ctx.listRequests()\`, not by name — names can collide.
|
||||
- When Documentation Context lists the collection's requests, you may pre-populate the UI with those names, but always discover via \`ctx.listRequests()\` at runtime so the app stays in sync as requests are added or renamed.
|
||||
@@ -219,7 +233,9 @@ Theme changes automatically add a \`light\` or \`dark\` class on \`document.body
|
||||
|
||||
Return ONLY the raw HTML/CSS/JS for the app. No code fences, no commentary, no preamble. Begin with the first line of code (either a tag like \`<div>\` / \`<style>\` / \`<!DOCTYPE html>\`, or a \`<script>\` block).
|
||||
|
||||
If existing app code was provided, return the COMPLETE updated app (your output replaces the entire file). Preserve any existing markup or logic the user did not ask you to remove.`,
|
||||
If existing app code was provided, return the COMPLETE updated app (your output replaces the entire file). Preserve any existing markup or logic the user did not ask you to remove.
|
||||
|
||||
${DECLINE_RULE}`,
|
||||
|
||||
'docs': `You are an AI assistant that writes API documentation in Markdown for the Bruno API client.
|
||||
|
||||
@@ -246,7 +262,9 @@ When Documentation Context is provided:
|
||||
|
||||
Return ONLY raw Markdown that can be saved directly. No wrapping commentary, no preamble like "Here is the documentation". Begin with the first line of Markdown.
|
||||
|
||||
If existing documentation was provided, return the COMPLETE updated document (your output replaces the entire file). Preserve any existing content the user did not ask you to remove.`
|
||||
If existing documentation was provided, return the COMPLETE updated document (your output replaces the entire file). Preserve any existing content the user did not ask you to remove.
|
||||
|
||||
${DECLINE_RULE}`
|
||||
};
|
||||
|
||||
const SCRIPT_TYPES = Object.keys(SCRIPT_PROMPTS);
|
||||
@@ -333,6 +351,19 @@ const buildScriptUserPrompt = ({
|
||||
return sections.join('\n\n');
|
||||
};
|
||||
|
||||
/**
|
||||
* Detect the decline sentinel in generated output. Returns the reason string
|
||||
* when the model declined, or null when the output is real content. Tolerates
|
||||
* a leading fence/whitespace the model may have wrapped around the sentinel.
|
||||
*/
|
||||
const parseDecline = (text) => {
|
||||
if (!text) return null;
|
||||
const cleaned = stripCodeFences(text).trim();
|
||||
if (!cleaned.startsWith(DECLINE_PREFIX)) return null;
|
||||
const reason = cleaned.slice(DECLINE_PREFIX.length).split('\n')[0].trim();
|
||||
return reason || 'This request is outside what can be generated here.';
|
||||
};
|
||||
|
||||
const stripCodeFences = (text) => {
|
||||
if (!text) return '';
|
||||
let out = text.trim();
|
||||
@@ -356,7 +387,10 @@ You may call these tools BEFORE producing the final code to gather context. Do n
|
||||
- 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.`;
|
||||
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.
|
||||
|
||||
If the task depends on the response structure (asserting body fields, extracting values, rendering response data) and read_response reports that no response is available, do NOT invent field names or guess the shape. Decline instead:
|
||||
${DECLINE_PREFIX} Run the request once so I can read the response structure, then try again.`;
|
||||
|
||||
const buildScriptSystemPrompt = (scriptType) => {
|
||||
const base = SCRIPT_PROMPTS[scriptType];
|
||||
@@ -367,8 +401,10 @@ const buildScriptSystemPrompt = (scriptType) => {
|
||||
module.exports = {
|
||||
SCRIPT_PROMPTS,
|
||||
SCRIPT_TYPES,
|
||||
DECLINE_PREFIX,
|
||||
buildScriptSystemPrompt,
|
||||
buildScriptUserPrompt,
|
||||
formatDocsContext,
|
||||
parseDecline,
|
||||
stripCodeFences
|
||||
};
|
||||
|
||||
46
packages/bruno-electron/src/ipc/ai/script-prompts.spec.js
Normal file
46
packages/bruno-electron/src/ipc/ai/script-prompts.spec.js
Normal file
@@ -0,0 +1,46 @@
|
||||
const { parseDecline, stripCodeFences, DECLINE_PREFIX, buildScriptSystemPrompt } = require('./script-prompts');
|
||||
|
||||
describe('parseDecline', () => {
|
||||
it('returns null for normal generated code', () => {
|
||||
expect(parseDecline('test("status", function() {});')).toBeNull();
|
||||
expect(parseDecline('<div>app</div>')).toBeNull();
|
||||
expect(parseDecline('')).toBeNull();
|
||||
expect(parseDecline(null)).toBeNull();
|
||||
});
|
||||
|
||||
it('extracts the reason from a decline line', () => {
|
||||
expect(parseDecline(`${DECLINE_PREFIX} This is not related to the API.`))
|
||||
.toBe('This is not related to the API.');
|
||||
});
|
||||
|
||||
it('tolerates surrounding whitespace and code fences', () => {
|
||||
expect(parseDecline(`\n\`\`\`\n${DECLINE_PREFIX} Run the request first.\n\`\`\`\n`))
|
||||
.toBe('Run the request first.');
|
||||
});
|
||||
|
||||
it('only reads the first line of the reason', () => {
|
||||
expect(parseDecline(`${DECLINE_PREFIX} Off topic.\nconst x = 1;`)).toBe('Off topic.');
|
||||
});
|
||||
|
||||
it('falls back to a generic reason when the sentinel is bare', () => {
|
||||
expect(parseDecline(`${DECLINE_PREFIX}`)).toBe('This request is outside what can be generated here.');
|
||||
});
|
||||
|
||||
it('does not fire when the sentinel appears mid-content', () => {
|
||||
expect(parseDecline(`const note = "${DECLINE_PREFIX} nope";`)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildScriptSystemPrompt', () => {
|
||||
it('includes the decline rule for every script type', () => {
|
||||
for (const type of ['tests', 'pre-request', 'post-response', 'docs', 'app-request', 'app-collection']) {
|
||||
expect(buildScriptSystemPrompt(type)).toContain(DECLINE_PREFIX);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('stripCodeFences', () => {
|
||||
it('still strips fences and preambles', () => {
|
||||
expect(stripCodeFences('```js\nconst a = 1;\n```')).toBe('const a = 1;');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user