mirror of
https://github.com/n8n-io/n8n.git
synced 2026-06-04 10:39:23 +02:00
feat: Implements AI Assistant empty state workflow previews experiment (#31519)
This commit is contained in:
parent
5504361d0f
commit
ef3a5606e0
|
|
@ -1239,6 +1239,17 @@
|
|||
"experiments.instanceAiPromptSuggestionsV2.suggestions.onboardNewHires.prompt": "When a new employee is added to our HR database, send them a welcome email with their first-week schedule, create their Notion onboarding page, and post an intro message in the #welcome Slack channel.",
|
||||
"experiments.instanceAiPromptSuggestionsV2.suggestions.extractDataFromEmails.label": "Extract data from emails",
|
||||
"experiments.instanceAiPromptSuggestionsV2.suggestions.extractDataFromEmails.prompt": "When an email arrives in Gmail with a PDF attachment, use Gemini to extract key data such as amounts, dates, or contract terms, save the structured data to a Google Sheet, and move the PDF to the right Google Drive folder.",
|
||||
"experiments.instanceAiWorkflowPreviewSuggestions.emptyState.title": "What do you want to automate?",
|
||||
"experiments.instanceAiWorkflowPreviewSuggestions.input.placeholder": "Tell me what to build or ask me a question",
|
||||
"experiments.instanceAiWorkflowPreviewSuggestions.suggestions.scoreMyLeads.label": "Score my leads",
|
||||
"experiments.instanceAiWorkflowPreviewSuggestions.suggestions.scoreMyLeads.prompt": "When a new lead is created in my CRM, enrich it with Lemlist, score it based on fit, then update the lead if qualified and notify the sales team on Slack.",
|
||||
"experiments.instanceAiWorkflowPreviewSuggestions.suggestions.processInvoices.label": "Process invoices",
|
||||
"experiments.instanceAiWorkflowPreviewSuggestions.suggestions.processInvoices.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.",
|
||||
"experiments.instanceAiWorkflowPreviewSuggestions.suggestions.whatsappSupport.label": "WhatsApp support agent",
|
||||
"experiments.instanceAiWorkflowPreviewSuggestions.suggestions.whatsappSupport.prompt": "When a customer sends a WhatsApp message, use Gemini 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.",
|
||||
"experiments.instanceAiWorkflowPreviewSuggestions.suggestions.scheduleSocialPosts.label": "Schedule social posts",
|
||||
"experiments.instanceAiWorkflowPreviewSuggestions.suggestions.scheduleSocialPosts.prompt": "Every Monday, read this week's content ideas from a Google Sheet, use Gemini to write tailored content then schedule them to post throughout the week.",
|
||||
"experiments.instanceAiWorkflowPreviewSuggestions.suggestions.seeAll": "See all",
|
||||
"experiments.personalizedTemplatesV3.browseAllTemplates": "Browse our template library",
|
||||
"experiments.personalizedTemplatesV3.couldntFind": "Need something different?",
|
||||
"experiments.personalizedTemplatesV3.exploreTemplates": "Get started with HubSpot workflows:",
|
||||
|
|
|
|||
|
|
@ -118,6 +118,10 @@ export const SURFACE_MCP_TO_NEW_CLOUD_USERS_EXPERIMENT = createExperiment(
|
|||
|
||||
export const CANVAS_NODES_GROUPING_EXPERIMENT = createExperiment('083_canvas_nodes_grouping');
|
||||
|
||||
export const INSTANCE_AI_WORKFLOW_PREVIEW_SUGGESTIONS_EXPERIMENT = createExperiment(
|
||||
'087_instance_ai_workflow_preview_suggestions',
|
||||
);
|
||||
|
||||
export const EXPERIMENTS_TO_TRACK = [
|
||||
EXTRA_TEMPLATE_LINKS_EXPERIMENT.name,
|
||||
TEMPLATE_ONBOARDING_EXPERIMENT.name,
|
||||
|
|
@ -148,4 +152,5 @@ export const EXPERIMENTS_TO_TRACK = [
|
|||
FLOATING_CHAT_HUB_PANEL_EXPERIMENT.name,
|
||||
SURFACE_MCP_TO_NEW_CLOUD_USERS_EXPERIMENT.name,
|
||||
CANVAS_NODES_GROUPING_EXPERIMENT.name,
|
||||
INSTANCE_AI_WORKFLOW_PREVIEW_SUGGESTIONS_EXPERIMENT.name,
|
||||
];
|
||||
|
|
|
|||
|
|
@ -0,0 +1,517 @@
|
|||
<script lang="ts" setup>
|
||||
import { computed, reactive, ref, onMounted, onUnmounted, watch, type Component } from 'vue';
|
||||
import { useElementSize } from '@vueuse/core';
|
||||
import type {
|
||||
PreviewWorkflow,
|
||||
PreviewWorkflowConnection,
|
||||
PreviewVisualizationType,
|
||||
PreviewOutputVisualization,
|
||||
} from '../workflows/types';
|
||||
import WorkflowPreviewNode from './WorkflowPreviewNode.vue';
|
||||
import SlackMessageVisualization from './visualizations/SlackMessageVisualization.vue';
|
||||
import SalesforceCardVisualization from './visualizations/SalesforceCardVisualization.vue';
|
||||
import InvoiceSpreadsheetVisualization from './visualizations/InvoiceSpreadsheetVisualization.vue';
|
||||
import WhatsAppChatVisualization from './visualizations/WhatsAppChatVisualization.vue';
|
||||
|
||||
const NODE_HALF_WIDTH = 48;
|
||||
const EDGE_CURVE_OFFSET = 60;
|
||||
const PADDING = 80;
|
||||
const CANVAS_HEIGHT = 420;
|
||||
const NODE_LABEL_OFFSET = 13;
|
||||
|
||||
const NODE_RUNNING_DURATION_MS = 250;
|
||||
|
||||
export type NodeAnimationState = 'idle' | 'running' | 'success';
|
||||
export type AnimationPhase = 'idle' | 'input' | 'nodes' | 'output' | 'done';
|
||||
|
||||
const visualizationComponents: Record<PreviewVisualizationType, Component> = {
|
||||
'slack-message': SlackMessageVisualization,
|
||||
'salesforce-card': SalesforceCardVisualization,
|
||||
'invoice-spreadsheet': InvoiceSpreadsheetVisualization,
|
||||
'whatsapp-chat': WhatsAppChatVisualization,
|
||||
};
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
workflow: PreviewWorkflow;
|
||||
animating?: boolean;
|
||||
}>(),
|
||||
{ animating: true },
|
||||
);
|
||||
|
||||
const canvasRef = ref<HTMLElement | null>(null);
|
||||
const { width: canvasRenderedWidth } = useElementSize(canvasRef);
|
||||
|
||||
const animationPhase = ref<AnimationPhase>('idle');
|
||||
const nodeStates = reactive<Record<string, NodeAnimationState>>({});
|
||||
|
||||
const hasInputViz = computed(() => !!props.workflow.inputVisualization);
|
||||
const hasOutputViz = computed(() => !!props.workflow.outputVisualization);
|
||||
|
||||
const outputVizItems = computed((): PreviewOutputVisualization[] => {
|
||||
const viz = props.workflow.outputVisualization;
|
||||
if (!viz) return [];
|
||||
if (Array.isArray(viz)) return viz;
|
||||
const last = lastNode.value;
|
||||
if (!last) return [];
|
||||
return [{ type: viz.type, props: viz.props, targetNodeId: last.id }];
|
||||
});
|
||||
|
||||
const inputVizComponent = computed(() =>
|
||||
props.workflow.inputVisualization
|
||||
? visualizationComponents[props.workflow.inputVisualization.type]
|
||||
: undefined,
|
||||
);
|
||||
|
||||
const executionSteps = computed(() => {
|
||||
const { nodes, connections } = props.workflow;
|
||||
const incomingMap = new Map<string, Set<string>>();
|
||||
for (const node of nodes) {
|
||||
incomingMap.set(node.id, new Set());
|
||||
}
|
||||
for (const conn of connections) {
|
||||
incomingMap.get(conn.target)?.add(conn.source);
|
||||
}
|
||||
|
||||
const visited = new Set<string>();
|
||||
const steps: string[][] = [];
|
||||
|
||||
while (visited.size < nodes.length) {
|
||||
const ready = nodes
|
||||
.filter((n) => !visited.has(n.id))
|
||||
.filter((n) => {
|
||||
const deps = incomingMap.get(n.id);
|
||||
return !deps || [...deps].every((d) => visited.has(d));
|
||||
})
|
||||
.map((n) => n.id);
|
||||
|
||||
if (ready.length === 0) break;
|
||||
steps.push(ready);
|
||||
for (const id of ready) visited.add(id);
|
||||
}
|
||||
|
||||
return steps;
|
||||
});
|
||||
|
||||
const firstNode = computed(() => {
|
||||
const nodes = props.workflow.nodes;
|
||||
if (nodes.length === 0) return undefined;
|
||||
return nodes.reduce((min, n) => (n.position.x < min.position.x ? n : min), nodes[0]);
|
||||
});
|
||||
|
||||
const lastNode = computed(() => {
|
||||
const nodes = props.workflow.nodes;
|
||||
if (nodes.length === 0) return undefined;
|
||||
return nodes.reduce((max, n) => (n.position.x > max.position.x ? n : max), nodes[0]);
|
||||
});
|
||||
|
||||
const triggerNodeIds = computed(() => {
|
||||
const targets = new Set(props.workflow.connections.map((c) => c.target));
|
||||
return new Set(props.workflow.nodes.filter((n) => !targets.has(n.id)).map((n) => n.id));
|
||||
});
|
||||
|
||||
function resetStates() {
|
||||
for (const node of props.workflow.nodes) {
|
||||
nodeStates[node.id] = 'idle';
|
||||
}
|
||||
}
|
||||
|
||||
let animationTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let stopped = false;
|
||||
|
||||
async function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
animationTimer = setTimeout(resolve, ms);
|
||||
});
|
||||
}
|
||||
|
||||
let outputCompletedCount = 0;
|
||||
|
||||
async function runNodeAnimation() {
|
||||
for (const step of executionSteps.value) {
|
||||
if (stopped) return;
|
||||
for (const id of step) nodeStates[id] = 'running';
|
||||
await sleep(NODE_RUNNING_DURATION_MS);
|
||||
if (stopped) return;
|
||||
for (const id of step) nodeStates[id] = 'success';
|
||||
}
|
||||
if (stopped) return;
|
||||
if (hasOutputViz.value) {
|
||||
outputCompletedCount = 0;
|
||||
animationPhase.value = 'output';
|
||||
} else {
|
||||
animationPhase.value = 'done';
|
||||
}
|
||||
}
|
||||
|
||||
function startAnimation() {
|
||||
stopped = false;
|
||||
resetStates();
|
||||
if (hasInputViz.value) {
|
||||
animationPhase.value = 'input';
|
||||
} else {
|
||||
animationPhase.value = 'nodes';
|
||||
void runNodeAnimation();
|
||||
}
|
||||
}
|
||||
|
||||
function stopAnimation() {
|
||||
stopped = true;
|
||||
if (animationTimer) {
|
||||
clearTimeout(animationTimer);
|
||||
animationTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function handleInputComplete() {
|
||||
if (stopped) return;
|
||||
animationPhase.value = 'nodes';
|
||||
void runNodeAnimation();
|
||||
}
|
||||
|
||||
function handleOutputComplete() {
|
||||
if (stopped) return;
|
||||
outputCompletedCount++;
|
||||
if (outputCompletedCount >= outputVizItems.value.length) {
|
||||
animationPhase.value = 'done';
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.animating,
|
||||
(val) => {
|
||||
if (val) {
|
||||
startAnimation();
|
||||
} else {
|
||||
stopAnimation();
|
||||
resetStates();
|
||||
animationPhase.value = 'idle';
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
resetStates();
|
||||
if (props.animating) {
|
||||
startAnimation();
|
||||
}
|
||||
});
|
||||
|
||||
onUnmounted(stopAnimation);
|
||||
|
||||
// CRM icons cycling logic (score-my-leads example)
|
||||
const crmCycleIndex = ref(0);
|
||||
const crmCycleVisible = ref(false);
|
||||
let crmCycleStartTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let crmCycleInterval: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
const hasCrmCycle = computed(() => !!props.workflow.crmCycle);
|
||||
const crmVariants = computed(() => props.workflow.crmCycle?.variants ?? []);
|
||||
const crmCurrentVariant = computed(() => crmVariants.value[crmCycleIndex.value]);
|
||||
const crmCycleNodeIds = computed(() => new Set(props.workflow.crmCycle?.nodeIds ?? []));
|
||||
|
||||
const CRM_INITIAL_DELAY_MS = 500;
|
||||
const CRM_INTERVAL_MS = 1400;
|
||||
|
||||
function startCrmCycle() {
|
||||
if (!hasCrmCycle.value) return;
|
||||
crmCycleVisible.value = true;
|
||||
crmCycleIndex.value = 0;
|
||||
const interval = props.workflow.crmCycle?.intervalMs ?? CRM_INTERVAL_MS;
|
||||
|
||||
crmCycleStartTimer = setTimeout(() => {
|
||||
crmCycleIndex.value = (crmCycleIndex.value + 1) % crmVariants.value.length;
|
||||
crmCycleInterval = setInterval(() => {
|
||||
crmCycleIndex.value = (crmCycleIndex.value + 1) % crmVariants.value.length;
|
||||
}, interval);
|
||||
}, CRM_INITIAL_DELAY_MS);
|
||||
}
|
||||
|
||||
function stopCrmCycle() {
|
||||
crmCycleVisible.value = false;
|
||||
if (crmCycleStartTimer) {
|
||||
clearTimeout(crmCycleStartTimer);
|
||||
crmCycleStartTimer = null;
|
||||
}
|
||||
if (crmCycleInterval) {
|
||||
clearInterval(crmCycleInterval);
|
||||
crmCycleInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
watch(animationPhase, (phase) => {
|
||||
if (phase === 'done' && hasCrmCycle.value) {
|
||||
startCrmCycle();
|
||||
} else {
|
||||
stopCrmCycle();
|
||||
}
|
||||
});
|
||||
|
||||
onUnmounted(stopCrmCycle);
|
||||
|
||||
const inputVizIcon = computed(() => {
|
||||
if (crmCycleVisible.value && crmCurrentVariant.value) {
|
||||
return crmCurrentVariant.value.icon.src;
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const bounds = computed(() => {
|
||||
const nodes = props.workflow.nodes;
|
||||
if (nodes.length === 0) return { minX: 0, minY: 0, width: 400, height: 200 };
|
||||
|
||||
const xs = nodes.map((n) => n.position.x);
|
||||
const ys = nodes.map((n) => n.position.y);
|
||||
const minX = Math.min(...xs) - PADDING;
|
||||
const minY = Math.min(...ys) - PADDING;
|
||||
const maxX = Math.max(...xs) + PADDING;
|
||||
const maxY = Math.max(...ys) + PADDING;
|
||||
|
||||
return { minX, minY, width: maxX - minX, height: maxY - minY };
|
||||
});
|
||||
|
||||
const CANVAS_WIDTH = 1600;
|
||||
const VIZ_MARGIN = 160;
|
||||
const VIZ_GAP = 28;
|
||||
const CANVAS_INNER_PADDING = 60;
|
||||
|
||||
const scale = computed(() => {
|
||||
const { width, height } = bounds.value;
|
||||
let availableWidth = CANVAS_WIDTH - 2 * CANVAS_INNER_PADDING;
|
||||
if (hasInputViz.value) availableWidth -= VIZ_MARGIN;
|
||||
if (hasOutputViz.value) availableWidth -= VIZ_MARGIN;
|
||||
const scaleX = availableWidth / width;
|
||||
const scaleY = (CANVAS_HEIGHT - 2 * CANVAS_INNER_PADDING) / height;
|
||||
return Math.min(scaleX, scaleY, 1);
|
||||
});
|
||||
|
||||
const effectiveCanvasWidth = computed(() =>
|
||||
canvasRenderedWidth.value > 0 ? canvasRenderedWidth.value : CANVAS_WIDTH,
|
||||
);
|
||||
|
||||
const containerStyle = computed(() => {
|
||||
const { width, height } = bounds.value;
|
||||
const s = scale.value;
|
||||
const scaledWidth = width * s;
|
||||
const scaledHeight = height * s;
|
||||
|
||||
return {
|
||||
width: `${width}px`,
|
||||
height: `${height}px`,
|
||||
transform: `scale(${s})`,
|
||||
transformOrigin: 'top left',
|
||||
left: `${(effectiveCanvasWidth.value - scaledWidth) / 2}px`,
|
||||
top: `${(CANVAS_HEIGHT - scaledHeight) / 2}px`,
|
||||
};
|
||||
});
|
||||
|
||||
const viewBox = computed(
|
||||
() => `${bounds.value.minX} ${bounds.value.minY} ${bounds.value.width} ${bounds.value.height}`,
|
||||
);
|
||||
|
||||
const canvasMarginLeft = computed(() => {
|
||||
const s = scale.value;
|
||||
const scaledWidth = bounds.value.width * s;
|
||||
return (effectiveCanvasWidth.value - scaledWidth) / 2;
|
||||
});
|
||||
|
||||
const canvasMarginTop = computed(() => {
|
||||
const s = scale.value;
|
||||
const scaledHeight = bounds.value.height * s;
|
||||
return (CANVAS_HEIGHT - scaledHeight) / 2;
|
||||
});
|
||||
|
||||
function toScreenX(workflowX: number): number {
|
||||
return (workflowX - bounds.value.minX) * scale.value + canvasMarginLeft.value;
|
||||
}
|
||||
|
||||
function toScreenY(workflowY: number): number {
|
||||
return (workflowY - bounds.value.minY) * scale.value + canvasMarginTop.value;
|
||||
}
|
||||
|
||||
const inputSlotStyle = computed(() => {
|
||||
if (!firstNode.value) return {};
|
||||
const nodeScreenX = toScreenX(firstNode.value.position.x - NODE_HALF_WIDTH);
|
||||
const nodeScreenY = toScreenY(firstNode.value.position.y - NODE_LABEL_OFFSET);
|
||||
return {
|
||||
left: `${nodeScreenX - VIZ_GAP}px`,
|
||||
top: `${nodeScreenY}px`,
|
||||
transform: 'translateX(-100%) translateY(-50%)',
|
||||
};
|
||||
});
|
||||
|
||||
const OUTPUT_VIZ_HEIGHT = 80;
|
||||
const OUTPUT_VIZ_GAP = 16;
|
||||
|
||||
const outputSlotStyles = computed(() => {
|
||||
const items = outputVizItems.value;
|
||||
if (items.length === 0) return [];
|
||||
|
||||
const positions = items.map((item) => {
|
||||
const node = props.workflow.nodes.find((n) => n.id === item.targetNodeId);
|
||||
const targetNode = node ?? lastNode.value;
|
||||
if (!targetNode) return { x: 0, y: 0 };
|
||||
return {
|
||||
x: toScreenX(targetNode.position.x + NODE_HALF_WIDTH),
|
||||
y: toScreenY(targetNode.position.y - NODE_LABEL_OFFSET),
|
||||
};
|
||||
});
|
||||
|
||||
if (items.length > 1) {
|
||||
const sorted = positions.map((p, i) => ({ idx: i, y: p.y })).sort((a, b) => a.y - b.y);
|
||||
const minSpacing = OUTPUT_VIZ_HEIGHT + OUTPUT_VIZ_GAP;
|
||||
for (let i = 1; i < sorted.length; i++) {
|
||||
const prev = sorted[i - 1];
|
||||
const curr = sorted[i];
|
||||
if (curr.y - prev.y < minSpacing) {
|
||||
positions[curr.idx].y = positions[prev.idx].y + minSpacing;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const isMulti = items.length > 1;
|
||||
return positions.map((pos) => ({
|
||||
left: `${pos.x + VIZ_GAP}px`,
|
||||
top: `${pos.y}px`,
|
||||
...(isMulti ? {} : { transform: 'translateY(-50%)' }),
|
||||
}));
|
||||
});
|
||||
|
||||
function getEdgePath(connection: PreviewWorkflowConnection): string {
|
||||
const sourceNode = props.workflow.nodes.find((n) => n.id === connection.source);
|
||||
const targetNode = props.workflow.nodes.find((n) => n.id === connection.target);
|
||||
if (!sourceNode || !targetNode) return '';
|
||||
|
||||
const sx = sourceNode.position.x + NODE_HALF_WIDTH;
|
||||
const sy = sourceNode.position.y - NODE_LABEL_OFFSET;
|
||||
const tx = targetNode.position.x - NODE_HALF_WIDTH;
|
||||
const ty = targetNode.position.y - NODE_LABEL_OFFSET;
|
||||
|
||||
const dist = Math.abs(tx - sx);
|
||||
const cx = Math.min(EDGE_CURVE_OFFSET, dist * 0.4);
|
||||
return `M ${sx} ${sy} C ${sx + cx} ${sy}, ${tx - cx} ${ty}, ${tx} ${ty}`;
|
||||
}
|
||||
|
||||
function isEdgeSuccess(connection: PreviewWorkflowConnection): boolean {
|
||||
return nodeStates[connection.target] === 'success';
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="canvasRef" :class="$style.canvas">
|
||||
<div :class="$style.viewport" :style="containerStyle">
|
||||
<svg :class="$style.edges" :viewBox="viewBox">
|
||||
<path
|
||||
v-for="(conn, idx) in props.workflow.connections"
|
||||
:key="`edge-${idx}`"
|
||||
:d="getEdgePath(conn)"
|
||||
:class="[$style.edge, isEdgeSuccess(conn) && $style.edgeSuccess]"
|
||||
/>
|
||||
</svg>
|
||||
<div :class="$style.nodesLayer">
|
||||
<WorkflowPreviewNode
|
||||
v-for="node in props.workflow.nodes"
|
||||
:key="node.id"
|
||||
:node="node"
|
||||
:state="nodeStates[node.id] ?? 'idle'"
|
||||
:trigger="triggerNodeIds.has(node.id)"
|
||||
:offset-x="bounds.minX"
|
||||
:offset-y="bounds.minY"
|
||||
:icon-override="
|
||||
crmCycleNodeIds.has(node.id) && crmCycleVisible ? crmCurrentVariant?.icon : undefined
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="inputVizComponent" :class="$style.vizSlot" :style="inputSlotStyle">
|
||||
<component
|
||||
:is="inputVizComponent"
|
||||
:active="animationPhase !== 'idle'"
|
||||
v-bind="props.workflow.inputVisualization?.props"
|
||||
:icon-override="inputVizIcon"
|
||||
slide-from="left"
|
||||
@complete="handleInputComplete"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="(outputViz, idx) in outputVizItems"
|
||||
:key="`output-viz-${idx}`"
|
||||
:class="[$style.vizSlot, outputVizItems.length > 1 && $style.vizSlotCompact]"
|
||||
:style="outputSlotStyles[idx]"
|
||||
>
|
||||
<component
|
||||
:is="visualizationComponents[outputViz.type]"
|
||||
:active="animationPhase === 'output' || animationPhase === 'done'"
|
||||
v-bind="outputViz.props"
|
||||
:icon-override="
|
||||
outputViz.type === 'salesforce-card' && crmCycleVisible ? inputVizIcon : undefined
|
||||
"
|
||||
@complete="handleOutputComplete"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.canvas {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: 1600px;
|
||||
height: 420px;
|
||||
margin: 0 auto;
|
||||
overflow: hidden;
|
||||
background-color: var(--canvas--color--background);
|
||||
background-image: radial-gradient(
|
||||
oklch(from var(--canvas--dot--color) l c h / 0.5) 1px,
|
||||
transparent 1px
|
||||
);
|
||||
background-size: 16px 16px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius--xl);
|
||||
}
|
||||
|
||||
.viewport {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.edges {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.edge {
|
||||
fill: none;
|
||||
stroke: var(--color--foreground--shade-1);
|
||||
stroke-width: 2;
|
||||
stroke-linecap: round;
|
||||
transition: stroke 0.3s ease;
|
||||
}
|
||||
|
||||
.edgeSuccess {
|
||||
stroke: var(--color--success);
|
||||
}
|
||||
|
||||
.nodesLayer {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.vizSlot {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.vizSlotCompact {
|
||||
transform: translateY(-50%);
|
||||
transform-origin: left center;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,188 @@
|
|||
<script lang="ts" setup>
|
||||
import { computed } from 'vue';
|
||||
import { N8nIcon } from '@n8n/design-system';
|
||||
import type { IconName } from '@n8n/design-system';
|
||||
import type { PreviewWorkflowNode, PreviewWorkflowNodeIcon } from '../workflows/types';
|
||||
import type { NodeAnimationState } from './WorkflowPreviewCanvas.vue';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
node: PreviewWorkflowNode;
|
||||
offsetX: number;
|
||||
offsetY: number;
|
||||
state?: NodeAnimationState;
|
||||
trigger?: boolean;
|
||||
iconOverride?: PreviewWorkflowNodeIcon;
|
||||
}>(),
|
||||
{ state: 'idle', trigger: false },
|
||||
);
|
||||
|
||||
const style = computed(() => ({
|
||||
left: `${props.node.position.x - props.offsetX}px`,
|
||||
top: `${props.node.position.y - props.offsetY}px`,
|
||||
}));
|
||||
|
||||
const iconWrapperStyle = computed(() => {
|
||||
if (props.node.iconColor) {
|
||||
return { color: `var(--node--icon--color--${props.node.iconColor})` };
|
||||
}
|
||||
return {};
|
||||
});
|
||||
|
||||
const activeIcon = computed(() => props.iconOverride ?? props.node.icon);
|
||||
|
||||
const iconName = computed(() =>
|
||||
activeIcon.value.type === 'icon' ? (activeIcon.value.name as IconName) : undefined,
|
||||
);
|
||||
const iconSrc = computed(() =>
|
||||
activeIcon.value.type === 'file' ? activeIcon.value.src : undefined,
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="[
|
||||
$style.node,
|
||||
props.trigger && $style.trigger,
|
||||
props.state === 'running' && $style.running,
|
||||
props.state === 'success' && $style.success,
|
||||
]"
|
||||
:style="style"
|
||||
>
|
||||
<div :class="$style.iconWrapper" :style="iconWrapperStyle">
|
||||
<N8nIcon v-if="iconName" :icon="iconName" :size="48" />
|
||||
<Transition v-else-if="iconSrc" :name="$style.swipe" mode="out-in">
|
||||
<img :key="iconSrc" :src="iconSrc" :class="$style.iconImage" />
|
||||
</Transition>
|
||||
<span v-else :class="$style.iconFallback">{{ props.node.label.charAt(0) }}</span>
|
||||
</div>
|
||||
<span :class="$style.label">{{ props.node.label }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.node {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
.iconWrapper {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 96px;
|
||||
height: 96px;
|
||||
border-radius: var(--radius--lg);
|
||||
background: var(--node--color--background, var(--color--background--light-3));
|
||||
border: 1.5px solid
|
||||
light-dark(
|
||||
oklch(from var(--color--neutral-black) l c h / 0.1),
|
||||
oklch(from var(--color--neutral-white) l c h / 0.15)
|
||||
);
|
||||
color: var(--node--icon--color, var(--color--foreground--shade-1));
|
||||
transition: border-color 0.2s ease;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: -3px;
|
||||
border-radius: 10px;
|
||||
z-index: -1;
|
||||
opacity: 0;
|
||||
background: conic-gradient(
|
||||
from var(--node--gradient-angle),
|
||||
rgba(255, 109, 90, 1),
|
||||
rgba(255, 109, 90, 1) 20%,
|
||||
rgba(255, 109, 90, 0.2) 35%,
|
||||
rgba(255, 109, 90, 0.2) 65%,
|
||||
rgba(255, 109, 90, 1) 90%,
|
||||
rgba(255, 109, 90, 1)
|
||||
);
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
}
|
||||
|
||||
.trigger .iconWrapper {
|
||||
border-radius: 36px var(--radius--lg) var(--radius--lg) 36px;
|
||||
|
||||
&::after {
|
||||
border-radius: 36px 10px 10px 36px;
|
||||
}
|
||||
}
|
||||
|
||||
.running .iconWrapper {
|
||||
border-color: transparent;
|
||||
|
||||
&::after {
|
||||
opacity: 1;
|
||||
animation: border-rotate 1.5s linear infinite;
|
||||
}
|
||||
}
|
||||
|
||||
.success .iconWrapper {
|
||||
border-width: 2px;
|
||||
border-color: var(--color--success);
|
||||
}
|
||||
|
||||
.iconImage {
|
||||
max-width: 48px;
|
||||
max-height: 48px;
|
||||
width: auto;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.iconFallback {
|
||||
font-size: var(--font-size--2xl);
|
||||
font-weight: var(--font-weight--bold);
|
||||
}
|
||||
|
||||
.label {
|
||||
margin-top: var(--spacing--2xs);
|
||||
font-size: var(--font-size--md);
|
||||
font-weight: var(--font-weight--medium);
|
||||
line-height: var(--line-height--sm);
|
||||
color: var(--color--text--base);
|
||||
white-space: nowrap;
|
||||
max-width: 192px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.swipe:global(-enter-active),
|
||||
.swipe:global(-leave-active) {
|
||||
transition:
|
||||
transform 0.3s ease,
|
||||
opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.swipe:global(-enter-from) {
|
||||
transform: translateX(16px);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.swipe:global(-leave-to) {
|
||||
transform: translateX(-16px);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
@property --node--gradient-angle {
|
||||
syntax: '<angle>';
|
||||
initial-value: 0deg;
|
||||
inherits: false;
|
||||
}
|
||||
|
||||
@keyframes border-rotate {
|
||||
from {
|
||||
--node--gradient-angle: 0deg;
|
||||
}
|
||||
|
||||
to {
|
||||
--node--gradient-angle: 360deg;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,191 @@
|
|||
<script lang="ts" setup>
|
||||
import { N8nIcon } from '@n8n/design-system';
|
||||
import { useI18n, type BaseTextKey } from '@n8n/i18n';
|
||||
import { onUnmounted, ref } from 'vue';
|
||||
import { useTelemetry } from '@/app/composables/useTelemetry';
|
||||
import { type WorkflowPreviewSuggestion } from '../suggestions';
|
||||
|
||||
const PREVIEW_HOVER_DELAY_MS = 300;
|
||||
|
||||
const props = defineProps<{
|
||||
suggestions: readonly WorkflowPreviewSuggestion[];
|
||||
disabled: boolean;
|
||||
}>();
|
||||
|
||||
interface SubmitSuggestionPayload {
|
||||
promptKey: BaseTextKey;
|
||||
suggestionId: string;
|
||||
suggestionKind: 'prompt';
|
||||
position: number;
|
||||
}
|
||||
|
||||
const emit = defineEmits<{
|
||||
'preview-change': [promptKey: BaseTextKey | null];
|
||||
'submit-suggestion': [payload: SubmitSuggestionPayload];
|
||||
'workflow-preview': [workflowFile: string | null];
|
||||
}>();
|
||||
|
||||
const i18n = useI18n();
|
||||
const telemetry = useTelemetry();
|
||||
const activePreview = ref<string | null>(null);
|
||||
let hoverTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
const hoverStartTimes = new Map<string, number>();
|
||||
|
||||
function clearHoverTimer() {
|
||||
if (!hoverTimer) return;
|
||||
clearTimeout(hoverTimer);
|
||||
hoverTimer = null;
|
||||
}
|
||||
|
||||
function trackHoverEnd(suggestionId: string) {
|
||||
const startTime = hoverStartTimes.get(suggestionId);
|
||||
if (startTime === undefined) return;
|
||||
hoverStartTimes.delete(suggestionId);
|
||||
|
||||
telemetry.track('AI Assistant suggestion button hovered', {
|
||||
suggestion_id: suggestionId,
|
||||
seconds: Math.floor((Date.now() - startTime) / 1000),
|
||||
});
|
||||
}
|
||||
|
||||
function clearPreview() {
|
||||
clearHoverTimer();
|
||||
for (const id of hoverStartTimes.keys()) {
|
||||
trackHoverEnd(id);
|
||||
}
|
||||
activePreview.value = null;
|
||||
emit('preview-change', null);
|
||||
emit('workflow-preview', null);
|
||||
}
|
||||
|
||||
function handleSuggestionEnter(suggestion: WorkflowPreviewSuggestion) {
|
||||
if (props.disabled) return;
|
||||
|
||||
hoverStartTimes.set(suggestion.id, Date.now());
|
||||
clearHoverTimer();
|
||||
hoverTimer = setTimeout(() => {
|
||||
hoverTimer = null;
|
||||
activePreview.value = suggestion.id;
|
||||
emit('preview-change', suggestion.promptKey);
|
||||
emit('workflow-preview', suggestion.workflowFile);
|
||||
}, PREVIEW_HOVER_DELAY_MS);
|
||||
}
|
||||
|
||||
function handleSuggestionFocus(suggestion: WorkflowPreviewSuggestion) {
|
||||
if (props.disabled) return;
|
||||
clearHoverTimer();
|
||||
activePreview.value = suggestion.id;
|
||||
emit('preview-change', suggestion.promptKey);
|
||||
emit('workflow-preview', suggestion.workflowFile);
|
||||
}
|
||||
|
||||
function handleSuggestionClick(suggestion: WorkflowPreviewSuggestion) {
|
||||
if (props.disabled) return;
|
||||
|
||||
const position = props.suggestions.indexOf(suggestion) + 1;
|
||||
telemetry.track('AI Assistant suggestion button clicked', {
|
||||
suggestion_id: suggestion.id,
|
||||
});
|
||||
|
||||
clearPreview();
|
||||
emit('submit-suggestion', {
|
||||
promptKey: suggestion.promptKey,
|
||||
suggestionId: suggestion.id,
|
||||
suggestionKind: 'prompt',
|
||||
position,
|
||||
});
|
||||
}
|
||||
|
||||
onUnmounted(clearPreview);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="$style.suggestions" data-test-id="instance-ai-workflow-preview-suggestions">
|
||||
<div :class="$style.suggestionRow">
|
||||
<button
|
||||
v-for="(suggestion, index) in props.suggestions"
|
||||
:key="suggestion.id"
|
||||
type="button"
|
||||
:class="[
|
||||
$style.suggestionButton,
|
||||
activePreview === suggestion.id && $style.suggestionButtonActive,
|
||||
]"
|
||||
:style="{ animationDelay: `${index * 50}ms` }"
|
||||
:data-test-id="`instance-ai-suggestion-${suggestion.id}`"
|
||||
:disabled="props.disabled"
|
||||
@click="handleSuggestionClick(suggestion)"
|
||||
@mouseenter="handleSuggestionEnter(suggestion)"
|
||||
@mouseleave="clearPreview"
|
||||
@focus="handleSuggestionFocus(suggestion)"
|
||||
@blur="clearPreview"
|
||||
>
|
||||
<N8nIcon :icon="suggestion.icon" :size="12" :class="$style.suggestionIcon" />
|
||||
<span>{{ i18n.baseText(suggestion.labelKey) }}</span>
|
||||
</button>
|
||||
<a
|
||||
href="https://n8n.io/workflows/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
:class="$style.seeAllLink"
|
||||
>
|
||||
<span>{{
|
||||
i18n.baseText(
|
||||
'experiments.instanceAiWorkflowPreviewSuggestions.suggestions.seeAll' as BaseTextKey,
|
||||
)
|
||||
}}</span>
|
||||
<N8nIcon icon="arrow-right" :size="12" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style module lang="scss">
|
||||
@use '@/features/ai/shared/styles/prompt-suggestion-buttons' as promptSuggestions;
|
||||
|
||||
.suggestions {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.suggestionRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--spacing--2xs);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.suggestionButton {
|
||||
@include promptSuggestions.prompt-suggestion-button;
|
||||
|
||||
flex: 0 0 auto;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.suggestionButtonActive {
|
||||
@include promptSuggestions.prompt-suggestion-button-active;
|
||||
}
|
||||
|
||||
.suggestionIcon {
|
||||
@include promptSuggestions.prompt-suggestion-icon;
|
||||
|
||||
.suggestionButton:hover &,
|
||||
.suggestionButton:focus-visible & {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.seeAllLink {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
flex: 0 0 auto;
|
||||
white-space: nowrap;
|
||||
font-size: var(--font-size--2xs);
|
||||
color: var(--color--text--tint-1);
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,235 @@
|
|||
<script lang="ts" setup>
|
||||
import { ref, watch, onUnmounted } from 'vue';
|
||||
|
||||
const APPEAR_DELAY_MS = 200;
|
||||
const ROW_DELAY_MS = 450;
|
||||
const CHECK_DELAY_MS = ROW_DELAY_MS + 350;
|
||||
const COMPLETE_DELAY_MS = CHECK_DELAY_MS + 650;
|
||||
|
||||
const props = defineProps<{ active: boolean }>();
|
||||
const emit = defineEmits<{ complete: [] }>();
|
||||
|
||||
const visible = ref(false);
|
||||
const rowVisible = ref(false);
|
||||
const checkVisible = ref(false);
|
||||
|
||||
let timers: Array<ReturnType<typeof setTimeout>> = [];
|
||||
|
||||
function clearTimers() {
|
||||
for (const t of timers) clearTimeout(t);
|
||||
timers = [];
|
||||
}
|
||||
|
||||
function runAnimation() {
|
||||
visible.value = false;
|
||||
rowVisible.value = false;
|
||||
checkVisible.value = false;
|
||||
|
||||
const base = APPEAR_DELAY_MS;
|
||||
timers.push(
|
||||
setTimeout(() => {
|
||||
visible.value = true;
|
||||
}, base),
|
||||
);
|
||||
timers.push(
|
||||
setTimeout(() => {
|
||||
rowVisible.value = true;
|
||||
}, base + ROW_DELAY_MS),
|
||||
);
|
||||
timers.push(
|
||||
setTimeout(() => {
|
||||
checkVisible.value = true;
|
||||
}, base + CHECK_DELAY_MS),
|
||||
);
|
||||
timers.push(
|
||||
setTimeout(() => {
|
||||
emit('complete');
|
||||
}, base + COMPLETE_DELAY_MS),
|
||||
);
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.active,
|
||||
(val) => {
|
||||
if (val) {
|
||||
runAnimation();
|
||||
} else {
|
||||
clearTimers();
|
||||
visible.value = false;
|
||||
rowVisible.value = false;
|
||||
checkVisible.value = false;
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
onUnmounted(clearTimers);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="[$style.card, visible && $style.cardVisible]">
|
||||
<div :class="$style.table">
|
||||
<div :class="[$style.row, $style.rowHeader]">
|
||||
<span :class="[$style.cell, $style.th]">Invoice</span>
|
||||
<span :class="[$style.cell, $style.th]">Date</span>
|
||||
<span :class="[$style.cell, $style.th]">Discrepancy</span>
|
||||
</div>
|
||||
|
||||
<div :class="$style.row">
|
||||
<span :class="$style.cell">INV-2024-045</span>
|
||||
<span :class="$style.cell">May 14</span>
|
||||
<span :class="[$style.cell, $style.tdMuted]">—</span>
|
||||
</div>
|
||||
|
||||
<div :class="$style.row">
|
||||
<span :class="$style.cell">INV-2024-046</span>
|
||||
<span :class="$style.cell">May 21</span>
|
||||
<span :class="[$style.cell, $style.tdMuted]">—</span>
|
||||
</div>
|
||||
|
||||
<div :class="[$style.rowCollapse, rowVisible && $style.rowCollapseOpen]">
|
||||
<div :class="[$style.row, $style.rowLast]">
|
||||
<span :class="$style.cell">INV-2024-047</span>
|
||||
<span :class="$style.cell">May 29</span>
|
||||
<span :class="[$style.cell, $style.tdDiscrepancy]">
|
||||
<svg
|
||||
v-if="checkVisible"
|
||||
viewBox="0 0 18 18"
|
||||
width="13"
|
||||
height="13"
|
||||
:class="$style.checkIcon"
|
||||
>
|
||||
<path
|
||||
d="M2.5 9.5 L7 14 L15.5 4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2.2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
:class="$style.checkPath"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
$border: light-dark(
|
||||
oklch(from var(--color--neutral-black) l c h / 0.1),
|
||||
oklch(from var(--color--neutral-white) l c h / 0.12)
|
||||
);
|
||||
|
||||
.card {
|
||||
width: 280px;
|
||||
padding: 0;
|
||||
background: var(--color--background--light-3);
|
||||
border-radius: var(--radius--lg);
|
||||
border: 1px solid $border;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
opacity: 0;
|
||||
transform: translateX(8px);
|
||||
transition:
|
||||
opacity 0.3s ease,
|
||||
transform 0.3s ease;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.cardVisible {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.table {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: grid;
|
||||
grid-template-columns: 2fr 1.2fr 1.3fr;
|
||||
}
|
||||
|
||||
.rowHeader {
|
||||
background: light-dark(
|
||||
oklch(from var(--color--neutral-black) l c h / 0.04),
|
||||
oklch(from var(--color--neutral-white) l c h / 0.06)
|
||||
);
|
||||
}
|
||||
|
||||
.rowCollapse {
|
||||
display: grid;
|
||||
grid-template-rows: 0fr;
|
||||
transition: grid-template-rows 0.35s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.rowCollapseOpen {
|
||||
grid-template-rows: 1fr;
|
||||
}
|
||||
|
||||
.rowLast {
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.cell {
|
||||
font-size: var(--font-size--2xs);
|
||||
line-height: 13px;
|
||||
color: var(--color--text--base);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
padding: 6px 9px;
|
||||
border-right: 1px solid $border;
|
||||
border-bottom: 1px solid $border;
|
||||
|
||||
&:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
}
|
||||
|
||||
.row:last-of-type .cell {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.rowLast .cell {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.th {
|
||||
font-size: 10px;
|
||||
font-weight: var(--font-weight--bold);
|
||||
color: var(--color--text--tint-1);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.tdMuted {
|
||||
color: var(--color--text--tint-1);
|
||||
}
|
||||
|
||||
.tdDiscrepancy {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: var(--color--warning);
|
||||
}
|
||||
|
||||
.checkIcon {
|
||||
overflow: visible;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.checkPath {
|
||||
stroke-dasharray: 20;
|
||||
stroke-dashoffset: 20;
|
||||
animation: draw-check 0.45s cubic-bezier(0.4, 0, 0.2, 1) forwards;
|
||||
}
|
||||
|
||||
@keyframes draw-check {
|
||||
to {
|
||||
stroke-dashoffset: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,156 @@
|
|||
<script lang="ts" setup>
|
||||
import { SALESFORCE_ICON_SVG } from '../../workflows/score-my-leads';
|
||||
import { computed, ref, watch, onUnmounted } from 'vue';
|
||||
|
||||
const APPEAR_DELAY_MS = 200;
|
||||
const COMPLETE_DELAY_MS = 600;
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
active: boolean;
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
slideFrom?: 'left' | 'right';
|
||||
icon?: string;
|
||||
iconOverride?: string;
|
||||
}>(),
|
||||
{
|
||||
title: 'New lead',
|
||||
subtitle: 'John Doe',
|
||||
slideFrom: 'right',
|
||||
},
|
||||
);
|
||||
|
||||
const currentIcon = computed(() => props.iconOverride ?? props.icon ?? SALESFORCE_ICON_SVG);
|
||||
|
||||
const emit = defineEmits<{
|
||||
complete: [];
|
||||
}>();
|
||||
|
||||
const visible = ref(false);
|
||||
let timers: Array<ReturnType<typeof setTimeout>> = [];
|
||||
|
||||
function clearTimers() {
|
||||
for (const t of timers) clearTimeout(t);
|
||||
timers = [];
|
||||
}
|
||||
|
||||
function runAnimation() {
|
||||
visible.value = false;
|
||||
|
||||
timers.push(
|
||||
setTimeout(() => {
|
||||
visible.value = true;
|
||||
}, APPEAR_DELAY_MS),
|
||||
);
|
||||
|
||||
timers.push(
|
||||
setTimeout(() => {
|
||||
emit('complete');
|
||||
}, APPEAR_DELAY_MS + COMPLETE_DELAY_MS),
|
||||
);
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.active,
|
||||
(val) => {
|
||||
if (val) runAnimation();
|
||||
else clearTimers();
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
onUnmounted(clearTimers);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="[
|
||||
$style.card,
|
||||
visible && $style.cardVisible,
|
||||
props.slideFrom === 'left' && $style.slideLeft,
|
||||
]"
|
||||
>
|
||||
<Transition :name="$style.swipe" mode="out-in">
|
||||
<img :key="currentIcon" :class="$style.icon" :src="currentIcon" alt="" />
|
||||
</Transition>
|
||||
<div :class="$style.content">
|
||||
<span :class="$style.title">{{ props.title }}</span>
|
||||
<span :class="$style.subtitle">{{ props.subtitle }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.card {
|
||||
width: 280px;
|
||||
padding: var(--spacing--sm) var(--spacing--md);
|
||||
background: var(--color--background--light-3);
|
||||
border-radius: var(--radius--lg);
|
||||
border: 1px solid
|
||||
light-dark(
|
||||
oklch(from var(--color--neutral-black) l c h / 0.08),
|
||||
oklch(from var(--color--neutral-white) l c h / 0.12)
|
||||
);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing--sm);
|
||||
opacity: 0;
|
||||
transform: translateX(8px);
|
||||
transition:
|
||||
opacity 0.3s ease,
|
||||
transform 0.3s ease;
|
||||
}
|
||||
|
||||
.slideLeft {
|
||||
transform: translateX(-8px);
|
||||
}
|
||||
|
||||
.cardVisible {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.icon {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.swipe:global(-enter-active),
|
||||
.swipe:global(-leave-active) {
|
||||
transition:
|
||||
transform 0.3s ease,
|
||||
opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.swipe:global(-enter-from) {
|
||||
transform: translateX(16px);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.swipe:global(-leave-to) {
|
||||
transform: translateX(-16px);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing--2xs);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: var(--font-size--sm);
|
||||
font-weight: var(--font-weight--bold);
|
||||
color: var(--color--text--base);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: var(--font-size--2xs);
|
||||
color: var(--color--text--tint-1);
|
||||
line-height: 1.4;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,136 @@
|
|||
<script lang="ts" setup>
|
||||
import { ref, watch, onUnmounted } from 'vue';
|
||||
|
||||
const APPEAR_DELAY_MS = 200;
|
||||
const COMPLETE_DELAY_MS = 600;
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
active: boolean;
|
||||
sender?: string;
|
||||
message?: string;
|
||||
}>(),
|
||||
{
|
||||
sender: 'n8n Bot',
|
||||
message: 'Urgent ticket: Login page broken',
|
||||
},
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
complete: [];
|
||||
}>();
|
||||
|
||||
const visible = ref(false);
|
||||
let timers: Array<ReturnType<typeof setTimeout>> = [];
|
||||
|
||||
function clearTimers() {
|
||||
for (const t of timers) clearTimeout(t);
|
||||
timers = [];
|
||||
}
|
||||
|
||||
function runAnimation() {
|
||||
visible.value = false;
|
||||
|
||||
timers.push(
|
||||
setTimeout(() => {
|
||||
visible.value = true;
|
||||
}, APPEAR_DELAY_MS),
|
||||
);
|
||||
|
||||
timers.push(
|
||||
setTimeout(() => {
|
||||
emit('complete');
|
||||
}, APPEAR_DELAY_MS + COMPLETE_DELAY_MS),
|
||||
);
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.active,
|
||||
(val) => {
|
||||
if (val) runAnimation();
|
||||
else clearTimers();
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
onUnmounted(clearTimers);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="[$style.card, visible && $style.cardVisible]">
|
||||
<div :class="$style.header">
|
||||
<div :class="$style.avatar">
|
||||
<img
|
||||
:class="$style.avatarIcon"
|
||||
src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' fill='%23fff' fill-rule='evenodd' stroke='%23000' stroke-linecap='round' stroke-linejoin='round' viewBox='0 0 150.852 150.852'%3E%3Cuse xlink:href='%23a' x='.926' y='.926'/%3E%3Csymbol id='a' overflow='visible'%3E%3Cg stroke-width='1.852'%3E%3Cpath fill='%23e01e5a' stroke='%23e01e5a' d='M40.741 93.55c0-8.735 6.607-15.772 14.815-15.772s14.815 7.037 14.815 15.772v38.824c0 8.737-6.607 15.774-14.815 15.774s-14.815-7.037-14.815-15.772z'/%3E%3Cpath fill='%23ecb22d' stroke='%23ecb22d' d='M93.55 107.408c-8.735 0-15.772-6.607-15.772-14.815s7.037-14.815 15.772-14.815h38.826c8.735 0 15.772 6.607 15.772 14.815s-7.037 14.815-15.772 14.815z'/%3E%3Cpath fill='%232fb67c' stroke='%232fb67c' d='M77.778 15.772C77.778 7.037 84.385 0 92.593 0s14.815 7.037 14.815 15.772v38.826c0 8.735-6.607 15.772-14.815 15.772s-14.815-7.037-14.815-15.772z'/%3E%3Cpath fill='%2336c5f1' stroke='%2336c5f1' d='M15.772 70.371C7.037 70.371 0 63.763 0 55.556s7.037-14.815 15.772-14.815h38.826c8.735 0 15.772 6.607 15.772 14.815s-7.037 14.815-15.772 14.815z'/%3E%3Cg stroke-linejoin='miter'%3E%3Cpath fill='%23ecb22d' stroke='%23ecb22d' d='M77.778 133.333c0 8.208 6.607 14.815 14.815 14.815s14.815-6.607 14.815-14.815-6.607-14.815-14.815-14.815H77.778z'/%3E%3Cpath fill='%232fb67c' stroke='%232fb67c' d='M133.334 70.371h-14.815V55.556c0-8.207 6.607-14.815 14.815-14.815s14.815 6.607 14.815 14.815-6.607 14.815-14.815 14.815z'/%3E%3Cpath fill='%23e01e5a' stroke='%23e01e5a' d='M14.815 77.778H29.63v14.815c0 8.207-6.607 14.815-14.815 14.815S0 100.8 0 92.593s6.607-14.815 14.815-14.815z'/%3E%3Cpath fill='%2336c5f1' stroke='%2336c5f1' d='M70.371 14.815V29.63H55.556c-8.207 0-14.815-6.607-14.815-14.815S47.348 0 55.556 0s14.815 6.607 14.815 14.815z'/%3E%3C/g%3E%3C/g%3E%3C/symbol%3E%3C/svg%3E"
|
||||
alt=""
|
||||
/>
|
||||
</div>
|
||||
<span :class="$style.sender">{{ props.sender }}</span>
|
||||
</div>
|
||||
<p :class="$style.message">{{ props.message }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.card {
|
||||
width: 280px;
|
||||
padding: var(--spacing--sm) var(--spacing--md);
|
||||
background: var(--color--background--light-3);
|
||||
border-radius: var(--radius--lg);
|
||||
border: 1px solid
|
||||
light-dark(
|
||||
oklch(from var(--color--neutral-black) l c h / 0.08),
|
||||
oklch(from var(--color--neutral-white) l c h / 0.12)
|
||||
);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing--2xs);
|
||||
opacity: 0;
|
||||
transform: translateX(8px);
|
||||
transition:
|
||||
opacity 0.3s ease,
|
||||
transform 0.3s ease;
|
||||
}
|
||||
|
||||
.cardVisible {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing--2xs);
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 6px;
|
||||
background: var(--color--neutral-700);
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
.avatarIcon {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.sender {
|
||||
font-size: var(--font-size--sm);
|
||||
font-weight: var(--font-weight--bold);
|
||||
color: var(--color--text--base);
|
||||
}
|
||||
|
||||
.message {
|
||||
margin: 0;
|
||||
font-size: var(--font-size--sm);
|
||||
color: var(--color--text--tint-1);
|
||||
line-height: 1.5;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,230 @@
|
|||
<script lang="ts" setup>
|
||||
import { ref, watch, onUnmounted } from 'vue';
|
||||
|
||||
const APPEAR_DELAY_MS = 200;
|
||||
const BUBBLE_DELAY_MS = 350;
|
||||
const COMPLETE_DELAY_MS = BUBBLE_DELAY_MS + 700;
|
||||
|
||||
const WHATSAPP_ICON =
|
||||
'data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%20width%3D%2260%22%20height%3D%2260%22%20fill-rule%3D%22evenodd%22%20clip-rule%3D%22evenodd%22%20viewBox%3D%220%200%2048%2048%22%3E%3Cpath%20fill%3D%22%23fff%22%20d%3D%22m4.868%2043.303%202.694-9.835a18.94%2018.94%200%200%201-2.535-9.489C5.032%2013.514%2013.548%205%2024.014%205a18.87%2018.87%200%200%201%2013.43%205.566A18.87%2018.87%200%200%201%2043%2023.994c-.004%2010.465-8.522%2018.98-18.986%2018.98h-.008a19%2019%200%200%201-9.073-2.311z%22/%3E%3Cpath%20fill%3D%22%23fff%22%20d%3D%22M4.868%2043.803a.5.5%200%200%201-.482-.631l2.639-9.636a19.5%2019.5%200%200%201-2.497-9.556C4.532%2013.238%2013.273%204.5%2024.014%204.5a19.37%2019.37%200%200%201%2013.784%205.713A19.36%2019.36%200%200%201%2043.5%2023.994c-.004%2010.741-8.746%2019.48-19.486%2019.48a19.54%2019.54%200%200%201-9.144-2.277l-9.875%202.589a.5.5%200%200%201-.127.017%22/%3E%3Cpath%20fill%3D%22%23cfd8dc%22%20d%3D%22M24.014%205a18.87%2018.87%200%200%201%2013.43%205.566A18.87%2018.87%200%200%201%2043%2023.994c-.004%2010.465-8.522%2018.98-18.986%2018.98h-.008a19%2019%200%200%201-9.073-2.311l-10.065%202.64%202.694-9.835a18.94%2018.94%200%200%201-2.535-9.489C5.032%2013.514%2013.548%205%2024.014%205m0-1C12.998%204%204.032%2012.962%204.027%2023.979a20%2020%200%200%200%202.461%209.622L3.903%2043.04a.998.998%200%200%200%201.219%201.231l9.687-2.54a20%2020%200%200%200%209.197%202.244c11.024%200%2019.99-8.963%2019.995-19.98A19.86%2019.86%200%200%200%2038.153%209.86%2019.87%2019.87%200%200%200%2024.014%204%22/%3E%3Cpath%20fill%3D%22%2340c351%22%20d%3D%22M35.176%2012.832a15.67%2015.67%200%200%200-11.157-4.626c-8.704%200-15.783%207.076-15.787%2015.774a15.74%2015.74%200%200%200%202.413%208.396l.376.597-1.595%205.821%205.973-1.566.577.342a15.75%2015.75%200%200%200%208.032%202.199h.006c8.698%200%2015.777-7.077%2015.78-15.776a15.68%2015.68%200%200%200-4.618-11.161%22/%3E%3Cpath%20fill%3D%22%23fff%22%20d%3D%22M19.268%2016.045c-.355-.79-.729-.806-1.068-.82-.277-.012-.593-.011-.909-.011s-.83.119-1.265.594-1.661%201.622-1.661%203.956%201.7%204.59%201.937%204.906%203.282%205.259%208.104%207.161c4.007%201.58%204.823%201.266%205.693%201.187s2.807-1.147%203.202-2.255.395-2.057.277-2.255c-.119-.198-.435-.316-.909-.554s-2.807-1.385-3.242-1.543-.751-.237-1.068.238c-.316.474-1.225%201.543-1.502%201.859s-.554.357-1.028.119-2.002-.738-3.815-2.354c-1.41-1.257-2.362-2.81-2.639-3.285-.277-.474-.03-.731.208-.968.213-.213.474-.554.712-.831.237-.277.316-.475.474-.791s.079-.594-.04-.831c-.117-.238-1.039-2.584-1.461-3.522%22/%3E%3C/svg%3E';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
active: boolean;
|
||||
sender?: string;
|
||||
message?: string;
|
||||
isOutgoing?: boolean;
|
||||
slideFrom?: 'left' | 'right';
|
||||
}>(),
|
||||
{
|
||||
sender: 'Customer',
|
||||
message: 'How do I reset my password?',
|
||||
isOutgoing: false,
|
||||
slideFrom: 'right',
|
||||
},
|
||||
);
|
||||
|
||||
const emit = defineEmits<{ complete: [] }>();
|
||||
|
||||
const visible = ref(false);
|
||||
const bubbleVisible = ref(false);
|
||||
|
||||
let timers: Array<ReturnType<typeof setTimeout>> = [];
|
||||
|
||||
function clearTimers() {
|
||||
for (const t of timers) clearTimeout(t);
|
||||
timers = [];
|
||||
}
|
||||
|
||||
function runAnimation() {
|
||||
visible.value = false;
|
||||
bubbleVisible.value = false;
|
||||
|
||||
timers.push(
|
||||
setTimeout(() => {
|
||||
visible.value = true;
|
||||
}, APPEAR_DELAY_MS),
|
||||
);
|
||||
timers.push(
|
||||
setTimeout(() => {
|
||||
bubbleVisible.value = true;
|
||||
}, APPEAR_DELAY_MS + BUBBLE_DELAY_MS),
|
||||
);
|
||||
timers.push(
|
||||
setTimeout(() => {
|
||||
emit('complete');
|
||||
}, APPEAR_DELAY_MS + COMPLETE_DELAY_MS),
|
||||
);
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.active,
|
||||
(val) => {
|
||||
if (val) {
|
||||
runAnimation();
|
||||
} else {
|
||||
clearTimers();
|
||||
visible.value = false;
|
||||
bubbleVisible.value = false;
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
onUnmounted(clearTimers);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="[$style.card, visible && $style.cardVisible, slideFrom === 'left' && $style.slideLeft]"
|
||||
>
|
||||
<div :class="$style.header">
|
||||
<div :class="$style.avatar">
|
||||
<img :src="WHATSAPP_ICON" :class="$style.avatarIcon" alt="" />
|
||||
</div>
|
||||
<span :class="$style.sender">{{ sender }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="bubbleVisible" :class="[$style.bubbleRow, isOutgoing && $style.bubbleRowOutgoing]">
|
||||
<div :class="[$style.bubble, isOutgoing ? $style.bubbleOutgoing : $style.bubbleIncoming]">
|
||||
<p :class="$style.bubbleText">{{ message }}</p>
|
||||
<svg
|
||||
v-if="isOutgoing"
|
||||
:class="$style.readTicks"
|
||||
width="18"
|
||||
height="12"
|
||||
viewBox="0 0 18 12"
|
||||
fill="none"
|
||||
>
|
||||
<path
|
||||
d="M1 6 L4.5 9.5 L10 2"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.6"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M5 6 L8.5 9.5 L14 2"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.6"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.card {
|
||||
width: 280px;
|
||||
padding: var(--spacing--sm) var(--spacing--md);
|
||||
background: var(--color--background--light-3);
|
||||
border-radius: var(--radius--lg);
|
||||
border: 1px solid
|
||||
light-dark(
|
||||
oklch(from var(--color--neutral-black) l c h / 0.08),
|
||||
oklch(from var(--color--neutral-white) l c h / 0.12)
|
||||
);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing--2xs);
|
||||
opacity: 0;
|
||||
transform: translateX(8px);
|
||||
transition:
|
||||
opacity 0.3s ease,
|
||||
transform 0.3s ease;
|
||||
}
|
||||
|
||||
.slideLeft {
|
||||
transform: translateX(-8px);
|
||||
}
|
||||
|
||||
.cardVisible {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing--2xs);
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
background: #25d366;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.avatarIcon {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.sender {
|
||||
font-size: var(--font-size--sm);
|
||||
font-weight: var(--font-weight--bold);
|
||||
color: var(--color--text--base);
|
||||
}
|
||||
|
||||
.bubbleRow {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.bubbleRowOutgoing {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.bubble {
|
||||
max-width: 85%;
|
||||
border-radius: 8px;
|
||||
padding: 6px 10px;
|
||||
animation: bubble-pop 0.25s cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
|
||||
}
|
||||
|
||||
.bubbleIncoming {
|
||||
background: light-dark(#ebebeb, #3a3a3a);
|
||||
border-bottom-left-radius: 2px;
|
||||
}
|
||||
|
||||
.bubbleOutgoing {
|
||||
background: light-dark(#d9fdd3, #1a4a2a);
|
||||
border-bottom-right-radius: 2px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.bubbleText {
|
||||
margin: 0;
|
||||
font-size: var(--font-size--xs);
|
||||
color: var(--color--text--base);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.readTicks {
|
||||
color: #8696a0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@keyframes bubble-pop {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.85);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
export { useInstanceAiWorkflowPreviewSuggestionsExperiment } from './useInstanceAiWorkflowPreviewSuggestionsExperiment';
|
||||
export {
|
||||
INSTANCE_AI_WORKFLOW_PREVIEW_SUGGESTIONS,
|
||||
INSTANCE_AI_WORKFLOW_PREVIEW_SUGGESTIONS_VERSION,
|
||||
type WorkflowPreviewSuggestion,
|
||||
} from './suggestions';
|
||||
export { default as WorkflowPreviewSuggestions } from './components/WorkflowPreviewSuggestions.vue';
|
||||
export { default as WorkflowPreviewCanvas } from './components/WorkflowPreviewCanvas.vue';
|
||||
export { getPreviewWorkflow } from './workflows';
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
import type { IconName } from '@n8n/design-system';
|
||||
import type { BaseTextKey } from '@n8n/i18n';
|
||||
import type { InstanceAiEmptyStatePromptSuggestion } from '@/features/ai/instanceAi/emptyStateSuggestions';
|
||||
|
||||
export interface WorkflowPreviewSuggestion extends InstanceAiEmptyStatePromptSuggestion {
|
||||
workflowFile: string;
|
||||
}
|
||||
|
||||
export const INSTANCE_AI_WORKFLOW_PREVIEW_SUGGESTIONS_VERSION = 'v3-workflow-preview';
|
||||
|
||||
export const INSTANCE_AI_WORKFLOW_PREVIEW_SUGGESTIONS: readonly WorkflowPreviewSuggestion[] = [
|
||||
{
|
||||
type: 'prompt',
|
||||
id: 'score-my-leads',
|
||||
icon: 'badge-check' as IconName,
|
||||
labelKey:
|
||||
'experiments.instanceAiWorkflowPreviewSuggestions.suggestions.scoreMyLeads.label' as BaseTextKey,
|
||||
promptKey:
|
||||
'experiments.instanceAiWorkflowPreviewSuggestions.suggestions.scoreMyLeads.prompt' as BaseTextKey,
|
||||
workflowFile: 'score-my-leads',
|
||||
},
|
||||
{
|
||||
type: 'prompt',
|
||||
id: 'process-invoices',
|
||||
icon: 'file-text' as IconName,
|
||||
labelKey:
|
||||
'experiments.instanceAiWorkflowPreviewSuggestions.suggestions.processInvoices.label' as BaseTextKey,
|
||||
promptKey:
|
||||
'experiments.instanceAiWorkflowPreviewSuggestions.suggestions.processInvoices.prompt' as BaseTextKey,
|
||||
workflowFile: 'process-invoices',
|
||||
},
|
||||
{
|
||||
type: 'prompt',
|
||||
id: 'whatsapp-support',
|
||||
icon: 'message-circle' as IconName,
|
||||
labelKey:
|
||||
'experiments.instanceAiWorkflowPreviewSuggestions.suggestions.whatsappSupport.label' as BaseTextKey,
|
||||
promptKey:
|
||||
'experiments.instanceAiWorkflowPreviewSuggestions.suggestions.whatsappSupport.prompt' as BaseTextKey,
|
||||
workflowFile: 'whatsapp-support',
|
||||
},
|
||||
{
|
||||
type: 'prompt',
|
||||
id: 'schedule-social-posts',
|
||||
icon: 'calendar' as IconName,
|
||||
labelKey:
|
||||
'experiments.instanceAiWorkflowPreviewSuggestions.suggestions.scheduleSocialPosts.label' as BaseTextKey,
|
||||
promptKey:
|
||||
'experiments.instanceAiWorkflowPreviewSuggestions.suggestions.scheduleSocialPosts.prompt' as BaseTextKey,
|
||||
workflowFile: 'schedule-social-posts',
|
||||
},
|
||||
];
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
import { computed } from 'vue';
|
||||
|
||||
import { INSTANCE_AI_WORKFLOW_PREVIEW_SUGGESTIONS_EXPERIMENT } from '@/app/constants/experiments';
|
||||
import { usePostHog } from '@/app/stores/posthog.store';
|
||||
|
||||
export function useInstanceAiWorkflowPreviewSuggestionsExperiment() {
|
||||
const posthogStore = usePostHog();
|
||||
|
||||
const isFeatureEnabled = computed(
|
||||
() =>
|
||||
posthogStore.getVariant(INSTANCE_AI_WORKFLOW_PREVIEW_SUGGESTIONS_EXPERIMENT.name) ===
|
||||
INSTANCE_AI_WORKFLOW_PREVIEW_SUGGESTIONS_EXPERIMENT.variant,
|
||||
);
|
||||
|
||||
return { isFeatureEnabled };
|
||||
}
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
import type { PreviewWorkflow } from './types';
|
||||
import { scoreMyLeadsWorkflow } from './score-my-leads';
|
||||
import { processInvoicesWorkflow } from './process-invoices';
|
||||
import { whatsappSupportWorkflow } from './whatsapp-support';
|
||||
import { scheduleSocialPostsWorkflow } from './schedule-social-posts';
|
||||
|
||||
export type {
|
||||
PreviewWorkflow,
|
||||
PreviewWorkflowNode,
|
||||
PreviewWorkflowConnection,
|
||||
PreviewVisualization,
|
||||
PreviewVisualizationType,
|
||||
PreviewOutputVisualization,
|
||||
CrmCycleConfig,
|
||||
CrmCycleVariant,
|
||||
} from './types';
|
||||
|
||||
const workflowRegistry: Record<string, PreviewWorkflow> = {
|
||||
'score-my-leads': scoreMyLeadsWorkflow,
|
||||
'process-invoices': processInvoicesWorkflow,
|
||||
'whatsapp-support': whatsappSupportWorkflow,
|
||||
'schedule-social-posts': scheduleSocialPostsWorkflow,
|
||||
};
|
||||
|
||||
export function getPreviewWorkflow(workflowFile: string): PreviewWorkflow | undefined {
|
||||
return workflowRegistry[workflowFile];
|
||||
}
|
||||
|
|
@ -0,0 +1,79 @@
|
|||
import type { PreviewWorkflow } from './types';
|
||||
|
||||
const GMAIL_ICON_SVG =
|
||||
'data:image/svg+xml,%3Csvg%20xmlns=%22http://www.w3.org/2000/svg%22%20width=%22256%22%20height=%22193%22%20preserveAspectRatio=%22xMidYMid%22%3E%3Cpath%20fill=%22%234285F4%22%20d=%22M58.182%20192.05V93.14L27.507%2065.077%200%2049.504v125.091c0%209.658%207.825%2017.455%2017.455%2017.455z%22/%3E%3Cpath%20fill=%22%2334A853%22%20d=%22M197.818%20192.05h40.727c9.659%200%2017.455-7.826%2017.455-17.455V49.505l-31.156%2017.837-27.026%2025.798z%22/%3E%3Cpath%20fill=%22%23EA4335%22%20d=%22m58.182%2093.14-4.174-38.647%204.174-36.989L128%2069.868l69.818-52.364%204.67%2034.992-4.67%2040.644L128%20145.504z%22/%3E%3Cpath%20fill=%22%23FBBC04%22%20d=%22M197.818%2017.504V93.14L256%2049.504V26.231c0-21.585-24.64-33.89-41.89-20.945z%22/%3E%3Cpath%20fill=%22%23C5221F%22%20d=%22m0%2049.504%2026.759%2020.07L58.182%2093.14V17.504L41.89%205.286C24.61-7.66%200%204.646%200%2026.23z%22/%3E%3C/svg%3E';
|
||||
|
||||
const ANTHROPIC_ICON_SVG =
|
||||
'data:image/svg+xml,%3Csvg%20xmlns=%22http://www.w3.org/2000/svg%22%20width=%2246%22%20height=%2232%22%20fill=%22none%22%3E%3Cpath%20fill=%22%237D7D87%22%20d=%22M32.73%200h-6.945L38.45%2032h6.945zM12.665%200%200%2032h7.082l2.59-6.72h13.25l2.59%206.72h7.082L19.929%200zm-.702%2019.337%204.334-11.246%204.334%2011.246z%22/%3E%3C/svg%3E';
|
||||
|
||||
const GOOGLE_SHEETS_ICON_SVG =
|
||||
'data:image/svg+xml,%3Csvg%20xmlns=%22http://www.w3.org/2000/svg%22%20width=%2260%22%20height=%2260%22%3E%3Cg%20fill=%22none%22%20fill-rule=%22evenodd%22%20stroke-linecap=%22round%22%20stroke-linejoin=%22round%22%3E%3Cpath%20fill=%22%2328B446%22%20d=%22M35.69%201%2052%2017.225v39.087a3.67%203.67%200%200%201-1.084%202.61A3.7%203.7%200%200%201%2048.293%2060H12.707a3.7%203.7%200%200%201-2.623-1.078A3.67%203.67%200%200%201%209%2056.312V4.688a3.67%203.67%200%200%201%201.084-2.61A3.7%203.7%200%200%201%2012.707%201z%22/%3E%3Cpath%20fill=%22%236ACE7C%22%20d=%22M35.69%201%2052%2017.225H39.397c-2.054%200-3.707-1.829-3.707-3.872z%22/%3E%3Cpath%20fill=%22%23219B38%22%20d=%22M39.211%2017.225%2052%2022.48v-5.255z%22/%3E%3Cpath%20fill=%22%23FFF%22%20d=%22M20.12%2031.975c0-.817.662-1.475%201.483-1.475h17.794c.821%200%201.482.658%201.482%201.475v15.487c0%20.818-.661%201.475-1.482%201.475H21.603a1.476%201.476%200%200%201-1.482-1.474V31.974zm2.225%201.475h6.672v2.212h-6.672zm0%205.162h6.672v2.213h-6.672zm0%205.163h6.672v2.212h-6.672zm9.638-10.325h6.672v2.212h-6.672zm0%205.162h6.672v2.213h-6.672zm0%205.163h6.672v2.212h-6.672z%22/%3E%3Cpath%20fill=%22%2328B446%22%20d=%22M34.69%200%2051%2016.225v39.087a3.67%203.67%200%200%201-1.084%202.61A3.7%203.7%200%200%201%2047.293%2059H11.707a3.7%203.7%200%200%201-2.623-1.078A3.67%203.67%200%200%201%208%2055.312V3.688a3.67%203.67%200%200%201%201.084-2.61A3.7%203.7%200%200%201%2011.707%200z%22/%3E%3Cpath%20fill=%22%236ACE7C%22%20d=%22M34.69%200%2051%2016.225H38.397c-2.054%200-3.707-1.829-3.707-3.872z%22/%3E%3Cpath%20fill=%22%23219B38%22%20d=%22M38.211%2016.225%2051%2021.48v-5.255z%22/%3E%3Cpath%20fill=%22%23FFF%22%20d=%22M19.12%2030.975c0-.817.662-1.475%201.483-1.475h17.794c.821%200%201.482.658%201.482%201.475v15.487c0%20.818-.661%201.475-1.482%201.475H20.603a1.476%201.476%200%200%201-1.482-1.474V30.974zm2.225%201.475h6.672v2.212h-6.672zm0%205.162h6.672v2.213h-6.672zm0%205.163h6.672v2.212h-6.672zm9.638-10.325h6.672v2.212h-6.672zm0%205.162h6.672v2.213h-6.672zm0%205.163h6.672v2.212h-6.672z%22/%3E%3C/g%3E%3C/svg%3E';
|
||||
|
||||
const GOOGLE_CALENDAR_ICON_SVG =
|
||||
'data:image/svg+xml,%3Csvg%20xmlns=%22http://www.w3.org/2000/svg%22%20xmlns:xlink=%22http://www.w3.org/1999/xlink%22%20fill=%22%23fff%22%20fill-rule=%22evenodd%22%20stroke=%22%23000%22%20stroke-linecap=%22round%22%20stroke-linejoin=%22round%22%20viewBox=%220%200%2081%2082%22%3E%3Cuse%20xlink:href=%22%23a%22%20x=%22.5%22%20y=%22.5%22/%3E%3Csymbol%20id=%22a%22%20overflow=%22visible%22%3E%3Cg%20fill-rule=%22nonzero%22%20stroke=%22none%22%3E%3Cpath%20d=%22M61.052%2018.947H18.947v42.105h42.105z%22/%3E%3Cpath%20fill=%22%23ea4335%22%20d=%22M61.053%2080%2080%2061.053H61.053z%22/%3E%3Cpath%20fill=%22%23fbbc04%22%20d=%22M80%2018.947H61.053v42.105H80z%22/%3E%3Cpath%20fill=%22%2334a853%22%20d=%22M61.052%2061.053H18.947V80h42.105z%22/%3E%3Cpath%20fill=%22%23188038%22%20d=%22M0%2061.053v12.632A6.314%206.314%200%200%200%206.316%2080h12.632V61.053z%22/%3E%3Cpath%20fill=%22%231967d2%22%20d=%22M80%2018.947V6.316A6.314%206.314%200%200%200%2073.685%200H61.053v18.947z%22/%3E%3Cpath%20fill=%22%234285f4%22%20d=%22M61.053%200H6.316A6.314%206.314%200%200%200%200%206.316v54.737h18.947V18.947h42.105V0zM27.584%2051.611c-1.574-1.063-2.663-2.616-3.258-4.668l3.653-1.505q.498%201.894%201.737%202.937c1.239%201.043%201.821%201.037%202.989%201.037q1.792%200%203.079-1.089c1.287-1.089%201.29-1.653%201.29-2.774a3.44%203.44%200%200%200-1.358-2.811c-.905-.727-2.042-1.089-3.4-1.089h-2.111v-3.616H32.1q1.752%200%202.953-.947c1.201-.947%201.2-1.495%201.2-2.595q0-1.467-1.074-2.342c-1.074-.875-1.621-.879-2.721-.879q-1.61-.002-2.558.858c-.948.86-1.106%201.301-1.379%202.111l-3.616-1.505c.479-1.358%201.358-2.558%202.647-3.595s2.937-1.558%204.937-1.558q2.22-.002%203.989.858c1.769.86%202.105%201.368%202.774%202.379s1%202.153%201%203.416q0%201.932-.932%203.274c-.932%201.342-1.384%201.579-2.289%202.058v.216a6.95%206.95%200%200%201%202.937%202.289q1.146%201.538%201.147%203.684c.001%202.146-.363%202.711-1.089%203.832s-1.732%202.005-3.005%202.647c-1.279.642-2.716.968-4.311.968-1.847.005-3.553-.526-5.126-1.589zm22.437-18.126-4.01%202.9-2.005-3.042%207.195-5.189h2.758v24.479h-3.937V33.484z%22/%3E%3C/g%3E%3C/symbol%3E%3C/svg%3E';
|
||||
|
||||
export const processInvoicesWorkflow: PreviewWorkflow = {
|
||||
nodes: [
|
||||
{
|
||||
id: 'gmail-trigger',
|
||||
label: 'Invoice received',
|
||||
icon: { type: 'file', src: GMAIL_ICON_SVG },
|
||||
position: { x: 0, y: 120 },
|
||||
},
|
||||
{
|
||||
id: 'claude',
|
||||
label: 'Extract & cross-check',
|
||||
icon: { type: 'file', src: ANTHROPIC_ICON_SVG },
|
||||
position: { x: 240, y: 120 },
|
||||
},
|
||||
{
|
||||
id: 'if-discrepancy',
|
||||
label: 'Discrepancy?',
|
||||
icon: { type: 'icon', name: 'node:if' },
|
||||
iconColor: 'green',
|
||||
position: { x: 480, y: 120 },
|
||||
},
|
||||
{
|
||||
id: 'flag-invoice',
|
||||
label: 'Flag invoice',
|
||||
icon: { type: 'file', src: GOOGLE_SHEETS_ICON_SVG },
|
||||
position: { x: 720, y: 0 },
|
||||
},
|
||||
{
|
||||
id: 'add-calendar',
|
||||
label: 'Add to Calendar',
|
||||
icon: { type: 'file', src: GOOGLE_CALENDAR_ICON_SVG },
|
||||
position: { x: 720, y: 240 },
|
||||
},
|
||||
],
|
||||
connections: [
|
||||
{ source: 'gmail-trigger', target: 'claude' },
|
||||
{ source: 'claude', target: 'if-discrepancy' },
|
||||
{ source: 'if-discrepancy', target: 'flag-invoice' },
|
||||
{ source: 'if-discrepancy', target: 'add-calendar' },
|
||||
],
|
||||
inputVisualization: {
|
||||
type: 'salesforce-card',
|
||||
props: {
|
||||
icon: GMAIL_ICON_SVG,
|
||||
title: 'Invoice received',
|
||||
subtitle: 'Subject: INV-2024-047',
|
||||
},
|
||||
},
|
||||
outputVisualization: [
|
||||
{
|
||||
type: 'invoice-spreadsheet',
|
||||
targetNodeId: 'flag-invoice',
|
||||
props: {},
|
||||
},
|
||||
{
|
||||
type: 'salesforce-card',
|
||||
targetNodeId: 'add-calendar',
|
||||
props: {
|
||||
icon: GOOGLE_CALENDAR_ICON_SVG,
|
||||
title: 'Event created',
|
||||
subtitle: 'Payment due · Jun 15',
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -0,0 +1,54 @@
|
|||
export interface PreviewWorkflowNodeIcon {
|
||||
type: 'icon' | 'file';
|
||||
name?: string;
|
||||
src?: string;
|
||||
}
|
||||
|
||||
export interface PreviewWorkflowNode {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: PreviewWorkflowNodeIcon;
|
||||
iconColor?: string;
|
||||
position: { x: number; y: number };
|
||||
}
|
||||
|
||||
export interface PreviewWorkflowConnection {
|
||||
source: string;
|
||||
target: string;
|
||||
}
|
||||
|
||||
export type PreviewVisualizationType =
|
||||
| 'slack-message'
|
||||
| 'salesforce-card'
|
||||
| 'invoice-spreadsheet'
|
||||
| 'whatsapp-chat';
|
||||
|
||||
export interface PreviewVisualization {
|
||||
type: PreviewVisualizationType;
|
||||
props?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface PreviewOutputVisualization {
|
||||
type: PreviewVisualizationType;
|
||||
props?: Record<string, unknown>;
|
||||
targetNodeId: string;
|
||||
}
|
||||
|
||||
export interface CrmCycleVariant {
|
||||
icon: PreviewWorkflowNodeIcon;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface CrmCycleConfig {
|
||||
nodeIds: string[];
|
||||
variants: CrmCycleVariant[];
|
||||
intervalMs?: number;
|
||||
}
|
||||
|
||||
export interface PreviewWorkflow {
|
||||
nodes: PreviewWorkflowNode[];
|
||||
connections: PreviewWorkflowConnection[];
|
||||
inputVisualization?: PreviewVisualization;
|
||||
outputVisualization?: PreviewVisualization | PreviewOutputVisualization[];
|
||||
crmCycle?: CrmCycleConfig;
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
|
|
@ -23,6 +23,14 @@ import {
|
|||
INSTANCE_AI_PROMPT_SUGGESTIONS_V2_VERSION,
|
||||
useInstanceAiPromptSuggestionsV2Experiment,
|
||||
} from '@/experiments/instanceAiPromptSuggestionsV2';
|
||||
import {
|
||||
WorkflowPreviewSuggestions,
|
||||
WorkflowPreviewCanvas,
|
||||
INSTANCE_AI_WORKFLOW_PREVIEW_SUGGESTIONS,
|
||||
INSTANCE_AI_WORKFLOW_PREVIEW_SUGGESTIONS_VERSION,
|
||||
getPreviewWorkflow,
|
||||
useInstanceAiWorkflowPreviewSuggestionsExperiment,
|
||||
} from '@/experiments/instanceAiWorkflowPreviewSuggestions';
|
||||
import InstanceAiInput from './components/InstanceAiInput.vue';
|
||||
import InstanceAiEmptyState from './components/InstanceAiEmptyState.vue';
|
||||
import InstanceAiViewHeader from './components/InstanceAiViewHeader.vue';
|
||||
|
|
@ -34,6 +42,10 @@ const INSTANCE_AI_PROMPT_SUGGESTIONS_V2_TITLE_KEY: BaseTextKey =
|
|||
'experiments.instanceAiPromptSuggestionsV2.emptyState.title';
|
||||
const INSTANCE_AI_PROMPT_SUGGESTIONS_V2_PLACEHOLDER_KEY: BaseTextKey =
|
||||
'experiments.instanceAiPromptSuggestionsV2.input.placeholder';
|
||||
const INSTANCE_AI_WORKFLOW_PREVIEW_SUGGESTIONS_TITLE_KEY =
|
||||
'experiments.instanceAiWorkflowPreviewSuggestions.emptyState.title' as BaseTextKey;
|
||||
const INSTANCE_AI_WORKFLOW_PREVIEW_SUGGESTIONS_PLACEHOLDER_KEY =
|
||||
'experiments.instanceAiWorkflowPreviewSuggestions.input.placeholder' as BaseTextKey;
|
||||
|
||||
const store = useInstanceAiStore();
|
||||
const { isLowCredits } = storeToRefs(store);
|
||||
|
|
@ -46,7 +58,15 @@ const { isFeatureEnabled: isProactiveAgentExperimentEnabled } =
|
|||
useInstanceAiProactiveAgentExperiment();
|
||||
const { isFeatureEnabled: isPromptSuggestionsV2ExperimentEnabled } =
|
||||
useInstanceAiPromptSuggestionsV2Experiment();
|
||||
const { isFeatureEnabled: isWorkflowPreviewSuggestionsExperimentEnabled } =
|
||||
useInstanceAiWorkflowPreviewSuggestionsExperiment();
|
||||
const showProactiveStarter = computed(() => isProactiveAgentExperimentEnabled.value);
|
||||
const activeWorkflowPreviewFile = ref<string | null>(null);
|
||||
const activeWorkflowPreview = computed(() => {
|
||||
if (!activeWorkflowPreviewFile.value) return null;
|
||||
return getPreviewWorkflow(activeWorkflowPreviewFile.value) ?? null;
|
||||
});
|
||||
|
||||
// Experiment cleanup: remove with instanceAiPromptSuggestionsV2.
|
||||
const emptyStatePromptSuggestionProps = computed(() => {
|
||||
if (showProactiveStarter.value) {
|
||||
|
|
@ -62,20 +82,36 @@ const emptyStatePromptSuggestionProps = computed(() => {
|
|||
};
|
||||
}
|
||||
|
||||
if (isWorkflowPreviewSuggestionsExperimentEnabled.value) {
|
||||
return {
|
||||
suggestions: INSTANCE_AI_WORKFLOW_PREVIEW_SUGGESTIONS,
|
||||
suggestionsComponent: WorkflowPreviewSuggestions,
|
||||
suggestionCatalogVersion: INSTANCE_AI_WORKFLOW_PREVIEW_SUGGESTIONS_VERSION,
|
||||
placeholderKey: INSTANCE_AI_WORKFLOW_PREVIEW_SUGGESTIONS_PLACEHOLDER_KEY,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
suggestions: INSTANCE_AI_EMPTY_STATE_SUGGESTIONS,
|
||||
};
|
||||
});
|
||||
const emptyStateTitleKey = computed<BaseTextKey>(() =>
|
||||
isPromptSuggestionsV2ExperimentEnabled.value
|
||||
? INSTANCE_AI_PROMPT_SUGGESTIONS_V2_TITLE_KEY
|
||||
: INSTANCE_AI_DEFAULT_TITLE_KEY,
|
||||
);
|
||||
const emptyStateTitleKey = computed<BaseTextKey>(() => {
|
||||
if (isPromptSuggestionsV2ExperimentEnabled.value) {
|
||||
return INSTANCE_AI_PROMPT_SUGGESTIONS_V2_TITLE_KEY;
|
||||
}
|
||||
if (isWorkflowPreviewSuggestionsExperimentEnabled.value) {
|
||||
return INSTANCE_AI_WORKFLOW_PREVIEW_SUGGESTIONS_TITLE_KEY;
|
||||
}
|
||||
return INSTANCE_AI_DEFAULT_TITLE_KEY;
|
||||
});
|
||||
|
||||
const chatInputRef = ref<InstanceType<typeof InstanceAiInput> | null>(null);
|
||||
const isStartingThread = ref(false);
|
||||
|
||||
useChatInputAutoFocus(chatInputRef, { disabled: isStartingThread });
|
||||
function handleWorkflowPreview(workflowFile: string | null) {
|
||||
activeWorkflowPreviewFile.value = workflowFile;
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void nextTick(() => chatInputRef.value?.focus());
|
||||
|
|
@ -144,8 +180,16 @@ async function handleSubmit(message: string, attachments?: InstanceAiAttachment[
|
|||
:is-submitting="isStartingThread"
|
||||
v-bind="emptyStatePromptSuggestionProps"
|
||||
@submit="handleSubmit"
|
||||
@workflow-preview="handleWorkflowPreview"
|
||||
/>
|
||||
</div>
|
||||
<Transition name="workflow-preview-fade">
|
||||
<WorkflowPreviewCanvas
|
||||
v-if="isWorkflowPreviewSuggestionsExperimentEnabled && activeWorkflowPreview"
|
||||
:workflow="activeWorkflowPreview"
|
||||
:class="$style.workflowPreview"
|
||||
/>
|
||||
</Transition>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -184,6 +228,11 @@ async function handleSubmit(message: string, attachments?: InstanceAiAttachment[
|
|||
max-width: 680px;
|
||||
}
|
||||
|
||||
.workflowPreview {
|
||||
width: 100%;
|
||||
max-width: 1600px;
|
||||
}
|
||||
|
||||
.proactiveLayout {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
|
|
@ -208,4 +257,26 @@ async function handleSubmit(message: string, attachments?: InstanceAiAttachment[
|
|||
margin: 0 auto;
|
||||
padding: 0 var(--spacing--lg) var(--spacing--sm);
|
||||
}
|
||||
|
||||
:global(.workflow-preview-fade-enter-active) {
|
||||
transition:
|
||||
opacity 0.25s ease,
|
||||
transform 0.25s ease;
|
||||
}
|
||||
|
||||
:global(.workflow-preview-fade-leave-active) {
|
||||
transition:
|
||||
opacity 0.18s ease,
|
||||
transform 0.18s ease;
|
||||
}
|
||||
|
||||
:global(.workflow-preview-fade-enter-from) {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
|
||||
:global(.workflow-preview-fade-leave-to) {
|
||||
opacity: 0;
|
||||
transform: translateY(4px);
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -15,12 +15,15 @@ const {
|
|||
experimentMocks,
|
||||
promptSuggestionsV2,
|
||||
promptSuggestionsV2Component,
|
||||
workflowPreviewSuggestions,
|
||||
workflowPreviewSuggestionsComponent,
|
||||
replaceMock,
|
||||
showErrorMock,
|
||||
} = vi.hoisted(() => ({
|
||||
experimentMocks: {
|
||||
proactiveAgentEnabled: { value: false },
|
||||
promptSuggestionsV2Enabled: { value: false },
|
||||
workflowPreviewEnabled: { value: false },
|
||||
},
|
||||
promptSuggestionsV2: Array.from({ length: 12 }, (_, index) => ({
|
||||
type: 'prompt',
|
||||
|
|
@ -30,6 +33,14 @@ const {
|
|||
promptKey: 'instanceAi.emptyState.suggestions.buildAgent.prompt',
|
||||
})),
|
||||
promptSuggestionsV2Component: { name: 'InstanceAiPromptSuggestionsV2Stub' },
|
||||
workflowPreviewSuggestions: Array.from({ length: 4 }, (_, index) => ({
|
||||
type: 'prompt',
|
||||
id: `wp-suggestion-${index + 1}`,
|
||||
icon: 'workflow',
|
||||
labelKey: 'instanceAi.emptyState.suggestions.buildWorkflow.label',
|
||||
promptKey: 'instanceAi.emptyState.suggestions.buildWorkflow.prompt',
|
||||
})),
|
||||
workflowPreviewSuggestionsComponent: { name: 'WorkflowPreviewSuggestionsStub' },
|
||||
replaceMock: vi.fn(),
|
||||
showErrorMock: vi.fn(),
|
||||
}));
|
||||
|
|
@ -53,6 +64,17 @@ vi.mock('@/experiments/instanceAiPromptSuggestionsV2', () => ({
|
|||
InstanceAiPromptSuggestionsV2: promptSuggestionsV2Component,
|
||||
}));
|
||||
|
||||
vi.mock('@/experiments/instanceAiWorkflowPreviewSuggestions', () => ({
|
||||
useInstanceAiWorkflowPreviewSuggestionsExperiment: () => ({
|
||||
isFeatureEnabled: experimentMocks.workflowPreviewEnabled,
|
||||
}),
|
||||
INSTANCE_AI_WORKFLOW_PREVIEW_SUGGESTIONS: workflowPreviewSuggestions,
|
||||
INSTANCE_AI_WORKFLOW_PREVIEW_SUGGESTIONS_VERSION: 'v3-workflow-preview',
|
||||
WorkflowPreviewSuggestions: workflowPreviewSuggestionsComponent,
|
||||
WorkflowPreviewCanvas: { name: 'WorkflowPreviewCanvasStub', template: '<div />' },
|
||||
getPreviewWorkflow: () => null,
|
||||
}));
|
||||
|
||||
vi.mock('@/app/composables/usePageRedirectionHelper', () => ({
|
||||
usePageRedirectionHelper: () => ({ goToUpgrade: vi.fn() }),
|
||||
}));
|
||||
|
|
@ -160,6 +182,7 @@ describe('InstanceAiEmptyView', () => {
|
|||
store.getOrCreateRuntime.mockReturnValue(thread);
|
||||
experimentMocks.proactiveAgentEnabled.value = false;
|
||||
experimentMocks.promptSuggestionsV2Enabled.value = false;
|
||||
experimentMocks.workflowPreviewEnabled.value = false;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
|
@ -192,6 +215,22 @@ describe('InstanceAiEmptyView', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('passes workflow preview suggestions, component, and catalog version when workflow preview experiment is enabled', () => {
|
||||
experimentMocks.workflowPreviewEnabled.value = true;
|
||||
|
||||
const { getByTestId, getByText } = renderView();
|
||||
|
||||
expect(getByText('What do you want to automate?')).toBeVisible();
|
||||
expect(getByTestId('instance-ai-input-suggestions')).toHaveTextContent('4');
|
||||
expect(getByTestId('instance-ai-input-suggestions-component')).toHaveTextContent('set');
|
||||
expect(getByTestId('instance-ai-input-suggestion-catalog-version')).toHaveTextContent(
|
||||
'v3-workflow-preview',
|
||||
);
|
||||
expect(getByTestId('instance-ai-input-placeholder-key')).toHaveTextContent(
|
||||
'experiments.instanceAiWorkflowPreviewSuggestions.input.placeholder',
|
||||
);
|
||||
});
|
||||
|
||||
it('renders the proactive starter and moves suggestions out of the composer when enabled', () => {
|
||||
experimentMocks.proactiveAgentEnabled.value = true;
|
||||
experimentMocks.promptSuggestionsV2Enabled.value = true;
|
||||
|
|
|
|||
|
|
@ -61,6 +61,7 @@ const emit = defineEmits<{
|
|||
submit: [message: string, attachments?: InstanceAiAttachment[]];
|
||||
stop: [];
|
||||
'cancel-plan-edit': [];
|
||||
'workflow-preview': [workflowFile: string | null];
|
||||
}>();
|
||||
|
||||
const i18n = useI18n();
|
||||
|
|
@ -144,6 +145,7 @@ watch(
|
|||
}
|
||||
|
||||
previewPromptKey.value = null;
|
||||
emit('workflow-preview', null);
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
|
@ -381,6 +383,7 @@ const resizable = computed(() => {
|
|||
@cycle-suggestions="handleSuggestionsCycled"
|
||||
@insert-suggestion="handleSuggestionInsert"
|
||||
@submit-suggestion="handleSuggestionSubmit"
|
||||
@workflow-preview="emit('workflow-preview', $event)"
|
||||
/>
|
||||
</Transition>
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user