feat(ai-assist): add docs generation with folder and collection context (#8246)

This commit is contained in:
Utkarsh
2026-06-22 11:38:28 +05:30
committed by GitHub
parent ccf62641dd
commit fd30eaeccf
9 changed files with 679 additions and 38 deletions

View File

@@ -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>

View 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();
});
});
});

View File

@@ -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]">

View File

@@ -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} />
)}

View File

@@ -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}>

View File

@@ -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

View 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
});
});
});
});

View File

@@ -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 };

View File

@@ -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
};