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
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:
28
packages/frontend/@n8n/chat/.gitignore
vendored
Normal file
28
packages/frontend/@n8n/chat/.gitignore
vendored
Normal 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
|
||||
5
packages/frontend/@n8n/chat/.np-config.json
Normal file
5
packages/frontend/@n8n/chat/.np-config.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"yarn": false,
|
||||
"tests": false,
|
||||
"contents": "./dist"
|
||||
}
|
||||
3
packages/frontend/@n8n/chat/.vscode/extensions.json
vendored
Normal file
3
packages/frontend/@n8n/chat/.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"recommendations": ["Vue.volar", "Vue.vscode-typescript-vue-plugin"]
|
||||
}
|
||||
361
packages/frontend/@n8n/chat/README.md
Normal file
361
packages/frontend/@n8n/chat/README.md
Normal 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**
|
||||

|
||||
|
||||
**Fullscreen Example**
|
||||

|
||||
|
||||
## 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)
|
||||
17
packages/frontend/@n8n/chat/eslint.config.mjs
Normal file
17
packages/frontend/@n8n/chat/eslint.config.mjs
Normal 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',
|
||||
},
|
||||
});
|
||||
13
packages/frontend/@n8n/chat/index.html
Normal file
13
packages/frontend/@n8n/chat/index.html
Normal 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>
|
||||
66
packages/frontend/@n8n/chat/package.json
Normal file
66
packages/frontend/@n8n/chat/package.json
Normal 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"
|
||||
]
|
||||
}
|
||||
BIN
packages/frontend/@n8n/chat/public/favicon.ico
Normal file
BIN
packages/frontend/@n8n/chat/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
BIN
packages/frontend/@n8n/chat/resources/images/fullscreen.png
Normal file
BIN
packages/frontend/@n8n/chat/resources/images/fullscreen.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 46 KiB |
BIN
packages/frontend/@n8n/chat/resources/images/windowed.png
Normal file
BIN
packages/frontend/@n8n/chat/resources/images/windowed.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 115 KiB |
214
packages/frontend/@n8n/chat/resources/workflow-manual.json
Normal file
214
packages/frontend/@n8n/chat/resources/workflow-manual.json
Normal 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": []
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
107
packages/frontend/@n8n/chat/resources/workflow.json
Normal file
107
packages/frontend/@n8n/chat/resources/workflow.json
Normal 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": []
|
||||
}
|
||||
25
packages/frontend/@n8n/chat/src/App.vue
Normal file
25
packages/frontend/@n8n/chat/src/App.vue
Normal 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>
|
||||
61
packages/frontend/@n8n/chat/src/__stories__/App.stories.ts
Normal file
61
packages/frontend/@n8n/chat/src/__stories__/App.stories.ts
Normal 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>,
|
||||
};
|
||||
299
packages/frontend/@n8n/chat/src/__tests__/Chat.spec.ts
Normal file
299
packages/frontend/@n8n/chat/src/__tests__/Chat.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
158
packages/frontend/@n8n/chat/src/__tests__/Input.spec.ts
Normal file
158
packages/frontend/@n8n/chat/src/__tests__/Input.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
176
packages/frontend/@n8n/chat/src/__tests__/MessageActions.spec.ts
Normal file
176
packages/frontend/@n8n/chat/src/__tests__/MessageActions.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
126
packages/frontend/@n8n/chat/src/__tests__/api/generic.spec.ts
Normal file
126
packages/frontend/@n8n/chat/src/__tests__/api/generic.spec.ts
Normal 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('{}');
|
||||
});
|
||||
});
|
||||
623
packages/frontend/@n8n/chat/src/__tests__/api/message.spec.ts
Normal file
623
packages/frontend/@n8n/chat/src/__tests__/api/message.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
372
packages/frontend/@n8n/chat/src/__tests__/index.spec.ts
Normal file
372
packages/frontend/@n8n/chat/src/__tests__/index.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
634
packages/frontend/@n8n/chat/src/__tests__/plugins/chat.spec.ts
Normal file
634
packages/frontend/@n8n/chat/src/__tests__/plugins/chat.spec.ts
Normal 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',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
12
packages/frontend/@n8n/chat/src/__tests__/setup.ts
Normal file
12
packages/frontend/@n8n/chat/src/__tests__/setup.ts
Normal 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(),
|
||||
}));
|
||||
16
packages/frontend/@n8n/chat/src/__tests__/utils/create.ts
Normal file
16
packages/frontend/@n8n/chat/src/__tests__/utils/create.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
52
packages/frontend/@n8n/chat/src/__tests__/utils/fetch.ts
Normal file
52
packages/frontend/@n8n/chat/src/__tests__/utils/fetch.ts
Normal 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;
|
||||
};
|
||||
}
|
||||
3
packages/frontend/@n8n/chat/src/__tests__/utils/index.ts
Normal file
3
packages/frontend/@n8n/chat/src/__tests__/utils/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './create';
|
||||
export * from './fetch';
|
||||
export * from './selectors';
|
||||
54
packages/frontend/@n8n/chat/src/__tests__/utils/selectors.ts
Normal file
54
packages/frontend/@n8n/chat/src/__tests__/utils/selectors.ts
Normal 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');
|
||||
}
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
106
packages/frontend/@n8n/chat/src/api/generic.ts
Normal file
106
packages/frontend/@n8n/chat/src/api/generic.ts
Normal 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),
|
||||
});
|
||||
}
|
||||
2
packages/frontend/@n8n/chat/src/api/index.ts
Normal file
2
packages/frontend/@n8n/chat/src/api/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './generic';
|
||||
export * from './message';
|
||||
237
packages/frontend/@n8n/chat/src/api/message.ts
Normal file
237
packages/frontend/@n8n/chat/src/api/message.ts
Normal 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),
|
||||
});
|
||||
}
|
||||
96
packages/frontend/@n8n/chat/src/components/Button.vue
Normal file
96
packages/frontend/@n8n/chat/src/components/Button.vue
Normal 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>
|
||||
173
packages/frontend/@n8n/chat/src/components/Chat.vue
Normal file
173
packages/frontend/@n8n/chat/src/components/Chat.vue
Normal 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>
|
||||
123
packages/frontend/@n8n/chat/src/components/ChatFile.vue
Normal file
123
packages/frontend/@n8n/chat/src/components/ChatFile.vue
Normal 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>
|
||||
124
packages/frontend/@n8n/chat/src/components/ChatWindow.vue
Normal file
124
packages/frontend/@n8n/chat/src/components/ChatWindow.vue
Normal 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>
|
||||
24
packages/frontend/@n8n/chat/src/components/GetStarted.vue
Normal file
24
packages/frontend/@n8n/chat/src/components/GetStarted.vue
Normal 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>
|
||||
@@ -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>
|
||||
471
packages/frontend/@n8n/chat/src/components/Input.vue
Normal file
471
packages/frontend/@n8n/chat/src/components/Input.vue
Normal 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>
|
||||
88
packages/frontend/@n8n/chat/src/components/Layout.vue
Normal file
88
packages/frontend/@n8n/chat/src/components/Layout.vue
Normal 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>
|
||||
@@ -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>
|
||||
168
packages/frontend/@n8n/chat/src/components/Message.vue
Normal file
168
packages/frontend/@n8n/chat/src/components/Message.vue
Normal 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 || '<Empty response>';
|
||||
});
|
||||
|
||||
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>
|
||||
@@ -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>
|
||||
120
packages/frontend/@n8n/chat/src/components/MessageTyping.vue
Normal file
120
packages/frontend/@n8n/chat/src/components/MessageTyping.vue
Normal 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>
|
||||
@@ -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>
|
||||
111
packages/frontend/@n8n/chat/src/components/MessagesList.vue
Normal file
111
packages/frontend/@n8n/chat/src/components/MessagesList.vue
Normal 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>
|
||||
17
packages/frontend/@n8n/chat/src/components/PoweredBy.vue
Normal file
17
packages/frontend/@n8n/chat/src/components/PoweredBy.vue
Normal 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>
|
||||
11
packages/frontend/@n8n/chat/src/components/index.ts
Normal file
11
packages/frontend/@n8n/chat/src/components/index.ts
Normal 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';
|
||||
3
packages/frontend/@n8n/chat/src/composables/index.ts
Normal file
3
packages/frontend/@n8n/chat/src/composables/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './useChat';
|
||||
export * from './useI18n';
|
||||
export * from './useOptions';
|
||||
8
packages/frontend/@n8n/chat/src/composables/useChat.ts
Normal file
8
packages/frontend/@n8n/chat/src/composables/useChat.ts
Normal 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;
|
||||
}
|
||||
22
packages/frontend/@n8n/chat/src/composables/useI18n.ts
Normal file
22
packages/frontend/@n8n/chat/src/composables/useI18n.ts
Normal 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 };
|
||||
}
|
||||
12
packages/frontend/@n8n/chat/src/composables/useOptions.ts
Normal file
12
packages/frontend/@n8n/chat/src/composables/useOptions.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
38
packages/frontend/@n8n/chat/src/constants/defaults.ts
Normal file
38
packages/frontend/@n8n/chat/src/constants/defaults.ts
Normal 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';
|
||||
4
packages/frontend/@n8n/chat/src/constants/index.ts
Normal file
4
packages/frontend/@n8n/chat/src/constants/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from './defaults';
|
||||
export * from './localStorage';
|
||||
export * from './symbols';
|
||||
export * from './messageComponents';
|
||||
@@ -0,0 +1,2 @@
|
||||
export const localStorageNamespace = 'n8n-chat';
|
||||
export const localStorageSessionIdKey = `${localStorageNamespace}/sessionId`;
|
||||
@@ -0,0 +1,3 @@
|
||||
export const MessageComponentKey = {
|
||||
WITH_BUTTONS: 'with-buttons',
|
||||
} as const;
|
||||
7
packages/frontend/@n8n/chat/src/constants/symbols.ts
Normal file
7
packages/frontend/@n8n/chat/src/constants/symbols.ts
Normal 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>;
|
||||
139
packages/frontend/@n8n/chat/src/css/_tokens.scss
Normal file
139
packages/frontend/@n8n/chat/src/css/_tokens.scss
Normal 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);
|
||||
}
|
||||
2
packages/frontend/@n8n/chat/src/css/index.scss
Normal file
2
packages/frontend/@n8n/chat/src/css/index.scss
Normal file
@@ -0,0 +1,2 @@
|
||||
@use 'tokens';
|
||||
@use 'markdown';
|
||||
632
packages/frontend/@n8n/chat/src/css/markdown.scss
Normal file
632
packages/frontend/@n8n/chat/src/css/markdown.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
24
packages/frontend/@n8n/chat/src/env.d.ts
vendored
Normal file
24
packages/frontend/@n8n/chat/src/env.d.ts
vendored
Normal 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;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import { createEventBus } from '@n8n/chat/utils';
|
||||
|
||||
export const chatEventBus = createEventBus();
|
||||
1
packages/frontend/@n8n/chat/src/event-buses/index.ts
Normal file
1
packages/frontend/@n8n/chat/src/event-buses/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './chatEventBus';
|
||||
44
packages/frontend/@n8n/chat/src/index.ts
Normal file
44
packages/frontend/@n8n/chat/src/index.ts
Normal 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;
|
||||
}
|
||||
6
packages/frontend/@n8n/chat/src/main.scss
Normal file
6
packages/frontend/@n8n/chat/src/main.scss
Normal file
@@ -0,0 +1,6 @@
|
||||
@use 'sass:meta';
|
||||
@use 'css';
|
||||
|
||||
.n8n-chat {
|
||||
@include meta.load-css('highlight.js/styles/github');
|
||||
}
|
||||
364
packages/frontend/@n8n/chat/src/plugins/chat.ts
Normal file
364
packages/frontend/@n8n/chat/src/plugins/chat.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
1
packages/frontend/@n8n/chat/src/plugins/index.ts
Normal file
1
packages/frontend/@n8n/chat/src/plugins/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './chat';
|
||||
17
packages/frontend/@n8n/chat/src/types/chat.ts
Normal file
17
packages/frontend/@n8n/chat/src/types/chat.ts
Normal 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;
|
||||
}
|
||||
5
packages/frontend/@n8n/chat/src/types/icons.d.ts
vendored
Normal file
5
packages/frontend/@n8n/chat/src/types/icons.d.ts
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
declare module 'virtual:icons/*' {
|
||||
import { FunctionalComponent, SVGAttributes } from 'vue';
|
||||
const component: FunctionalComponent<SVGAttributes>;
|
||||
export default component;
|
||||
}
|
||||
5
packages/frontend/@n8n/chat/src/types/index.ts
Normal file
5
packages/frontend/@n8n/chat/src/types/index.ts
Normal 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';
|
||||
19
packages/frontend/@n8n/chat/src/types/messages.ts
Normal file
19
packages/frontend/@n8n/chat/src/types/messages.ts
Normal 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[];
|
||||
}
|
||||
47
packages/frontend/@n8n/chat/src/types/options.ts
Normal file
47
packages/frontend/@n8n/chat/src/types/options.ts
Normal 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;
|
||||
}
|
||||
19
packages/frontend/@n8n/chat/src/types/streaming.ts
Normal file
19
packages/frontend/@n8n/chat/src/types/streaming.ts
Normal 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;
|
||||
}
|
||||
24
packages/frontend/@n8n/chat/src/types/webhook.ts
Normal file
24
packages/frontend/@n8n/chat/src/types/webhook.ts
Normal 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;
|
||||
}
|
||||
51
packages/frontend/@n8n/chat/src/utils/event-bus.ts
Normal file
51
packages/frontend/@n8n/chat/src/utils/event-bus.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
3
packages/frontend/@n8n/chat/src/utils/index.ts
Normal file
3
packages/frontend/@n8n/chat/src/utils/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './event-bus';
|
||||
export * from './mount';
|
||||
export * from './utils';
|
||||
16
packages/frontend/@n8n/chat/src/utils/mount.ts
Normal file
16
packages/frontend/@n8n/chat/src/utils/mount.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
133
packages/frontend/@n8n/chat/src/utils/streaming.ts
Normal file
133
packages/frontend/@n8n/chat/src/utils/streaming.ts
Normal 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;
|
||||
}
|
||||
119
packages/frontend/@n8n/chat/src/utils/streamingHandlers.ts
Normal file
119
packages/frontend/@n8n/chat/src/utils/streamingHandlers.ts
Normal 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;
|
||||
}
|
||||
70
packages/frontend/@n8n/chat/src/utils/utils.ts
Normal file
70
packages/frontend/@n8n/chat/src/utils/utils.ts
Normal 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;
|
||||
}
|
||||
13
packages/frontend/@n8n/chat/stylelint.config.mjs
Normal file
13
packages/frontend/@n8n/chat/stylelint.config.mjs
Normal 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,
|
||||
},
|
||||
};
|
||||
25
packages/frontend/@n8n/chat/tsconfig.json
Normal file
25
packages/frontend/@n8n/chat/tsconfig.json
Normal 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"]
|
||||
}
|
||||
105
packages/frontend/@n8n/chat/vite.config.mts
Normal file
105
packages/frontend/@n8n/chat/vite.config.mts
Normal 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,
|
||||
);
|
||||
24
packages/frontend/@n8n/composables/.gitignore
vendored
Normal file
24
packages/frontend/@n8n/composables/.gitignore
vendored
Normal 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?
|
||||
24
packages/frontend/@n8n/composables/README.md
Normal file
24
packages/frontend/@n8n/composables/README.md
Normal 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).
|
||||
4
packages/frontend/@n8n/composables/biome.jsonc
Normal file
4
packages/frontend/@n8n/composables/biome.jsonc
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"$schema": "../../../../node_modules/@biomejs/biome/configuration_schema.json",
|
||||
"extends": ["../../../../biome.jsonc"]
|
||||
}
|
||||
7
packages/frontend/@n8n/composables/eslint.config.mjs
Normal file
7
packages/frontend/@n8n/composables/eslint.config.mjs
Normal 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' },
|
||||
});
|
||||
49
packages/frontend/@n8n/composables/package.json
Normal file
49
packages/frontend/@n8n/composables/package.json
Normal 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"
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import '@testing-library/jest-dom';
|
||||
import { configure } from '@testing-library/vue';
|
||||
|
||||
configure({ testIdAttribute: 'data-test-id' });
|
||||
1
packages/frontend/@n8n/composables/src/shims.d.ts
vendored
Normal file
1
packages/frontend/@n8n/composables/src/shims.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
125
packages/frontend/@n8n/composables/src/useDeviceSupport.test.ts
Normal file
125
packages/frontend/@n8n/composables/src/useDeviceSupport.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
46
packages/frontend/@n8n/composables/src/useDeviceSupport.ts
Normal file
46
packages/frontend/@n8n/composables/src/useDeviceSupport.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
114
packages/frontend/@n8n/composables/src/useDocumentTitle.test.ts
Normal file
114
packages/frontend/@n8n/composables/src/useDocumentTitle.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
65
packages/frontend/@n8n/composables/src/useDocumentTitle.ts
Normal file
65
packages/frontend/@n8n/composables/src/useDocumentTitle.ts
Normal 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 };
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
41
packages/frontend/@n8n/composables/src/useShortKeyPress.ts
Normal file
41
packages/frontend/@n8n/composables/src/useShortKeyPress.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user