first commit
Some checks failed
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled

This commit is contained in:
2026-03-17 16:22:57 +03:30
commit 3d5eaf9445
15349 changed files with 2847338 additions and 0 deletions

28
packages/frontend/@n8n/chat/.gitignore vendored Normal file
View File

@@ -0,0 +1,28 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
.DS_Store
dist
dist-ssr
coverage
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# Auto-generated files
src/components.d.ts

View File

@@ -0,0 +1,5 @@
{
"yarn": false,
"tests": false,
"contents": "./dist"
}

View File

@@ -0,0 +1,3 @@
{
"recommendations": ["Vue.volar", "Vue.vscode-typescript-vue-plugin"]
}

View File

@@ -0,0 +1,361 @@
# n8n Chat
This is an embeddable Chat widget for n8n. It allows the execution of AI-Powered Workflows through a Chat window.
**Windowed Example**
![n8n Chat Windowed](https://raw.githubusercontent.com/n8n-io/n8n/master/packages/frontend/%40n8n/chat/resources/images/windowed.png)
**Fullscreen Example**
![n8n Chat Fullscreen](https://raw.githubusercontent.com/n8n-io/n8n/master/packages/frontend/%40n8n/chat/resources/images/fullscreen.png)
## Prerequisites
Create a n8n workflow which you want to execute via chat. The workflow has to be triggered using a **Chat Trigger** node.
Open the **Chat Trigger** node and add your domain to the **Allowed Origins (CORS)** field. This makes sure that only requests from your domain are accepted.
[See example workflow](https://github.com/n8n-io/n8n/blob/master/packages/%40n8n/chat/resources/workflow.json)
To use streaming responses, you need to enable the **Streaming response** response mode in the **Chat Trigger** node.
[See example workflow with streaming](https://github.com/n8n-io/n8n/blob/master/packages/%40n8n/chat/resources/workflow-streaming.json)
> Make sure the workflow is **Active.**
### How it works
Each Chat request is sent to the n8n Webhook endpoint, which then sends back a response.
Each request is accompanied by an `action` query parameter, where `action` can be one of:
- `loadPreviousSession` - When the user opens the Chatbot again and the previous chat session should be loaded
- `sendMessage` - When the user sends a message
## Installation
Open the **Webhook** node and replace `YOUR_PRODUCTION_WEBHOOK_URL` with your production URL. This is the URL that the Chat widget will use to send requests to.
### a. CDN Embed
Add the following code to your HTML page.
```html
<link href="https://cdn.jsdelivr.net/npm/@n8n/chat/dist/style.css" rel="stylesheet" />
<script type="module">
import { createChat } from 'https://cdn.jsdelivr.net/npm/@n8n/chat/dist/chat.bundle.es.js';
createChat({
webhookUrl: 'YOUR_PRODUCTION_WEBHOOK_URL'
});
</script>
```
### b. Import Embed
Install and save n8n Chat as a production dependency.
```sh
npm install @n8n/chat
```
Import the CSS and use the `createChat` function to initialize your Chat window.
```ts
import '@n8n/chat/style.css';
import { createChat } from '@n8n/chat';
createChat({
webhookUrl: 'YOUR_PRODUCTION_WEBHOOK_URL'
});
```
##### Vue.js
```html
<script lang="ts" setup>
// App.vue
import { onMounted } from 'vue';
import '@n8n/chat/style.css';
import { createChat } from '@n8n/chat';
onMounted(() => {
createChat({
webhookUrl: 'YOUR_PRODUCTION_WEBHOOK_URL'
});
});
</script>
<template>
<div></div>
</template>
```
##### React
```tsx
// App.tsx
import { useEffect } from 'react';
import '@n8n/chat/style.css';
import { createChat } from '@n8n/chat';
export const App = () => {
useEffect(() => {
createChat({
webhookUrl: 'YOUR_PRODUCTION_WEBHOOK_URL'
});
}, []);
return (<div></div>);
};
```
## Options
The default options are:
```ts
createChat({
webhookUrl: '',
webhookConfig: {
method: 'POST',
headers: {}
},
target: '#n8n-chat',
mode: 'window',
chatInputKey: 'chatInput',
chatSessionKey: 'sessionId',
loadPreviousSession: true,
metadata: {},
showWelcomeScreen: false,
defaultLanguage: 'en',
initialMessages: [
'Hi there! 👋',
'My name is Nathan. How can I assist you today?'
],
i18n: {
en: {
title: 'Hi there! 👋',
subtitle: "Start a chat. We're here to help you 24/7.",
footer: '',
getStarted: 'New Conversation',
inputPlaceholder: 'Type your question..',
},
},
enableStreaming: false,
});
```
### `webhookUrl`
- **Type**: `string`
- **Required**: `true`
- **Examples**:
- `https://yourname.app.n8n.cloud/webhook/513107b3-6f3a-4a1e-af21-659f0ed14183`
- `http://localhost:5678/webhook/513107b3-6f3a-4a1e-af21-659f0ed14183`
- **Description**: The URL of the n8n Webhook endpoint. Should be the production URL.
### `webhookConfig`
- **Type**: `{ method: string, headers: Record<string, string> }`
- **Default**: `{ method: 'POST', headers: {} }`
- **Description**: The configuration for the Webhook request.
### `target`
- **Type**: `string`
- **Default**: `'#n8n-chat'`
- **Description**: The CSS selector of the element where the Chat window should be embedded.
### `mode`
- **Type**: `'window' | 'fullscreen'`
- **Default**: `'window'`
- **Description**: The render mode of the Chat window.
- In `window` mode, the Chat window will be embedded in the target element as a chat toggle button and a fixed size chat window.
- In `fullscreen` mode, the Chat will take up the entire width and height of its target container.
### `showWelcomeScreen`
- **Type**: `boolean`
- **Default**: `false`
- **Description**: Whether to show the welcome screen when the Chat window is opened.
### `chatInputKey`
- **Type**: `string`
- **Default**: `'chatInput'`
- **Description**: The key to use for sending the chat input for the AI Agent node.
### `chatSessionKey`
- **Type**: `string`
- **Default**: `'sessionId'`
- **Description**: The key to use for sending the chat history session ID for the AI Memory node.
### `loadPreviousSession`
- **Type**: `boolean`
- **Default**: `true`
- **Description**: Whether to load previous messages (chat context).
### `defaultLanguage`
- **Type**: `string`
- **Default**: `'en'`
- **Description**: The default language of the Chat window. Currently only `en` is supported.
### `i18n`
- **Type**: `{ [key: string]: Record<string, string> }`
- **Description**: The i18n configuration for the Chat window. Currently only `en` is supported.
### `initialMessages`
- **Type**: `string[]`
- **Description**: The initial messages to be displayed in the Chat window.
### `allowFileUploads`
- **Type**: `Ref<boolean> | boolean`
- **Default**: `false`
- **Description**: Whether to allow file uploads in the chat. If set to `true`, users will be able to upload files through the chat interface.
### `allowedFilesMimeTypes`
- **Type**: `Ref<string> | string`
- **Default**: `''`
- **Description**: A comma-separated list of allowed MIME types for file uploads. Only applicable if `allowFileUploads` is set to `true`. If left empty, all file types are allowed. For example: `'image/*,application/pdf'`.
### enableStreaming
- Type: boolean
- Default: false
- Description: Whether to enable streaming responses from the n8n workflow. If set to `true`, the chat will display responses as they are being generated, providing a more interactive experience. For this to work the workflow must be configured as well to return streaming responses.
## Customization
The Chat window is entirely customizable using CSS variables.
```css
:root {
/* Colors */
--chat--color--primary: #e74266;
--chat--color--primary-shade-50: #db4061;
--chat--color--primary--shade-100: #cf3c5c;
--chat--color--secondary: #20b69e;
--chat--color-secondary-shade-50: #1ca08a;
--chat--color-white: #fff;
--chat--color-light: #f2f4f8;
--chat--color-light-shade-50: #e6e9f1;
--chat--color-light-shade-100: #c2c5cc;
--chat--color-medium: #d2d4d9;
--chat--color-dark: #101330;
--chat--color-disabled: #d2d4d9;
--chat--color-typing: #404040;
/* Base Layout */
--chat--spacing: 1rem;
--chat--border-radius: 0.25rem;
--chat--transition-duration: 0.15s;
--chat--font-family:
-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen-Sans, Ubuntu, Cantarell,
'Helvetica Neue', sans-serif;
/* Window Dimensions */
--chat--window--width: 400px;
--chat--window--height: 600px;
--chat--window--bottom: var(--chat--spacing);
--chat--window--right: var(--chat--spacing);
--chat--window--z-index: 9999;
--chat--window--border: 1px solid var(--chat--color-light-shade-50);
--chat--window--border-radius: var(--chat--border-radius);
--chat--window--margin-bottom: var(--chat--spacing);
/* Header Styles */
--chat--header-height: auto;
--chat--header--padding: var(--chat--spacing);
--chat--header--background: var(--chat--color-dark);
--chat--header--color: var(--chat--color-light);
--chat--header--border-top: none;
--chat--header--border-bottom: none;
--chat--header--border-left: none;
--chat--header--border-right: none;
--chat--heading--font-size: 2em;
--chat--subtitle--font-size: inherit;
--chat--subtitle--line-height: 1.8;
/* Message Styles */
--chat--message--font-size: 1rem;
--chat--message--padding: var(--chat--spacing);
--chat--message--border-radius: var(--chat--border-radius);
--chat--message-line-height: 1.5;
--chat--message--margin-bottom: calc(var(--chat--spacing) * 1);
--chat--message--bot--background: var(--chat--color-white);
--chat--message--bot--color: var(--chat--color-dark);
--chat--message--bot--border: none;
--chat--message--user--background: var(--chat--color--secondary);
--chat--message--user--color: var(--chat--color-white);
--chat--message--user--border: none;
--chat--message--pre--background: rgba(0, 0, 0, 0.05);
--chat--messages-list--padding: var(--chat--spacing);
/* Toggle Button */
--chat--toggle--size: 64px;
--chat--toggle--width: var(--chat--toggle--size);
--chat--toggle--height: var(--chat--toggle--size);
--chat--toggle--border-radius: 50%;
--chat--toggle--background: var(--chat--color--primary);
--chat--toggle--hover--background: var(--chat--color--primary-shade-50);
--chat--toggle--active--background: var(--chat--color--primary--shade-100);
--chat--toggle--color: var(--chat--color-white);
/* Input Area */
--chat--textarea--height: 50px;
--chat--textarea--max-height: 30rem;
--chat--input--font-size: inherit;
--chat--input--border: 0;
--chat--input--border-radius: 0;
--chat--input--padding: 0.8rem;
--chat--input--background: var(--chat--color-white);
--chat--input--text-color: initial;
--chat--input--line-height: 1.5;
--chat--input--placeholder--font-size: var(--chat--input--font-size);
--chat--input--border-active: 0;
--chat--input--left--panel--width: 2rem;
/* Button Styles */
--chat--button--padding: calc(var(--chat--spacing) * 5 / 8) var(--chat--spacing);
--chat--button--border-radius: var(--chat--border-radius);
--chat--button--font-size: 1rem;
--chat--button--line-height: 1;
--chat--button--color--primary: var(--chat--color-light);
--chat--button--background--primary: var(--chat--color--secondary);
--chat--button--border--primary: none;
--chat--button--color--primary--hover: var(--chat--color-light);
--chat--button--background--primary--hover: var(--chat--color-secondary-shade-50);
--chat--button--border--primary--hover: none;
--chat--button--color--primary--disabled: var(--chat--color-light);
--chat--button--background--primary--disabled: #81bbb1;
--chat--button--border--primary--disabled: none;
--chat--button--color--secondary: var(--chat--color-light);
--chat--button--background--secondary: hsl(0, 0%, 58%);
--chat--button--border--secondary: none;
--chat--button--color--secondary--hover: var(--chat--color-light);
--chat--button--background--secondary--hover: hsl(0, 0%, 51%);
--chat--button--border--secondary--hover: none;
--chat--button--color--secondary--disabled: var(--chat--color-light);
--chat--button--background--secondary--disabled: hsl(0, 0%, 78%);
--chat--button--border--secondary--disabled: none;
--chat--close--button--color-hover: var(--chat--color--primary);
/* Send and File Buttons */
--chat--input--send--button--background: var(--chat--color-white);
--chat--input--send--button--color: var(--chat--color--secondary);
--chat--input--send--button--background-hover: var(--chat--color--primary-shade-50);
--chat--input--send--button--color-hover: var(--chat--color-secondary-shade-50);
--chat--input--file--button--background: var(--chat--color-white);
--chat--input--file--button--color: var(--chat--color--secondary);
--chat--input--file--button--background-hover: var(--chat--input--file--button--background);
--chat--input--file--button--color-hover: var(--chat--color-secondary-shade-50);
--chat--files-spacing: 0.25rem;
/* Body and Footer */
--chat--body--background: var(--chat--color-light);
--chat--footer--background: var(--chat--color-light);
--chat--footer--color: var(--chat--color-dark);
}
```
## Caveats
### Fullscreen mode
In fullscreen mode, the Chat window will take up the entire width and height of its target container. Make sure that the container has a set width and height.
```css
html,
body,
#n8n-chat {
width: 100%;
height: 100%;
}
```
## License
You can find the license information [here](https://github.com/n8n-io/n8n/blob/master/README.md#license)

View File

@@ -0,0 +1,17 @@
import { frontendConfig } from '@n8n/eslint-config/frontend';
import { defineConfig } from 'eslint/config';
export default defineConfig(frontendConfig, {
rules: {
// TODO: Remove these
'no-empty': 'warn',
'@typescript-eslint/require-await': 'warn',
'@typescript-eslint/no-empty-object-type': 'warn',
'@typescript-eslint/naming-convention': 'warn',
'@typescript-eslint/no-unsafe-function-type': 'warn',
'@typescript-eslint/no-unsafe-call': 'warn',
'@typescript-eslint/no-unsafe-member-access': 'warn',
'@typescript-eslint/no-unsafe-return': 'warn',
'@typescript-eslint/no-unsafe-assignment': 'warn',
},
});

View File

@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/favicon.ico">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Vite App</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

View File

@@ -0,0 +1,66 @@
{
"name": "@n8n/chat",
"version": "1.11.0",
"scripts": {
"dev": "pnpm run --dir=../storybook dev --initial-path=/docs/chat-chat--docs",
"build": "pnpm build:vite && pnpm build:bundle",
"build:vite": "cross-env vite build",
"build:bundle": "cross-env INCLUDE_VUE=true vite build",
"preview": "vite preview",
"test:dev": "vitest",
"test": "vitest run",
"typecheck": "vue-tsc --noEmit",
"lint": "eslint src --quiet",
"lint:fix": "eslint src --fix",
"lint:styles": "stylelint \"src/**/*.{scss,sass,vue}\" --cache",
"lint:styles:fix": "stylelint \"src/**/*.{scss,sass,vue}\" --fix --cache",
"format": "biome format --write src && prettier --write src/ --ignore-path ../../../../.prettierignore",
"format:check": "biome ci src && prettier --check src/ --ignore-path ../../../../.prettierignore"
},
"types": "./dist/index.d.ts",
"main": "./dist/chat.umd.js",
"module": "./dist/chat.es.js",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/chat.es.js",
"require": "./dist/chat.umd.js"
},
"./style.css": {
"import": "./dist/style.css",
"require": "./dist/style.css"
},
"./*": {
"import": "./*",
"require": "./*"
}
},
"dependencies": {
"@n8n/design-system": "workspace:*",
"@vueuse/core": "catalog:frontend",
"highlight.js": "catalog:frontend",
"markdown-it-link-attributes": "^4.0.1",
"uuid": "catalog:",
"vue": "catalog:frontend",
"vue-markdown-render": "catalog:frontend"
},
"devDependencies": {
"@iconify-json/mdi": "^1.1.54",
"@n8n/eslint-config": "workspace:*",
"@n8n/stylelint-config": "workspace:*",
"@n8n/typescript-config": "workspace:*",
"@n8n/vitest-config": "workspace:*",
"@storybook/vue3-vite": "catalog:storybook",
"@vitejs/plugin-vue": "catalog:frontend",
"@vitest/coverage-v8": "catalog:",
"unplugin-icons": "^0.19.0",
"vite": "catalog:",
"vitest": "catalog:",
"vite-plugin-dts": "^4.5.3",
"vue-tsc": "catalog:frontend"
},
"files": [
"README.md",
"dist"
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 115 KiB

View File

@@ -0,0 +1,214 @@
{
"name": "Hosted n8n AI Chat Manual",
"nodes": [
{
"parameters": {
"options": {}
},
"id": "e6043748-44fc-4019-9301-5690fe26c614",
"name": "OpenAI Chat Model",
"type": "@n8n/n8n-nodes-langchain.lmChatOpenAi",
"typeVersion": 1,
"position": [860, 540],
"credentials": {
"openAiApi": {
"id": "cIIkOhl7tUX1KsL6",
"name": "OpenAi account"
}
}
},
{
"parameters": {
"sessionKey": "={{ $json.sessionId }}"
},
"id": "0a68a59a-8ab6-4fa5-a1ea-b7f99a93109b",
"name": "Simple Memory",
"type": "@n8n/n8n-nodes-langchain.memoryBufferWindow",
"typeVersion": 1,
"position": [640, 540]
},
{
"parameters": {
"text": "={{ $json.chatInput }}",
"options": {}
},
"id": "3d4e0fbf-d761-4569-b02e-f5c1eeb830c8",
"name": "AI Agent",
"type": "@n8n/n8n-nodes-langchain.agent",
"typeVersion": 1.1,
"position": [840, 300]
},
{
"parameters": {
"dataType": "string",
"value1": "={{ $json.action }}",
"rules": {
"rules": [
{
"value2": "loadPreviousSession",
"outputKey": "loadPreviousSession"
},
{
"value2": "sendMessage",
"outputKey": "sendMessage"
}
]
}
},
"id": "84213c7b-abc7-4f40-9567-cd3484a4ae6b",
"name": "Switch",
"type": "n8n-nodes-base.switch",
"typeVersion": 2,
"position": [300, 280]
},
{
"parameters": {
"simplifyOutput": false
},
"id": "3be7f076-98ed-472a-80b6-bf8d9538ac87",
"name": "Chat Messages Retriever",
"type": "@n8n/n8n-nodes-langchain.memoryChatRetriever",
"typeVersion": 1,
"position": [620, 140]
},
{
"parameters": {
"options": {}
},
"id": "3417c644-8a91-4524-974a-45b4a46d0e2e",
"name": "Respond to Webhook",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1,
"position": [1240, 140]
},
{
"parameters": {
"public": true,
"authentication": "n8nUserAuth",
"options": {
"loadPreviousSession": "manually",
"responseMode": "responseNode"
}
},
"id": "1b30c239-a819-45b4-b0ae-bdd5b92a5424",
"name": "Chat Trigger",
"type": "@n8n/n8n-nodes-langchain.chatTrigger",
"typeVersion": 1,
"position": [80, 280],
"webhookId": "ed3dea26-7d68-42b3-9032-98fe967d441d"
},
{
"parameters": {
"aggregate": "aggregateAllItemData",
"options": {}
},
"id": "79672cf0-686b-41eb-90ae-fd31b6da837d",
"name": "Aggregate",
"type": "n8n-nodes-base.aggregate",
"typeVersion": 1,
"position": [1000, 140]
}
],
"pinData": {},
"connections": {
"OpenAI Chat Model": {
"ai_languageModel": [
[
{
"node": "AI Agent",
"type": "ai_languageModel",
"index": 0
}
]
]
},
"Simple Memory": {
"ai_memory": [
[
{
"node": "AI Agent",
"type": "ai_memory",
"index": 0
},
{
"node": "Chat Messages Retriever",
"type": "ai_memory",
"index": 0
}
]
]
},
"Switch": {
"main": [
[
{
"node": "Chat Messages Retriever",
"type": "main",
"index": 0
}
],
[
{
"node": "AI Agent",
"type": "main",
"index": 0
}
]
]
},
"Chat Messages Retriever": {
"main": [
[
{
"node": "Aggregate",
"type": "main",
"index": 0
}
]
]
},
"AI Agent": {
"main": [
[
{
"node": "Respond to Webhook",
"type": "main",
"index": 0
}
]
]
},
"Chat Trigger": {
"main": [
[
{
"node": "Switch",
"type": "main",
"index": 0
}
]
]
},
"Aggregate": {
"main": [
[
{
"node": "Respond to Webhook",
"type": "main",
"index": 0
}
]
]
}
},
"active": true,
"settings": {
"executionOrder": "v1"
},
"versionId": "425c0efe-3aa0-4e0e-8c06-abe12234b1fd",
"id": "1569HF92Y02EUtsU",
"meta": {
"instanceId": "374b43d8b8d6299cc777811a4ad220fc688ee2d54a308cfb0de4450a5233ca9e"
},
"tags": []
}

View File

@@ -0,0 +1,84 @@
{
"nodes": [
{
"parameters": {
"model": {
"__rl": true,
"mode": "list",
"value": "gpt-4.1-mini"
},
"options": {}
},
"type": "@n8n/n8n-nodes-langchain.lmChatOpenAi",
"typeVersion": 1.2,
"position": [400, 224],
"id": "9593988a-2ca5-4a82-bd3a-3d8fcb69036d",
"name": "OpenAI Chat Model",
"credentials": {
"openAiApi": {
"id": "Rr1g6PqGGpNJcaBf",
"name": "OpenAi account"
}
}
},
{
"parameters": {
"options": {
"returnIntermediateSteps": false,
"enableStreaming": false
}
},
"type": "@n8n/n8n-nodes-langchain.agent",
"typeVersion": 2.2,
"position": [400, 48],
"id": "72b37ab2-7352-476b-bca6-5b41e2290082",
"name": "AI Agent"
},
{
"parameters": {
"public": true,
"options": {
"responseMode": "streaming"
}
},
"type": "@n8n/n8n-nodes-langchain.chatTrigger",
"typeVersion": 1.2,
"position": [32, 48],
"id": "012ecda4-3ae8-4328-a371-c7ae63cabf1c",
"name": "When chat message received",
"webhookId": "022d6461-e68d-4531-b713-833953c388c2"
}
],
"connections": {
"OpenAI Chat Model": {
"ai_languageModel": [
[
{
"node": "AI Agent",
"type": "ai_languageModel",
"index": 0
}
]
]
},
"AI Agent": {
"main": [[]]
},
"When chat message received": {
"main": [
[
{
"node": "AI Agent",
"type": "main",
"index": 0
}
]
]
}
},
"pinData": {},
"meta": {
"templateCredsSetupCompleted": true,
"instanceId": "1d6725ae695aac02e442b627cd2266d305eba55a427b2dcc7702a0271b70043c"
}
}

View File

@@ -0,0 +1,107 @@
{
"name": "Hosted n8n AI Chat",
"nodes": [
{
"parameters": {
"options": {}
},
"id": "4c109d13-62a2-4e23-9979-e50201db743d",
"name": "OpenAI Chat Model",
"type": "@n8n/n8n-nodes-langchain.lmChatOpenAi",
"typeVersion": 1,
"position": [640, 540],
"credentials": {
"openAiApi": {
"id": "cIIkOhl7tUX1KsL6",
"name": "OpenAi account"
}
}
},
{
"parameters": {
"sessionKey": "={{ $json.sessionId }}"
},
"id": "b416df7b-4802-462f-8f74-f0a71dc4c0be",
"name": "Simple Memory",
"type": "@n8n/n8n-nodes-langchain.memoryBufferWindow",
"typeVersion": 1,
"position": [340, 540]
},
{
"parameters": {
"text": "={{ $json.chatInput }}",
"options": {}
},
"id": "4de25807-a2ef-4453-900e-e00e0021ecdc",
"name": "AI Agent",
"type": "@n8n/n8n-nodes-langchain.agent",
"typeVersion": 1.1,
"position": [620, 300]
},
{
"parameters": {
"public": true,
"options": {
"loadPreviousSession": "memory"
}
},
"id": "5a9612ae-51c1-4be2-bd8b-8556872d1149",
"name": "Chat Trigger",
"type": "@n8n/n8n-nodes-langchain.chatTrigger",
"typeVersion": 1,
"position": [340, 300],
"webhookId": "f406671e-c954-4691-b39a-66c90aa2f103"
}
],
"pinData": {},
"connections": {
"OpenAI Chat Model": {
"ai_languageModel": [
[
{
"node": "AI Agent",
"type": "ai_languageModel",
"index": 0
}
]
]
},
"Simple Memory": {
"ai_memory": [
[
{
"node": "AI Agent",
"type": "ai_memory",
"index": 0
},
{
"node": "Chat Trigger",
"type": "ai_memory",
"index": 0
}
]
]
},
"Chat Trigger": {
"main": [
[
{
"node": "AI Agent",
"type": "main",
"index": 0
}
]
]
}
},
"active": true,
"settings": {
"executionOrder": "v1"
},
"versionId": "6076136f-fdb4-48d9-b483-d1c24c95ef9e",
"id": "zaBHnDtj22BzEQ6K",
"meta": {
"instanceId": "374b43d8b8d6299cc777811a4ad220fc688ee2d54a308cfb0de4450a5233ca9e"
},
"tags": []
}

View File

@@ -0,0 +1,25 @@
<script lang="ts" setup>
import hljs from 'highlight.js/lib/core';
import hljsJavascript from 'highlight.js/lib/languages/javascript';
import hljsXML from 'highlight.js/lib/languages/xml';
import { computed, onMounted } from 'vue';
import Chat from '@n8n/chat/components/Chat.vue';
import ChatWindow from '@n8n/chat/components/ChatWindow.vue';
import { useOptions } from '@n8n/chat/composables';
defineProps({});
const { options } = useOptions();
const isFullscreen = computed<boolean>(() => options.mode === 'fullscreen');
onMounted(() => {
hljs.registerLanguage('xml', hljsXML);
hljs.registerLanguage('javascript', hljsJavascript);
});
</script>
<template>
<Chat v-if="isFullscreen" class="n8n-chat" />
<ChatWindow v-else class="n8n-chat" />
</template>

View File

@@ -0,0 +1,61 @@
import type { StoryObj } from '@storybook/vue3';
import { onMounted } from 'vue';
import { createChat } from '@n8n/chat/index';
import type { ChatOptions } from '@n8n/chat/types';
const webhookUrl = 'http://localhost:5678/webhook/ad712f8b-3546-4d08-b049-e0d035334a4c/chat';
const meta = {
title: 'Chat/Chat',
render: (args: Partial<ChatOptions>) => ({
setup() {
onMounted(() => {
createChat(args);
});
return {};
},
template: '<div id="n8n-chat" />',
}),
parameters: {
layout: 'fullscreen',
},
tags: ['autodocs'],
};
// eslint-disable-next-line import-x/no-default-export
export default meta;
type Story = StoryObj<typeof meta>;
export const Fullscreen: Story = {
args: {
webhookUrl,
mode: 'fullscreen',
enableStreaming: false,
loadPreviousSession: false,
} satisfies Partial<ChatOptions>,
};
export const Windowed: Story = {
args: {
webhookUrl,
mode: 'window',
enableStreaming: false,
loadPreviousSession: false,
} satisfies Partial<ChatOptions>,
};
export const WorkflowChat: Story = {
name: 'Workflow Chat',
args: {
webhookUrl: 'http://localhost:5678/webhook/ad324b56-3e40-4b27-874f-58d150504edc/chat',
mode: 'fullscreen',
allowedFilesMimeTypes: 'image/*,text/*,audio/*, application/pdf',
allowFileUploads: true,
showWelcomeScreen: false,
initialMessages: [],
enableStreaming: false,
loadPreviousSession: false,
} satisfies Partial<ChatOptions>,
};

View File

@@ -0,0 +1,299 @@
import type { VueWrapper } from '@vue/test-utils';
import { mount } from '@vue/test-utils';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { chatEventBus } from '@n8n/chat/event-buses';
import Chat from '../components/Chat.vue';
import type { ChatMessage } from '../types/messages';
// Mock child components
vi.mock('../components/GetStarted.vue', () => ({
default: { name: 'GetStarted', template: '<div>GetStarted</div>' },
}));
vi.mock('../components/GetStartedFooter.vue', () => ({
default: { name: 'GetStartedFooter', template: '<div>GetStartedFooter</div>' },
}));
vi.mock('../components/Input.vue', () => ({
default: {
name: 'Input',
template:
'<div data-test-id="chat-input" @arrow-key-down="$emit(\'arrowKeyDown\', $event)" @escape-key-down="$emit(\'escapeKeyDown\', $event)"></div>',
},
}));
vi.mock('../components/Layout.vue', () => ({
default: {
name: 'Layout',
template: '<div><slot /><slot name="footer" /></div>',
},
}));
vi.mock('../components/MessagesList.vue', () => ({
default: {
name: 'MessagesList',
template: '<div>MessagesList</div>',
props: ['messages'],
},
}));
vi.mock('virtual:icons/mdi/close', () => ({
default: { name: 'IconClose' },
}));
const mockChatStore = {
initialize: vi.fn().mockResolvedValue(undefined),
startNewSession: vi.fn(),
messages: [] as ChatMessage[],
};
const mockOptions = {
mode: 'window' as const,
showWindowCloseButton: true,
showWelcomeScreen: false,
};
vi.mock('@n8n/chat/composables', () => ({
useI18n: () => ({
t: (key: string) => key,
}),
useChat: () => ({
messages: { value: mockChatStore.messages },
initialize: mockChatStore.initialize,
startNewSession: mockChatStore.startNewSession,
currentSessionId: { value: 'test-session' },
}),
useOptions: () => ({ options: mockOptions }),
}));
vi.mock('@n8n/chat/event-buses', () => ({
chatEventBus: {
emit: vi.fn(),
on: vi.fn(),
},
}));
describe('Chat', () => {
let wrapper: VueWrapper;
beforeEach(() => {
vi.clearAllMocks();
mockChatStore.messages = [];
});
afterEach(() => {
if (wrapper) {
wrapper.unmount();
}
});
describe('arrow key navigation', () => {
beforeEach(() => {
// Set up messages for testing navigation
mockChatStore.messages = [
{
id: '1',
text: 'First message',
sender: 'user',
type: 'text',
},
{
id: '2',
text: 'Bot response',
sender: 'bot',
type: 'text',
},
{
id: '3',
text: 'Second message',
sender: 'user',
type: 'text',
},
{
id: '4',
text: 'Third message',
sender: 'user',
type: 'text',
},
];
});
it('should navigate to previous message on ArrowUp', async () => {
wrapper = mount(Chat);
// Trigger ArrowUp
const input = wrapper.findComponent({ name: 'Input' });
await input.vm.$emit('arrowKeyDown', { key: 'ArrowUp', currentInputValue: '' });
expect(vi.mocked(chatEventBus.emit)).toHaveBeenCalledWith('setInputValue', 'Third message');
});
it('should navigate through message history on multiple ArrowUp presses', async () => {
wrapper = mount(Chat);
const input = wrapper.findComponent({ name: 'Input' });
// First ArrowUp - should get most recent message
await input.vm.$emit('arrowKeyDown', { key: 'ArrowUp', currentInputValue: '' });
expect(vi.mocked(chatEventBus.emit)).toHaveBeenCalledWith('setInputValue', 'Third message');
// Second ArrowUp - should get second most recent
await input.vm.$emit('arrowKeyDown', { key: 'ArrowUp', currentInputValue: 'Third message' });
expect(vi.mocked(chatEventBus.emit)).toHaveBeenCalledWith('setInputValue', 'Second message');
// Third ArrowUp - should get oldest
await input.vm.$emit('arrowKeyDown', { key: 'ArrowUp', currentInputValue: 'Second message' });
expect(vi.mocked(chatEventBus.emit)).toHaveBeenCalledWith('setInputValue', 'First message');
});
it('should not go beyond the oldest message', async () => {
wrapper = mount(Chat);
const input = wrapper.findComponent({ name: 'Input' });
// Navigate to the end
await input.vm.$emit('arrowKeyDown', { key: 'ArrowUp', currentInputValue: '' });
await input.vm.$emit('arrowKeyDown', { key: 'ArrowUp', currentInputValue: 'Third message' });
await input.vm.$emit('arrowKeyDown', { key: 'ArrowUp', currentInputValue: 'Second message' });
vi.clearAllMocks();
// Try to go beyond the oldest message
await input.vm.$emit('arrowKeyDown', { key: 'ArrowUp', currentInputValue: 'First message' });
// Should still emit blur/focus, but not setInputValue
expect(vi.mocked(chatEventBus.emit)).toHaveBeenCalledWith('blurInput');
expect(vi.mocked(chatEventBus.emit)).toHaveBeenCalledWith('focusInput');
expect(vi.mocked(chatEventBus.emit)).not.toHaveBeenCalledWith(
'setInputValue',
expect.anything(),
);
});
it('should navigate forward on ArrowDown', async () => {
wrapper = mount(Chat);
const input = wrapper.findComponent({ name: 'Input' });
// Navigate back first
await input.vm.$emit('arrowKeyDown', { key: 'ArrowUp', currentInputValue: '' });
await input.vm.$emit('arrowKeyDown', { key: 'ArrowUp', currentInputValue: 'Third message' });
vi.clearAllMocks();
// Navigate forward
await input.vm.$emit('arrowKeyDown', {
key: 'ArrowDown',
currentInputValue: 'Second message',
});
expect(vi.mocked(chatEventBus.emit)).toHaveBeenCalledWith('setInputValue', 'Third message');
});
it('should clear input when navigating past the newest message', async () => {
wrapper = mount(Chat);
const input = wrapper.findComponent({ name: 'Input' });
// Navigate back
await input.vm.$emit('arrowKeyDown', { key: 'ArrowUp', currentInputValue: '' });
vi.clearAllMocks();
// Navigate forward to clear
await input.vm.$emit('arrowKeyDown', {
key: 'ArrowDown',
currentInputValue: 'Third message',
});
expect(vi.mocked(chatEventBus.emit)).toHaveBeenCalledWith('setInputValue', '');
});
it('should handle empty message history gracefully', async () => {
mockChatStore.messages = [];
wrapper = mount(Chat);
const input = wrapper.findComponent({ name: 'Input' });
await input.vm.$emit('arrowKeyDown', { key: 'ArrowUp', currentInputValue: '' });
expect(vi.mocked(chatEventBus.emit)).not.toHaveBeenCalled();
});
it('should only include user messages in navigation', async () => {
wrapper = mount(Chat);
const input = wrapper.findComponent({ name: 'Input' });
// First ArrowUp should skip bot messages
await input.vm.$emit('arrowKeyDown', { key: 'ArrowUp', currentInputValue: '' });
expect(vi.mocked(chatEventBus.emit)).toHaveBeenCalledWith('setInputValue', 'Third message');
// Second ArrowUp should also skip bot messages
await input.vm.$emit('arrowKeyDown', { key: 'ArrowUp', currentInputValue: 'Third message' });
expect(vi.mocked(chatEventBus.emit)).toHaveBeenCalledWith('setInputValue', 'Second message');
});
it('should reset history index when messageSent event is emitted', async () => {
wrapper = mount(Chat);
const input = wrapper.findComponent({ name: 'Input' });
// Navigate back in history
await input.vm.$emit('arrowKeyDown', { key: 'ArrowUp', currentInputValue: '' });
await input.vm.$emit('arrowKeyDown', { key: 'ArrowUp', currentInputValue: 'Third message' });
// Get the messageSent callback
const messageSentCallback = vi
.mocked(chatEventBus.on)
.mock.calls.find((call) => call[0] === 'messageSent')?.[1];
expect(messageSentCallback).toBeDefined();
// Trigger messageSent event
if (messageSentCallback) {
messageSentCallback();
}
vi.clearAllMocks();
// After reset, ArrowUp should start from the beginning again
await input.vm.$emit('arrowKeyDown', { key: 'ArrowUp', currentInputValue: '' });
expect(vi.mocked(chatEventBus.emit)).toHaveBeenCalledWith('setInputValue', 'Third message');
});
it('should preserve current input when starting navigation', async () => {
wrapper = mount(Chat);
const input = wrapper.findComponent({ name: 'Input' });
// Start navigation with some input
await input.vm.$emit('arrowKeyDown', {
key: 'ArrowUp',
currentInputValue: 'My partial message',
});
// Navigate down to restore
await input.vm.$emit('arrowKeyDown', {
key: 'ArrowDown',
currentInputValue: 'Third message',
});
await input.vm.$emit('arrowKeyDown', {
key: 'ArrowDown',
currentInputValue: 'Third message',
});
// Should restore the original input
expect(vi.mocked(chatEventBus.emit)).toHaveBeenCalledWith(
'setInputValue',
'My partial message',
);
});
it('should emit blur and focus events during navigation', async () => {
wrapper = mount(Chat);
const input = wrapper.findComponent({ name: 'Input' });
vi.clearAllMocks();
await input.vm.$emit('arrowKeyDown', { key: 'ArrowUp', currentInputValue: '' });
// Should blur before setting value
expect(vi.mocked(chatEventBus.emit)).toHaveBeenCalledWith('blurInput');
// Should focus after setting value
expect(vi.mocked(chatEventBus.emit)).toHaveBeenCalledWith('focusInput');
});
});
});

View File

@@ -0,0 +1,158 @@
import type { VueWrapper } from '@vue/test-utils';
import { mount } from '@vue/test-utils';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import Input from '../components/Input.vue';
vi.mock('@vueuse/core', () => ({
useFileDialog: vi.fn(() => ({
open: vi.fn(),
reset: vi.fn(),
onChange: vi.fn(),
})),
}));
vi.mock('uuid', () => ({
v4: vi.fn(() => 'mock-uuid-123'),
}));
vi.mock('virtual:icons/mdi/paperclip', () => ({
default: { name: 'IconPaperclip' },
}));
vi.mock('virtual:icons/mdi/send', () => ({
default: { name: 'IconSend' },
}));
vi.mock('@n8n/chat/composables', () => ({
useI18n: () => ({
t: (key: string) => key,
}),
useChat: () => ({
waitingForResponse: { value: false },
blockUserInput: { value: false },
currentSessionId: { value: 'session-123' },
messages: { value: [] },
sendMessage: vi.fn(),
ws: null,
}),
useOptions: () => ({
options: {
disabled: { value: false },
allowFileUploads: { value: true },
allowedFilesMimeTypes: { value: 'image/*,text/*' },
webhookUrl: 'https://example.com/webhook',
},
}),
}));
vi.mock('@n8n/chat/event-buses', () => ({
chatEventBus: {
on: vi.fn(),
off: vi.fn(),
},
}));
vi.mock('./ChatFile.vue', () => ({
default: { name: 'ChatFile' },
}));
describe('ChatInput', () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let wrapper: VueWrapper<any>;
beforeEach(() => {
// @ts-expect-error - mock WebSocket
global.WebSocket = vi.fn().mockImplementation(
() =>
({
send: vi.fn(),
close: vi.fn(),
onmessage: null,
onclose: null,
}) as unknown as WebSocket,
);
});
afterEach(() => {
if (wrapper) {
wrapper.unmount();
}
vi.clearAllMocks();
});
it('renders the component with default props', () => {
wrapper = mount(Input);
expect(wrapper.find('textarea').exists()).toBe(true);
expect(wrapper.find('[data-test-id="chat-input"]').exists()).toBe(true);
expect(wrapper.find('.chat-input-send-button').exists()).toBe(true);
});
it('applies custom placeholder', () => {
wrapper = mount(Input, {
props: {
placeholder: 'customPlaceholder',
},
});
const textarea = wrapper.find('textarea');
expect(textarea.attributes('placeholder')).toBe('customPlaceholder');
});
it('updates input value when typing', async () => {
const textarea = wrapper.find('textarea');
await textarea.setValue('Hello world');
expect(wrapper.vm.input).toBe('Hello world');
});
it('does not submit on Shift+Enter', async () => {
const textarea = wrapper.find('textarea');
const onSubmitSpy = vi.spyOn(wrapper.vm, 'onSubmit');
await textarea.setValue('Test message');
await textarea.trigger('keydown.enter', { shiftKey: true });
expect(onSubmitSpy).not.toHaveBeenCalled();
});
it('sets up WebSocket connection with execution ID', () => {
const executionId = 'exec-123';
wrapper.vm.setupWebsocketConnection(executionId);
expect(global.WebSocket).toHaveBeenCalledWith(expect.stringContaining('sessionId=session-123'));
expect(global.WebSocket).toHaveBeenCalledWith(expect.stringContaining('executionId=exec-123'));
});
it('handles WebSocket messages correctly', async () => {
const mockWs = {
send: vi.fn(),
onmessage: null,
onclose: null,
};
wrapper.vm.chatStore.ws = mockWs;
wrapper.vm.waitingForChatResponse = true;
await wrapper.vm.respondToChatNode(mockWs, 'Test message');
expect(mockWs.send).toHaveBeenCalledWith(expect.stringContaining('"chatInput":"Test message"'));
});
it('handles empty file list gracefully', () => {
wrapper.vm.files = null;
expect(() => wrapper.vm.attachFiles()).not.toThrow();
expect(wrapper.vm.attachFiles()).toEqual([]);
});
it('prevents submit when disabled', async () => {
const submitButton = wrapper.find('.chat-input-send-button');
await submitButton.trigger('click');
expect(wrapper.vm.isSubmitting).toBe(false);
});
});

View File

@@ -0,0 +1,176 @@
import type { VueWrapper } from '@vue/test-utils';
import { mount } from '@vue/test-utils';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { useChat } from '@n8n/chat/composables';
import { chatEventBus } from '@n8n/chat/event-buses';
import type { ChatMessage } from '@n8n/chat/types';
import MessageActions from '../components/MessageActions.vue';
vi.mock('@n8n/design-system', () => ({
N8nTooltip: {
name: 'N8nTooltip',
template: '<div><slot /></div>',
},
N8nIcon: {
name: 'N8nIcon',
template: '<div :icon="icon" :size="size" @click="$emit(\'click\')"></div>',
props: ['icon', 'size'],
},
}));
vi.mock('@n8n/chat/composables', () => ({
useChat: vi.fn(() => ({
sendMessage: vi.fn(),
})),
useOptions: () => ({
options: {
enableMessageActions: true,
},
}),
useI18n: () => ({
t: vi.fn(),
}),
}));
vi.mock('@n8n/chat/event-buses', () => ({
chatEventBus: {
emit: vi.fn(),
},
}));
describe('MessageActions', () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let wrapper: VueWrapper<any>;
const userMessage: ChatMessage = {
id: '1',
text: 'Hello, world!',
sender: 'user',
type: 'text',
};
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
if (wrapper) {
wrapper.unmount();
}
});
it('should render message actions for user messages when enabled', () => {
wrapper = mount(MessageActions, {
props: {
message: userMessage,
},
});
expect(wrapper.find('.message-actions').exists()).toBe(true);
});
it('should call sendMessage when repost icon is clicked', async () => {
const mockSendMessage = vi.fn();
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument
vi.mocked(useChat).mockReturnValue({ sendMessage: mockSendMessage } as any);
wrapper = mount(MessageActions, {
props: {
message: userMessage,
},
});
const repostIcon = wrapper.find('[icon="redo-2"]');
expect(repostIcon.exists()).toBe(true);
await repostIcon.trigger('click');
expect(mockSendMessage).toHaveBeenCalledWith('Hello, world!', []);
});
it('should emit setInputValue event when copy to input icon is clicked', async () => {
wrapper = mount(MessageActions, {
props: {
message: userMessage,
},
});
const copyIcon = wrapper.find('[icon="files"]');
expect(copyIcon.exists()).toBe(true);
await copyIcon.trigger('click');
expect(vi.mocked(chatEventBus.emit)).toHaveBeenCalledWith('setInputValue', 'Hello, world!');
});
it('should handle messages with files when reposting', async () => {
const mockSendMessage = vi.fn();
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument
vi.mocked(useChat).mockReturnValue({ sendMessage: mockSendMessage } as any);
const messageWithFiles: ChatMessage = {
id: '3',
text: 'Message with files',
sender: 'user',
type: 'text',
files: [new File(['test'], 'test.txt')],
};
wrapper = mount(MessageActions, {
props: {
message: messageWithFiles,
},
});
const repostIcon = wrapper.find('[icon="redo-2"]');
await repostIcon.trigger('click');
expect(mockSendMessage).toHaveBeenCalledWith('Message with files', [expect.any(File)]);
});
it('should not repost empty messages', async () => {
const mockSendMessage = vi.fn();
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument
vi.mocked(useChat).mockReturnValue({ sendMessage: mockSendMessage } as any);
const emptyMessage: ChatMessage = {
id: '4',
text: ' ',
sender: 'user',
type: 'text',
};
wrapper = mount(MessageActions, {
props: {
message: emptyMessage,
},
});
const repostIcon = wrapper.find('[icon="redo-2"]');
await repostIcon.trigger('click');
expect(mockSendMessage).not.toHaveBeenCalled();
});
it('should not copy empty messages to input', async () => {
const emptyMessage: ChatMessage = {
id: '5',
text: ' ',
sender: 'user',
type: 'text',
};
wrapper = mount(MessageActions, {
props: {
message: emptyMessage,
},
});
const copyIcon = wrapper.find('[icon="files"]');
await copyIcon.trigger('click');
expect(vi.mocked(chatEventBus.emit)).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,120 @@
import { waitFor } from '@testing-library/vue';
import { mount } from '@vue/test-utils';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import MessageWithButtons from '../components/MessageWithButtons.vue';
vi.mock('../components/MarkdownRenderer.vue', () => ({
default: {
name: 'MarkdownRenderer',
template: '<div>{{ text }}</div>',
props: ['text'],
},
}));
vi.mock('@n8n/chat/composables', () => ({
useOptions: () => ({
options: {
webhookUrl: 'https://webhook.example.com/webhook/123/chat',
},
}),
}));
const editorOrigin = 'http://localhost:5678';
const webhookOrigin = 'https://webhook.example.com';
const relativeUrlButtons = [
{ text: 'Confirm', link: '/api/confirm', type: 'primary' as const },
{ text: 'Cancel', link: '/api/cancel', type: 'secondary' as const },
];
describe('MessageWithButtons', () => {
beforeEach(() => {
vi.stubGlobal('fetch', vi.fn());
Object.defineProperty(window, 'location', {
value: new URL(`${editorOrigin}/chat`),
writable: true,
});
});
afterEach(() => {
vi.restoreAllMocks();
});
it('does not render buttons whose links do not match the configured webhook origin', () => {
const wrapper = mount(MessageWithButtons, {
props: { text: 'Please confirm', buttons: relativeUrlButtons },
});
expect(wrapper.findAll('button')).toHaveLength(0);
expect(fetch).not.toHaveBeenCalled();
});
it('renders and fetches when the link is on the configured webhook origin', async () => {
vi.mocked(fetch).mockResolvedValue({ ok: true } as Response);
const webhookButtons = [
{
text: 'Approve',
link: `${webhookOrigin}/webhook/123/approve`,
type: 'primary' as const,
},
];
const wrapper = mount(MessageWithButtons, {
props: { text: 'Please approve', buttons: webhookButtons },
});
expect(wrapper.find('button').exists()).toBe(true);
await wrapper.find('button').trigger('click');
await waitFor(() => expect(fetch).toHaveBeenCalledWith(`${webhookOrigin}/webhook/123/approve`));
});
it('does not render a button when the link points to an unrecognized origin', () => {
const externalButtons = [
{ text: 'Go', link: 'https://broken-link.com/approve', type: 'primary' as const },
];
const wrapper = mount(MessageWithButtons, {
props: { text: 'Click me', buttons: externalButtons },
});
expect(wrapper.find('button').exists()).toBe(false);
expect(fetch).not.toHaveBeenCalled();
});
it('does not render a button for an absolute URL on a different host with the same port', () => {
const externalButtons = [
{ text: 'Go', link: 'http://other-host:5678/api/confirm', type: 'primary' as const },
];
const wrapper = mount(MessageWithButtons, {
props: { text: 'Click me', buttons: externalButtons },
});
expect(wrapper.find('button').exists()).toBe(false);
expect(fetch).not.toHaveBeenCalled();
});
it('renders only valid-origin buttons when the list contains mixed URLs', () => {
const mixedButtons = [
{ text: 'Editor', link: '/api/confirm', type: 'primary' as const },
{
text: 'Webhook',
link: `${webhookOrigin}/webhook/123/approve`,
type: 'secondary' as const,
},
{ text: 'Other', link: 'http://broken-url/approve', type: 'secondary' as const },
];
const wrapper = mount(MessageWithButtons, {
props: { text: 'Choose', buttons: mixedButtons },
});
const rendered = wrapper.findAll('button');
expect(rendered).toHaveLength(1);
expect(rendered[0].text()).toBe('Webhook');
});
});

View File

@@ -0,0 +1,126 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { postWithFiles } from '@n8n/chat/api/generic';
describe('postWithFiles', () => {
beforeEach(() => {
vi.restoreAllMocks();
});
it('should properly serialize object metadata to JSON string in FormData', async () => {
const mockResponse = {
ok: true,
status: 200,
json: async () => await Promise.resolve({ success: true }),
text: async () => await Promise.resolve('success'),
clone: () => mockResponse,
} as Response;
const fetchSpy = vi.spyOn(global, 'fetch').mockResolvedValue(mockResponse);
const testFile = new File(['test content'], 'test.txt', { type: 'text/plain' });
const metadata = {
userId: 'user-123',
token: 'abc-def-ghi',
nested: {
prop: 'value',
num: 42,
},
};
await postWithFiles(
'https://example.com/webhook',
{
action: 'sendMessage',
sessionId: 'test-session',
chatInput: 'test message',
metadata,
},
[testFile],
);
expect(fetchSpy).toHaveBeenCalledWith('https://example.com/webhook', {
method: 'POST',
body: expect.any(FormData),
mode: 'cors',
cache: 'no-cache',
headers: {},
});
// Get the FormData from the call
const formData = fetchSpy.mock.calls[0][1]?.body as FormData;
expect(formData).toBeInstanceOf(FormData);
// Verify that metadata was properly serialized as JSON, not "[object Object]"
const metadataValue = formData.get('metadata');
expect(metadataValue).toBe(JSON.stringify(metadata));
// Verify other fields are still strings
expect(formData.get('action')).toBe('sendMessage');
expect(formData.get('sessionId')).toBe('test-session');
expect(formData.get('chatInput')).toBe('test message');
// Verify file was included
expect(formData.get('files')).toBe(testFile);
});
it('should handle primitive values correctly', async () => {
const mockResponse = {
ok: true,
status: 200,
json: async () => await Promise.resolve({ success: true }),
text: async () => await Promise.resolve('success'),
clone: () => mockResponse,
} as Response;
const fetchSpy = vi.spyOn(global, 'fetch').mockResolvedValue(mockResponse);
await postWithFiles('https://example.com/webhook', {
stringValue: 'test',
});
const formData = fetchSpy.mock.calls[0][1]?.body as FormData;
expect(formData.get('stringValue')).toBe('test');
});
it('should handle arrays as JSON strings', async () => {
const mockResponse = {
ok: true,
status: 200,
json: async () => await Promise.resolve({ success: true }),
text: async () => await Promise.resolve('success'),
clone: () => mockResponse,
} as Response;
const fetchSpy = vi.spyOn(global, 'fetch').mockResolvedValue(mockResponse);
const arrayValue = ['item1', 'item2', { nested: 'object' }];
await postWithFiles('https://example.com/webhook', {
arrayValue,
});
const formData = fetchSpy.mock.calls[0][1]?.body as FormData;
expect(formData.get('arrayValue')).toBe(JSON.stringify(arrayValue));
});
it('should handle empty objects correctly', async () => {
const mockResponse = {
ok: true,
status: 200,
json: async () => await Promise.resolve({ success: true }),
text: async () => await Promise.resolve('success'),
clone: () => mockResponse,
} as Response;
const fetchSpy = vi.spyOn(global, 'fetch').mockResolvedValue(mockResponse);
await postWithFiles('https://example.com/webhook', {
emptyObject: {},
});
const formData = fetchSpy.mock.calls[0][1]?.body as FormData;
expect(formData.get('emptyObject')).toBe('{}');
});
});

View File

@@ -0,0 +1,623 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { sendMessageStreaming } from '@n8n/chat/api';
import type { ChatOptions } from '@n8n/chat/types';
describe('sendMessageStreaming', () => {
const mockOptions: ChatOptions = {
webhookUrl: 'https://test.example.com/webhook',
chatSessionKey: 'sessionId',
chatInputKey: 'chatInput',
i18n: {
en: {
title: 'Test',
subtitle: 'Test',
footer: 'Test',
getStarted: 'Test',
inputPlaceholder: 'Test',
closeButtonTooltip: 'Test',
},
},
};
beforeEach(() => {
vi.restoreAllMocks();
});
it('should call the webhook URL with correct parameters', async () => {
const chunks = [
{
type: 'begin',
metadata: {
nodeId: 'node-1',
nodeName: 'Test Node',
timestamp: Date.now(),
runIndex: 0,
itemIndex: 0,
},
},
{
type: 'item',
content: 'Hello ',
metadata: {
nodeId: 'node-1',
nodeName: 'Test Node',
timestamp: Date.now(),
runIndex: 0,
itemIndex: 0,
},
},
{
type: 'item',
content: 'World!',
metadata: {
nodeId: 'node-1',
nodeName: 'Test Node',
timestamp: Date.now(),
runIndex: 0,
itemIndex: 0,
},
},
{
type: 'end',
metadata: {
nodeId: 'node-1',
nodeName: 'Test Node',
timestamp: Date.now(),
runIndex: 0,
itemIndex: 0,
},
},
];
const encoder = new TextEncoder();
const stream = new ReadableStream({
start(controller) {
chunks.forEach((chunk) => {
const data = JSON.stringify(chunk) + '\n';
controller.enqueue(encoder.encode(data));
});
controller.close();
},
});
const mockResponse = {
ok: true,
status: 200,
body: stream,
headers: new Headers(),
} as Response;
vi.spyOn(global, 'fetch').mockResolvedValue(mockResponse);
const onChunk = vi.fn();
const onBeginMessage = vi.fn();
const onEndMessage = vi.fn();
await sendMessageStreaming('Test message', [], 'test-session-id', mockOptions, {
onChunk,
onBeginMessage,
onEndMessage,
});
expect(fetch).toHaveBeenCalledWith('https://test.example.com/webhook', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'text/plain',
},
body: JSON.stringify({
action: 'sendMessage',
sessionId: 'test-session-id',
chatInput: 'Test message',
}),
});
expect(onBeginMessage).toHaveBeenCalledTimes(1);
expect(onBeginMessage).toHaveBeenCalledWith('node-1', 0);
expect(onChunk).toHaveBeenCalledTimes(2);
expect(onChunk).toHaveBeenCalledWith('Hello ', 'node-1', 0);
expect(onChunk).toHaveBeenCalledWith('World!', 'node-1', 0);
expect(onEndMessage).toHaveBeenCalledTimes(1);
expect(onEndMessage).toHaveBeenCalledWith('node-1', 0);
});
it('should handle multiple runs and items correctly', async () => {
const chunks = [
{
type: 'begin',
metadata: {
nodeId: 'node-1',
nodeName: 'Test Node',
timestamp: Date.now(),
runIndex: 0,
itemIndex: 0,
},
},
{
type: 'item',
content: 'Run 0 Item 0 ',
metadata: {
nodeId: 'node-1',
nodeName: 'Test Node',
timestamp: Date.now(),
runIndex: 0,
itemIndex: 0,
},
},
{
type: 'end',
metadata: {
nodeId: 'node-1',
nodeName: 'Test Node',
timestamp: Date.now(),
runIndex: 0,
itemIndex: 0,
},
},
{
type: 'begin',
metadata: {
nodeId: 'node-1',
nodeName: 'Test Node',
timestamp: Date.now(),
runIndex: 1,
itemIndex: 0,
},
},
{
type: 'item',
content: 'Run 1 Item 0 ',
metadata: {
nodeId: 'node-1',
nodeName: 'Test Node',
timestamp: Date.now(),
runIndex: 1,
itemIndex: 0,
},
},
{
type: 'end',
metadata: {
nodeId: 'node-1',
nodeName: 'Test Node',
timestamp: Date.now(),
runIndex: 1,
itemIndex: 0,
},
},
];
const encoder = new TextEncoder();
const stream = new ReadableStream({
start(controller) {
chunks.forEach((chunk) => {
const data = JSON.stringify(chunk) + '\n';
controller.enqueue(encoder.encode(data));
});
controller.close();
},
});
const mockResponse = {
ok: true,
status: 200,
body: stream,
headers: new Headers(),
} as Response;
vi.spyOn(global, 'fetch').mockResolvedValue(mockResponse);
const onChunk = vi.fn();
const onBeginMessage = vi.fn();
const onEndMessage = vi.fn();
await sendMessageStreaming('Test message', [], 'test-session-id', mockOptions, {
onChunk,
onBeginMessage,
onEndMessage,
});
expect(onBeginMessage).toHaveBeenCalledTimes(2);
expect(onBeginMessage).toHaveBeenCalledWith('node-1', 0);
expect(onBeginMessage).toHaveBeenCalledWith('node-1', 1);
expect(onChunk).toHaveBeenCalledTimes(2);
expect(onChunk).toHaveBeenCalledWith('Run 0 Item 0 ', 'node-1', 0);
expect(onChunk).toHaveBeenCalledWith('Run 1 Item 0 ', 'node-1', 1);
expect(onEndMessage).toHaveBeenCalledTimes(2);
expect(onEndMessage).toHaveBeenCalledWith('node-1', 0);
expect(onEndMessage).toHaveBeenCalledWith('node-1', 1);
});
it('should support file uploads with streaming', async () => {
const testFile = new File(['test'], 'test.txt', { type: 'text/plain' });
const chunks = [
{
type: 'begin',
metadata: {
nodeId: 'node-1',
nodeName: 'Test Node',
timestamp: Date.now(),
runIndex: 0,
itemIndex: 0,
},
},
{
type: 'item',
content: 'File processed: ',
metadata: {
nodeId: 'node-1',
nodeName: 'Test Node',
timestamp: Date.now(),
runIndex: 0,
itemIndex: 0,
},
},
{
type: 'item',
content: 'test.txt',
metadata: {
nodeId: 'node-1',
nodeName: 'Test Node',
timestamp: Date.now(),
runIndex: 0,
itemIndex: 0,
},
},
{
type: 'end',
metadata: {
nodeId: 'node-1',
nodeName: 'Test Node',
timestamp: Date.now(),
runIndex: 0,
itemIndex: 0,
},
},
];
const encoder = new TextEncoder();
const stream = new ReadableStream({
start(controller) {
chunks.forEach((chunk) => {
const data = JSON.stringify(chunk) + '\n';
controller.enqueue(encoder.encode(data));
});
controller.close();
},
});
const mockResponse = {
ok: true,
status: 200,
body: stream,
headers: new Headers(),
} as Response;
vi.spyOn(global, 'fetch').mockResolvedValue(mockResponse);
const onChunk = vi.fn();
const onBeginMessage = vi.fn();
const onEndMessage = vi.fn();
await sendMessageStreaming('Test message', [testFile], 'test-session-id', mockOptions, {
onChunk,
onBeginMessage,
onEndMessage,
});
// Verify FormData was used for file upload
expect(fetch).toHaveBeenCalledWith('https://test.example.com/webhook', {
method: 'POST',
headers: {
Accept: 'text/plain',
},
body: expect.any(FormData),
});
expect(onBeginMessage).toHaveBeenCalledTimes(1);
expect(onBeginMessage).toHaveBeenCalledWith('node-1', 0);
expect(onChunk).toHaveBeenCalledTimes(2);
expect(onChunk).toHaveBeenCalledWith('File processed: ', 'node-1', 0);
expect(onChunk).toHaveBeenCalledWith('test.txt', 'node-1', 0);
expect(onEndMessage).toHaveBeenCalledTimes(1);
expect(onEndMessage).toHaveBeenCalledWith('node-1', 0);
});
it('should strip Content-Type header when uploading files even if set in webhookConfig', async () => {
const optionsWithContentType: ChatOptions = {
...mockOptions,
webhookConfig: {
headers: { 'Content-Type': 'application/json', 'X-Custom': 'value' },
},
};
const mockResponse = {
ok: true,
status: 200,
body: new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode('{"type":"end"}\n'));
controller.close();
},
}),
headers: new Headers(),
} as Response;
vi.spyOn(global, 'fetch').mockResolvedValue(mockResponse);
await sendMessageStreaming(
'test',
[new File([''], 'test.txt')],
'session',
optionsWithContentType,
{
onChunk: vi.fn(),
onEndMessage: vi.fn(),
onBeginMessage: vi.fn(),
},
);
// Content-Type must be excluded for FormData (browser sets it with boundary)
// Other custom headers should still be included
expect(fetch).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
headers: { Accept: 'text/plain', 'X-Custom': 'value' },
body: expect.any(FormData),
}),
);
});
it('should handle HTTP errors', async () => {
const mockResponse = {
ok: false,
status: 500,
headers: new Headers(),
text: async () => 'Internal Server Error',
} as Response;
vi.spyOn(global, 'fetch').mockResolvedValue(mockResponse);
await expect(
sendMessageStreaming('Test message', [], 'test-session-id', mockOptions, {
onChunk: vi.fn(),
onEndMessage: vi.fn(),
onBeginMessage: vi.fn(),
}),
).rejects.toThrow('Error while sending message. Error: Internal Server Error');
});
it('should handle missing response body', async () => {
const mockResponse = {
ok: true,
status: 200,
body: null,
headers: new Headers(),
} as Response;
vi.spyOn(global, 'fetch').mockResolvedValue(mockResponse);
await expect(
sendMessageStreaming('Test message', [], 'test-session-id', mockOptions, {
onChunk: vi.fn(),
onEndMessage: vi.fn(),
onBeginMessage: vi.fn(),
}),
).rejects.toThrow('Response body is not readable');
});
it('should include custom headers from webhook config', async () => {
const optionsWithHeaders: ChatOptions = {
...mockOptions,
webhookConfig: {
headers: {
Authorization: 'Bearer token',
'X-Custom-Header': 'value',
},
},
};
const chunks = [
{
type: 'begin',
metadata: { nodeId: 'node-1', nodeName: 'Test Node', timestamp: Date.now() },
},
{ type: 'end', metadata: { nodeId: 'node-1', nodeName: 'Test Node', timestamp: Date.now() } },
];
const encoder = new TextEncoder();
const stream = new ReadableStream({
start(controller) {
chunks.forEach((chunk) => {
const data = JSON.stringify(chunk) + '\n';
controller.enqueue(encoder.encode(data));
});
controller.close();
},
});
const mockResponse = {
ok: true,
status: 200,
body: stream,
headers: new Headers(),
} as Response;
vi.spyOn(global, 'fetch').mockResolvedValue(mockResponse);
await sendMessageStreaming('Test message', [], 'test-session-id', optionsWithHeaders, {
onChunk: vi.fn(),
onEndMessage: vi.fn(),
onBeginMessage: vi.fn(),
});
expect(fetch).toHaveBeenCalledWith('https://test.example.com/webhook', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'text/plain',
Authorization: 'Bearer token',
'X-Custom-Header': 'value',
},
body: JSON.stringify({
action: 'sendMessage',
sessionId: 'test-session-id',
chatInput: 'Test message',
}),
});
});
it('should include metadata when provided', async () => {
const optionsWithMetadata: ChatOptions = {
...mockOptions,
metadata: {
userId: 'user-123',
source: 'chat-widget',
},
};
const chunks = [
{
type: 'begin',
metadata: { nodeId: 'node-1', nodeName: 'Test Node', timestamp: Date.now() },
},
{ type: 'end', metadata: { nodeId: 'node-1', nodeName: 'Test Node', timestamp: Date.now() } },
];
const encoder = new TextEncoder();
const stream = new ReadableStream({
start(controller) {
chunks.forEach((chunk) => {
const data = JSON.stringify(chunk) + '\n';
controller.enqueue(encoder.encode(data));
});
controller.close();
},
});
const mockResponse = {
ok: true,
status: 200,
body: stream,
headers: new Headers(),
} as Response;
vi.spyOn(global, 'fetch').mockResolvedValue(mockResponse);
await sendMessageStreaming('Test message', [], 'test-session-id', optionsWithMetadata, {
onChunk: vi.fn(),
onEndMessage: vi.fn(),
onBeginMessage: vi.fn(),
});
expect(fetch).toHaveBeenCalledWith('https://test.example.com/webhook', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'text/plain',
},
body: JSON.stringify({
action: 'sendMessage',
sessionId: 'test-session-id',
chatInput: 'Test message',
metadata: {
userId: 'user-123',
source: 'chat-widget',
},
}),
});
});
describe('async handlers', () => {
it('should support async onEndMessage handler', async () => {
const onEndMessage = vi.fn().mockImplementation(async () => {
await new Promise((resolve) => setTimeout(resolve, 10));
});
const chunks = [
{
type: 'begin',
metadata: { nodeId: 'node-1', nodeName: 'Test Node', timestamp: Date.now() },
},
{
type: 'end',
metadata: { nodeId: 'node-1', nodeName: 'Test Node', timestamp: Date.now() },
},
];
const encoder = new TextEncoder();
const stream = new ReadableStream({
start(controller) {
chunks.forEach((chunk) => {
const data = JSON.stringify(chunk) + '\n';
controller.enqueue(encoder.encode(data));
});
controller.close();
},
});
const mockResponse = {
ok: true,
status: 200,
body: stream,
headers: new Headers(),
} as Response;
vi.spyOn(global, 'fetch').mockResolvedValue(mockResponse);
await sendMessageStreaming('Test message', [], 'test-session-id', mockOptions, {
onChunk: vi.fn(),
onEndMessage,
onBeginMessage: vi.fn(),
});
expect(onEndMessage).toHaveBeenCalledWith('node-1', undefined);
});
it('should await async onEndMessage on error chunks', async () => {
const onEndMessage = vi.fn().mockImplementation(async () => {
await new Promise((resolve) => setTimeout(resolve, 10));
});
const chunks = [
{
type: 'begin',
metadata: { nodeId: 'node-1', nodeName: 'Test Node', timestamp: Date.now() },
},
{
type: 'error',
content: 'Something went wrong',
metadata: { nodeId: 'node-1', nodeName: 'Test Node', timestamp: Date.now() },
},
];
const encoder = new TextEncoder();
const stream = new ReadableStream({
start(controller) {
chunks.forEach((chunk) => {
const data = JSON.stringify(chunk) + '\n';
controller.enqueue(encoder.encode(data));
});
controller.close();
},
});
const mockResponse = {
ok: true,
status: 200,
body: stream,
headers: new Headers(),
} as Response;
vi.spyOn(global, 'fetch').mockResolvedValue(mockResponse);
const onChunk = vi.fn();
await sendMessageStreaming('Test message', [], 'test-session-id', mockOptions, {
onChunk,
onEndMessage,
onBeginMessage: vi.fn(),
});
expect(onChunk).toHaveBeenCalledWith('Error: Something went wrong', 'node-1', undefined);
expect(onEndMessage).toHaveBeenCalledWith('node-1', undefined);
});
});
});

