refactor(editor): Remove the AI Assistant template examples experiment (#38353)

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Charlie Kolb 2026-09-16 13:11:56 +00:00 committed by GitHub
parent 5b4fc8c27d
commit 8a93e66d2c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
23 changed files with 5 additions and 279919 deletions

View File

@ -18,8 +18,7 @@
"**/cl100k_base.json",
"**/o200k_base.json",
"**/*.generated.ts",
"**/expectations/**",
"**/instance-ai-examples.data.json"
"**/expectations/**"
]
},
"formatter": {

View File

@ -515,8 +515,6 @@ export {
} from './otel/update-otel-settings.dto';
export { TestOtelTraceDto } from './otel/test-otel-trace.dto';
export { InstanceAiExamplesQueryDto } from './instance-ai-examples/instance-ai-examples-query.dto';
export {
PromotionChangesQueryDto,
promotableResourceSchema,

View File

@ -1,26 +0,0 @@
import { z } from 'zod';
import { Z } from '../../zod-class';
const pageValidator = z
.string()
.optional()
.transform((val) => (val ? parseInt(val, 10) : 1))
.refine((val) => !isNaN(val) && Number.isInteger(val) && val >= 1, {
message: 'Param `page` must be a positive integer',
});
const limitValidator = z
.string()
.optional()
.transform((val) => (val ? parseInt(val, 10) : 20))
.refine((val) => !isNaN(val) && Number.isInteger(val) && val >= 1 && val <= 100, {
message: 'Param `limit` must be an integer between 1 and 100',
});
export class InstanceAiExamplesQueryDto extends Z.class({
category: z.string().optional(),
subcategory: z.string().optional(),
page: pageValidator,
limit: limitValidator,
}) {}

View File

@ -19,7 +19,6 @@ const publicApiEnabled = process.env.N8N_PUBLIC_API_DISABLED !== 'true';
generateUserManagementEmailTemplates();
generateTimezoneData();
copyInstanceAiExamplesData();
copyAgentIntegrationAssets();
if (publicApiEnabled) {
@ -136,32 +135,6 @@ function bundleSpec(sourcePath, distPath) {
}
}
// Experiment cleanup: remove with InstanceAiTemplateExamplesExperiment.
// The data lives in the frontend source tree but is read at runtime by the CLI, so it
// must be bundled into `dist` to ship with the published package.
function copyInstanceAiExamplesData() {
const source = path.resolve(
ROOT_DIR,
'..',
'frontend',
'editor-ui',
'src',
'experiments',
'instanceAiTemplateExamples',
'instance-ai-examples.data.json',
);
if (!existsSync(source)) {
throw new Error(`Instance AI examples data file not found: ${source}`);
}
const destination = path.resolve(ROOT_DIR, 'dist', 'instance-ai-examples.data.json');
shell.cp(source, destination);
if (!existsSync(destination)) {
throw new Error(`Failed to copy Instance AI examples data file to: ${destination}`);
}
}
function copyAgentIntegrationAssets() {
const sourceDir = path.resolve(
ROOT_DIR,

View File

@ -1,129 +0,0 @@
// Experiment cleanup: remove with InstanceAiTemplateExamplesExperiment
import { InstanceAiExamplesQueryDto } from '@n8n/api-types';
import { Get, Query, RestController } from '@n8n/decorators';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { jsonParse } from 'n8n-workflow';
import { CLI_DIR } from '@/constants';
interface ExampleNode {
name: string;
displayName?: string;
icon?: string;
iconData?: { type: string; fileBuffer?: string; icon?: string };
}
interface ExampleWorkflow {
id: number;
name: string;
category: string;
subcategory?: string;
relevanceScore?: number;
prompt?: string;
nodes: ExampleNode[];
}
interface RawExamplesData {
categories: string[];
subcategories: Record<string, string[]>;
nodeIcons: Record<string, { type: string; fileBuffer?: string; icon?: string }>;
totalWorkflows: number;
workflows: ExampleWorkflow[];
}
interface ExamplesData {
categories: string[];
subcategories: Record<string, string[]>;
totalWorkflows: number;
workflows: ExampleWorkflow[];
}
const NATIVE_NODE_PREFIXES = ['@n8n/n8n-nodes-langchain.'];
const NATIVE_NODES = new Set([
'n8n-nodes-base.code',
'n8n-nodes-base.httpRequest',
'n8n-nodes-base.html',
'n8n-nodes-base.crypto',
'n8n-nodes-base.editImage',
'n8n-nodes-base.ftp',
'n8n-nodes-base.evaluation',
'n8n-nodes-base.emailSend',
'n8n-nodes-base.sms77',
'n8n-nodes-base.apiTemplateIo',
]);
function hasVisibleNodes(nodes: ExampleNode[]): boolean {
return nodes.some(
(n) => !NATIVE_NODES.has(n.name) && !NATIVE_NODE_PREFIXES.some((p) => n.name.startsWith(p)),
);
}
@RestController('/instance-ai-examples')
export class InstanceAiExamplesController {
private cachedData: ExamplesData | null = null;
// Public endpoint: no @GlobalScope/@ProjectScope. The payload is template metadata
// that carries no user or instance-specific data, so it is served without
// auth rather than adding a dedicated RBAC scope for this temporary experiment.
@Get('/', { skipAuth: true })
getExamples(_req: unknown, _res: unknown, @Query query: InstanceAiExamplesQueryDto) {
const data = this.loadData();
const { category, subcategory, page, limit } = query;
let workflows = data.workflows;
if (category) {
workflows = workflows.filter((w) => w.category === category);
}
if (subcategory) {
workflows = workflows.filter((w) => w.subcategory === subcategory);
}
const totalWorkflows = workflows.length;
const offset = (page - 1) * limit;
const paginatedWorkflows = workflows.slice(offset, offset + limit);
return {
categories: data.categories,
subcategories: data.subcategories,
totalWorkflows,
workflows: paginatedWorkflows,
};
}
private loadData(): ExamplesData {
if (this.cachedData) {
return this.cachedData;
}
const filePath = resolve(CLI_DIR, 'dist', 'instance-ai-examples.data.json');
const raw = readFileSync(filePath, 'utf-8');
const rawData = jsonParse<RawExamplesData>(raw);
const workflows: ExampleWorkflow[] = rawData.workflows.map((w) => ({
...w,
nodes: w.nodes.map((n) => ({
...n,
iconData: rawData.nodeIcons[n.name],
})),
}));
workflows.sort((a, b) => {
const scoreDiff = (b.relevanceScore ?? 0) - (a.relevanceScore ?? 0);
if (scoreDiff !== 0) return scoreDiff;
const aVisible = hasVisibleNodes(a.nodes) ? 1 : 0;
const bVisible = hasVisibleNodes(b.nodes) ? 1 : 0;
return bVisible - aVisible;
});
this.cachedData = {
categories: rawData.categories,
subcategories: rawData.subcategories,
totalWorkflows: rawData.totalWorkflows,
workflows,
};
return this.cachedData;
}
}

View File

@ -39,7 +39,6 @@ import '@/controllers/change-email.controller';
import '@/controllers/ai.controller';
import '@/controllers/ai-preference.controller';
import '@/controllers/dynamic-node-parameters.controller';
import '@/controllers/instance-ai-examples.controller';
import '@/controllers/invitation.controller';
import '@/controllers/me.controller';
import '@/controllers/node-types.controller';

View File

@ -7371,15 +7371,6 @@
"instanceAi.message.actions.showDebugInfo": "Show debug information",
"instanceAi.message.actions.hideDebugInfo": "Hide debug information",
"instanceAi.emptyState.title": "n8n Assistant",
"experiments.instanceAiTemplateExamples.empty": "No examples found",
"experiments.instanceAiTemplateExamples.emptyState.title": "What can I help you automate?",
"experiments.instanceAiTemplateExamples.description": "Or start with an automation from the community",
"experiments.instanceAiTemplateExamples.card.editHint": "Use with my apps",
"experiments.instanceAiTemplateExamples.card.ariaLabel": "Use example: {name}",
"experiments.instanceAiTemplateExamples.nav.previous": "Previous examples",
"experiments.instanceAiTemplateExamples.nav.next": "Next examples",
"experiments.instanceAiTemplateExamples.input.placeholder": "Tell me what to build or ask a question",
"experiments.instanceAiTemplateExamples.subcategories.all": "All",
"instanceAi.workflowBuilderUnavailable.title": "Workflow builder unavailable",
"instanceAi.workflowBuilderUnavailable.enableSandboxDescription": "Ask an admin to enable Instance AI sandboxing.",
"instanceAi.workflowBuilderUnavailable.serviceUrlDescription": "Ask an admin to set N8N_SANDBOX_SERVICE_URL for the n8n Sandbox Service.",

View File

@ -1,10 +1,9 @@
import type { RawAxiosRequestHeaders } from 'axios';
import type { IDataObject, INode, INodeCredentialsDetails } from 'n8n-workflow';
import type { INode, INodeCredentialsDetails } from 'n8n-workflow';
import type { VersionNode } from './versions';
import type { WorkflowData } from './workflows';
import type { IRestApiContext } from '../types';
import { get, makeRestApiRequest } from '../utils';
import { get } from '../utils';
export interface IWorkflowTemplateNode
extends Pick<
@ -211,39 +210,3 @@ export async function getWorkflowTemplate(
): Promise<IWorkflowTemplate> {
return await get(apiEndpoint, `/workflows/templates/${templateId}`, undefined, headers);
}
export interface IInstanceAiExamplesQuery {
category?: string;
subcategory?: string;
page?: number;
limit?: number;
}
export interface IInstanceAiExampleWorkflow {
id: number;
name: string;
category: string;
subcategory?: string;
relevanceScore?: number;
prompt?: string;
nodes: Array<{
name: string;
displayName?: string;
icon?: string;
iconData?: { type: string; fileBuffer: string };
}>;
}
export interface IInstanceAiExamplesResponse {
categories: string[];
subcategories: Record<string, string[]>;
totalWorkflows: number;
workflows: IInstanceAiExampleWorkflow[];
}
export async function getInstanceAiExamples(
context: IRestApiContext,
query: IInstanceAiExamplesQuery,
): Promise<IInstanceAiExamplesResponse> {
return await makeRestApiRequest(context, 'GET', '/instance-ai-examples', query as IDataObject);
}

View File

@ -124,9 +124,6 @@ export const INSTANCE_AI_BROWSER_USE_EXPERIMENT = createExperiment('090_instance
export const INSTANCE_AI_COMPUTER_USE_EXPERIMENT = createExperiment('091_instance_ai_computer_use');
export const INSTANCE_AI_TEMPLATE_EXAMPLES_EXPERIMENT = createExperiment(
'092_instance_ai_template_examples',
);
export const INSTANCE_AI_BROWSER_CREDENTIAL_SETUP_EXPERIMENT = createExperiment(
'094_instance_ai_browser_credential_setup',
);
@ -193,7 +190,6 @@ export const EXPERIMENTS_TO_TRACK = [
INSTANCE_AI_SPLIT_EMPTY_STATE_EXPERIMENT.name,
INSTANCE_AI_BROWSER_USE_EXPERIMENT.name,
INSTANCE_AI_COMPUTER_USE_EXPERIMENT.name,
INSTANCE_AI_TEMPLATE_EXAMPLES_EXPERIMENT.name,
INSTANCE_AI_BROWSER_CREDENTIAL_SETUP_EXPERIMENT.name,
EXPOSE_ALL_WORKFLOWS_TO_MCP_EXPERIMENT.name,
TRIAL_INTRO_MODAL_EXPERIMENT.name,

View File

@ -1,202 +0,0 @@
<!-- Experiment cleanup: remove with InstanceAiTemplateExamplesExperiment -->
<script lang="ts" setup>
import { ref, computed, onMounted } from 'vue';
import type { ITemplatesCategory } from '@n8n/rest-api-client/api/templates';
import { useI18n } from '@n8n/i18n';
import type { BaseTextKey } from '@n8n/i18n';
import { useTelemetry } from '@n8n/composables/useTelemetry';
import { CATEGORY_BAR_REVEAL_DELAY_MS } from '../constants';
const HIDDEN_CATEGORIES = new Set(['Other']);
const LAST_CATEGORIES = new Set(['Personal Productivity']);
const i18n = useI18n();
const telemetry = useTelemetry();
const props = withDefaults(
defineProps<{
categories: ITemplatesCategory[];
selectedCategoryId: string;
compact?: boolean;
extraCompact?: boolean;
}>(),
{ compact: false, extraCompact: false },
);
const sortedCategories = computed(() => {
const main = props.categories.filter(
(c) => !HIDDEN_CATEGORIES.has(c.name) && !LAST_CATEGORIES.has(c.name),
);
const last = props.categories.filter((c) => LAST_CATEGORIES.has(c.name));
return [...main, ...last];
});
const emit = defineEmits<{
select: [categoryId: string];
}>();
function selectCategory(categoryId: string, categoryName: string) {
telemetry.track('AI Assistant template examples category clicked', {
category_id: categoryId,
category_name: categoryName,
});
emit('select', categoryId);
}
const scrollRef = ref<HTMLElement | null>(null);
const showLeftFade = ref(false);
const showRightFade = ref(false);
const revealed = ref(false);
function updateFades() {
const el = scrollRef.value;
if (!el) return;
showLeftFade.value = el.scrollLeft > 0;
showRightFade.value = el.scrollLeft < el.scrollWidth - el.clientWidth - 1;
}
onMounted(() => {
updateFades();
setTimeout(() => {
revealed.value = true;
}, CATEGORY_BAR_REVEAL_DELAY_MS);
});
</script>
<template>
<div>
<p
:class="[
$style.description,
props.compact && $style.descriptionCompact,
props.extraCompact && $style.descriptionExtraCompact,
]"
>
{{ i18n.baseText('experiments.instanceAiTemplateExamples.description' as BaseTextKey) }}
</p>
<div :class="$style.wrapper">
<div v-if="showLeftFade" :class="$style.fadeLeft" />
<div ref="scrollRef" :class="$style.container" @scroll="updateFades">
<button
v-for="(category, index) in sortedCategories"
:key="category.id"
:class="[
$style.pill,
selectedCategoryId === String(category.id) && $style.active,
revealed && $style.visible,
]"
:style="{ '--entrance-delay': `${index * 30}ms` }"
@click="selectCategory(String(category.id), category.name)"
>
{{ category.name }}
</button>
</div>
<div v-if="showRightFade" :class="$style.fadeRight" />
</div>
</div>
</template>
<style lang="scss" module>
.heading {
margin-bottom: var(--spacing--2xs);
margin-top: var(--spacing--md) !important;
font-size: var(--font-size--xl);
font-weight: var(--font-weight--medium);
color: var(--color--neutral);
text-align: center;
}
.description {
text-align: center;
color: var(--color--text--tint-1);
font-size: var(--font-size--sm);
margin-bottom: var(--spacing--xs);
margin-top: var(--spacing--xl) !important;
}
.descriptionCompact {
margin-top: var(--spacing--xs) !important;
}
.descriptionExtraCompact {
margin-top: 0 !important;
margin-bottom: var(--spacing--3xs);
}
.wrapper {
position: relative;
}
.container {
display: flex;
gap: var(--spacing--3xs);
overflow-x: auto;
scrollbar-width: none;
-ms-overflow-style: none;
padding: var(--spacing--3xs) 0;
&::before,
&::after {
content: '';
flex: 1;
}
&::-webkit-scrollbar {
display: none;
}
}
.fadeLeft,
.fadeRight {
position: absolute;
top: 0;
bottom: 0;
width: 60px;
pointer-events: none;
z-index: 1;
}
.fadeLeft {
left: 0;
background: linear-gradient(to right, var(--color--background--light-2), transparent);
}
.fadeRight {
right: 0;
background: linear-gradient(to left, var(--color--background--light-2), transparent);
}
.pill {
user-select: none;
padding: var(--spacing--3xs) var(--spacing--xs);
border-radius: var(--radius--xl);
border: 1px solid var(--color--foreground);
background: transparent;
color: var(--color--text);
font-size: var(--font-size--2xs);
font-weight: var(--font-weight--medium);
cursor: pointer;
white-space: nowrap;
flex-shrink: 0;
opacity: 0;
transform: translateY(4px);
transition:
opacity 0.2s ease var(--entrance-delay, 0ms),
transform 0.2s ease var(--entrance-delay, 0ms),
background 0.15s ease 0ms,
border-color 0.15s ease 0ms,
color 0.15s ease 0ms;
&:hover {
background: var(--color--foreground--tint-1);
}
}
.visible {
opacity: 1;
transform: translateY(0);
}
.active {
border-color: var(--color--neutral-400);
}
</style>

View File

@ -1,150 +0,0 @@
<!-- Experiment cleanup: remove with InstanceAiTemplateExamplesExperiment -->
<script lang="ts" setup>
import type { IInstanceAiExampleWorkflow } from '@n8n/rest-api-client/api/templates';
import TemplateNodeIconSet from './TemplateNodeIconSet.vue';
import { N8nText, N8nIcon } from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
import type { BaseTextKey } from '@n8n/i18n';
import { useTelemetry } from '@n8n/composables/useTelemetry';
const i18n = useI18n();
const telemetry = useTelemetry();
const props = defineProps<{
workflow: IInstanceAiExampleWorkflow;
selectedCategory?: string;
selectedSubcategory?: string;
showAllNodes?: boolean;
}>();
const emit = defineEmits<{
hover: [prompt: string];
hoverEnd: [];
select: [prompt: string];
}>();
function getPrompt(): string {
return props.workflow.prompt ?? '';
}
function handleSelect() {
telemetry.track('AI Assistant template examples example clicked', {
example_title: props.workflow.name,
category: props.selectedCategory ?? 'All',
subcategory: props.selectedSubcategory,
});
emit('select', getPrompt());
}
</script>
<template>
<div
:class="$style.card"
role="button"
tabindex="0"
:aria-label="
i18n.baseText('experiments.instanceAiTemplateExamples.card.ariaLabel' as BaseTextKey, {
interpolate: { name: workflow.name },
})
"
data-test-id="template-example-card"
@mouseenter="emit('hover', getPrompt())"
@mouseleave="emit('hoverEnd')"
@focus="emit('hover', getPrompt())"
@blur="emit('hoverEnd')"
@click="handleSelect"
@keydown.enter="handleSelect"
@keydown.space.prevent="handleSelect"
>
<N8nText :class="$style.title" :bold="true" size="medium" tag="h3">
{{ workflow.name }}
</N8nText>
<div :class="$style.footer">
<TemplateNodeIconSet :nodes="workflow.nodes" :show-all="showAllNodes" />
</div>
<div :class="$style.editHint">
<span :class="$style.editHintText">{{
i18n.baseText('experiments.instanceAiTemplateExamples.card.editHint' as BaseTextKey)
}}</span>
<N8nIcon icon="pencil-alt" size="small" />
</div>
</div>
</template>
<style lang="scss" module>
.card {
position: relative;
display: flex;
flex-direction: column;
justify-content: space-between;
padding: var(--spacing--sm);
background: var(--color--foreground--tint-2);
border: 1px solid var(--color--foreground--tint-1);
border-radius: var(--radius--lg);
cursor: pointer;
transition:
background 0.15s ease,
border-color 0.15s ease,
transform 0.15s ease,
box-shadow 0.15s ease;
&:hover {
background: var(--color--foreground--tint-1);
border-color: var(--color--foreground);
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}
&:focus-visible {
outline: 2px solid var(--color--primary);
outline-offset: 2px;
}
}
.title {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
overflow: hidden;
text-overflow: ellipsis;
line-height: 1.55;
min-height: 3.1em;
margin-bottom: var(--spacing--xs);
}
.footer {
display: flex;
align-items: center;
opacity: 0.5;
filter: grayscale(1);
transition:
opacity 0.15s ease,
filter 0.15s ease;
.card:hover & {
opacity: 1;
filter: grayscale(0);
}
}
.editHint {
position: absolute;
bottom: 11px;
right: 11px;
display: flex;
align-items: center;
gap: 6px;
color: var(--color--text--tint-1);
opacity: 0;
transition: opacity 0.15s ease;
.card:hover & {
opacity: 0.6;
}
}
.editHintText {
font-size: var(--font-size--2xs);
font-weight: var(--font-weight--medium);
}
</style>

View File

@ -1,327 +0,0 @@
<!-- Experiment cleanup: remove with InstanceAiTemplateExamplesExperiment -->
<script lang="ts" setup>
import { computed, onMounted, ref, useTemplateRef } from 'vue';
import { storeToRefs } from 'pinia';
import { useResizeObserver } from '@vueuse/core';
import { useI18n } from '@n8n/i18n';
import type { BaseTextKey } from '@n8n/i18n';
import { N8nIcon, N8nLoading, N8nText } from '@n8n/design-system';
import { useInstanceAiTemplateExamplesStore } from '../instanceAiTemplateExamples.store';
import {
GRID_REVEAL_DELAY_MS,
MAX_VISIBLE_FULL,
MAX_VISIBLE_COMPACT,
MIN_HEIGHT_FOR_FULL_PX,
MIN_HEIGHT_FOR_COMPACT_PX,
NAV_BUTTON_OFFSET_PX,
GRID_HEIGHT_FULL_PX,
GRID_HEIGHT_COMPACT_PX,
} from '../constants';
import TemplateCategoryBar from './TemplateCategoryBar.vue';
import TemplateSubcategoryBar from './TemplateSubcategoryBar.vue';
import TemplateExampleCard from './TemplateExampleCard.vue';
const emit = defineEmits<{
'hover-prompt': [prompt: string];
'hover-end': [];
'select-prompt': [prompt: string];
}>();
const i18n = useI18n();
const store = useInstanceAiTemplateExamplesStore();
const {
categories,
selectedCategoryId,
selectedSubcategory,
subcategories,
workflows,
isLoading,
totalPages,
hasNextPage,
hasPrevPage,
} = storeToRefs(store);
const compact = ref(false);
const extraCompact = ref(false);
const navAtBottom = ref(false);
const containerRef = useTemplateRef<HTMLElement>('catalogContainer');
const gridAreaStyle = computed(() => {
const value = compact.value ? `${GRID_HEIGHT_COMPACT_PX}px` : `${GRID_HEIGHT_FULL_PX}px`;
if (navAtBottom.value) return { minHeight: value };
return { height: value };
});
// Handles prevention of horizontal and vertical overflow of the templates grid and lateral buttons
useResizeObserver(document.body, () => {
if (!containerRef.value) return;
const rect = containerRef.value.getBoundingClientRect();
const available = window.innerHeight - rect.top;
compact.value = available < MIN_HEIGHT_FOR_FULL_PX;
extraCompact.value = available < MIN_HEIGHT_FOR_COMPACT_PX;
let overflowAncestor = containerRef.value.parentElement;
while (overflowAncestor) {
const style = getComputedStyle(overflowAncestor);
if (style.overflow === 'hidden' || style.overflowX === 'hidden') break;
overflowAncestor = overflowAncestor.parentElement;
}
if (overflowAncestor) {
const parentRect = overflowAncestor.getBoundingClientRect();
const spaceLeft = rect.left - parentRect.left;
const spaceRight = parentRect.right - rect.right;
navAtBottom.value = spaceLeft < NAV_BUTTON_OFFSET_PX || spaceRight < NAV_BUTTON_OFFSET_PX;
} else {
navAtBottom.value = rect.left < NAV_BUTTON_OFFSET_PX;
}
});
const visibleWorkflows = computed(() =>
workflows.value.slice(0, compact.value ? MAX_VISIBLE_COMPACT : MAX_VISIBLE_FULL),
);
const gridRevealed = ref(false);
onMounted(() => {
void store.initialize();
setTimeout(() => {
gridRevealed.value = true;
}, GRID_REVEAL_DELAY_MS);
});
</script>
<template>
<div ref="catalogContainer" :class="$style.container">
<div :class="subcategories.length > 0 ? undefined : $style.barsSpacing">
<TemplateCategoryBar
:categories="categories"
:selected-category-id="selectedCategoryId"
:compact="compact"
:extra-compact="extraCompact"
@select="store.selectCategory"
/>
</div>
<TemplateSubcategoryBar
v-if="subcategories.length > 0"
:subcategories="subcategories"
:selected-subcategory="selectedSubcategory"
@select="store.selectSubcategory"
/>
<div :class="[$style.gridArea, gridRevealed && $style.gridRevealed]" :style="gridAreaStyle">
<button
v-if="totalPages > 1 && hasPrevPage && !navAtBottom"
:class="$style.navButton"
:aria-label="
i18n.baseText('experiments.instanceAiTemplateExamples.nav.previous' as BaseTextKey)
"
@click="store.prevPage()"
>
<N8nIcon icon="chevron-left" size="medium" />
</button>
<div
v-if="isLoading && visibleWorkflows.length === 0"
:class="[$style.grid, compact && $style.gridCompact]"
>
<div v-for="n in compact ? 2 : 4" :key="n" :class="$style.skeletonCard">
<div :class="$style.skeletonTitle">
<N8nLoading :rows="1" :loading="true" />
</div>
<div :class="$style.skeletonFooter">
<N8nLoading :rows="1" :loading="true" />
</div>
</div>
</div>
<div
v-else-if="visibleWorkflows.length > 0"
:class="[$style.grid, compact && $style.gridCompact, isLoading && $style.gridLoading]"
>
<TemplateExampleCard
v-for="workflow in visibleWorkflows"
:key="workflow.id"
:workflow="workflow"
:selected-category="categories.find((c) => String(c.id) === selectedCategoryId)?.name"
:selected-subcategory="selectedSubcategory || undefined"
:show-all-nodes="!selectedCategoryId"
@hover="emit('hover-prompt', $event)"
@hover-end="emit('hover-end')"
@select="emit('select-prompt', $event)"
/>
</div>
<div v-else :class="$style.empty">
<N8nText size="small" color="text-light">
{{ i18n.baseText('experiments.instanceAiTemplateExamples.empty' as BaseTextKey) }}
</N8nText>
</div>
<button
v-if="totalPages > 1 && hasNextPage && !navAtBottom"
:class="$style.navButton"
:aria-label="
i18n.baseText('experiments.instanceAiTemplateExamples.nav.next' as BaseTextKey)
"
@click="store.nextPage()"
>
<N8nIcon icon="chevron-right" size="medium" />
</button>
</div>
<div
v-if="totalPages > 1 && navAtBottom && (hasPrevPage || hasNextPage)"
:class="$style.bottomNav"
>
<button
v-if="hasPrevPage"
:class="$style.bottomNavButton"
:aria-label="
i18n.baseText('experiments.instanceAiTemplateExamples.nav.previous' as BaseTextKey)
"
@click="store.prevPage()"
>
<N8nIcon icon="chevron-left" size="medium" />
</button>
<button
v-if="hasNextPage"
:class="$style.bottomNavButton"
:aria-label="
i18n.baseText('experiments.instanceAiTemplateExamples.nav.next' as BaseTextKey)
"
@click="store.nextPage()"
>
<N8nIcon icon="chevron-right" size="medium" />
</button>
</div>
</div>
</template>
<style lang="scss" module>
.container {
width: 100%;
max-width: 1014px;
min-width: 0;
margin: 0 auto;
}
.barsSpacing {
margin-bottom: 16px;
}
.gridArea {
position: relative;
display: flex;
align-items: center;
justify-content: center;
overflow: visible;
opacity: 0;
transition: opacity 0.3s ease;
}
.gridRevealed {
opacity: 1;
}
.grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
grid-template-rows: repeat(2, 1fr);
gap: 12px;
width: 100%;
height: 100%;
}
.gridCompact {
grid-template-rows: 1fr;
}
.gridLoading {
opacity: 0.5;
pointer-events: none;
transition: opacity 0.15s ease;
}
.skeletonCard {
display: flex;
flex-direction: column;
justify-content: space-between;
padding: var(--spacing--sm);
background: var(--color--foreground--tint-2);
border: 1px solid var(--color--foreground--tint-1);
border-radius: var(--radius--lg);
}
.skeletonTitle {
min-height: 3.1em;
line-height: 1.55;
margin-bottom: var(--spacing--xs);
}
.skeletonFooter {
height: 32px;
width: 120px;
}
.empty {
display: flex;
align-items: center;
justify-content: center;
padding: var(--spacing--xl);
width: 100%;
}
.navButton {
position: absolute;
top: 50%;
transform: translateY(-50%);
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border-radius: 50%;
border: none;
background: transparent;
color: var(--color--text);
cursor: pointer;
transition: background 0.15s ease;
&:hover {
background: var(--color--foreground--tint-1);
}
&:first-child {
left: -48px;
}
&:last-child {
right: -48px;
}
}
.bottomNav {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
margin-top: 12px;
}
.bottomNavButton {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border-radius: 50%;
border: none;
background: transparent;
color: var(--color--text);
cursor: pointer;
transition: background 0.15s ease;
&:hover {
background: var(--color--foreground--tint-1);
}
}
</style>

View File

@ -1,115 +0,0 @@
<!-- Experiment cleanup: remove with InstanceAiTemplateExamplesExperiment -->
<script lang="ts" setup>
import { computed } from 'vue';
import { N8nTooltip } from '@n8n/design-system';
import NodeIcon from '@/app/components/NodeIcon.vue';
import type { IInstanceAiExampleWorkflow } from '@n8n/rest-api-client/api/templates';
import type { VersionNode } from '@n8n/rest-api-client/api/versions';
type ExampleNode = IInstanceAiExampleWorkflow['nodes'][number];
const MAX_VISIBLE = 3;
const DARK_MODE_INVERT_NODES = new Set([
'n8n-nodes-base.notion',
'n8n-nodes-base.twitter',
'n8n-nodes-base.perplexity',
'n8n-nodes-base.openAi',
'n8n-nodes-base.github',
'n8n-nodes-base.mailchimp',
'@n8n/n8n-nodes-langchain.openAi',
]);
const NATIVE_NODES = new Set([
'n8n-nodes-base.code',
'n8n-nodes-base.httpRequest',
'n8n-nodes-base.html',
'n8n-nodes-base.crypto',
'n8n-nodes-base.editImage',
'n8n-nodes-base.ftp',
'n8n-nodes-base.evaluation',
'n8n-nodes-base.emailSend',
'n8n-nodes-base.sms77',
'n8n-nodes-base.apiTemplateIo',
]);
function isNativeNode(name: string): boolean {
if (NATIVE_NODES.has(name)) return true;
if (name.startsWith('@n8n/n8n-nodes-langchain.')) return true;
return false;
}
const props = withDefaults(
defineProps<{
nodes: ExampleNode[];
showAll?: boolean;
}>(),
{ showAll: false },
);
const thirdPartyNodes = computed(() =>
props.showAll ? props.nodes : props.nodes.filter((node) => !isNativeNode(node.name)),
);
const visibleNodes = computed(() => thirdPartyNodes.value.slice(0, MAX_VISIBLE));
const hiddenNodes = computed(() => thirdPartyNodes.value.slice(MAX_VISIBLE));
const hiddenCount = computed(() => hiddenNodes.value.length);
const hiddenTooltip = computed(() => hiddenNodes.value.map((node) => node.displayName).join(', '));
</script>
<template>
<div :class="$style.container">
<div
v-for="node in visibleNodes"
:key="node.name"
:class="[$style.iconWrapper, DARK_MODE_INVERT_NODES.has(node.name) && $style.invertOnDark]"
>
<NodeIcon :node-type="node as unknown as VersionNode" :size="20" :show-tooltip="true" />
</div>
<N8nTooltip v-if="hiddenCount > 0" :content="hiddenTooltip" placement="right">
<div :class="$style.overflow">+{{ hiddenCount }}</div>
</N8nTooltip>
</div>
</template>
<style lang="scss" module>
.container {
display: flex;
align-items: center;
gap: var(--spacing--3xs);
}
.iconWrapper {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: var(--radius--s);
}
.invertOnDark {
:global(body[data-theme='dark']) & {
filter: invert(1);
}
}
@media (prefers-color-scheme: dark) {
.invertOnDark {
:global(body:not([data-theme])) & {
filter: invert(1);
}
}
}
.overflow {
display: flex;
align-items: center;
justify-content: center;
height: 32px;
padding: 0 var(--spacing--3xs);
border-radius: var(--radius--s);
font-size: var(--font-size--2xs);
font-weight: var(--font-weight--bold);
color: var(--color--text--tint-1);
}
</style>

View File

@ -1,372 +0,0 @@
<!-- Experiment cleanup: remove with InstanceAiTemplateExamplesExperiment -->
<script lang="ts" setup>
import { ref, computed, onMounted, watch, nextTick } from 'vue';
import { useResizeObserver } from '@vueuse/core';
import { N8nIcon } from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
import type { BaseTextKey } from '@n8n/i18n';
import { useTelemetry } from '@n8n/composables/useTelemetry';
const i18n = useI18n();
const telemetry = useTelemetry();
const props = defineProps<{
subcategories: string[];
selectedSubcategory: string;
}>();
const emit = defineEmits<{
select: [subcategory: string];
}>();
function selectSubcategory(subcategory: string) {
telemetry.track('AI Assistant template examples subcategory clicked', {
subcategory_name: subcategory || 'All',
});
emit('select', subcategory);
}
const scrollRef = ref<HTMLElement | null>(null);
const showLeftFade = ref(false);
const showRightFade = ref(false);
const revealed = ref(false);
const wrapperHovered = ref(false);
const showScrollHintRight = computed(() => showRightFade.value && wrapperHovered.value);
const showScrollHintLeft = computed(() => showLeftFade.value && wrapperHovered.value);
const SCROLL_JUMP_PX = 150;
const SCROLL_JUMP_DURATION_MS = 300;
const SCROLL_SPEED_PX_PER_FRAME = 4;
const HOLD_THRESHOLD_MS = 200;
function updateFades() {
const el = scrollRef.value;
if (!el) return;
showLeftFade.value = el.scrollLeft > 0;
showRightFade.value = el.scrollLeft < el.scrollWidth - el.clientWidth - 1;
}
let scrollAnimationId: number | null = null;
let holdTimeout: ReturnType<typeof setTimeout> | null = null;
let isHolding = false;
function animateJump(direction: number) {
const el = scrollRef.value;
if (!el) return;
const start = el.scrollLeft;
const target = start + direction * SCROLL_JUMP_PX;
const startTime = performance.now();
function step(now: number) {
const elapsed = now - startTime;
const progress = Math.min(elapsed / SCROLL_JUMP_DURATION_MS, 1);
const eased = 1 - Math.pow(1 - progress, 3);
el!.scrollLeft = start + (target - start) * eased;
updateFades();
if (progress < 1) {
scrollAnimationId = requestAnimationFrame(step);
} else {
scrollAnimationId = null;
}
}
scrollAnimationId = requestAnimationFrame(step);
}
function startContinuousScroll(direction: number) {
function step() {
const el = scrollRef.value;
if (!el) return;
el.scrollLeft += direction * SCROLL_SPEED_PX_PER_FRAME;
updateFades();
scrollAnimationId = requestAnimationFrame(step);
}
scrollAnimationId = requestAnimationFrame(step);
}
function stopScroll() {
if (holdTimeout !== null) {
clearTimeout(holdTimeout);
holdTimeout = null;
}
if (scrollAnimationId !== null) {
cancelAnimationFrame(scrollAnimationId);
scrollAnimationId = null;
}
isHolding = false;
}
function handleScrollStart(direction: number) {
stopScroll();
isHolding = false;
holdTimeout = setTimeout(() => {
isHolding = true;
startContinuousScroll(direction);
}, HOLD_THRESHOLD_MS);
}
function handleScrollEnd(direction: number) {
const wasHolding = isHolding;
stopScroll();
if (!wasHolding) {
animateJump(direction);
}
}
function startScrollRight() {
handleScrollStart(1);
}
function startScrollLeft() {
handleScrollStart(-1);
}
function endScrollRight() {
handleScrollEnd(1);
}
function endScrollLeft() {
handleScrollEnd(-1);
}
onMounted(() => {
updateFades();
nextTick(() => {
revealed.value = true;
});
});
useResizeObserver(scrollRef, updateFades);
let prevSubcategories: string[] = [];
watch(
() => props.subcategories,
(newVal) => {
const changed =
newVal.length !== prevSubcategories.length ||
newVal.some((v, i) => v !== prevSubcategories[i]);
prevSubcategories = [...newVal];
if (!changed) return;
revealed.value = false;
if (scrollRef.value) {
scrollRef.value.scrollLeft = 0;
}
nextTick(() => {
revealed.value = true;
requestAnimationFrame(updateFades);
});
},
);
</script>
<template>
<div
:class="$style.wrapper"
@mouseenter="wrapperHovered = true"
@mouseleave="wrapperHovered = false"
>
<div v-if="showLeftFade" :class="$style.fadeLeft" />
<div ref="scrollRef" :class="$style.container" @scroll="updateFades">
<button
:class="[
$style.pill,
selectedSubcategory === '' && $style.active,
revealed && $style.visible,
]"
:style="{ '--entrance-delay': '0ms' }"
@click="selectSubcategory('')"
>
{{
i18n.baseText('experiments.instanceAiTemplateExamples.subcategories.all' as BaseTextKey)
}}
</button>
<button
v-for="(subcategory, index) in subcategories"
:key="subcategory"
:class="[
$style.pill,
selectedSubcategory === subcategory && $style.active,
revealed && $style.visible,
]"
:style="{ '--entrance-delay': `${(index + 1) * 30}ms` }"
@click="selectSubcategory(subcategory)"
>
{{ subcategory }}
</button>
</div>
<div v-if="showRightFade" :class="$style.fadeRight" />
<button
:class="[
$style.scrollHint,
$style.scrollHintLeft,
showScrollHintLeft && $style.scrollHintVisible,
]"
@mousedown.prevent="startScrollLeft"
@mouseup="endScrollLeft"
@mouseleave="stopScroll"
>
<N8nIcon icon="chevron-left" size="small" />
</button>
<button
:class="[
$style.scrollHint,
$style.scrollHintRight,
showScrollHintRight && $style.scrollHintVisible,
]"
@mousedown.prevent="startScrollRight"
@mouseup="endScrollRight"
@mouseleave="stopScroll"
>
<N8nIcon icon="chevron-right" size="small" />
</button>
</div>
</template>
<style lang="scss" module>
.wrapper {
position: relative;
margin-bottom: 16px;
max-width: 100%;
min-width: 0;
}
.container {
display: flex;
gap: var(--spacing--3xs);
overflow-x: auto;
scrollbar-width: none;
-ms-overflow-style: none;
padding: var(--spacing--3xs) 0;
&::before,
&::after {
content: '';
flex: 1;
}
&::-webkit-scrollbar {
display: none;
}
}
.fadeLeft,
.fadeRight {
position: absolute;
top: 0;
bottom: 0;
width: 60px;
pointer-events: none;
z-index: 1;
}
.fadeLeft {
left: 0;
background: linear-gradient(to right, var(--color--background--light-2), transparent);
}
.fadeRight {
right: 0;
background: linear-gradient(to left, var(--color--background--light-2), transparent);
}
.pill {
user-select: none;
padding: var(--spacing--4xs) var(--spacing--2xs);
border-radius: var(--radius--xl);
border: 1px solid var(--color--foreground);
background: transparent;
color: var(--color--text);
font-size: var(--font-size--2xs);
font-weight: var(--font-weight--medium);
cursor: pointer;
white-space: nowrap;
flex-shrink: 0;
opacity: 0;
transform: translateY(4px);
transition:
opacity 0.2s ease var(--entrance-delay, 0ms),
transform 0.2s ease var(--entrance-delay, 0ms),
background 0.15s ease 0ms,
border-color 0.15s ease 0ms,
color 0.15s ease 0ms;
&:hover {
background: var(--color--foreground--tint-1);
}
}
.visible {
opacity: 1;
transform: translateY(0);
}
.active {
border-color: var(--color--neutral-400);
}
.scrollHint {
position: absolute;
top: 50%;
transform: translateY(-50%);
color: var(--color--text);
opacity: 0;
pointer-events: none;
transition: opacity 0.2s ease;
background: var(--color--background--light-2);
border: none;
border-radius: 50%;
padding: var(--spacing--3xs);
cursor: pointer;
}
.scrollHintVisible {
opacity: 0.7;
pointer-events: auto;
&:hover {
opacity: 1;
}
}
.scrollHintRight {
right: -12px;
z-index: 2;
}
.scrollHintRight.scrollHintVisible {
animation: nudgeRight 1s ease-in-out infinite;
}
.scrollHintLeft {
left: -12px;
z-index: 2;
}
.scrollHintLeft.scrollHintVisible {
animation: nudgeLeft 1s ease-in-out infinite;
}
@keyframes nudgeRight {
0%,
100% {
transform: translateY(-50%) translateX(0);
}
50% {
transform: translateY(-50%) translateX(4px);
}
}
@keyframes nudgeLeft {
0%,
100% {
transform: translateY(-50%) translateX(0);
}
50% {
transform: translateY(-50%) translateX(-4px);
}
}
</style>

