mirror of
https://github.com/n8n-io/n8n.git
synced 2026-05-31 08:46:58 +02:00
feat(editor): Add new AI chat to universal create dropdown (#30719)
This commit is contained in:
parent
b9d8452d8c
commit
1d60318c28
|
|
@ -4758,6 +4758,7 @@
|
|||
"projects.menu.create.credential": "New credential",
|
||||
"projects.menu.create.agent": "New agent",
|
||||
"projects.menu.create.project": "New project",
|
||||
"projects.menu.create.instanceAiThread": "New AI chat",
|
||||
"projects.settings": "Project settings",
|
||||
"projects.settings.info": "Project info",
|
||||
"projects.settings.newProjectName": "My project",
|
||||
|
|
|
|||
|
|
@ -13,10 +13,16 @@ import type { CloudPlanState } from '@/Interface';
|
|||
|
||||
import { VIEWS } from '@/app/constants';
|
||||
import { NEW_AGENT_VIEW, AGENTS_MODULE_NAME } from '@/features/agents/constants';
|
||||
import { INSTANCE_AI_VIEW } from '@/features/ai/instanceAi/constants';
|
||||
import { hasPermission } from '@/app/utils/rbac/permissions';
|
||||
import type { Project, ProjectListItem } from '@/features/collaboration/projects/projects.types';
|
||||
|
||||
import { useGlobalEntityCreation } from './useGlobalEntityCreation';
|
||||
|
||||
vi.mock('@/app/utils/rbac/permissions', () => ({
|
||||
hasPermission: vi.fn().mockReturnValue(false),
|
||||
}));
|
||||
|
||||
vi.mock('@/app/composables/usePageRedirectionHelper', () => {
|
||||
const goToUpgrade = vi.fn();
|
||||
return {
|
||||
|
|
@ -54,6 +60,7 @@ vi.mock('vue-router', async (importOriginal) => {
|
|||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia());
|
||||
routerPushMock.mockReset();
|
||||
vi.mocked(hasPermission).mockReturnValue(false);
|
||||
});
|
||||
|
||||
describe('useGlobalEntityCreation', () => {
|
||||
|
|
@ -402,4 +409,118 @@ describe('useGlobalEntityCreation', () => {
|
|||
expect(agentEntry?.submenu).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('instance-ai module', () => {
|
||||
const INSTANCE_AI_SETTINGS = {
|
||||
enabled: true,
|
||||
localGatewayDisabled: false,
|
||||
proxyEnabled: false,
|
||||
cloudManaged: false,
|
||||
};
|
||||
|
||||
const enableInstanceAi = () => {
|
||||
const settingsStore = mockedStore(useSettingsStore);
|
||||
settingsStore.isModuleActive.mockImplementation((name: string) => name === 'instance-ai');
|
||||
settingsStore.moduleSettings = { 'instance-ai': { ...INSTANCE_AI_SETTINGS } };
|
||||
vi.mocked(hasPermission).mockReturnValue(true);
|
||||
return settingsStore;
|
||||
};
|
||||
|
||||
it('omits the instance-ai entry when the module is inactive', () => {
|
||||
const projectsStore = mockedStore(useProjectsStore);
|
||||
projectsStore.isTeamProjectFeatureEnabled = false;
|
||||
projectsStore.personalProject = { id: 'personal-project' } as Project;
|
||||
|
||||
const { menu } = useGlobalEntityCreation();
|
||||
|
||||
expect(menu.value.find((item) => item.id === 'instance-ai-thread')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('omits the instance-ai entry when the module is active but disabled in settings', () => {
|
||||
const settingsStore = mockedStore(useSettingsStore);
|
||||
settingsStore.isModuleActive.mockImplementation((name: string) => name === 'instance-ai');
|
||||
settingsStore.moduleSettings = {
|
||||
'instance-ai': { ...INSTANCE_AI_SETTINGS, enabled: false },
|
||||
};
|
||||
vi.mocked(hasPermission).mockReturnValue(true);
|
||||
|
||||
const projectsStore = mockedStore(useProjectsStore);
|
||||
projectsStore.isTeamProjectFeatureEnabled = false;
|
||||
projectsStore.personalProject = { id: 'personal-project' } as Project;
|
||||
|
||||
const { menu } = useGlobalEntityCreation();
|
||||
|
||||
expect(menu.value.find((item) => item.id === 'instance-ai-thread')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('omits the instance-ai entry when the user lacks the instanceAi:message scope', () => {
|
||||
const settingsStore = mockedStore(useSettingsStore);
|
||||
settingsStore.isModuleActive.mockImplementation((name: string) => name === 'instance-ai');
|
||||
settingsStore.moduleSettings = { 'instance-ai': { ...INSTANCE_AI_SETTINGS } };
|
||||
vi.mocked(hasPermission).mockReturnValue(false);
|
||||
|
||||
const projectsStore = mockedStore(useProjectsStore);
|
||||
projectsStore.isTeamProjectFeatureEnabled = false;
|
||||
projectsStore.personalProject = { id: 'personal-project' } as Project;
|
||||
|
||||
const { menu } = useGlobalEntityCreation();
|
||||
|
||||
expect(menu.value.find((item) => item.id === 'instance-ai-thread')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('appends the instance-ai entry as the last item in the community shape', () => {
|
||||
enableInstanceAi();
|
||||
|
||||
const projectsStore = mockedStore(useProjectsStore);
|
||||
projectsStore.isTeamProjectFeatureEnabled = false;
|
||||
projectsStore.personalProject = { id: 'personal-project' } as Project;
|
||||
|
||||
const { menu } = useGlobalEntityCreation();
|
||||
|
||||
expect(menu.value.at(-1)).toStrictEqual(
|
||||
expect.objectContaining({
|
||||
id: 'instance-ai-thread',
|
||||
route: { name: INSTANCE_AI_VIEW },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('appends the instance-ai entry as the last item when team feature is enabled but no team projects exist', () => {
|
||||
enableInstanceAi();
|
||||
|
||||
const projectsStore = mockedStore(useProjectsStore);
|
||||
projectsStore.teamProjectsLimit = -1;
|
||||
projectsStore.isTeamProjectFeatureEnabled = true;
|
||||
projectsStore.personalProject = { id: 'personal-project' } as Project;
|
||||
projectsStore.myProjects = [];
|
||||
|
||||
const { menu } = useGlobalEntityCreation();
|
||||
|
||||
expect(menu.value.at(-1)).toStrictEqual(
|
||||
expect.objectContaining({
|
||||
id: 'instance-ai-thread',
|
||||
route: { name: INSTANCE_AI_VIEW },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('appends the instance-ai entry as the last item in the global shape', () => {
|
||||
enableInstanceAi();
|
||||
|
||||
const projectsStore = mockedStore(useProjectsStore);
|
||||
projectsStore.teamProjectsLimit = -1;
|
||||
projectsStore.isTeamProjectFeatureEnabled = true;
|
||||
projectsStore.personalProject = { id: 'personal-project' } as Project;
|
||||
projectsStore.myProjects = [{ id: '1', name: '1', type: 'team' }] as ProjectListItem[];
|
||||
|
||||
const { menu } = useGlobalEntityCreation();
|
||||
|
||||
expect(menu.value.at(-1)).toStrictEqual(
|
||||
expect.objectContaining({
|
||||
id: 'instance-ai-thread',
|
||||
route: { name: INSTANCE_AI_VIEW },
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { computed, ref } from 'vue';
|
||||
import { VIEWS } from '@/app/constants';
|
||||
import { AGENTS_MODULE_NAME, NEW_AGENT_VIEW } from '@/features/agents/constants';
|
||||
import { INSTANCE_AI_VIEW } from '@/features/ai/instanceAi/constants';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
import { sortByProperty } from '@n8n/utils/sort/sortByProperty';
|
||||
|
|
@ -11,6 +12,7 @@ import { useCloudPlanStore } from '@/app/stores/cloudPlan.store';
|
|||
import { useSourceControlStore } from '@/features/integrations/sourceControl.ee/sourceControl.store';
|
||||
import { getResourcePermissions } from '@n8n/permissions';
|
||||
import { usePageRedirectionHelper } from '@/app/composables/usePageRedirectionHelper';
|
||||
import { hasPermission } from '@/app/utils/rbac/permissions';
|
||||
import type { Scope } from '@n8n/permissions';
|
||||
import type { RouteLocationRaw } from 'vue-router';
|
||||
import { updatedIconSet, type IconName } from '@n8n/design-system/components/N8nIcon/icons';
|
||||
|
|
@ -46,6 +48,7 @@ export const useGlobalEntityCreation = () => {
|
|||
const WORKFLOWS_MENU_ID = 'workflow';
|
||||
const CREDENTIALS_MENU_ID = 'credential';
|
||||
const AGENTS_MENU_ID = 'agent';
|
||||
const INSTANCE_AI_THREAD_MENU_ID = 'instance-ai-thread';
|
||||
const DEFAULT_ICON: IconName = 'layers';
|
||||
|
||||
const settingsStore = useSettingsStore();
|
||||
|
|
@ -79,11 +82,29 @@ export const useGlobalEntityCreation = () => {
|
|||
|
||||
const isAgentsModuleActive = computed(() => settingsStore.isModuleActive(AGENTS_MODULE_NAME));
|
||||
|
||||
const isInstanceAiAvailable = computed(
|
||||
() =>
|
||||
settingsStore.isModuleActive('instance-ai') &&
|
||||
settingsStore.moduleSettings['instance-ai']?.enabled !== false &&
|
||||
hasPermission(['rbac'], { rbac: { scope: 'instanceAi:message' } }),
|
||||
);
|
||||
|
||||
const instanceAiThreadItem = computed<Item | null>(() =>
|
||||
isInstanceAiAvailable.value
|
||||
? {
|
||||
id: INSTANCE_AI_THREAD_MENU_ID,
|
||||
title: i18n.baseText('projects.menu.create.instanceAiThread'),
|
||||
route: { name: INSTANCE_AI_VIEW },
|
||||
}
|
||||
: null,
|
||||
);
|
||||
|
||||
const menu = computed<Item[]>(() => {
|
||||
const workflowTitle = i18n.baseText('projects.menu.create.workflow');
|
||||
const credentialTitle = i18n.baseText('projects.menu.create.credential');
|
||||
const agentTitle = i18n.baseText('projects.menu.create.agent');
|
||||
const projectTitle = i18n.baseText('projects.menu.create.project');
|
||||
const instanceAiTrailing = instanceAiThreadItem.value ? [instanceAiThreadItem.value] : [];
|
||||
|
||||
// Community
|
||||
if (!projectsStore.isTeamProjectFeatureEnabled) {
|
||||
|
|
@ -126,6 +147,7 @@ export const useGlobalEntityCreation = () => {
|
|||
title: projectTitle,
|
||||
disabled: true,
|
||||
},
|
||||
...instanceAiTrailing,
|
||||
];
|
||||
}
|
||||
|
||||
|
|
@ -173,6 +195,7 @@ export const useGlobalEntityCreation = () => {
|
|||
disabled:
|
||||
!projectsStore.canCreateProjects || !projectsStore.hasPermissionToCreateProjects,
|
||||
},
|
||||
...instanceAiTrailing,
|
||||
] satisfies Item[];
|
||||
}
|
||||
|
||||
|
|
@ -290,6 +313,7 @@ export const useGlobalEntityCreation = () => {
|
|||
title: projectTitle,
|
||||
disabled: !projectsStore.canCreateProjects || !projectsStore.hasPermissionToCreateProjects,
|
||||
},
|
||||
...instanceAiTrailing,
|
||||
] satisfies Item[];
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -72,11 +72,6 @@ const groupedThreads = computed(() => {
|
|||
});
|
||||
});
|
||||
|
||||
function handleNewThread() {
|
||||
if (!activeThreadId.value) return;
|
||||
void router.push({ name: INSTANCE_AI_VIEW });
|
||||
}
|
||||
|
||||
async function handleDeleteThread(threadId: string) {
|
||||
const wasActive = threadId === activeThreadId.value;
|
||||
const deleted = await store.deleteThread(threadId);
|
||||
|
|
@ -154,15 +149,18 @@ function handleThreadAction(action: string, threadId: string) {
|
|||
placement="bottom"
|
||||
:show-after="TOOLTIP_DELAY_MS"
|
||||
>
|
||||
<N8nIconButton
|
||||
icon="plus"
|
||||
variant="ghost"
|
||||
size="small"
|
||||
icon-size="large"
|
||||
:aria-label="i18n.baseText('instanceAi.thread.new')"
|
||||
data-test-id="instance-ai-new-thread-button"
|
||||
@click="handleNewThread"
|
||||
/>
|
||||
<RouterLink v-slot="{ href, navigate }" :to="{ name: INSTANCE_AI_VIEW }" custom>
|
||||
<N8nIconButton
|
||||
:href="href"
|
||||
icon="plus"
|
||||
variant="ghost"
|
||||
size="small"
|
||||
icon-size="large"
|
||||
:aria-label="i18n.baseText('instanceAi.thread.new')"
|
||||
data-test-id="instance-ai-new-thread-button"
|
||||
@click="navigate"
|
||||
/>
|
||||
</RouterLink>
|
||||
</N8nTooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user