View File

@@ -0,0 +1,372 @@
import { fireEvent, waitFor } from '@testing-library/vue';
import {
createFetchResponse,
createGetLatestMessagesResponse,
createSendMessageResponse,
createMockStreamingFetchResponse,
getChatInputSendButton,
getChatInputTextarea,
getChatMessage,
getChatMessageByText,
getChatMessages,
getChatMessageTyping,
getChatWindowToggle,
getChatWindowWrapper,
getChatWrapper,
getGetStartedButton,
getMountingTarget,
} from '@n8n/chat/__tests__/utils';
import { createChat } from '@n8n/chat/index';
describe('createChat()', () => {
let app: ReturnType<typeof createChat>;
afterEach(() => {
vi.clearAllMocks();
app.unmount();
});
describe('mode', () => {
it('should create fullscreen chat app with default options', () => {
const fetchSpy = vi.spyOn(window, 'fetch');
fetchSpy.mockImplementationOnce(createFetchResponse(createGetLatestMessagesResponse()));
app = createChat({
mode: 'fullscreen',
});
expect(getMountingTarget()).toBeVisible();
expect(getChatWrapper()).toBeVisible();
expect(getChatWindowWrapper()).not.toBeInTheDocument();
});
it('should create window chat app with default options', () => {
const fetchSpy = vi.spyOn(window, 'fetch');
fetchSpy.mockImplementationOnce(createFetchResponse(createGetLatestMessagesResponse()));
app = createChat({
mode: 'window',
});
expect(getMountingTarget()).toBeDefined();
expect(getChatWindowWrapper()).toBeVisible();
expect(getChatWrapper()).not.toBeVisible();
});
it('should open window chat app using toggle button', async () => {
const fetchSpy = vi.spyOn(window, 'fetch');
fetchSpy.mockImplementationOnce(createFetchResponse(createGetLatestMessagesResponse()));
app = createChat();
expect(getMountingTarget()).toBeVisible();
expect(getChatWindowWrapper()).toBeVisible();
const trigger = getChatWindowToggle();
await fireEvent.click(trigger as HTMLElement);
expect(getChatWrapper()).toBeVisible();
});
});
describe('loadPreviousMessages', () => {
it('should load previous messages on mount', async () => {
const fetchSpy = vi.spyOn(global, 'fetch');
fetchSpy.mockImplementation(createFetchResponse(createGetLatestMessagesResponse()));
app = createChat({
mode: 'fullscreen',
showWelcomeScreen: true,
});
const getStartedButton = getGetStartedButton();
await fireEvent.click(getStartedButton as HTMLElement);
expect(fetchSpy.mock.calls[0][1]).toEqual(
expect.objectContaining({
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: expect.stringContaining('"action":"loadPreviousSession"') as unknown,
mode: 'cors',
cache: 'no-cache',
}),
);
});
});
describe('initialMessages', () => {
it.each(['fullscreen', 'window'] as Array<'fullscreen' | 'window'>)(
'should show initial default messages in %s mode',
async (mode) => {
const fetchSpy = vi.spyOn(window, 'fetch');
fetchSpy.mockImplementationOnce(createFetchResponse(createGetLatestMessagesResponse()));
const initialMessages = ['Hello tester!', 'How are you?'];
app = createChat({
mode,
initialMessages,
});
if (mode === 'window') {
const trigger = getChatWindowToggle();
await fireEvent.click(trigger as HTMLElement);
}
expect(getChatMessages().length).toBe(initialMessages.length);
expect(getChatMessageByText(initialMessages[0])).toBeInTheDocument();
expect(getChatMessageByText(initialMessages[1])).toBeInTheDocument();
},
);
});
describe('sendMessage', () => {
it.each(['window', 'fullscreen'] as Array<'fullscreen' | 'window'>)(
'should send a message and render a text message in %s mode',
async (mode) => {
const input = 'Hello User World!';
const output = 'Hello Bot World!';
const fetchSpy = vi.spyOn(window, 'fetch');
fetchSpy
.mockImplementationOnce(createFetchResponse(createGetLatestMessagesResponse))
.mockImplementationOnce(createFetchResponse(createSendMessageResponse(output)));
app = createChat({
mode,
});
if (mode === 'window') {
const trigger = getChatWindowToggle();
await fireEvent.click(trigger as HTMLElement);
}
expect(getChatMessageTyping()).not.toBeInTheDocument();
expect(getChatMessages().length).toBe(2);
await waitFor(() => expect(getChatInputTextarea()).toBeInTheDocument());
const textarea = getChatInputTextarea();
const sendButton = getChatInputSendButton();
await fireEvent.update(textarea as HTMLElement, input);
expect(sendButton).not.toBeDisabled();
await fireEvent.click(sendButton as HTMLElement);
expect(fetchSpy.mock.calls[1][1]).toEqual(
expect.objectContaining({
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: expect.stringMatching(/"action":"sendMessage"/) as unknown,
mode: 'cors',
cache: 'no-cache',
}),
);
expect(fetchSpy.mock.calls[1][1]?.body).toContain(`"${input}"`);
expect(getChatMessages().length).toBe(3);
expect(getChatMessageByText(input)).toBeInTheDocument();
expect(getChatMessageTyping()).toBeVisible();
await waitFor(() => expect(getChatMessageTyping()).not.toBeInTheDocument());
expect(getChatMessageByText(output)).toBeInTheDocument();
},
);
it.each(['fullscreen', 'window'] as Array<'fullscreen' | 'window'>)(
'should send a message and render a code markdown message in %s mode',
async (mode) => {
const input = 'Teach me javascript!';
const output = '# Code\n```js\nconsole.log("Hello World!");\n```';
const fetchSpy = vi.spyOn(window, 'fetch');
fetchSpy
.mockImplementationOnce(createFetchResponse(createGetLatestMessagesResponse))
.mockImplementationOnce(createFetchResponse(createSendMessageResponse(output)));
app = createChat({
mode,
});
if (mode === 'window') {
const trigger = getChatWindowToggle();
await fireEvent.click(trigger as HTMLElement);
}
await waitFor(() => expect(getChatInputTextarea()).toBeInTheDocument());
const textarea = getChatInputTextarea();
const sendButton = getChatInputSendButton();
await fireEvent.update(textarea as HTMLElement, input);
await fireEvent.click(sendButton as HTMLElement);
expect(getChatMessageByText(input)).toBeInTheDocument();
expect(getChatMessages().length).toBe(3);
await waitFor(() => expect(getChatMessageTyping()).not.toBeInTheDocument());
const lastMessage = getChatMessage(-1);
expect(lastMessage).toBeInTheDocument();
expect(lastMessage.querySelector('h1')).toHaveTextContent('Code');
expect(lastMessage.querySelector('code')).toHaveTextContent('console.log("Hello World!");');
},
);
});
describe('streaming', () => {
it('should handle streaming responses when enableStreaming is true', async () => {
const input = 'Tell me a story!';
const chunks = [
{
type: 'begin',
metadata: {
nodeId: 'node-1',
itemIndex: 0,
runIndex: 0,
nodeName: 'Test Node',
timestamp: Date.now(),
},
},
{
type: 'item',
content: 'Once upon ',
metadata: {
nodeId: 'node-1',
itemIndex: 0,
runIndex: 0,
nodeName: 'Test Node',
timestamp: Date.now(),
},
},
{
type: 'item',
content: 'a time, ',
metadata: {
nodeId: 'node-1',
itemIndex: 0,
runIndex: 0,
nodeName: 'Test Node',
timestamp: Date.now(),
},
},
{
type: 'item',
content: 'there was a test.',
metadata: {
nodeId: 'node-1',
itemIndex: 0,
runIndex: 0,
nodeName: 'Test Node',
timestamp: Date.now(),
},
},
{
type: 'end',
metadata: {
nodeId: 'node-1',
itemIndex: 0,
runIndex: 0,
nodeName: 'Test Node',
timestamp: Date.now(),
},
},
];
const fetchSpy = vi.spyOn(window, 'fetch');
fetchSpy
.mockImplementationOnce(createFetchResponse(createGetLatestMessagesResponse))
.mockImplementationOnce(createMockStreamingFetchResponse(chunks));
app = createChat({
mode: 'fullscreen',
enableStreaming: true,
});
await waitFor(() => expect(getChatInputTextarea()).toBeInTheDocument());
const textarea = getChatInputTextarea();
const sendButton = getChatInputSendButton();
await fireEvent.update(textarea as HTMLElement, input);
await fireEvent.click(sendButton as HTMLElement);
expect(getChatMessageByText(input)).toBeInTheDocument();
expect(getChatMessages().length).toBe(3);
await waitFor(() => expect(getChatMessageTyping()).not.toBeInTheDocument());
const expectedOutput = 'Once upon a time, there was a test.';
await waitFor(() => expect(getChatMessageByText(expectedOutput)).toBeInTheDocument());
expect(fetchSpy.mock.calls[1][1]).toEqual(
expect.objectContaining({
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'text/plain',
},
body: expect.stringMatching(/"action":"sendMessage"/) as unknown,
}),
);
});
it('should fall back to regular API when enableStreaming is false', async () => {
const input = 'Hello!';
const output = 'Hello Bot World!';
const fetchSpy = vi.spyOn(window, 'fetch');
fetchSpy
.mockImplementationOnce(createFetchResponse(createGetLatestMessagesResponse))
.mockImplementationOnce(createFetchResponse(createSendMessageResponse(output)));
app = createChat({
mode: 'fullscreen',
enableStreaming: false,
});
await waitFor(() => expect(getChatInputTextarea()).toBeInTheDocument());
const textarea = getChatInputTextarea();
const sendButton = getChatInputSendButton();
await fireEvent.update(textarea as HTMLElement, input);
await fireEvent.click(sendButton as HTMLElement);
expect(getChatMessageByText(input)).toBeInTheDocument();
await waitFor(() => expect(getChatMessageTyping()).not.toBeInTheDocument());
expect(getChatMessageByText(output)).toBeInTheDocument();
});
it('should handle streaming errors gracefully', async () => {
const input = 'This should fail!';
const fetchSpy = vi.spyOn(window, 'fetch');
fetchSpy
.mockImplementationOnce(createFetchResponse(createGetLatestMessagesResponse))
.mockImplementationOnce(async () => {
throw new Error('Network error');
});
app = createChat({
mode: 'fullscreen',
enableStreaming: true,
});
await waitFor(() => expect(getChatInputTextarea()).toBeInTheDocument());
const textarea = getChatInputTextarea();
const sendButton = getChatInputSendButton();
await fireEvent.update(textarea as HTMLElement, input);
await fireEvent.click(sendButton as HTMLElement);
expect(getChatMessageByText(input)).toBeInTheDocument();
await waitFor(() => expect(getChatMessageTyping()).not.toBeInTheDocument());
expect(getChatMessageByText('Error: Failed to receive response')).toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1,634 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { createApp } from 'vue';
import * as api from '@n8n/chat/api';
import type { StreamingEventHandlers } from '@n8n/chat/api/message';
import { localStorageSessionIdKey } from '@n8n/chat/constants';
import { chatEventBus } from '@n8n/chat/event-buses';
import { ChatPlugin } from '@n8n/chat/plugins/chat';
import type { Chat, ChatOptions, LoadPreviousSessionResponse } from '@n8n/chat/types';
// Mock dependencies
vi.mock('@n8n/chat/api');
vi.mock('@n8n/chat/event-buses', () => ({
chatEventBus: {
emit: vi.fn(),
},
}));
// Helper function to set up chat store with proper typing
function setupChatStore(options: ChatOptions): Chat {
const app = createApp({
template: '<div></div>',
});
app.use(ChatPlugin, options);
return app.config.globalProperties.$chat as Chat;
}
describe('ChatPlugin', () => {
let mockOptions: ChatOptions;
beforeEach(() => {
// Reset mocks
vi.clearAllMocks();
// Setup default options
mockOptions = {
webhookUrl: 'http://localhost:5678/webhook',
chatInputKey: 'message',
chatSessionKey: 'sessionId',
enableStreaming: false,
initialMessages: [], // Explicitly set to empty to override defaults
i18n: {
en: {
title: 'Test Chat',
subtitle: 'Test subtitle',
footer: '',
getStarted: 'Start',
inputPlaceholder: 'Type a message...',
closeButtonTooltip: 'Close',
},
},
};
// Setup localStorage mock
const localStorageMock = {
getItem: vi.fn(),
setItem: vi.fn(),
removeItem: vi.fn(),
clear: vi.fn(),
};
Object.defineProperty(window, 'localStorage', {
value: localStorageMock,
writable: true,
});
});
afterEach(() => {
vi.clearAllMocks();
});
describe('sendMessage', () => {
let chatStore: Chat;
beforeEach(() => {
chatStore = setupChatStore(mockOptions);
});
it('should send a message without streaming', async () => {
const mockResponse = { output: 'Hello from bot!' };
vi.mocked(api.sendMessage).mockResolvedValueOnce(mockResponse);
await chatStore.sendMessage('Hello bot!');
expect(api.sendMessage).toHaveBeenCalledWith('Hello bot!', [], null, mockOptions);
expect(chatStore.messages.value).toHaveLength(2);
expect(chatStore.messages.value[0]).toMatchObject({
text: 'Hello bot!',
sender: 'user',
});
expect(chatStore.messages.value[1]).toMatchObject({
text: 'Hello from bot!',
sender: 'bot',
});
});
it('should handle empty response gracefully', async () => {
const mockResponse = {};
vi.mocked(api.sendMessage).mockResolvedValueOnce(mockResponse);
await chatStore.sendMessage('Hello bot!');
expect(chatStore.messages.value).toHaveLength(2);
expect(chatStore.messages.value[1]).toMatchObject({
text: '',
sender: 'bot',
});
});
it('should handle response with only text property', async () => {
const mockResponse = { text: 'Response text' };
vi.mocked(api.sendMessage).mockResolvedValueOnce(mockResponse);
await chatStore.sendMessage('Hello bot!');
expect(chatStore.messages.value[1]).toMatchObject({
text: 'Response text',
sender: 'bot',
});
});
it('should handle errors during message sending', async () => {
vi.mocked(api.sendMessage).mockRejectedValueOnce(new Error('Network error'));
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
await chatStore.sendMessage('Hello bot!');
expect(consoleErrorSpy).toHaveBeenCalledWith('Chat API error:', expect.any(Error));
// Error messages should be displayed to the user
expect(chatStore.messages.value).toHaveLength(2);
expect(chatStore.messages.value[0]).toMatchObject({
text: 'Hello bot!',
sender: 'user',
});
expect(chatStore.messages.value[1]).toMatchObject({
text: 'Error: Failed to receive response',
sender: 'bot',
});
consoleErrorSpy.mockRestore();
});
it('should send files with message', async () => {
const mockFile = new File(['content'], 'test.txt', { type: 'text/plain' });
const mockResponse = { output: 'File received!' };
vi.mocked(api.sendMessage).mockResolvedValueOnce(mockResponse);
await chatStore.sendMessage('Here is a file', [mockFile]);
expect(api.sendMessage).toHaveBeenCalledWith('Here is a file', [mockFile], null, mockOptions);
expect(chatStore.messages.value[0]).toMatchObject({
text: 'Here is a file',
sender: 'user',
files: [mockFile],
});
});
it('should set waitingForResponse correctly', async () => {
const mockResponse = { output: 'Response' };
vi.mocked(api.sendMessage).mockResolvedValueOnce(mockResponse);
expect(chatStore.waitingForResponse.value).toBe(false);
const sendPromise = chatStore.sendMessage('Test');
expect(chatStore.waitingForResponse.value).toBe(true);
await sendPromise;
expect(chatStore.waitingForResponse.value).toBe(false);
});
it('should emit scrollToBottom events', async () => {
const mockResponse = { output: 'Response' };
vi.mocked(api.sendMessage).mockResolvedValueOnce(mockResponse);
await chatStore.sendMessage('Test');
expect(chatEventBus.emit).toHaveBeenCalledWith('scrollToBottom');
expect(chatEventBus.emit).toHaveBeenCalledTimes(2); // Once after user message, once after bot response
});
});
describe('streaming', () => {
let chatStore: Chat;
beforeEach(() => {
mockOptions.enableStreaming = true;
chatStore = setupChatStore(mockOptions);
});
it('should handle streaming messages', async () => {
const mockStreamingResponse = { hasReceivedChunks: true };
vi.mocked(api.sendMessageStreaming).mockResolvedValueOnce(mockStreamingResponse);
await chatStore.sendMessage('Stream this!');
expect(api.sendMessageStreaming).toHaveBeenCalledWith(
'Stream this!',
[],
null,
mockOptions,
expect.objectContaining({
onChunk: expect.any(Function) as StreamingEventHandlers['onChunk'],
onBeginMessage: expect.any(Function) as StreamingEventHandlers['onBeginMessage'],
onEndMessage: expect.any(Function) as StreamingEventHandlers['onEndMessage'],
}),
);
});
it('should handle empty streaming response', async () => {
const mockStreamingResponse = { hasReceivedChunks: false };
vi.mocked(api.sendMessageStreaming).mockResolvedValueOnce(mockStreamingResponse);
await chatStore.sendMessage('Stream this!');
expect(chatStore.messages.value).toHaveLength(2);
expect(chatStore.messages.value[1]).toMatchObject({
text: '[No response received. This could happen if streaming is enabled in the trigger but disabled in agent node(s)]',
sender: 'bot',
});
});
it('should handle streaming errors', async () => {
vi.mocked(api.sendMessageStreaming).mockRejectedValueOnce(new Error('Stream error'));
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
await chatStore.sendMessage('Stream this!');
expect(consoleErrorSpy).toHaveBeenCalledWith('Chat API error:', expect.any(Error));
expect(chatStore.messages.value[1]).toMatchObject({
text: 'Error: Failed to receive response',
sender: 'bot',
});
consoleErrorSpy.mockRestore();
});
it('should handle streaming with files', async () => {
const mockFile = new File(['content'], 'test.txt', { type: 'text/plain' });
const mockStreamingResponse = { hasReceivedChunks: true };
vi.mocked(api.sendMessageStreaming).mockResolvedValueOnce(mockStreamingResponse);
await chatStore.sendMessage('Stream with file', [mockFile]);
expect(api.sendMessageStreaming).toHaveBeenCalledWith(
'Stream with file',
[mockFile],
null,
mockOptions,
expect.objectContaining({
onChunk: expect.any(Function) as StreamingEventHandlers['onChunk'],
onBeginMessage: expect.any(Function) as StreamingEventHandlers['onBeginMessage'],
onEndMessage: expect.any(Function) as StreamingEventHandlers['onEndMessage'],
}),
);
});
});
describe('session management', () => {
let chatStore: Chat;
beforeEach(() => {
mockOptions.loadPreviousSession = true;
chatStore = setupChatStore(mockOptions);
});
it('should load previous session', async () => {
const mockSessionId = 'existing-session';
const mockMessages: LoadPreviousSessionResponse = {
data: [
{
id: ['HumanMessage-1'], // The implementation expects string but types say array
kwargs: { content: 'Previous user message', additional_kwargs: {} },
lc: 1,
type: 'HumanMessage',
},
{
id: ['AIMessage-1'],
kwargs: { content: 'Previous bot message', additional_kwargs: {} },
lc: 1,
type: 'AIMessage',
},
],
};
(window.localStorage.getItem as ReturnType<typeof vi.fn>).mockReturnValueOnce(mockSessionId);
vi.mocked(api.loadPreviousSession).mockResolvedValueOnce(mockMessages);
const sessionId = await chatStore.loadPreviousSession?.();
expect(sessionId).toBe(mockSessionId);
expect(api.loadPreviousSession).toHaveBeenCalledWith(mockSessionId, mockOptions);
expect(chatStore.messages.value).toHaveLength(2);
expect(chatStore.messages.value[0]).toMatchObject({
text: 'Previous user message',
sender: 'bot', // Both will be 'bot' because id is an array, not a string
});
expect(chatStore.messages.value[1]).toMatchObject({
text: 'Previous bot message',
sender: 'bot',
});
expect(chatStore.currentSessionId.value).toBe(mockSessionId);
});
it('should create new session if no previous session exists', async () => {
(window.localStorage.getItem as ReturnType<typeof vi.fn>).mockReturnValueOnce(null);
vi.mocked(api.loadPreviousSession).mockResolvedValueOnce({ data: [] });
const sessionId = await chatStore.loadPreviousSession?.();
expect(sessionId).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i);
expect(chatStore.messages.value).toHaveLength(0);
expect(chatStore.currentSessionId.value).toBe(sessionId);
// eslint-disable-next-line @typescript-eslint/unbound-method
expect(window.localStorage.setItem).toHaveBeenCalledWith(localStorageSessionIdKey, sessionId);
});
it('should preserve manually set sessionId when no messages exist', async () => {
const manualSessionId = '5123f177-df4b-4c0b-b2a1-645432140313';
(window.localStorage.getItem as ReturnType<typeof vi.fn>).mockReturnValueOnce(
manualSessionId,
);
vi.mocked(api.loadPreviousSession).mockResolvedValueOnce({ data: [] });
const sessionId = await chatStore.loadPreviousSession?.();
expect(sessionId).toBe(manualSessionId);
expect(chatStore.currentSessionId.value).toBe(manualSessionId);
expect(chatStore.messages.value).toHaveLength(0);
});
it('should preserve manually set sessionId when messages exist', async () => {
const manualSessionId = '5123f177-df4b-4c0b-b2a1-645432140313';
(window.localStorage.getItem as ReturnType<typeof vi.fn>).mockReturnValueOnce(
manualSessionId,
);
const mockMessages: LoadPreviousSessionResponse = {
data: [
{
id: ['user', 'uuid-1'],
kwargs: { content: 'Hello', additional_kwargs: {} },
lc: 1,
type: 'HumanMessage',
},
],
};
vi.mocked(api.loadPreviousSession).mockResolvedValueOnce(mockMessages);
const sessionId = await chatStore.loadPreviousSession?.();
expect(sessionId).toBe(manualSessionId);
expect(chatStore.currentSessionId.value).toBe(manualSessionId);
expect(chatStore.messages.value).toHaveLength(1);
});
it('should skip loading if loadPreviousSession is false', async () => {
mockOptions.loadPreviousSession = false;
chatStore = setupChatStore(mockOptions);
const result = await chatStore.loadPreviousSession?.();
expect(result).toBeUndefined();
expect(api.loadPreviousSession).not.toHaveBeenCalled();
});
it('should start a new session when localStorage is empty', async () => {
(window.localStorage.getItem as ReturnType<typeof vi.fn>).mockReturnValueOnce(null);
await chatStore.startNewSession?.();
expect(chatStore.currentSessionId.value).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i,
);
// eslint-disable-next-line @typescript-eslint/unbound-method
expect(window.localStorage.setItem).toHaveBeenCalledWith(
localStorageSessionIdKey,
chatStore.currentSessionId.value,
);
});
it('should preserve existing sessionId when starting new session with loadPreviousSession enabled', async () => {
const existingSessionId = '5123f177-df4b-4c0b-b2a1-645432140313';
mockOptions.loadPreviousSession = true;
chatStore = setupChatStore(mockOptions);
(window.localStorage.getItem as ReturnType<typeof vi.fn>).mockReturnValueOnce(
existingSessionId,
);
await chatStore.startNewSession?.();
expect(chatStore.currentSessionId.value).toBe(existingSessionId);
// localStorage.setItem should not be called since sessionId already exists
// eslint-disable-next-line @typescript-eslint/unbound-method
expect(window.localStorage.setItem).not.toHaveBeenCalled();
});
it('should generate new sessionId when loadPreviousSession is disabled', async () => {
const existingSessionId = '5123f177-df4b-4c0b-b2a1-645432140313';
mockOptions.loadPreviousSession = false;
chatStore = setupChatStore(mockOptions);
(window.localStorage.getItem as ReturnType<typeof vi.fn>).mockReturnValueOnce(
existingSessionId,
);
await chatStore.startNewSession?.();
// Should generate new UUID, not preserve existing one
expect(chatStore.currentSessionId.value).not.toBe(existingSessionId);
expect(chatStore.currentSessionId.value).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i,
);
// localStorage.setItem should be called with new sessionId
// eslint-disable-next-line @typescript-eslint/unbound-method
expect(window.localStorage.setItem).toHaveBeenCalledWith(
localStorageSessionIdKey,
chatStore.currentSessionId.value,
);
});
});
describe('initial messages', () => {
it('should compute initial messages from options', () => {
mockOptions.initialMessages = ['Welcome!', 'How can I help you?'];
const chatStore = setupChatStore(mockOptions);
expect(chatStore.initialMessages.value).toHaveLength(2);
expect(chatStore.initialMessages.value[0]).toMatchObject({
text: 'Welcome!',
sender: 'bot',
});
expect(chatStore.initialMessages.value[1]).toMatchObject({
text: 'How can I help you?',
sender: 'bot',
});
});
it('should handle undefined initial messages', () => {
const chatStore = setupChatStore(mockOptions);
expect(chatStore.initialMessages.value).toHaveLength(0);
});
});
describe('beforeMessageSent and afterMessageSent hooks', () => {
it('should call beforeMessageSent before sending (non-streaming)', async () => {
const callOrder: string[] = [];
const beforeMessageSent = vi.fn(() => {
callOrder.push('before');
});
vi.mocked(api.sendMessage).mockImplementation(async () => {
callOrder.push('send');
return { output: 'Response' };
});
const chatStore = setupChatStore({ ...mockOptions, beforeMessageSent });
await chatStore.sendMessage('Test message');
expect(beforeMessageSent).toHaveBeenCalledWith('Test message');
expect(callOrder).toEqual(['before', 'send']);
});
it('should call afterMessageSent after sending (non-streaming)', async () => {
const mockResponse = { output: 'Response' };
const afterMessageSent = vi.fn();
vi.mocked(api.sendMessage).mockResolvedValueOnce(mockResponse);
const chatStore = setupChatStore({
...mockOptions,
webhookConfig: { method: 'POST' },
afterMessageSent,
});
await chatStore.sendMessage('Test message');
expect(afterMessageSent).toHaveBeenCalledWith('Test message', mockResponse);
});
it('should call beforeMessageSent before sending (streaming)', async () => {
const callOrder: string[] = [];
const beforeMessageSent = vi.fn(() => {
callOrder.push('before');
});
vi.mocked(api.sendMessageStreaming).mockImplementation(async () => {
callOrder.push('stream');
return { hasReceivedChunks: true };
});
const chatStore = setupChatStore({
...mockOptions,
enableStreaming: true,
beforeMessageSent,
});
await chatStore.sendMessage('Test message');
expect(beforeMessageSent).toHaveBeenCalledWith('Test message');
expect(callOrder).toEqual(['before', 'stream']);
});
it('should call afterMessageSent after streaming completes', async () => {
const afterMessageSent = vi.fn();
vi.mocked(api.sendMessageStreaming).mockResolvedValueOnce({ hasReceivedChunks: true });
const chatStore = setupChatStore({
...mockOptions,
enableStreaming: true,
afterMessageSent,
});
await chatStore.sendMessage('Test message');
expect(afterMessageSent).toHaveBeenCalledWith(
'Test message',
expect.objectContaining({
hasReceivedChunks: true,
}),
);
});
it('should call hooks in correct order (non-streaming)', async () => {
const callOrder: string[] = [];
const beforeMessageSent = vi.fn(() => {
callOrder.push('before');
});
const afterMessageSent = vi.fn(() => {
callOrder.push('after');
});
vi.mocked(api.sendMessage).mockResolvedValueOnce({ output: 'Response' });
const chatStore = setupChatStore({
...mockOptions,
webhookConfig: { method: 'POST' },
beforeMessageSent,
afterMessageSent,
});
await chatStore.sendMessage('Test message');
expect(callOrder).toEqual(['before', 'after']);
});
it('should call beforeMessageSent with file uploads', async () => {
const beforeMessageSent = vi.fn();
const testFile = new File(['test'], 'test.txt', { type: 'text/plain' });
vi.mocked(api.sendMessage).mockResolvedValueOnce({ output: 'File received' });
const chatStore = setupChatStore({ ...mockOptions, beforeMessageSent });
await chatStore.sendMessage('Test message', [testFile]);
expect(beforeMessageSent).toHaveBeenCalledWith('Test message');
});
});
describe('edge cases', () => {
let chatStore: Chat;
beforeEach(() => {
chatStore = setupChatStore(mockOptions);
});
it('should handle sending message with null session ID', async () => {
const mockResponse = { output: 'Response' };
vi.mocked(api.sendMessage).mockResolvedValueOnce(mockResponse);
chatStore.currentSessionId.value = null;
await chatStore.sendMessage('Test');
expect(api.sendMessage).toHaveBeenCalledWith('Test', [], null, mockOptions);
});
it('should handle empty text message', async () => {
const mockResponse = { output: 'Response' };
vi.mocked(api.sendMessage).mockResolvedValueOnce(mockResponse);
await chatStore.sendMessage('');
expect(chatStore.messages.value[0]).toMatchObject({
text: '',
sender: 'user',
});
});
it('should handle streaming with existing bot messages', async () => {
mockOptions.enableStreaming = true;
chatStore = setupChatStore(mockOptions);
// Add an existing bot message
chatStore.messages.value.push({
id: 'existing',
text: 'Existing message',
sender: 'bot',
});
const mockStreamingResponse = { hasReceivedChunks: false };
vi.mocked(api.sendMessageStreaming).mockResolvedValueOnce(mockStreamingResponse);
await chatStore.sendMessage('Test');
// Should still add error message even with existing bot messages
const lastMessage = chatStore.messages.value[chatStore.messages.value.length - 1];
assert(lastMessage.type === 'text');
expect(lastMessage.text).toBe(
'[No response received. This could happen if streaming is enabled in the trigger but disabled in agent node(s)]',
);
});
it('should return response when executionStarted is true', async () => {
const mockResponse = {
executionStarted: true,
executionId: '12345',
};
vi.mocked(api.sendMessage).mockResolvedValueOnce(mockResponse);
const result = await chatStore.sendMessage('Execute workflow');
expect(result).toEqual(mockResponse);
// Should only have the user message, no bot response
expect(chatStore.messages.value).toHaveLength(1);
expect(chatStore.messages.value[0]).toMatchObject({
text: 'Execute workflow',
sender: 'user',
});
});
it('should handle message field in response', async () => {
const mockResponse = { message: 'Response from message field' };
vi.mocked(api.sendMessage).mockResolvedValueOnce(mockResponse);
await chatStore.sendMessage('Test message field');
expect(chatStore.messages.value[1]).toMatchObject({
text: 'Response from message field',
sender: 'bot',
});
});
});
});

View File

@@ -0,0 +1,66 @@
import { vi, describe, it, expect } from 'vitest';
import { createApp } from 'vue';
import * as api from '@n8n/chat/api';
import { ChatPlugin } from '../../plugins/chat';
vi.mock('@n8n/chat/api');
describe('ChatPlugin', () => {
it('should return sendMessageResponse when executionStarted is true', async () => {
const app = createApp({});
const options = {
webhookUrl: 'test',
i18n: {
en: {
message: 'message',
title: 'title',
subtitle: 'subtitle',
footer: 'footer',
getStarted: 'getStarted',
inputPlaceholder: 'inputPlaceholder',
closeButtonTooltip: 'closeButtonTooltip',
},
},
};
(api.sendMessage as jest.Mock).mockResolvedValue({ executionStarted: true });
app.use(ChatPlugin, options);
const chatStore = app.config.globalProperties.$chat;
const result = await chatStore.sendMessage('test message');
expect(result).toEqual({ executionStarted: true });
});
it('should return null when sendMessageResponse is null', async () => {
const app = createApp({});
const options = {
webhookUrl: 'test',
i18n: {
en: {
message: 'message',
title: 'title',
subtitle: 'subtitle',
footer: 'footer',
getStarted: 'getStarted',
inputPlaceholder: 'inputPlaceholder',
closeButtonTooltip: 'closeButtonTooltip',
},
},
};
(api.sendMessage as jest.Mock).mockResolvedValue({});
app.use(ChatPlugin, options);
const chatStore = app.config.globalProperties.$chat;
const result = await chatStore.sendMessage('test message');
expect(result).toEqual(null);
});
});

View File

@@ -0,0 +1,12 @@
import '@testing-library/jest-dom';
import { configure } from '@testing-library/vue';
configure({ testIdAttribute: 'data-test-id' });
window.ResizeObserver =
window.ResizeObserver ||
vi.fn().mockImplementation(() => ({
disconnect: vi.fn(),
observe: vi.fn(),
unobserve: vi.fn(),
}));

View File

@@ -0,0 +1,16 @@
import { createChat } from '@n8n/chat/index';
export function createTestChat(options: Parameters<typeof createChat>[0] = {}): {
unmount: () => void;
container: Element;
} {
const app = createChat(options);
const container = app._container as Element;
const unmount = () => app.unmount();
return {
unmount,
container,
};
}

View File

@@ -0,0 +1,52 @@
import type { LoadPreviousSessionResponse, SendMessageResponse } from '@n8n/chat/types';
export function createFetchResponse<T>(data: T) {
const jsonData = JSON.stringify(data);
return async () =>
({
json: async () => await new Promise<T>((resolve) => resolve(data)),
text: async () => jsonData,
clone() {
return this;
},
}) as unknown as Response;
}
export const createGetLatestMessagesResponse = (
data: LoadPreviousSessionResponse['data'] = [],
): LoadPreviousSessionResponse => ({ data });
export const createSendMessageResponse = (
output: SendMessageResponse['output'],
): SendMessageResponse => ({
output,
});
export function createMockStreamingFetchResponse(
chunks: Array<{
type: string;
content?: string;
metadata?: { nodeId: string; nodeName: string; timestamp: number };
}>,
) {
return async () => {
const encoder = new TextEncoder();
const stream = new ReadableStream({
start(controller) {
chunks.forEach((chunk) => {
const data = JSON.stringify(chunk) + '\n';
controller.enqueue(encoder.encode(data));
});
controller.close();
},
});
return {
ok: true,
status: 200,
body: stream,
headers: new Headers(),
} as Response;
};
}

View File

@@ -0,0 +1,3 @@
export * from './create';
export * from './fetch';
export * from './selectors';

View File

@@ -0,0 +1,54 @@
import { screen } from '@testing-library/vue';
import { defaultMountingTarget } from '@n8n/chat/constants';
export function getMountingTarget(target = defaultMountingTarget) {
return document.querySelector(target);
}
export function getChatWindowWrapper() {
return document.querySelector('.chat-window-wrapper');
}
export function getChatWindowToggle() {
return document.querySelector('.chat-window-toggle');
}
export function getChatWrapper() {
return document.querySelector('.chat-wrapper');
}
export function getChatMessages() {
return document.querySelectorAll('.chat-message:not(.chat-message-typing)');
}
export function getChatMessage(index: number) {
const messages = getChatMessages();
return index < 0 ? messages[messages.length + index] : messages[index];
}
export function getChatMessageByText(text: string) {
return screen.queryByText(text, {
selector: '.chat-message:not(.chat-message-typing) .chat-message-markdown p',
});
}
export function getChatMessageTyping() {
return document.querySelector('.chat-message-typing');
}
export function getGetStartedButton() {
return document.querySelector('.chat-get-started .chat-button');
}
export function getChatInput() {
return document.querySelector('.chat-input');
}
export function getChatInputTextarea() {
return document.querySelector('.chat-input textarea');
}
export function getChatInputSendButton() {
return document.querySelector('.chat-input .chat-input-send-button');
}

View File

@@ -0,0 +1,181 @@
import { describe, expect, it } from 'vitest';
import type { ChatMessageText } from '@n8n/chat/types';
import {
StreamingMessageManager,
createBotMessage,
updateMessageInArray,
} from '@n8n/chat/utils/streaming';
describe('StreamingMessageManager', () => {
it('should initialize runs correctly', () => {
const manager = new StreamingMessageManager();
const message1 = manager.initializeRun('node-1', 0);
const message2 = manager.initializeRun('node-1', 1);
expect(manager.getRunCount()).toBe(2);
expect(message1.id).toBeDefined();
expect(message2.id).toBeDefined();
expect(message1.id).not.toBe(message2.id);
});
it('should create separate messages for different runs', () => {
const manager = new StreamingMessageManager();
// Initialize two different runs
const message1 = manager.addRunToActive('node-1', 0);
const message2 = manager.addRunToActive('node-1', 1);
expect(manager.getRunCount()).toBe(2);
expect(message1.id).not.toBe(message2.id);
// Add chunks to different runs
const result1 = manager.addChunkToRun('node-1', 'Run 0 content', 0);
const result2 = manager.addChunkToRun('node-1', 'Run 1 content', 1);
expect(result1?.text).toBe('Run 0 content');
expect(result2?.text).toBe('Run 1 content');
expect(result1?.id).toBe(message1.id);
expect(result2?.id).toBe(message2.id);
});
it('should accumulate chunks within the same run', () => {
const manager = new StreamingMessageManager();
const message = manager.addRunToActive('node-1', 0);
manager.addChunkToRun('node-1', 'Hello ', 0);
const result = manager.addChunkToRun('node-1', 'World!', 0);
expect(result?.text).toBe('Hello World!');
expect(result?.id).toBe(message.id);
});
it('should handle runs without runIndex (backward compatibility)', () => {
const manager = new StreamingMessageManager();
const message = manager.addRunToActive('node-1');
const result = manager.addChunkToRun('node-1', 'Single run content');
expect(result?.text).toBe('Single run content');
expect(result?.id).toBe(message.id);
expect(manager.getRunCount()).toBe(1);
});
it('should track active runs correctly', () => {
const manager = new StreamingMessageManager();
manager.addRunToActive('node-1', 0);
manager.addRunToActive('node-1', 1);
expect(manager.getRunCount()).toBe(2);
manager.removeRunFromActive('node-1', 0);
expect(manager.areAllRunsComplete()).toBe(false);
manager.removeRunFromActive('node-1', 1);
expect(manager.areAllRunsComplete()).toBe(true);
});
it('should return all messages in order', () => {
const manager = new StreamingMessageManager();
const message1 = manager.addRunToActive('node-1', 0);
const message2 = manager.addRunToActive('node-1', 1);
const message3 = manager.addRunToActive('node-2', 0);
const allMessages = manager.getAllMessages();
expect(allMessages).toHaveLength(3);
expect(allMessages[0].id).toBe(message1.id);
expect(allMessages[1].id).toBe(message2.id);
expect(allMessages[2].id).toBe(message3.id);
});
it('should reset correctly', () => {
const manager = new StreamingMessageManager();
manager.addRunToActive('node-1', 0);
manager.addRunToActive('node-1', 1);
manager.addChunkToRun('node-1', 'test', 0);
expect(manager.getRunCount()).toBe(2);
manager.reset();
expect(manager.getRunCount()).toBe(0);
expect(manager.getAllMessages()).toHaveLength(0);
});
});
describe('createBotMessage', () => {
it('should create a bot message with default values', () => {
const message = createBotMessage();
expect(message.type).toBe('text');
expect(message.text).toBe('');
expect(message.sender).toBe('bot');
expect(message.id).toBeDefined();
});
it('should create a bot message with custom id', () => {
const customId = 'custom-id-123';
const message = createBotMessage(customId);
expect(message.id).toBe(customId);
});
});
describe('updateMessageInArray', () => {
it('should update message in array', () => {
const messages: ChatMessageText[] = [
{
id: 'msg-1',
type: 'text',
text: 'Hello',
sender: 'bot',
},
{
id: 'msg-2',
type: 'text',
text: 'World',
sender: 'user',
},
];
const updatedMessage: ChatMessageText = {
id: 'msg-1',
type: 'text',
text: 'Hello Updated',
sender: 'bot',
};
updateMessageInArray(messages, 'msg-1', updatedMessage);
expect(messages[0].text).toBe('Hello Updated');
expect(messages[1].text).toBe('World'); // Should remain unchanged
});
it('should throw error on non-existent message id', () => {
const messages: ChatMessageText[] = [
{
id: 'msg-1',
type: 'text',
text: 'Hello',
sender: 'bot',
},
];
const updatedMessage: ChatMessageText = {
id: 'non-existent',
type: 'text',
text: 'Should not be added',
sender: 'bot',
};
expect(() => updateMessageInArray(messages, 'non-existent', updatedMessage)).toThrow(
"Can't update message. No message with id non-existent found",
);
});
});

View File

@@ -0,0 +1,256 @@
import { describe, expect, it, vi, beforeEach } from 'vitest';
import { ref, type Ref } from 'vue';
import type { ChatMessage, ChatMessageText, ChatOptions } from '@n8n/chat/types';
import { StreamingMessageManager } from '@n8n/chat/utils/streaming';
import {
handleStreamingChunk,
handleNodeStart,
handleNodeComplete,
} from '@n8n/chat/utils/streamingHandlers';
// Mock the chatEventBus
vi.mock('@n8n/chat/event-buses', () => ({
chatEventBus: {
emit: vi.fn(),
},
}));
describe('streamingHandlers', () => {
let messages: Ref<ChatMessage[]>;
let receivedMessage: Ref<ChatMessageText | null>;
let streamingManager: StreamingMessageManager;
beforeEach(() => {
messages = ref<ChatMessage[]>([]);
receivedMessage = ref<ChatMessageText | null>(null);
streamingManager = new StreamingMessageManager();
vi.clearAllMocks();
});
describe('handleStreamingChunk', () => {
it('should handle single-node streaming (no nodeId)', () => {
handleStreamingChunk('Hello', undefined, streamingManager, receivedMessage, messages);
expect(receivedMessage.value).toBeDefined();
expect(receivedMessage.value?.text).toBe('Hello');
expect(messages.value).toHaveLength(1);
handleStreamingChunk(' World!', undefined, streamingManager, receivedMessage, messages);
expect(receivedMessage.value?.text).toBe('Hello World!');
expect(messages.value).toHaveLength(1);
});
it('should handle streaming with separate messages per runIndex', () => {
// Start the runs (doesn't create messages yet)
handleNodeStart('node-1', streamingManager, 0);
handleNodeStart('node-1', streamingManager, 1);
expect(messages.value).toHaveLength(0); // No messages created yet
// Now handle chunks for different runs - this will create the messages
handleStreamingChunk(
'Run 0 content',
'node-1',
streamingManager,
receivedMessage,
messages,
0,
);
handleStreamingChunk(
'Run 1 content',
'node-1',
streamingManager,
receivedMessage,
messages,
1,
);
expect(messages.value).toHaveLength(2); // Messages created on first chunk
// Check that we have two separate messages with different content
const message1 = messages.value[0] as ChatMessageText;
const message2 = messages.value[1] as ChatMessageText;
expect(message1.text).toBe('Run 0 content');
expect(message2.text).toBe('Run 1 content');
expect(message1.id).not.toBe(message2.id);
});
it('should accumulate chunks within the same run', () => {
// Start a run (doesn't create message yet)
handleNodeStart('node-1', streamingManager, 0);
expect(messages.value).toHaveLength(0);
// Add multiple chunks to the same run - message created on first chunk
handleStreamingChunk('Hello ', 'node-1', streamingManager, receivedMessage, messages, 0);
expect(messages.value).toHaveLength(1);
handleStreamingChunk('World!', 'node-1', streamingManager, receivedMessage, messages, 0);
const message = messages.value[0] as ChatMessageText;
expect(message.text).toBe('Hello World!');
expect(messages.value).toHaveLength(1);
});
it('should handle errors gracefully', () => {
// Simulate an error by passing invalid parameters
const invalidStreamingManager = null as unknown as StreamingMessageManager;
expect(() => {
handleStreamingChunk('test', 'node-1', invalidStreamingManager, receivedMessage, messages);
}).not.toThrow();
});
});
describe('handleNodeStart', () => {
it('should register runs but not create messages yet', () => {
handleNodeStart('node-1', streamingManager, 0);
handleNodeStart('node-1', streamingManager, 1);
// No messages created yet - they'll be created on first chunk
expect(messages.value).toHaveLength(0);
// But runs should be registered as active
// We can verify this by checking that chunks will create messages
handleStreamingChunk('test', 'node-1', streamingManager, receivedMessage, messages, 0);
expect(messages.value).toHaveLength(1);
});
it('should handle runs without runIndex', () => {
handleNodeStart('node-1', streamingManager);
expect(messages.value).toHaveLength(0);
// Verify run is registered by adding a chunk
handleStreamingChunk('test', 'node-1', streamingManager, receivedMessage, messages);
expect(messages.value).toHaveLength(1);
});
it('should handle errors gracefully', () => {
const invalidStreamingManager = null as unknown as StreamingMessageManager;
expect(() => {
handleNodeStart('node-1', invalidStreamingManager);
}).not.toThrow();
});
});
describe('handleNodeComplete', () => {
const mockOptions: ChatOptions = {
webhookUrl: 'http://test.com',
i18n: {
en: {
title: '',
subtitle: '',
footer: '',
getStarted: '',
inputPlaceholder: '',
closeButtonTooltip: '',
},
},
};
const userMessage = 'test message';
it('should mark run as complete', async () => {
// Setup initial state
streamingManager.addRunToActive('node-1', 0);
await handleNodeComplete('node-1', streamingManager, 0, userMessage, mockOptions, messages);
expect(streamingManager.areAllRunsComplete()).toBe(true);
});
it('should handle multiple runs completion', async () => {
// Setup two runs
streamingManager.addRunToActive('node-1', 0);
streamingManager.addRunToActive('node-1', 1);
// Complete first run
await handleNodeComplete('node-1', streamingManager, 0, userMessage, mockOptions, messages);
expect(streamingManager.areAllRunsComplete()).toBe(false);
// Complete second run
await handleNodeComplete('node-1', streamingManager, 1, userMessage, mockOptions, messages);
expect(streamingManager.areAllRunsComplete()).toBe(true);
});
it('should handle runs without runIndex', async () => {
streamingManager.addRunToActive('node-1');
await handleNodeComplete(
'node-1',
streamingManager,
undefined,
userMessage,
mockOptions,
messages,
);
expect(streamingManager.areAllRunsComplete()).toBe(true);
});
it('should handle errors gracefully', async () => {
const invalidStreamingManager = null as unknown as StreamingMessageManager;
await expect(
handleNodeComplete(
'node-1',
invalidStreamingManager,
undefined,
userMessage,
mockOptions,
messages,
),
).resolves.not.toThrow();
});
it('should call afterMessageSent hook when provided', async () => {
const afterMessageSent = vi.fn();
const optionsWithHook: ChatOptions = {
...mockOptions,
afterMessageSent,
};
// Setup and add a message
streamingManager.addRunToActive('node-1', 0);
const message = streamingManager.getRunMessage('node-1', 0);
await handleNodeComplete(
'node-1',
streamingManager,
0,
userMessage,
optionsWithHook,
messages,
);
expect(afterMessageSent).toHaveBeenCalledWith(userMessage, {
message,
hasReceivedChunks: true,
});
});
it('should not call afterMessageSent hook if no message exists', async () => {
const afterMessageSent = vi.fn();
const optionsWithHook: ChatOptions = {
...mockOptions,
afterMessageSent,
};
// Don't add any message
await handleNodeComplete(
'node-1',
streamingManager,
0,
userMessage,
optionsWithHook,
messages,
);
expect(afterMessageSent).not.toHaveBeenCalled();
});
});
});

View File

@@ -0,0 +1,79 @@
import { MessageComponentKey } from '@n8n/chat/constants';
import { parseBotChatMessageContent, shouldBlockUserInput } from '@n8n/chat/utils';
describe('utils', () => {
describe('parseBotChatMessageContent', () => {
it('should return a string for a non-JSON message', () => {
const message = parseBotChatMessageContent('test');
expect(message).toEqual({
id: expect.any(String),
sender: 'bot',
text: 'test',
});
});
it('should parse a message with buttons', () => {
const jsonMessage = {
type: 'with-buttons',
text: 'test',
buttons: [{ text: 'Approve', link: 'https://yes.com', type: 'primary' }],
blockUserInput: true,
};
const message = parseBotChatMessageContent(JSON.stringify(jsonMessage));
expect(message).toEqual({
id: expect.any(String),
sender: 'bot',
type: 'component',
key: MessageComponentKey.WITH_BUTTONS,
arguments: {
text: 'test',
buttons: [{ text: 'Approve', link: 'https://yes.com', type: 'primary' }],
blockUserInput: true,
},
});
});
});
describe('shouldBlockUserInput', () => {
it('should return true for message with buttons and when blockUserInput is true', () => {
const message = {
id: '1',
sender: 'bot' as const,
type: 'component' as const,
key: MessageComponentKey.WITH_BUTTONS,
arguments: { blockUserInput: true },
};
const result = shouldBlockUserInput(message);
expect(result).toBe(true);
});
it('should return false for message with buttons and when blockUserInput is false', () => {
const message = {
id: '1',
sender: 'bot' as const,
type: 'component' as const,
key: MessageComponentKey.WITH_BUTTONS,
arguments: { blockUserInput: false },
};
const result = shouldBlockUserInput(message);
expect(result).toBe(false);
});
it('should return false for regular message', () => {
const message = {
id: '1',
sender: 'bot' as const,
text: 'test',
};
const result = shouldBlockUserInput(message);
expect(result).toBe(false);
});
});
});

View File

@@ -0,0 +1,106 @@
async function getAccessToken() {
return '';
}
export async function authenticatedFetch<T>(...args: Parameters<typeof fetch>): Promise<T> {
const accessToken = await getAccessToken();
const body = args[1]?.body;
const headers: RequestInit['headers'] & { 'Content-Type'?: string } = {
...(accessToken ? { authorization: `Bearer ${accessToken}` } : {}),
...args[1]?.headers,
};
// Automatically set content type to application/json if body is FormData
if (body instanceof FormData) {
delete headers['Content-Type'];
} else {
headers['Content-Type'] = 'application/json';
}
const response = await fetch(args[0], {
...args[1],
mode: 'cors',
cache: 'no-cache',
headers,
});
let responseData;
try {
responseData = await response.clone().json();
} catch (error) {
responseData = await response.text();
}
return responseData as T;
}
export async function get<T>(url: string, query: object = {}, options: RequestInit = {}) {
let resolvedUrl = url;
if (Object.keys(query).length > 0) {
resolvedUrl = `${resolvedUrl}?${new URLSearchParams(
query as Record<string, string>,
).toString()}`;
}
return await authenticatedFetch<T>(resolvedUrl, { ...options, method: 'GET' });
}
export async function post<T>(url: string, body: object = {}, options: RequestInit = {}) {
return await authenticatedFetch<T>(url, {
...options,
method: 'POST',
body: JSON.stringify(body),
});
}
export async function postWithFiles<T>(
url: string,
body: Record<string, string | object> = {},
files: File[] = [],
options: RequestInit = {},
) {
const formData = new FormData();
for (const key in body) {
const value = body[key];
if (typeof value === 'object' && value !== null) {
formData.append(key, JSON.stringify(value));
} else {
formData.append(key, value);
}
}
for (const file of files) {
formData.append('files', file);
}
return await authenticatedFetch<T>(url, {
...options,
method: 'POST',
body: formData,
});
}
export async function put<T>(url: string, body: object = {}, options: RequestInit = {}) {
return await authenticatedFetch<T>(url, {
...options,
method: 'PUT',
body: JSON.stringify(body),
});
}
export async function patch<T>(url: string, body: object = {}, options: RequestInit = {}) {
return await authenticatedFetch<T>(url, {
...options,
method: 'PATCH',
body: JSON.stringify(body),
});
}
export async function del<T>(url: string, body: object = {}, options: RequestInit = {}) {
return await authenticatedFetch<T>(url, {
...options,
method: 'DELETE',
body: JSON.stringify(body),
});
}

View File

@@ -0,0 +1,2 @@
export * from './generic';
export * from './message';

View File

@@ -0,0 +1,237 @@
import { get, post, postWithFiles } from '@n8n/chat/api/generic';
import type {
ChatOptions,
LoadPreviousSessionResponse,
SendMessageResponse,
StructuredChunk,
} from '@n8n/chat/types';
export async function loadPreviousSession(sessionId: string, options: ChatOptions) {
const method = options.webhookConfig?.method === 'POST' ? post : get;
return await method<LoadPreviousSessionResponse>(
`${options.webhookUrl}`,
{
action: 'loadPreviousSession',
[options.chatSessionKey as string]: sessionId,
...(options.metadata ? { metadata: options.metadata } : {}),
},
{
headers: options.webhookConfig?.headers,
},
);
}
export async function sendMessage(
message: string,
files: File[],
sessionId: string,
options: ChatOptions,
) {
let response: SendMessageResponse;
if (files.length > 0) {
response = await postWithFiles<SendMessageResponse>(
`${options.webhookUrl}`,
{
action: 'sendMessage',
[options.chatSessionKey as string]: sessionId,
[options.chatInputKey as string]: message,
...(options.metadata ? { metadata: options.metadata } : {}),
},
files,
{
headers: options.webhookConfig?.headers,
},
);
} else {
const method = options.webhookConfig?.method === 'POST' ? post : get;
response = await method<SendMessageResponse>(
`${options.webhookUrl}`,
{
action: 'sendMessage',
[options.chatSessionKey as string]: sessionId,
[options.chatInputKey as string]: message,
...(options.metadata ? { metadata: options.metadata } : {}),
},
{
headers: options.webhookConfig?.headers,
},
);
}
// Call afterMessageSent handler if provided
if (options.afterMessageSent) {
await options.afterMessageSent(message, response);
}
return response;
}
// Create a transform stream that parses newline-delimited JSON
function createLineParser(): TransformStream<Uint8Array, StructuredChunk> {
let buffer = '';
const decoder = new TextDecoder();
return new TransformStream({
transform(chunk, controller) {
buffer += decoder.decode(chunk, { stream: true });
// Process all complete lines in the buffer
const lines = buffer.split('\n');
buffer = lines.pop() ?? ''; // Keep incomplete line in buffer
for (const line of lines) {
if (line.trim()) {
try {
const parsed = JSON.parse(line) as StructuredChunk;
controller.enqueue(parsed);
} catch (error) {
// Handle non-JSON lines as plain text
controller.enqueue({
type: 'item',
content: line,
} as StructuredChunk);
}
}
}
},
flush(controller) {
// Process any remaining buffer content
if (buffer.trim()) {
try {
const parsed = JSON.parse(buffer) as StructuredChunk;
controller.enqueue(parsed);
} catch (error) {
controller.enqueue({
type: 'item',
content: buffer,
} as StructuredChunk);
}
}
},
});
}
export interface StreamingEventHandlers {
onBeginMessage: (nodeId: string, runIndex?: number) => void;
onChunk: (chunk: string, nodeId?: string, runIndex?: number) => void;
onEndMessage: (nodeId: string, runIndex?: number) => void | Promise<void>;
}
export async function sendMessageStreaming(
message: string,
files: File[],
sessionId: string,
options: ChatOptions,
handlers: StreamingEventHandlers,
): Promise<{ hasReceivedChunks: boolean }> {
// Build request
const response = await (files.length > 0
? sendWithFiles(message, files, sessionId, options)
: sendTextOnly(message, sessionId, options));
if (!response.ok) {
const errorText = await response.text();
console.error('HTTP error response:', response.status, errorText);
throw new Error(`Error while sending message. Error: ${errorText}`);
}
if (!response.body) {
throw new Error('Response body is not readable');
}
// Process the stream
const reader = response.body.pipeThrough(createLineParser()).getReader();
let hasReceivedChunks = false;
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
const nodeId = value.metadata?.nodeId || 'unknown';
const runIndex = value.metadata?.runIndex;
switch (value.type) {
case 'begin':
handlers.onBeginMessage(nodeId, runIndex);
break;
case 'item':
hasReceivedChunks = true;
handlers.onChunk(value.content ?? '', nodeId, runIndex);
break;
case 'end':
await handlers.onEndMessage(nodeId, runIndex);
break;
case 'error':
hasReceivedChunks = true;
handlers.onChunk(`Error: ${value.content ?? 'Unknown error'}`, nodeId, runIndex);
await handlers.onEndMessage(nodeId, runIndex);
break;
}
}
} finally {
reader.releaseLock();
}
return { hasReceivedChunks };
}
// Helper function for file uploads
async function sendWithFiles(
message: string,
files: File[],
sessionId: string,
options: ChatOptions,
): Promise<Response> {
const formData = new FormData();
formData.append('action', 'sendMessage');
formData.append(options.chatSessionKey as string, sessionId);
formData.append(options.chatInputKey as string, message);
if (options.metadata) {
formData.append('metadata', JSON.stringify(options.metadata));
}
for (const file of files) {
formData.append('files', file);
}
// Exclude Content-Type to let the browser set it with the multipart boundary
const headers: Record<string, string> = {
Accept: 'text/plain',
...options.webhookConfig?.headers,
};
delete headers['Content-Type'];
return await fetch(options.webhookUrl, {
method: 'POST',
headers,
body: formData,
});
}
// Helper function for text-only messages
async function sendTextOnly(
message: string,
sessionId: string,
options: ChatOptions,
): Promise<Response> {
const body = {
action: 'sendMessage',
[options.chatSessionKey as string]: sessionId,
[options.chatInputKey as string]: message,
...(options.metadata ? { metadata: options.metadata } : {}),
};
return await fetch(options.webhookUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'text/plain',
...options.webhookConfig?.headers,
},
body: JSON.stringify(body),
});
}