View File

@ -1,14 +0,0 @@
export const CATEGORY_BAR_REVEAL_DELAY_MS = 100;
export const CATEGORY_PILL_STAGGER_MS = 30;
export const GRID_REVEAL_DELAY_MS = 400;
export const TEMPLATE_PROMPT_SUFFIX =
'\n\nAsk me questions to narrow down my use case and the tools I use to best personalize the example for my needs.';
export const MAX_VISIBLE_FULL = 4;
export const MAX_VISIBLE_COMPACT = 2;
export const MIN_HEIGHT_FOR_FULL_PX = 430;
export const MIN_HEIGHT_FOR_COMPACT_PX = 250;
export const NAV_BUTTON_OFFSET_PX = 48;
export const GRID_HEIGHT_FULL_PX = 212;
export const GRID_HEIGHT_COMPACT_PX = 100;
export const INSTANCE_AI_TEMPLATE_EXAMPLES_EXPOSURE_EVENT = 'AI Assistant template examples shown';

View File

@ -1,94 +0,0 @@
import type { IInstanceAiExampleWorkflow } from '@n8n/rest-api-client/api/templates';
import { NODE_ICONS } from './nodeIcons';
export const FEATURED_DEFAULTS: IInstanceAiExampleWorkflow[] = [
{
id: 900001,
name: 'Auto-extract invoice details from Gmail and flag discrepancies',
nodes: [
{
name: 'n8n-nodes-base.gmail',
displayName: 'Gmail',
iconData: { type: 'file', fileBuffer: NODE_ICONS['n8n-nodes-base.gmail'] },
},
{
name: 'n8n-nodes-base.googleSheets',
displayName: 'Google Sheets',
iconData: { type: 'file', fileBuffer: NODE_ICONS['n8n-nodes-base.googleSheets'] },
},
{
name: 'n8n-nodes-base.googleCalendar',
displayName: 'Google Calendar',
iconData: { type: 'file', fileBuffer: NODE_ICONS['n8n-nodes-base.googleCalendar'] },
},
],
category: '',
subcategory: '',
relevanceScore: 5,
prompt:
'Every morning, scan Gmail for new invoices, use Claude to extract details and cross-check them against purchase orders in Google Sheets, flag any discrepancies for review, and add all payment due dates to Google Calendar automatically.',
},
{
id: 900002,
name: 'Research and score new leads then assign to sales reps',
nodes: [
{
name: 'n8n-nodes-base.hubspot',
displayName: 'HubSpot',
iconData: { type: 'file', fileBuffer: NODE_ICONS['n8n-nodes-base.hubspot'] },
},
],
category: '',
subcategory: '',
relevanceScore: 5,
prompt:
'When a new lead fills in our contact form, use Claude to research their company online, score their fit based on size, industry, and intent, draft a personalised first-touch reply, and assign them to the right sales rep in HubSpot based on region and product interest.',
},
{
id: 900003,
name: 'AI WhatsApp support bot with FAQ lookup and ticket creation',
nodes: [
{
name: 'n8n-nodes-base.whatsApp',
displayName: 'WhatsApp',
iconData: { type: 'file', fileBuffer: NODE_ICONS['n8n-nodes-base.whatsApp'] },
},
{
name: 'n8n-nodes-base.googleSheets',
displayName: 'Google Sheets',
iconData: { type: 'file', fileBuffer: NODE_ICONS['n8n-nodes-base.googleSheets'] },
},
{
name: 'n8n-nodes-base.notion',
displayName: 'Notion',
iconData: { type: 'file', fileBuffer: NODE_ICONS['n8n-nodes-base.notion'] },
},
{
name: 'n8n-nodes-base.slack',
displayName: 'Slack',
iconData: { type: 'file', fileBuffer: NODE_ICONS['n8n-nodes-base.slack'] },
},
],
category: '',
subcategory: '',
relevanceScore: 5,
prompt:
'When a customer sends a WhatsApp message, use Claude to match their question against our FAQ in Google Sheets and reply instantly. If it cannot resolve it, create a support ticket in Notion and alert the team on Slack.',
},
{
id: 900004,
name: 'AI-powered weekly social media content scheduler',
nodes: [
{
name: 'n8n-nodes-base.googleSheets',
displayName: 'Google Sheets',
iconData: { type: 'file', fileBuffer: NODE_ICONS['n8n-nodes-base.googleSheets'] },
},
],
category: '',
subcategory: '',
relevanceScore: 5,
prompt:
"Every Monday, read this week's content ideas from a Google Sheet, use Claude to write tailored captions for LinkedIn, Instagram, and X, then schedule them to post throughout the week.",
},
];

