mirror of
https://github.com/usebruno/bruno.git
synced 2026-06-22 20:25:38 +00:00
feat(ai-assist): add docs generation with folder and collection context (#8246)
This commit is contained in:
@@ -21,18 +21,29 @@ const SUGGESTIONS = {
|
||||
{ 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' }
|
||||
],
|
||||
'docs': [
|
||||
{ label: 'Overview', prompt: 'Write an overview section describing the purpose and key features' },
|
||||
{ label: 'Request', prompt: 'Document the request method, URL, headers, parameters, and body' },
|
||||
{ label: 'Examples', prompt: 'Add request and response examples with sample JSON' },
|
||||
{ label: 'Errors', prompt: 'Document common error responses and status codes' }
|
||||
]
|
||||
};
|
||||
|
||||
const TITLES = {
|
||||
'tests': 'Generate Tests',
|
||||
'pre-request': 'Generate Pre-Request Script',
|
||||
'post-response': 'Generate Post-Response Script'
|
||||
'post-response': 'Generate Post-Response Script',
|
||||
'docs': 'Generate Documentation'
|
||||
};
|
||||
|
||||
const PREVIEW_LABELS = {
|
||||
docs: 'Preview · replaces current documentation'
|
||||
};
|
||||
|
||||
const isValidType = (t) => SUGGESTIONS[t] !== undefined;
|
||||
|
||||
const AIAssist = ({ scriptType, currentScript, requestContext, onApply }) => {
|
||||
const AIAssist = ({ scriptType, currentScript, requestContext, docsContext, onApply }) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [prompt, setPrompt] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
@@ -49,6 +60,7 @@ const AIAssist = ({ scriptType, currentScript, requestContext, onApply }) => {
|
||||
|
||||
const suggestions = useMemo(() => SUGGESTIONS[scriptType] || [], [scriptType]);
|
||||
const title = TITLES[scriptType] || 'Generate with AI';
|
||||
const previewLabel = PREVIEW_LABELS[scriptType] || 'Preview · replaces current script';
|
||||
|
||||
const close = useCallback(() => {
|
||||
setIsOpen(false);
|
||||
@@ -85,7 +97,8 @@ const AIAssist = ({ scriptType, currentScript, requestContext, onApply }) => {
|
||||
scriptType,
|
||||
prompt: text,
|
||||
currentScript: currentScript || '',
|
||||
requestContext
|
||||
requestContext,
|
||||
docsContext
|
||||
});
|
||||
if (result?.error) {
|
||||
setError(result.error);
|
||||
@@ -102,7 +115,7 @@ const AIAssist = ({ scriptType, currentScript, requestContext, onApply }) => {
|
||||
setIsLoading(false);
|
||||
}
|
||||
},
|
||||
[prompt, isLoading, scriptType, currentScript, requestContext]
|
||||
[prompt, isLoading, scriptType, currentScript, requestContext, docsContext]
|
||||
);
|
||||
|
||||
const handleApply = useCallback(() => {
|
||||
@@ -206,7 +219,7 @@ const AIAssist = ({ scriptType, currentScript, requestContext, onApply }) => {
|
||||
<>
|
||||
<div className="popup-body">
|
||||
<div className="preview-section">
|
||||
<span className="preview-label">Preview · replaces current script</span>
|
||||
<span className="preview-label">{previewLabel}</span>
|
||||
<pre className="preview-code">{generated}</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
356
packages/bruno-app/src/components/AIAssist/index.spec.js
Normal file
356
packages/bruno-app/src/components/AIAssist/index.spec.js
Normal file
@@ -0,0 +1,356 @@
|
||||
import '@testing-library/jest-dom';
|
||||
import React from 'react';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { Provider } from 'react-redux';
|
||||
import { configureStore } from '@reduxjs/toolkit';
|
||||
import { ThemeProvider } from 'styled-components';
|
||||
import { aiGenerateScript } from 'utils/ai';
|
||||
import AIAssist from './index';
|
||||
|
||||
jest.mock('utils/ai', () => ({
|
||||
aiGenerateScript: jest.fn()
|
||||
}));
|
||||
|
||||
const theme = {
|
||||
bg: '#1e1e1e',
|
||||
text: '#ffffff',
|
||||
border: { radius: { sm: '4px', md: '6px' } },
|
||||
colors: {
|
||||
accent: '#6366f1',
|
||||
text: { muted: '#9ca3af', danger: '#ef4444' },
|
||||
bg: { danger: '#ef4444' }
|
||||
},
|
||||
input: {
|
||||
border: '#374151',
|
||||
bg: '#111827',
|
||||
focusBorder: '#6366f1'
|
||||
},
|
||||
font: { monospace: 'monospace' }
|
||||
};
|
||||
|
||||
const createStore = (aiEnabled = true) => configureStore({
|
||||
reducer: {
|
||||
app: (state = { preferences: { ai: { enabled: aiEnabled } } }) => state
|
||||
}
|
||||
});
|
||||
|
||||
const defaultProps = {
|
||||
scriptType: 'tests',
|
||||
currentScript: 'test("ok", () => {});',
|
||||
onApply: jest.fn()
|
||||
};
|
||||
|
||||
const renderAIAssist = ({
|
||||
props = {},
|
||||
aiEnabled = true
|
||||
} = {}) => {
|
||||
const mergedProps = { ...defaultProps, ...props };
|
||||
return render(
|
||||
<Provider store={createStore(aiEnabled)}>
|
||||
<ThemeProvider theme={theme}>
|
||||
<AIAssist {...mergedProps} />
|
||||
</ThemeProvider>
|
||||
</Provider>
|
||||
);
|
||||
};
|
||||
|
||||
const openPopup = () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Generate Tests' }));
|
||||
};
|
||||
|
||||
describe('AIAssist', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
aiGenerateScript.mockResolvedValue({ content: 'test("generated", () => {});' });
|
||||
});
|
||||
|
||||
describe('visibility', () => {
|
||||
it('renders nothing when AI is disabled', () => {
|
||||
const { container } = renderAIAssist({ aiEnabled: false });
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it('renders nothing for an unsupported script type', () => {
|
||||
const { container } = renderAIAssist({ props: { scriptType: 'unknown-type' } });
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it('renders the trigger when AI is enabled and the script type is supported', () => {
|
||||
renderAIAssist();
|
||||
expect(screen.getByRole('button', { name: 'Generate Tests' })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('titles', () => {
|
||||
it.each([
|
||||
['tests', 'Generate Tests'],
|
||||
['pre-request', 'Generate Pre-Request Script'],
|
||||
['post-response', 'Generate Post-Response Script'],
|
||||
['docs', 'Generate Documentation']
|
||||
])('uses the correct title for %s', (scriptType, title) => {
|
||||
renderAIAssist({ props: { scriptType } });
|
||||
expect(screen.getByRole('button', { name: title })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('popup interactions', () => {
|
||||
it('opens and closes the popup from the trigger and close button', () => {
|
||||
renderAIAssist();
|
||||
openPopup();
|
||||
|
||||
expect(screen.getByRole('dialog', { name: 'Generate Tests' })).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Close' }));
|
||||
expect(screen.queryByRole('dialog', { name: 'Generate Tests' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('closes the popup when Escape is pressed', () => {
|
||||
renderAIAssist();
|
||||
openPopup();
|
||||
|
||||
fireEvent.keyDown(document, { key: 'Escape' });
|
||||
expect(screen.queryByRole('dialog', { name: 'Generate Tests' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('closes the popup when clicking outside', () => {
|
||||
renderAIAssist();
|
||||
openPopup();
|
||||
|
||||
fireEvent.mouseDown(document.body);
|
||||
expect(screen.queryByRole('dialog', { name: 'Generate Tests' })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('prompt view', () => {
|
||||
it('shows suggestion chips when the prompt is empty', () => {
|
||||
renderAIAssist();
|
||||
openPopup();
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Status 200' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'JSON body' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows docs suggestions for the docs script type', () => {
|
||||
renderAIAssist({ props: { scriptType: 'docs', currentScript: '# Overview' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Generate Documentation' }));
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Overview' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Request' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides suggestions once the user starts typing', () => {
|
||||
renderAIAssist();
|
||||
openPopup();
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('Describe what you want to generate...'), {
|
||||
target: { value: 'Add a status test' }
|
||||
});
|
||||
|
||||
expect(screen.queryByRole('button', { name: 'Status 200' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('keeps Generate disabled until the prompt has text', () => {
|
||||
renderAIAssist();
|
||||
openPopup();
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Generate' })).toBeDisabled();
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('Describe what you want to generate...'), {
|
||||
target: { value: 'Add a status test' }
|
||||
});
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Generate' })).toBeEnabled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('generation flow', () => {
|
||||
it('generates from a suggestion chip', async () => {
|
||||
renderAIAssist();
|
||||
openPopup();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Status 200' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(aiGenerateScript).toHaveBeenCalledWith({
|
||||
scriptType: 'tests',
|
||||
prompt: 'Add a test asserting the response status code is 200',
|
||||
currentScript: 'test("ok", () => {});',
|
||||
requestContext: undefined
|
||||
});
|
||||
});
|
||||
|
||||
expect(screen.getByText('test("generated", () => {});')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('passes docs context for folder and collection documentation', async () => {
|
||||
const docsContext = {
|
||||
scope: 'folder',
|
||||
name: 'Users',
|
||||
collectionName: 'Pet Store API',
|
||||
folders: [{ name: 'Admin', requestCount: 1, subfolderCount: 0 }],
|
||||
requests: [{ name: 'List Users', method: 'GET', url: '{{base}}/users' }]
|
||||
};
|
||||
|
||||
renderAIAssist({ props: { scriptType: 'docs', currentScript: '', docsContext } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Generate Documentation' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Overview' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(aiGenerateScript).toHaveBeenCalledWith({
|
||||
scriptType: 'docs',
|
||||
prompt: 'Write an overview section describing the purpose and key features',
|
||||
currentScript: '',
|
||||
requestContext: undefined,
|
||||
docsContext
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('generates from the prompt input and passes request context', async () => {
|
||||
const requestContext = {
|
||||
method: 'GET',
|
||||
url: 'https://api.example.com/users',
|
||||
headers: [],
|
||||
params: [],
|
||||
body: null
|
||||
};
|
||||
|
||||
renderAIAssist({ props: { requestContext } });
|
||||
openPopup();
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('Describe what you want to generate...'), {
|
||||
target: { value: 'Add auth header test' }
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Generate' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(aiGenerateScript).toHaveBeenCalledWith({
|
||||
scriptType: 'tests',
|
||||
prompt: 'Add auth header test',
|
||||
currentScript: 'test("ok", () => {});',
|
||||
requestContext
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
['metaKey', { metaKey: true }],
|
||||
['ctrlKey', { ctrlKey: true }]
|
||||
])('generates when pressing modifier+Enter using %s', async (_modifier, keyEvent) => {
|
||||
renderAIAssist();
|
||||
openPopup();
|
||||
|
||||
const textarea = screen.getByPlaceholderText('Describe what you want to generate...');
|
||||
fireEvent.change(textarea, { target: { value: 'Add response time test' } });
|
||||
fireEvent.keyDown(textarea, { key: 'Enter', ...keyEvent });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(aiGenerateScript).toHaveBeenCalledWith({
|
||||
scriptType: 'tests',
|
||||
prompt: 'Add response time test',
|
||||
currentScript: 'test("ok", () => {});',
|
||||
requestContext: undefined
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('shows a loading state while generation is in progress', async () => {
|
||||
let resolveGenerate;
|
||||
aiGenerateScript.mockImplementation(() => new Promise((resolve) => {
|
||||
resolveGenerate = resolve;
|
||||
}));
|
||||
|
||||
renderAIAssist();
|
||||
openPopup();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Status 200' }));
|
||||
|
||||
expect(screen.getByText('Generating...')).toBeInTheDocument();
|
||||
|
||||
resolveGenerate({ content: 'test("done", () => {});' });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('test("done", () => {});')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows an API error without entering preview mode', async () => {
|
||||
aiGenerateScript.mockResolvedValue({ error: 'Provider unavailable' });
|
||||
|
||||
renderAIAssist();
|
||||
openPopup();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Status 200' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Provider unavailable')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.queryByRole('button', { name: 'Apply' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows a fallback error when no content is returned', async () => {
|
||||
aiGenerateScript.mockResolvedValue({});
|
||||
|
||||
renderAIAssist();
|
||||
openPopup();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Status 200' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('No content was generated. Try rephrasing your prompt.')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('preview and apply', () => {
|
||||
const showPreview = async () => {
|
||||
renderAIAssist();
|
||||
openPopup();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Status 200' }));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: 'Apply' })).toBeInTheDocument();
|
||||
});
|
||||
};
|
||||
|
||||
it('uses the script preview label for script types', async () => {
|
||||
await showPreview();
|
||||
expect(screen.getByText('Preview · replaces current script')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('uses the documentation preview label for docs', async () => {
|
||||
aiGenerateScript.mockResolvedValue({ content: '# API Docs' });
|
||||
|
||||
renderAIAssist({ props: { scriptType: 'docs', currentScript: '# Existing' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Generate Documentation' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Overview' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Preview · replaces current documentation')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText('# API Docs')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('applies generated content and closes the popup', async () => {
|
||||
const onApply = jest.fn();
|
||||
renderAIAssist({ props: { onApply } });
|
||||
openPopup();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Status 200' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: 'Apply' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Apply' }));
|
||||
|
||||
expect(onApply).toHaveBeenCalledWith('test("generated", () => {});');
|
||||
expect(screen.queryByRole('dialog', { name: 'Generate Tests' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('returns to the prompt view when Back is clicked', async () => {
|
||||
await showPreview();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Back' }));
|
||||
|
||||
expect(screen.getByPlaceholderText('Describe what you want to generate...')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Status 200' })).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'Apply' })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -4,11 +4,13 @@ import find from 'lodash/find';
|
||||
import { updateCollectionDocs, deleteCollectionDraft } from 'providers/ReduxStore/slices/collections';
|
||||
import { updateDocsEditing } from 'providers/ReduxStore/slices/tabs';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
import { useRef } from 'react';
|
||||
import { useMemo, useRef } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { saveCollectionSettings } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import Markdown from 'components/MarkDown';
|
||||
import CodeEditor from 'components/CodeEditor';
|
||||
import AIAssist from 'components/AIAssist';
|
||||
import { buildDocsContextFromCollection } from 'utils/ai';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { IconEdit, IconX, IconFileText } from '@tabler/icons';
|
||||
import Button from 'ui/Button/index';
|
||||
@@ -25,6 +27,7 @@ const Docs = ({ collection }) => {
|
||||
const isEditing = focusedTab?.docsEditing || false;
|
||||
const docs = collection.draft?.root ? get(collection, 'draft.root.docs', '') : get(collection, 'root.docs', '');
|
||||
const preferences = useSelector((state) => state.app.preferences);
|
||||
const docsContext = useMemo(() => buildDocsContextFromCollection(collection), [collection]);
|
||||
|
||||
// StyledWrapper has overflow-y: auto — use null selector.
|
||||
// Preview mode: hook tracks wrapper scroll. Edit mode: CodeEditor's onScroll/initialScroll.
|
||||
@@ -85,18 +88,21 @@ const Docs = ({ collection }) => {
|
||||
</div>
|
||||
</div>
|
||||
{isEditing ? (
|
||||
<CodeEditor
|
||||
collection={collection}
|
||||
theme={displayedTheme}
|
||||
value={docs}
|
||||
onEdit={onEdit}
|
||||
onSave={onSave}
|
||||
mode="application/text"
|
||||
font={get(preferences, 'font.codeFont', 'default')}
|
||||
fontSize={get(preferences, 'font.codeFontSize')}
|
||||
initialScroll={scroll}
|
||||
onScroll={setScroll}
|
||||
/>
|
||||
<div className="relative flex-1 min-h-0">
|
||||
<CodeEditor
|
||||
collection={collection}
|
||||
theme={displayedTheme}
|
||||
value={docs}
|
||||
onEdit={onEdit}
|
||||
onSave={onSave}
|
||||
mode="application/text"
|
||||
font={get(preferences, 'font.codeFont', 'default')}
|
||||
fontSize={get(preferences, 'font.codeFontSize')}
|
||||
initialScroll={scroll}
|
||||
onScroll={setScroll}
|
||||
/>
|
||||
<AIAssist scriptType="docs" currentScript={docs || ''} docsContext={docsContext} onApply={onEdit} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="pl-1">
|
||||
<div className="h-[1px] min-h-[500px]">
|
||||
|
||||
@@ -4,11 +4,13 @@ import find from 'lodash/find';
|
||||
import { updateRequestDocs } from 'providers/ReduxStore/slices/collections';
|
||||
import { updateDocsEditing } from 'providers/ReduxStore/slices/tabs';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
import { useRef } from 'react';
|
||||
import { useMemo, useRef } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { saveRequest } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import Markdown from 'components/MarkDown';
|
||||
import CodeEditor from 'components/CodeEditor';
|
||||
import AIAssist from 'components/AIAssist';
|
||||
import { buildRequestContextFromItem } from 'utils/ai';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { usePersistedState } from 'hooks/usePersistedState';
|
||||
import { useTrackScroll } from 'hooks/useTrackScroll';
|
||||
@@ -42,6 +44,7 @@ const Documentation = ({ item, collection }) => {
|
||||
};
|
||||
|
||||
const onSave = () => dispatch(saveRequest(item.uid, collection.uid));
|
||||
const requestContext = useMemo(() => buildRequestContextFromItem(item), [item]);
|
||||
|
||||
if (!item) {
|
||||
return null;
|
||||
@@ -54,18 +57,26 @@ const Documentation = ({ item, collection }) => {
|
||||
</div>
|
||||
|
||||
{isEditing ? (
|
||||
<CodeEditor
|
||||
collection={collection}
|
||||
theme={displayedTheme}
|
||||
font={get(preferences, 'font.codeFont', 'default')}
|
||||
fontSize={get(preferences, 'font.codeFontSize')}
|
||||
value={docs || ''}
|
||||
onEdit={onEdit}
|
||||
onSave={onSave}
|
||||
mode="application/text"
|
||||
initialScroll={scroll}
|
||||
onScroll={setScroll}
|
||||
/>
|
||||
<div className="relative flex-1 min-h-0">
|
||||
<CodeEditor
|
||||
collection={collection}
|
||||
theme={displayedTheme}
|
||||
font={get(preferences, 'font.codeFont', 'default')}
|
||||
fontSize={get(preferences, 'font.codeFontSize')}
|
||||
value={docs || ''}
|
||||
onEdit={onEdit}
|
||||
onSave={onSave}
|
||||
mode="application/text"
|
||||
initialScroll={scroll}
|
||||
onScroll={setScroll}
|
||||
/>
|
||||
<AIAssist
|
||||
scriptType="docs"
|
||||
currentScript={docs || ''}
|
||||
requestContext={requestContext}
|
||||
onApply={onEdit}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<Markdown collectionPath={collection.pathname} onDoubleClick={toggleViewMode} content={docs} />
|
||||
)}
|
||||
|
||||
@@ -4,11 +4,13 @@ import find from 'lodash/find';
|
||||
import { updateFolderDocs } from 'providers/ReduxStore/slices/collections';
|
||||
import { updateDocsEditing } from 'providers/ReduxStore/slices/tabs';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
import { useRef } from 'react';
|
||||
import { useMemo, useRef } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { saveFolderRoot } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import Markdown from 'components/MarkDown';
|
||||
import CodeEditor from 'components/CodeEditor';
|
||||
import AIAssist from 'components/AIAssist';
|
||||
import { buildDocsContextFromFolder } from 'utils/ai';
|
||||
import Button from 'ui/Button';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { usePersistedState } from 'hooks/usePersistedState';
|
||||
@@ -43,6 +45,7 @@ const Documentation = ({ collection, folder }) => {
|
||||
};
|
||||
|
||||
const onSave = () => dispatch(saveFolderRoot(collection.uid, folder.uid));
|
||||
const docsContext = useMemo(() => buildDocsContextFromFolder(collection, folder), [collection, folder]);
|
||||
|
||||
if (!folder) {
|
||||
return null;
|
||||
@@ -56,7 +59,7 @@ const Documentation = ({ collection, folder }) => {
|
||||
|
||||
{isEditing ? (
|
||||
<div className="flex flex-col flex-1 min-h-0">
|
||||
<div className="mt-2 flex-1 overflow-auto min-h-0">
|
||||
<div className="mt-2 flex-1 overflow-auto min-h-0 relative">
|
||||
<CodeEditor
|
||||
collection={collection}
|
||||
theme={displayedTheme}
|
||||
@@ -69,6 +72,7 @@ const Documentation = ({ collection, folder }) => {
|
||||
initialScroll={scroll}
|
||||
onScroll={setScroll}
|
||||
/>
|
||||
<AIAssist scriptType="docs" currentScript={docs || ''} docsContext={docsContext} onApply={onEdit} />
|
||||
</div>
|
||||
<div className="mt-6 flex-shrink-0">
|
||||
<Button type="submit" size="sm" onClick={onSave}>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { callIpc } from 'utils/common/ipc';
|
||||
import { isItemAFolder, isItemARequest, sortItemsBySidebarOrder } from 'utils/collections';
|
||||
|
||||
/**
|
||||
* Renderer-side wrapper around the AI IPC channels.
|
||||
@@ -41,6 +42,66 @@ export const buildRequestContextFromItem = (item) => {
|
||||
};
|
||||
};
|
||||
|
||||
const summarizeDocsItems = (items = []) => {
|
||||
const folders = [];
|
||||
const requests = [];
|
||||
|
||||
for (const item of sortItemsBySidebarOrder(items)) {
|
||||
if (item.isTransient) continue;
|
||||
|
||||
if (isItemAFolder(item)) {
|
||||
const nestedItems = item.items || [];
|
||||
const nestedRequests = nestedItems.filter((i) => isItemARequest(i) && !i.isTransient);
|
||||
const nestedFolders = nestedItems.filter((i) => isItemAFolder(i) && !i.isTransient);
|
||||
folders.push({
|
||||
name: item.name,
|
||||
requestCount: nestedRequests.length,
|
||||
subfolderCount: nestedFolders.length
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isItemARequest(item)) {
|
||||
const req = item.draft?.request || item.request;
|
||||
requests.push({
|
||||
name: item.name,
|
||||
method: req?.method || 'GET',
|
||||
url: req?.url || '',
|
||||
type: item.type
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { folders, requests };
|
||||
};
|
||||
|
||||
export const buildDocsContextFromCollection = (collection) => {
|
||||
if (!collection) return null;
|
||||
|
||||
const { folders, requests } = summarizeDocsItems(collection.items || []);
|
||||
|
||||
return {
|
||||
scope: 'collection',
|
||||
name: collection.name || '',
|
||||
folders,
|
||||
requests
|
||||
};
|
||||
};
|
||||
|
||||
export const buildDocsContextFromFolder = (collection, folder) => {
|
||||
if (!folder) return null;
|
||||
|
||||
const { folders, requests } = summarizeDocsItems(folder.items || []);
|
||||
|
||||
return {
|
||||
scope: 'folder',
|
||||
name: folder.name || '',
|
||||
collectionName: collection?.name || '',
|
||||
folders,
|
||||
requests
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Start a streaming generation. Subscribes to the corresponding `main:ai-stream-*`
|
||||
* channels filtered by streamId. Returns a handle with `.stop()` and a promise
|
||||
|
||||
118
packages/bruno-app/src/utils/ai/index.spec.js
Normal file
118
packages/bruno-app/src/utils/ai/index.spec.js
Normal file
@@ -0,0 +1,118 @@
|
||||
import {
|
||||
buildDocsContextFromCollection,
|
||||
buildDocsContextFromFolder,
|
||||
buildRequestContextFromItem
|
||||
} from './index';
|
||||
|
||||
describe('utils/ai', () => {
|
||||
const collection = {
|
||||
name: 'Pet Store API',
|
||||
items: [
|
||||
{
|
||||
uid: 'f1',
|
||||
type: 'folder',
|
||||
name: 'Users',
|
||||
items: [
|
||||
{
|
||||
uid: 'r1',
|
||||
type: 'http-request',
|
||||
name: 'List Users',
|
||||
request: { method: 'GET', url: '{{base}}/users' }
|
||||
},
|
||||
{
|
||||
uid: 'f2',
|
||||
type: 'folder',
|
||||
name: 'Admin',
|
||||
items: [
|
||||
{
|
||||
uid: 'r2',
|
||||
type: 'http-request',
|
||||
name: 'Delete User',
|
||||
request: { method: 'DELETE', url: '{{base}}/users/1' }
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
uid: 'r3',
|
||||
type: 'http-request',
|
||||
name: 'Health Check',
|
||||
request: { method: 'GET', url: '{{base}}/health' }
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
describe('buildDocsContextFromCollection', () => {
|
||||
it('summarizes top-level folders and requests', () => {
|
||||
expect(buildDocsContextFromCollection(collection)).toEqual({
|
||||
scope: 'collection',
|
||||
name: 'Pet Store API',
|
||||
folders: [
|
||||
{
|
||||
name: 'Users',
|
||||
requestCount: 1,
|
||||
subfolderCount: 1
|
||||
}
|
||||
],
|
||||
requests: [
|
||||
{
|
||||
name: 'Health Check',
|
||||
method: 'GET',
|
||||
url: '{{base}}/health',
|
||||
type: 'http-request'
|
||||
}
|
||||
]
|
||||
});
|
||||
});
|
||||
|
||||
it('returns null when collection is missing', () => {
|
||||
expect(buildDocsContextFromCollection(null)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildDocsContextFromFolder', () => {
|
||||
it('summarizes direct child folders and requests for the folder scope', () => {
|
||||
const folder = collection.items[0];
|
||||
|
||||
expect(buildDocsContextFromFolder(collection, folder)).toEqual({
|
||||
scope: 'folder',
|
||||
name: 'Users',
|
||||
collectionName: 'Pet Store API',
|
||||
folders: [
|
||||
{
|
||||
name: 'Admin',
|
||||
requestCount: 1,
|
||||
subfolderCount: 0
|
||||
}
|
||||
],
|
||||
requests: [
|
||||
{
|
||||
name: 'List Users',
|
||||
method: 'GET',
|
||||
url: '{{base}}/users',
|
||||
type: 'http-request'
|
||||
}
|
||||
]
|
||||
});
|
||||
});
|
||||
|
||||
it('returns null when folder is missing', () => {
|
||||
expect(buildDocsContextFromFolder(collection, null)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildRequestContextFromItem', () => {
|
||||
it('builds request details for request-level docs', () => {
|
||||
const item = collection.items[1];
|
||||
|
||||
expect(buildRequestContextFromItem(item)).toEqual({
|
||||
url: '{{base}}/health',
|
||||
method: 'GET',
|
||||
headers: [],
|
||||
params: [],
|
||||
body: null
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -145,7 +145,7 @@ const registerAiIpc = (mainWindow) => {
|
||||
});
|
||||
|
||||
ipcMain.handle('renderer:ai-generate-script', async (_event, params) => {
|
||||
const { scriptType, prompt, currentScript, requestContext, model: requestedModel } = params || {};
|
||||
const { scriptType, prompt, currentScript, requestContext, docsContext, model: requestedModel } = params || {};
|
||||
|
||||
if (!SCRIPT_TYPES.includes(scriptType)) {
|
||||
return { error: `Unknown scriptType: ${scriptType}` };
|
||||
@@ -170,7 +170,7 @@ const registerAiIpc = (mainWindow) => {
|
||||
const { text } = await generateText({
|
||||
model,
|
||||
system: SCRIPT_PROMPTS[scriptType],
|
||||
prompt: buildScriptUserPrompt({ userPrompt: prompt, currentScript, requestContext }),
|
||||
prompt: buildScriptUserPrompt({ userPrompt: prompt, currentScript, requestContext, docsContext, scriptType }),
|
||||
maxOutputTokens: 2048
|
||||
});
|
||||
return { content: stripCodeFences(text), modelId };
|
||||
|
||||
@@ -140,11 +140,78 @@ Common use cases:
|
||||
|
||||
Do NOT use \`test()\` or \`expect()\` — those belong in the Tests tab.
|
||||
|
||||
${COMMON_OUTPUT_RULES}`
|
||||
${COMMON_OUTPUT_RULES}`,
|
||||
|
||||
'docs': `You are an AI assistant that writes API documentation in Markdown for the Bruno API client.
|
||||
|
||||
## Documentation Context
|
||||
|
||||
Documentation is stored as Markdown and rendered in Bruno's Docs tab. It supports standard Markdown: headings, lists, tables, code blocks, links, and emphasis.
|
||||
|
||||
Write clear, practical API documentation. Common sections include:
|
||||
- Overview and purpose
|
||||
- Authentication requirements
|
||||
- Request details (method, URL, headers, parameters, body)
|
||||
- Response format and status codes
|
||||
- Example requests and responses
|
||||
- Error handling
|
||||
|
||||
Use fenced code blocks with language tags for HTTP, JSON, curl, or other examples.
|
||||
|
||||
When Documentation Context is provided:
|
||||
- For a collection, write documentation for the whole collection using its top-level folders and requests.
|
||||
- For a folder, write documentation scoped to that folder using its direct subfolders and requests.
|
||||
- Reference the listed requests and subfolders by name. Do not invent endpoints that are not in the context.
|
||||
|
||||
## Output Rules
|
||||
|
||||
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.`
|
||||
};
|
||||
|
||||
const SCRIPT_TYPES = Object.keys(SCRIPT_PROMPTS);
|
||||
|
||||
const formatChildCount = (count, singular, plural) => {
|
||||
if (!count) return '';
|
||||
return `${count} ${count === 1 ? singular : plural}`;
|
||||
};
|
||||
|
||||
const formatDocsContext = (ctx) => {
|
||||
if (!ctx) return '';
|
||||
const parts = [];
|
||||
|
||||
if (ctx.scope === 'collection') {
|
||||
parts.push(`Collection: ${ctx.name || 'Untitled'}`);
|
||||
} else if (ctx.scope === 'folder') {
|
||||
if (ctx.collectionName) parts.push(`Collection: ${ctx.collectionName}`);
|
||||
parts.push(`Folder: ${ctx.name || 'Untitled'}`);
|
||||
}
|
||||
|
||||
const folders = ctx.folders || [];
|
||||
if (folders.length) {
|
||||
parts.push(`Subfolders:\n${folders.map((folder) => {
|
||||
const details = [
|
||||
formatChildCount(folder.requestCount, 'request', 'requests'),
|
||||
formatChildCount(folder.subfolderCount, 'subfolder', 'subfolders')
|
||||
].filter(Boolean).join(', ');
|
||||
const suffix = details ? ` (${details})` : '';
|
||||
return ` - ${folder.name}${suffix}`;
|
||||
}).join('\n')}`);
|
||||
}
|
||||
|
||||
const requests = ctx.requests || [];
|
||||
if (requests.length) {
|
||||
parts.push(`Requests:\n${requests.map((request) => {
|
||||
const method = request.method || 'GET';
|
||||
const url = request.url || '';
|
||||
return ` - ${request.name}: ${method}${url ? ` ${url}` : ''}`;
|
||||
}).join('\n')}`);
|
||||
}
|
||||
|
||||
return parts.join('\n\n');
|
||||
};
|
||||
|
||||
const formatRequestContext = (ctx) => {
|
||||
if (!ctx) return '';
|
||||
const parts = [];
|
||||
@@ -176,12 +243,16 @@ const formatRequestContext = (ctx) => {
|
||||
return parts.join('\n\n');
|
||||
};
|
||||
|
||||
const buildScriptUserPrompt = ({ userPrompt, currentScript, requestContext }) => {
|
||||
const buildScriptUserPrompt = ({ userPrompt, currentScript, requestContext, docsContext, scriptType }) => {
|
||||
const sections = [];
|
||||
const docsContextStr = formatDocsContext(docsContext);
|
||||
if (docsContextStr) sections.push(`Documentation Context\n${docsContextStr}`);
|
||||
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\`\`\``);
|
||||
const existingLabel = scriptType === 'docs' ? 'Existing Documentation' : 'Existing Code';
|
||||
const fenceLang = scriptType === 'docs' ? 'markdown' : 'js';
|
||||
sections.push(`${existingLabel}\n\`\`\`${fenceLang}\n${currentScript}\n\`\`\``);
|
||||
}
|
||||
sections.push(`User Request\n${userPrompt}`);
|
||||
return sections.join('\n\n');
|
||||
@@ -203,5 +274,6 @@ module.exports = {
|
||||
SCRIPT_PROMPTS,
|
||||
SCRIPT_TYPES,
|
||||
buildScriptUserPrompt,
|
||||
formatDocsContext,
|
||||
stripCodeFences
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user