View File

@@ -0,0 +1,96 @@
<script setup lang="ts">
import { computed } from 'vue';
defineOptions({ inheritAttrs: false });
const props = withDefaults(
defineProps<{
type?: 'primary' | 'secondary';
element?: 'button' | 'a';
disabled?: boolean;
}>(),
{
type: 'primary',
element: 'button',
disabled: false,
},
);
const buttonTypeClass = computed(() => {
return `chat-button-${props.type}${props.disabled ? '-disabled' : ''}`;
});
</script>
<template>
<span :class="{ 'chat-button-wrapper-disabled': props.disabled }">
<component :is="element" :class="['chat-button', buttonTypeClass]" v-bind="$attrs">
<slot />
</component>
</span>
</template>
<style lang="scss">
.chat-button-wrapper-disabled {
cursor: not-allowed;
}
.chat-button {
display: inline-flex;
text-align: center;
vertical-align: middle;
user-select: none;
padding: var(--chat--button--padding);
font-size: var(--chat--button--font-size);
line-height: var(--chat--button--line-height);
border-radius: var(--chat--button--border-radius);
transition:
color var(--chat--transition-duration) ease-in-out,
background-color var(--chat--transition-duration) ease-in-out,
border-color var(--chat--transition-duration) ease-in-out,
box-shadow var(--chat--transition-duration) ease-in-out;
cursor: pointer;
text-decoration: none;
&:focus {
outline: 0;
box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.25);
}
}
.chat-button-primary {
color: var(--chat--button--color--primary);
background-color: var(--chat--button--background--primary);
border: var(--chat--button--border--primary);
&:hover {
color: var(--chat--button--color--primary--hover);
background-color: var(--chat--button--background--primary--hover);
border: var(--chat--button--border--primary--hover);
}
}
.chat-button-primary-disabled {
pointer-events: none;
color: var(--chat--button--color--primary--disabled);
background-color: var(--chat--button--background--primary--disabled);
border: var(--chat--button--border--primary--disabled);
}
.chat-button-secondary {
color: var(--chat--button--color--secondary);
background-color: var(--chat--button--background--secondary);
border: var(--chat--button--border--secondary);
&:hover {
color: var(--chat--button--color--secondary--hover);
background-color: var(--chat--button--background--secondary--hover);
border: var(--chat--button--border--secondary--hover);
}
}
.chat-button-secondary-disabled {
pointer-events: none;
color: var(--chat--button--color--secondary--disabled);
background-color: var(--chat--button--background--secondary--disabled);
border: var(--chat--button--border--secondary--disabled);
}
</style>