View File

@ -1,4 +0,0 @@
export { useInstanceAiTemplateExamplesExperiment } from './useInstanceAiTemplateExamplesExperiment';
export { useInstanceAiTemplateExamplesStore } from './instanceAiTemplateExamples.store';
export { default as TemplateExamplesCatalog } from './components/TemplateExamplesCatalog.vue';
export { TEMPLATE_PROMPT_SUFFIX } from './constants';

View File

@ -1,199 +0,0 @@
import { defineStore } from 'pinia';
import { computed, ref } from 'vue';
import type {
IInstanceAiExampleWorkflow,
IInstanceAiExamplesQuery,
ITemplatesCategory,
} from '@n8n/rest-api-client/api/templates';
import { getInstanceAiExamples } from '@n8n/rest-api-client/api/templates';
import { useRootStore } from '@n8n/stores/useRootStore';
import { FEATURED_DEFAULTS } from './featuredDefaults';
const PAGE_SIZE = 4;
export const useInstanceAiTemplateExamplesStore = defineStore('instanceAiTemplateExamples', () => {
const rootStore = useRootStore();
const categories = ref<ITemplatesCategory[]>([]);
const subcategoriesMap = ref<Record<string, string[]>>({});
const selectedCategoryId = ref<string>('');
const selectedSubcategory = ref<string>('');
const currentPage = ref(1);
const workflows = ref<IInstanceAiExampleWorkflow[]>([]);
const totalWorkflows = ref(0);
const isLoading = ref(false);
const hasLoadFailed = ref(false);
const hasLoadedOnce = ref(false);
const hoveredPrompt = ref<string | null>(null);
const totalPages = computed(() => Math.ceil(totalWorkflows.value / PAGE_SIZE));
const hasNextPage = computed(() => currentPage.value < totalPages.value);
const hasPrevPage = computed(() => currentPage.value > 1);
const subcategories = computed<string[]>(() => {
const selectedCategory = categories.value.find(
(c) => String(c.id) === selectedCategoryId.value,
);
if (!selectedCategory) return [];
return subcategoriesMap.value[selectedCategory.name] ?? [];
});
let latestRequestId = 0;
async function fetchWorkflows(): Promise<boolean> {
const requestId = ++latestRequestId;
isLoading.value = true;
try {
const selectedCategory = categories.value.find(
(c) => String(c.id) === selectedCategoryId.value,
);
const isDefaultView = !selectedCategory && !selectedSubcategory.value;
// Page 1 of default view shows curated featured examples
if (isDefaultView && currentPage.value === 1) {
const response = await getInstanceAiExamples(rootStore.restApiContext, {
page: 1,
limit: 1,
});
if (requestId !== latestRequestId) return true;
if (categories.value.length === 0) {
categories.value = response.categories.map((name, index) => ({
id: index + 1,
name,
}));
}
subcategoriesMap.value = response.subcategories;
workflows.value = FEATURED_DEFAULTS;
totalWorkflows.value = response.totalWorkflows + FEATURED_DEFAULTS.length;
hasLoadedOnce.value = true;
return true;
}
const apiPage = isDefaultView ? currentPage.value - 1 : currentPage.value;
const query: IInstanceAiExamplesQuery = {
page: apiPage,
limit: PAGE_SIZE,
};
if (selectedCategory) {
query.category = selectedCategory.name;
}
if (selectedSubcategory.value) {
query.subcategory = selectedSubcategory.value;
}
const response = await getInstanceAiExamples(rootStore.restApiContext, query);
if (requestId !== latestRequestId) return true;
if (categories.value.length === 0) {
categories.value = response.categories.map((name, index) => ({
id: index + 1,
name,
}));
}
subcategoriesMap.value = response.subcategories;
workflows.value = response.workflows;
totalWorkflows.value = isDefaultView
? response.totalWorkflows + FEATURED_DEFAULTS.length
: response.totalWorkflows;
hasLoadedOnce.value = true;
return true;
} catch {
if (requestId !== latestRequestId) return true;
if (!hasLoadedOnce.value) {
workflows.value = [];
totalWorkflows.value = 0;
hasLoadFailed.value = true;
}
return false;
} finally {
if (requestId === latestRequestId) {
isLoading.value = false;
}
}
}
async function fetchCategories() {
if (categories.value.length > 0) return;
await fetchWorkflows();
}
async function selectCategory(categoryId: string) {
const previousCategoryId = selectedCategoryId.value;
const previousSubcategory = selectedSubcategory.value;
const previousPage = currentPage.value;
selectedCategoryId.value = categoryId;
selectedSubcategory.value = '';
currentPage.value = 1;
const succeeded = await fetchWorkflows();
if (!succeeded) {
selectedCategoryId.value = previousCategoryId;
selectedSubcategory.value = previousSubcategory;
currentPage.value = previousPage;
}
}
async function selectSubcategory(subcategory: string) {
const previousSubcategory = selectedSubcategory.value;
const previousPage = currentPage.value;
selectedSubcategory.value = subcategory;
currentPage.value = 1;
const succeeded = await fetchWorkflows();
if (!succeeded) {
selectedSubcategory.value = previousSubcategory;
currentPage.value = previousPage;
}
}
async function nextPage() {
if (!hasNextPage.value) return;
currentPage.value++;
const succeeded = await fetchWorkflows();
if (!succeeded) {
currentPage.value--;
}
}
async function prevPage() {
if (!hasPrevPage.value) return;
currentPage.value--;
const succeeded = await fetchWorkflows();
if (!succeeded) {
currentPage.value++;
}
}
async function initialize() {
await fetchWorkflows();
}
return {
categories,
selectedCategoryId,
selectedSubcategory,
subcategories,
currentPage,
workflows,
totalWorkflows,
totalPages,
isLoading,
hasLoadFailed,
hasNextPage,
hasPrevPage,
hoveredPrompt,
fetchCategories,
fetchWorkflows,
selectCategory,
selectSubcategory,
nextPage,
prevPage,
initialize,
};
});

