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",
|
||||
"openapi-types": "^7.0.1",
|
||||
"react": "^18.2.0",
|
||||
"react-aria": "^3.23.1",
|
||||
"react-aria": "3.23.1",
|
||||
"react-dnd": "^7.4.5",
|
||||
"react-dnd-html5-backend": "^7.4.4",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-redux": "^7.2.6",
|
||||
"react-router-dom": "^6.4.2",
|
||||
"react-stately": "^3.21.0",
|
||||
"react-stately": "3.21.0",
|
||||
"react-use": "^17.4.0",
|
||||
"redux": "^4.1.2",
|
||||
"redux-mock-store": "^1.5.4",
|
||||
|
@ -189,13 +189,13 @@
|
||||
"objectpath": "^2.0.0",
|
||||
"openapi-types": "^7.0.1",
|
||||
"react": "^18.2.0",
|
||||
"react-aria": "^3.23.1",
|
||||
"react-aria": "3.23.1",
|
||||
"react-dnd": "^7.4.5",
|
||||
"react-dnd-html5-backend": "^7.4.4",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-redux": "^7.2.6",
|
||||
"react-router-dom": "^6.4.2",
|
||||
"react-stately": "^3.21.0",
|
||||
"react-stately": "3.21.0",
|
||||
"react-use": "^17.4.0",
|
||||
"redux": "^4.1.2",
|
||||
"redux-mock-store": "^1.5.4",
|
||||
|
@ -40,3 +40,39 @@ export function parseApiSpec(
|
||||
|
||||
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);
|
||||
const resourcesWithIds = resources.map(r => ({
|
||||
...r,
|
||||
_id: ResourceIdMap.get(r._id),
|
||||
parentId: ResourceIdMap.get(r.parentId),
|
||||
}));
|
||||
|
||||
return {
|
||||
resources,
|
||||
resources: resourcesWithIds,
|
||||
workspace: existingWorkspace,
|
||||
};
|
||||
} else {
|
||||
@ -318,8 +323,6 @@ export async function importResources({
|
||||
const subEnvironments =
|
||||
resources.filter(isEnvironment).filter(env => env.parentId.startsWith(models.environment.prefix)) || [];
|
||||
|
||||
console.log({ subEnvironments });
|
||||
|
||||
if (subEnvironments.length > 0) {
|
||||
const firstSubEnvironment = subEnvironments[0];
|
||||
|
||||
@ -336,8 +339,14 @@ export async function importResources({
|
||||
|
||||
await db.flushChanges(bufferId);
|
||||
|
||||
const resourcesWithIds = resources.map(r => ({
|
||||
...r,
|
||||
_id: ResourceIdMap.get(r._id),
|
||||
parentId: ResourceIdMap.get(r.parentId),
|
||||
}));
|
||||
|
||||
return {
|
||||
resources,
|
||||
resources: resourcesWithIds,
|
||||
workspace: newWorkspace,
|
||||
};
|
||||
}
|
||||
|
@ -139,6 +139,10 @@ export const tryToInterpolateRequest = async (request: Request, environmentId: s
|
||||
extraInfo,
|
||||
});
|
||||
} 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}`);
|
||||
}
|
||||
};
|
||||
|
@ -3,13 +3,12 @@ import React, { FC, Fragment, ReactNode } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import styled from 'styled-components';
|
||||
|
||||
import coreLogo from '../images/insomnia-logo.svg';
|
||||
import { selectIsLoggedIn } from '../redux/selectors';
|
||||
import { GitHubStarsButton } from './github-stars-button';
|
||||
import { InsomniaAILogo } from './insomnia-icon';
|
||||
|
||||
const LogoWrapper = styled.div({
|
||||
display: 'flex',
|
||||
width: '50px',
|
||||
justifyContent: 'center',
|
||||
});
|
||||
|
||||
@ -83,7 +82,7 @@ export const AppHeader: FC<AppHeaderProps> = ({
|
||||
gridLeft={(
|
||||
<Fragment>
|
||||
<LogoWrapper>
|
||||
<img style={{ zIndex: 1 }} src={coreLogo} alt="Insomnia" width="28" height="28" />
|
||||
<InsomniaAILogo />
|
||||
</LogoWrapper>
|
||||
{!isLoggedIn ? <GitHubStarsButton /> : null}
|
||||
</Fragment>
|
||||
|
@ -63,7 +63,7 @@ const Checkmark = styled(SvgIcon)({
|
||||
});
|
||||
|
||||
type ItemContentProps = PropsWithChildren<{
|
||||
icon?: string;
|
||||
icon?: string | ReactNode;
|
||||
label?: string | ReactNode;
|
||||
hint?: PlatformKeyCombinations;
|
||||
className?: string;
|
||||
@ -82,7 +82,7 @@ export const ItemContent: FC<ItemContentProps> = (props: ItemContentProps) => {
|
||||
const content = (
|
||||
<>
|
||||
<StyledItemContent>
|
||||
{icon && <StyledIcon icon={icon} style={iconStyle} />}
|
||||
{icon && typeof icon === 'string' ? <StyledIcon icon={icon} style={iconStyle} /> : icon}
|
||||
{children || label}
|
||||
</StyledItemContent>
|
||||
{hint && <DropdownHint keyBindings={hint} />}
|
||||
|
@ -1,6 +1,7 @@
|
||||
import React, { FC, useCallback, useRef, useState } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import { isLoggedIn } from '../../../account/session';
|
||||
import { database as db } from '../../../common/database';
|
||||
import { getWorkspaceLabel } from '../../../common/get-workspace-label';
|
||||
import { RENDER_PURPOSE_NO_RENDER } from '../../../common/render';
|
||||
@ -10,8 +11,10 @@ import { isDesign, Workspace } from '../../../models/workspace';
|
||||
import type { WorkspaceAction } from '../../../plugins';
|
||||
import { ConfigGenerator, getConfigGenerators, getWorkspaceActions } from '../../../plugins';
|
||||
import * as pluginContexts from '../../../plugins/context';
|
||||
import { useAIContext } from '../../context/app/ai-context';
|
||||
import { selectActiveApiSpec, selectActiveEnvironment, selectActiveProject, selectActiveWorkspace, selectActiveWorkspaceName, selectSettings } from '../../redux/selectors';
|
||||
import { type DropdownHandle, Dropdown, DropdownButton, DropdownItem, DropdownSection, ItemContent } from '../base/dropdown';
|
||||
import { InsomniaAI } from '../insomnia-ai-icon';
|
||||
import { showError, showModal } from '../modals';
|
||||
import { showGenerateConfigModal } from '../modals/generate-config-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 dropdownRef = useRef<DropdownHandle>(null);
|
||||
|
||||
const {
|
||||
generating: loading,
|
||||
access,
|
||||
generateTests,
|
||||
} = useAIContext();
|
||||
|
||||
const handlePluginClick = useCallback(async ({ action, plugin, label }: WorkspaceAction, workspace: Workspace) => {
|
||||
setLoadingActions({ ...loadingActions, [label]: true });
|
||||
try {
|
||||
@ -96,6 +105,7 @@ export const WorkspaceDropdown: FC = () => {
|
||||
<Dropdown
|
||||
aria-label="Workspace Dropdown"
|
||||
ref={dropdownRef}
|
||||
closeOnSelect={false}
|
||||
className="wide workspace-dropdown"
|
||||
onOpen={handleDropdownOpen}
|
||||
triggerButton={
|
||||
@ -167,6 +177,40 @@ export const WorkspaceDropdown: FC = () => {
|
||||
</DropdownItem>
|
||||
}
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
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 { PanelContainer, TabItem, Tabs } from '../base/tabs';
|
||||
import { Account } from '../settings/account';
|
||||
import { AI } from '../settings/ai';
|
||||
import { General } from '../settings/general';
|
||||
import { ImportExport } from '../settings/import-export';
|
||||
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_THEMES = 'themes';
|
||||
export const TAB_INDEX_PLUGINS = 'plugins';
|
||||
export const TAB_INDEX_AI = 'ai';
|
||||
|
||||
export const SettingsModal = forwardRef<SettingsModalHandle, ModalProps>((props, ref) => {
|
||||
const [defaultTabKey, setDefaultTabKey] = useState('general');
|
||||
const modalRef = useRef<ModalHandle>(null);
|
||||
@ -79,6 +82,11 @@ export const SettingsModal = forwardRef<SettingsModalHandle, ModalProps>((props,
|
||||
<Plugins />
|
||||
</PanelContainer>
|
||||
</TabItem>
|
||||
<TabItem key="ai" title="AI">
|
||||
<PanelContainer className="pad">
|
||||
<AI />
|
||||
</PanelContainer>
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
</ModalBody>
|
||||
</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 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';
|
||||
|
||||
interface Props {
|
||||
@ -17,6 +20,11 @@ const StyledSpecEditorSidebar = styled.div`
|
||||
`;
|
||||
|
||||
export const SpecEditorSidebar: FC<Props> = ({ apiSpec, handleSetSelection }) => {
|
||||
const {
|
||||
generating: loading,
|
||||
generateTestsFromSpec,
|
||||
access,
|
||||
} = useAIContext();
|
||||
const onClick = (...itemPath: any[]): void => {
|
||||
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);
|
||||
// Account for JSON (as string) line number shift
|
||||
scrollPosition.start.line = 1;
|
||||
} catch {}
|
||||
} catch { }
|
||||
|
||||
const sourceMap = new YAMLSourceMap();
|
||||
const specMap = sourceMap.index(
|
||||
@ -48,8 +56,32 @@ export const SpecEditorSidebar: FC<Props> = ({ apiSpec, handleSetSelection }) =>
|
||||
};
|
||||
|
||||
const specJSON = YAML.parse(apiSpec.contents);
|
||||
|
||||
return (
|
||||
<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} />
|
||||
</StyledSpecEditorSidebar>
|
||||
);
|
||||
|
@ -28,7 +28,17 @@ const KongLink = styled.a({
|
||||
|
||||
export const StatusBar: FC = () => {
|
||||
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/">
|
||||
Made with <SvgIcon icon="heart" /> by Kong
|
||||
</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;
|
||||
}
|
||||
|
||||
@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',
|
||||
action: async (...args) =>
|
||||
|
@ -4,15 +4,19 @@ import path from 'path';
|
||||
import { ActionFunction, redirect } from 'react-router-dom';
|
||||
|
||||
import * as session from '../../account/session';
|
||||
import { parseApiSpec, resolveComponentSchemaRefs } from '../../common/api-specs';
|
||||
import { ACTIVITY_DEBUG, ACTIVITY_SPEC } from '../../common/constants';
|
||||
import { database } from '../../common/database';
|
||||
import { importResources, scanResources } from '../../common/import';
|
||||
import { generateId } from '../../common/misc';
|
||||
import * as models from '../../models';
|
||||
import * as workspaceOperations from '../../models/helpers/workspace-operations';
|
||||
import { DEFAULT_ORGANIZATION_ID } from '../../models/organization';
|
||||
import { DEFAULT_PROJECT_ID, isRemoteProject } from '../../models/project';
|
||||
import { isRequest, Request } from '../../models/request';
|
||||
import { UnitTest } from '../../models/unit-test';
|
||||
import { isCollection } from '../../models/workspace';
|
||||
import { axiosRequest } from '../../network/axios-request';
|
||||
import { getSendRequestCallback } from '../../network/unit-test-feature';
|
||||
import { initializeLocalBackendProjectAndMarkForSync } from '../../sync/vcs/initialize-backend-project';
|
||||
import { getVCS } from '../../sync/vcs/vcs';
|
||||
@ -124,8 +128,7 @@ export const createNewWorkspaceAction: ActionFunction = async ({
|
||||
);
|
||||
|
||||
return redirect(
|
||||
`/organization/${organizationId}/project/${projectId}/workspace/${workspace._id}/${
|
||||
workspace.scope === 'collection' ? ACTIVITY_DEBUG : ACTIVITY_SPEC
|
||||
`/organization/${organizationId}/project/${projectId}/workspace/${workspace._id}/${workspace.scope === 'collection' ? ACTIVITY_DEBUG : ACTIVITY_SPEC
|
||||
}`
|
||||
);
|
||||
};
|
||||
@ -209,8 +212,7 @@ export const duplicateWorkspaceAction: ActionFunction = async ({ request, params
|
||||
}
|
||||
|
||||
return redirect(
|
||||
`/organization/${organizationId}/project/${projectId}/workspace/${newWorkspace._id}/${
|
||||
newWorkspace.scope === 'collection' ? ACTIVITY_DEBUG : ACTIVITY_SPEC
|
||||
`/organization/${organizationId}/project/${projectId}/workspace/${newWorkspace._id}/${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}`);
|
||||
};
|
||||
|
||||
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 { WorkspaceHeader } from '../components/workspace-header';
|
||||
import { AppHooks } from '../containers/app-hooks';
|
||||
import { AIProvider } from '../context/app/ai-context';
|
||||
import withDragDropContext from '../context/app/drag-drop-context';
|
||||
import { NunjucksEnabledProvider } from '../context/nunjucks/nunjucks-enabled-context';
|
||||
import Modals from './modals';
|
||||
@ -248,38 +249,40 @@ const Root = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<NunjucksEnabledProvider>
|
||||
<AppHooks />
|
||||
<div className="app">
|
||||
<ErrorBoundary showAlert>
|
||||
<Modals />
|
||||
{importUri && (
|
||||
<ImportModal
|
||||
onHide={() => setImportUri('')}
|
||||
organizationId={organizationId}
|
||||
defaultProjectId={projectId || ''}
|
||||
defaultWorkspaceId={workspaceId}
|
||||
from={{ type: 'uri', defaultValue: importUri }}
|
||||
/>
|
||||
)}
|
||||
<Layout>
|
||||
<OrganizationsNav />
|
||||
<AppHeader
|
||||
gridCenter={
|
||||
workspaceData ? <WorkspaceHeader {...workspaceData} /> : null
|
||||
}
|
||||
gridRight={<AccountToolbar />}
|
||||
/>
|
||||
<Outlet />
|
||||
<StatusBar />
|
||||
</Layout>
|
||||
</ErrorBoundary>
|
||||
<AIProvider>
|
||||
<NunjucksEnabledProvider>
|
||||
<AppHooks />
|
||||
<div className="app">
|
||||
<ErrorBoundary showAlert>
|
||||
<Modals />
|
||||
{importUri && (
|
||||
<ImportModal
|
||||
onHide={() => setImportUri('')}
|
||||
organizationId={organizationId}
|
||||
defaultProjectId={projectId || ''}
|
||||
defaultWorkspaceId={workspaceId}
|
||||
from={{ type: 'uri', defaultValue: importUri }}
|
||||
/>
|
||||
)}
|
||||
<Layout>
|
||||
<OrganizationsNav />
|
||||
<AppHeader
|
||||
gridCenter={
|
||||
workspaceData ? <WorkspaceHeader {...workspaceData} /> : null
|
||||
}
|
||||
gridRight={<AccountToolbar />}
|
||||
/>
|
||||
<Outlet />
|
||||
<StatusBar />
|
||||
</Layout>
|
||||
</ErrorBoundary>
|
||||
|
||||
<ErrorBoundary showAlert>
|
||||
<Toast />
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
</NunjucksEnabledProvider>
|
||||
<ErrorBoundary showAlert>
|
||||
<Toast />
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
</NunjucksEnabledProvider>
|
||||
</AIProvider>
|
||||
);
|
||||
};
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user