View File

@@ -0,0 +1,173 @@
<script setup lang="ts">
import Close from 'virtual:icons/mdi/close';
import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue';
import GetStarted from '@n8n/chat/components/GetStarted.vue';
import GetStartedFooter from '@n8n/chat/components/GetStartedFooter.vue';
import Input from '@n8n/chat/components/Input.vue';
import type { ArrowKeyDownPayload } from '@n8n/chat/components/Input.vue';
import Layout from '@n8n/chat/components/Layout.vue';
import MessagesList from '@n8n/chat/components/MessagesList.vue';
import { useI18n, useChat, useOptions } from '@n8n/chat/composables';
import { chatEventBus } from '@n8n/chat/event-buses';
const { t } = useI18n();
const chatStore = useChat();
const { messages, currentSessionId } = chatStore;
const { options } = useOptions();
const showCloseButton = computed(() => options.mode === 'window' && options.showWindowCloseButton);
// Message history navigation
const messageHistoryIndex = ref(-1);
const currentInputBuffer = ref('');
const userMessages = computed(() =>
messages.value
.filter((m) => m.sender === 'user')
.map((m) => ('text' in m && typeof m.text === 'string' ? m.text : '')),
);
function getStarted() {
if (!chatStore.startNewSession) {
return;
}
void chatStore.startNewSession();
void nextTick(() => {
chatEventBus.emit('scrollToBottom');
});
}
async function initialize() {
if (!chatStore.loadPreviousSession) {
return;
}
await chatStore.loadPreviousSession();
void nextTick(() => {
chatEventBus.emit('scrollToBottom');
});
}
function closeChat() {
chatEventBus.emit('close');
}
function onArrowKeyDown(payload: ArrowKeyDownPayload) {
const userMessagesList = userMessages.value;
if (userMessagesList.length === 0) {
return;
}
// Save current input if we're starting navigation
if (messageHistoryIndex.value === -1 && payload.currentInputValue.length > 0) {
currentInputBuffer.value = payload.currentInputValue;
}
if (payload.key === 'ArrowUp') {
// Temporarily blur to avoid cursor position issues
chatEventBus.emit('blurInput');
// Navigate to previous message
if (messageHistoryIndex.value < userMessagesList.length - 1) {
messageHistoryIndex.value++;
const messageText = userMessagesList[userMessagesList.length - 1 - messageHistoryIndex.value];
chatEventBus.emit('setInputValue', messageText);
}
// Refocus and move cursor to end
chatEventBus.emit('focusInput');
} else if (payload.key === 'ArrowDown') {
// Only navigate if we're in history mode
if (messageHistoryIndex.value === -1) return;
// Temporarily blur to avoid cursor position issues
chatEventBus.emit('blurInput');
// Navigate to next message or restore original input
if (messageHistoryIndex.value > 0) {
messageHistoryIndex.value--;
const messageText = userMessagesList[userMessagesList.length - 1 - messageHistoryIndex.value];
chatEventBus.emit('setInputValue', messageText);
} else if (messageHistoryIndex.value === 0) {
// Reached the end - restore original input or clear
messageHistoryIndex.value = -1;
chatEventBus.emit('setInputValue', currentInputBuffer.value);
currentInputBuffer.value = '';
}
// Refocus and move cursor to end
chatEventBus.emit('focusInput');
}
}
let clearOnMessageSent: () => void;
onMounted(async () => {
if (!messages.value.length && options.messageHistory) {
messages.value = options.messageHistory.map((m) => ({ ...m }));
}
await initialize();
if (!options.showWelcomeScreen && !currentSessionId.value) {
getStarted();
}
// Reset history index and buffer when a new message is sent
clearOnMessageSent = chatEventBus.on('messageSent', () => {
messageHistoryIndex.value = -1;
currentInputBuffer.value = '';
});
});
onUnmounted(() => {
if (clearOnMessageSent) {
clearOnMessageSent();
}
});
</script>
<template>
<Layout class="chat-wrapper">
<template #header>
<div class="chat-heading">
<h1>
{{ t('title') }}
</h1>
<button
v-if="showCloseButton"
class="chat-close-button"
:title="t('closeButtonTooltip')"
@click="closeChat"
>
<Close height="18" width="18" />
</button>
</div>
<p v-if="t('subtitle')">{{ t('subtitle') }}</p>
</template>
<GetStarted v-if="!currentSessionId && options.showWelcomeScreen" @click:button="getStarted" />
<MessagesList v-else :messages="messages" />
<template #footer>
<Input v-if="currentSessionId" @arrow-key-down="onArrowKeyDown" />
<GetStartedFooter v-else />
</template>
</Layout>
</template>
<style lang="scss">
.chat-heading {
display: flex;
justify-content: space-between;
align-items: center;
}
.chat-close-button {
display: flex;
border: none;
background: none;
cursor: pointer;
&:hover {
color: var(--chat--close--button--color-hover, var(--chat--color--primary));
}
}
</style>

