mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 05:29:44 +02:00
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:
parent
5b4fc8c27d
commit
8a93e66d2c
|
|
@ -18,8 +18,7 @@
|
|||
"**/cl100k_base.json",
|
||||
"**/o200k_base.json",
|
||||
"**/*.generated.ts",
|
||||
"**/expectations/**",
|
||||
"**/instance-ai-examples.data.json"
|
||||
"**/expectations/**"
|
||||
]
|
||||
},
|
||||
"formatter": {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}) {}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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';
|
||||
|
|
@ -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.",
|
||||
},
|
||||
];
|
||||
|
|
@ -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';
|
||||
File diff suppressed because one or more lines are too long
|
|
@ -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,
|
||||
};
|
||||
});
|
||||
|
|
@ -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+',
|
||||
};
|
||||
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user