feat(editor): Add new AI chat to universal create dropdown (#30719)

This commit is contained in:
Raúl Gómez Morales 2026-05-19 14:58:38 +02:00 committed by GitHub
parent b9d8452d8c
commit 1d60318c28
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 158 additions and 14 deletions

View File

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

View File

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

View File

@ -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[];
});

View File

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