View File

@ -1,16 +0,0 @@
export const NODE_ICONS: Record<string, string> = {
'n8n-nodes-base.gmail':
'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNTYiIGhlaWdodD0iMTkzIiBwcmVzZXJ2ZUFzcGVjdFJhdGlvPSJ4TWlkWU1pZCI+PHBhdGggZmlsbD0iIzQyODVGNCIgZD0iTTU4LjE4MiAxOTIuMDVWOTMuMTRMMjcuNTA3IDY1LjA3NyAwIDQ5LjUwNHYxMjUuMDkxYzAgOS42NTggNy44MjUgMTcuNDU1IDE3LjQ1NSAxNy40NTV6Ii8+PHBhdGggZmlsbD0iIzM0QTg1MyIgZD0iTTE5Ny44MTggMTkyLjA1aDQwLjcyN2M5LjY1OSAwIDE3LjQ1NS03LjgyNiAxNy40NTUtMTcuNDU1VjQ5LjUwNWwtMzEuMTU2IDE3LjgzNy0yNy4wMjYgMjUuNzk4eiIvPjxwYXRoIGZpbGw9IiNFQTQzMzUiIGQ9Im01OC4xODIgOTMuMTQtNC4xNzQtMzguNjQ3IDQuMTc0LTM2Ljk4OUwxMjggNjkuODY4bDY5LjgxOC01Mi4zNjQgNC42NyAzNC45OTItNC42NyA0MC42NDRMMTI4IDE0NS41MDR6Ii8+PHBhdGggZmlsbD0iI0ZCQkMwNCIgZD0iTTE5Ny44MTggMTcuNTA0VjkzLjE0TDI1NiA0OS41MDRWMjYuMjMxYzAtMjEuNTg1LTI0LjY0LTMzLjg5LTQxLjg5LTIwLjk0NXoiLz48cGF0aCBmaWxsPSIjQzUyMjFGIiBkPSJtMCA0OS41MDQgMjYuNzU5IDIwLjA3TDU4LjE4MiA5My4xNFYxNy41MDRMNDEuODkgNS4yODZDMjQuNjEtNy42NiAwIDQuNjQ2IDAgMjYuMjN6Ii8+PC9zdmc+',
'n8n-nodes-base.googleSheets':
'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI2MCIgaGVpZ2h0PSI2MCI+PGcgZmlsbD0ibm9uZSIgZmlsbC1ydWxlPSJldmVub2RkIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiPjxwYXRoIGZpbGw9IiMyOEI0NDYiIGQ9Ik0zNS42OSAxIDUyIDE3LjIyNXYzOS4wODdhMy42NyAzLjY3IDAgMCAxLTEuMDg0IDIuNjFBMy43IDMuNyAwIDAgMSA0OC4yOTMgNjBIMTIuNzA3YTMuNyAzLjcgMCAwIDEtMi42MjMtMS4wNzhBMy42NyAzLjY3IDAgMCAxIDkgNTYuMzEyVjQuNjg4YTMuNjcgMy42NyAwIDAgMSAxLjA4NC0yLjYxQTMuNyAzLjcgMCAwIDEgMTIuNzA3IDF6Ii8+PHBhdGggZmlsbD0iIzZBQ0U3QyIgZD0iTTM1LjY5IDEgNTIgMTcuMjI1SDM5LjM5N2MtMi4wNTQgMC0zLjcwNy0xLjgyOS0zLjcwNy0zLjg3MnoiLz48cGF0aCBmaWxsPSIjMjE5QjM4IiBkPSJNMzkuMjExIDE3LjIyNSA1MiAyMi40OHYtNS4yNTV6Ii8+PHBhdGggZmlsbD0iI0ZGRiIgZD0iTTIwLjEyIDMxLjk3NWMwLS44MTcuNjYyLTEuNDc1IDEuNDgzLTEuNDc1aDE3Ljc5NGMuODIxIDAgMS40ODIuNjU4IDEuNDgyIDEuNDc1djE1LjQ4N2MwIC44MTgtLjY2MSAxLjQ3NS0xLjQ4MiAxLjQ3NUgyMS42MDNhMS40NzYgMS40NzYgMCAwIDEtMS40ODItMS40NzRWMzEuOTc0em0yLjIyNSAxLjQ3NWg2LjY3MnYyLjIxMmgtNi42NzJ6bTAgNS4xNjJoNi42NzJ2Mi4yMTNoLTYuNjcyem0wIDUuMTYzaDYuNjcydjIuMjEyaC02LjY3MnptOS42MzgtMTAuMzI1aDYuNjcydjIuMjEyaC02LjY3MnptMCA1LjE2Mmg2LjY3MnYyLjIxM2gtNi42NzJ6bTAgNS4xNjNoNi42NzJ2Mi4yMTJoLTYuNjcyeiIvPjxwYXRoIGZpbGw9IiMyOEI0NDYiIGQ9Ik0zNC42OSAwIDUxIDE2LjIyNXYzOS4wODdhMy42NyAzLjY3IDAgMCAxLTEuMDg0IDIuNjFBMy43IDMuNyAwIDAgMSA0Ny4yOTMgNTlIMTEuNzA3YTMuNyAzLjcgMCAwIDEtMi42MjMtMS4wNzhBMy42NyAzLjY3IDAgMCAxIDggNTUuMzEyVjMuNjg4YTMuNjcgMy42NyAwIDAgMSAxLjA4NC0yLjYxQTMuNyAzLjcgMCAwIDEgMTEuNzA3IDB6Ii8+PHBhdGggZmlsbD0iIzZBQ0U3QyIgZD0iTTM0LjY5IDAgNTEgMTYuMjI1SDM4LjM5N2MtMi4wNTQgMC0zLjcwNy0xLjgyOS0zLjcwNy0zLjg3MnoiLz48cGF0aCBmaWxsPSIjMjE5QjM4IiBkPSJNMzguMjExIDE2LjIyNSA1MSAyMS40OHYtNS4yNTV6Ii8+PHBhdGggZmlsbD0iI0ZGRiIgZD0iTTE5LjEyIDMwLjk3NWMwLS44MTcuNjYyLTEuNDc1IDEuNDgzLTEuNDc1aDE3Ljc5NGMuODIxIDAgMS40ODIuNjU4IDEuNDgyIDEuNDc1djE1LjQ4N2MwIC44MTgtLjY2MSAxLjQ3NS0xLjQ4MiAxLjQ3NUgyMC42MDNhMS40NzYgMS40NzYgMCAwIDEtMS40ODItMS40NzRWMzAuOTc0em0yLjIyNSAxLjQ3NWg2LjY3MnYyLjIxMmgtNi42NzJ6bTAgNS4xNjJoNi42NzJ2Mi4yMTNoLTYuNjcyem0wIDUuMTYzaDYuNjcydjIuMjEyaC02LjY3MnptOS42MzgtMTAuMzI1aDYuNjcydjIuMjEyaC02LjY3MnptMCA1LjE2Mmg2LjY3MnYyLjIxM2gtNi42NzJ6bTAgNS4xNjNoNi42NzJ2Mi4yMTJoLTYuNjcyeiIvPjwvZz48L3N2Zz4=',
'n8n-nodes-base.googleCalendar':
'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHhtbG5zOnhsaW5rPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5L3hsaW5rIiBmaWxsPSIjZmZmIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiIHN0cm9rZT0iIzAwMCIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIiB2aWV3Qm94PSIwIDAgODEgODIiPjx1c2UgeGxpbms6aHJlZj0iI2EiIHg9Ii41IiB5PSIuNSIvPjxzeW1ib2wgaWQ9ImEiIG92ZXJmbG93PSJ2aXNpYmxlIj48ZyBmaWxsLXJ1bGU9Im5vbnplcm8iIHN0cm9rZT0ibm9uZSI+PHBhdGggZD0iTTYxLjA1MiAxOC45NDdIMTguOTQ3djQyLjEwNWg0Mi4xMDV6Ii8+PHBhdGggZmlsbD0iI2VhNDMzNSIgZD0iTTYxLjA1MyA4MCA4MCA2MS4wNTNINjEuMDUzeiIvPjxwYXRoIGZpbGw9IiNmYmJjMDQiIGQ9Ik04MCAxOC45NDdINjEuMDUzdjQyLjEwNUg4MHoiLz48cGF0aCBmaWxsPSIjMzRhODUzIiBkPSJNNjEuMDUyIDYxLjA1M0gxOC45NDdWODBoNDIuMTA1eiIvPjxwYXRoIGZpbGw9IiMxODgwMzgiIGQ9Ik0wIDYxLjA1M3YxMi42MzJBNi4zMTQgNi4zMTQgMCAwIDAgNi4zMTYgODBoMTIuNjMyVjYxLjA1M3oiLz48cGF0aCBmaWxsPSIjMTk2N2QyIiBkPSJNODAgMTguOTQ3VjYuMzE2QTYuMzE0IDYuMzE0IDAgMCAwIDczLjY4NSAwSDYxLjA1M3YxOC45NDd6Ii8+PHBhdGggZmlsbD0iIzQyODVmNCIgZD0iTTYxLjA1MyAwSDYuMzE2QTYuMzE0IDYuMzE0IDAgMCAwIDAgNi4zMTZ2NTQuNzM3aDE4Ljk0N1YxOC45NDdoNDIuMTA1VjB6TTI3LjU4NCA1MS42MTFjLTEuNTc0LTEuMDYzLTIuNjYzLTIuNjE2LTMuMjU4LTQuNjY4bDMuNjUzLTEuNTA1cS40OTggMS44OTQgMS43MzcgMi45MzdjMS4yMzkgMS4wNDMgMS44MjEgMS4wMzcgMi45ODkgMS4wMzdxMS43OTIgMCAzLjA3OS0xLjA0OWMxLjI4Ny0xLjA4OSAxLjI5LTEuNjUzIDEuMjktMi43NzRhMy40NCAzLjQ0IDAgMCAwLTEuMzU4LTIuODExYy0uOTA1LS43MjctMi4wNDItMS4wODktMy40LTEuMDg5aC0yLjExMXYtMy42MTZIMzIuMXExLjc1MiAwIDIuOTUzLS45NDdjMS4yMDEtLjk0NyAxLjItMS40OTUgMS4yLTIuNTk1cTAtMS40NjctMS4wNzQtMi4zNDJjLTEuMDc0LS44NzUtMS42MjEtLjg3OS0yLjcyMS0uODc5cS0xLjYxLS4wMDItMi41NTguODU4Yy0uOTQ4Ljg2LTEuMTA2IDEuMzAxLTEuMzc5IDIuMTExbC0zLjYxNi0xLjUwNWMuNDc5LTEuMzU4IDEuMzU4LTIuNTU4IDIuNjQ3LTMuNTk1czIuOTM3LTEuNTU4IDQuOTM3LTEuNTU4cTIuMjItLjAwMiAzLjk4OS44NThjMS43NjkuODYgMi4xMDUgMS4zNjggMi43NzQgMi4zNzlzMSAyLjE1MyAxIDMuNDE2cTAgMS45MzItLjkzMiAzLjI3NGMtLjkzMiAxLjM0Mi0xLjM4NCAxLjU3OS0yLjI4OSAyLjA1OHYuMjE2YTYuOTUgNi45NSAwIDAgMSAyLjkzNyAyLjI4OXExLjE0NiAxLjUzOCAxLjE0NyAzLjY4NGMuMDAxIDIuMTQ2LS4zNjMgMi43MTEtMS4wODkgMy44MzJzLTEuNzMyIDIuMDA1LTMuMDA1IDIuNjQ3Yy0xLjI3OS42NDItMi43MTYuOTY4LTQuMzExLjk2OC0xLjg0Ny4wMDUtMy41NTMtLjUyNi01LjEyNi0xLjU4OXptMjIuNDM3LTE4LjEyNi00LjAxIDIuOS0yLjAwNS0zLjA0MiA3LjE5NS01LjE4OWgyLjc1OHYyNC40NzloLTMuOTM3VjMzLjQ4NHoiLz48L2c+PC9zeW1ib2w+PC9zdmc+',
'n8n-nodes-base.hubspot':
'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHhtbG5zOnhsaW5rPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5L3hsaW5rIiBmaWxsPSIjZmZmIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiIHN0cm9rZT0iIzAwMCIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIiB2aWV3Qm94PSIwIDAgNjIuODgzIDY5Ljg4MyI+PHVzZSB4bGluazpocmVmPSIjYSIgeD0iMi40NDIiIHk9IjIuNDQyIi8+PHN5bWJvbCBpZD0iYSIgb3ZlcmZsb3c9InZpc2libGUiPjxwYXRoIGZpbGw9IiNmODc2MWYiIGZpbGwtcnVsZT0ibm9uemVybyIgc3Ryb2tlPSJub25lIiBkPSJNNTUuNTA0IDMwLjQwMWExNi4yNiAxNi4yNiAwIDAgMC01LjkwNC01Ljg2NGMtMS44NjUtMS4wODQtMy43OTQtMS43NzMtNS45NzItMi4wN3YtNy43OThhNS43MSA1LjcxIDAgMCAwIDMuNTI1LTUuMzU3IDUuODYgNS44NiAwIDAgMC01Ljg1OS01Ljg4OSA1LjkxIDUuOTEgMCAwIDAtNS45MDggNS44ODljMCAyLjM5MyAxLjI3IDQuNDM0IDMuNDUyIDUuMzU3djcuNzU0YTE3IDE3IDAgMCAwLTUuMTk1IDEuNjMxTDEyLjc2OSA4LjI0N2MuMTQ2LS41NTIuMjczLTEuMTIzLjI3My0xLjcyNEE2LjUyIDYuNTIgMCAwIDAgNi41MTkgMCA2LjUyIDYuNTIgMCAwIDAgMCA2LjUyNGE2LjUyMyA2LjUyMyAwIDAgMCA2LjUyNCA2LjUyNCA2LjQ3IDYuNDcgMCAwIDAgMy4zNS0uOTUybDEuMzY3IDEuMDM1IDE4LjcyNiAxMy41MDFjLS45OTEuOTA4LTEuOTE0IDEuOTQzLTIuNjUxIDMuMTA1LTEuNDk0IDIuMzY4LTIuNDA3IDQuOTcxLTIuNDA3IDcuODEzdi41ODZhMTYuNCAxNi40IDAgMCAwIDEuMDI1IDUuNjQ1QzI2LjUgNDUuMzI0IDI3LjMzIDQ2LjczIDI4LjM2MSA0OGwtNi4yMjEgNi4yMzVhNS4wMSA1LjAxIDAgMCAwLTUuMjk4IDEuMTYyYy0uOTQ3Ljk0Mi0xLjQ4IDIuMjI3LTEuNDc1IDMuNTY1cy41MjcgMi42MTIgMS40NzkgMy41NjQgMi4yMjcgMS40OCAzLjU2NSAxLjQ4YTUgNSAwIDAgMCAzLjU2NS0xLjQ4IDUuMDUgNS4wNSAwIDAgMCAxLjQ3NS0zLjU2NCA1IDUgMCAwIDAtLjIzNC0xLjUxNGw2LjQyNi02LjQyNmExNiAxNiAwIDAgMCAyLjg1NiAxLjU2MyAxNi43IDE2LjcgMCAwIDAgNi42ODUgMS40MDZoLjQzOWExNS43NiAxNS43NiAwIDAgMCA3LjYyNy0xLjkyOSAxNS43NyAxNS43NyAwIDAgMCA1Ljk3Ny01LjYzYzEuNDk5LTIuMzkzIDIuMzE5LTUuMDQ0IDIuMzE5LTcuOTU5di0uMTQ2YzAtMi44NjYtLjY2NC01LjUwOC0yLjA1MS03Ljkzem0tNy44NDcgMTMuNDg3Yy0xLjc0MyAxLjkzOC0zLjc1IDMuMTM1LTYuMDE2IDMuMTM1aC0uNDNjLTEuMjk0IDAtMi41NjQtLjM1Ni0zLjc5OS0xLjAxMWE4LjggOC44IDAgMCAxLTMuMzMtMy4wMzJjLS44OTgtMS4yNy0xLjM4Ny0yLjY1Ni0xLjM4Ny00LjEyNnYtLjQzOWMwLTEuNDQ1LjI3OC0yLjgxNy45NzctNC4xMTEuNzQ3LTEuNDY1IDEuNzU4LTIuNTE1IDMuMTAxLTMuMzg5YTcuNiA3LjYgMCAwIDEgNC4yOTctMS4yOTRoLjE0N2MxLjQxNiAwIDIuNzY5LjI3OCA0LjAzOC45MjhhOC41NiA4LjU2IDAgMCAxIDMuMTc0IDIuODg2IDkuMiA5LjIgMCAwIDEgMS40MjEgNC4wNTNsLjAzNC45MTNjMCAxLjk4Ny0uNzYyIDMuODI4LTIuMjggNS40OTh6Ii8+PC9zeW1ib2w+PC9zdmc+',
'n8n-nodes-base.whatsApp':
'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI2MCIgaGVpZ2h0PSI2MCIgZmlsbC1ydWxlPSJldmVub2RkIiBjbGlwLXJ1bGU9ImV2ZW5vZGQiIHZpZXdCb3g9IjAgMCA0OCA0OCI+PHBhdGggZmlsbD0iI2ZmZiIgZD0ibTQuODY4IDQzLjMwMyAyLjY5NC05LjgzNWExOC45NCAxOC45NCAwIDAgMS0yLjUzNS05LjQ4OUM1LjAzMiAxMy41MTQgMTMuNTQ4IDUgMjQuMDE0IDVhMTguODcgMTguODcgMCAwIDEgMTMuNDMgNS41NjZBMTguODcgMTguODcgMCAwIDEgNDMgMjMuOTk0Yy0uMDA0IDEwLjQ2NS04LjUyMiAxOC45OC0xOC45ODYgMTguOThoLS4wMDhhMTkgMTkgMCAwIDEtOS4wNzMtMi4zMTF6Ii8+PHBhdGggZmlsbD0iI2ZmZiIgZD0iTTQuODY4IDQzLjgwM2EuNS41IDAgMCAxLS40ODItLjYzMWwyLjYzOS05LjYzNmExOS41IDE5LjUgMCAwIDEtMi40OTctOS41NTZDNC41MzIgMTMuMjM4IDEzLjI3MyA0LjUgMjQuMDE0IDQuNWExOS4zNyAxOS4zNyAwIDAgMSAxMy43ODQgNS43MTNBMTkuMzYgMTkuMzYgMCAwIDEgNDMuNSAyMy45OTRjLS4wMDQgMTAuNzQxLTguNzQ2IDE5LjQ4LTE5LjQ4NiAxOS40OGExOS41NCAxOS41NCAwIDAgMS05LjE0NC0yLjI3N2wtOS44NzUgMi41ODlhLjUuNSAwIDAgMS0uMTI3LjAxNyIvPjxwYXRoIGZpbGw9IiNjZmQ4ZGMiIGQ9Ik0yNC4wMTQgNWExOC44NyAxOC44NyAwIDAgMSAxMy40MyA1LjU2NkExOC44NyAxOC44NyAwIDAgMSA0MyAyMy45OTRjLS4wMDQgMTAuNDY1LTguNTIyIDE4Ljk4LTE4Ljk4NiAxOC45OGgtLjAwOGExOSAxOSAwIDAgMS05LjA3My0yLjMxMWwtMTAuMDY1IDIuNjQgMi42OTQtOS44MzVhMTguOTQgMTguOTQgMCAwIDEtMi41MzUtOS40ODlDNS4wMzIgMTMuNTE0IDEzLjU0OCA1IDI0LjAxNCA1bTAtMUMxMi45OTggNCA0LjAzMiAxMi45NjIgNC4wMjcgMjMuOTc5YTIwIDIwIDAgMCAwIDIuNDYxIDkuNjIyTDMuOTAzIDQzLjA0YS45OTguOTk4IDAgMCAwIDEuMjE5IDEuMjMxbDkuNjg3LTIuNTRhMjAgMjAgMCAwIDAgOS4xOTcgMi4yNDRjMTEuMDI0IDAgMTkuOTktOC45NjMgMTkuOTk1LTE5Ljk4QTE5Ljg2IDE5Ljg2IDAgMCAwIDM4LjE1MyA5Ljg2IDE5Ljg3IDE5Ljg3IDAgMCAwIDI0LjAxNCA0Ii8+PHBhdGggZmlsbD0iIzQwYzM1MSIgZD0iTTM1LjE3NiAxMi44MzJhMTUuNjcgMTUuNjcgMCAwIDAtMTEuMTU3LTQuNjI2Yy04LjcwNCAwLTE1Ljc4MyA3LjA3Ni0xNS43ODcgMTUuNzc0YTE1Ljc0IDE1Ljc0IDAgMCAwIDIuNDEzIDguMzk2bC4zNzYuNTk3LTEuNTk1IDUuODIxIDUuOTczLTEuNTY2LjU3Ny4zNDJhMTUuNzUgMTUuNzUgMCAwIDAgOC4wMzIgMi4xOTloLjAwNmM4LjY5OCAwIDE1Ljc3Ny03LjA3NyAxNS43OC0xNS43NzZhMTUuNjggMTUuNjggMCAwIDAtNC42MTgtMTEuMTYxIi8+PHBhdGggZmlsbD0iI2ZmZiIgZD0iTTE5LjI2OCAxNi4wNDVjLS4zNTUtLjc5LS43MjktLjgwNi0xLjA2OC0uODItLjI3Ny0uMDEyLS41OTMtLjAxMS0uOTA5LS4wMTFzLS44My4xMTktMS4yNjUuNTk0LTEuNjYxIDEuNjIyLTEuNjYxIDMuOTU2IDEuNyA0LjU5IDEuOTM3IDQuOTA2IDMuMjgyIDUuMjU5IDguMTA0IDcuMTYxYzQuMDA3IDEuNTggNC44MjMgMS4yNjYgNS42OTMgMS4xODdzMi44MDctMS4xNDcgMy4yMDItMi4yNTUuMzk1LTIuMDU3LjI3Ny0yLjI1NWMtLjExOS0uMTk4LS40MzUtLjMxNi0uOTA5LS41NTRzLTIuODA3LTEuMzg1LTMuMjQyLTEuNTQzLS43NTEtLjIzNy0xLjA2OC4yMzhjLS4zMTYuNDc0LTEuMjI1IDEuNTQzLTEuNTAyIDEuODU5cy0uNTU0LjM1Ny0xLjAyOC4xMTktMi4wMDItLjczOC0zLjgxNS0yLjM1NGMtMS40MS0xLjI1Ny0yLjM2Mi0yLjgxLTIuNjM5LTMuMjg1LS4yNzctLjQ3NC0uMDMtLjczMS4yMDgtLjk2OC4yMTMtLjIxMy40NzQtLjU1NC43MTItLjgzMS4yMzctLjI3Ny4zMTYtLjQ3NS40NzQtLjc5MXMuMDc5LS41OTQtLjA0LS44MzFjLS4xMTctLjIzOC0xLjAzOS0yLjU4NC0xLjQ2MS0zLjUyMiIvPjwvc3ZnPg==',
'n8n-nodes-base.notion':
'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNDAiIGhlaWdodD0iNDAiIHZpZXdCb3g9IjAgMCA0MCA0MCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZmlsbC1ydWxlPSJldmVub2RkIiBjbGlwLXJ1bGU9ImV2ZW5vZGQiIGQ9Ik03LjU4Mjc2IDYuOTc2NzlDOC44MjA0NyA3Ljk4MjM4IDkuMjg0NzkgNy45MDU2NiAxMS42MDkxIDcuNzUwNTdMMzMuNTIwNiA2LjQzNDg4QzMzLjk4NTMgNi40MzQ4OCAzMy41OTg5IDUuOTcxMjcgMzMuNDQzOSA1Ljg5NDIzTDI5LjgwNDkgMy4yNjM0OEMyOS4xMDc2IDIuNzIyMTMgMjguMTc4NiAyLjEwMjE3IDI2LjM5ODIgMi4yNTcyNkw1LjE4MTE1IDMuODA0NzZDNC40MDczNiAzLjg4MTQ4IDQuMjUyODIgNC4yNjgzNyA0LjU2MDk2IDQuNTc4NDdMNy41ODI3NiA2Ljk3Njc5Wk04Ljg5ODI5IDEyLjA4MzNWMzUuMTM4MUM4Ljg5ODI5IDM2LjM3NzEgOS41MTc0NiAzNi44NDA3IDEwLjkxMSAzNi43NjRMMzQuOTkxOSAzNS4zNzA2QzM2LjM4NjIgMzUuMjkzOSAzNi41NDE1IDM0LjQ0MTcgMzYuNTQxNSAzMy40MzUyVjEwLjUzNTFDMzYuNTQxNSA5LjUzMDE5IDM2LjE1NDkgOC45ODgyOSAzNS4zMDE0IDkuMDY1NjRMMTAuMTM2NyAxMC41MzUxQzkuMjA3OTkgMTAuNjEzMSA4Ljg5ODIxIDExLjA3NzcgOC44OTgyMSAxMi4wODMzSDguODk4MjlaTTMyLjY3MDggMTMuMzJDMzIuODI1MiAxNC4wMTcgMzIuNjcwOCAxNC43MTMzIDMxLjk3MjUgMTQuNzkxN0wzMC44MTIzIDE1LjAyMjlWMzIuMDQzNEMyOS44MDQ5IDMyLjU4NDggMjguODc1OSAzMi44OTQ0IDI4LjEwMTggMzIuODk0NEMyNi44NjI1IDMyLjg5NDQgMjYuNTUyMSAzMi41MDcyIDI1LjYyMzcgMzEuMzQ3NEwxOC4wMzQzIDE5LjQzMjlWMzAuOTYwNUwyMC40MzU5IDMxLjUwMjRDMjAuNDM1OSAzMS41MDI0IDIwLjQzNTkgMzIuODk0NCAxOC40OTgzIDMyLjg5NDRMMTMuMTU2OCAzMy4yMDQyQzEzLjAwMTYgMzIuODk0NCAxMy4xNTY4IDMyLjEyMTQgMTMuNjk4NiAzMS45NjY1TDE1LjA5MjUgMzEuNTgwMlYxNi4zMzg1TDEzLjE1NzIgMTYuMTgzNEMxMy4wMDE5IDE1LjQ4NjQgMTMuMzg4NSAxNC40ODE0IDE0LjQ3MzMgMTQuNDAzNUwyMC4yMDM1IDE0LjAxNzJMMjguMTAxOCAyNi4wODY4VjE1LjQwOTdMMjYuMDg4MSAxNS4xNzg2QzI1LjkzMzUgMTQuMzI2NSAyNi41NTIxIDEzLjcwNzggMjcuMzI2NSAxMy42MzExTDMyLjY3MDggMTMuMzJaTTMuMzk5NzMgMS43MTU5OEwyNS40Njg4IDAuMDkwNzQ1N0MyOC4xNzkgLTAuMTQxNjg4IDI4Ljg3NjMgMC4wMTQwMjQ1IDMwLjU3OTYgMS4yNTEzNUwzNy42MjQzIDYuMjAyNzZDMzguNzg2NyA3LjA1NDIxIDM5LjE3NDIgNy4yODYwMiAzOS4xNzQyIDguMjE0MTlWMzUuMzcwNkMzOS4xNzQyIDM3LjA3MjYgMzguNTU0MiAzOC4wNzkxIDM2LjM4NjUgMzguMjMzMUwxMC43NTc3IDM5Ljc4MDdDOS4xMzA0OSAzOS44NTgzIDguMzU2MDcgMzkuNjI2NCA3LjUwMzkyIDM4LjU0MjZMMi4zMTYwOCAzMS44MTE3QzEuMzg2NTggMzAuNTcyNiAxIDI5LjY0NTcgMSAyOC41NjEzVjQuNDIyODNDMSAzLjAzMTA1IDEuNjIwMTkgMS44NzAwNSAzLjM5OTczIDEuNzE1OThWMS43MTU5OFoiIGZpbGw9ImJsYWNrIi8+Cjwvc3ZnPgo=',
'n8n-nodes-base.slack':
'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHhtbG5zOnhsaW5rPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5L3hsaW5rIiBmaWxsPSIjZmZmIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiIHN0cm9rZT0iIzAwMCIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIiB2aWV3Qm94PSIwIDAgMTUwLjg1MiAxNTAuODUyIj48dXNlIHhsaW5rOmhyZWY9IiNhIiB4PSIuOTI2IiB5PSIuOTI2Ii8+PHN5bWJvbCBpZD0iYSIgb3ZlcmZsb3c9InZpc2libGUiPjxnIHN0cm9rZS13aWR0aD0iMS44NTIiPjxwYXRoIGZpbGw9IiNlMDFlNWEiIHN0cm9rZT0iI2UwMWU1YSIgZD0iTTQwLjc0MSA5My41NWMwLTguNzM1IDYuNjA3LTE1Ljc3MiAxNC44MTUtMTUuNzcyczE0LjgxNSA3LjAzNyAxNC44MTUgMTUuNzcydjM4LjgyNGMwIDguNzM3LTYuNjA3IDE1Ljc3NC0xNC44MTUgMTUuNzc0cy0xNC44MTUtNy4wMzctMTQuODE1LTE1Ljc3MnoiLz48cGF0aCBmaWxsPSIjZWNiMjJkIiBzdHJva2U9IiNlY2IyMmQiIGQ9Ik05My41NSAxMDcuNDA4Yy04LjczNSAwLTE1Ljc3Mi02LjYwNy0xNS43NzItMTQuODE1czcuMDM3LTE0LjgxNSAxNS43NzItMTQuODE1aDM4LjgyNmM4LjczNSAwIDE1Ljc3MiA2LjYwNyAxNS43NzIgMTQuODE1cy03LjAzNyAxNC44MTUtMTUuNzcyIDE0LjgxNXoiLz48cGF0aCBmaWxsPSIjMmZiNjdjIiBzdHJva2U9IiMyZmI2N2MiIGQ9Ik03Ny43NzggMTUuNzcyQzc3Ljc3OCA3LjAzNyA4NC4zODUgMCA5Mi41OTMgMHMxNC44MTUgNy4wMzcgMTQuODE1IDE1Ljc3MnYzOC44MjZjMCA4LjczNS02LjYwNyAxNS43NzItMTQuODE1IDE1Ljc3MnMtMTQuODE1LTcuMDM3LTE0LjgxNS0xNS43NzJ6Ii8+PHBhdGggZmlsbD0iIzM2YzVmMSIgc3Ryb2tlPSIjMzZjNWYxIiBkPSJNMTUuNzcyIDcwLjM3MUM3LjAzNyA3MC4zNzEgMCA2My43NjMgMCA1NS41NTZzNy4wMzctMTQuODE1IDE1Ljc3Mi0xNC44MTVoMzguODI2YzguNzM1IDAgMTUuNzcyIDYuNjA3IDE1Ljc3MiAxNC44MTVzLTcuMDM3IDE0LjgxNS0xNS43NzIgMTQuODE1eiIvPjxnIHN0cm9rZS1saW5lam9pbj0ibWl0ZXIiPjxwYXRoIGZpbGw9IiNlY2IyMmQiIHN0cm9rZT0iI2VjYjIyZCIgZD0iTTc3Ljc3OCAxMzMuMzMzYzAgOC4yMDggNi42MDcgMTQuODE1IDE0LjgxNSAxNC44MTVzMTQuODE1LTYuNjA3IDE0LjgxNS0xNC44MTUtNi42MDctMTQuODE1LTE0LjgxNS0xNC44MTVINzcuNzc4eiIvPjxwYXRoIGZpbGw9IiMyZmI2N2MiIHN0cm9rZT0iIzJmYjY3YyIgZD0iTTEzMy4zMzQgNzAuMzcxaC0xNC44MTVWNTUuNTU2YzAtOC4yMDcgNi42MDctMTQuODE1IDE0LjgxNS0xNC44MTVzMTQuODE1IDYuNjA3IDE0LjgxNSAxNC44MTUtNi42MDcgMTQuODE1LTE0LjgxNSAxNC44MTV6Ii8+PHBhdGggZmlsbD0iI2UwMWU1YSIgc3Ryb2tlPSIjZTAxZTVhIiBkPSJNMTQuODE1IDc3Ljc3OEgyOS42M3YxNC44MTVjMCA4LjIwNy02LjYwNyAxNC44MTUtMTQuODE1IDE0LjgxNVMwIDEwMC44IDAgOTIuNTkzczYuNjA3LTE0LjgxNSAxNC44MTUtMTQuODE1eiIvPjxwYXRoIGZpbGw9IiMzNmM1ZjEiIHN0cm9rZT0iIzM2YzVmMSIgZD0iTTcwLjM3MSAxNC44MTVWMjkuNjNINTUuNTU2Yy04LjIwNyAwLTE0LjgxNS02LjYwNy0xNC44MTUtMTQuODE1UzQ3LjM0OCAwIDU1LjU1NiAwczE0LjgxNSA2LjYwNyAxNC44MTUgMTQuODE1eiIvPjwvZz48L2c+PC9zeW1ib2w+PC9zdmc+',
};