View File

@@ -0,0 +1,123 @@
<script setup lang="ts">
import IconDelete from 'virtual:icons/mdi/closeThick';
import IconFileImage from 'virtual:icons/mdi/fileImage';
import IconFileMusic from 'virtual:icons/mdi/fileMusic';
import IconFileText from 'virtual:icons/mdi/fileText';
import IconFileVideo from 'virtual:icons/mdi/fileVideo';
import IconPreview from 'virtual:icons/mdi/openInNew';
import { computed, type FunctionalComponent } from 'vue';
const props = defineProps<{
file: File;
isRemovable: boolean;
isPreviewable?: boolean;
href?: string;
}>();
const emit = defineEmits<{
remove: [value: File];
}>();
const iconMapper: Record<string, FunctionalComponent> = {
document: IconFileText,
audio: IconFileMusic,
image: IconFileImage,
video: IconFileVideo,
};
const TypeIcon = computed(() => {
const type = props.file?.type.split('/')[0];
return iconMapper[type] || IconFileText;
});
function onClick() {
if (props.href) {
window.open(props.href, '_blank', 'noopener noreferrer');
return;
}
if (props.isPreviewable) {
window.open(URL.createObjectURL(props.file));
}
}
function onDelete() {
emit('remove', props.file);
}
</script>
<template>
<div class="chat-file" data-test-id="chat-file" @click="onClick">
<TypeIcon class="chat-icon" />
<p class="chat-file-name">{{ file.name }}</p>
<span
v-if="isRemovable"
class="chat-file-delete"
data-test-id="chat-file-remove"
@click.stop="onDelete"
>
<IconDelete />
</span>
<IconPreview v-else-if="isPreviewable || href" class="chat-file-preview" />
</div>
</template>
<style scoped lang="scss">
.chat-file {
display: flex;
align-items: center;
flex-wrap: nowrap;
width: fit-content;
max-width: 15rem;
padding: 0.5rem;
border-radius: 0.25rem;
gap: 0.25rem;
font-size: 0.75rem;
background: white;
color: var(--chat--color-dark);
border: 1px solid var(--chat--color-dark);
&:has(.chat-file-preview) {
cursor: pointer;
}
}
.chat-icon {
flex-shrink: 0;
}
.chat-file-name-tooltip {
overflow: hidden;
}
.chat-file-name {
overflow: hidden;
max-width: 100%;
text-overflow: ellipsis;
white-space: nowrap;
margin: 0;
}
.chat-file-delete,
.chat-file-preview {
background: none;
border: none;
display: block;
cursor: pointer;
flex-shrink: 0;
}
.chat-file-delete {
position: relative;
&:hover {
color: red;
}
/* Increase hit area for better clickability */
&:before {
content: '';
position: absolute;
top: -10px;
right: -10px;
bottom: -10px;
left: -10px;
}
}
</style>

View File

@@ -0,0 +1,124 @@
<script lang="ts" setup>
import IconChat from 'virtual:icons/mdi/chat';
import IconChevronDown from 'virtual:icons/mdi/chevron-down';
import { nextTick, ref } from 'vue';
import Chat from '@n8n/chat/components/Chat.vue';
import { chatEventBus } from '@n8n/chat/event-buses';
const isOpen = ref(false);
function toggle() {
isOpen.value = !isOpen.value;
if (isOpen.value) {
void nextTick(() => {
chatEventBus.emit('scrollToBottom');
});
}
}
</script>
<template>
<div class="chat-window-wrapper">
<Transition name="chat-window-transition">
<div v-show="isOpen" class="chat-window">
<Chat />
</div>
</Transition>
<div class="chat-window-toggle" @click="toggle">
<Transition name="chat-window-toggle-transition" mode="out-in">
<IconChat v-if="!isOpen" height="32" width="32" />
<IconChevronDown v-else height="32" width="32" />
</Transition>
</div>
</div>
</template>
<style lang="scss">
.chat-window-wrapper {
position: fixed;
display: flex;
flex-direction: column;
bottom: var(--chat--window--bottom);
right: var(--chat--window--right);
z-index: var(--chat--window--z-index);
max-width: calc(100% - var(--chat--window--right, var(--chat--spacing)) * 2);
max-height: calc(100% - var(--chat--window--bottom, var(--chat--spacing)) * 2);
.chat-window {
display: flex;
width: var(--chat--window--width);
height: var(--chat--window--height);
max-width: 100%;
max-height: 100%;
border: var(--chat--window--border, 1px solid var(--chat--color-light-shade-100));
border-radius: var(--chat--window--border-radius, var(--chat--border-radius));
margin-bottom: var(--chat--window--margin-bottom, var(--chat--spacing));
overflow: hidden;
transform-origin: bottom right;
.chat-layout {
width: auto;
height: auto;
flex: 1;
}
}
.chat-window-toggle {
flex: 0 0 auto;
background: var(--chat--toggle--background);
color: var(--chat--toggle--color);
cursor: pointer;
width: var(--chat--toggle--width);
height: var(--chat--toggle--height);
border-radius: var(--chat--toggle--border-radius, 50%);
display: inline-flex;
align-items: center;
justify-content: center;
margin-left: auto;
transition:
transform var(--chat--transition-duration) ease,
background var(--chat--transition-duration) ease;
&:hover,
&:focus {
transform: scale(1.05);
background: var(--chat--toggle--hover--background);
}
&:active {
transform: scale(0.95);
background: var(--chat--toggle--active--background);
}
}
}
.chat-window-transition {
&-enter-active,
&-leave-active {
transition:
transform var(--chat--transition-duration) ease,
opacity var(--chat--transition-duration) ease;
}
&-enter-from,
&-leave-to {
transform: scale(0);
opacity: 0;
}
}
.chat-window-toggle-transition {
&-enter-active,
&-leave-active {
transition: opacity var(--chat--transition-duration) ease;
}
&-enter-from,
&-leave-to {
opacity: 0;
}
}
</style>

View File

@@ -0,0 +1,24 @@
<script setup lang="ts">
import Button from '@n8n/chat/components/Button.vue';
import { useI18n } from '@n8n/chat/composables';
const { t } = useI18n();
</script>
<template>
<div class="chat-get-started">
<Button @click="$emit('click:button')">
{{ t('getStarted') }}
</Button>
</div>
</template>
<style lang="scss">
.chat-get-started {
padding-top: var(--chat--spacing);
padding-bottom: var(--chat--spacing);
display: flex;
justify-content: center;
align-items: center;
height: 100%;
}
</style>

View File

@@ -0,0 +1,20 @@
<script setup lang="ts">
import PoweredBy from '@n8n/chat/components/PoweredBy.vue';
import { useI18n } from '@n8n/chat/composables';
const { t, te } = useI18n();
</script>
<template>
<div class="chat-get-started-footer">
<div v-if="te('footer')">
{{ t('footer') }}
</div>
<PoweredBy />
</div>
</template>
<style lang="scss">
.chat-get-started-footer {
padding: var(--chat--spacing);
}
</style>

View File

@@ -0,0 +1,471 @@
<script setup lang="ts">
import { useFileDialog } from '@vueuse/core';
import { v4 as uuidv4 } from 'uuid';
import IconPaperclip from 'virtual:icons/mdi/paperclip';
import IconSend from 'virtual:icons/mdi/send';
import { computed, onMounted, onUnmounted, ref, unref } from 'vue';
import { useI18n, useChat, useOptions } from '@n8n/chat/composables';
import { chatEventBus } from '@n8n/chat/event-buses';
import {
constructChatWebsocketUrl,
parseBotChatMessageContent,
shouldBlockUserInput,
} from '@n8n/chat/utils';
import ChatFile from './ChatFile.vue';
import type { ChatMessage } from '../types';
export interface ChatInputProps {
placeholder?: string;
}
const props = withDefaults(defineProps<ChatInputProps>(), {
placeholder: 'inputPlaceholder',
});
export interface ArrowKeyDownPayload {
key: 'ArrowUp' | 'ArrowDown';
currentInputValue: string;
}
const { t } = useI18n();
const emit = defineEmits<{
arrowKeyDown: [value: ArrowKeyDownPayload];
}>();
const { options } = useOptions();
const chatStore = useChat();
const { waitingForResponse } = chatStore;
const files = ref<FileList | null>(null);
const chatTextArea = ref<HTMLTextAreaElement | null>(null);
const input = ref('');
const isSubmitting = ref(false);
const resizeObserver = ref<ResizeObserver | null>(null);
const waitingForChatResponse = ref(false);
const isSubmitDisabled = computed(() => {
if (chatStore.blockUserInput.value) return true;
if (waitingForChatResponse.value) return false;
return input.value === '' || unref(waitingForResponse) || options.disabled?.value === true;
});
const isInputDisabled = computed(() => options.disabled?.value === true);
const isFileUploadDisabled = computed(
() => isFileUploadAllowed.value && unref(waitingForResponse) && !options.disabled?.value,
);
const isFileUploadAllowed = computed(() => unref(options.allowFileUploads) === true);
const allowedFileTypes = computed(() => unref(options.allowedFilesMimeTypes));
const styleVars = computed(() => {
const controlsCount = isFileUploadAllowed.value ? 2 : 1;
return {
'--controls-count': controlsCount,
};
});
const {
open: openFileDialog,
reset: resetFileDialog,
onChange,
} = useFileDialog({
multiple: true,
reset: false,
});
onChange((newFiles) => {
if (!newFiles) return;
const newFilesDT = new DataTransfer();
// Add current files
if (files.value) {
for (let i = 0; i < files.value.length; i++) {
newFilesDT.items.add(files.value[i]);
}
}
for (let i = 0; i < newFiles.length; i++) {
newFilesDT.items.add(newFiles[i]);
}
files.value = newFilesDT.files;
});
onMounted(() => {
chatEventBus.on('focusInput', focusChatInput);
chatEventBus.on('blurInput', blurChatInput);
chatEventBus.on('setInputValue', setInputValue);
if (chatTextArea.value) {
resizeObserver.value = new ResizeObserver((entries) => {
for (const entry of entries) {
if (entry.target === chatTextArea.value) {
adjustTextAreaHeight();
}
}
});
// Start observing the textarea
resizeObserver.value.observe(chatTextArea.value);
}
});
onUnmounted(() => {
chatEventBus.off('focusInput', focusChatInput);
chatEventBus.off('blurInput', blurChatInput);
chatEventBus.off('setInputValue', setInputValue);
if (resizeObserver.value) {
resizeObserver.value.disconnect();
resizeObserver.value = null;
}
});
function blurChatInput() {
if (chatTextArea.value) {
chatTextArea.value.blur();
}
}
function focusChatInput() {
if (chatTextArea.value) {
chatTextArea.value.focus();
}
}
function setInputValue(value: string) {
input.value = value;
focusChatInput();
}
function attachFiles() {
if (files.value) {
const filesToAttach = Array.from(files.value);
resetFileDialog();
files.value = null;
return filesToAttach;
}
return [];
}
function setupWebsocketConnection(executionId: string) {
// if webhookUrl is not defined onSubmit is called from integrated chat
// do not setup websocket as it would be handled by the integrated chat
if (options.webhookUrl && chatStore.currentSessionId.value) {
try {
const wsUrl = constructChatWebsocketUrl(
options.webhookUrl,
executionId,
chatStore.currentSessionId.value,
true,
);
chatStore.ws = new WebSocket(wsUrl);
chatStore.ws.onmessage = (e) => {
if (e.data === 'n8n|heartbeat') {
chatStore.ws?.send('n8n|heartbeat-ack');
return;
}
if (e.data === 'n8n|continue') {
waitingForChatResponse.value = false;
chatStore.waitingForResponse.value = true;
return;
}
const newMessage = parseBotChatMessageContent(e.data as string);
chatStore.messages.value.push(newMessage);
waitingForChatResponse.value = true;
chatStore.waitingForResponse.value = false;
chatStore.blockUserInput.value = shouldBlockUserInput(newMessage);
};
chatStore.ws.onclose = () => {
chatStore.ws = null;
waitingForChatResponse.value = false;
chatStore.waitingForResponse.value = false;
chatStore.blockUserInput.value = false;
};
} catch (error) {
// do not throw error here as it should work with n8n versions that do not support websockets
console.error('Error setting up websocket connection', error);
}
}
}
async function processFiles(data: File[] | undefined) {
if (!data || data.length === 0) return [];
const filePromises = data.map(async (file) => {
// We do not need to await here as it will be awaited on the return by Promise.all
// eslint-disable-next-line @typescript-eslint/return-await
return new Promise<{ name: string; type: string; data: string }>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () =>
resolve({
name: file.name,
type: file.type,
data: reader.result as string,
});
reader.onerror = () =>
reject(new Error(`Error reading file: ${reader.error?.message ?? 'Unknown error'}`));
reader.readAsDataURL(file);
});
});
return await Promise.all(filePromises);
}
async function respondToChatNode(ws: WebSocket, messageText: string) {
const sentMessage: ChatMessage = {
id: uuidv4(),
text: messageText,
sender: 'user',
files: files.value ? attachFiles() : undefined,
};
chatStore.messages.value.push(sentMessage);
ws.send(
JSON.stringify({
sessionId: chatStore.currentSessionId.value,
action: 'sendMessage',
chatInput: messageText,
files: await processFiles(sentMessage.files),
}),
);
chatStore.waitingForResponse.value = true;
waitingForChatResponse.value = false;
}
async function onSubmit(event: MouseEvent | KeyboardEvent) {
event.preventDefault();
if (isSubmitDisabled.value) {
return;
}
const messageText = input.value;
input.value = '';
isSubmitting.value = true;
if (chatStore.ws && waitingForChatResponse.value) {
await respondToChatNode(chatStore.ws, messageText);
// Emit event to reset message history navigation
chatEventBus.emit('messageSent');
return;
}
const response = await chatStore.sendMessage(messageText, attachFiles());
if (response?.executionId) {
setupWebsocketConnection(response.executionId);
}
// Emit event to reset message history navigation
chatEventBus.emit('messageSent');
isSubmitting.value = false;
}
async function onSubmitKeydown(event: KeyboardEvent) {
if (event.shiftKey || event.isComposing) {
return;
}
await onSubmit(event);
adjustTextAreaHeight();
}
function onFileRemove(file: File) {
if (!files.value) return;
const dt = new DataTransfer();
for (let i = 0; i < files.value.length; i++) {
const currentFile = files.value[i];
if (file.name !== currentFile.name) dt.items.add(currentFile);
}
resetFileDialog();
files.value = dt.files;
}
function onKeyDown(event: KeyboardEvent) {
if (event.key === 'ArrowUp' || event.key === 'ArrowDown') {
event.preventDefault();
emit('arrowKeyDown', {
key: event.key,
currentInputValue: input.value,
});
}
}
function onOpenFileDialog() {
if (isFileUploadDisabled.value) return;
openFileDialog({ accept: unref(allowedFileTypes) });
}
function adjustTextAreaHeight() {
const textarea = chatTextArea.value;
if (!textarea) return;
// Set to content minimum to get the right scrollHeight
textarea.style.height = 'var(--chat--textarea--height)';
// Get the new height, with a small buffer for padding
const newHeight = Math.min(textarea.scrollHeight, 480); // 30rem
textarea.style.height = `${newHeight}px`;
}
</script>
<template>
<div class="chat-input" :style="styleVars">
<div class="chat-inputs">
<div v-if="$slots.leftPanel" class="chat-input-left-panel">
<slot name="leftPanel" />
</div>
<textarea
ref="chatTextArea"
v-model="input"
data-test-id="chat-input"
:disabled="isInputDisabled"
:placeholder="t(props.placeholder)"
@keydown.enter="onSubmitKeydown"
@keydown="onKeyDown"
@input="adjustTextAreaHeight"
@mousedown="adjustTextAreaHeight"
@focus="adjustTextAreaHeight"
/>
<div class="chat-inputs-controls">
<button
v-if="isFileUploadAllowed"
:disabled="isFileUploadDisabled"
class="chat-input-file-button"
data-test-id="chat-attach-file-button"
@click="onOpenFileDialog"
>
<IconPaperclip height="24" width="24" />
</button>
<button :disabled="isSubmitDisabled" class="chat-input-send-button" @click="onSubmit">
<IconSend height="24" width="24" />
</button>
</div>
</div>
<div v-if="files?.length && (!isSubmitting || waitingForChatResponse)" class="chat-files">
<ChatFile
v-for="file in files"
:key="file.name"
:file="file"
:is-removable="true"
:is-previewable="true"
@remove="onFileRemove"
/>
</div>
</div>
</template>
<style lang="scss" scoped>
.chat-input {
display: flex;
justify-content: center;
align-items: center;
width: 100%;
flex-direction: column;
position: relative;
* {
box-sizing: border-box;
}
}
.chat-inputs {
width: var(--chat--input--width, 100%);
display: flex;
justify-content: center;
align-items: flex-end;
background: var(--chat--input--container--background, var(--color--background--light-2));
border: var(--chat--input--container--border, 1px solid var(--color--foreground--tint-1));
border-radius: var(--chat--input--container--border-radius, 24px);
padding: var(--chat--input--container--padding, 12px);
textarea {
font-family: inherit;
font-size: var(--chat--input--font-size);
width: 100%;
border: none;
border-radius: var(--chat--input--border-radius);
padding: var(--chat--input--padding);
min-height: var(--chat--textarea--height);
max-height: var(--chat--textarea--max-height);
height: var(--chat--textarea--height);
resize: none;
overflow-y: auto;
background: var(--chat--input--background, white);
color: var(--chat--input--text-color, initial);
outline: none;
line-height: var(--chat--input--line-height, 1.5);
&::placeholder {
font-size: var(--chat--input--placeholder--font-size, var(--chat--input--font-size));
}
}
}
.chat-inputs-controls {
display: flex;
}
.chat-input-send-button,
.chat-input-file-button {
height: var(--chat--textarea--height);
width: var(--chat--textarea--height);
background: var(--chat--input--send--button--background, transparent);
cursor: pointer;
color: var(--chat--input--send--button--color, var(--chat--color--secondary));
border: none;
border-radius: var(--chat--input--button--border-radius, 16px);
font-size: 24px;
display: inline-flex;
align-items: center;
justify-content: center;
transition: all var(--chat--transition-duration, 0.15s) ease;
margin: 8px;
svg {
min-width: fit-content;
}
&[disabled] {
cursor: no-drop;
color: var(--chat--color-disabled);
}
&:hover:not([disabled]) {
background: var(--chat--input--send--button--background-hover, rgba(0, 0, 0, 0.05));
color: var(--chat--input--send--button--color-hover, var(--chat--color--secondary));
}
}
.chat-input-file-button {
background: var(--chat--input--file--button--background, transparent);
color: var(--chat--input--file--button--color, var(--chat--color--secondary));
&:hover:not([disabled]) {
background: var(--chat--input--file--button--background-hover, rgba(0, 0, 0, 0.05));
color: var(--chat--input--file--button--color-hover, var(--chat--color--secondary));
}
}
.chat-files {
display: flex;
overflow-x: hidden;
overflow-y: auto;
width: 100%;
flex-direction: row;
flex-wrap: wrap;
gap: 0.5rem;
padding: var(--chat--files-spacing);
}
.chat-input-left-panel {
width: var(--chat--input--left--panel--width);
margin-left: 0.4rem;
}
</style>

