mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-17 01:56:46 +00:00
refactor: Move @n8n/chat package to frontend/@n8n (no-changelog) (#13425)
This commit is contained in:
2
packages/frontend/@n8n/chat/.eslintignore
Normal file
2
packages/frontend/@n8n/chat/.eslintignore
Normal file
@@ -0,0 +1,2 @@
|
||||
.eslintrc.cjs
|
||||
vitest.config.ts
|
||||
10
packages/frontend/@n8n/chat/.eslintrc.cjs
Normal file
10
packages/frontend/@n8n/chat/.eslintrc.cjs
Normal file
@@ -0,0 +1,10 @@
|
||||
const sharedOptions = require('@n8n_io/eslint-config/shared');
|
||||
|
||||
/**
|
||||
* @type {import('@types/eslint').ESLint.ConfigData}
|
||||
*/
|
||||
module.exports = {
|
||||
extends: ['@n8n_io/eslint-config/frontend'],
|
||||
|
||||
...sharedOptions(__dirname, 'frontend'),
|
||||
};
|
||||
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
|
||||
|
||||
/cypress/videos/
|
||||
/cypress/screenshots/
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
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"
|
||||
}
|
||||
4
packages/frontend/@n8n/chat/.storybook/main.ts
Normal file
4
packages/frontend/@n8n/chat/.storybook/main.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { sharedConfig } from '@n8n/storybook/main';
|
||||
|
||||
const config = { ...sharedConfig };
|
||||
export default config;
|
||||
7
packages/frontend/@n8n/chat/.storybook/preview.scss
Normal file
7
packages/frontend/@n8n/chat/.storybook/preview.scss
Normal file
@@ -0,0 +1,7 @@
|
||||
html,
|
||||
body,
|
||||
#storybook-root,
|
||||
#n8n-chat {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
15
packages/frontend/@n8n/chat/.storybook/preview.ts
Normal file
15
packages/frontend/@n8n/chat/.storybook/preview.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import type { Preview } from '@storybook/vue3';
|
||||
import './preview.scss';
|
||||
|
||||
const preview: Preview = {
|
||||
parameters: {
|
||||
controls: {
|
||||
matchers: {
|
||||
color: /(background|color)$/i,
|
||||
date: /Date$/,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default preview;
|
||||
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"]
|
||||
}
|
||||
280
packages/frontend/@n8n/chat/README.md
Normal file
280
packages/frontend/@n8n/chat/README.md
Normal file
@@ -0,0 +1,280 @@
|
||||
# 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)
|
||||
|
||||
> 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..',
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### `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'`.
|
||||
|
||||
## Customization
|
||||
The Chat window is entirely customizable using CSS variables.
|
||||
|
||||
```css
|
||||
:root {
|
||||
--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: #ffffff;
|
||||
--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: #777980;
|
||||
--chat--color-typing: #404040;
|
||||
|
||||
--chat--spacing: 1rem;
|
||||
--chat--border-radius: 0.25rem;
|
||||
--chat--transition-duration: 0.15s;
|
||||
|
||||
--chat--window--width: 400px;
|
||||
--chat--window--height: 600px;
|
||||
|
||||
--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-bottom: none;
|
||||
--chat--header--border-bottom: none;
|
||||
--chat--heading--font-size: 2em;
|
||||
--chat--header--color: var(--chat--color-light);
|
||||
--chat--subtitle--font-size: inherit;
|
||||
--chat--subtitle--line-height: 1.8;
|
||||
|
||||
--chat--textarea--height: 50px;
|
||||
|
||||
--chat--message--font-size: 1rem;
|
||||
--chat--message--padding: var(--chat--spacing);
|
||||
--chat--message--border-radius: var(--chat--border-radius);
|
||||
--chat--message-line-height: 1.8;
|
||||
--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--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);
|
||||
--chat--toggle--size: 64px;
|
||||
}
|
||||
```
|
||||
|
||||
## 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)
|
||||
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>
|
||||
61
packages/frontend/@n8n/chat/package.json
Normal file
61
packages/frontend/@n8n/chat/package.json
Normal file
@@ -0,0 +1,61 @@
|
||||
{
|
||||
"name": "@n8n/chat",
|
||||
"version": "0.34.0",
|
||||
"scripts": {
|
||||
"dev": "pnpm run storybook",
|
||||
"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 . --ext .js,.ts,.vue --quiet",
|
||||
"lintfix": "eslint . --ext .js,.ts,.vue --fix",
|
||||
"format": "biome format --write src .storybook && prettier --write src/ --ignore-path ../../.prettierignore",
|
||||
"format:check": "biome ci src .storybook && prettier --check src/ --ignore-path ../../.prettierignore",
|
||||
"storybook": "storybook dev -p 6006 --no-open",
|
||||
"build:storybook": "storybook build"
|
||||
},
|
||||
"main": "./dist/chat.umd.js",
|
||||
"module": "./dist/chat.es.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./dist/chat.es.js",
|
||||
"require": "./dist/chat.umd.js",
|
||||
"types": "./dist/index.d.ts"
|
||||
},
|
||||
"./style.css": {
|
||||
"import": "./dist/style.css",
|
||||
"require": "./dist/style.css"
|
||||
},
|
||||
"./*": {
|
||||
"import": "./*",
|
||||
"require": "./*"
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@vueuse/core": "^10.11.0",
|
||||
"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/storybook": "workspace:*",
|
||||
"@vitejs/plugin-vue": "catalog:frontend",
|
||||
"@vitest/coverage-v8": "catalog:frontend",
|
||||
"unplugin-icons": "^0.19.0",
|
||||
"vite": "catalog:frontend",
|
||||
"vitest": "catalog:frontend",
|
||||
"vite-plugin-dts": "^4.3.0",
|
||||
"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": "Window Buffer 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
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Window Buffer 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": []
|
||||
}
|
||||
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": "Window Buffer 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
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Window Buffer 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": []
|
||||
}
|
||||
24
packages/frontend/@n8n/chat/src/App.vue
Normal file
24
packages/frontend/@n8n/chat/src/App.vue
Normal file
@@ -0,0 +1,24 @@
|
||||
<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, ChatWindow } from '@n8n/chat/components';
|
||||
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>
|
||||
56
packages/frontend/@n8n/chat/src/__stories__/App.stories.ts
Normal file
56
packages/frontend/@n8n/chat/src/__stories__/App.stories.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
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/f406671e-c954-4691-b39a-66c90aa2f103/chat';
|
||||
|
||||
const meta = {
|
||||
title: '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/no-default-export
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Fullscreen: Story = {
|
||||
args: {
|
||||
webhookUrl,
|
||||
mode: 'fullscreen',
|
||||
} satisfies Partial<ChatOptions>,
|
||||
};
|
||||
|
||||
export const Windowed: Story = {
|
||||
args: {
|
||||
webhookUrl,
|
||||
mode: 'window',
|
||||
} 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: [],
|
||||
} satisfies Partial<ChatOptions>,
|
||||
};
|
||||
219
packages/frontend/@n8n/chat/src/__tests__/index.spec.ts
Normal file
219
packages/frontend/@n8n/chat/src/__tests__/index.spec.ts
Normal file
@@ -0,0 +1,219 @@
|
||||
import { fireEvent, waitFor } from '@testing-library/vue';
|
||||
|
||||
import {
|
||||
createFetchResponse,
|
||||
createGetLatestMessagesResponse,
|
||||
createSendMessageResponse,
|
||||
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!");');
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
13
packages/frontend/@n8n/chat/src/__tests__/setup.ts
Normal file
13
packages/frontend/@n8n/chat/src/__tests__/setup.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import '@testing-library/jest-dom';
|
||||
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,
|
||||
};
|
||||
}
|
||||
18
packages/frontend/@n8n/chat/src/__tests__/utils/fetch.ts
Normal file
18
packages/frontend/@n8n/chat/src/__tests__/utils/fetch.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { LoadPreviousSessionResponse, SendMessageResponse } from '@n8n/chat/types';
|
||||
|
||||
export function createFetchResponse<T>(data: T) {
|
||||
return async () =>
|
||||
({
|
||||
json: async () => await new Promise<T>((resolve) => resolve(data)),
|
||||
}) as Response;
|
||||
}
|
||||
|
||||
export const createGetLatestMessagesResponse = (
|
||||
data: LoadPreviousSessionResponse['data'] = [],
|
||||
): LoadPreviousSessionResponse => ({ data });
|
||||
|
||||
export const createSendMessageResponse = (
|
||||
output: SendMessageResponse['output'],
|
||||
): SendMessageResponse => ({
|
||||
output,
|
||||
});
|
||||
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');
|
||||
}
|
||||
93
packages/frontend/@n8n/chat/src/api/generic.ts
Normal file
93
packages/frontend/@n8n/chat/src/api/generic.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
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,
|
||||
});
|
||||
|
||||
return (await response.json()) 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, unknown> = {},
|
||||
files: File[] = [],
|
||||
options: RequestInit = {},
|
||||
) {
|
||||
const formData = new FormData();
|
||||
|
||||
for (const key in body) {
|
||||
formData.append(key, body[key] as string);
|
||||
}
|
||||
|
||||
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';
|
||||
57
packages/frontend/@n8n/chat/src/api/message.ts
Normal file
57
packages/frontend/@n8n/chat/src/api/message.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { get, post, postWithFiles } from '@n8n/chat/api/generic';
|
||||
import type {
|
||||
ChatOptions,
|
||||
LoadPreviousSessionResponse,
|
||||
SendMessageResponse,
|
||||
} 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,
|
||||
) {
|
||||
if (files.length > 0) {
|
||||
return 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,
|
||||
},
|
||||
);
|
||||
}
|
||||
const method = options.webhookConfig?.method === 'POST' ? post : get;
|
||||
return 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,
|
||||
},
|
||||
);
|
||||
}
|
||||
41
packages/frontend/@n8n/chat/src/components/Button.vue
Normal file
41
packages/frontend/@n8n/chat/src/components/Button.vue
Normal file
@@ -0,0 +1,41 @@
|
||||
<template>
|
||||
<button class="chat-button">
|
||||
<slot />
|
||||
</button>
|
||||
</template>
|
||||
<style lang="scss">
|
||||
.chat-button {
|
||||
display: inline-flex;
|
||||
text-align: center;
|
||||
vertical-align: middle;
|
||||
user-select: none;
|
||||
color: var(--chat--button--color, var(--chat--color-light));
|
||||
background-color: var(--chat--button--background, var(--chat--color-primary));
|
||||
border: 1px solid transparent;
|
||||
padding: var(--chat--button--padding, calc(var(--chat--spacing) * 1 / 2) var(--chat--spacing));
|
||||
font-size: 1rem;
|
||||
line-height: 1.5;
|
||||
border-radius: var(--chat--button--border-radius, var(--chat--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;
|
||||
|
||||
&:hover {
|
||||
color: var(--chat--button--hover--color, var(--chat--color-light));
|
||||
background-color: var(--chat--button--hover--background, var(--chat--color-primary-shade-50));
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: 0;
|
||||
box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.25);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.65;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
97
packages/frontend/@n8n/chat/src/components/Chat.vue
Normal file
97
packages/frontend/@n8n/chat/src/components/Chat.vue
Normal file
@@ -0,0 +1,97 @@
|
||||
<script setup lang="ts">
|
||||
import Close from 'virtual:icons/mdi/close';
|
||||
import { computed, nextTick, onMounted } 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 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);
|
||||
|
||||
async 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');
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await initialize();
|
||||
if (!options.showWelcomeScreen && !currentSessionId.value) {
|
||||
await getStarted();
|
||||
}
|
||||
});
|
||||
</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" />
|
||||
<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>
|
||||
105
packages/frontend/@n8n/chat/src/components/ChatFile.vue
Normal file
105
packages/frontend/@n8n/chat/src/components/ChatFile.vue
Normal file
@@ -0,0 +1,105 @@
|
||||
<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;
|
||||
}>();
|
||||
|
||||
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.isPreviewable) {
|
||||
window.open(URL.createObjectURL(props.file));
|
||||
}
|
||||
}
|
||||
function onDelete() {
|
||||
emit('remove', props.file);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="chat-file" @click="onClick">
|
||||
<TypeIcon />
|
||||
<p class="chat-file-name">{{ file.name }}</p>
|
||||
<span v-if="isRemovable" class="chat-file-delete" @click.stop="onDelete">
|
||||
<IconDelete />
|
||||
</span>
|
||||
<IconPreview v-else-if="isPreviewable" 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);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.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, var(--chat--spacing));
|
||||
right: var(--chat--window--right, var(--chat--spacing));
|
||||
z-index: var(--chat--window--z-index, 9999);
|
||||
|
||||
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, var(--chat--toggle--size));
|
||||
height: var(--chat--toggle--height, var(--chat--toggle--size));
|
||||
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>
|
||||
350
packages/frontend/@n8n/chat/src/components/Input.vue
Normal file
350
packages/frontend/@n8n/chat/src/components/Input.vue
Normal file
@@ -0,0 +1,350 @@
|
||||
<script setup lang="ts">
|
||||
import { useFileDialog } from '@vueuse/core';
|
||||
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 ChatFile from './ChatFile.vue';
|
||||
|
||||
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 isSubmitDisabled = computed(() => {
|
||||
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) {
|
||||
adjustHeight({ target: chatTextArea.value } as unknown as Event);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 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();
|
||||
}
|
||||
|
||||
async function onSubmit(event: MouseEvent | KeyboardEvent) {
|
||||
event.preventDefault();
|
||||
|
||||
if (isSubmitDisabled.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const messageText = input.value;
|
||||
input.value = '';
|
||||
isSubmitting.value = true;
|
||||
await chatStore.sendMessage(messageText, Array.from(files.value ?? []));
|
||||
isSubmitting.value = false;
|
||||
resetFileDialog();
|
||||
files.value = null;
|
||||
}
|
||||
|
||||
async function onSubmitKeydown(event: KeyboardEvent) {
|
||||
if (event.shiftKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
await onSubmit(event);
|
||||
adjustHeight({ target: chatTextArea.value } as unknown as Event);
|
||||
}
|
||||
|
||||
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 adjustHeight(event: Event) {
|
||||
const textarea = event.target as HTMLTextAreaElement;
|
||||
// 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" @keydown.stop="onKeyDown">
|
||||
<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"
|
||||
@input="adjustHeight"
|
||||
@mousedown="adjustHeight"
|
||||
@focus="adjustHeight"
|
||||
/>
|
||||
|
||||
<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" 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: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: flex-end;
|
||||
|
||||
textarea {
|
||||
font-family: inherit;
|
||||
font-size: var(--chat--input--font-size, inherit);
|
||||
width: 100%;
|
||||
border: var(--chat--input--border, 0);
|
||||
border-radius: var(--chat--input--border-radius, 0);
|
||||
padding: var(--chat--input--padding, 0.8rem);
|
||||
min-height: var(--chat--textarea--height, 2.5rem); // Set a smaller initial height
|
||||
max-height: var(--chat--textarea--max-height, 30rem);
|
||||
height: var(--chat--textarea--height, 2.5rem); // Set initial height same as min-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, inherit));
|
||||
}
|
||||
&:focus,
|
||||
&:hover {
|
||||
border-color: var(--chat--input--border-active, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
.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, white);
|
||||
cursor: pointer;
|
||||
color: var(--chat--input--send--button--color, var(--chat--color-secondary));
|
||||
border: 0;
|
||||
font-size: 24px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: color var(--chat--transition-duration) ease;
|
||||
|
||||
svg {
|
||||
min-width: fit-content;
|
||||
}
|
||||
|
||||
&[disabled] {
|
||||
cursor: no-drop;
|
||||
color: var(--chat--color-disabled);
|
||||
}
|
||||
|
||||
.chat-input-send-button {
|
||||
&:hover,
|
||||
&:focus {
|
||||
background: var(
|
||||
--chat--input--send--button--background-hover,
|
||||
var(--chat--input--send--button--background)
|
||||
);
|
||||
color: var(--chat--input--send--button--color-hover, var(--chat--color-secondary-shade-50));
|
||||
}
|
||||
}
|
||||
}
|
||||
.chat-input-file-button {
|
||||
background: var(--chat--input--file--button--background, white);
|
||||
color: var(--chat--input--file--button--color, var(--chat--color-secondary));
|
||||
|
||||
&:hover {
|
||||
background: var(
|
||||
--chat--input--file--button--background-hover,
|
||||
var(--chat--input--file--button--background)
|
||||
);
|
||||
color: var(--chat--input--file--button--color-hover, var(--chat--color-secondary-shade-50));
|
||||
}
|
||||
}
|
||||
|
||||
.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, 0.25rem);
|
||||
}
|
||||
|
||||
.chat-input-left-panel {
|
||||
width: var(--chat--input--left--panel--width, 2rem);
|
||||
margin-left: 0.4rem;
|
||||
}
|
||||
</style>
|
||||
100
packages/frontend/@n8n/chat/src/components/Layout.vue
Normal file
100
packages/frontend/@n8n/chat/src/components/Layout.vue
Normal file
@@ -0,0 +1,100 @@
|
||||
<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,
|
||||
(
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
'Segoe UI',
|
||||
Roboto,
|
||||
Oxygen-Sans,
|
||||
Ubuntu,
|
||||
Cantarell,
|
||||
'Helvetica Neue',
|
||||
sans-serif
|
||||
)
|
||||
);
|
||||
|
||||
.chat-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 1em;
|
||||
height: var(--chat--header-height, auto);
|
||||
padding: var(--chat--header--padding, var(--chat--spacing));
|
||||
background: var(--chat--header--background, var(--chat--color-dark));
|
||||
color: var(--chat--header--color, var(--chat--color-light));
|
||||
border-top: var(--chat--header--border-top, none);
|
||||
border-bottom: var(--chat--header--border-bottom, none);
|
||||
border-left: var(--chat--header--border-left, none);
|
||||
border-right: var(--chat--header--border-right, none);
|
||||
h1 {
|
||||
font-size: var(--chat--heading--font-size);
|
||||
color: var(--chat--header--color, var(--chat--color-light));
|
||||
}
|
||||
p {
|
||||
font-size: var(--chat--subtitle--font-size, inherit);
|
||||
line-height: var(--chat--subtitle--line-height, 1.8);
|
||||
}
|
||||
}
|
||||
|
||||
.chat-body {
|
||||
background: var(--chat--body--background, var(--chat--color-light));
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
position: relative;
|
||||
min-height: 100px;
|
||||
}
|
||||
|
||||
.chat-footer {
|
||||
border-top: 1px solid var(--chat--color-light-shade-100);
|
||||
background: var(--chat--footer--background, var(--chat--color-light));
|
||||
color: var(--chat--footer--color, var(--chat--color-dark));
|
||||
}
|
||||
}
|
||||
</style>
|
||||
227
packages/frontend/@n8n/chat/src/components/Message.vue
Normal file
227
packages/frontend/@n8n/chat/src/components/Message.vue
Normal file
@@ -0,0 +1,227 @@
|
||||
<script lang="ts" setup>
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
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 { computed, ref, toRefs, onMounted } from 'vue';
|
||||
import VueMarkdown from 'vue-markdown-render';
|
||||
|
||||
import { useOptions } from '@n8n/chat/composables';
|
||||
import type { ChatMessage, ChatMessageText } from '@n8n/chat/types';
|
||||
|
||||
import ChatFile from './ChatFile.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
message: ChatMessage;
|
||||
}>();
|
||||
|
||||
hljs.registerLanguage('javascript', javascript);
|
||||
hljs.registerLanguage('typescript', typescript);
|
||||
hljs.registerLanguage('python', python);
|
||||
hljs.registerLanguage('xml', xml);
|
||||
hljs.registerLanguage('bash', bash);
|
||||
|
||||
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 linksNewTabPlugin = (vueMarkdownItInstance: MarkdownIt) => {
|
||||
vueMarkdownItInstance.use(markdownLink, {
|
||||
attrs: {
|
||||
target: '_blank',
|
||||
rel: 'noopener',
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const scrollToView = () => {
|
||||
if (messageContainer.value?.scrollIntoView) {
|
||||
messageContainer.value.scrollIntoView({
|
||||
block: 'start',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
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
|
||||
},
|
||||
};
|
||||
|
||||
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" class="chat-message-actions">
|
||||
<slot name="beforeMessage" v-bind="{ message }" />
|
||||
</div>
|
||||
<slot>
|
||||
<template v-if="message.type === 'component' && messageComponents[message.key]">
|
||||
<component :is="messageComponents[message.key]" v-bind="message.arguments" />
|
||||
</template>
|
||||
<VueMarkdown
|
||||
v-else
|
||||
class="chat-message-markdown"
|
||||
:source="messageText"
|
||||
:options="markdownOptions"
|
||||
:plugins="[linksNewTabPlugin]"
|
||||
/>
|
||||
<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, 1rem);
|
||||
padding: var(--chat--message--padding, var(--chat--spacing));
|
||||
border-radius: var(--chat--message--border-radius, var(--chat--border-radius));
|
||||
scroll-margin: 3rem;
|
||||
|
||||
.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, 1.5);
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
// Default message gap is half of the spacing
|
||||
+ .chat-message {
|
||||
margin-top: var(--chat--message--margin-bottom, calc(var(--chat--spacing) * 1));
|
||||
}
|
||||
|
||||
// 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, none);
|
||||
}
|
||||
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, none);
|
||||
}
|
||||
color: var(--chat--message--user--color);
|
||||
margin-left: auto;
|
||||
border-bottom-right-radius: 0;
|
||||
}
|
||||
|
||||
> .chat-message-markdown {
|
||||
display: block;
|
||||
box-sizing: border-box;
|
||||
|
||||
> *: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);
|
||||
}
|
||||
}
|
||||
.chat-message-files {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.25rem;
|
||||
padding-top: 0.5rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
121
packages/frontend/@n8n/chat/src/components/MessageTyping.vue
Normal file
121
packages/frontend/@n8n/chat/src/components/MessageTyping.vue
Normal file
@@ -0,0 +1,121 @@
|
||||
<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',
|
||||
createdAt: '',
|
||||
};
|
||||
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>
|
||||
56
packages/frontend/@n8n/chat/src/components/MessagesList.vue
Normal file
56
packages/frontend/@n8n/chat/src/components/MessagesList.vue
Normal file
@@ -0,0 +1,56 @@
|
||||
<script lang="ts" setup>
|
||||
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[];
|
||||
}>();
|
||||
|
||||
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 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, var(--chat--spacing));
|
||||
}
|
||||
</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>
|
||||
10
packages/frontend/@n8n/chat/src/components/index.ts
Normal file
10
packages/frontend/@n8n/chat/src/components/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
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';
|
||||
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,
|
||||
};
|
||||
}
|
||||
30
packages/frontend/@n8n/chat/src/constants/defaults.ts
Normal file
30
packages/frontend/@n8n/chat/src/constants/defaults.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
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',
|
||||
},
|
||||
},
|
||||
theme: {},
|
||||
};
|
||||
|
||||
export const defaultMountingTarget = '#n8n-chat';
|
||||
3
packages/frontend/@n8n/chat/src/constants/index.ts
Normal file
3
packages/frontend/@n8n/chat/src/constants/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './defaults';
|
||||
export * from './localStorage';
|
||||
export * from './symbols';
|
||||
@@ -0,0 +1,2 @@
|
||||
export const localStorageNamespace = 'n8n-chat';
|
||||
export const localStorageSessionIdKey = `${localStorageNamespace}/sessionId`;
|
||||
9
packages/frontend/@n8n/chat/src/constants/symbols.ts
Normal file
9
packages/frontend/@n8n/chat/src/constants/symbols.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import type { InjectionKey } from 'vue';
|
||||
|
||||
import type { Chat, ChatOptions } from '@n8n/chat/types';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
export const ChatSymbol = 'Chat' as unknown as InjectionKey<Chat>;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
export const ChatOptionsSymbol = 'ChatOptions' as unknown as InjectionKey<ChatOptions>;
|
||||
38
packages/frontend/@n8n/chat/src/css/_tokens.scss
Normal file
38
packages/frontend/@n8n/chat/src/css/_tokens.scss
Normal file
@@ -0,0 +1,38 @@
|
||||
:root {
|
||||
--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: #ffffff;
|
||||
--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: #777980;
|
||||
--chat--color-typing: #404040;
|
||||
|
||||
--chat--spacing: 1rem;
|
||||
--chat--border-radius: 0.25rem;
|
||||
--chat--transition-duration: 0.15s;
|
||||
|
||||
--chat--window--width: 400px;
|
||||
--chat--window--height: 600px;
|
||||
|
||||
--chat--textarea--height: 50px;
|
||||
|
||||
--chat--message--bot--background: var(--chat--color-white);
|
||||
--chat--message--bot--color: var(--chat--color-dark);
|
||||
--chat--message--user--background: var(--chat--color-secondary);
|
||||
--chat--message--user--color: var(--chat--color-white);
|
||||
--chat--message--pre--background: rgba(0, 0, 0, 0.05);
|
||||
|
||||
--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);
|
||||
--chat--toggle--size: 64px;
|
||||
|
||||
--chat--heading--font-size: 2em;
|
||||
}
|
||||
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 @@
|
||||
@import 'tokens';
|
||||
@import 'markdown';
|
||||
654
packages/frontend/@n8n/chat/src/css/markdown.scss
Normal file
654
packages/frontend/@n8n/chat/src/css/markdown.scss
Normal file
@@ -0,0 +1,654 @@
|
||||
@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) {
|
||||
@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:
|
||||
/* macOS 10.10+ */
|
||||
'Menlo',
|
||||
/* Windows 6+ */ 'Consolas',
|
||||
/* Android 4+ */ 'Roboto Mono',
|
||||
/* Ubuntu 10.10+ */ 'Ubuntu Monospace',
|
||||
/* KDE Plasma 5+ */ 'Noto Mono',
|
||||
/* KDE Plasma 4+ */ 'Oxygen Mono',
|
||||
/* Linux/OpenOffice fallback */ 'Liberation Mono',
|
||||
/* fallback */ 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: bolder;
|
||||
}
|
||||
|
||||
/*
|
||||
Add correct font size (all)
|
||||
*/
|
||||
small {
|
||||
font-size: 80%;
|
||||
}
|
||||
|
||||
/*
|
||||
Change alignment on media elements (all)
|
||||
*/
|
||||
audio,
|
||||
canvas,
|
||||
iframe,
|
||||
img,
|
||||
svg,
|
||||
video {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
/*
|
||||
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;
|
||||
}
|
||||
/* ----- 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: normal;
|
||||
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;
|
||||
}
|
||||
|
||||
small {
|
||||
font-size: 1em;
|
||||
opacity: 0.8; /* or some other way of differentiating it from body text */
|
||||
}
|
||||
|
||||
mark {
|
||||
background: pink; /* change to proper color, based on theme */
|
||||
}
|
||||
|
||||
/*
|
||||
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: bolder;
|
||||
}
|
||||
|
||||
blockquote {
|
||||
border-left: 0.3rem solid #7a283a;
|
||||
border-left: 0.3rem solid var(--theme);
|
||||
margin: 0.6rem 0 1.2rem 0;
|
||||
padding-left: 2rem;
|
||||
}
|
||||
|
||||
blockquote p {
|
||||
font-size: 1.2em;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
figure {
|
||||
margin: 0;
|
||||
}
|
||||
/* ----- Layout ----- */
|
||||
|
||||
body {
|
||||
background: #fefefe;
|
||||
background: var(--background-main);
|
||||
color: #1f1f1f;
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
mark {
|
||||
background: pink; /* change to proper color, based on theme */
|
||||
padding: 0.1em 0.15em;
|
||||
}
|
||||
|
||||
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 {
|
||||
-moz-tab-size: 4;
|
||||
-o-tab-size: 4;
|
||||
tab-size: 4;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
1
packages/frontend/@n8n/chat/src/env.d.ts
vendored
Normal file
1
packages/frontend/@n8n/chat/src/env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -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;
|
||||
}
|
||||
5
packages/frontend/@n8n/chat/src/main.scss
Normal file
5
packages/frontend/@n8n/chat/src/main.scss
Normal file
@@ -0,0 +1,5 @@
|
||||
.n8n-chat {
|
||||
@import 'highlight.js/styles/github';
|
||||
}
|
||||
|
||||
@import 'css';
|
||||
118
packages/frontend/@n8n/chat/src/plugins/chat.ts
Normal file
118
packages/frontend/@n8n/chat/src/plugins/chat.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import type { Plugin } from 'vue';
|
||||
import { computed, nextTick, 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 } from '@n8n/chat/types';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
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 initialMessages = computed<ChatMessage[]>(() =>
|
||||
(options.initialMessages ?? []).map((text) => ({
|
||||
id: uuidv4(),
|
||||
text,
|
||||
sender: 'bot',
|
||||
createdAt: new Date().toISOString(),
|
||||
})),
|
||||
);
|
||||
|
||||
async function sendMessage(text: string, files: File[] = []) {
|
||||
const sentMessage: ChatMessage = {
|
||||
id: uuidv4(),
|
||||
text,
|
||||
sender: 'user',
|
||||
files,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
messages.value.push(sentMessage);
|
||||
waitingForResponse.value = true;
|
||||
|
||||
void nextTick(() => {
|
||||
chatEventBus.emit('scrollToBottom');
|
||||
});
|
||||
|
||||
const sendMessageResponse = await api.sendMessage(
|
||||
text,
|
||||
files,
|
||||
currentSessionId.value as string,
|
||||
options,
|
||||
);
|
||||
|
||||
let textMessage = sendMessageResponse.output ?? sendMessageResponse.text ?? '';
|
||||
|
||||
if (textMessage === '' && Object.keys(sendMessageResponse).length > 0) {
|
||||
try {
|
||||
textMessage = JSON.stringify(sendMessageResponse, null, 2);
|
||||
} catch (e) {
|
||||
// Failed to stringify the object so fallback to empty string
|
||||
}
|
||||
}
|
||||
|
||||
const receivedMessage: ChatMessage = {
|
||||
id: uuidv4(),
|
||||
text: textMessage,
|
||||
sender: 'bot',
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
messages.value.push(receivedMessage);
|
||||
|
||||
waitingForResponse.value = false;
|
||||
|
||||
void nextTick(() => {
|
||||
chatEventBus.emit('scrollToBottom');
|
||||
});
|
||||
}
|
||||
|
||||
async function loadPreviousSession() {
|
||||
if (!options.loadPreviousSession) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sessionId = localStorage.getItem(localStorageSessionIdKey) ?? uuidv4();
|
||||
const previousMessagesResponse = await api.loadPreviousSession(sessionId, options);
|
||||
const timestamp = new Date().toISOString();
|
||||
|
||||
messages.value = (previousMessagesResponse?.data || []).map((message, index) => ({
|
||||
id: `${index}`,
|
||||
text: message.kwargs.content,
|
||||
sender: message.id.includes('HumanMessage') ? 'user' : 'bot',
|
||||
createdAt: timestamp,
|
||||
}));
|
||||
|
||||
if (messages.value.length) {
|
||||
currentSessionId.value = sessionId;
|
||||
}
|
||||
|
||||
return sessionId;
|
||||
}
|
||||
|
||||
async function startNewSession() {
|
||||
currentSessionId.value = uuidv4();
|
||||
|
||||
localStorage.setItem(localStorageSessionIdKey, currentSessionId.value);
|
||||
}
|
||||
|
||||
const chatStore = {
|
||||
initialMessages,
|
||||
messages,
|
||||
currentSessionId,
|
||||
waitingForResponse,
|
||||
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';
|
||||
6
packages/frontend/@n8n/chat/src/shims.d.ts
vendored
Normal file
6
packages/frontend/@n8n/chat/src/shims.d.ts
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
declare module '*.vue' {
|
||||
import { defineComponent } from 'vue';
|
||||
|
||||
const component: ReturnType<typeof defineComponent>;
|
||||
export default component;
|
||||
}
|
||||
5
packages/frontend/@n8n/chat/src/style.scss
Normal file
5
packages/frontend/@n8n/chat/src/style.scss
Normal file
@@ -0,0 +1,5 @@
|
||||
.n8n-chat {
|
||||
@import 'highlight.js/styles/github';
|
||||
}
|
||||
|
||||
@import 'css';
|
||||
13
packages/frontend/@n8n/chat/src/types/chat.ts
Normal file
13
packages/frontend/@n8n/chat/src/types/chat.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import type { Ref } from 'vue';
|
||||
|
||||
import type { ChatMessage } from '@n8n/chat/types/messages';
|
||||
|
||||
export interface Chat {
|
||||
initialMessages: Ref<ChatMessage[]>;
|
||||
messages: Ref<ChatMessage[]>;
|
||||
currentSessionId: Ref<string | null>;
|
||||
waitingForResponse: Ref<boolean>;
|
||||
loadPreviousSession?: () => Promise<string | undefined>;
|
||||
startNewSession?: () => Promise<void>;
|
||||
sendMessage: (text: string, files: File[]) => Promise<void>;
|
||||
}
|
||||
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;
|
||||
}
|
||||
4
packages/frontend/@n8n/chat/src/types/index.ts
Normal file
4
packages/frontend/@n8n/chat/src/types/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from './chat';
|
||||
export * from './messages';
|
||||
export * from './options';
|
||||
export * from './webhook';
|
||||
20
packages/frontend/@n8n/chat/src/types/messages.ts
Normal file
20
packages/frontend/@n8n/chat/src/types/messages.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
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;
|
||||
createdAt: string;
|
||||
transparent?: boolean;
|
||||
sender: 'user' | 'bot';
|
||||
files?: File[];
|
||||
}
|
||||
36
packages/frontend/@n8n/chat/src/types/options.ts
Normal file
36
packages/frontend/@n8n/chat/src/types/options.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import type { Component, Ref } from 'vue';
|
||||
|
||||
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;
|
||||
chatInputKey?: string;
|
||||
chatSessionKey?: string;
|
||||
defaultLanguage?: 'en';
|
||||
initialMessages?: string[];
|
||||
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;
|
||||
}
|
||||
18
packages/frontend/@n8n/chat/src/types/webhook.ts
Normal file
18
packages/frontend/@n8n/chat/src/types/webhook.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
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;
|
||||
}
|
||||
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/ban-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,
|
||||
};
|
||||
}
|
||||
2
packages/frontend/@n8n/chat/src/utils/index.ts
Normal file
2
packages/frontend/@n8n/chat/src/utils/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './event-bus';
|
||||
export * from './mount';
|
||||
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);
|
||||
}
|
||||
}
|
||||
23
packages/frontend/@n8n/chat/tsconfig.json
Normal file
23
packages/frontend/@n8n/chat/tsconfig.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"extends": "../../../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "dist",
|
||||
"baseUrl": "src",
|
||||
"target": "esnext",
|
||||
"module": "esnext",
|
||||
"allowJs": true,
|
||||
"importHelpers": true,
|
||||
"incremental": false,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"resolveJsonModule": true,
|
||||
"types": ["vitest/globals"],
|
||||
"paths": {
|
||||
"@n8n/chat/*": ["./*"]
|
||||
},
|
||||
"lib": ["esnext", "dom", "dom.iterable", "scripthost"],
|
||||
// TODO: remove all options below this line
|
||||
"useUnknownInCatchVariables": false
|
||||
},
|
||||
"include": ["**/*.ts", "**/*.vue"]
|
||||
}
|
||||
53
packages/frontend/@n8n/chat/vite.config.mts
Normal file
53
packages/frontend/@n8n/chat/vite.config.mts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import { resolve } from 'path';
|
||||
import vue from '@vitejs/plugin-vue';
|
||||
import icons from 'unplugin-icons/vite';
|
||||
import dts from 'vite-plugin-dts';
|
||||
|
||||
const includeVue = process.env.INCLUDE_VUE === 'true';
|
||||
const srcPath = resolve(__dirname, 'src');
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
vue(),
|
||||
icons({
|
||||
compiler: 'vue3',
|
||||
autoInstall: true,
|
||||
}),
|
||||
dts(),
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': srcPath,
|
||||
'@n8n/chat': srcPath,
|
||||
lodash: 'lodash-es',
|
||||
},
|
||||
},
|
||||
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',
|
||||
// Provide global variables to use in the UMD build
|
||||
// for externalized deps
|
||||
globals: includeVue
|
||||
? {}
|
||||
: {
|
||||
vue: 'Vue',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
30
packages/frontend/@n8n/chat/vitest.config.mts
Normal file
30
packages/frontend/@n8n/chat/vitest.config.mts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { resolve } from 'path';
|
||||
import { mergeConfig } from 'vite';
|
||||
import { type UserConfig } from 'vitest';
|
||||
import { defineConfig } from 'vitest/config';
|
||||
import viteConfig from './vite.config.mts';
|
||||
|
||||
const srcPath = resolve(__dirname, 'src');
|
||||
const vitestConfig = defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'jsdom',
|
||||
root: srcPath,
|
||||
setupFiles: ['./src/__tests__/setup.ts'],
|
||||
...(process.env.COVERAGE_ENABLED === 'true'
|
||||
? {
|
||||
coverage: {
|
||||
enabled: true,
|
||||
provider: 'v8',
|
||||
reporter: process.env.CI === 'true' ? 'cobertura' : 'text-summary',
|
||||
all: true,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
}) as UserConfig;
|
||||
|
||||
export default mergeConfig(
|
||||
viteConfig,
|
||||
vitestConfig,
|
||||
);
|
||||
Reference in New Issue
Block a user