mirror of
https://github.com/Kong/insomnia
synced 2024-11-07 22:30:15 +00:00
Add AI test generation (#6053)
* savez * ai animation * save * fixes * improvements to ux and loading states * ux improvements * delete unused file * undo vscode settings changes * add prod api url * remove console * fixes * rename loading to generating * remove extra case in ai settings * generate a new folder for the generated requests * fix icon * fix unused import
This commit is contained in:
parent
b4857c7a13
commit
e4612735c0
4
packages/insomnia/package-lock.json
generated
4
packages/insomnia/package-lock.json
generated
@ -137,13 +137,13 @@
|
|||||||
"objectpath": "^2.0.0",
|
"objectpath": "^2.0.0",
|
||||||
"openapi-types": "^7.0.1",
|
"openapi-types": "^7.0.1",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-aria": "^3.23.1",
|
"react-aria": "3.23.1",
|
||||||
"react-dnd": "^7.4.5",
|
"react-dnd": "^7.4.5",
|
||||||
"react-dnd-html5-backend": "^7.4.4",
|
"react-dnd-html5-backend": "^7.4.4",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"react-redux": "^7.2.6",
|
"react-redux": "^7.2.6",
|
||||||
"react-router-dom": "^6.4.2",
|
"react-router-dom": "^6.4.2",
|
||||||
"react-stately": "^3.21.0",
|
"react-stately": "3.21.0",
|
||||||
"react-use": "^17.4.0",
|
"react-use": "^17.4.0",
|
||||||
"redux": "^4.1.2",
|
"redux": "^4.1.2",
|
||||||
"redux-mock-store": "^1.5.4",
|
"redux-mock-store": "^1.5.4",
|
||||||
|
@ -189,13 +189,13 @@
|
|||||||
"objectpath": "^2.0.0",
|
"objectpath": "^2.0.0",
|
||||||
"openapi-types": "^7.0.1",
|
"openapi-types": "^7.0.1",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-aria": "^3.23.1",
|
"react-aria": "3.23.1",
|
||||||
"react-dnd": "^7.4.5",
|
"react-dnd": "^7.4.5",
|
||||||
"react-dnd-html5-backend": "^7.4.4",
|
"react-dnd-html5-backend": "^7.4.4",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"react-redux": "^7.2.6",
|
"react-redux": "^7.2.6",
|
||||||
"react-router-dom": "^6.4.2",
|
"react-router-dom": "^6.4.2",
|
||||||
"react-stately": "^3.21.0",
|
"react-stately": "3.21.0",
|
||||||
"react-use": "^17.4.0",
|
"react-use": "^17.4.0",
|
||||||
"redux": "^4.1.2",
|
"redux": "^4.1.2",
|
||||||
"redux-mock-store": "^1.5.4",
|
"redux-mock-store": "^1.5.4",
|
||||||
|
@ -40,3 +40,39 @@ export function parseApiSpec(
|
|||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function resolveComponentSchemaRefs(
|
||||||
|
spec: ParsedApiSpec,
|
||||||
|
methodInfo: Record<string, any>,
|
||||||
|
) {
|
||||||
|
const schemas = spec.contents?.components?.schemas;
|
||||||
|
if (!schemas) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolveRefs = (obj: Record<string, any>): Record<string, any> => {
|
||||||
|
if (typeof obj !== 'object') {
|
||||||
|
return obj;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(obj)) {
|
||||||
|
return obj.map(resolveRefs);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (obj.$ref) {
|
||||||
|
const ref = obj.$ref.replace('#/components/schemas/', '');
|
||||||
|
return resolveRefs(schemas[ref]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolved: Record<string, any> = {};
|
||||||
|
for (const [key, value] of Object.entries(obj)) {
|
||||||
|
resolved[key] = resolveRefs(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return resolved;
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolved = resolveRefs(methodInfo);
|
||||||
|
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
|
@ -225,9 +225,14 @@ export async function importResources({
|
|||||||
}
|
}
|
||||||
|
|
||||||
await db.flushChanges(bufferId);
|
await db.flushChanges(bufferId);
|
||||||
|
const resourcesWithIds = resources.map(r => ({
|
||||||
|
...r,
|
||||||
|
_id: ResourceIdMap.get(r._id),
|
||||||
|
parentId: ResourceIdMap.get(r.parentId),
|
||||||
|
}));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
resources,
|
resources: resourcesWithIds,
|
||||||
workspace: existingWorkspace,
|
workspace: existingWorkspace,
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
@ -318,8 +323,6 @@ export async function importResources({
|
|||||||
const subEnvironments =
|
const subEnvironments =
|
||||||
resources.filter(isEnvironment).filter(env => env.parentId.startsWith(models.environment.prefix)) || [];
|
resources.filter(isEnvironment).filter(env => env.parentId.startsWith(models.environment.prefix)) || [];
|
||||||
|
|
||||||
console.log({ subEnvironments });
|
|
||||||
|
|
||||||
if (subEnvironments.length > 0) {
|
if (subEnvironments.length > 0) {
|
||||||
const firstSubEnvironment = subEnvironments[0];
|
const firstSubEnvironment = subEnvironments[0];
|
||||||
|
|
||||||
@ -336,8 +339,14 @@ export async function importResources({
|
|||||||
|
|
||||||
await db.flushChanges(bufferId);
|
await db.flushChanges(bufferId);
|
||||||
|
|
||||||
|
const resourcesWithIds = resources.map(r => ({
|
||||||
|
...r,
|
||||||
|
_id: ResourceIdMap.get(r._id),
|
||||||
|
parentId: ResourceIdMap.get(r.parentId),
|
||||||
|
}));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
resources,
|
resources: resourcesWithIds,
|
||||||
workspace: newWorkspace,
|
workspace: newWorkspace,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -139,6 +139,10 @@ export const tryToInterpolateRequest = async (request: Request, environmentId: s
|
|||||||
extraInfo,
|
extraInfo,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
// @TODO Find a better way to detect missing environment variables in requests and show a more helpful error
|
||||||
|
if ('type' in err && err.type === 'render') {
|
||||||
|
throw new Error('Failed to run the request. This is likely due to missing environment variables that are referenced in the request.');
|
||||||
|
}
|
||||||
throw new Error(`Failed to render request: ${request._id}`);
|
throw new Error(`Failed to render request: ${request._id}`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
@ -3,13 +3,12 @@ import React, { FC, Fragment, ReactNode } from 'react';
|
|||||||
import { useSelector } from 'react-redux';
|
import { useSelector } from 'react-redux';
|
||||||
import styled from 'styled-components';
|
import styled from 'styled-components';
|
||||||
|
|
||||||
import coreLogo from '../images/insomnia-logo.svg';
|
|
||||||
import { selectIsLoggedIn } from '../redux/selectors';
|
import { selectIsLoggedIn } from '../redux/selectors';
|
||||||
import { GitHubStarsButton } from './github-stars-button';
|
import { GitHubStarsButton } from './github-stars-button';
|
||||||
|
import { InsomniaAILogo } from './insomnia-icon';
|
||||||
|
|
||||||
const LogoWrapper = styled.div({
|
const LogoWrapper = styled.div({
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
width: '50px',
|
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -83,7 +82,7 @@ export const AppHeader: FC<AppHeaderProps> = ({
|
|||||||
gridLeft={(
|
gridLeft={(
|
||||||
<Fragment>
|
<Fragment>
|
||||||
<LogoWrapper>
|
<LogoWrapper>
|
||||||
<img style={{ zIndex: 1 }} src={coreLogo} alt="Insomnia" width="28" height="28" />
|
<InsomniaAILogo />
|
||||||
</LogoWrapper>
|
</LogoWrapper>
|
||||||
{!isLoggedIn ? <GitHubStarsButton /> : null}
|
{!isLoggedIn ? <GitHubStarsButton /> : null}
|
||||||
</Fragment>
|
</Fragment>
|
||||||
|
@ -63,7 +63,7 @@ const Checkmark = styled(SvgIcon)({
|
|||||||
});
|
});
|
||||||
|
|
||||||
type ItemContentProps = PropsWithChildren<{
|
type ItemContentProps = PropsWithChildren<{
|
||||||
icon?: string;
|
icon?: string | ReactNode;
|
||||||
label?: string | ReactNode;
|
label?: string | ReactNode;
|
||||||
hint?: PlatformKeyCombinations;
|
hint?: PlatformKeyCombinations;
|
||||||
className?: string;
|
className?: string;
|
||||||
@ -82,7 +82,7 @@ export const ItemContent: FC<ItemContentProps> = (props: ItemContentProps) => {
|
|||||||
const content = (
|
const content = (
|
||||||
<>
|
<>
|
||||||
<StyledItemContent>
|
<StyledItemContent>
|
||||||
{icon && <StyledIcon icon={icon} style={iconStyle} />}
|
{icon && typeof icon === 'string' ? <StyledIcon icon={icon} style={iconStyle} /> : icon}
|
||||||
{children || label}
|
{children || label}
|
||||||
</StyledItemContent>
|
</StyledItemContent>
|
||||||
{hint && <DropdownHint keyBindings={hint} />}
|
{hint && <DropdownHint keyBindings={hint} />}
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import React, { FC, useCallback, useRef, useState } from 'react';
|
import React, { FC, useCallback, useRef, useState } from 'react';
|
||||||
import { useSelector } from 'react-redux';
|
import { useSelector } from 'react-redux';
|
||||||
|
|
||||||
|
import { isLoggedIn } from '../../../account/session';
|
||||||
import { database as db } from '../../../common/database';
|
import { database as db } from '../../../common/database';
|
||||||
import { getWorkspaceLabel } from '../../../common/get-workspace-label';
|
import { getWorkspaceLabel } from '../../../common/get-workspace-label';
|
||||||
import { RENDER_PURPOSE_NO_RENDER } from '../../../common/render';
|
import { RENDER_PURPOSE_NO_RENDER } from '../../../common/render';
|
||||||
@ -10,8 +11,10 @@ import { isDesign, Workspace } from '../../../models/workspace';
|
|||||||
import type { WorkspaceAction } from '../../../plugins';
|
import type { WorkspaceAction } from '../../../plugins';
|
||||||
import { ConfigGenerator, getConfigGenerators, getWorkspaceActions } from '../../../plugins';
|
import { ConfigGenerator, getConfigGenerators, getWorkspaceActions } from '../../../plugins';
|
||||||
import * as pluginContexts from '../../../plugins/context';
|
import * as pluginContexts from '../../../plugins/context';
|
||||||
|
import { useAIContext } from '../../context/app/ai-context';
|
||||||
import { selectActiveApiSpec, selectActiveEnvironment, selectActiveProject, selectActiveWorkspace, selectActiveWorkspaceName, selectSettings } from '../../redux/selectors';
|
import { selectActiveApiSpec, selectActiveEnvironment, selectActiveProject, selectActiveWorkspace, selectActiveWorkspaceName, selectSettings } from '../../redux/selectors';
|
||||||
import { type DropdownHandle, Dropdown, DropdownButton, DropdownItem, DropdownSection, ItemContent } from '../base/dropdown';
|
import { type DropdownHandle, Dropdown, DropdownButton, DropdownItem, DropdownSection, ItemContent } from '../base/dropdown';
|
||||||
|
import { InsomniaAI } from '../insomnia-ai-icon';
|
||||||
import { showError, showModal } from '../modals';
|
import { showError, showModal } from '../modals';
|
||||||
import { showGenerateConfigModal } from '../modals/generate-config-modal';
|
import { showGenerateConfigModal } from '../modals/generate-config-modal';
|
||||||
import { SettingsModal, TAB_INDEX_EXPORT } from '../modals/settings-modal';
|
import { SettingsModal, TAB_INDEX_EXPORT } from '../modals/settings-modal';
|
||||||
@ -30,6 +33,12 @@ export const WorkspaceDropdown: FC = () => {
|
|||||||
const [loadingActions, setLoadingActions] = useState<Record<string, boolean>>({});
|
const [loadingActions, setLoadingActions] = useState<Record<string, boolean>>({});
|
||||||
const dropdownRef = useRef<DropdownHandle>(null);
|
const dropdownRef = useRef<DropdownHandle>(null);
|
||||||
|
|
||||||
|
const {
|
||||||
|
generating: loading,
|
||||||
|
access,
|
||||||
|
generateTests,
|
||||||
|
} = useAIContext();
|
||||||
|
|
||||||
const handlePluginClick = useCallback(async ({ action, plugin, label }: WorkspaceAction, workspace: Workspace) => {
|
const handlePluginClick = useCallback(async ({ action, plugin, label }: WorkspaceAction, workspace: Workspace) => {
|
||||||
setLoadingActions({ ...loadingActions, [label]: true });
|
setLoadingActions({ ...loadingActions, [label]: true });
|
||||||
try {
|
try {
|
||||||
@ -96,6 +105,7 @@ export const WorkspaceDropdown: FC = () => {
|
|||||||
<Dropdown
|
<Dropdown
|
||||||
aria-label="Workspace Dropdown"
|
aria-label="Workspace Dropdown"
|
||||||
ref={dropdownRef}
|
ref={dropdownRef}
|
||||||
|
closeOnSelect={false}
|
||||||
className="wide workspace-dropdown"
|
className="wide workspace-dropdown"
|
||||||
onOpen={handleDropdownOpen}
|
onOpen={handleDropdownOpen}
|
||||||
triggerButton={
|
triggerButton={
|
||||||
@ -167,6 +177,40 @@ export const WorkspaceDropdown: FC = () => {
|
|||||||
</DropdownItem>
|
</DropdownItem>
|
||||||
}
|
}
|
||||||
</DropdownSection>
|
</DropdownSection>
|
||||||
|
|
||||||
|
<DropdownSection
|
||||||
|
aria-label='AI'
|
||||||
|
title="Insomnia AI"
|
||||||
|
items={isLoggedIn() && access.enabled && activeWorkspace.scope === 'design' ? [{
|
||||||
|
label: 'Auto-generate Tests For Collection',
|
||||||
|
key: 'insomnia-ai/generate-test-suite',
|
||||||
|
action: generateTests,
|
||||||
|
}] : []}
|
||||||
|
>
|
||||||
|
{item =>
|
||||||
|
<DropdownItem
|
||||||
|
key={`generateConfig-${item.label}`}
|
||||||
|
aria-label={item.label}
|
||||||
|
>
|
||||||
|
<ItemContent
|
||||||
|
icon={
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: '0 var(--padding-xs)',
|
||||||
|
width: 'unset',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<InsomniaAI />
|
||||||
|
</span>}
|
||||||
|
isDisabled={loading}
|
||||||
|
label={item.label}
|
||||||
|
onClick={item.action}
|
||||||
|
/>
|
||||||
|
</DropdownItem>
|
||||||
|
}
|
||||||
|
</DropdownSection>
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
37
packages/insomnia/src/ui/components/insomnia-ai-icon.tsx
Normal file
37
packages/insomnia/src/ui/components/insomnia-ai-icon.tsx
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import { useAIContext } from '../context/app/ai-context';
|
||||||
|
|
||||||
|
export const InsomniaAI = ({
|
||||||
|
...props
|
||||||
|
}: React.SVGProps<SVGSVGElement>) => {
|
||||||
|
const { generating: loading } = useAIContext();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
stroke="currentColor"
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09zm8.446-7.189L18 9.75l-.259-1.035a3.375 3.375 0 00-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 002.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 002.456 2.456L21.75 6l-1.035.259a3.375 3.375 0 00-2.456 2.456zm-1.365 11.852L16.5 21.75l-.394-1.183a2.25 2.25 0 00-1.423-1.423L13.5 18.75l1.183-.394a2.25 2.25 0 001.423-1.423l.394-1.183.394 1.183a2.25 2.25 0 001.423 1.423l1.183.394-1.183.394a2.25 2.25 0 00-1.423 1.423z"
|
||||||
|
>
|
||||||
|
{loading && (
|
||||||
|
<animate
|
||||||
|
attributeName="opacity"
|
||||||
|
values="0.4;1;0.4"
|
||||||
|
dur="2s"
|
||||||
|
repeatCount="indefinite"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</path>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
};
|
173
packages/insomnia/src/ui/components/insomnia-icon.tsx
Normal file
173
packages/insomnia/src/ui/components/insomnia-icon.tsx
Normal file
@ -0,0 +1,173 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import styled, { keyframes } from 'styled-components';
|
||||||
|
|
||||||
|
import { useAIContext } from '../context/app/ai-context';
|
||||||
|
|
||||||
|
const SlideInLeftKeyframes = keyframes`
|
||||||
|
0% {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(-100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const FadeInKeyframes = keyframes`
|
||||||
|
0% {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Layout = styled.div({
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 'var(--padding-xs)',
|
||||||
|
paddingLeft: '11px',
|
||||||
|
position: 'relative',
|
||||||
|
});
|
||||||
|
|
||||||
|
const RelativeFrame = styled.div({
|
||||||
|
position: 'relative',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 'var(--padding-xs)',
|
||||||
|
});
|
||||||
|
|
||||||
|
const AILoadingText = styled.div`
|
||||||
|
display: flex;
|
||||||
|
z-index: 1;
|
||||||
|
align-items: center;
|
||||||
|
height: 100%;
|
||||||
|
font-size: var(--font-size-small);
|
||||||
|
color: var(--color-font);
|
||||||
|
padding-right: var(--padding-sm);
|
||||||
|
opacity: 0;
|
||||||
|
animation: ${FadeInKeyframes} 0.1s 0.3s ease-out forwards;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const LoadingBoundary = styled.div({
|
||||||
|
display: 'flex',
|
||||||
|
width: 'calc(100% + 4px)',
|
||||||
|
height: 'calc(100% + 2px)',
|
||||||
|
position: 'absolute',
|
||||||
|
overflow: 'hidden',
|
||||||
|
borderRadius: '60px',
|
||||||
|
});
|
||||||
|
|
||||||
|
const LoadingBar = styled.div`
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background-color: rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 60px;
|
||||||
|
opacity: 0;
|
||||||
|
animation: ${SlideInLeftKeyframes} 0.4s ease-out forwards;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const LoadingBarIndicator = styled.div`
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background-color: #7400e1;
|
||||||
|
border-radius: 60px;
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateX(-100%);
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const InsomniaAILogo = ({
|
||||||
|
...props
|
||||||
|
}: React.SVGProps<SVGSVGElement>) => {
|
||||||
|
const {
|
||||||
|
generating: loading,
|
||||||
|
progress,
|
||||||
|
} = useAIContext();
|
||||||
|
|
||||||
|
const loadingProgress = 100 - (progress.progress / progress.total) * 100;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout>
|
||||||
|
<RelativeFrame>
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 128 128"
|
||||||
|
width="28px"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fillRule="evenodd"
|
||||||
|
clipRule="evenodd"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeMiterlimit={2}
|
||||||
|
style={{
|
||||||
|
zIndex: 1,
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M16 32.187c8.387 0 15.186-6.8 15.186-15.187S24.387 1.814 16 1.814.813 8.613.813 17 7.613 32.187 16 32.187z"
|
||||||
|
fill="#fff"
|
||||||
|
fillRule="nonzero"
|
||||||
|
transform="translate(-448 -236) translate(448.128 232.136) scale(3.99198)"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M16 1C7.163 1 0 8.163 0 17s7.163 16 16 16 16-7.163 16-16S24.837 1 16 1zm0 1.627c7.938 0 14.373 6.435 14.373 14.373S23.938 31.373 16 31.373 1.627 24.938 1.627 17 8.062 2.627 16 2.627z"
|
||||||
|
fill="#4000bf"
|
||||||
|
fillRule="nonzero"
|
||||||
|
transform="translate(-448 -236) translate(448.128 232.136) scale(3.99198)"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M16.181 5.61c6.29 0 11.39 5.1 11.39 11.39 0 6.291-5.1 11.39-11.39 11.39-6.291 0-11.39-5.099-11.39-11.39 0-1.537.305-3.004.857-4.341a4.43 4.43 0 106.191-6.192 11.362 11.362 0 014.342-.857z"
|
||||||
|
fill="url(#_Linear1)"
|
||||||
|
transform="translate(-448 -236) translate(448.128 232.136) scale(3.99198)"
|
||||||
|
>
|
||||||
|
{loading && (
|
||||||
|
<animate
|
||||||
|
attributeName="opacity"
|
||||||
|
values="0.4;1;0.4"
|
||||||
|
dur="4s"
|
||||||
|
repeatCount="indefinite"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</path>
|
||||||
|
<defs>
|
||||||
|
<linearGradient
|
||||||
|
id="_Linear1"
|
||||||
|
x1={0}
|
||||||
|
y1={0}
|
||||||
|
x2={1}
|
||||||
|
y2={0}
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
gradientTransform="rotate(-90 22.285 6.105) scale(22.7797)"
|
||||||
|
>
|
||||||
|
<stop offset={0} stopColor="#7400e1" />
|
||||||
|
<stop offset={1} stopColor="#4000bf" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
{loading && <LoadingBoundary>
|
||||||
|
<LoadingBar />
|
||||||
|
<LoadingBarIndicator
|
||||||
|
style={{
|
||||||
|
opacity: progress.progress === 0 || progress.total === progress.progress ? 0 : 1,
|
||||||
|
transform: `translateX(-${loadingProgress}%)`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</LoadingBoundary>
|
||||||
|
}
|
||||||
|
{loading && (
|
||||||
|
<AILoadingText>
|
||||||
|
<span>{'AI is thinking...'}</span>
|
||||||
|
</AILoadingText>
|
||||||
|
)}
|
||||||
|
</RelativeFrame>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
};
|
@ -7,6 +7,7 @@ import { ModalBody } from '../base/modal-body';
|
|||||||
import { ModalHeader } from '../base/modal-header';
|
import { ModalHeader } from '../base/modal-header';
|
||||||
import { PanelContainer, TabItem, Tabs } from '../base/tabs';
|
import { PanelContainer, TabItem, Tabs } from '../base/tabs';
|
||||||
import { Account } from '../settings/account';
|
import { Account } from '../settings/account';
|
||||||
|
import { AI } from '../settings/ai';
|
||||||
import { General } from '../settings/general';
|
import { General } from '../settings/general';
|
||||||
import { ImportExport } from '../settings/import-export';
|
import { ImportExport } from '../settings/import-export';
|
||||||
import { Plugins } from '../settings/plugins';
|
import { Plugins } from '../settings/plugins';
|
||||||
@ -23,6 +24,8 @@ export const TAB_INDEX_EXPORT = 'data';
|
|||||||
export const TAB_INDEX_SHORTCUTS = 'keyboard';
|
export const TAB_INDEX_SHORTCUTS = 'keyboard';
|
||||||
export const TAB_INDEX_THEMES = 'themes';
|
export const TAB_INDEX_THEMES = 'themes';
|
||||||
export const TAB_INDEX_PLUGINS = 'plugins';
|
export const TAB_INDEX_PLUGINS = 'plugins';
|
||||||
|
export const TAB_INDEX_AI = 'ai';
|
||||||
|
|
||||||
export const SettingsModal = forwardRef<SettingsModalHandle, ModalProps>((props, ref) => {
|
export const SettingsModal = forwardRef<SettingsModalHandle, ModalProps>((props, ref) => {
|
||||||
const [defaultTabKey, setDefaultTabKey] = useState('general');
|
const [defaultTabKey, setDefaultTabKey] = useState('general');
|
||||||
const modalRef = useRef<ModalHandle>(null);
|
const modalRef = useRef<ModalHandle>(null);
|
||||||
@ -79,6 +82,11 @@ export const SettingsModal = forwardRef<SettingsModalHandle, ModalProps>((props,
|
|||||||
<Plugins />
|
<Plugins />
|
||||||
</PanelContainer>
|
</PanelContainer>
|
||||||
</TabItem>
|
</TabItem>
|
||||||
|
<TabItem key="ai" title="AI">
|
||||||
|
<PanelContainer className="pad">
|
||||||
|
<AI />
|
||||||
|
</PanelContainer>
|
||||||
|
</TabItem>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</ModalBody>
|
</ModalBody>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
104
packages/insomnia/src/ui/components/settings/ai.tsx
Normal file
104
packages/insomnia/src/ui/components/settings/ai.tsx
Normal file
@ -0,0 +1,104 @@
|
|||||||
|
import React, { Fragment, useCallback } from 'react';
|
||||||
|
|
||||||
|
import { isLoggedIn } from '../../../account/session';
|
||||||
|
import { useAIContext } from '../../context/app/ai-context';
|
||||||
|
import { Link } from '../base/link';
|
||||||
|
import { InsomniaAI } from '../insomnia-ai-icon';
|
||||||
|
import { hideAllModals, showModal } from '../modals';
|
||||||
|
import { LoginModal } from '../modals/login-modal';
|
||||||
|
|
||||||
|
export const AI = () => {
|
||||||
|
const loggedIn = isLoggedIn();
|
||||||
|
|
||||||
|
const handleLogin = useCallback((event: React.SyntheticEvent<HTMLAnchorElement>) => {
|
||||||
|
event.preventDefault();
|
||||||
|
hideAllModals();
|
||||||
|
showModal(LoginModal);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const {
|
||||||
|
access: {
|
||||||
|
enabled,
|
||||||
|
loading,
|
||||||
|
},
|
||||||
|
} = useAIContext();
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <div>Loading...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loggedIn && enabled) {
|
||||||
|
return <Fragment>
|
||||||
|
<div
|
||||||
|
className="notice pad success"
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h1
|
||||||
|
className="no-margin-top"
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 'var(--padding-xs)',
|
||||||
|
}}
|
||||||
|
>Insomnia AI is enabled
|
||||||
|
<InsomniaAI /> </h1>
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
textAlign: 'center',
|
||||||
|
width: '100%',
|
||||||
|
maxWidth: '66ch',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
The Insomnia AI add-on is currently available on your account. The pay as-you-go consumption of this capability will be automatically added to your account and invoiced accordingly.
|
||||||
|
</p>
|
||||||
|
<br />
|
||||||
|
<div
|
||||||
|
className="pad"
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 'var(--padding-xs)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<i className='fa fa-info-circle' /> Beware that too many requests of Insomnia AI could generate an unpredictable spend.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Fragment >;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Fragment>
|
||||||
|
<div
|
||||||
|
className="notice pad surprise"
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h1 className="no-margin-top">Try Insomnia AI <InsomniaAI /></h1>
|
||||||
|
<p>
|
||||||
|
Improve your productivity with Insomnia AI and perform complex operations in 1-click, like auto-generating API tests for your documents and collections.
|
||||||
|
<br />
|
||||||
|
<br />
|
||||||
|
This capability is an add-on to Enterprise customers only.
|
||||||
|
</p>
|
||||||
|
<br />
|
||||||
|
<div className="pad">
|
||||||
|
<Link button className="btn btn--clicky" href="https://insomnia.rest/pricing/contact">
|
||||||
|
Enable Insomnia AI <i className="fa fa-external-link" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{!loggedIn ? <p>
|
||||||
|
Or{' '}<a href="#" onClick={handleLogin} className="theme--link">
|
||||||
|
Log In
|
||||||
|
</a>
|
||||||
|
</p> : null}
|
||||||
|
</Fragment >
|
||||||
|
);
|
||||||
|
};
|
@ -4,6 +4,9 @@ import YAML from 'yaml';
|
|||||||
import YAMLSourceMap from 'yaml-source-map';
|
import YAMLSourceMap from 'yaml-source-map';
|
||||||
|
|
||||||
import type { ApiSpec } from '../../../models/api-spec';
|
import type { ApiSpec } from '../../../models/api-spec';
|
||||||
|
import { useAIContext } from '../../context/app/ai-context';
|
||||||
|
import { InsomniaAI } from '../insomnia-ai-icon';
|
||||||
|
import { Button } from '../themed-button';
|
||||||
import { Sidebar } from './sidebar';
|
import { Sidebar } from './sidebar';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@ -17,6 +20,11 @@ const StyledSpecEditorSidebar = styled.div`
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
export const SpecEditorSidebar: FC<Props> = ({ apiSpec, handleSetSelection }) => {
|
export const SpecEditorSidebar: FC<Props> = ({ apiSpec, handleSetSelection }) => {
|
||||||
|
const {
|
||||||
|
generating: loading,
|
||||||
|
generateTestsFromSpec,
|
||||||
|
access,
|
||||||
|
} = useAIContext();
|
||||||
const onClick = (...itemPath: any[]): void => {
|
const onClick = (...itemPath: any[]): void => {
|
||||||
const scrollPosition = { start: { line: 0, col: 0 }, end: { line: 0, col: 200 } };
|
const scrollPosition = { start: { line: 0, col: 0 }, end: { line: 0, col: 200 } };
|
||||||
|
|
||||||
@ -24,7 +32,7 @@ export const SpecEditorSidebar: FC<Props> = ({ apiSpec, handleSetSelection }) =>
|
|||||||
JSON.parse(apiSpec.contents);
|
JSON.parse(apiSpec.contents);
|
||||||
// Account for JSON (as string) line number shift
|
// Account for JSON (as string) line number shift
|
||||||
scrollPosition.start.line = 1;
|
scrollPosition.start.line = 1;
|
||||||
} catch {}
|
} catch { }
|
||||||
|
|
||||||
const sourceMap = new YAMLSourceMap();
|
const sourceMap = new YAMLSourceMap();
|
||||||
const specMap = sourceMap.index(
|
const specMap = sourceMap.index(
|
||||||
@ -48,8 +56,32 @@ export const SpecEditorSidebar: FC<Props> = ({ apiSpec, handleSetSelection }) =>
|
|||||||
};
|
};
|
||||||
|
|
||||||
const specJSON = YAML.parse(apiSpec.contents);
|
const specJSON = YAML.parse(apiSpec.contents);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<StyledSpecEditorSidebar>
|
<StyledSpecEditorSidebar>
|
||||||
|
<div>
|
||||||
|
{access.enabled && (
|
||||||
|
<Button
|
||||||
|
variant="text"
|
||||||
|
disabled={loading}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
justifyContent: 'flex-start!important',
|
||||||
|
gap: 'var(--padding-xs)',
|
||||||
|
}}
|
||||||
|
onClick={generateTestsFromSpec}
|
||||||
|
>
|
||||||
|
<InsomniaAI
|
||||||
|
style={{
|
||||||
|
flex: '0 0 20px',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
Auto-generate Tests For Collection
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<Sidebar jsonData={specJSON} onClick={onClick} />
|
<Sidebar jsonData={specJSON} onClick={onClick} />
|
||||||
</StyledSpecEditorSidebar>
|
</StyledSpecEditorSidebar>
|
||||||
);
|
);
|
||||||
|
@ -28,7 +28,17 @@ const KongLink = styled.a({
|
|||||||
|
|
||||||
export const StatusBar: FC = () => {
|
export const StatusBar: FC = () => {
|
||||||
return <Bar>
|
return <Bar>
|
||||||
<SettingsButton />
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 'var(--padding-xs)',
|
||||||
|
color: 'var(--color-font)',
|
||||||
|
fontSize: 'var(--font-size-xs)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SettingsButton />
|
||||||
|
</div>
|
||||||
<KongLink className="made-with-love" href="https://konghq.com/">
|
<KongLink className="made-with-love" href="https://konghq.com/">
|
||||||
Made with <SvgIcon icon="heart" /> by Kong
|
Made with <SvgIcon icon="heart" /> by Kong
|
||||||
</KongLink>
|
</KongLink>
|
||||||
|
119
packages/insomnia/src/ui/context/app/ai-context.tsx
Normal file
119
packages/insomnia/src/ui/context/app/ai-context.tsx
Normal file
@ -0,0 +1,119 @@
|
|||||||
|
import React, { createContext, FC, PropsWithChildren, useContext, useEffect } from 'react';
|
||||||
|
import { useFetcher, useFetchers, useParams } from 'react-router-dom';
|
||||||
|
import { usePrevious } from 'react-use';
|
||||||
|
|
||||||
|
import { isLoggedIn } from '../../../account/session';
|
||||||
|
|
||||||
|
const AIContext = createContext({
|
||||||
|
generating: false,
|
||||||
|
generateTests: () => { },
|
||||||
|
generateTestsFromSpec: () => { },
|
||||||
|
access: {
|
||||||
|
enabled: false,
|
||||||
|
loading: false,
|
||||||
|
},
|
||||||
|
progress: {
|
||||||
|
total: 0,
|
||||||
|
progress: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const AIProvider: FC<PropsWithChildren> = ({ children }) => {
|
||||||
|
const {
|
||||||
|
organizationId,
|
||||||
|
projectId,
|
||||||
|
workspaceId,
|
||||||
|
} = useParams() as {
|
||||||
|
organizationId: string;
|
||||||
|
projectId: string;
|
||||||
|
workspaceId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const [progress, setProgress] = React.useState({
|
||||||
|
total: 0,
|
||||||
|
progress: 0,
|
||||||
|
});
|
||||||
|
const aiAccessFetcher = useFetcher();
|
||||||
|
const aiGenerateTestsFetcher = useFetcher();
|
||||||
|
const aiGenerateTestsFromSpecFetcher = useFetcher();
|
||||||
|
const loading = useFetchers().filter(loader => loader.formAction?.includes('/ai/generate/')).some(loader => loader.state !== 'idle');
|
||||||
|
|
||||||
|
const loggedIn = isLoggedIn();
|
||||||
|
|
||||||
|
const prevProjectId = usePrevious(projectId);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!loggedIn) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetcherHasNotRun = aiAccessFetcher.state === 'idle' && !aiAccessFetcher.data;
|
||||||
|
const projectIdHasChanged = prevProjectId !== projectId;
|
||||||
|
|
||||||
|
if (fetcherHasNotRun || projectIdHasChanged) {
|
||||||
|
aiAccessFetcher.submit({}, {
|
||||||
|
method: 'post',
|
||||||
|
action: `/organization/${organizationId}/project/${projectId}/workspace/${workspaceId}/ai/access`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [aiAccessFetcher, organizationId, projectId, workspaceId, loggedIn, prevProjectId]);
|
||||||
|
|
||||||
|
const isAIEnabled = aiAccessFetcher.data?.enabled ?? false;
|
||||||
|
|
||||||
|
const aiGenerateTestsProgressStream = aiGenerateTestsFetcher.data as TransformStream;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (aiGenerateTestsProgressStream) {
|
||||||
|
const progress = aiGenerateTestsProgressStream.readable;
|
||||||
|
|
||||||
|
progress.pipeTo(new WritableStream({
|
||||||
|
write: (chunk: any) => {
|
||||||
|
setProgress(chunk);
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}, [aiGenerateTestsProgressStream]);
|
||||||
|
|
||||||
|
const aiGenerateTestsFromSpecProgressStream = aiGenerateTestsFromSpecFetcher.data as TransformStream;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (aiGenerateTestsFromSpecProgressStream) {
|
||||||
|
const progress = aiGenerateTestsFromSpecProgressStream.readable;
|
||||||
|
|
||||||
|
progress.pipeTo(new WritableStream({
|
||||||
|
write: (chunk: any) => {
|
||||||
|
setProgress(chunk);
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}, [aiGenerateTestsFromSpecProgressStream]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AIContext.Provider
|
||||||
|
value={{
|
||||||
|
generating: loading || (progress.total > 0 && progress.progress < progress.total),
|
||||||
|
progress,
|
||||||
|
generateTests: () => {
|
||||||
|
aiGenerateTestsFetcher.submit({}, {
|
||||||
|
method: 'post',
|
||||||
|
action: `/organization/${organizationId}/project/${projectId}/workspace/${workspaceId}/ai/generate/tests`,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
generateTestsFromSpec: () => {
|
||||||
|
aiGenerateTestsFromSpecFetcher.submit({}, {
|
||||||
|
method: 'post',
|
||||||
|
action: `/organization/${organizationId}/project/${projectId}/workspace/${workspaceId}/ai/generate/collection-and-tests`,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
access: {
|
||||||
|
enabled: isAIEnabled,
|
||||||
|
loading: aiAccessFetcher.state !== 'idle',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</AIContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useAIContext = () => useContext(AIContext);
|
@ -833,3 +833,33 @@ strong {
|
|||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes AIBlockLoading {
|
||||||
|
0% {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(-100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes AIfadeIn {
|
||||||
|
0% {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-block {
|
||||||
|
animation: AIBlockLoading 1s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-text {
|
||||||
|
opacity: 0;
|
||||||
|
animation: AIfadeIn 0.5s 0.5s ease-in-out forwards;
|
||||||
|
}
|
||||||
|
@ -254,6 +254,29 @@ const router = createMemoryRouter(
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'ai',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: 'generate',
|
||||||
|
children: [
|
||||||
|
|
||||||
|
{
|
||||||
|
path: 'collection-and-tests',
|
||||||
|
action: async (...args) => (await import('./routes/actions')).generateCollectionAndTestsAction(...args),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'tests',
|
||||||
|
action: async (...args) => (await import('./routes/actions')).generateTestsAction(...args),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'access',
|
||||||
|
action: async (...args) => (await import('./routes/actions')).accessAIApiAction(...args),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'duplicate',
|
path: 'duplicate',
|
||||||
action: async (...args) =>
|
action: async (...args) =>
|
||||||
|
@ -4,15 +4,19 @@ import path from 'path';
|
|||||||
import { ActionFunction, redirect } from 'react-router-dom';
|
import { ActionFunction, redirect } from 'react-router-dom';
|
||||||
|
|
||||||
import * as session from '../../account/session';
|
import * as session from '../../account/session';
|
||||||
|
import { parseApiSpec, resolveComponentSchemaRefs } from '../../common/api-specs';
|
||||||
import { ACTIVITY_DEBUG, ACTIVITY_SPEC } from '../../common/constants';
|
import { ACTIVITY_DEBUG, ACTIVITY_SPEC } from '../../common/constants';
|
||||||
import { database } from '../../common/database';
|
import { database } from '../../common/database';
|
||||||
import { importResources, scanResources } from '../../common/import';
|
import { importResources, scanResources } from '../../common/import';
|
||||||
|
import { generateId } from '../../common/misc';
|
||||||
import * as models from '../../models';
|
import * as models from '../../models';
|
||||||
import * as workspaceOperations from '../../models/helpers/workspace-operations';
|
import * as workspaceOperations from '../../models/helpers/workspace-operations';
|
||||||
import { DEFAULT_ORGANIZATION_ID } from '../../models/organization';
|
import { DEFAULT_ORGANIZATION_ID } from '../../models/organization';
|
||||||
import { DEFAULT_PROJECT_ID, isRemoteProject } from '../../models/project';
|
import { DEFAULT_PROJECT_ID, isRemoteProject } from '../../models/project';
|
||||||
|
import { isRequest, Request } from '../../models/request';
|
||||||
import { UnitTest } from '../../models/unit-test';
|
import { UnitTest } from '../../models/unit-test';
|
||||||
import { isCollection } from '../../models/workspace';
|
import { isCollection } from '../../models/workspace';
|
||||||
|
import { axiosRequest } from '../../network/axios-request';
|
||||||
import { getSendRequestCallback } from '../../network/unit-test-feature';
|
import { getSendRequestCallback } from '../../network/unit-test-feature';
|
||||||
import { initializeLocalBackendProjectAndMarkForSync } from '../../sync/vcs/initialize-backend-project';
|
import { initializeLocalBackendProjectAndMarkForSync } from '../../sync/vcs/initialize-backend-project';
|
||||||
import { getVCS } from '../../sync/vcs/vcs';
|
import { getVCS } from '../../sync/vcs/vcs';
|
||||||
@ -124,8 +128,7 @@ export const createNewWorkspaceAction: ActionFunction = async ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
return redirect(
|
return redirect(
|
||||||
`/organization/${organizationId}/project/${projectId}/workspace/${workspace._id}/${
|
`/organization/${organizationId}/project/${projectId}/workspace/${workspace._id}/${workspace.scope === 'collection' ? ACTIVITY_DEBUG : ACTIVITY_SPEC
|
||||||
workspace.scope === 'collection' ? ACTIVITY_DEBUG : ACTIVITY_SPEC
|
|
||||||
}`
|
}`
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@ -209,8 +212,7 @@ export const duplicateWorkspaceAction: ActionFunction = async ({ request, params
|
|||||||
}
|
}
|
||||||
|
|
||||||
return redirect(
|
return redirect(
|
||||||
`/organization/${organizationId}/project/${projectId}/workspace/${newWorkspace._id}/${
|
`/organization/${organizationId}/project/${projectId}/workspace/${newWorkspace._id}/${newWorkspace.scope === 'collection' ? ACTIVITY_DEBUG : ACTIVITY_SPEC
|
||||||
newWorkspace.scope === 'collection' ? ACTIVITY_DEBUG : ACTIVITY_SPEC
|
|
||||||
}`
|
}`
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@ -517,3 +519,258 @@ export const generateCollectionFromApiSpecAction: ActionFunction = async ({
|
|||||||
|
|
||||||
return redirect(`/organization/${organizationId}/project/${projectId}/workspace/${workspaceId}/${ACTIVITY_DEBUG}`);
|
return redirect(`/organization/${organizationId}/project/${projectId}/workspace/${workspaceId}/${ACTIVITY_DEBUG}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const generateCollectionAndTestsAction: ActionFunction = async ({ params }) => {
|
||||||
|
const { organizationId, projectId, workspaceId } = params;
|
||||||
|
|
||||||
|
invariant(typeof organizationId === 'string', 'Organization ID is required');
|
||||||
|
invariant(typeof projectId === 'string', 'Project ID is required');
|
||||||
|
invariant(typeof workspaceId === 'string', 'Workspace ID is required');
|
||||||
|
|
||||||
|
const apiSpec = await models.apiSpec.getByParentId(workspaceId);
|
||||||
|
|
||||||
|
invariant(apiSpec, 'API Spec not found');
|
||||||
|
|
||||||
|
const workspace = await models.workspace.getById(workspaceId);
|
||||||
|
|
||||||
|
invariant(workspace, 'Workspace not found');
|
||||||
|
|
||||||
|
const workspaceMeta = await models.workspaceMeta.getOrCreateByParentId(workspaceId);
|
||||||
|
|
||||||
|
const isLintError = (result: IRuleResult) => result.severity === 0;
|
||||||
|
const rulesetPath = path.join(
|
||||||
|
process.env['INSOMNIA_DATA_PATH'] || window.app.getPath('userData'),
|
||||||
|
`version-control/git/${workspaceMeta?.gitRepositoryId}/other/.spectral.yaml`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const results = (await window.main.spectralRun({ contents: apiSpec.contents, rulesetPath })).filter(isLintError);
|
||||||
|
if (apiSpec.contents && results && results.length) {
|
||||||
|
throw new Error('Error Generating Configuration');
|
||||||
|
}
|
||||||
|
|
||||||
|
const resources = await scanResources({
|
||||||
|
content: apiSpec.contents,
|
||||||
|
});
|
||||||
|
|
||||||
|
const aiGeneratedRequestGroup = await models.requestGroup.create({
|
||||||
|
name: 'AI Generated Requests',
|
||||||
|
parentId: workspaceId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const requests = resources.requests?.filter(isRequest).map(request => {
|
||||||
|
return {
|
||||||
|
...request,
|
||||||
|
_id: generateId(models.request.prefix),
|
||||||
|
parentId: aiGeneratedRequestGroup._id,
|
||||||
|
};
|
||||||
|
}) || [];
|
||||||
|
|
||||||
|
await Promise.all(requests.map(request => models.request.create(request)));
|
||||||
|
|
||||||
|
const aiTestSuite = await models.unitTestSuite.create({
|
||||||
|
name: 'AI Generated Tests',
|
||||||
|
parentId: workspaceId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const spec = parseApiSpec(apiSpec.contents);
|
||||||
|
|
||||||
|
const getMethodInfo = (request: Request) => {
|
||||||
|
try {
|
||||||
|
const specPaths = Object.keys(spec.contents?.paths) || [];
|
||||||
|
|
||||||
|
const pathMatches = specPaths.filter(path => request.url.endsWith(path));
|
||||||
|
|
||||||
|
const closestPath = pathMatches.sort((a, b) => {
|
||||||
|
return a.length - b.length;
|
||||||
|
})[0];
|
||||||
|
|
||||||
|
const methodInfo = spec.contents?.paths[closestPath][request.method.toLowerCase()];
|
||||||
|
|
||||||
|
return methodInfo;
|
||||||
|
} catch (error) {
|
||||||
|
console.log(error);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const tests: Partial<UnitTest>[] = requests.map(request => {
|
||||||
|
return {
|
||||||
|
name: `Test: ${request.name}`,
|
||||||
|
code: '',
|
||||||
|
parentId: aiTestSuite._id,
|
||||||
|
requestId: request._id,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const total = tests.length;
|
||||||
|
let progress = 0;
|
||||||
|
|
||||||
|
// @TODO Investigate the defer API for streaming results.
|
||||||
|
const progressStream = new TransformStream();
|
||||||
|
const writer = progressStream.writable.getWriter();
|
||||||
|
|
||||||
|
writer.write({
|
||||||
|
progress,
|
||||||
|
total,
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const test of tests) {
|
||||||
|
async function generateTest() {
|
||||||
|
try {
|
||||||
|
const request = requests.find(r => r._id === test.requestId);
|
||||||
|
if (!request) {
|
||||||
|
throw new Error('Request not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const methodInfo = resolveComponentSchemaRefs(spec, getMethodInfo(request));
|
||||||
|
|
||||||
|
const response = await axiosRequest({
|
||||||
|
method: 'POST',
|
||||||
|
url: 'https://ai.insomnia.rest/v1/generate-test',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-Session-Id': session.getCurrentSessionId(),
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
teamId: organizationId,
|
||||||
|
request: requests.find(r => r._id === test.requestId),
|
||||||
|
methodInfo,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const aiTest = response.data.test;
|
||||||
|
|
||||||
|
await models.unitTest.create({ ...aiTest, parentId: aiTestSuite._id, requestId: test.requestId });
|
||||||
|
writer.write({
|
||||||
|
progress: ++progress,
|
||||||
|
total,
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.log(err);
|
||||||
|
writer.write({
|
||||||
|
progress: ++progress,
|
||||||
|
total,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
generateTest();
|
||||||
|
}
|
||||||
|
|
||||||
|
return progressStream;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const generateTestsAction: ActionFunction = async ({ params }) => {
|
||||||
|
const { organizationId, projectId, workspaceId } = params;
|
||||||
|
|
||||||
|
invariant(typeof organizationId === 'string', 'Organization ID is required');
|
||||||
|
invariant(typeof projectId === 'string', 'Project ID is required');
|
||||||
|
invariant(typeof workspaceId === 'string', 'Workspace ID is required');
|
||||||
|
|
||||||
|
const apiSpec = await models.apiSpec.getByParentId(workspaceId);
|
||||||
|
|
||||||
|
invariant(apiSpec, 'API Spec not found');
|
||||||
|
|
||||||
|
const workspace = await models.workspace.getById(workspaceId);
|
||||||
|
|
||||||
|
invariant(workspace, 'Workspace not found');
|
||||||
|
|
||||||
|
const workspaceDescendants = await database.withDescendants(workspace);
|
||||||
|
|
||||||
|
const requests = workspaceDescendants.filter(isRequest);
|
||||||
|
|
||||||
|
const aiTestSuite = await models.unitTestSuite.create({
|
||||||
|
name: 'AI Generated Tests',
|
||||||
|
parentId: workspaceId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const tests: Partial<UnitTest>[] = requests.map(request => {
|
||||||
|
return {
|
||||||
|
name: `Test: ${request.name}`,
|
||||||
|
code: '',
|
||||||
|
parentId: aiTestSuite._id,
|
||||||
|
requestId: request._id,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const total = tests.length;
|
||||||
|
let progress = 0;
|
||||||
|
// @TODO Investigate the defer API for streaming results.
|
||||||
|
const progressStream = new TransformStream();
|
||||||
|
const writer = progressStream.writable.getWriter();
|
||||||
|
|
||||||
|
writer.write({
|
||||||
|
progress,
|
||||||
|
total,
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const test of tests) {
|
||||||
|
async function generateTest() {
|
||||||
|
try {
|
||||||
|
const response = await axiosRequest({
|
||||||
|
method: 'POST',
|
||||||
|
url: 'https://ai.insomnia.rest/v1/generate-test',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-Session-Id': session.getCurrentSessionId(),
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
teamId: organizationId,
|
||||||
|
request: requests.find(r => r._id === test.requestId),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const aiTest = response.data.test;
|
||||||
|
|
||||||
|
await models.unitTest.create({ ...aiTest, parentId: aiTestSuite._id, requestId: test.requestId });
|
||||||
|
|
||||||
|
writer.write({
|
||||||
|
progress: ++progress,
|
||||||
|
total,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.log(err);
|
||||||
|
writer.write({
|
||||||
|
progress: ++progress,
|
||||||
|
total,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
generateTest();
|
||||||
|
}
|
||||||
|
|
||||||
|
return progressStream;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const accessAIApiAction: ActionFunction = async ({ params }) => {
|
||||||
|
console.log('AI');
|
||||||
|
const { organizationId, projectId, workspaceId } = params;
|
||||||
|
|
||||||
|
invariant(typeof organizationId === 'string', 'Organization ID is required');
|
||||||
|
invariant(typeof projectId === 'string', 'Project ID is required');
|
||||||
|
invariant(typeof workspaceId === 'string', 'Workspace ID is required');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await axiosRequest({
|
||||||
|
method: 'POST',
|
||||||
|
url: 'https://ai.insomnia.rest/v1/access',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-Session-Id': session.getCurrentSessionId(),
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
teamId: organizationId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const enabled = response.data.enabled;
|
||||||
|
|
||||||
|
return {
|
||||||
|
enabled,
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
console.log(err);
|
||||||
|
return { enabled: false };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
@ -41,6 +41,7 @@ import { StatusBar } from '../components/statusbar';
|
|||||||
import { Toast } from '../components/toast';
|
import { Toast } from '../components/toast';
|
||||||
import { WorkspaceHeader } from '../components/workspace-header';
|
import { WorkspaceHeader } from '../components/workspace-header';
|
||||||
import { AppHooks } from '../containers/app-hooks';
|
import { AppHooks } from '../containers/app-hooks';
|
||||||
|
import { AIProvider } from '../context/app/ai-context';
|
||||||
import withDragDropContext from '../context/app/drag-drop-context';
|
import withDragDropContext from '../context/app/drag-drop-context';
|
||||||
import { NunjucksEnabledProvider } from '../context/nunjucks/nunjucks-enabled-context';
|
import { NunjucksEnabledProvider } from '../context/nunjucks/nunjucks-enabled-context';
|
||||||
import Modals from './modals';
|
import Modals from './modals';
|
||||||
@ -248,38 +249,40 @@ const Root = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<NunjucksEnabledProvider>
|
<AIProvider>
|
||||||
<AppHooks />
|
<NunjucksEnabledProvider>
|
||||||
<div className="app">
|
<AppHooks />
|
||||||
<ErrorBoundary showAlert>
|
<div className="app">
|
||||||
<Modals />
|
<ErrorBoundary showAlert>
|
||||||
{importUri && (
|
<Modals />
|
||||||
<ImportModal
|
{importUri && (
|
||||||
onHide={() => setImportUri('')}
|
<ImportModal
|
||||||
organizationId={organizationId}
|
onHide={() => setImportUri('')}
|
||||||
defaultProjectId={projectId || ''}
|
organizationId={organizationId}
|
||||||
defaultWorkspaceId={workspaceId}
|
defaultProjectId={projectId || ''}
|
||||||
from={{ type: 'uri', defaultValue: importUri }}
|
defaultWorkspaceId={workspaceId}
|
||||||
/>
|
from={{ type: 'uri', defaultValue: importUri }}
|
||||||
)}
|
/>
|
||||||
<Layout>
|
)}
|
||||||
<OrganizationsNav />
|
<Layout>
|
||||||
<AppHeader
|
<OrganizationsNav />
|
||||||
gridCenter={
|
<AppHeader
|
||||||
workspaceData ? <WorkspaceHeader {...workspaceData} /> : null
|
gridCenter={
|
||||||
}
|
workspaceData ? <WorkspaceHeader {...workspaceData} /> : null
|
||||||
gridRight={<AccountToolbar />}
|
}
|
||||||
/>
|
gridRight={<AccountToolbar />}
|
||||||
<Outlet />
|
/>
|
||||||
<StatusBar />
|
<Outlet />
|
||||||
</Layout>
|
<StatusBar />
|
||||||
</ErrorBoundary>
|
</Layout>
|
||||||
|
</ErrorBoundary>
|
||||||
|
|
||||||
<ErrorBoundary showAlert>
|
<ErrorBoundary showAlert>
|
||||||
<Toast />
|
<Toast />
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
</div>
|
</div>
|
||||||
</NunjucksEnabledProvider>
|
</NunjucksEnabledProvider>
|
||||||
|
</AIProvider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user