View File

@@ -0,0 +1,88 @@
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref } from 'vue';
import { chatEventBus } from '@n8n/chat/event-buses';
const chatBodyRef = ref<HTMLElement | null>(null);
function scrollToBottom() {
const element = chatBodyRef.value as HTMLElement;
if (element) {
element.scrollTop = element.scrollHeight;
}
}
onMounted(() => {
chatEventBus.on('scrollToBottom', scrollToBottom);
window.addEventListener('resize', scrollToBottom);
});
onBeforeUnmount(() => {
chatEventBus.off('scrollToBottom', scrollToBottom);
window.removeEventListener('resize', scrollToBottom);
});
</script>
<template>
<main class="chat-layout">
<div v-if="$slots.header" class="chat-header">
<slot name="header" />
</div>
<div v-if="$slots.default" ref="chatBodyRef" class="chat-body">
<slot />
</div>
<div v-if="$slots.footer" class="chat-footer">
<slot name="footer" />
</div>
</main>
</template>
<style lang="scss">
.chat-layout {
width: 100%;
height: 100%;
display: flex;
overflow-y: auto;
flex-direction: column;
font-family: var(--chat--font-family);
.chat-header {
display: flex;
flex-direction: column;
justify-content: center;
gap: 1em;
height: var(--chat--header-height);
padding: var(--chat--header--padding);
background: var(--chat--header--background);
color: var(--chat--header--color);
border-top: var(--chat--header--border-top);
border-bottom: var(--chat--header--border-bottom);
border-left: var(--chat--header--border-left);
border-right: var(--chat--header--border-right);
h1 {
font-size: var(--chat--heading--font-size);
color: var(--chat--header--color);
}
p {
font-size: var(--chat--subtitle--font-size);
line-height: var(--chat--subtitle--line-height);
}
}
.chat-body {
background: var(--chat--body--background);
flex: 1;
display: flex;
flex-direction: column;
overflow-y: auto;
position: relative;
min-height: 100px;
}
.chat-footer {
border-top: var(--chat--footer--border-top, 1px solid var(--chat--color-light-shade-100));
background: var(--chat--footer--background);
padding: var(--chat--footer--padding);
color: var(--chat--footer--color);
}
}
</style>

View File

@@ -0,0 +1,78 @@
<script lang="ts" setup>
import hljs from 'highlight.js/lib/core';
import bash from 'highlight.js/lib/languages/bash';
import javascript from 'highlight.js/lib/languages/javascript';
import python from 'highlight.js/lib/languages/python';
import typescript from 'highlight.js/lib/languages/typescript';
import xml from 'highlight.js/lib/languages/xml';
import type MarkdownIt from 'markdown-it';
import markdownLink from 'markdown-it-link-attributes';
import VueMarkdown from 'vue-markdown-render';
defineProps<{
text: string;
}>();
hljs.registerLanguage('javascript', javascript);
hljs.registerLanguage('typescript', typescript);
hljs.registerLanguage('python', python);
hljs.registerLanguage('xml', xml);
hljs.registerLanguage('bash', bash);
const linksNewTabPlugin = (vueMarkdownItInstance: MarkdownIt) => {
vueMarkdownItInstance.use(markdownLink, {
attrs: {
target: '_blank',
rel: 'noopener',
},
});
};
const markdownOptions = {
highlight(str: string, lang: string) {
if (lang && hljs.getLanguage(lang)) {
try {
return hljs.highlight(str, { language: lang }).value;
} catch {}
}
return ''; // use external default escaping
},
};
</script>
<template>
<VueMarkdown
class="chat-message-markdown"
:source="text"
:options="markdownOptions"
:plugins="[linksNewTabPlugin]"
/>
</template>
<style lang="scss">
.chat-message-markdown {
display: block;
box-sizing: border-box;
font-size: inherit;
> *:first-child {
margin-top: 0;
}
> *:last-child {
margin-bottom: 0;
}
pre {
font-family: inherit;
font-size: inherit;
margin: 0;
white-space: pre-wrap;
box-sizing: border-box;
padding: var(--chat--spacing);
background: var(--chat--message--pre--background);
border-radius: var(--chat--border-radius);
}
}
</style>

View File

@@ -0,0 +1,168 @@
<script lang="ts" setup>
import { computed, ref, toRefs, onMounted } from 'vue';
import { useOptions } from '@n8n/chat/composables';
import type { ChatMessage, ChatMessageText } from '@n8n/chat/types';
import ChatFile from './ChatFile.vue';
import MarkdownRenderer from './MarkdownRenderer.vue';
import MessageActions from './MessageActions.vue';
const props = defineProps<{
message: ChatMessage;
}>();
defineSlots<{
beforeMessage(props: { message: ChatMessage }): ChatMessage;
default: { message: ChatMessage };
}>();
const { message } = toRefs(props);
const { options } = useOptions();
const messageContainer = ref<HTMLElement | null>(null);
const fileSources = ref<Record<string, string>>({});
const messageText = computed(() => {
return (message.value as ChatMessageText).text || '&lt;Empty response&gt;';
});
const classes = computed(() => {
return {
'chat-message-from-user': message.value.sender === 'user',
'chat-message-from-bot': message.value.sender === 'bot',
'chat-message-transparent': message.value.transparent === true,
};
});
const scrollToView = () => {
if (messageContainer.value?.scrollIntoView) {
messageContainer.value.scrollIntoView({
block: 'start',
});
}
};
const messageComponents = { ...(options?.messageComponents ?? {}) };
defineExpose({ scrollToView });
const readFileAsDataURL = async (file: File): Promise<string> =>
await new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.onerror = reject;
reader.readAsDataURL(file);
});
onMounted(async () => {
if (message.value.files) {
for (const file of message.value.files) {
try {
const dataURL = await readFileAsDataURL(file);
fileSources.value[file.name] = dataURL;
} catch (error) {
console.error('Error reading file:', error);
}
}
}
});
</script>
<template>
<div ref="messageContainer" class="chat-message" :class="classes">
<div
v-if="!!$slots.beforeMessage || options?.enableMessageActions"
class="chat-message-actions"
>
<slot name="beforeMessage" v-bind="{ message }" />
<MessageActions :message="message" />
</div>
<slot>
<template v-if="message.type === 'component' && messageComponents[message.key]">
<component :is="messageComponents[message.key]" v-bind="message.arguments" />
</template>
<MarkdownRenderer v-else :text="messageText" />
<div v-if="(message.files ?? []).length > 0" class="chat-message-files">
<div v-for="file in message.files ?? []" :key="file.name" class="chat-message-file">
<ChatFile :file="file" :is-removable="false" :is-previewable="true" />
</div>
</div>
</slot>
</div>
</template>
<style lang="scss">
.chat-message {
display: block;
position: relative;
max-width: fit-content;
font-size: var(--chat--message--font-size);
padding: var(--chat--message--padding);
border-radius: var(--chat--message--border-radius);
scroll-margin: 3rem;
overflow: hidden;
.chat-message-actions {
position: absolute;
bottom: calc(100% - 0.5rem);
left: 0;
opacity: 0;
transform: translateY(-0.25rem);
display: flex;
gap: 1rem;
}
&.chat-message-from-user .chat-message-actions {
left: auto;
right: 0;
}
&:hover {
.chat-message-actions {
opacity: 1;
}
}
p {
line-height: var(--chat--message-line-height);
word-wrap: break-word;
}
// Default message gap is half of the spacing
+ .chat-message {
margin-top: var(--chat--message--margin-bottom);
}
// Spacing between messages from different senders is double the individual message gap
&.chat-message-from-user + &.chat-message-from-bot,
&.chat-message-from-bot + &.chat-message-from-user {
margin-top: var(--chat--spacing);
}
&.chat-message-from-bot {
&:not(.chat-message-transparent) {
background-color: var(--chat--message--bot--background);
border: var(--chat--message--bot--border);
}
color: var(--chat--message--bot--color);
border-bottom-left-radius: 0;
}
&.chat-message-from-user {
&:not(.chat-message-transparent) {
background-color: var(--chat--message--user--background);
border: var(--chat--message--user--border);
}
color: var(--chat--message--user--color);
margin-left: auto;
border-bottom-right-radius: 0;
}
.chat-message-files {
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
padding-top: 0.5rem;
}
}
</style>

View File

@@ -0,0 +1,68 @@
<script setup lang="ts">
import { N8nTooltip, N8nIcon } from '@n8n/design-system';
import { useI18n, useChat, useOptions } from '@n8n/chat/composables';
import { chatEventBus } from '@n8n/chat/event-buses';
import type { ChatMessage } from '@n8n/chat/types';
const props = defineProps<{
message: ChatMessage;
}>();
const { options } = useOptions();
const chatStore = useChat();
const { t } = useI18n();
async function repostMessage() {
if (props.message.sender === 'user') {
// Repost user message by sending it again
const messageText =
'text' in props.message && typeof props.message.text === 'string' ? props.message.text : '';
if (messageText.trim()) {
await chatStore.sendMessage(
messageText,
props.message.files ? Array.from(props.message.files) : [],
);
}
}
}
function copyToInput() {
const messageText =
'text' in props.message && typeof props.message?.text === 'string' ? props.message?.text : '';
if (messageText.trim()) {
chatEventBus.emit('setInputValue', messageText);
}
}
</script>
<template>
<div v-if="options.enableMessageActions" class="message-actions">
<N8nTooltip v-if="message.sender === 'user'">
<N8nIcon icon="redo-2" size="medium" class="icon" @click="repostMessage" />
<template #content>{{ t('repostButton') }}</template>
</N8nTooltip>
<N8nTooltip v-if="message.sender === 'user'">
<N8nIcon icon="files" size="medium" class="icon" @click="copyToInput" />
<template #content>{{ t('reuseButton') }}</template>
</N8nTooltip>
</div>
</template>
<style lang="scss" scoped>
.message-actions {
display: inline-flex;
gap: var(--chat--message--actions--gap);
margin: 0 var(--chat--message--actions--gap);
align-items: center;
}
.icon {
color: var(--chat--message--actions--color);
cursor: pointer;
&:hover {
color: var(--chat--message--actions--hover);
}
}
</style>

View File

@@ -0,0 +1,120 @@
<script lang="ts" setup>
import { computed, onMounted, ref } from 'vue';
import type { ChatMessage } from '@n8n/chat/types';
import { Message } from './index';
const props = withDefaults(
defineProps<{
animation?: 'bouncing' | 'scaling';
}>(),
{
animation: 'bouncing',
},
);
const message: ChatMessage = {
id: 'typing',
text: '',
sender: 'bot',
};
const messageContainer = ref<InstanceType<typeof Message>>();
const classes = computed(() => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
'chat-message-typing': true,
[`chat-message-typing-animation-${props.animation}`]: true,
};
});
onMounted(() => {
messageContainer.value?.scrollToView();
});
</script>
<template>
<Message
ref="messageContainer"
:class="classes"
:message="message"
data-test-id="chat-message-typing"
>
<div class="chat-message-typing-body">
<span class="chat-message-typing-circle"></span>
<span class="chat-message-typing-circle"></span>
<span class="chat-message-typing-circle"></span>
</div>
</Message>
</template>
<style lang="scss">
.chat-message-typing {
max-width: 80px;
&.chat-message-typing-animation-scaling .chat-message-typing-circle {
animation: chat-message-typing-animation-scaling 800ms ease-in-out infinite;
animation-delay: 3600ms;
}
&.chat-message-typing-animation-bouncing .chat-message-typing-circle {
animation: chat-message-typing-animation-bouncing 800ms ease-in-out infinite;
animation-delay: 3600ms;
}
.chat-message-typing-body {
display: flex;
justify-content: center;
align-items: center;
}
.chat-message-typing-circle {
display: block;
height: 10px;
width: 10px;
border-radius: 50%;
background-color: var(--chat--color-typing);
margin: 3px;
&:nth-child(1) {
animation-delay: 0ms;
}
&:nth-child(2) {
animation-delay: 333ms;
}
&:nth-child(3) {
animation-delay: 666ms;
}
}
}
@keyframes chat-message-typing-animation-scaling {
0% {
transform: scale(1);
}
33% {
transform: scale(1);
}
50% {
transform: scale(1.4);
}
100% {
transform: scale(1);
}
}
@keyframes chat-message-typing-animation-bouncing {
0% {
transform: translateY(0);
}
33% {
transform: translateY(0);
}
50% {
transform: translateY(-10px);
}
100% {
transform: translateY(0);
}
}
</style>

View File

@@ -0,0 +1,70 @@
<script setup lang="ts">
import { ref } from 'vue';
import { useOptions } from '@n8n/chat/composables';
import Button from './Button.vue';
import MarkdownRenderer from './MarkdownRenderer.vue';
defineProps<{
text: string;
buttons: Array<{
text: string;
link: string;
type: 'primary' | 'secondary';
}>;
}>();
const chatOptions = useOptions();
const clickedButtonIndex = ref<number | null>(null);
const isButtonVisible = (link: string, index: number): boolean => {
try {
const validOrigin = new URL(chatOptions.options.webhookUrl).origin;
const url = new URL(link, window.location.href);
if (url.origin !== validOrigin) {
return false;
}
return clickedButtonIndex.value === null || index === clickedButtonIndex.value;
} catch {
return false;
}
};
const onClick = async (link: string, index: number) => {
if (clickedButtonIndex.value !== null) {
return;
}
const response = await fetch(link);
if (response.ok) {
clickedButtonIndex.value = index;
}
};
</script>
<template>
<div>
<MarkdownRenderer :text="text" />
<div :class="$style.buttons">
<template v-for="(button, index) in buttons" :key="button.text">
<Button
v-if="isButtonVisible(button.link, index)"
element="button"
:type="button.type"
:disabled="index === clickedButtonIndex"
@click="onClick(button.link, index)"
>{{ button.text }}</Button
>
</template>
</div>
</div>
</template>
<style lang="scss" module>
.buttons {
display: flex;
gap: var(--chat--spacing);
margin-top: var(--chat--spacing);
}
</style>

View File

@@ -0,0 +1,111 @@
<script lang="ts" setup>
import { N8nIcon, N8nText } from '@n8n/design-system';
import { ref, watch } from 'vue';
import Message from '@n8n/chat/components/Message.vue';
import MessageTyping from '@n8n/chat/components/MessageTyping.vue';
import { useChat } from '@n8n/chat/composables';
import type { ChatMessage } from '@n8n/chat/types';
defineProps<{
messages: ChatMessage[];
emptyText?: string;
}>();
defineSlots<{
beforeMessage(props: { message: ChatMessage }): ChatMessage;
}>();
const chatStore = useChat();
const messageComponents = ref<Array<InstanceType<typeof Message>>>([]);
const { initialMessages, waitingForResponse } = chatStore;
watch(
() => messageComponents.value.length,
() => {
const lastMessageComponent = messageComponents.value[messageComponents.value.length - 1];
if (lastMessageComponent) {
lastMessageComponent.scrollToView();
}
},
);
</script>
<template>
<div
v-if="emptyText && initialMessages.length === 0 && messages.length === 0"
class="empty-container"
>
<div class="empty" data-test-id="chat-messages-empty">
<N8nIcon icon="message-circle" size="large" class="emptyIcon" />
<N8nText tag="p" size="medium" color="text-base">
{{ emptyText }}
</N8nText>
</div>
</div>
<div v-else class="chat-messages-list">
<Message
v-for="initialMessage in initialMessages"
:key="initialMessage.id"
:message="initialMessage"
/>
<template v-for="message in messages" :key="message.id">
<Message ref="messageComponents" :message="message">
<template #beforeMessage="{ message }">
<slot name="beforeMessage" v-bind="{ message }" />
</template>
</Message>
</template>
<MessageTyping v-if="waitingForResponse" />
</div>
</template>
<style lang="scss">
.chat-messages-list {
margin-top: auto;
display: block;
padding: var(--chat--messages-list--padding);
}
.empty-container {
container-type: size;
display: flex;
align-items: center;
justify-content: center;
p {
max-width: 16em;
margin: 0;
}
}
.empty {
text-align: center;
color: var(--color--text);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--spacing--xs);
padding-inline: var(--spacing--md);
padding-bottom: var(--spacing--lg);
overflow: hidden;
}
.emptyIcon {
zoom: 2.5;
color: var(--color-button-secondary-border);
}
@container (height < 150px) {
.empty {
flex-direction: row;
text-align: left;
}
.emptyIcon {
zoom: 1.5;
}
}
</style>

View File

@@ -0,0 +1,17 @@
<template>
<div class="chat-powered-by">
Powered by
<a href="https://n8n.io?utm_source=n8n-external&utm_medium=widget-powered-by">n8n</a>
</div>
</template>
<style lang="scss">
.chat-powered-by {
text-align: center;
a {
color: var(--chat--color--primary);
text-decoration: none;
}
}
</style>

View File

@@ -0,0 +1,11 @@
export { default as Button } from './Button.vue';
export { default as Chat } from './Chat.vue';
export { default as ChatWindow } from './ChatWindow.vue';
export { default as GetStarted } from './GetStarted.vue';
export { default as GetStartedFooter } from './GetStartedFooter.vue';
export { default as Input } from './Input.vue';
export { default as Layout } from './Layout.vue';
export { default as Message } from './Message.vue';
export { default as MessagesList } from './MessagesList.vue';
export { default as PoweredBy } from './PoweredBy.vue';
export { default as MessageWithButtons } from './MessageWithButtons.vue';

View File

@@ -0,0 +1,3 @@
export * from './useChat';
export * from './useI18n';
export * from './useOptions';

View File

@@ -0,0 +1,8 @@
import { inject } from 'vue';
import { ChatSymbol } from '@n8n/chat/constants';
import type { Chat } from '@n8n/chat/types';
export function useChat() {
return inject(ChatSymbol) as Chat;
}

View File

@@ -0,0 +1,22 @@
import { isRef } from 'vue';
import { useOptions } from '@n8n/chat/composables/useOptions';
export function useI18n() {
const { options } = useOptions();
const language = options?.defaultLanguage ?? 'en';
function t(key: string): string {
const val = options?.i18n?.[language]?.[key];
if (isRef(val)) {
return val.value as string;
}
return val ?? key;
}
function te(key: string): boolean {
return !!options?.i18n?.[language]?.[key];
}
return { t, te };
}

View File

@@ -0,0 +1,12 @@
import { inject } from 'vue';
import { ChatOptionsSymbol } from '@n8n/chat/constants';
import type { ChatOptions } from '@n8n/chat/types';
export function useOptions() {
const options = inject(ChatOptionsSymbol) as ChatOptions;
return {
options,
};
}

View File

@@ -0,0 +1,38 @@
import MessageWithButtons from '@n8n/chat/components/MessageWithButtons.vue';
import { MessageComponentKey } from '@n8n/chat/constants/messageComponents';
import type { ChatOptions } from '@n8n/chat/types';
export const defaultOptions: ChatOptions = {
webhookUrl: 'http://localhost:5678',
webhookConfig: {
method: 'POST',
headers: {},
},
target: '#n8n-chat',
mode: 'window',
loadPreviousSession: true,
chatInputKey: 'chatInput',
chatSessionKey: 'sessionId',
defaultLanguage: 'en',
showWelcomeScreen: false,
initialMessages: ['Hi there! 👋', 'My name is Nathan. How can I assist you today?'],
i18n: {
en: {
title: 'Hi there! 👋',
subtitle: "Start a chat. We're here to help you 24/7.",
footer: '',
getStarted: 'New Conversation',
inputPlaceholder: 'Type your question..',
closeButtonTooltip: 'Close chat',
repostButton: 'Repost message',
reuseButton: 'Reuse message',
},
},
theme: {},
enableStreaming: false,
messageComponents: {
[MessageComponentKey.WITH_BUTTONS]: MessageWithButtons,
},
};
export const defaultMountingTarget = '#n8n-chat';

View File

@@ -0,0 +1,4 @@
export * from './defaults';
export * from './localStorage';
export * from './symbols';
export * from './messageComponents';

View File

@@ -0,0 +1,2 @@
export const localStorageNamespace = 'n8n-chat';
export const localStorageSessionIdKey = `${localStorageNamespace}/sessionId`;

View File

@@ -0,0 +1,3 @@
export const MessageComponentKey = {
WITH_BUTTONS: 'with-buttons',
} as const;

View File

@@ -0,0 +1,7 @@
import type { InjectionKey } from 'vue';
import type { Chat, ChatOptions } from '@n8n/chat/types';
export const ChatSymbol = 'Chat' as unknown as InjectionKey<Chat>;
export const ChatOptionsSymbol = 'ChatOptions' as unknown as InjectionKey<ChatOptions>;

View File

@@ -0,0 +1,139 @@
:root {
/* Colors */
--chat--color--primary: #e74266;
--chat--color--primary-shade-50: #db4061;
--chat--color--primary--shade-100: #cf3c5c;
--chat--color--secondary: #20b69e;
--chat--color-secondary-shade-50: #1ca08a;
--chat--color-white: #fff;
--chat--color-light: #f2f4f8;
--chat--color-light-shade-50: #e6e9f1;
--chat--color-light-shade-100: #c2c5cc;
--chat--color-medium: #d2d4d9;
--chat--color-dark: #101330;
--chat--color-disabled: #d2d4d9;
--chat--color-typing: #404040;
/* Base Layout */
--chat--spacing: 1rem;
--chat--border-radius: 0.25rem;
--chat--transition-duration: 0.15s;
--chat--font-family:
-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen-Sans, Ubuntu, Cantarell,
'Helvetica Neue', sans-serif;
/* Window Dimensions */
--chat--window--width: 400px;
--chat--window--height: 600px;
--chat--window--bottom: var(--chat--spacing);
--chat--window--right: var(--chat--spacing);
--chat--window--z-index: 9999;
--chat--window--border: 1px solid var(--chat--color-light-shade-50);
--chat--window--border-radius: var(--chat--border-radius);
--chat--window--margin-bottom: var(--chat--spacing);
/* Header Styles */
--chat--header-height: auto;
--chat--header--padding: var(--chat--spacing);
--chat--header--background: var(--chat--color-dark);
--chat--header--color: var(--chat--color-light);
--chat--header--border-top: none;
--chat--header--border-bottom: none;
--chat--header--border-left: none;
--chat--header--border-right: none;
--chat--heading--font-size: 2em;
--chat--subtitle--font-size: inherit;
--chat--subtitle--line-height: 1.8;
/* Message Styles */
--chat--message--font-size: 1rem;
--chat--message--padding: var(--chat--spacing);
--chat--message--border-radius: var(--chat--border-radius);
--chat--message-line-height: 1.5;
--chat--message--margin-bottom: calc(var(--chat--spacing) * 1);
--chat--message--bot--background: var(--chat--color-white);
--chat--message--bot--color: var(--chat--color-dark);
--chat--message--bot--border: none;
--chat--message--user--background: var(--chat--color--secondary);
--chat--message--user--color: var(--chat--color-white);
--chat--message--user--border: none;
--chat--message--pre--background: rgba(0, 0, 0, 0.05);
--chat--messages-list--padding: var(--chat--spacing);
/* Toggle Button */
--chat--toggle--size: 64px;
--chat--toggle--width: var(--chat--toggle--size);
--chat--toggle--height: var(--chat--toggle--size);
--chat--toggle--border-radius: 50%;
--chat--toggle--background: var(--chat--color--primary);
--chat--toggle--hover--background: var(--chat--color--primary-shade-50);
--chat--toggle--active--background: var(--chat--color--primary--shade-100);
--chat--toggle--color: var(--chat--color-white);
/* Input Area */
--chat--textarea--height: 50px;
--chat--textarea--max-height: 30rem;
--chat--input--width: 100%;
--chat--input--font-size: inherit;
--chat--input--border: 0;
--chat--input--border-radius: 0;
--chat--input--padding: 0.8rem;
--chat--input--background: var(--chat--color-white);
--chat--input--text-color: initial;
--chat--input--line-height: 1.5;
--chat--input--placeholder--font-size: var(--chat--input--font-size);
--chat--input--border-active: 0;
--chat--input--left--panel--width: 2rem;
--chat--input--container--background: var(--chat--color-white);
--chat--input--container--border: 0;
--chat--input--container--border-radius: 0;
--chat--input--container--padding: 0;
--chat--input--button--border-radius: 16px;
/* Button Styles */
--chat--button--padding: calc(var(--chat--spacing) * 5 / 8) var(--chat--spacing);
--chat--button--border-radius: var(--chat--border-radius);
--chat--button--font-size: 1rem;
--chat--button--line-height: 1;
--chat--button--color--primary: var(--chat--color-light);
--chat--button--background--primary: var(--chat--color--secondary);
--chat--button--border--primary: none;
--chat--button--color--primary--hover: var(--chat--color-light);
--chat--button--background--primary--hover: var(--chat--color-secondary-shade-50);
--chat--button--border--primary--hover: none;
--chat--button--color--primary--disabled: var(--chat--color-light);
--chat--button--background--primary--disabled: #81bbb1;
--chat--button--border--primary--disabled: none;
--chat--button--color--secondary: var(--chat--color-light);
--chat--button--background--secondary: hsl(0, 0%, 58%);
--chat--button--border--secondary: none;
--chat--button--color--secondary--hover: var(--chat--color-light);
--chat--button--background--secondary--hover: hsl(0, 0%, 51%);
--chat--button--border--secondary--hover: none;
--chat--button--color--secondary--disabled: var(--chat--color-light);
--chat--button--background--secondary--disabled: hsl(0, 0%, 78%);
--chat--button--border--secondary--disabled: none;
--chat--close--button--color-hover: var(--chat--color--primary);
/* Message Action Buttons */
--chat--message--actions--color: var(--chat--color--primary);
--chat--message--actions--gap: var(--chat--spacing);
/* Send and File Buttons */
--chat--input--send--button--background: var(--chat--color-white);
--chat--input--send--button--color: var(--chat--color--secondary);
--chat--input--send--button--background-hover: var(--chat--color--primary-shade-50);
--chat--input--send--button--color-hover: var(--chat--color-secondary-shade-50);
--chat--input--file--button--background: var(--chat--color-white);
--chat--input--file--button--color: var(--chat--color--secondary);
--chat--input--file--button--background-hover: var(--chat--input--file--button--background);
--chat--input--file--button--color-hover: var(--chat--color-secondary-shade-50);
--chat--files-spacing: 0.25rem;
/* Body and Footer */
--chat--body--background: var(--chat--color-light);
--chat--footer--padding: 0;
--chat--footer--background: var(--chat--color-light);
--chat--footer--color: var(--chat--color-dark);
--chat--footer--border-top: 1px solid var(--chat--color-light-shade-100);
}

View File

@@ -0,0 +1,2 @@
@use 'tokens';
@use 'markdown';

View File