View File

@ -1,18 +0,0 @@
import { computed } from 'vue';
import { INSTANCE_AI_TEMPLATE_EXAMPLES_EXPERIMENT } from '@/app/constants/experiments';
import { usePostHog } from '@/app/stores/posthog.store';
export function useInstanceAiTemplateExamplesExperiment() {
const posthogStore = usePostHog();
const currentVariant = computed(() =>
posthogStore.getVariant(INSTANCE_AI_TEMPLATE_EXAMPLES_EXPERIMENT.name),
);
const isFeatureEnabled = computed(
() => currentVariant.value === INSTANCE_AI_TEMPLATE_EXAMPLES_EXPERIMENT.variant,
);
return { currentVariant, isFeatureEnabled };
}

View File

@ -18,9 +18,7 @@ import { getExperimentTelemetryPayload } from '@/experiments/utils';
import {
INSTANCE_AI_INSPIRATION_FROM_TAXONOMY_EXPERIMENT,
INSTANCE_AI_PERSONALIZED_PROMPT_SUGGESTIONS_EXPERIMENT,
INSTANCE_AI_TEMPLATE_EXAMPLES_EXPERIMENT,
} from '@/app/constants/experiments';
import { INSTANCE_AI_TEMPLATE_EXAMPLES_EXPOSURE_EVENT } from '@/experiments/instanceAiTemplateExamples/constants';
import { useSettingsStore } from '@n8n/stores/settings.store';
import { useCloudPlanStore } from '@n8n/stores/cloudPlan.store';
import { useInstanceAiStore } from './instanceAi.store';
@ -81,12 +79,6 @@ import WorkflowBuilderUnavailableNotice from './components/WorkflowBuilderUnavai
import CreditWarningBanner from '@/features/ai/assistant/components/Agent/CreditWarningBanner.vue';
import ProjectSelect from './components/ProjectSelect.vue';
import { useProjectsStore } from '@/features/collaboration/projects/projects.store';
import {
useInstanceAiTemplateExamplesExperiment,
useInstanceAiTemplateExamplesStore,
TemplateExamplesCatalog,
TEMPLATE_PROMPT_SUFFIX,
} from '@/experiments/instanceAiTemplateExamples';
import { InstanceAiFreeNudge } from '@/experiments/instanceAiFreeNudge';
// Experiment cleanup: remove with instanceAiPromptSuggestionsV2.
@ -148,31 +140,6 @@ const { isFeatureEnabled: isProactiveAgentExperimentEnabled } =
useInstanceAiProactiveAgentExperiment();
const { isFeatureEnabled: isPromptSuggestionsV2ExperimentEnabled } =
useInstanceAiPromptSuggestionsV2Experiment();
const {
isFeatureEnabled: isTemplateExamplesExperimentEnabled,
currentVariant: templateExamplesVariant,
} = useInstanceAiTemplateExamplesExperiment();
const templateExamplesStore = useInstanceAiTemplateExamplesStore();
const showTemplateExamples = computed(
() => isTemplateExamplesExperimentEnabled.value && !templateExamplesStore.hasLoadFailed,
);
let hasTrackedTemplateExamplesExposure = false;
watch(
showTemplateExamples,
(visible) => {
const variant = templateExamplesVariant.value;
if (!visible || hasTrackedTemplateExamplesExposure || typeof variant !== 'string') {
return;
}
telemetry.track(
INSTANCE_AI_TEMPLATE_EXAMPLES_EXPOSURE_EVENT,
getExperimentTelemetryPayload(INSTANCE_AI_TEMPLATE_EXAMPLES_EXPERIMENT, variant),
);
hasTrackedTemplateExamplesExposure = true;
},
{ immediate: true },
);
const { isVariantEnabled: isSplitVariantEnabled } = useInstanceAiSplitEmptyStateExperiment();
// Experiment cleanup: remove with instanceAiSplitEmptyState.
const splitPreviewPromptKey = ref<BaseTextKey | null>(null);
@ -214,7 +181,7 @@ const isTaxonomySegmentResolved = computed(
'taxonomy',
);
const shouldTrackInspirationFromTaxonomyExposure = computed(() => {
if (showProactiveStarter.value || isSplitLayoutActive.value || showTemplateExamples.value) {
if (showProactiveStarter.value || isSplitLayoutActive.value) {
return false;
}
@ -411,10 +378,6 @@ const emptyStatePromptSuggestionProps = computed(() => {
return {};
}
if (showTemplateExamples.value) {
return {};
}
if (isTaxonomySuggestionsPending.value) {
return {
suggestions: [],
@ -486,16 +449,7 @@ const emptyStatePromptSuggestionProps = computed(() => {
placeholderKey: INSTANCE_AI_WORKFLOW_PREVIEW_SUGGESTIONS_PLACEHOLDER_KEY,
};
});
// Experiment cleanup: remove with InstanceAiTemplateExamplesExperiment
const INSTANCE_AI_TEMPLATE_EXAMPLES_TITLE_KEY =
'experiments.instanceAiTemplateExamples.emptyState.title' as BaseTextKey;
const INSTANCE_AI_TEMPLATE_EXAMPLES_PLACEHOLDER_KEY =
'experiments.instanceAiTemplateExamples.input.placeholder' as BaseTextKey;
const emptyStateTitleKey = computed<BaseTextKey>(() => {
if (showTemplateExamples.value) {
return INSTANCE_AI_TEMPLATE_EXAMPLES_TITLE_KEY;
}
if (
shouldShowTaxonomySuggestions.value ||
isTaxonomySuggestionsPending.value ||
@ -528,20 +482,6 @@ watch(
},
);
// Experiment cleanup: remove with InstanceAiTemplateExamplesExperiment
const templatePreviewPrompt = ref<string | null>(null);
function handleTemplateHoverPrompt(prompt: string) {
templatePreviewPrompt.value = prompt;
}
function handleTemplateHoverEnd() {
templatePreviewPrompt.value = null;
}
const inputPulsing = ref(false);
const selectedTemplatePrompt = ref<string | null>(null);
type ShelfSuggestionPayload = InstanceAiPrefillDeclaration & {
promptKey: BaseTextKey;
suggestionId: string;
@ -549,19 +489,6 @@ type ShelfSuggestionPayload = InstanceAiPrefillDeclaration & {
position: number;
};
function handleTemplateSelectPrompt(prompt: string) {
templatePreviewPrompt.value = null;
if (chatInputRef.value) {
chatInputRef.value.setPrefill({ text: prompt, prefillType: 'template_example' });
chatInputRef.value.focus();
}
selectedTemplatePrompt.value = prompt;
inputPulsing.value = true;
setTimeout(() => {
inputPulsing.value = false;
}, 250);
}
// EOF InstanceAiTemplateExamplesExperiment experiment cleanup
const emptyLayoutRef = useTemplateRef<HTMLElement>('emptyLayout');
const centeredInputRef = useTemplateRef<HTMLElement>('centeredInput');
const CANVAS_NATURAL_HEIGHT_PX = 420;
@ -630,13 +557,6 @@ async function handleSubmit(
return;
}
// Experiment cleanup: remove with InstanceAiTemplateExamplesExperiment
const isFromTemplate =
isTemplateExamplesExperimentEnabled.value &&
selectedTemplatePrompt.value !== null &&
message.startsWith(selectedTemplatePrompt.value);
const finalMessage = isFromTemplate ? message + TEMPLATE_PROMPT_SUFFIX : message;
const threadId = uuidv4();
isStartingThread.value = true;
@ -661,7 +581,7 @@ async function handleSubmit(
// not an option: it reads its composer draft from localStorage once, synchronously, on
// mount, which always precedes this response. `sendMessage` has already surfaced the
// reason, so restore what was typed and stay put.
const sent = await thread.sendMessage(finalMessage, {
const sent = await thread.sendMessage(message, {
authorship,
attachments,
pushRef: rootStore.pushRef,
@ -822,14 +742,8 @@ function handleShelfSuggestionInsert(payload: ShelfSuggestionPayload) {
<WorkflowBuilderUnavailableNotice v-if="!settingsStore.isWorkflowBuilderAvailable" />
<InstanceAiInput
ref="chatInputRef"
:class="inputPulsing && $style.inputPulse"
:is-submitting="isStartingThread"
:is-workflow-builder-available="settingsStore.isWorkflowBuilderAvailable"
:contextual-suggestion="templatePreviewPrompt"
:placeholder-key="
showTemplateExamples ? INSTANCE_AI_TEMPLATE_EXAMPLES_PLACEHOLDER_KEY : undefined
"
:bold-placeholder="showTemplateExamples"
v-bind="emptyStatePromptSuggestionProps"
@submit="handleSubmit"
@workflow-preview="handleWorkflowPreview"
@ -842,14 +756,6 @@ function handleShelfSuggestionInsert(payload: ShelfSuggestionPayload) {
</template>
</InstanceAiInput>
</div>
<!-- Experiment cleanup: remove with InstanceAiTemplateExamplesExperiment -->
<TemplateExamplesCatalog
v-if="showTemplateExamples"
:class="$style.templateCatalog"
@hover-prompt="handleTemplateHoverPrompt"
@hover-end="handleTemplateHoverEnd"
@select-prompt="handleTemplateSelectPrompt"
/>
<Transition name="workflow-preview-fade">
<div
v-if="activeWorkflowPreview && hasSpaceForPreview"
@ -919,29 +825,6 @@ function handleShelfSuggestionInsert(payload: ShelfSuggestionPayload) {
gap: var(--spacing--xs);
}
.inputPulse {
animation: inputScaleUp 0.25s ease;
}
@keyframes inputScaleUp {
0% {
transform: scale(1);
}
50% {
transform: scale(1.02);
}
100% {
transform: scale(1);
}
}
.templateCatalog {
width: 100%;
max-width: 1014px;
min-width: 0;
margin-top: var(--spacing--m);
}
.workflowPreviewWrapper {
width: 100%;
max-width: 1600px;

View File

@ -33,14 +33,8 @@ const {
appSettingsStoreMock,
replaceMock,
showErrorMock,
templateExamplesStoreMock,
templateExamplesEnabled,
telemetryTrack,
} = vi.hoisted(() => ({
templateExamplesStoreMock: {
hasLoadFailed: false,
},
templateExamplesEnabled: { value: false },
experimentMocks: {
proactiveAgentEnabled: { value: false },
promptSuggestionsV2Enabled: { value: false },
@ -240,39 +234,6 @@ vi.mock('@/experiments/instanceAiWorkflowPreviewSuggestions', () => ({
getPreviewWorkflow: () => null,
}));
vi.mock('@/experiments/instanceAiTemplateExamples', async () => {
const { computed, h } = await import('vue');
type VueSetupContext = {
emit: (event: string, ...args: unknown[]) => void;
};
return {
useInstanceAiTemplateExamplesExperiment: () => ({
isFeatureEnabled: computed(() => templateExamplesEnabled.value),
currentVariant: computed(() => (templateExamplesEnabled.value ? 'variant' : 'control')),
}),
useInstanceAiTemplateExamplesStore: () => templateExamplesStoreMock,
TEMPLATE_PROMPT_SUFFIX:
'\n\nAsk me questions to narrow down my use case and the tools I use to best personalize the example for my needs.',
TemplateExamplesCatalog: {
name: 'TemplateExamplesCatalogStub',
emits: ['hover-prompt', 'hover-end', 'select-prompt'],
setup(_props: Record<string, unknown>, { emit }: VueSetupContext) {
return () =>
h('div', { 'data-test-id': 'template-examples-catalog' }, [
h(
'button',
{
'data-test-id': 'template-example-card',
onClick: () => emit('select-prompt', 'Build me an invoice automation'),
},
'example card',
),
]);
},
},
};
});
vi.mock('@/app/composables/usePageRedirectionHelper', () => ({
usePageRedirectionHelper: () => ({ goToUpgrade: vi.fn() }),
}));
@ -519,8 +480,6 @@ describe('InstanceAiEmptyView', () => {
cloudPlanStoreMock.state.initialized = false;
cloudPlanStoreMock.currentUserCloudInfo = null;
appSettingsStoreMock.isCloudDeployment = false;
templateExamplesStoreMock.hasLoadFailed = false;
templateExamplesEnabled.value = false;
});
afterEach(() => {
@ -1046,12 +1005,6 @@ describe('InstanceAiEmptyView', () => {
experimentMocks.proactiveAgentEnabled.value = true;
},
},
{
name: 'template examples',
setup: () => {
templateExamplesEnabled.value = true;
},
},
])('does not track inspiration from taxonomy exposure when $name is active', ({ setup }) => {
experimentMocks.inspirationFromTaxonomyVariant.value = 'control';
appSettingsStoreMock.isCloudDeployment = true;
@ -1343,50 +1296,4 @@ describe('InstanceAiEmptyView', () => {
expect(thread.sendMessage).not.toHaveBeenCalled();
expect(replaceMock).not.toHaveBeenCalled();
});
it('injects the prompt into the input when a template example card is clicked', async () => {
templateExamplesEnabled.value = true;
const { getByTestId } = renderView();
expect(getByTestId('template-examples-catalog')).toBeInTheDocument();
expect(getByTestId('instance-ai-free-nudge-stub')).toHaveAttribute('data-eligible', 'true');
await fireEvent.click(getByTestId('template-example-card'));
await flushPromises();
expect(getByTestId('instance-ai-input-stub')).toHaveClass('inputPulse');
expect(getByTestId('instance-ai-free-nudge-stub')).not.toHaveClass('inputPulse');
expect(getByTestId('instance-ai-input-text')).toHaveTextContent(
'Build me an invoice automation',
);
});
// The pre-fill tagging must not disturb what the agent receives: the suffix is
// still appended, and the card now reports itself instead of being recovered by
// matching that suffix against the message body.
it('sends a template example with its suffix intact and reports it as a pre-fill', async () => {
templateExamplesEnabled.value = true;
store.syncThread.mockResolvedValue(undefined);
const { getByTestId } = renderView();
await fireEvent.click(getByTestId('template-example-card'));
await flushPromises();
await fireEvent.click(getByTestId('instance-ai-input-stub-submit'));
await flushPromises();
expect(thread.sendMessage).toHaveBeenCalledWith(
'Build me an invoice automation\n\nAsk me questions to narrow down my use case and the tools I use to best personalize the example for my needs.',
{
authorship: {
kind: 'prefill',
prefillType: 'template_example',
promptModified: false,
},
attachments: undefined,
pushRef: 'test-push-ref',
},
);
});
});