@@ -0,0 +1,632 @@
@use 'sass:meta';
@include meta.load-css('highlight.js/styles/github.css');
@mixin hljs-dark-theme {
@include meta.load-css('highlight.js/styles/github-dark-dimmed.css');
}
body[data-theme='dark'] {
@include hljs-dark-theme;
}
@media (prefers-color-scheme: dark) {
body:not([data-theme]) {
@include hljs-dark-theme;
}
}
// https://github.com/pxlrbt/markdown-css
.chat-message-markdown {
/*
universalize.css (v1.0.2) — by Alexander Sandberg (https://alexandersandberg.com)
------------------------------------------------------------------------------
Based on Sanitize.css (https://github.com/csstools/sanitize.css).
(all) = Used for all browsers.
x lines = Applies to x lines down, including current line.
------------------------------------------------------------------------------
*/
/*
1. Use default UI font (all)
2. Make font size more accessible to everyone (all)
3. Make line height consistent (all)
4. Prevent font size adjustment after orientation changes (IE, iOS)
5. Prevent overflow from long words (all)
*/
line-height: 1.4; /* 3 */
-webkit-text-size-adjust: 100%; /* 4 */
word-break: break-word; /* 5 */
/*
Prevent padding and border from affecting width (all)
*/
*,
::before,
::after {
box-sizing: border-box;
}
/*
1. Inherit text decoration (all)
2. Inherit vertical alignment (all)
*/
::before,
::after {
text-decoration: inherit; /* 1 */
vertical-align: inherit; /* 2 */
}
/*
Remove inconsistent and unnecessary margins
*/
body, /* (all) */
dl dl, /* (Chrome, Edge, IE, Safari) 5 lines */
dl ol,
dl ul,
ol dl,
ul dl,
ol ol, /* (Edge 18-, IE) 4 lines */
ol ul,
ul ol,
ul ul,
button, /* (Safari) 3 lines */
input,
select,
textarea {
/* (Firefox, Safari) */
margin: 0;
}
/*
1. Show overflow (IE18-, IE)
2. Correct sizing (Firefox)
*/
hr {
overflow: visible;
height: 0;
}
/*
Add correct display
*/
main, /* (IE11) */
details {
/* (Edge 18-, IE) */
display: block;
}
summary {
/* (all) */
display: list-item;
}
/*
Remove style on navigation lists (all)
*/
nav ol,
nav ul {
list-style: none;
padding: 0;
}
/*
1. Use default monospace UI font (all)
2. Correct font sizing (all)
*/
pre,
code,
kbd,
samp {
font-family:
var(--font-family--monospace),
/* macOS emoji */ 'Apple Color Emoji',
/* Windows emoji */ 'Segoe UI Emoji',
/* Windows emoji */ 'Segoe UI Symbol',
/* Linux emoji */ 'Noto Color Emoji'; /* 1 */
font-size: 1em; /* 2 */
}
/*
1. Change cursor for <abbr> elements (all)
2. Add correct text decoration (Edge 18-, IE, Safari)
*/
abbr[title] {
cursor: help; /* 1 */
text-decoration: underline; /* 2 */
-webkit-text-decoration: underline dotted;
text-decoration: underline dotted; /* 2 */
}
/*
Add correct font weight (Chrome, Edge, Safari)
*/
b,
strong {
font-weight: var(--font-weight--bold);
}
/*
Add correct font size (all)
*/
small {
font-size: 80%;
opacity: 0.8; /* or some other way of differentiating it from body text */
}
/*
Change alignment on media elements (all)
*/
audio,
canvas,
iframe,
img,
svg,
video {
vertical-align: middle;
}
img {
max-width: 100%;
height: auto;
}
/*
Remove border on iframes (all)
*/
iframe {
border-style: none;
}
/*
Change fill color to match text (all)
*/
svg:not([fill]) {
fill: currentColor;
}
/*
Hide overflow (IE11)
*/
svg:not(:root) {
overflow: hidden;
}
/*
Show overflow (Edge 18-, IE)
*/
button,
input {
overflow: visible;
}
/*
Remove inheritance of text transform (Edge 18-, Firefox, IE)
*/
button,
select {
text-transform: none;
}
/*
Correct inability to style buttons (iOS, Safari)
*/
button,
[type='button'],
[type='reset'],
[type='submit'] {
-webkit-appearance: button;
}
/*
1. Fix inconsistent appearance (all)
2. Correct padding (Firefox)
*/
fieldset {
border: 1px solid #666; /* 1 */
padding: 0.35em 0.75em 0.625em; /* 2 */
}
/*
1. Correct color inheritance from <fieldset> (IE)
2. Correct text wrapping (Edge 18-, IE)
*/
legend {
color: inherit; /* 1 */
display: table; /* 2 */
max-width: 100%; /* 2 */
white-space: normal; /* 2 */
}
/*
1. Add correct display (Edge 18-, IE)
2. Add correct vertical alignment (Chrome, Edge, Firefox)
*/
progress {
display: inline-block; /* 1 */
vertical-align: baseline; /* 2 */
}
/*
1. Remove default vertical scrollbar (IE)
2. Change resize direction (all)
*/
textarea {
overflow: auto; /* 1 */
resize: vertical; /* 2 */
}
/*
1. Correct outline style (Safari)
2. Correct odd appearance (Chrome, Edge, Safari)
*/
[type='search'] {
outline-offset: -2px; /* 1 */
-webkit-appearance: textfield; /* 2 */
}
/*
Correct cursor style of increment and decrement buttons (Safari)
*/
::-webkit-inner-spin-button,
::-webkit-outer-spin-button {
height: auto;
}
/*
Correct text style (Chrome, Edge, Safari)
*/
::-webkit-input-placeholder {
color: inherit;
opacity: 0.54;
}
/*
Remove inner padding (Chrome, Edge, Safari on macOS)
*/
::-webkit-search-decoration {
-webkit-appearance: none;
}
/*
1. Inherit font properties (Safari)
2. Correct inability to style upload buttons (iOS, Safari)
*/
::-webkit-file-upload-button {
font: inherit; /* 1 */
-webkit-appearance: button; /* 2 */
}
/*
Remove inner border and padding of focus outlines (Firefox)
*/
::-moz-focus-inner {
border-style: none;
padding: 0;
}
/*
Restore focus outline style (Firefox)
*/
:-moz-focusring {
outline: 1px dotted ButtonText;
}
/*
Remove :invalid styles (Firefox)
*/
:-moz-ui-invalid {
box-shadow: none;
}
/*
Change cursor on busy elements (all)
*/
[aria-busy='true'] {
cursor: progress;
}
/*
Change cursor on control elements (all)
*/
[aria-controls] {
cursor: pointer;
}
/*
Change cursor on disabled, non-editable, or inoperable elements (all)
*/
[aria-disabled='true'],
[disabled] {
cursor: not-allowed;
}
/*
Change display on visually hidden accessible elements (all)
*/
[aria-hidden='false'][hidden] {
display: inline;
display: initial;
}
[aria-hidden='false'][hidden]:not(:focus) {
clip: rect(0, 0, 0, 0);
position: absolute;
}
/*
Print out URLs after links (all)
*/
@media print {
a[href^='http']::after {
content: ' (' attr(href) ')';
}
}
/* ----- Variables ----- */
/* Light mode default, dark mode if recognized as preferred */
:root {
--background-main: #fefefe;
--background-element: #eee;
--background-inverted: #282a36;
--text-main: #1f1f1f;
--text-alt: #333;
--text-inverted: #fefefe;
--border-element: #282a36;
--theme: #7a283a;
--theme-light: hsl(0, 25%, 65%);
--theme-dark: hsl(0, 25%, 45%);
}
/* @media (prefers-color-scheme: dark) {
:root {
--background-main: #282a36;
--text-main: #fefefe;
}
} */
/* ----- Base ----- */
body {
margin: auto;
max-width: 36rem;
min-height: 100%;
overflow-x: hidden;
background: var(--background-main);
color: var(--text-main);
}
/* ----- Typography ----- */
h1,
h2,
h3,
h4,
h5,
h6 {
margin: 2rem 0 0.8em;
}
/*
Heading sizes based on a modular scale of 1.25 (all)
*/
h1 {
font-size: 2.441rem;
line-height: 1.1;
}
h2 {
font-size: 1.953rem;
line-height: 1.15;
}
h3 {
font-size: 1.563rem;
line-height: 1.2;
}
h4 {
font-size: 1.25rem;
line-height: 1.3;
}
h5 {
font-size: 1rem;
line-height: 1.4;
}
h6 {
font-size: 1rem;
line-height: 1.4;
/* differentiate from h5, somehow. color or style? */
}
p,
ul,
ol,
figure {
margin: 0.6rem 0 1.2rem;
}
/*
Subtitles
- Change to header h* + span instead?
- Add support for taglines (small title above main) as well? Needs <header>:
header > span:first-child
*/
h1 span,
h2 span,
h3 span,
h4 span,
h5 span,
h6 span {
display: block;
font-size: 1em;
font-style: italic;
font-weight: var(--font-weight--regular);
line-height: 1.3;
margin-top: 0.3em;
}
h1 span {
font-size: 0.6em;
}
h2 span {
font-size: 0.7em;
}
h3 span {
font-size: 0.8em;
}
h4 span {
font-size: 0.9em;
}
mark {
background: pink; /* change to proper color, based on theme */
padding: 0.1em 0.15em;
}
/*
Define a custom tab-size in browsers that support it.
*/
pre {
-moz-tab-size: 4;
-o-tab-size: 4;
tab-size: 4;
}
/*
Long underlined text can be hard to read for dyslexics. Replace with bold.
*/
ins {
text-decoration: none;
font-weight: var(--font-weight--bold);
}
blockquote {
border-left: 0.3rem solid #7a283a;
border-left: 0.3rem solid var(--theme);
margin: 0.6rem 0 1.2rem;
padding-left: 2rem;
}
blockquote p {
font-size: 1.2em;
font-style: italic;
}
figure {
margin: 0;
}
/* ----- Layout ----- */
a {
color: #7a283a;
color: var(--theme);
text-decoration: underline;
}
a:hover {
color: hsl(0, 25%, 65%);
color: var(--theme-light);
}
a:active {
color: hsl(0, 25%, 45%);
color: var(--theme-dark);
}
:focus {
outline: 3px solid hsl(0, 25%, 65%);
outline: 3px solid var(--theme-light);
outline-offset: 3px;
}
input {
background: #eee;
background: var(--background-element);
padding: 0.5rem 0.65rem;
border-radius: 0.5rem;
border: 2px solid #282a36;
border: 2px solid var(--border-element);
font-size: 1rem;
}
kbd, /* different style for kbd? */
code {
padding: 0.1em 0.25em;
border-radius: 0.2rem;
-webkit-box-decoration-break: clone;
box-decoration-break: clone;
}
kbd > kbd {
padding-left: 0;
padding-right: 0;
}
pre code {
display: block;
padding: 0 0 0.5rem 0.5rem;
word-break: normal;
overflow-x: auto;
}
/* ----- Forms ----- */
/* ----- Misc ----- */
[tabindex='-1']:focus {
outline: none;
}
[hidden] {
display: none;
}
[aria-disabled],
[disabled] {
cursor: not-allowed !important;
pointer-events: none !important;
}
/*
Style anchor links only
*/
a[href^='#']::after {
content: '';
}
/*
Skip link
*/
body > a:first-child {
background: #7a283a;
background: var(--theme);
border-radius: 0.2rem;
color: #fefefe;
color: var(--text-inverted);
padding: 0.3em 0.5em;
position: absolute;
top: -10rem;
}
body > a:first-child:focus {
top: 1rem;
}
// Lists
ul,
ol {
padding-left: 1.5rem;
margin-bottom: 1rem;
li {
margin-bottom: 0.5rem;
}
}
}

View File

@@ -0,0 +1,24 @@
/// <reference types="vite/client" />
declare module 'markdown-it-task-lists' {
import type { PluginWithOptions } from 'markdown-it';
declare namespace markdownItTaskLists {
interface Config {
enabled?: boolean;
label?: boolean;
labelAfter?: boolean;
}
}
declare const markdownItTaskLists: PluginWithOptions<markdownItTaskLists.Config>;
export = markdownItTaskLists;
}
declare module '~icons/*' {
import type { FunctionalComponent, SVGAttributes } from 'vue';
const component: FunctionalComponent<SVGAttributes>;
export default component;
}

View File

@@ -0,0 +1,3 @@
import { createEventBus } from '@n8n/chat/utils';
export const chatEventBus = createEventBus();

View File

@@ -0,0 +1 @@
export * from './chatEventBus';

View File

@@ -0,0 +1,44 @@
import './main.scss';
import { createApp } from 'vue';
import { defaultMountingTarget, defaultOptions } from '@n8n/chat/constants';
import { ChatPlugin } from '@n8n/chat/plugins';
import type { ChatOptions } from '@n8n/chat/types';
import { createDefaultMountingTarget } from '@n8n/chat/utils';
import App from './App.vue';
export function createChat(options?: Partial<ChatOptions>) {
const resolvedOptions: ChatOptions = {
...defaultOptions,
...options,
webhookConfig: {
...defaultOptions.webhookConfig,
...options?.webhookConfig,
},
i18n: {
...defaultOptions.i18n,
...options?.i18n,
en: {
...defaultOptions.i18n?.en,
...options?.i18n?.en,
},
},
theme: {
...defaultOptions.theme,
...options?.theme,
},
};
const mountingTarget = resolvedOptions.target ?? defaultMountingTarget;
if (typeof mountingTarget === 'string') {
createDefaultMountingTarget(mountingTarget);
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
const app = createApp(App);
app.use(ChatPlugin, resolvedOptions);
app.mount(mountingTarget);
return app;
}

View File

@@ -0,0 +1,6 @@
@use 'sass:meta';
@use 'css';
.n8n-chat {
@include meta.load-css('highlight.js/styles/github');
}

View File

@@ -0,0 +1,364 @@
import { v4 as uuidv4 } from 'uuid';
import { type Plugin, computed, nextTick, ref, type Ref } from 'vue';
import * as api from '@n8n/chat/api';
import { ChatOptionsSymbol, ChatSymbol, localStorageSessionIdKey } from '@n8n/chat/constants';
import { chatEventBus } from '@n8n/chat/event-buses';
import type {
ChatMessage,
ChatOptions,
ChatMessageText,
SendMessageResponse,
} from '@n8n/chat/types';
import { StreamingMessageManager, createBotMessage } from '@n8n/chat/utils/streaming';
import {
handleStreamingChunk,
handleNodeStart,
handleNodeComplete,
} from '@n8n/chat/utils/streamingHandlers';
/**
* Creates a new user message object with a unique ID
* @param text - The message text content
* @param files - Optional array of files attached to the message
* @returns A ChatMessage object representing the user's message
*/
function createUserMessage(text: string, files: File[] = []): ChatMessage {
return {
id: uuidv4(),
text,
sender: 'user',
files,
};
}
/**
* Extracts text content from a message response
* Falls back to JSON stringification if no text is found but the response object has data
* @param response - The response object from the API
* @returns The extracted text message or stringified response
*/
function processMessageResponse(response: SendMessageResponse): string {
let textMessage = response.output ?? response.text ?? response.message ?? '';
if (typeof textMessage === 'object' && textMessage.type && textMessage.type === 'text') {
return textMessage.text;
}
if (textMessage === '' && Object.keys(response).length > 0) {
try {
textMessage = JSON.stringify(response, null, 2);
} catch (e) {
// Failed to stringify the object so fallback to empty string
}
}
return textMessage as string;
}
interface EmptyStreamConfig {
receivedMessage: Ref<ChatMessageText | null>;
messages: Ref<ChatMessage[]>;
}
/**
* Handles the case when a streaming response returns no chunks
* Creates an error message explaining the likely cause
* @param config - Configuration object containing message refs
*/
function handleEmptyStreamResponse(config: EmptyStreamConfig): void {
const { receivedMessage, messages } = config;
if (!receivedMessage.value) {
receivedMessage.value = createBotMessage();
messages.value.push(receivedMessage.value);
} else {
// Check if any existing messages have content
const hasContent = messages.value.some(
(msg) => msg.sender === 'bot' && 'text' in msg && msg.text.trim().length > 0,
);
if (!hasContent) {
receivedMessage.value = createBotMessage();
messages.value.push(receivedMessage.value);
}
}
receivedMessage.value.text =
'[No response received. This could happen if streaming is enabled in the trigger but disabled in agent node(s)]';
}
interface ErrorConfig {
error: unknown;
receivedMessage: Ref<ChatMessageText | null>;
messages: Ref<ChatMessage[]>;
}
/**
* Handles errors that occur during message sending
* Creates an error message for the user and logs the error to console
* @param config - Configuration object containing error and message refs
*/
function handleMessageError(config: ErrorConfig): void {
const { error, receivedMessage, messages } = config;
receivedMessage.value ??= createBotMessage();
receivedMessage.value.text = 'Error: Failed to receive response';
// Ensure the error message is added to messages array if not already there
if (!messages.value.includes(receivedMessage.value)) {
messages.value.push(receivedMessage.value);
}
console.error('Chat API error:', error);
}
interface StreamingMessageConfig {
text: string;
files: File[];
sessionId: string;
options: ChatOptions;
messages: Ref<ChatMessage[]>;
receivedMessage: Ref<ChatMessageText | null>;
streamingManager: StreamingMessageManager;
blockUserInput: Ref<boolean>;
}
/**
* Handles sending messages with streaming enabled
* Sets up streaming event handlers and processes the response chunks
* @param config - Configuration object for streaming message handling
*/
async function handleStreamingMessage(config: StreamingMessageConfig): Promise<boolean> {
const {
text,
files,
sessionId,
options,
messages,
receivedMessage,
streamingManager,
blockUserInput,
} = config;
const handlers: api.StreamingEventHandlers = {
onChunk: (chunk: string, nodeId?: string, runIndex?: number) => {
handleStreamingChunk(chunk, nodeId, streamingManager, receivedMessage, messages, runIndex);
},
onBeginMessage: (nodeId: string, runIndex?: number) => {
handleNodeStart(nodeId, streamingManager, runIndex);
},
onEndMessage: async (nodeId: string, runIndex?: number) => {
const shouldBlock = await handleNodeComplete(
nodeId,
streamingManager,
runIndex,
text,
options,
messages,
);
if (shouldBlock) {
blockUserInput.value = true;
}
},
};
const { hasReceivedChunks } = await api.sendMessageStreaming(
text,
files,
sessionId,
options,
handlers,
);
// Check if no chunks were received (empty stream)
if (!hasReceivedChunks) {
handleEmptyStreamResponse({ receivedMessage, messages });
}
return hasReceivedChunks;
}
interface NonStreamingMessageConfig {
text: string;
files: File[];
sessionId: string;
options: ChatOptions;
}
/**
* Handles sending messages without streaming
* Sends the message and processes the complete response
* @param config - Configuration object for non-streaming message handling
* @returns The API response or a bot message
*/
async function handleNonStreamingMessage(
config: NonStreamingMessageConfig,
): Promise<{ response?: SendMessageResponse; botMessage?: ChatMessageText }> {
const { text, files, sessionId, options } = config;
const sendMessageResponse = await api.sendMessage(text, files, sessionId, options);
if (sendMessageResponse?.executionStarted) {
return { response: sendMessageResponse };
}
const receivedMessage = createBotMessage();
receivedMessage.text = processMessageResponse(sendMessageResponse);
return { response: sendMessageResponse, botMessage: receivedMessage };
}
export const ChatPlugin: Plugin<ChatOptions> = {
install(app, options) {
app.provide(ChatOptionsSymbol, options);
const messages = ref<ChatMessage[]>([]);
const currentSessionId = ref<string | null>(null);
const waitingForResponse = ref(false);
const blockUserInput = ref(false);
const initialMessages = computed<ChatMessage[]>(() =>
(options.initialMessages ?? []).map((text) => ({
id: uuidv4(),
text,
sender: 'bot',
})),
);
async function sendMessage(
text: string,
files: File[] = [],
): Promise<SendMessageResponse | null> {
// Create and add user message
const sentMessage = createUserMessage(text, files);
messages.value.push(sentMessage);
waitingForResponse.value = true;
void nextTick(() => {
chatEventBus.emit('scrollToBottom');
});
const receivedMessage = ref<ChatMessageText | null>(null);
const streamingManager = new StreamingMessageManager();
try {
// Call beforeMessageSent handler if provided
if (options.beforeMessageSent) {
await options.beforeMessageSent(text);
}
if (options?.enableStreaming) {
const hasReceivedChunks = await handleStreamingMessage({
text,
files,
sessionId: currentSessionId.value as string,
options,
messages,
receivedMessage,
streamingManager,
blockUserInput,
});
// Call afterMessageSent handler if provided
if (options.afterMessageSent) {
await options.afterMessageSent(text, {
hasReceivedChunks,
message: receivedMessage.value ?? '',
});
}
} else {
const result = await handleNonStreamingMessage({
text,
files,
sessionId: currentSessionId.value as string,
options,
});
if (result.response?.executionStarted) {
waitingForResponse.value = false;
return result.response;
}
if (result.botMessage) {
receivedMessage.value = result.botMessage;
messages.value.push(result.botMessage);
}
if (options.afterMessageSent) {
await options.afterMessageSent(text, result.response);
}
}
} catch (error) {
handleMessageError({ error, receivedMessage, messages });
} finally {
waitingForResponse.value = false;
}
void nextTick(() => {
chatEventBus.emit('scrollToBottom');
});
return null;
}
async function loadPreviousSession() {
if (!options.loadPreviousSession) {
return;
}
// Use provided sessionId if available, otherwise check localStorage or generate new one
let sessionId = options.sessionId ?? localStorage.getItem(localStorageSessionIdKey);
// Save to localStorage if it was newly generated
if (!sessionId) {
sessionId = uuidv4();
localStorage.setItem(localStorageSessionIdKey, sessionId);
}
const previousMessagesResponse = await api.loadPreviousSession(sessionId, options);
messages.value = (previousMessagesResponse?.data || []).map((message, index) => ({
id: `${index}`,
text: message.kwargs.content,
sender: message.id.includes('HumanMessage') ? 'user' : 'bot',
}));
// Always set currentSessionId to preserve manually set sessionIds
currentSessionId.value = sessionId;
// Store the sessionId in localStorage for future use
localStorage.setItem(localStorageSessionIdKey, sessionId);
return sessionId;
}
// eslint-disable-next-line @typescript-eslint/require-await
async function startNewSession() {
// Use provided sessionId if available, otherwise generate new one
const existingSessionId = localStorage.getItem(localStorageSessionIdKey);
// Only preserve existing sessionId if loadPreviousSession is enabled
// When loadPreviousSession is false, always generate a new session
if (existingSessionId && options.loadPreviousSession && !options.sessionId) {
// Preserve existing sessionId (e.g., manually set by user)
currentSessionId.value = existingSessionId;
} else {
currentSessionId.value = options.sessionId ?? uuidv4();
// Generate new UUID and save to localStorage
localStorage.setItem(localStorageSessionIdKey, currentSessionId.value);
}
}
const chatStore = {
initialMessages,
messages,
currentSessionId,
waitingForResponse,
blockUserInput,
loadPreviousSession,
startNewSession,
sendMessage,
};
app.provide(ChatSymbol, chatStore);
app.config.globalProperties.$chat = chatStore;
},
};

View File

@@ -0,0 +1 @@
export * from './chat';

View File

@@ -0,0 +1,17 @@
import type { Ref } from 'vue';
import type { ChatMessage } from '@n8n/chat/types/messages';
import type { SendMessageResponse } from './webhook';
export interface Chat {
initialMessages: Ref<ChatMessage[]>;
messages: Ref<ChatMessage[]>;
currentSessionId: Ref<string | null>;
waitingForResponse: Ref<boolean>;
blockUserInput: Ref<boolean>;
loadPreviousSession?: () => Promise<string | undefined>;
startNewSession?: () => Promise<void>;
sendMessage: (text: string, files?: File[]) => Promise<SendMessageResponse | null>;
ws?: WebSocket | null;
}

View File

@@ -0,0 +1,5 @@
declare module 'virtual:icons/*' {
import { FunctionalComponent, SVGAttributes } from 'vue';
const component: FunctionalComponent<SVGAttributes>;
export default component;
}

View File

@@ -0,0 +1,5 @@
export type * from './chat';
export type * from './messages';
export type * from './options';
export type * from './webhook';
export type * from './streaming';

View File

@@ -0,0 +1,19 @@
export type ChatMessage<T = Record<string, unknown>> = ChatMessageComponent<T> | ChatMessageText;
export interface ChatMessageComponent<T = Record<string, unknown>> extends ChatMessageBase {
type: 'component';
key: string;
arguments: T;
}
export interface ChatMessageText extends ChatMessageBase {
type?: 'text';
text: string;
}
interface ChatMessageBase {
id: string;
transparent?: boolean;
sender: 'user' | 'bot';
files?: File[];
}

View File

@@ -0,0 +1,47 @@
import type { Component, Ref } from 'vue';
import type { ChatMessage } from './messages';
import type { SendMessageResponse } from './webhook';
export interface ChatOptions {
webhookUrl: string;
webhookConfig?: {
method?: 'GET' | 'POST';
headers?: Record<string, string>;
};
target?: string | Element;
mode?: 'window' | 'fullscreen';
showWindowCloseButton?: boolean;
showWelcomeScreen?: boolean;
loadPreviousSession?: boolean;
sessionId?: string;
chatInputKey?: string;
chatSessionKey?: string;
defaultLanguage?: 'en';
initialMessages?: string[];
messageHistory?: ChatMessage[];
metadata?: Record<string, unknown>;
i18n: Record<
string,
{
title: string;
subtitle: string;
footer: string;
getStarted: string;
inputPlaceholder: string;
closeButtonTooltip: string;
[message: string]: string;
}
>;
theme?: {};
messageComponents?: Record<string, Component>;
disabled?: Ref<boolean>;
allowFileUploads?: Ref<boolean> | boolean;
allowedFilesMimeTypes?: Ref<string> | string;
enableStreaming?: boolean;
// Event handlers for message lifecycle
beforeMessageSent?: (message: string) => void | Promise<void>;
afterMessageSent?: (message: string, response?: SendMessageResponse) => void | Promise<void>;
// Message action options
enableMessageActions?: boolean;
}

View File

@@ -0,0 +1,19 @@
export type ChunkType = 'begin' | 'item' | 'end' | 'error';
export interface StructuredChunk {
type: ChunkType;
content?: string;
metadata: {
nodeId: string;
nodeName: string;
timestamp: number;
runIndex: number;
itemIndex: number;
};
}
export interface NodeStreamingState {
nodeId: string;
chunks: string[];
isActive: boolean;
startTime: number;
}

View File

@@ -0,0 +1,24 @@
import { type ChatMessageText } from './messages';
export interface LoadPreviousSessionResponseItem {
id: string[];
kwargs: {
content: string;
additional_kwargs: Record<string, unknown>;
};
lc: number;
type: string;
}
export interface LoadPreviousSessionResponse {
data: LoadPreviousSessionResponseItem[];
}
export interface SendMessageResponse {
output?: string;
text?: string;
message?: string | ChatMessageText;
executionId?: string;
executionStarted?: boolean;
hasReceivedChunks?: boolean;
}

View File

@@ -0,0 +1,51 @@
// eslint-disable-next-line @typescript-eslint/no-restricted-types
export type CallbackFn = Function;
export type UnregisterFn = () => void;
export interface EventBus {
on: (eventName: string, fn: CallbackFn) => UnregisterFn;
off: (eventName: string, fn: CallbackFn) => void;
emit: <T = Event>(eventName: string, event?: T) => void;
}
export function createEventBus(): EventBus {
const handlers = new Map<string, CallbackFn[]>();
function off(eventName: string, fn: CallbackFn) {
const eventFns = handlers.get(eventName);
if (eventFns) {
eventFns.splice(eventFns.indexOf(fn) >>> 0, 1);
}
}
function on(eventName: string, fn: CallbackFn): UnregisterFn {
let eventFns = handlers.get(eventName);
if (!eventFns) {
eventFns = [fn];
} else {
eventFns.push(fn);
}
handlers.set(eventName, eventFns);
return () => off(eventName, fn);
}
function emit<T = Event>(eventName: string, event?: T) {
const eventFns = handlers.get(eventName);
if (eventFns) {
eventFns.slice().forEach(async (handler) => {
await handler(event);
});
}
}
return {
on,
off,
emit,
};
}

View File

@@ -0,0 +1,3 @@
export * from './event-bus';
export * from './mount';
export * from './utils';

View File

@@ -0,0 +1,16 @@
export function createDefaultMountingTarget(mountingTarget: string) {
const mountingTargetNode = document.querySelector(mountingTarget);
if (!mountingTargetNode) {
const generatedMountingTargetNode = document.createElement('div');
if (mountingTarget.startsWith('#')) {
generatedMountingTargetNode.id = mountingTarget.replace('#', '');
}
if (mountingTarget.startsWith('.')) {
generatedMountingTargetNode.classList.add(mountingTarget.replace('.', ''));
}
document.body.appendChild(generatedMountingTargetNode);
}
}

View File

@@ -0,0 +1,133 @@
import { v4 as uuidv4 } from 'uuid';
import type { ChatMessage, ChatMessageText } from '@n8n/chat/types';
export interface NodeRunData {
content: string;
isComplete: boolean;
message: ChatMessageText;
}
/**
* Manages the state of streaming messages for nodes.
* This class is responsible for tracking the state of each run of nodes,
* including the content of each chunk, whether it's complete, and the message
* object that represents the run of a given node.
*/
export class StreamingMessageManager {
private nodeRuns = new Map<string, NodeRunData>();
private runOrder: string[] = [];
private activeRuns = new Set<string>();
constructor() {}
private getRunKey(nodeId: string, runIndex?: number): string {
if (runIndex !== undefined) {
return `${nodeId}-${runIndex}`;
}
return nodeId;
}
initializeRun(nodeId: string, runIndex?: number): ChatMessageText {
const runKey = this.getRunKey(nodeId, runIndex);
if (!this.nodeRuns.has(runKey)) {
const message = createBotMessage();
this.nodeRuns.set(runKey, {
content: '',
isComplete: false,
message,
});
this.runOrder.push(runKey);
return message;
}
return this.nodeRuns.get(runKey)!.message;
}
registerRunStart(nodeId: string, runIndex?: number): void {
const runKey = this.getRunKey(nodeId, runIndex);
this.activeRuns.add(runKey);
}
addRunToActive(nodeId: string, runIndex?: number): ChatMessageText {
const runKey = this.getRunKey(nodeId, runIndex);
this.activeRuns.add(runKey);
return this.initializeRun(nodeId, runIndex);
}
removeRunFromActive(nodeId: string, runIndex?: number): void {
const runKey = this.getRunKey(nodeId, runIndex);
this.activeRuns.delete(runKey);
const runData = this.nodeRuns.get(runKey);
if (runData) {
runData.isComplete = true;
}
}
addChunkToRun(nodeId: string, chunk: string, runIndex?: number): ChatMessageText | null {
const runKey = this.getRunKey(nodeId, runIndex);
const runData = this.nodeRuns.get(runKey);
if (runData) {
runData.content += chunk;
// Create a new message object to trigger Vue reactivity
const updatedMessage: ChatMessageText = {
...runData.message,
text: runData.content,
};
runData.message = updatedMessage;
return updatedMessage;
}
return null;
}
getRunMessage(nodeId: string, runIndex?: number): ChatMessageText | null {
const runKey = this.getRunKey(nodeId, runIndex);
const runData = this.nodeRuns.get(runKey);
return runData?.message ?? null;
}
areAllRunsComplete(): boolean {
return Array.from(this.nodeRuns.values()).every((data) => data.isComplete);
}
getRunCount(): number {
return this.runOrder.length;
}
getActiveRunCount(): number {
return this.activeRuns.size;
}
getAllMessages(): ChatMessageText[] {
return this.runOrder
.map((key) => this.nodeRuns.get(key)?.message)
.filter((message): message is ChatMessageText => message !== undefined);
}
reset(): void {
this.nodeRuns.clear();
this.runOrder = [];
this.activeRuns.clear();
}
}
export function createBotMessage(id?: string): ChatMessageText {
return {
id: id ?? uuidv4(),
type: 'text',
text: '',
sender: 'bot',
};
}
export function updateMessageInArray(
messages: ChatMessage[],
messageId: string,
updatedMessage: ChatMessageText,
): void {
const messageIndex = messages.findIndex((msg: ChatMessage) => msg.id === messageId);
if (messageIndex === -1) {
throw new Error(`Can't update message. No message with id ${messageId} found`);
}
messages[messageIndex] = updatedMessage;
}

View File

@@ -0,0 +1,119 @@
import { nextTick } from 'vue';
import type { Ref } from 'vue';
import { chatEventBus } from '@n8n/chat/event-buses';
import type { ChatMessage, ChatMessageText, ChatOptions } from '@n8n/chat/types';
import type { StreamingMessageManager } from './streaming';
import { createBotMessage, updateMessageInArray } from './streaming';
import { parseBotChatMessageContent, shouldBlockUserInput } from './utils';
export function handleStreamingChunk(
chunk: string,
nodeId: string | undefined,
streamingManager: StreamingMessageManager,
receivedMessage: Ref<ChatMessageText | null>,
messages: Ref<ChatMessage[]>,
runIndex?: number,
): void {
try {
// Only skip empty chunks, but not whitespace only chunks
if (chunk === '') {
return;
}
if (!nodeId) {
// Simple single-node streaming (backwards compatibility)
if (!receivedMessage.value) {
receivedMessage.value = createBotMessage();
messages.value.push(receivedMessage.value);
}
const updatedMessage: ChatMessageText = {
...receivedMessage.value,
text: receivedMessage.value.text + chunk,
};
updateMessageInArray(messages.value, receivedMessage.value.id, updatedMessage);
receivedMessage.value = updatedMessage;
} else {
// Multi-run streaming with separate messages per runIndex
// Create message on first chunk if it doesn't exist
let runMessage = streamingManager.getRunMessage(nodeId, runIndex);
if (!runMessage) {
runMessage = streamingManager.addRunToActive(nodeId, runIndex);
messages.value.push(runMessage);
}
// Add chunk to the run
const updatedMessage = streamingManager.addChunkToRun(nodeId, chunk, runIndex);
if (updatedMessage) {
updateMessageInArray(messages.value, updatedMessage.id, updatedMessage);
}
}
void nextTick(() => {
chatEventBus.emit('scrollToBottom');
});
} catch (error) {
console.error('Error handling stream chunk:', error);
// Continue gracefully without breaking the stream
}
}
export function handleNodeStart(
nodeId: string,
streamingManager: StreamingMessageManager,
runIndex?: number,
): void {
try {
// Just register the run as starting, don't create a message yet
// Message will be created when first chunk arrives
streamingManager.registerRunStart(nodeId, runIndex);
} catch (error) {
console.error('Error handling node start:', error);
}
}
export async function handleNodeComplete(
nodeId: string,
streamingManager: StreamingMessageManager,
runIndex: number | undefined,
userMessage: string,
options: ChatOptions,
messages: Ref<ChatMessage[]>,
): Promise<boolean> {
try {
// Get the completed message before marking it complete
const completedMessage = streamingManager.getRunMessage(nodeId, runIndex);
// Mark the run as complete
streamingManager.removeRunFromActive(nodeId, runIndex);
// Check if the completed streaming message is a HITL component message
if (completedMessage && 'text' in completedMessage) {
const parsed = parseBotChatMessageContent(completedMessage.text);
if (parsed.type === 'component') {
// Replace the text message with the component message in the array
const index = messages.value.findIndex((m) => m.id === completedMessage.id);
if (index !== -1) {
parsed.id = completedMessage.id;
messages.value[index] = parsed;
}
return shouldBlockUserInput(parsed);
}
}
// Call afterMessageSent hook if provided and we have a message
if (options.afterMessageSent && completedMessage) {
await options.afterMessageSent(userMessage, {
message: completedMessage,
hasReceivedChunks: true,
});
}
} catch (error) {
console.error('Error handling node complete:', error);
}
return false;
}

View File

@@ -0,0 +1,70 @@
import { v4 as uuid } from 'uuid';
import { MessageComponentKey } from '../constants';
import type { ChatMessage } from '../types';
const CHAT_NODE_MESSAGE_WITH_BUTTONS_TYPE = 'with-buttons';
interface ChatNodeMessageWithButtons {
type: typeof CHAT_NODE_MESSAGE_WITH_BUTTONS_TYPE;
text: string;
blockUserInput: boolean;
buttons: Array<{
text: string;
link: string;
type: string;
}>;
}
export function constructChatWebsocketUrl(
url: string,
executionId: string,
sessionId: string,
isPublic: boolean,
) {
const baseUrl = new URL(url).origin;
const wsProtocol = baseUrl.startsWith('https') ? 'wss' : 'ws';
const wsUrl = baseUrl.replace(/^https?/, wsProtocol);
return `${wsUrl}/chat?sessionId=${sessionId}&executionId=${executionId}${isPublic ? '&isPublic=true' : ''}`;
}
export function parseBotChatMessageContent(message: string): ChatMessage {
const id = uuid();
let chatMessage: ChatMessage = {
id,
sender: 'bot',
text: message,
};
try {
const parsed = JSON.parse(message) as ChatNodeMessageWithButtons;
if (parsed.type === CHAT_NODE_MESSAGE_WITH_BUTTONS_TYPE) {
chatMessage = {
id,
sender: 'bot',
type: 'component',
key: MessageComponentKey.WITH_BUTTONS,
arguments: {
text: parsed.text,
buttons: parsed.buttons,
blockUserInput: parsed.blockUserInput,
},
};
}
} catch {
// ignore error as the message might be just a string
}
return chatMessage;
}
export function shouldBlockUserInput(message: ChatMessage): boolean {
if (
message.type === 'component' &&
message.key === MessageComponentKey.WITH_BUTTONS &&
typeof message.arguments?.blockUserInput === 'boolean'
) {
return message.arguments?.blockUserInput;
}
return false;
}

View File

@@ -0,0 +1,13 @@
import { baseConfig } from '@n8n/stylelint-config/base';
export default {
...baseConfig,
rules: {
...baseConfig.rules,
// Disable css-var-naming rule for chat package
// Because most var names seem to be breaking
// And it needs to continue to be backwards compatible
// As users could be using it directly on websites and customizing css variables
'@n8n/css-var-naming': null,
},
};

View File

@@ -0,0 +1,25 @@
{
"extends": "@n8n/typescript-config/tsconfig.common.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist",
"baseUrl": "src",
"target": "esnext",
"module": "esnext",
"moduleResolution": "bundler",
"allowJs": true,
"importHelpers": true,
"incremental": false,
"allowSyntheticDefaultImports": true,
"resolveJsonModule": true,
"types": ["vitest/globals"],
"paths": {
"@n8n/chat/*": ["./*"],
"@n8n/design-system*": ["../../design-system/src*"]
},
"lib": ["esnext", "dom", "dom.iterable", "scripthost"],
// TODO: remove all options below this line
"useUnknownInCatchVariables": false
},
"include": ["**/*.ts", "**/*.vue"]
}

View File

@@ -0,0 +1,105 @@
import { defineConfig, mergeConfig, PluginOption } from 'vite';
import { resolve } from 'path';
import { renameSync, writeFileSync, readFileSync } from 'fs';
import vue from '@vitejs/plugin-vue';
import icons from 'unplugin-icons/vite';
import dts from 'vite-plugin-dts';
import { vitestConfig } from '@n8n/vitest-config/frontend';
import pkg from './package.json';
const includeVue = process.env.INCLUDE_VUE === 'true';
const srcPath = resolve(__dirname, 'src');
const packagesDir = resolve(__dirname, '..', '..', '..');
const banner = `/*! Package version @n8n/chat@${pkg.version} */`;
// https://vitejs.dev/config/
export default mergeConfig(
defineConfig({
plugins: [
vue(),
icons({
compiler: 'vue3',
autoInstall: true,
}),
dts(),
{
name: 'rename-css-file',
closeBundle() {
// The chat.css is automatically named based on vite.config.ts library name.
// ChatTrigger Node requires https://cdn.jsdelivr.net/npm/@n8n/chat/dist/style.css
// As such for backwards compatibility, we need to maintain the same name file
const cssPath = resolve(__dirname, 'dist', 'chat.css');
const newCssPath = resolve(__dirname, 'dist', 'style.css');
try {
renameSync(cssPath, newCssPath);
} catch (error) {
console.error('Failed to rename chat.css file:', error);
}
},
},
{
name: 'inject-build-version',
closeBundle() {
const cssPath = resolve(__dirname, 'dist', 'style.css');
try {
const cssContent = readFileSync(cssPath, 'utf-8');
const updatedCssContent = banner + '\n' + cssContent;
writeFileSync(cssPath, updatedCssContent, 'utf-8');
} catch (error) {
console.error('Failed to inject build version into CSS file:', error);
}
},
},
],
resolve: {
alias: [
{
find: '@',
replacement: srcPath,
},
{
find: '@n8n/chat',
replacement: srcPath,
},
{
find: /^@n8n\/chat(.+)$/,
replacement: srcPath + '$1',
},
{
find: /^@n8n\/design-system(.+)$/,
replacement: resolve(packagesDir, 'frontend', '@n8n', 'design-system', 'src$1'),
},
],
},
define: {
'process.env.NODE_ENV': process.env.NODE_ENV ? `"${process.env.NODE_ENV}"` : '"development"',
},
build: {
emptyOutDir: !includeVue,
lib: {
entry: resolve(__dirname, 'src', 'index.ts'),
name: 'N8nChat',
fileName: (format) => (includeVue ? `chat.bundle.${format}.js` : `chat.${format}.js`),
},
rollupOptions: {
// make sure to externalize deps that shouldn't be bundled
// into your library
external: includeVue ? [] : ['vue'],
output: {
exports: 'named',
// inject banner on top of all JS files
banner,
// Provide global variables to use in the UMD build
// for externalized deps
globals: includeVue
? {}
: {
vue: 'Vue',
},
},
},
},
}),
vitestConfig,
);

View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

View File

@@ -0,0 +1,24 @@
# @n8n/composables
A collection of Vue composables that provide common functionality across n8n's Front-End packages.
## Table of Contents
- [Features](#features)
- [Contributing](#contributing)
- [License](#license)
## Features
- **Reusable Logic**: Encapsulate complex stateful logic into composable functions.
- **Consistency**: Ensure consistent patterns and practices across our Vue components.
- **Extensible**: Easily add new composables as our project grows.
- **Optimized**: Fully compatible with the Composition API.
## Contributing
For more details, please read our [CONTRIBUTING.md](CONTRIBUTING.md).
## License
For more details, please read our [LICENSE.md](LICENSE.md).

View File

@@ -0,0 +1,4 @@
{
"$schema": "../../../../node_modules/@biomejs/biome/configuration_schema.json",
"extends": ["../../../../biome.jsonc"]
}

View File

@@ -0,0 +1,7 @@
import { defineConfig } from 'eslint/config';
import { frontendConfig } from '@n8n/eslint-config/frontend';
export default defineConfig(frontendConfig, {
files: ['**/*.test.ts'],
rules: { '@typescript-eslint/no-unsafe-assignment': 'warn' },
});

View File

@@ -0,0 +1,49 @@
{
"name": "@n8n/composables",
"type": "module",
"version": "1.14.0",
"files": [
"dist"
],
"exports": {
"./*": {
"types": "./dist/*.d.mts",
"import": "./dist/*.mjs",
"require": "./dist/*.cjs"
}
},
"scripts": {
"dev": "tsdown --watch",
"build": "tsdown",
"preview": "vite preview",
"typecheck": "vue-tsc --noEmit",
"test": "vitest run",
"test:dev": "vitest --silent=false",
"lint": "eslint src --quiet",
"lint:fix": "eslint src --fix",
"format": "biome format --write . && prettier --write . --ignore-path ../../../../.prettierignore",
"format:check": "biome ci . && prettier --check . --ignore-path ../../../../.prettierignore"
},
"devDependencies": {
"@n8n/eslint-config": "workspace:*",
"@n8n/typescript-config": "workspace:*",
"@n8n/vitest-config": "workspace:*",
"@testing-library/jest-dom": "catalog:frontend",
"@testing-library/user-event": "catalog:frontend",
"@testing-library/vue": "catalog:frontend",
"@vitejs/plugin-vue": "catalog:frontend",
"@vue/tsconfig": "catalog:frontend",
"@vueuse/core": "catalog:frontend",
"vue": "catalog:frontend",
"tsdown": "catalog:",
"typescript": "catalog:",
"vite": "catalog:",
"vitest": "catalog:",
"vue-tsc": "catalog:frontend"
},
"peerDependencies": {
"@vueuse/core": "catalog:frontend",
"vue": "catalog:frontend"
},
"license": "See LICENSE.md file in the root of the repository"
}

View File

@@ -0,0 +1,4 @@
import '@testing-library/jest-dom';
import { configure } from '@testing-library/vue';
configure({ testIdAttribute: 'data-test-id' });

View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

View File

@@ -0,0 +1,125 @@
import { useDeviceSupport } from './useDeviceSupport';
const detectPointerType = (query: string) => {
const isCoarse = query === '(any-pointer: coarse)';
const isFine = query === '(any-pointer: fine)';
return { fine: isFine, coarse: isCoarse };
};
describe('useDeviceSupport()', () => {
beforeEach(() => {
global.window = Object.create(window);
global.navigator = { userAgent: 'test-agent', maxTouchPoints: 0 } as Navigator;
});
describe('isTouchDevice', () => {
it('should be false if window matches `any-pointer: fine` and `!any-pointer: coarse`', () => {
Object.defineProperty(window, 'matchMedia', {
value: vi.fn().mockImplementation((query: string) => {
const { fine, coarse } = detectPointerType(query);
return { matches: fine && !coarse };
}),
});
const { isTouchDevice } = useDeviceSupport();
expect(isTouchDevice).toEqual(false);
});
it('should be false if window matches `any-pointer: fine` and `any-pointer: coarse`', () => {
Object.defineProperty(window, 'matchMedia', {
value: vi.fn().mockImplementation((query: string) => {
const { fine, coarse } = detectPointerType(query);
return { matches: fine && coarse };
}),
});
const { isTouchDevice } = useDeviceSupport();
expect(isTouchDevice).toEqual(false);
});
it('should be true if window matches `any-pointer: coarse` and `!any-pointer: fine`', () => {
Object.defineProperty(window, 'matchMedia', {
value: vi.fn().mockImplementation((query: string) => {
const { fine, coarse } = detectPointerType(query);
return { matches: coarse && !fine };
}),
});
const { isTouchDevice } = useDeviceSupport();
expect(isTouchDevice).toEqual(true);
});
});
describe('isMacOs', () => {
it('should be true for macOS user agent', () => {
Object.defineProperty(navigator, 'userAgent', { value: 'macintosh' });
const { isMacOs } = useDeviceSupport();
expect(isMacOs).toEqual(true);
});
it('should be false for non-macOS user agent', () => {
Object.defineProperty(navigator, 'userAgent', { value: 'windows' });
const { isMacOs } = useDeviceSupport();
expect(isMacOs).toEqual(false);
});
});
describe('controlKeyCode', () => {
it('should return Meta on macOS', () => {
Object.defineProperty(navigator, 'userAgent', { value: 'macintosh' });
const { controlKeyCode } = useDeviceSupport();
expect(controlKeyCode).toEqual('Meta');
});
it('should return Control on non-macOS', () => {
Object.defineProperty(navigator, 'userAgent', { value: 'windows' });
const { controlKeyCode } = useDeviceSupport();
expect(controlKeyCode).toEqual('Control');
});
});
describe('isMobileDevice', () => {
it('should be true for iOS user agent', () => {
Object.defineProperty(navigator, 'userAgent', { value: 'iphone' });
const { isMobileDevice } = useDeviceSupport();
expect(isMobileDevice).toEqual(true);
});
it('should be true for Android user agent', () => {
Object.defineProperty(navigator, 'userAgent', { value: 'android' });
const { isMobileDevice } = useDeviceSupport();
expect(isMobileDevice).toEqual(true);
});
it('should be false for non-mobile user agent', () => {
Object.defineProperty(navigator, 'userAgent', { value: 'windows' });
const { isMobileDevice } = useDeviceSupport();
expect(isMobileDevice).toEqual(false);
});
it('should be true for iPad user agent', () => {
Object.defineProperty(navigator, 'userAgent', { value: 'ipad' });
const { isMobileDevice } = useDeviceSupport();
expect(isMobileDevice).toEqual(true);
});
it('should be true for iPod user agent', () => {
Object.defineProperty(navigator, 'userAgent', { value: 'ipod' });
const { isMobileDevice } = useDeviceSupport();
expect(isMobileDevice).toEqual(true);
});
});
describe('isCtrlKeyPressed()', () => {
it('should return true for metaKey press on macOS', () => {
Object.defineProperty(navigator, 'userAgent', { value: 'macintosh' });
const { isCtrlKeyPressed } = useDeviceSupport();
const event = new KeyboardEvent('keydown', { metaKey: true });
expect(isCtrlKeyPressed(event)).toEqual(true);
});
it('should return true for ctrlKey press on non-macOS', () => {
Object.defineProperty(navigator, 'userAgent', { value: 'windows' });
const { isCtrlKeyPressed } = useDeviceSupport();
const event = new KeyboardEvent('keydown', { ctrlKey: true });
expect(isCtrlKeyPressed(event)).toEqual(true);
});
});
});

View File

@@ -0,0 +1,46 @@
import { computed, ref } from 'vue';
export function useDeviceSupport() {
/**
* Check if the device is a touch device but exclude devices that have a fine pointer (mouse or track-pad)
* - `fine` will check for an accurate pointing device. Examples include mice, touch-pads, and drawing styluses
* - `coarse` will check for a pointing device of limited accuracy. Examples include touchscreens and motion-detection sensors
* - `any-pointer` will check for the presence of any pointing device, if there are multiple of them
*/
const isTouchDevice = ref(
window.matchMedia('(any-pointer: coarse)').matches &&
!window.matchMedia('(any-pointer: fine)').matches,
);
const userAgent = ref(navigator.userAgent.toLowerCase());
const isIOs = ref(
userAgent.value.includes('iphone') ||
userAgent.value.includes('ipad') ||
userAgent.value.includes('ipod'),
);
const isAndroidOs = ref(userAgent.value.includes('android'));
const isMacOs = ref(userAgent.value.includes('macintosh') || isIOs.value);
const isMobileDevice = ref(isIOs.value || isAndroidOs.value);
const controlKeyCode = ref(isMacOs.value ? 'Meta' : 'Control');
const controlKeyText = computed(() => (isMacOs.value ? '⌘' : 'Ctrl'));
function isCtrlKeyPressed(e: MouseEvent | KeyboardEvent): boolean {
if (isMacOs.value) {
return (e as KeyboardEvent).metaKey;
}
return (e as KeyboardEvent).ctrlKey;
}
return {
userAgent: userAgent.value,
isTouchDevice: isTouchDevice.value,
isAndroidOs: isAndroidOs.value,
isIOs: isIOs.value,
isMacOs: isMacOs.value,
isMobileDevice: isMobileDevice.value,
controlKeyCode: controlKeyCode.value,
controlKeyText,
isCtrlKeyPressed,
};
}

View File

@@ -0,0 +1,114 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { useDocumentTitle } from './useDocumentTitle';
describe('useDocumentTitle', () => {
beforeEach(() => {
document.title = '';
});
it('should set the document title', () => {
const { set } = useDocumentTitle();
set('Test Title');
expect(document.title).toBe('Test Title - n8n');
});
it('should reset the document title', () => {
const { set, reset } = useDocumentTitle();
set('Test Title');
reset();
expect(document.title).toBe('Workflow Automation - n8n');
});
it('should use the correct suffix for the release channel', () => {
const { set } = useDocumentTitle({ releaseChannel: 'beta' });
set('Test Title');
expect(document.title).toBe('Test Title - n8n[BETA]');
});
it('should use default suffix for stable release channel', () => {
const { set } = useDocumentTitle({ releaseChannel: 'stable' });
set('Test Title');
expect(document.title).toBe('Test Title - n8n');
});
it('should use default suffix when release channel is undefined', () => {
const { set } = useDocumentTitle({ releaseChannel: undefined });
set('Test Title');
expect(document.title).toBe('Test Title - n8n');
});
describe('setDocumentTitle', () => {
it('should set document title with IDLE status', () => {
const { setDocumentTitle } = useDocumentTitle();
setDocumentTitle('My Workflow', 'IDLE');
expect(document.title).toBe('▶️ My Workflow - n8n');
});
it('should set document title with EXECUTING status', () => {
const { setDocumentTitle } = useDocumentTitle();
setDocumentTitle('My Workflow', 'EXECUTING');
expect(document.title).toBe('🔄 My Workflow - n8n');
});
it('should set document title with ERROR status', () => {
const { setDocumentTitle } = useDocumentTitle();
setDocumentTitle('My Workflow', 'ERROR');
expect(document.title).toBe('⚠️ My Workflow - n8n');
});
it('should set document title with DEBUG status', () => {
const { setDocumentTitle } = useDocumentTitle();
setDocumentTitle('My Workflow', 'DEBUG');
expect(document.title).toBe('⚠️ My Workflow - n8n');
});
it('should set document title with AI_BUILDING status', () => {
const { setDocumentTitle } = useDocumentTitle();
setDocumentTitle('My Workflow', 'AI_BUILDING');
expect(document.title).toBe('[Building] My Workflow - n8n');
});
it('should set document title with AI_DONE status', () => {
const { setDocumentTitle } = useDocumentTitle();
setDocumentTitle('My Workflow', 'AI_DONE');
expect(document.title).toBe('[Done] My Workflow - n8n');
});
});
describe('getDocumentState', () => {
it('should return undefined initially', () => {
const { getDocumentState } = useDocumentTitle();
expect(getDocumentState()).toBeUndefined();
});
it('should return the current state after setDocumentTitle is called', () => {
const { setDocumentTitle, getDocumentState } = useDocumentTitle();
setDocumentTitle('My Workflow', 'AI_BUILDING');
expect(getDocumentState()).toBe('AI_BUILDING');
});
it('should track state changes', () => {
const { setDocumentTitle, getDocumentState } = useDocumentTitle();
setDocumentTitle('My Workflow', 'IDLE');
expect(getDocumentState()).toBe('IDLE');
setDocumentTitle('My Workflow', 'AI_BUILDING');
expect(getDocumentState()).toBe('AI_BUILDING');
setDocumentTitle('My Workflow', 'AI_DONE');
expect(getDocumentState()).toBe('AI_DONE');
});
it('should return undefined after reset is called', () => {
const { setDocumentTitle, getDocumentState, reset } = useDocumentTitle();
setDocumentTitle('My Workflow', 'AI_DONE');
expect(getDocumentState()).toBe('AI_DONE');
reset();
expect(getDocumentState()).toBeUndefined();
});
});
});

View File

@@ -0,0 +1,65 @@
import { ref, type Ref } from 'vue';
const DEFAULT_TITLE = 'n8n';
const DEFAULT_TAGLINE = 'Workflow Automation';
export type WorkflowTitleStatus =
| 'EXECUTING'
| 'IDLE'
| 'ERROR'
| 'DEBUG'
| 'AI_BUILDING'
| 'AI_DONE';
export interface UseDocumentTitleOptions {
/**
* The release channel (e.g., 'stable', 'beta', 'dev').
* If not provided or 'stable', the title will be 'n8n'.
* Otherwise, it will be 'n8n[CHANNEL]'.
*/
releaseChannel?: string;
/**
* Optional window reference for setting the document title.
* Useful for pop-out windows.
*/
windowRef?: Ref<Window | undefined>;
}
export function useDocumentTitle(options: UseDocumentTitleOptions = {}) {
const { releaseChannel, windowRef } = options;
const suffix =
!releaseChannel || releaseChannel === 'stable'
? DEFAULT_TITLE
: `${DEFAULT_TITLE}[${releaseChannel.toUpperCase()}]`;
const currentState = ref<WorkflowTitleStatus | undefined>(undefined);
const set = (title: string) => {
const sections = [title || DEFAULT_TAGLINE, suffix];
(windowRef?.value?.document ?? document).title = sections.join(' - ');
};
const reset = () => {
currentState.value = undefined;
set('');
};
const setDocumentTitle = (workflowName: string, status: WorkflowTitleStatus) => {
currentState.value = status;
let prefix = '⚠️';
if (status === 'EXECUTING') {
prefix = '🔄';
} else if (status === 'IDLE') {
prefix = '▶️';
} else if (status === 'AI_BUILDING') {
prefix = '[Building]';
} else if (status === 'AI_DONE') {
prefix = '[Done]';
}
set(`${prefix} ${workflowName}`);
};
const getDocumentState = () => currentState.value;
return { set, reset, setDocumentTitle, getDocumentState };
}

View File

@@ -0,0 +1,71 @@
import { onKeyDown, onKeyUp } from '@vueuse/core';
import { ref } from 'vue';
import { useShortKeyPress } from './useShortKeyPress';
vi.mock('@vueuse/core', () => ({
onKeyDown: vi.fn(),
onKeyUp: vi.fn(),
}));
describe('useShortKeyPress', () => {
it('should call the function on short key press', async () => {
vi.useFakeTimers();
const fn = vi.fn();
const key = 'a';
const threshold = 300;
const disabled = ref(false);
useShortKeyPress(key, fn, { threshold, disabled });
const keyDownHandler = vi.mocked(onKeyDown).mock.calls[0][1];
const keyUpHandler = vi.mocked(onKeyUp).mock.calls[0][1];
keyDownHandler(new KeyboardEvent('keydown', { key }));
await vi.advanceTimersByTimeAsync(100);
keyUpHandler(new KeyboardEvent('keydown', { key }));
expect(fn).toHaveBeenCalled();
});
it('should not call the function if key press duration exceeds threshold', async () => {
vi.useFakeTimers();
const fn = vi.fn();
const key = 'a';
const threshold = 300;
const disabled = ref(false);
useShortKeyPress(key, fn, { threshold, disabled });
const keyDownHandler = vi.mocked(onKeyDown).mock.calls[0][1];
const keyUpHandler = vi.mocked(onKeyUp).mock.calls[0][1];
keyDownHandler(new KeyboardEvent('keydown', { key }));
await vi.advanceTimersByTimeAsync(400);
keyUpHandler(new KeyboardEvent('keydown', { key }));
expect(fn).not.toHaveBeenCalled();
});
it('should not call the function if disabled is true', async () => {
vi.useFakeTimers();
const fn = vi.fn();
const key = 'a';
const threshold = 300;
const disabled = ref(true);
useShortKeyPress(key, fn, { threshold, disabled });
const keyDownHandler = vi.mocked(onKeyDown).mock.calls[0][1];
const keyUpHandler = vi.mocked(onKeyUp).mock.calls[0][1];
keyDownHandler(new KeyboardEvent('keydown', { key }));
await vi.advanceTimersByTimeAsync(100);
keyUpHandler(new KeyboardEvent('keydown', { key }));
expect(fn).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,41 @@
import { onKeyDown, onKeyUp } from '@vueuse/core';
import type { KeyFilter } from '@vueuse/core';
import { ref, unref } from 'vue';
import type { MaybeRefOrGetter } from 'vue';
export function useShortKeyPress(
key: KeyFilter,
fn: () => void,
{
dedupe = true,
threshold = 300,
disabled = false,
}: {
dedupe?: boolean;
threshold?: number;
disabled?: MaybeRefOrGetter<boolean>;
},
) {
const keyDownTime = ref<number | null>(null);
onKeyDown(
key,
() => {
if (unref(disabled)) return;
keyDownTime.value = Date.now();
},
{
dedupe,
},
);
onKeyUp(key, () => {
if (unref(disabled) || !keyDownTime.value) return;
const isShortPress = Date.now() - keyDownTime.value < threshold;
if (isShortPress) {
fn();
}
});
}

View File

@@ -0,0 +1,53 @@
import { nextTick, ref } from 'vue';
import { useThrottleWithReactiveDelay } from './useThrottleWithReactiveDelay';
describe(useThrottleWithReactiveDelay, () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.restoreAllMocks();
});
it('should return throttled ref with initial value', () => {
const state = ref('initial');
const delay = ref(100);
const throttled = useThrottleWithReactiveDelay(state, delay);
expect(throttled.value).toBe('initial');
});
it('should throttle state updates', async () => {
const state = ref('initial');
const delay = ref(100);
const throttled = useThrottleWithReactiveDelay(state, delay);
state.value = 'updated';
await nextTick();
expect(throttled.value).toBe('initial');
vi.advanceTimersByTime(100);
await nextTick();
expect(throttled.value).toBe('updated');
});
it('should respect reactive delay changes', async () => {
const state = ref('initial');
const delay = ref(100);
const throttled = useThrottleWithReactiveDelay(state, delay);
delay.value = 200;
state.value = 'updated';
await nextTick();
vi.advanceTimersByTime(100);
await nextTick();
expect(throttled.value).toBe('initial');
vi.advanceTimersByTime(100);
await nextTick();
expect(throttled.value).toBe('updated');
});
});

Some files were not shown because too many files have changed in this diff Show More