Merge branch 'master' into cha-add-new-providers-3

This commit is contained in:
Jaakko Husso 2025-11-20 17:03:50 +02:00
commit b62ac45679
No known key found for this signature in database
141 changed files with 5119 additions and 2498 deletions

View File

@ -1,119 +0,0 @@
#!/bin/bash
set -euo pipefail
# Script to determine Docker tags for runners images (Alpine and distroless variants)
#
# Usage: determine-runners-tags.sh RELEASE_TYPE N8N_VERSION_TAG GHCR_BASE DOCKER_BASE PLATFORM GITHUB_OUTPUT
#
# Example:
# determine-runners-tags.sh \
# "stable" \
# "1.123.0" \
# "ghcr.io/n8n-io/runners" \
# "n8nio/runners" \
# "amd64" \
# "$GITHUB_OUTPUT"
#
# Output (written to GITHUB_OUTPUT):
# Alpine variant:
# tags=ghcr.io/n8n-io/runners:1.123.0-amd64, n8nio/runners:1.123.0-amd64
# ghcr_platform_tag=ghcr.io/n8n-io/runners:1.123.0-amd64
# dockerhub_platform_tag=n8nio/runners:1.123.0-amd64
# primary_ghcr_manifest_tag=ghcr.io/n8n-io/runners:1.123.0
#
# Distroless variant:
# tags_distroless=ghcr.io/n8n-io/runners:1.123.0-distroless-amd64, n8nio/runners:1.123.0-distroless-amd64
# ghcr_platform_tag_distroless=ghcr.io/n8n-io/runners:1.123.0-distroless-amd64
# dockerhub_platform_tag_distroless=n8nio/runners:1.123.0-distroless-amd64
# primary_ghcr_manifest_tag_distroless=ghcr.io/n8n-io/runners:1.123.0-distroless
RELEASE_TYPE="${1:?Missing RELEASE_TYPE argument}"
N8N_VERSION_TAG="${2:?Missing N8N_VERSION_TAG argument}"
GHCR_BASE="${3:?Missing GHCR_BASE argument}"
DOCKER_BASE="${4:?Missing DOCKER_BASE argument}"
PLATFORM="${5:?Missing PLATFORM argument}"
GITHUB_OUTPUT="${6:?Missing GITHUB_OUTPUT argument}"
generate_tags() {
local VARIANT_SUFFIX="$1"
local OUTPUT_FILE="$2"
local GHCR_TAGS_FOR_PUSH=""
local DOCKER_TAGS_FOR_PUSH=""
local PRIMARY_GHCR_MANIFEST_TAG_VALUE=""
case "$RELEASE_TYPE" in
"stable")
PRIMARY_GHCR_MANIFEST_TAG_VALUE="${GHCR_BASE}:${N8N_VERSION_TAG}${VARIANT_SUFFIX}"
GHCR_TAGS_FOR_PUSH="${PRIMARY_GHCR_MANIFEST_TAG_VALUE}-${PLATFORM}"
DOCKER_TAGS_FOR_PUSH="${DOCKER_BASE}:${N8N_VERSION_TAG}${VARIANT_SUFFIX}-${PLATFORM}"
;;
"nightly")
PRIMARY_GHCR_MANIFEST_TAG_VALUE="${GHCR_BASE}:nightly${VARIANT_SUFFIX}"
GHCR_TAGS_FOR_PUSH="${PRIMARY_GHCR_MANIFEST_TAG_VALUE}-${PLATFORM}"
DOCKER_TAGS_FOR_PUSH="${DOCKER_BASE}:nightly${VARIANT_SUFFIX}-${PLATFORM}"
;;
"branch")
PRIMARY_GHCR_MANIFEST_TAG_VALUE="${GHCR_BASE}:${N8N_VERSION_TAG}${VARIANT_SUFFIX}"
GHCR_TAGS_FOR_PUSH="${PRIMARY_GHCR_MANIFEST_TAG_VALUE}-${PLATFORM}"
DOCKER_TAGS_FOR_PUSH=""
;;
"dev"|*)
if [[ "$N8N_VERSION_TAG" == pr-* ]]; then
PRIMARY_GHCR_MANIFEST_TAG_VALUE="${GHCR_BASE}:${N8N_VERSION_TAG}${VARIANT_SUFFIX}"
GHCR_TAGS_FOR_PUSH="${PRIMARY_GHCR_MANIFEST_TAG_VALUE}-${PLATFORM}"
DOCKER_TAGS_FOR_PUSH=""
else
PRIMARY_GHCR_MANIFEST_TAG_VALUE="${GHCR_BASE}:dev${VARIANT_SUFFIX}"
GHCR_TAGS_FOR_PUSH="${PRIMARY_GHCR_MANIFEST_TAG_VALUE}-${PLATFORM}"
DOCKER_TAGS_FOR_PUSH="${DOCKER_BASE}:dev${VARIANT_SUFFIX}-${PLATFORM}"
fi
;;
esac
local ALL_TAGS="${GHCR_TAGS_FOR_PUSH}"
if [[ -n "$DOCKER_TAGS_FOR_PUSH" ]]; then
ALL_TAGS="${ALL_TAGS}\n${DOCKER_TAGS_FOR_PUSH}"
fi
{
echo "tags<<EOF"
echo -e "$ALL_TAGS"
echo "EOF"
echo "ghcr_platform_tag=${GHCR_TAGS_FOR_PUSH}"
echo "dockerhub_platform_tag=${DOCKER_TAGS_FOR_PUSH}"
} >> "$OUTPUT_FILE"
# Only output manifest tags from the first platform to avoid duplicates
if [[ "$PLATFORM" == "amd64" ]]; then
echo "primary_ghcr_manifest_tag=${PRIMARY_GHCR_MANIFEST_TAG_VALUE}" >> "$OUTPUT_FILE"
fi
}
# Reads outputs from a temp file and appends them to GITHUB_OUTPUT with _distroless suffix
# Transforms variable names: tags -> tags_distroless, ghcr_platform_tag -> ghcr_platform_tag_distroless
transform_and_append_distroless_outputs() {
local TEMP_FILE="$1"
while IFS= read -r line; do
if [[ "$line" == "tags<<EOF" ]]; then
echo "tags_distroless<<EOF" >> "$GITHUB_OUTPUT"
elif [[ "$line" =~ ^(ghcr_platform_tag|dockerhub_platform_tag|primary_ghcr_manifest_tag)= ]]; then
key="${line%%=*}"
value="${line#*=}"
echo "${key}_distroless=${value}" >> "$GITHUB_OUTPUT"
else
# Pass through EOF markers and tag content
echo "$line" >> "$GITHUB_OUTPUT"
fi
done < "$TEMP_FILE"
}
# Generate tags for Alpine variant (no suffix)
generate_tags "" "$GITHUB_OUTPUT"
# Generate tags for distroless variant
DISTROLESS_OUTPUT=$(mktemp)
generate_tags "-distroless" "$DISTROLESS_OUTPUT"
transform_and_append_distroless_outputs "$DISTROLESS_OUTPUT"
rm "$DISTROLESS_OUTPUT"

171
.github/scripts/docker/docker-config.mjs vendored Normal file
View File

@ -0,0 +1,171 @@
#!/usr/bin/env node
import { appendFileSync } from 'node:fs';
class BuildContext {
constructor() {
this.githubOutput = process.env.GITHUB_OUTPUT || null;
}
determine({ event, pr, branch, version, releaseType, pushEnabled }) {
let context = {
version: '',
release_type: '',
platforms: ['linux/amd64', 'linux/arm64'],
push_to_ghcr: true,
push_to_docker: false,
};
// Determine version and release type based on event
switch (event) {
case 'schedule':
context.version = 'nightly';
context.release_type = 'nightly';
context.push_to_docker = true;
break;
case 'pull_request':
context.version = `pr-${pr}`;
context.release_type = 'dev';
context.push_to_ghcr = false;
break;
case 'workflow_dispatch':
context.version = `branch-${this.sanitizeBranch(branch)}`;
context.release_type = 'branch';
context.platforms = ['linux/amd64'];
break;
case 'push':
if (branch === 'master') {
context.version = 'dev';
context.release_type = 'dev';
context.push_to_docker = true;
} else {
context.version = `branch-${this.sanitizeBranch(branch)}`;
context.release_type = 'branch';
context.platforms = ['linux/amd64'];
}
break;
case 'workflow_call':
case 'release':
if (!version) throw new Error('Version required for release');
context.version = version;
context.release_type = releaseType || 'stable';
context.push_to_docker = true;
break;
default:
throw new Error(`Unknown event: ${event}`);
}
// Handle push_enabled override
if (pushEnabled !== undefined) {
context.push_enabled = pushEnabled;
} else {
context.push_enabled = context.push_to_ghcr;
}
return context;
}
sanitizeBranch(branch) {
if (!branch) return 'unknown';
return branch
.toLowerCase()
.replace(/[^a-z0-9._-]/g, '-')
.replace(/^[.-]/, '')
.replace(/[.-]$/, '')
.substring(0, 128);
}
buildMatrix(platforms) {
const runners = {
'linux/amd64': 'blacksmith-4vcpu-ubuntu-2204',
'linux/arm64': 'blacksmith-4vcpu-ubuntu-2204-arm',
};
const matrix = {
platform: [],
include: [],
};
for (const platform of platforms) {
const shortName = platform.split('/').pop(); // amd64 or arm64
matrix.platform.push(shortName);
matrix.include.push({
platform: shortName,
runner: runners[platform],
docker_platform: platform,
});
}
return matrix;
}
output(context, matrix = null) {
const buildMatrix = matrix || this.buildMatrix(context.platforms);
if (this.githubOutput) {
const outputs = [
`version=${context.version}`,
`release_type=${context.release_type}`,
`platforms=${JSON.stringify(context.platforms)}`,
`push_to_ghcr=${context.push_to_ghcr}`,
`push_to_docker=${context.push_to_docker}`,
`push_enabled=${context.push_enabled}`,
`build_matrix=${JSON.stringify(buildMatrix)}`,
];
appendFileSync(this.githubOutput, outputs.join('\n') + '\n');
} else {
console.log(JSON.stringify({ ...context, build_matrix: buildMatrix }, null, 2));
}
}
}
// CLI - Simple argument parsing
if (import.meta.url === `file://${process.argv[1]}`) {
const args = process.argv.slice(2);
const getArg = (name) => {
const index = args.indexOf(`--${name}`);
if (index === -1 || !args[index + 1]) return undefined;
const value = args[index + 1];
// Handle empty strings and 'null' as undefined
return value === '' || value === 'null' ? undefined : value;
};
try {
const context = new BuildContext();
const pushEnabledArg = getArg('push-enabled');
const result = context.determine({
event: getArg('event') || process.env.GITHUB_EVENT_NAME,
pr: getArg('pr') || process.env.GITHUB_PR_NUMBER,
branch: getArg('branch') || process.env.GITHUB_REF_NAME,
version: getArg('version'),
releaseType: getArg('release-type'),
pushEnabled: pushEnabledArg === 'true' ? true : pushEnabledArg === 'false' ? false : undefined,
});
const matrix = context.buildMatrix(result.platforms);
// Debug output when GITHUB_OUTPUT is set (running in Actions)
if (context.githubOutput) {
console.log('=== Build Context ===');
console.log(`version: ${result.version}`);
console.log(`release_type: ${result.release_type}`);
console.log(`platforms: ${JSON.stringify(result.platforms, null, 2)}`);
console.log(`push_to_ghcr: ${result.push_to_ghcr}`);
console.log(`push_to_docker: ${result.push_to_docker}`);
console.log(`push_enabled: ${result.push_enabled}`);
console.log('build_matrix:', JSON.stringify(matrix, null, 2));
}
context.output(result, matrix);
} catch (error) {
console.error(`Error: ${error.message}`);
process.exit(1);
}
}
export default BuildContext;

113
.github/scripts/docker/docker-tags.mjs vendored Normal file
View File

@ -0,0 +1,113 @@
#!/usr/bin/env node
import { appendFileSync } from 'node:fs';
class TagGenerator {
constructor() {
this.githubOwner = process.env.GITHUB_REPOSITORY_OWNER || 'n8n-io';
this.dockerUsername = process.env.DOCKER_USERNAME || 'n8nio';
this.githubOutput = process.env.GITHUB_OUTPUT || null;
}
generate({ image, version, platform, includeDockerHub = false }) {
let imageName = image;
let versionSuffix = '';
if (image === 'runners-distroless') {
imageName = 'runners';
versionSuffix = '-distroless';
}
const platformSuffix = platform ? `-${platform.split('/').pop()}` : '';
const fullVersion = `${version}${versionSuffix}${platformSuffix}`;
const tags = {
ghcr: [`ghcr.io/${this.githubOwner}/${imageName}:${fullVersion}`],
docker: includeDockerHub ? [`${this.dockerUsername}/${imageName}:${fullVersion}`] : [],
};
tags.all = [...tags.ghcr, ...tags.docker];
return tags;
}
output(tags, prefix = '') {
if (this.githubOutput) {
const prefixStr = prefix ? `${prefix}_` : '';
const primaryTag = tags.ghcr[0] ? tags.ghcr[0].replace(/-amd64$|-arm64$/, '') : '';
const outputs = [
`${prefixStr}tags=${tags.all.join(',')}`,
`${prefixStr}ghcr_tag=${tags.ghcr[0] || ''}`,
`${prefixStr}docker_tag=${tags.docker[0] || ''}`,
`${prefixStr}primary_tag=${primaryTag}`,
];
appendFileSync(this.githubOutput, outputs.join('\n') + '\n');
} else {
console.log(JSON.stringify(tags, null, 2));
}
}
generateAll({ version, platform, includeDockerHub = false }) {
const images = ['n8n', 'runners', 'runners-distroless'];
const results = {};
for (const image of images) {
const tags = this.generate({ image, version, platform, includeDockerHub });
const prefix = image.replace('-distroless', '_distroless');
results[prefix] = tags;
if (this.githubOutput) {
this.output(tags, prefix);
}
}
return results;
}
}
if (import.meta.url === `file://${process.argv[1]}`) {
const args = process.argv.slice(2);
const getArg = (name) => {
const index = args.indexOf(`--${name}`);
return index !== -1 && args[index + 1] ? args[index + 1] : undefined;
};
const hasFlag = (name) => args.includes(`--${name}`);
try {
const generator = new TagGenerator();
const version = getArg('version');
if (!version) {
console.error('Error: --version is required');
process.exit(1);
}
if (hasFlag('all')) {
const results = generator.generateAll({
version,
platform: getArg('platform'),
includeDockerHub: hasFlag('include-docker'),
});
if (!generator.githubOutput) {
console.log(JSON.stringify(results, null, 2));
}
} else {
const image = getArg('image');
if (!image) {
console.error('Error: Either --image or --all is required');
process.exit(1);
}
const tags = generator.generate({
image,
version,
platform: getArg('platform'),
includeDockerHub: hasFlag('include-docker'),
});
generator.output(tags);
}
} catch (error) {
console.error(`Error: ${error.message}`);
process.exit(1);
}
}
export default TagGenerator;

View File

@ -59,7 +59,7 @@ jobs:
name: MariaDB
needs: build
runs-on: blacksmith-4vcpu-ubuntu-2204
timeout-minutes: 20
timeout-minutes: 30
env:
DB_MYSQLDB_PASSWORD: password
DB_MYSQLDB_POOL_SIZE: 1

View File

@ -1,9 +1,7 @@
# This workflow is used to build and push the Docker image for n8nio/n8n and n8nio/runners
# - determine-build-context: Determines what needs to be built based on the trigger
# - build-and-push-docker: This builds on both an ARM64 and AMD64 runner so the builds are native to the platform. Uses blacksmith native runners and build-push-action
# - create_multi_arch_manifest: This creates the multi-arch manifest for the Docker image. Needed to recombine the images from the build-and-push-docker job since they are separate runners.
# - security-scan: This scans the n8nio/n8n Docker image for security vulnerabilities using Trivy.
# - security-scan-runners: This scans the n8nio/runners Docker image for security vulnerabilities using Trivy.
#
# - Uses docker-config.mjs for context determination, this determines what needs to be built based on the trigger
# - Uses docker-tags.mjs for tag generation, this generates the tags for the images
name: 'Docker: Build and Push'
@ -50,6 +48,8 @@ on:
- ready_for_review
paths:
- '.github/workflows/docker-build-push.yml'
- '.github/scripts/docker/docker-config.mjs'
- '.github/scripts/docker/docker-tags.mjs'
- 'docker/images/n8n/Dockerfile'
- 'docker/images/runners/Dockerfile'
- 'docker/images/runners/Dockerfile.distroless'
@ -60,107 +60,24 @@ jobs:
runs-on: ubuntu-latest
outputs:
release_type: ${{ steps.context.outputs.release_type }}
n8n_version: ${{ steps.context.outputs.n8n_version }}
n8n_version: ${{ steps.context.outputs.version }}
push_enabled: ${{ steps.context.outputs.push_enabled }}
build_matrix: ${{ steps.matrix.outputs.matrix }}
push_to_docker: ${{ steps.context.outputs.push_to_docker }}
build_matrix: ${{ steps.context.outputs.build_matrix }}
steps:
- name: Determine build context values
- name: Checkout code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Determine build context
id: context
run: |
# Debug info
echo "Event: ${{ github.event_name }}"
echo "Ref: ${{ github.ref }}"
echo "Ref Name: ${{ github.ref_name }}"
# Check if called by another workflow (has n8n_version input)
if [[ -n "${{ inputs.n8n_version }}" ]]; then
# workflow_call - used for releases
{
echo "release_type=${{ inputs.release_type }}"
echo "n8n_version=${{ inputs.n8n_version }}"
echo "push_enabled=${{ inputs.push_enabled }}"
} >> "$GITHUB_OUTPUT"
elif [[ "${{ github.event_name }}" == "schedule" ]]; then
# Nightly builds
{
echo "release_type=nightly"
echo "n8n_version=snapshot"
echo "push_enabled=true"
} >> "$GITHUB_OUTPUT"
elif [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
# Build branches for Nathan deploy
BRANCH_NAME="${{ github.ref_name }}"
# Fallback to parsing ref if ref_name is empty
if [[ -z "$BRANCH_NAME" ]] && [[ "${{ github.ref }}" =~ ^refs/heads/(.+)$ ]]; then
BRANCH_NAME="${BASH_REMATCH[1]}"
fi
# Sanitize branch name for Docker tag
SAFE_BRANCH_NAME=$(echo "$BRANCH_NAME" | tr '/' '-' | tr -cd '[:alnum:]-_')
if [[ -z "$SAFE_BRANCH_NAME" ]]; then
echo "Error: Could not determine valid branch name"
exit 1
fi
{
echo "release_type=branch"
echo "n8n_version=branch-${SAFE_BRANCH_NAME}"
echo "push_enabled=${{ inputs.push_enabled }}"
} >> "$GITHUB_OUTPUT"
elif [[ "${{ github.event_name }}" == "pull_request" ]]; then
# Direct PR triggers for testing Dockerfile changes
{
echo "release_type=dev"
echo "n8n_version=pr-${{ github.event.pull_request.number }}"
echo "push_enabled=false"
} >> "$GITHUB_OUTPUT"
fi
# Output summary for logs
echo "=== Build Context Summary ==="
echo "Release type: $(grep release_type "$GITHUB_OUTPUT" | cut -d= -f2)"
echo "N8N version: $(grep n8n_version "$GITHUB_OUTPUT" | cut -d= -f2)"
echo "Push enabled: $(grep push_enabled "$GITHUB_OUTPUT" | cut -d= -f2)"
- name: Determine build matrix
id: matrix
run: |
RELEASE_TYPE="${{ steps.context.outputs.release_type }}"
# Branch builds only need AMD64, everything else needs both platforms
if [[ "$RELEASE_TYPE" == "branch" ]]; then
MATRIX='{
"platform": ["amd64"],
"include": [{
"platform": "amd64",
"runner": "blacksmith-4vcpu-ubuntu-2204",
"docker_platform": "linux/amd64"
}]
}'
else
# All other builds (stable, nightly, dev, PR) need both platforms
MATRIX='{
"platform": ["amd64", "arm64"],
"include": [{
"platform": "amd64",
"runner": "blacksmith-4vcpu-ubuntu-2204",
"docker_platform": "linux/amd64"
}, {
"platform": "arm64",
"runner": "blacksmith-4vcpu-ubuntu-2204-arm",
"docker_platform": "linux/arm64"
}]
}'
fi
# Output matrix as single line for GITHUB_OUTPUT
echo "matrix=$(echo "$MATRIX" | jq -c .)" >> "$GITHUB_OUTPUT"
echo "Build matrix: $(echo "$MATRIX" | jq .)"
node .github/scripts/docker/docker-config.mjs \
--event "${{ github.event_name }}" \
--pr "${{ github.event.pull_request.number }}" \
--branch "${{ github.ref_name }}" \
--version "${{ inputs.n8n_version }}" \
--release-type "${{ inputs.release_type }}" \
--push-enabled "${{ inputs.push_enabled }}"
build-and-push-docker:
name: Build App, then Build and Push Docker Image (${{ matrix.platform }})
@ -170,10 +87,10 @@ jobs:
strategy:
matrix: ${{ fromJSON(needs.determine-build-context.outputs.build_matrix) }}
outputs:
image_ref: ${{ steps.determine-tags.outputs.primary_ghcr_manifest_tag }}
primary_ghcr_manifest_tag: ${{ steps.determine-tags.outputs.primary_ghcr_manifest_tag }}
runners_primary_ghcr_manifest_tag: ${{ steps.determine-runners-tags.outputs.primary_ghcr_manifest_tag }}
runners_distroless_primary_ghcr_manifest_tag: ${{ steps.determine-runners-tags.outputs.primary_ghcr_manifest_tag_distroless }}
image_ref: ${{ steps.determine-tags.outputs.n8n_primary_tag }}
primary_ghcr_manifest_tag: ${{ steps.determine-tags.outputs.n8n_primary_tag }}
runners_primary_ghcr_manifest_tag: ${{ steps.determine-tags.outputs.runners_primary_tag }}
runners_distroless_primary_ghcr_manifest_tag: ${{ steps.determine-tags.outputs.runners_distroless_primary_tag }}
steps:
- name: Checkout code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
@ -181,101 +98,23 @@ jobs:
fetch-depth: 0
- name: Setup and Build
uses: n8n-io/n8n/.github/actions/setup-nodejs-blacksmith@f5fbbbe0a28a886451c886cac6b49192a39b0eea # v1.104.1
uses: ./.github/actions/setup-nodejs-blacksmith
with:
build-command: pnpm build:n8n
- name: Determine Docker tags
- name: Determine Docker tags for all images
id: determine-tags
run: |
RELEASE_TYPE="${{ needs.determine-build-context.outputs.release_type }}"
N8N_VERSION_TAG="${{ needs.determine-build-context.outputs.n8n_version }}"
GHCR_BASE="ghcr.io/${{ github.repository_owner }}/n8n"
DOCKER_BASE="${{ secrets.DOCKER_USERNAME }}/n8n"
PLATFORM="${{ matrix.platform }}"
node .github/scripts/docker/docker-tags.mjs \
--all \
--version "${{ needs.determine-build-context.outputs.n8n_version }}" \
--platform "${{ matrix.docker_platform }}" \
${{ needs.determine-build-context.outputs.push_to_docker == 'true' && '--include-docker' || '' }}
GHCR_TAGS_FOR_PUSH=""
DOCKER_TAGS_FOR_PUSH=""
PRIMARY_GHCR_MANIFEST_TAG_VALUE=""
# Validate inputs
if [[ "$RELEASE_TYPE" == "stable" && -z "$N8N_VERSION_TAG" ]]; then
echo "Error: N8N_VERSION_TAG is empty for a stable release."
exit 1
fi
if [[ "$RELEASE_TYPE" == "branch" && -z "$N8N_VERSION_TAG" ]]; then
echo "Error: N8N_VERSION_TAG is empty for a branch release."
exit 1
fi
# Determine tags based on release type
case "$RELEASE_TYPE" in
"stable")
PRIMARY_GHCR_MANIFEST_TAG_VALUE="${GHCR_BASE}:${N8N_VERSION_TAG}"
GHCR_TAGS_FOR_PUSH="${PRIMARY_GHCR_MANIFEST_TAG_VALUE}-${PLATFORM}"
DOCKER_TAGS_FOR_PUSH="${DOCKER_BASE}:${N8N_VERSION_TAG}-${PLATFORM}"
;;
"nightly")
PRIMARY_GHCR_MANIFEST_TAG_VALUE="${GHCR_BASE}:nightly"
GHCR_TAGS_FOR_PUSH="${PRIMARY_GHCR_MANIFEST_TAG_VALUE}-${PLATFORM}"
DOCKER_TAGS_FOR_PUSH="${DOCKER_BASE}:nightly-${PLATFORM}"
;;
"branch")
PRIMARY_GHCR_MANIFEST_TAG_VALUE="${GHCR_BASE}:${N8N_VERSION_TAG}"
GHCR_TAGS_FOR_PUSH="${PRIMARY_GHCR_MANIFEST_TAG_VALUE}-${PLATFORM}"
# No Docker Hub tags for branch builds
DOCKER_TAGS_FOR_PUSH=""
;;
"dev"|*)
if [[ "$N8N_VERSION_TAG" == pr-* ]]; then
# PR builds only go to GHCR
PRIMARY_GHCR_MANIFEST_TAG_VALUE="${GHCR_BASE}:${N8N_VERSION_TAG}"
GHCR_TAGS_FOR_PUSH="${PRIMARY_GHCR_MANIFEST_TAG_VALUE}-${PLATFORM}"
DOCKER_TAGS_FOR_PUSH=""
else
# Regular dev builds go to both registries
PRIMARY_GHCR_MANIFEST_TAG_VALUE="${GHCR_BASE}:dev"
GHCR_TAGS_FOR_PUSH="${PRIMARY_GHCR_MANIFEST_TAG_VALUE}-${PLATFORM}"
DOCKER_TAGS_FOR_PUSH="${DOCKER_BASE}:dev-${PLATFORM}"
fi
;;
esac
# Combine all tags
ALL_TAGS="${GHCR_TAGS_FOR_PUSH}"
if [[ -n "$DOCKER_TAGS_FOR_PUSH" ]]; then
ALL_TAGS="${ALL_TAGS}\n${DOCKER_TAGS_FOR_PUSH}"
fi
echo "Generated Tags for push: $ALL_TAGS"
{
echo "tags<<EOF"
echo -e "$ALL_TAGS"
echo "EOF"
} >> "$GITHUB_OUTPUT"
{
echo "ghcr_platform_tag=${GHCR_TAGS_FOR_PUSH}"
echo "dockerhub_platform_tag=${DOCKER_TAGS_FOR_PUSH}"
} >> "$GITHUB_OUTPUT"
# Only output manifest tags from the first platform to avoid duplicates
if [[ "$PLATFORM" == "amd64" ]]; then
echo "primary_ghcr_manifest_tag=${PRIMARY_GHCR_MANIFEST_TAG_VALUE}" >> "$GITHUB_OUTPUT"
fi
- name: Determine Docker tags (runners variants)
id: determine-runners-tags
run: |
.github/scripts/determine-runners-tags.sh \
"${{ needs.determine-build-context.outputs.release_type }}" \
"${{ needs.determine-build-context.outputs.n8n_version }}" \
"ghcr.io/${{ github.repository_owner }}/runners" \
"${{ secrets.DOCKER_USERNAME }}/runners" \
"${{ matrix.platform }}" \
"$GITHUB_OUTPUT"
echo "=== Generated Docker Tags ==="
cat "$GITHUB_OUTPUT" | grep "_tags=" | while IFS='=' read -r key value; do
echo "${key}: ${value%%,*}..." # Show first tag for brevity
done
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3.10.0
@ -289,10 +128,9 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to Docker Hub
if: needs.determine-build-context.outputs.push_enabled == 'true' && (
steps.determine-tags.outputs.dockerhub_platform_tag != '' ||
steps.determine-runners-tags.outputs.dockerhub_platform_tag != '' ||
steps.determine-runners-tags.outputs.dockerhub_platform_tag_distroless != '')
if: |
needs.determine-build-context.outputs.push_enabled == 'true' &&
needs.determine-build-context.outputs.push_to_docker == 'true'
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
with:
username: ${{ secrets.DOCKER_USERNAME }}
@ -311,7 +149,7 @@ jobs:
provenance: true
sbom: true
push: ${{ needs.determine-build-context.outputs.push_enabled == 'true' }}
tags: ${{ steps.determine-tags.outputs.tags }}
tags: ${{ steps.determine-tags.outputs.n8n_tags }}
- name: Build and push task runners Docker image (Alpine)
uses: useblacksmith/build-push-action@574eb0ee0b59c6a687ace24192f0727dfb65d6d7 # v1.2
@ -326,7 +164,7 @@ jobs:
provenance: true
sbom: true
push: ${{ needs.determine-build-context.outputs.push_enabled == 'true' }}
tags: ${{ steps.determine-runners-tags.outputs.tags }}
tags: ${{ steps.determine-tags.outputs.runners_tags }}
- name: Build and push task runners Docker image (distroless)
uses: useblacksmith/build-push-action@574eb0ee0b59c6a687ace24192f0727dfb65d6d7 # v1.2
@ -341,7 +179,7 @@ jobs:
provenance: true
sbom: true
push: ${{ needs.determine-build-context.outputs.push_enabled == 'true' }}
tags: ${{ steps.determine-runners-tags.outputs.tags_distroless }}
tags: ${{ steps.determine-tags.outputs.runners_distroless_tags }}
create_multi_arch_manifest:
name: Create Multi-Arch Manifest
@ -361,181 +199,70 @@ jobs:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Determine Docker Hub manifest tag
id: dockerhub_check
run: |
RELEASE_TYPE="${{ needs.determine-build-context.outputs.release_type }}"
N8N_VERSION="${{ needs.determine-build-context.outputs.n8n_version }}"
DOCKER_BASE="${{ secrets.DOCKER_USERNAME }}/n8n"
# Determine if Docker Hub manifest is needed and construct the tag
case "$RELEASE_TYPE" in
"stable")
{
echo "DOCKER_MANIFEST_TAG=${DOCKER_BASE}:${N8N_VERSION}"
echo "CREATE_DOCKERHUB_MANIFEST=true"
} >> "$GITHUB_OUTPUT"
;;
"nightly")
{
echo "DOCKER_MANIFEST_TAG=${DOCKER_BASE}:nightly"
echo "CREATE_DOCKERHUB_MANIFEST=true"
} >> "$GITHUB_OUTPUT"
;;
"dev")
if [[ "$N8N_VERSION" != pr-* ]]; then
{
echo "DOCKER_MANIFEST_TAG=${DOCKER_BASE}:dev"
echo "CREATE_DOCKERHUB_MANIFEST=true"
} >> "$GITHUB_OUTPUT"
else
echo "CREATE_DOCKERHUB_MANIFEST=false" >> "$GITHUB_OUTPUT"
fi
;;
*)
echo "CREATE_DOCKERHUB_MANIFEST=false" >> "$GITHUB_OUTPUT"
;;
esac
- name: Determine Docker Hub manifest tags (runners variants)
id: dockerhub_runners_check
run: |
RELEASE_TYPE="${{ needs.determine-build-context.outputs.release_type }}"
N8N_VERSION="${{ needs.determine-build-context.outputs.n8n_version }}"
DOCKER_BASE="${{ secrets.DOCKER_USERNAME }}/runners"
# Generate manifest tags for both Alpine and distroless variants
for VARIANT in "" "_distroless"; do
SUFFIX="${VARIANT//_/-}" # Convert _distroless to -distroless for tag
OUTPUT_SUFFIX="${VARIANT}" # Keep underscore for output var names
case "$RELEASE_TYPE" in
"stable")
echo "DOCKER_MANIFEST_TAG${OUTPUT_SUFFIX}=${DOCKER_BASE}:${N8N_VERSION}${SUFFIX}" >> "$GITHUB_OUTPUT"
echo "CREATE_DOCKERHUB_MANIFEST${OUTPUT_SUFFIX}=true" >> "$GITHUB_OUTPUT"
;;
"nightly")
echo "DOCKER_MANIFEST_TAG${OUTPUT_SUFFIX}=${DOCKER_BASE}:nightly${SUFFIX}" >> "$GITHUB_OUTPUT"
echo "CREATE_DOCKERHUB_MANIFEST${OUTPUT_SUFFIX}=true" >> "$GITHUB_OUTPUT"
;;
"dev")
if [[ "$N8N_VERSION" != pr-* ]]; then
echo "DOCKER_MANIFEST_TAG${OUTPUT_SUFFIX}=${DOCKER_BASE}:dev${SUFFIX}" >> "$GITHUB_OUTPUT"
echo "CREATE_DOCKERHUB_MANIFEST${OUTPUT_SUFFIX}=true" >> "$GITHUB_OUTPUT"
else
echo "CREATE_DOCKERHUB_MANIFEST${OUTPUT_SUFFIX}=false" >> "$GITHUB_OUTPUT"
fi
;;
*)
echo "CREATE_DOCKERHUB_MANIFEST${OUTPUT_SUFFIX}=false" >> "$GITHUB_OUTPUT"
;;
esac
done
- name: Login to Docker Hub
if: steps.dockerhub_check.outputs.CREATE_DOCKERHUB_MANIFEST == 'true' ||
steps.dockerhub_runners_check.outputs.CREATE_DOCKERHUB_MANIFEST == 'true' ||
steps.dockerhub_runners_check.outputs.CREATE_DOCKERHUB_MANIFEST_distroless == 'true'
if: needs.determine-build-context.outputs.push_to_docker == 'true'
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Create GHCR multi-arch manifest
if: needs.build-and-push-docker.outputs.primary_ghcr_manifest_tag != ''
- name: Create GHCR multi-arch manifests
run: |
MANIFEST_TAG="${{ needs.build-and-push-docker.outputs.primary_ghcr_manifest_tag }}"
RELEASE_TYPE="${{ needs.determine-build-context.outputs.release_type }}"
echo "Creating GHCR manifest: $MANIFEST_TAG"
# Function to create manifest for an image
create_manifest() {
local IMAGE_NAME=$1
local MANIFEST_TAG=$2
# For branch builds, only AMD64 is built
if [[ "$RELEASE_TYPE" == "branch" ]]; then
docker buildx imagetools create \
--tag $MANIFEST_TAG \
${MANIFEST_TAG}-amd64
else
docker buildx imagetools create \
--tag $MANIFEST_TAG \
${MANIFEST_TAG}-amd64 \
${MANIFEST_TAG}-arm64
fi
if [[ -z "$MANIFEST_TAG" ]]; then
echo "Skipping $IMAGE_NAME - no manifest tag"
return
fi
- name: Create GHCR multi-arch manifest for runners (Alpine)
if: needs.build-and-push-docker.outputs.runners_primary_ghcr_manifest_tag != ''
echo "Creating GHCR manifest for $IMAGE_NAME: $MANIFEST_TAG"
# For branch builds, only AMD64 is built
if [[ "$RELEASE_TYPE" == "branch" ]]; then
docker buildx imagetools create \
--tag "$MANIFEST_TAG" \
"${MANIFEST_TAG}-amd64"
else
docker buildx imagetools create \
--tag "$MANIFEST_TAG" \
"${MANIFEST_TAG}-amd64" \
"${MANIFEST_TAG}-arm64"
fi
}
# Create manifests for all images
create_manifest "n8n" "${{ needs.build-and-push-docker.outputs.primary_ghcr_manifest_tag }}"
create_manifest "runners" "${{ needs.build-and-push-docker.outputs.runners_primary_ghcr_manifest_tag }}"
create_manifest "runners-distroless" "${{ needs.build-and-push-docker.outputs.runners_distroless_primary_ghcr_manifest_tag }}"
- name: Create Docker Hub manifests
if: needs.determine-build-context.outputs.push_to_docker == 'true'
run: |
MANIFEST_TAG="${{ needs.build-and-push-docker.outputs.runners_primary_ghcr_manifest_tag }}"
RELEASE_TYPE="${{ needs.determine-build-context.outputs.release_type }}"
VERSION="${{ needs.determine-build-context.outputs.n8n_version }}"
DOCKER_BASE="${{ secrets.DOCKER_USERNAME }}"
echo "Creating GHCR runners manifest: $MANIFEST_TAG"
# Create manifests for each image type
declare -A images=(
["n8n"]="${VERSION}"
["runners"]="${VERSION}"
["runners-distroless"]="${VERSION}-distroless"
)
# For branch builds, only AMD64 is built
if [[ "$RELEASE_TYPE" == "branch" ]]; then
for image in "${!images[@]}"; do
TAG_SUFFIX="${images[$image]}"
IMAGE_NAME="${image//-distroless/}" # Remove -distroless from image name
echo "Creating Docker Hub manifest for $image"
docker buildx imagetools create \
--tag $MANIFEST_TAG \
${MANIFEST_TAG}-amd64
else
docker buildx imagetools create \
--tag $MANIFEST_TAG \
${MANIFEST_TAG}-amd64 \
${MANIFEST_TAG}-arm64
fi
- name: Create GHCR multi-arch manifest for runners (distroless)
if: needs.build-and-push-docker.outputs.runners_distroless_primary_ghcr_manifest_tag != ''
run: |
MANIFEST_TAG="${{ needs.build-and-push-docker.outputs.runners_distroless_primary_ghcr_manifest_tag }}"
RELEASE_TYPE="${{ needs.determine-build-context.outputs.release_type }}"
echo "Creating GHCR runners distroless manifest: $MANIFEST_TAG"
# For branch builds, only AMD64 is built
if [[ "$RELEASE_TYPE" == "branch" ]]; then
docker buildx imagetools create \
--tag $MANIFEST_TAG \
${MANIFEST_TAG}-amd64
else
docker buildx imagetools create \
--tag $MANIFEST_TAG \
${MANIFEST_TAG}-amd64 \
${MANIFEST_TAG}-arm64
fi
- name: Create Docker Hub multi-arch manifest
if: steps.dockerhub_check.outputs.CREATE_DOCKERHUB_MANIFEST == 'true'
run: |
MANIFEST_TAG="${{ steps.dockerhub_check.outputs.DOCKER_MANIFEST_TAG }}"
echo "Creating Docker Hub manifest: $MANIFEST_TAG"
docker buildx imagetools create \
--tag $MANIFEST_TAG \
${MANIFEST_TAG}-amd64 \
${MANIFEST_TAG}-arm64
- name: Create Docker Hub multi-arch manifest for runners (Alpine)
if: steps.dockerhub_runners_check.outputs.CREATE_DOCKERHUB_MANIFEST == 'true'
run: |
MANIFEST_TAG="${{ steps.dockerhub_runners_check.outputs.DOCKER_MANIFEST_TAG }}"
echo "Creating Docker Hub manifest: $MANIFEST_TAG"
docker buildx imagetools create \
--tag $MANIFEST_TAG \
${MANIFEST_TAG}-amd64 \
${MANIFEST_TAG}-arm64
- name: Create Docker Hub multi-arch manifest for runners (distroless)
if: steps.dockerhub_runners_check.outputs.CREATE_DOCKERHUB_MANIFEST_distroless == 'true'
run: |
MANIFEST_TAG="${{ steps.dockerhub_runners_check.outputs.DOCKER_MANIFEST_TAG_distroless }}"
echo "Creating Docker Hub distroless manifest: $MANIFEST_TAG"
docker buildx imagetools create \
--tag $MANIFEST_TAG \
${MANIFEST_TAG}-amd64 \
${MANIFEST_TAG}-arm64
--tag "${DOCKER_BASE}/${IMAGE_NAME}:${TAG_SUFFIX}" \
"${DOCKER_BASE}/${IMAGE_NAME}:${TAG_SUFFIX}-amd64" \
"${DOCKER_BASE}/${IMAGE_NAME}:${TAG_SUFFIX}-arm64"
done
call-success-url:
name: Call Success URL
@ -566,7 +293,7 @@ jobs:
security-scan-runners:
name: Security Scan (runners)
needs: [determine-build-context, build-and-push-docker]
needs: [determine-build-context, build-and-push-docker, create_multi_arch_manifest]
if: |
success() &&
(needs.determine-build-context.outputs.release_type == 'stable' ||

View File

@ -11,15 +11,6 @@ WORKDIR /app/task-runner-javascript
RUN corepack enable pnpm
# Install extra runtime-only npm packages. Allow usage in the Code node via
# 'NODE_FUNCTION_ALLOW_EXTERNAL' env variable on n8n-task-runners.json.
RUN rm -f node_modules/.modules.yaml
RUN mv package.json package.json.bak
COPY docker/images/runners/package.json /app/task-runner-javascript/package.json
RUN pnpm install --prod --no-lockfile --silent
RUN mv package.json extras.json
RUN mv package.json.bak package.json
# Remove `catalog` and `workspace` references from package.json to allow `pnpm add` in extended images
RUN node -e "const pkg = require('./package.json'); \
Object.keys(pkg.dependencies || {}).forEach(k => { \
@ -79,11 +70,6 @@ RUN uv sync \
--all-extras \
--no-editable
# Install extra runtime-only Python packages. Allow usage in the Code node via
# 'N8N_RUNNERS_EXTERNAL_ALLOW' env variable on n8n-task-runners.json.
COPY docker/images/runners/extras.txt /app/task-runner-python/extras.txt
RUN uv pip install -r /app/task-runner-python/extras.txt
# ==============================================================================
# STAGE 3: Task Runner Launcher download
# ==============================================================================

View File

@ -26,14 +26,24 @@ WORKDIR /app/task-runner-javascript
RUN corepack enable pnpm
# Install extra runtime-only npm packages. Allow usage in the Code node via
# 'NODE_FUNCTION_ALLOW_EXTERNAL' env variable on n8n-task-runners.json.
RUN rm -f node_modules/.modules.yaml
RUN mv package.json package.json.bak
COPY docker/images/runners/package.json /app/task-runner-javascript/package.json
RUN pnpm install --prod --no-lockfile --silent
RUN mv package.json extras.json
RUN mv package.json.bak package.json
# Remove `catalog` and `workspace` references from package.json to allow `pnpm add`
RUN node -e "const pkg = require('./package.json'); \
Object.keys(pkg.dependencies || {}).forEach(k => { \
const val = pkg.dependencies[k]; \
if (val === 'catalog:' || val.startsWith('catalog:') || val.startsWith('workspace:')) \
delete pkg.dependencies[k]; \
}); \
Object.keys(pkg.devDependencies || {}).forEach(k => { \
const val = pkg.devDependencies[k]; \
if (val === 'catalog:' || val.startsWith('catalog:') || val.startsWith('workspace:')) \
delete pkg.devDependencies[k]; \
}); \
delete pkg.devDependencies; \
require('fs').writeFileSync('./package.json', JSON.stringify(pkg, null, 2));"
# Install moment by default (special case for n8n cloud)
RUN rm -f node_modules/.modules.yaml && \
pnpm add moment@2.30.1 --prod --no-lockfile
# ==============================================================================
# STAGE 2: Python runner build (@n8n/task-runner-python) with uv
@ -81,11 +91,6 @@ RUN uv sync \
--all-extras \
--no-editable
# Install extra runtime-only Python packages. Allow usage in the Code node via
# 'N8N_RUNNERS_EXTERNAL_ALLOW' env variable on n8n-task-runners.json.
COPY docker/images/runners/extras.txt /app/task-runner-python/extras.txt
RUN uv pip install -r /app/task-runner-python/extras.txt
# ==============================================================================
# STAGE 3: Task Runner Launcher download
# ==============================================================================

View File

@ -1,5 +0,0 @@
# Runtime-only extra Requirements File for installing dependencies in the Python task runner image.
# Installed at Docker image build time. Allow usage in the Code node
# via 'N8N_RUNNERS_EXTERNAL_ALLOW' env variable on n8n-task-runners.json.
# numpy==2.3.2

View File

@ -1,8 +0,0 @@
{
"name": "task-runner-runtime-extras",
"description": "Runtime-only extra dependencies for installing packages in the JavaScript task runner image. Installed at Docker image build time. Allow usage in the Code node via 'NODE_FUNCTION_ALLOW_EXTERNAL' env variable on n8n-task-runners.json.",
"private": true,
"dependencies": {
"moment": "2.30.1"
}
}

View File

@ -12,6 +12,7 @@
"lint:fix": "eslint . --fix",
"watch": "tsc -p tsconfig.build.json --watch",
"test": "jest",
"test:unit": "jest",
"test:dev": "jest --watch"
},
"main": "dist/index.js",

View File

@ -19,6 +19,8 @@ export const chatHubLLMProviderSchema = z.enum([
'ollama',
'awsBedrock',
'deepSeek',
'cohere',
'mistralCloud',
]);
export type ChatHubLLMProvider = z.infer<typeof chatHubLLMProviderSchema>;
@ -44,6 +46,8 @@ export const PROVIDER_CREDENTIAL_TYPE_MAP: Record<
azureOpenAi: 'azureOpenAiApi',
awsBedrock: 'aws',
deepSeek: 'deepSeekApi',
cohere: 'cohereApi',
mistralCloud: 'mistralCloudApi',
};
export type ChatHubAgentTool = typeof JINA_AI_TOOL_NODE_TYPE | typeof SEAR_XNG_TOOL_NODE_TYPE;
@ -86,6 +90,16 @@ const deepSeekModelSchema = z.object({
model: z.string(),
});
const cohereModelSchema = z.object({
provider: z.literal('cohere'),
model: z.string(),
});
const mistralCloudModelSchema = z.object({
provider: z.literal('mistralCloud'),
model: z.string(),
});
const n8nModelSchema = z.object({
provider: z.literal('n8n'),
workflowId: z.string(),
@ -104,6 +118,8 @@ export const chatHubConversationModelSchema = z.discriminatedUnion('provider', [
ollamaModelSchema,
awsBedrockModelSchema,
deepSeekModelSchema,
cohereModelSchema,
mistralCloudModelSchema,
n8nModelSchema,
chatAgentSchema,
]);
@ -115,6 +131,8 @@ export type ChatHubAzureOpenAIModel = z.infer<typeof azureOpenAIModelSchema>;
export type ChatHubOllamaModel = z.infer<typeof ollamaModelSchema>;
export type ChatHubAwsBedrockModel = z.infer<typeof awsBedrockModelSchema>;
export type ChatHubDeepSeekModel = z.infer<typeof deepSeekModelSchema>;
export type ChatHubCohereModel = z.infer<typeof cohereModelSchema>;
export type ChatHubMistralCloudModel = z.infer<typeof mistralCloudModelSchema>;
export type ChatHubBaseLLMModel =
| ChatHubOpenAIModel
| ChatHubAnthropicModel
@ -122,7 +140,9 @@ export type ChatHubBaseLLMModel =
| ChatHubAzureOpenAIModel
| ChatHubOllamaModel
| ChatHubAwsBedrockModel
| ChatHubDeepSeekModel;
| ChatHubDeepSeekModel
| ChatHubCohereModel
| ChatHubMistralCloudModel;
export type ChatHubN8nModel = z.infer<typeof n8nModelSchema>;
export type ChatHubCustomAgentModel = z.infer<typeof chatAgentSchema>;
@ -144,6 +164,7 @@ export interface ChatModelDto {
description: string | null;
updatedAt: string | null;
createdAt: string | null;
allowFileUploads?: boolean;
}
/**
@ -165,11 +186,25 @@ export const emptyChatModelsResponse: ChatModelsResponse = {
ollama: { models: [] },
awsBedrock: { models: [] },
deepSeek: { models: [] },
cohere: { models: [] },
mistralCloud: { models: [] },
n8n: { models: [] },
// eslint-disable-next-line @typescript-eslint/naming-convention
'custom-agent': { models: [] },
};
/**
* Chat attachment schema for incoming requests.
* Requires base64 data and fileName.
* MimeType, fileType, fileExtension, and fileSize are populated server-side.
*/
export const chatAttachmentSchema = z.object({
data: z.string(),
fileName: z.string(),
});
export type ChatAttachment = z.infer<typeof chatAttachmentSchema>;
export class ChatHubSendMessageRequest extends Z.class({
messageId: z.string().uuid(),
sessionId: z.string().uuid(),
@ -183,6 +218,7 @@ export class ChatHubSendMessageRequest extends Z.class({
}),
),
tools: z.array(INodeSchema),
attachments: z.array(chatAttachmentSchema),
}) {}
export class ChatHubRegenerateMessageRequest extends Z.class({
@ -257,9 +293,20 @@ export interface ChatHubMessageDto {
previousMessageId: ChatMessageId | null;
retryOfMessageId: ChatMessageId | null;
revisionOfMessageId: ChatMessageId | null;
attachments: Array<{ fileName?: string; mimeType?: string }>;
}
export type ChatHubConversationsResponse = ChatHubSessionDto[];
export class ChatHubConversationsRequest extends Z.class({
limit: z.coerce.number().int().min(1).max(100),
cursor: z.string().uuid().optional(),
}) {}
export interface ChatHubConversationsResponse {
data: ChatHubSessionDto[];
nextCursor: string | null;
hasMore: boolean;
}
export interface ChatHubConversationDto {
messages: Record<ChatMessageId, ChatHubMessageDto>;

View File

@ -6,25 +6,30 @@ describe('ImportWorkflowFromUrlDto', () => {
{
name: 'valid URL with .json extension',
url: 'https://example.com/workflow.json',
projectId: '12345',
},
{
name: 'valid URL without .json extension',
url: 'https://example.com/workflow',
projectId: '12345',
},
{
name: 'valid URL with query parameters',
url: 'https://example.com/workflow.json?param=value',
projectId: '12345',
},
{
name: 'valid URL with fragments',
url: 'https://example.com/workflow.json#section',
projectId: '12345',
},
{
name: 'valid API endpoint URL',
url: 'https://api.example.com/v1/workflows/123',
projectId: '12345',
},
])('should validate $name', ({ url }) => {
const result = ImportWorkflowFromUrlDto.safeParse({ url });
])('should validate $name', ({ url, projectId }) => {
const result = ImportWorkflowFromUrlDto.safeParse({ url, projectId });
expect(result.success).toBe(true);
});
});

View File

@ -27,10 +27,13 @@ export {
emptyChatModelsResponse,
type ChatModelsRequest,
type ChatModelsResponse,
chatAttachmentSchema,
type ChatAttachment,
ChatHubSendMessageRequest,
ChatHubRegenerateMessageRequest,
ChatHubEditMessageRequest,
ChatHubUpdateConversationRequest,
ChatHubConversationsRequest,
type ChatMessageId,
type ChatSessionId,
type ChatHubMessageDto,

View File

@ -12,6 +12,7 @@
"lint:fix": "eslint . --fix",
"watch": "tsc -p tsconfig.build.json --watch",
"test": "jest",
"test:unit": "jest",
"test:dev": "jest --watch"
},
"main": "dist/index.js",

View File

@ -12,6 +12,7 @@
"lint:fix": "eslint . --fix",
"watch": "tsc -p tsconfig.build.json --watch",
"test": "jest",
"test:unit": "jest",
"test:dev": "jest --watch"
},
"main": "dist/index.js",

View File

@ -8,6 +8,7 @@
"generate:sql:grammar": "lezer-generator --typeScript --output src/grammar.sql.ts src/sql.grammar",
"generate": "pnpm generate:sql:grammar && pnpm format",
"build": "tsc -p tsconfig.build.json",
"test:unit": "jest",
"lint": "eslint . --quiet",
"lint:fix": "eslint . --fix",
"format": "biome format --write src test",

View File

@ -22,6 +22,7 @@
"generate": "pnpm generate:expressions:grammar && pnpm format",
"build": "tsc -p tsconfig.build.json",
"test": "jest",
"test:unit": "jest",
"lint": "eslint . --quiet",
"lint:fix": "eslint . --fix",
"format": "biome format --write src test",

View File

@ -12,6 +12,7 @@
"lint:fix": "eslint . --fix",
"watch": "tsc -p tsconfig.build.json --watch",
"test": "jest",
"test:unit": "jest",
"test:dev": "jest --watch"
},
"main": "dist/index.js",

View File

@ -12,6 +12,7 @@
"lint:fix": "eslint . --fix",
"watch": "tsc -p tsconfig.build.json --watch",
"test": "jest",
"test:unit": "jest",
"test:dev": "jest --watch"
},
"main": "dist/index.js",

View File

@ -0,0 +1,19 @@
import type { MigrationContext, ReversibleMigration } from '../migration-types';
const table = {
messages: 'chat_hub_messages',
} as const;
export class AddAttachmentsToChatHubMessages1761773155024 implements ReversibleMigration {
async up({ schemaBuilder: { addColumns, column } }: MigrationContext) {
await addColumns(table.messages, [
column('attachments').json.comment(
'File attachments for the message (if any), stored as JSON. Files are stored as base64-encoded data URLs.',
),
]);
}
async down({ schemaBuilder: { dropColumns } }: MigrationContext) {
await dropColumns(table.messages, ['attachments']);
}
}

View File

@ -0,0 +1,69 @@
import type { IrreversibleMigration, MigrationContext } from '../migration-types';
const TABLE_NAME = 'oauth_authorization_codes';
const TEMP_TABLE_NAME = 'temp_oauth_authorization_codes';
export class ChangeOAuthStateColumnToUnboundedVarchar1763572724000
implements IrreversibleMigration
{
async up({
isSqlite,
isMysql,
isPostgres,
escape,
copyTable,
queryRunner,
schemaBuilder: { createTable, column, dropTable },
}: MigrationContext) {
const tableName = escape.tableName(TABLE_NAME);
if (isSqlite) {
const tempTableName = escape.tableName(TEMP_TABLE_NAME);
await createTable(TEMP_TABLE_NAME)
.withColumns(
column('code').varchar(255).primary.notNull,
column('clientId').varchar().notNull,
column('userId').uuid.notNull,
column('redirectUri').varchar().notNull,
column('codeChallenge').varchar().notNull,
column('codeChallengeMethod').varchar(255).notNull,
column('expiresAt').bigint.notNull.comment('Unix timestamp in milliseconds'),
column('state').varchar(),
column('used').bool.notNull.default(false),
)
.withForeignKey('clientId', {
tableName: 'oauth_clients',
columnName: 'id',
onDelete: 'CASCADE',
})
.withForeignKey('userId', {
tableName: 'user',
columnName: 'id',
onDelete: 'CASCADE',
}).withTimestamps;
await copyTable(TABLE_NAME, TEMP_TABLE_NAME);
await dropTable(TABLE_NAME);
await queryRunner.query(`ALTER TABLE ${tempTableName} RENAME TO ${tableName};`);
} else if (isMysql) {
await queryRunner.query(
`ALTER TABLE ${tableName} MODIFY COLUMN ${escape.columnName('state')} TEXT;`,
);
await queryRunner.query(
`ALTER TABLE ${tableName} MODIFY COLUMN ${escape.columnName('codeChallenge')} TEXT NOT NULL;`,
);
await queryRunner.query(
`ALTER TABLE ${tableName} MODIFY COLUMN ${escape.columnName('redirectUri')} TEXT NOT NULL;`,
);
} else if (isPostgres) {
await queryRunner.query(
`ALTER TABLE ${tableName} ALTER COLUMN ${escape.columnName('state')} TYPE VARCHAR,` +
` ALTER COLUMN ${escape.columnName('codeChallenge')} TYPE VARCHAR,` +
` ALTER COLUMN ${escape.columnName('redirectUri')} TYPE VARCHAR;`,
);
}
}
}

View File

@ -111,8 +111,10 @@ import { CreateChatHubAgentTable1760020000000 } from '../common/1760020000000-Cr
import { UniqueRoleNames1760020838000 } from '../common/1760020838000-UniqueRoleNames';
import { CreateOAuthEntities1760116750277 } from '../common/1760116750277-CreateOAuthEntities';
import { CreateWorkflowDependencyTable1760314000000 } from '../common/1760314000000-CreateWorkflowDependencyTable';
import { AddAttachmentsToChatHubMessages1761773155024 } from '../common/1761773155024-AddAttachmentsToChatHubMessages';
import { AddWorkflowDescriptionColumn1762177736257 } from '../common/1762177736257-AddWorkflowDescriptionColumn';
import { BackfillMissingWorkflowHistoryRecords1762763704614 } from '../common/1762763704614-BackfillMissingWorkflowHistoryRecords';
import { ChangeOAuthStateColumnToUnboundedVarchar1763572724000 } from '../common/1763572724000-ChangeOAuthStateColumnToUnboundedVarchar';
import type { Migration } from '../migration-types';
export const mysqlMigrations: Migration[] = [
@ -231,4 +233,6 @@ export const mysqlMigrations: Migration[] = [
BackfillMissingWorkflowHistoryRecords1762763704614,
AddWorkflowHistoryAutoSaveFields1762847206508,
AddToolsColumnToChatHubTables1761830340990,
ChangeOAuthStateColumnToUnboundedVarchar1763572724000,
AddAttachmentsToChatHubMessages1761773155024,
];

View File

@ -109,10 +109,12 @@ import { UniqueRoleNames1760020838000 } from '../common/1760020838000-UniqueRole
import { CreateOAuthEntities1760116750277 } from '../common/1760116750277-CreateOAuthEntities';
import { CreateWorkflowDependencyTable1760314000000 } from '../common/1760314000000-CreateWorkflowDependencyTable';
import { DropUnusedChatHubColumns1760965142113 } from '../common/1760965142113-DropUnusedChatHubColumns';
import { AddAttachmentsToChatHubMessages1761773155024 } from '../common/1761773155024-AddAttachmentsToChatHubMessages';
import { AddToolsColumnToChatHubTables1761830340990 } from '../common/1761830340990-AddToolsColumnToChatHubTables';
import { AddWorkflowDescriptionColumn1762177736257 } from '../common/1762177736257-AddWorkflowDescriptionColumn';
import { BackfillMissingWorkflowHistoryRecords1762763704614 } from '../common/1762763704614-BackfillMissingWorkflowHistoryRecords';
import { AddWorkflowHistoryAutoSaveFields1762847206508 } from '../common/1762847206508-AddWorkflowHistoryAutoSaveFields';
import { ChangeOAuthStateColumnToUnboundedVarchar1763572724000 } from '../common/1763572724000-ChangeOAuthStateColumnToUnboundedVarchar';
import type { Migration } from '../migration-types';
export const postgresMigrations: Migration[] = [
@ -231,4 +233,6 @@ export const postgresMigrations: Migration[] = [
ChangeDefaultForIdInUserTable1762771264000,
AddWorkflowHistoryAutoSaveFields1762847206508,
AddToolsColumnToChatHubTables1761830340990,
ChangeOAuthStateColumnToUnboundedVarchar1763572724000,
AddAttachmentsToChatHubMessages1761773155024,
];

View File

@ -105,10 +105,12 @@ import { UniqueRoleNames1760020838000 } from '../common/1760020838000-UniqueRole
import { CreateOAuthEntities1760116750277 } from '../common/1760116750277-CreateOAuthEntities';
import { CreateWorkflowDependencyTable1760314000000 } from '../common/1760314000000-CreateWorkflowDependencyTable';
import { DropUnusedChatHubColumns1760965142113 } from '../common/1760965142113-DropUnusedChatHubColumns';
import { AddAttachmentsToChatHubMessages1761773155024 } from '../common/1761773155024-AddAttachmentsToChatHubMessages';
import { AddToolsColumnToChatHubTables1761830340990 } from '../common/1761830340990-AddToolsColumnToChatHubTables';
import { AddWorkflowDescriptionColumn1762177736257 } from '../common/1762177736257-AddWorkflowDescriptionColumn';
import { BackfillMissingWorkflowHistoryRecords1762763704614 } from '../common/1762763704614-BackfillMissingWorkflowHistoryRecords';
import { AddWorkflowHistoryAutoSaveFields1762847206508 } from '../common/1762847206508-AddWorkflowHistoryAutoSaveFields';
import { ChangeOAuthStateColumnToUnboundedVarchar1763572724000 } from '../common/1763572724000-ChangeOAuthStateColumnToUnboundedVarchar';
import type { Migration } from '../migration-types';
const sqliteMigrations: Migration[] = [
@ -223,6 +225,8 @@ const sqliteMigrations: Migration[] = [
BackfillMissingWorkflowHistoryRecords1762763704614,
AddWorkflowHistoryAutoSaveFields1762847206508,
AddToolsColumnToChatHubTables1761830340990,
ChangeOAuthStateColumnToUnboundedVarchar1763572724000,
AddAttachmentsToChatHubMessages1761773155024,
];
export { sqliteMigrations };

View File

@ -408,7 +408,7 @@ export class ExecutionRepository extends Repository<ExecutionEntity> {
async hardDelete(ids: { workflowId: string; executionId: string }) {
return await Promise.all([
this.delete(ids.executionId),
this.binaryDataService.deleteMany([ids]),
this.binaryDataService.deleteMany([{ type: 'execution', ...ids }]),
]);
}
@ -509,6 +509,7 @@ export class ExecutionRepository extends Repository<ExecutionEntity> {
}
const ids = executions.map(({ id, workflowId }) => ({
type: 'execution' as const,
executionId: id,
workflowId,
}));
@ -605,7 +606,11 @@ export class ExecutionRepository extends Repository<ExecutionEntity> {
*/
withDeleted: true,
})
).map(({ id: executionId, workflowId }) => ({ workflowId, executionId }));
).map(({ id: executionId, workflowId }) => ({
type: 'execution' as const,
workflowId,
executionId,
}));
return workflowIdsAndExecutionIds;
}

View File

@ -12,6 +12,7 @@
"lint:fix": "eslint . --fix",
"watch": "tsc -p tsconfig.build.json --watch",
"test": "jest",
"test:unit": "jest",
"test:dev": "jest --watch"
},
"main": "dist/index.js",

View File

@ -0,0 +1,148 @@
import { Container } from '@n8n/di';
import type {
ContextEstablishmentOptions,
ContextEstablishmentResult,
IContextEstablishmentHook,
} from '../context-establishment-hook';
import {
ContextEstablishmentHookMetadata,
ContextEstablishmentHook,
} from '../context-establishment-hook-metadata';
describe('@ContextEstablishmentHook decorator', () => {
let hookMetadata: ContextEstablishmentHookMetadata;
beforeEach(() => {
jest.resetAllMocks();
hookMetadata = new ContextEstablishmentHookMetadata();
Container.set(ContextEstablishmentHookMetadata, hookMetadata);
});
it('should register hook in ContextEstablishmentHookMetadata', () => {
@ContextEstablishmentHook()
class TestHook implements IContextEstablishmentHook {
hookDescription = { name: 'test.hook' };
async execute(_options: ContextEstablishmentOptions): Promise<ContextEstablishmentResult> {
return {};
}
isApplicableToTriggerNode(_nodeType: string): boolean {
return true;
}
}
const registeredHooks = hookMetadata.getClasses();
expect(registeredHooks).toContain(TestHook);
expect(registeredHooks).toHaveLength(1);
});
it('should register multiple hooks', () => {
@ContextEstablishmentHook()
class FirstHook implements IContextEstablishmentHook {
hookDescription = { name: 'first.hook' };
async execute(_options: ContextEstablishmentOptions): Promise<ContextEstablishmentResult> {
return {};
}
isApplicableToTriggerNode(_nodeType: string): boolean {
return true;
}
}
@ContextEstablishmentHook()
class SecondHook implements IContextEstablishmentHook {
hookDescription = { name: 'second.hook' };
async execute(_options: ContextEstablishmentOptions): Promise<ContextEstablishmentResult> {
return {};
}
isApplicableToTriggerNode(_nodeType: string): boolean {
return true;
}
}
@ContextEstablishmentHook()
class ThirdHook implements IContextEstablishmentHook {
hookDescription = { name: 'third.hook' };
async execute(_options: ContextEstablishmentOptions): Promise<ContextEstablishmentResult> {
return {};
}
isApplicableToTriggerNode(_nodeType: string): boolean {
return true;
}
}
const registeredHooks = hookMetadata.getClasses();
expect(registeredHooks).toContain(FirstHook);
expect(registeredHooks).toContain(SecondHook);
expect(registeredHooks).toContain(ThirdHook);
expect(registeredHooks).toHaveLength(3);
});
it('should apply Service decorator', () => {
@ContextEstablishmentHook()
class TestHook implements IContextEstablishmentHook {
hookDescription = { name: 'test.hook' };
async execute(_options: ContextEstablishmentOptions): Promise<ContextEstablishmentResult> {
return {};
}
isApplicableToTriggerNode(_nodeType: string): boolean {
return true;
}
}
expect(Container.has(TestHook)).toBe(true);
});
it('should allow instantiation of registered hooks with accessible hookDescription', () => {
@ContextEstablishmentHook()
class TestHook implements IContextEstablishmentHook {
hookDescription = { name: 'credentials.bearerToken' };
async execute(_options: ContextEstablishmentOptions): Promise<ContextEstablishmentResult> {
return {};
}
isApplicableToTriggerNode(_nodeType: string): boolean {
return true;
}
}
const hookInstance = Container.get(TestHook);
expect(hookInstance).toBeInstanceOf(TestHook);
expect(hookInstance.hookDescription).toEqual({ name: 'credentials.bearerToken' });
expect(hookInstance.hookDescription.name).toBe('credentials.bearerToken');
});
it('should register hooks with different description names', () => {
@ContextEstablishmentHook()
class BearerTokenHook implements IContextEstablishmentHook {
hookDescription = { name: 'credentials.bearerToken' };
async execute(_options: ContextEstablishmentOptions): Promise<ContextEstablishmentResult> {
return {};
}
isApplicableToTriggerNode(_nodeType: string): boolean {
return true;
}
}
@ContextEstablishmentHook()
class ApiKeyHook implements IContextEstablishmentHook {
hookDescription = { name: 'credentials.apiKey' };
async execute(_options: ContextEstablishmentOptions): Promise<ContextEstablishmentResult> {
return {};
}
isApplicableToTriggerNode(_nodeType: string): boolean {
return true;
}
}
const registeredHooks = hookMetadata.getClasses();
const bearerTokenHook = Container.get(BearerTokenHook);
const apiKeyHook = Container.get(ApiKeyHook);
expect(registeredHooks).toHaveLength(2);
expect(bearerTokenHook.hookDescription.name).toBe('credentials.bearerToken');
expect(apiKeyHook.hookDescription.name).toBe('credentials.apiKey');
});
});

View File

@ -0,0 +1,200 @@
import { Container, Service } from '@n8n/di';
import { ContextEstablishmentHookClass } from './context-establishment-hook';
/**
* Registry entry for a context establishment hook.
*
* This is a lightweight wrapper around the hook class constructor that can be
* extended in the future to include additional metadata if needed (e.g., module
* source, registration timestamp, feature flags, license flags).
*
* @internal
*/
type ContextEstablishmentHookEntry = {
/** The hook class constructor for DI container instantiation */
class: ContextEstablishmentHookClass;
};
/**
* Low-level metadata registry for context establishment hooks.
*
* This service acts as a simple collection of registered hook classes that gets
* populated automatically by the @ContextEstablishmentHook decorator at module
* load time. It serves as the foundation for the higher-level Hook Registry.
*
* **Architecture:**
* ```
* Decorator ContextEstablishmentHookMetadata Hook Registry Execution Engine
* (registration) (collection) (discovery) (execution)
* ```
*
* @see ContextEstablishmentHook decorator for automatic registration
* @see IContextEstablishmentHook for hook interface
*/
@Service()
export class ContextEstablishmentHookMetadata {
/**
* Internal collection of registered hook classes.
*
* Uses Set for efficient deduplication (though duplicate registration
* should not occur with proper decorator usage).
*/
private readonly contextEstablishmentHooks: Set<ContextEstablishmentHookEntry> = new Set();
/**
* Registers a hook class in the metadata collection.
*
* Called automatically by the @ContextEstablishmentHook decorator during
* module loading. Should not be called directly by application code.
*
* **Note:** This method does not validate uniqueness or check for naming
* conflicts. Validation happens later in the Hook Registry.
*
* @param hookEntry - The hook class entry to register
*
* @internal Called by decorator only
*/
register(hookEntry: ContextEstablishmentHookEntry) {
this.contextEstablishmentHooks.add(hookEntry);
}
/**
* Retrieves all registered hook entries.
*
* Returns an array of [index, entry] tuples compatible with Set.entries().
* Primarily used for debugging or low-level iteration.
*
* **Prefer getClasses()** for most use cases as it returns just the classes.
*
* @returns Array of [index, entry] tuples from the internal Set
*
* @example
* ```typescript
* const entries = metadata.getEntries();
* for (const [index, entry] of entries) {
* console.log(`Hook ${index}:`, entry.class.name);
* }
* ```
*/
getEntries() {
return [...this.contextEstablishmentHooks.entries()];
}
/**
* Retrieves all registered hook classes.
*
* This is the primary method used by the Hook Registry to obtain hook classes
* for instantiation and indexing. Returns just the class constructors without
* the wrapper entry objects.
*
* **Usage pattern:**
* ```typescript
* const classes = metadata.getClasses();
* const hooks = classes.map(HookClass => Container.get(HookClass));
* const hooksByName = new Map(hooks.map(h => [h.hookDescription.name, h]));
* ```
*
* @returns Array of hook class constructors ready for DI instantiation
*
* @example
* ```typescript
* @Service()
* export class HookRegistry {
* constructor(
* private metadata: ContextEstablishmentHookMetadata,
* private container: Container
* ) {
* const hookClasses = metadata.getClasses();
* this.hooks = hookClasses.map(cls => container.get(cls));
* }
* }
* ```
*/
getClasses() {
return [...this.contextEstablishmentHooks.values()].map((entry) => entry.class);
}
}
/**
* Class decorator for context establishment hooks.
*
* This decorator performs two critical functions:
* 1. **Registers** the hook class in ContextEstablishmentHookMetadata for discovery
* 2. **Enables DI** by applying @Service() to make the hook injectable
*
* The decorator executes at module load time (when the class is defined), ensuring
* all hooks are registered before the application starts. This enables automatic
* discovery without manual registration code.
*
* **Registration flow:**
* ```
* @ContextEstablishmentHook() // 1. Decorator executes
* export class BearerTokenHook // 2. Class is defined
*
* ContextEstablishmentHookMetadata // 3. Hook class registered in metadata
*
* @Service() // 4. DI container registration
*
* Hook is discoverable & injectable // 5. Ready for use
* ```
*
* **Design pattern:**
* This follows the declarative registration pattern used throughout n8n for
* extensibility (similar to node registration). Hooks self-register without
* requiring central registration files or manual imports.
*
* **Requirements:**
* - Decorated class MUST implement IContextEstablishmentHook
* - Decorated class MUST have a hookDescription property with unique name
*
* **Important notes:**
* - No decorator parameters needed (hook metadata lives on hook instance)
* - Hooks are registered as singletons via @Service()
* - Registration happens eagerly at module load, not lazily
* - Duplicate decoration of the same class is safe (Set deduplicates)
*
* @see IContextEstablishmentHook for interface requirements
* @see ContextEstablishmentHookMetadata for underlying registry
* @see HookDescription for hook metadata structure
*
* @example
* ```typescript
* // Basic hook registration:
* @ContextEstablishmentHook()
* export class BearerTokenHook implements IContextEstablishmentHook {
* hookDescription = {
* name: 'credentials.bearerToken'
* };
*
* async execute(options: ContextEstablishmentOptions) {
* // Extract bearer token from Authorization header
* const token = this.extractToken(options.triggerItem);
* return {
* triggerItem: this.removeAuthHeader(options.triggerItem),
* contextUpdate: {
* credentials: { version: 1, identity: token }
* }
* };
* }
*
* isApplicableToTriggerNode(nodeType: string) {
* return nodeType === 'n8n-nodes-base.webhook';
* }
* }
* ```
*
* @returns A class decorator function that registers and enables DI for the hook
*/
export const ContextEstablishmentHook =
<T extends ContextEstablishmentHookClass>() =>
(target: T) => {
// Register hook class in metadata for discovery by Hook Registry
Container.get(ContextEstablishmentHookMetadata).register({
class: target,
});
// Enable dependency injection for the hook class
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return Service()(target);
};

View File

@ -0,0 +1,328 @@
import type { Constructable } from '@n8n/di';
import type {
INode,
INodeExecutionData,
PlaintextExecutionContext,
IWorkflowBase,
} from 'n8n-workflow';
/**
* Input parameters passed to a context establishment hook during execution.
*
* Hooks receive the current workflow state and extract information from
* trigger items to build the execution context (e.g., credentials, environment).
* All hooks work with plaintext (decrypted) context for runtime operations.
*
* @see IContextEstablishmentHook
* @see PlaintextExecutionContext
*/
export type ContextEstablishmentOptions = {
/** The trigger node that initiated the workflow execution */
triggerNode: INode;
/** The complete workflow definition */
workflow: IWorkflowBase;
/**
* Trigger items from the workflow execution start.
* This array represents items as modified by previous hooks in the chain.
* Hooks can extract data from these items and optionally modify them
* (e.g., removing sensitive headers before storage).
*/
triggerItems: INodeExecutionData[];
/**
* The plaintext execution context built so far.
* Includes base context plus results from any previously executed hooks.
* Contains decrypted credential data for runtime operations.
*
* @see PlaintextExecutionContext for security considerations
*/
context: PlaintextExecutionContext;
/**
* Hook-specific configuration provided by the trigger node.
* Structure varies per hook type (e.g., { removeFromItem: true } for bearer token hook).
*/
options?: Record<string, unknown>;
};
/**
* Result returned by a context establishment hook after execution.
*
* Hooks can modify trigger items (e.g., remove sensitive headers) and
* contribute partial context updates that get merged into the execution context.
* All context data is in plaintext form during hook execution.
*
* @see IContextEstablishmentHook
* @see PlaintextExecutionContext
*/
export type ContextEstablishmentResult = {
/**
* The potentially modified trigger items.
* If undefined, the original trigger items are preserved unchanged.
*
* Common use case: Removing sensitive data (e.g., Authorization headers)
* before storing items in execution history.
*
* @example
* ```typescript
* // Remove Authorization header from trigger items
* const modifiedItems = options.triggerItems.map(item => ({
* ...item,
* json: {
* ...item.json,
* headers: {
* ...item.json.headers,
* authorization: undefined
* }
* }
* }));
* return { triggerItems: modifiedItems, contextUpdate: { ... } };
* ```
*/
triggerItems?: INodeExecutionData[];
/**
* Partial context update to merge into the execution context.
* If undefined, no context updates are applied.
*
* Contains only this hook's contributions (e.g., credentials data).
* Multiple hooks' updates are merged sequentially during execution.
* Context data is in plaintext form and will be encrypted before persistence.
*
* @example
* ```typescript
* // Add credential context from bearer token
* return {
* triggerItems: modifiedItems,
* contextUpdate: {
* credentials: {
* version: 1,
* identity: extractedToken,
* metadata: { source: 'bearer-token' }
* }
* }
* };
* ```
*/
contextUpdate?: Partial<PlaintextExecutionContext>;
};
/**
* Metadata describing a context establishment hook.
*
* This object carries self-describing information about the hook that enables
* runtime discovery, lookup, and instantiation. Each hook instance serves as
* the single source of truth for its own metadata.
*
* **Design rationale:**
* - Hook instances are self-describing (no external configuration files)
* - Name lookup happens at runtime via Registry, not during registration
* - Description can be extended without changing decorator or registry internals
* - Supports future features like versioning, schema validation, and categorization
*
* **Future extensions** may include:
* - `version?: string` - Semantic version of hook implementation for compatibility checks
* - `configSchema?: ZodSchema` - Validation schema for hook-specific options
* - `tags?: string[]` - Categorization tags for grouping and filtering
* - `applicableTriggers?: string[]` - Cached list of compatible trigger node types
* - `deprecated?: boolean | string` - Deprecation status and migration guidance
*
* @see IContextEstablishmentHook.hookDescription
* @see ContextEstablishmentHookMetadata for registration mechanism
*
* @example
* ```typescript
* @ContextEstablishmentHook()
* export class BearerTokenHook implements IContextEstablishmentHook {
* hookDescription = {
* name: 'credentials.bearerToken'
* };
*
* // ... hook implementation
* }
*
* ```
*/
export type HookDescription = {
/**
* Unique identifier for this hook type.
*
* Used by the Hook Registry (to be implemented) to index and retrieve
* hook instances at runtime. Must be unique across all registered hooks.
*
* **Naming convention**: Use namespaced names like 'credentials.bearerToken'
* or 'envVars.tenantConfig' to organize hooks by domain and avoid collisions.
*
* **Usage contexts:**
* - Trigger node configuration specifies hooks by name
* - Hook Registry uses name as lookup key
* - UI displays localized names via i18n (e.g., `hooks.${name}.displayName`)
* - Logging and debugging references hooks by name
* - Error messages include hook name for troubleshooting
*
* **Versioning**: Future hook versions can use naming like 'credentials.bearerToken.v2'
* if breaking changes are needed, though this is not required initially.
*
* @example 'credentials.bearerToken'
* @example 'credentials.apiKey'
* @example 'envVars.tenantConfig'
* @example 'audit.requestMetadata'
*/
name: string;
};
/**
* Interface for context establishment hooks that extract data from trigger
* items and extend the execution context during workflow initialization.
*
* @see ContextEstablishmentOptions - Input parameters
* @see ContextEstablishmentResult - Output structure
* @see PlaintextExecutionContext - Runtime context type with decrypted data
*/
export interface IContextEstablishmentHook {
/**
* Self-describing metadata for this hook instance.
*
* Provides the unique name and future metadata used by the Hook Registry
* for discovery, lookup, and validation. This property makes each hook
* instance self-contained and discoverable without external configuration.
*
* @see HookDescription for detailed metadata structure and future extensions
*
* @example
* ```typescript
* @ContextEstablishmentHook()
* export class BearerTokenHook implements IContextEstablishmentHook {
* hookDescription = {
* name: 'credentials.bearerToken'
* };
*
* async execute(options: ContextEstablishmentOptions) {
* // Hook implementation
* }
*
* isApplicableToTriggerNode(nodeType: string) {
* return nodeType === 'n8n-nodes-base.webhook';
* }
* }
* ```
*/
hookDescription: HookDescription;
/**
* Executes the hook to extract context data from trigger information.
*
* **Implementation requirements:**
* 1. Extract relevant data from trigger items (headers, body, query params, etc.)
* 2. Optionally modify trigger items to remove sensitive data
* 3. Return partial context updates to merge into execution context
* 4. Throw errors for unrecoverable failures (stops workflow execution)
*
* **Execution order:**
* Hooks execute sequentially in the order configured by the trigger node.
* Each hook receives:
* - Trigger items as modified by all previous hooks
* - Context with updates from all previous hooks
*
* **Context handling:**
* - Input context is plaintext (PlaintextExecutionContext) for runtime operations
* - Output updates are plaintext and will be encrypted before persistence
* - Never log or expose plaintext context outside hook execution
*
* **Error handling:**
* - Throw errors if required data is missing (e.g., expected header not found)
* - Use descriptive error messages for debugging
* - Errors stop workflow execution (fail-fast approach)
*
* @param options - Input parameters including trigger node, workflow, items, and current context
* @returns Promise resolving to modified trigger items and context updates
* @throws Error if hook execution fails (stops workflow execution)
*
* @example
* ```typescript
* async execute(options: ContextEstablishmentOptions): Promise<ContextEstablishmentResult> {
* const removeHeader = options.options?.removeFromItem ?? true;
*
* // Extract data
* const token = this.extractToken(options.triggerItems);
* if (!token) {
* throw new Error('Bearer token not found in Authorization header');
* }
*
* // Optionally modify items
* const modifiedItems = removeHeader
* ? this.removeAuthHeader(options.triggerItems)
* : undefined;
*
* // Return context update
* return {
* triggerItems: modifiedItems,
* contextUpdate: {
* credentials: { version: 1, identity: token }
* }
* };
* }
* ```
*/
execute(options: ContextEstablishmentOptions): Promise<ContextEstablishmentResult>;
/**
* Method to determine if this hook is applicable to a specific trigger node type.
*
* **Use cases:**
* - **UI filtering**: Show only relevant hooks for a trigger type in node configuration
* - **Validation**: Prevent incompatible hook configurations at save time
* - **Auto-suggestion**: Suggest applicable hooks based on trigger node selection
* - **Documentation**: Generate trigger-specific hook documentation
*
* **Implementation notes:**
* - Return true if the hook can extract meaningful data from this trigger type
* - Consider transport layer (HTTP, AMQP, manual, etc.)
* - Multiple triggers can share the same hook (e.g., webhook and form trigger both support bearer tokens)
*
* @param nodeType - The node type identifier (e.g., 'n8n-nodes-base.webhook')
* @returns true if this hook can be used with the given trigger node type
*
* @example
* ```typescript
* // Hook only works with HTTP-based triggers
* isApplicableToTriggerNode(nodeType: string): boolean {
* return [
* 'n8n-nodes-base.webhook',
* 'n8n-nodes-base.formTrigger',
* 'n8n-nodes-base.httpRequest'
* ].includes(nodeType);
* }
* ```
*
* @example
* ```typescript
* // Hook works with any trigger that has HTTP headers
* isApplicableToTriggerNode(nodeType: string): boolean {
* return nodeType.includes('webhook') || nodeType.includes('http');
* }
* ```
*/
isApplicableToTriggerNode(nodeType: string): boolean;
}
/**
* Type representing the constructor/class of a context establishment hook.
*
* Used by the dependency injection container to register and instantiate
* hook classes at runtime. Works with the @ContextEstablishmentHook decorator.
*
* @see IContextEstablishmentHook
* @see ContextEstablishmentHook decorator in './index.ts'
*
* @example
* ```typescript
* import { Container } from '@n8n/di';
* import type { ContextEstablishmentHookClass } from './context-establishment-hook';
*
* const HookClass: ContextEstablishmentHookClass = BearerTokenHook;
* const hookInstance = Container.get(HookClass);
* ```
*/
export type ContextEstablishmentHookClass = Constructable<IContextEstablishmentHook>;

View File

@ -0,0 +1,5 @@
export {
ContextEstablishmentHookMetadata,
ContextEstablishmentHook,
} from './context-establishment-hook-metadata';
export type * from './context-establishment-hook';

View File

@ -12,6 +12,7 @@
"lint:fix": "eslint . --fix",
"watch": "tsc -p tsconfig.build.json --watch",
"test": "jest",
"test:unit": "jest",
"test:dev": "jest --watch"
},
"main": "dist/di.js",

View File

@ -24,6 +24,7 @@
"format": "biome format --write .",
"format:check": "biome ci .",
"test": "vitest run",
"test:unit": "vitest run",
"test:dev": "vitest",
"typecheck": "tsc --noEmit",
"watch": "tsc --watch"

View File

@ -21,6 +21,7 @@
"lint:fix": "eslint src --fix",
"lint:docs": "eslint-doc-generator --check",
"test": "vitest run",
"test:unit": "vitest run",
"test:dev": "vitest",
"typecheck": "tsc --noEmit",
"watch": "tsc --watch --project tsconfig.build.json"

View File

@ -12,6 +12,7 @@
"lint:fix": "eslint . --fix",
"watch": "tsc -p tsconfig.build.json --watch",
"test": "vitest run",
"test:unit": "vitest run",
"test:dev": "vitest --silent=false"
},
"main": "dist/index.js",

View File

@ -29,6 +29,7 @@
"build": "rimraf ./dist && pnpm run build:types && pnpm run build:cjs && pnpm run build:esm",
"dry": "pnpm run build && pnpm pub --dry-run",
"test": "jest",
"test:unit": "jest",
"test:watch": "jest --watch"
},
"keywords": [

View File

@ -27,6 +27,7 @@
"build": "tsc -p tsconfig.build.json && pnpm copy-templates",
"publish:dry": "pnpm run build && pnpm pub --dry-run",
"test": "vitest run",
"test:unit": "vitest run",
"test:dev": "vitest --silent=false",
"start": "./bin/n8n-node.mjs"
},

View File

@ -12,6 +12,7 @@
"lint:fix": "eslint . --fix",
"watch": "tsc -p tsconfig.build.json --watch",
"test": "jest",
"test:unit": "jest",
"test:dev": "jest --watch"
},
"main": "dist/index.js",

View File

@ -143,6 +143,7 @@ exports[`Scope Information ensure scopes are defined correctly 1`] = `
"role:manage",
"role:*",
"mcp:manage",
"mcp:oauth",
"mcp:*",
"mcpApiKey:create",
"mcpApiKey:rotate",

View File

@ -20,6 +20,7 @@
"format": "biome format --write .",
"format:check": "biome ci .",
"test": "jest",
"test:unit": "jest",
"test:dev": "jest --watch",
"typecheck": "tsc --noEmit",
"watch": "tsc --watch"

View File

@ -10,6 +10,7 @@
"format": "biome format --write src",
"format:check": "biome ci src",
"test": "jest",
"test:unit": "jest",
"test:watch": "jest --watch",
"lint": "eslint . --quiet",
"lint:fix": "eslint . --fix",

View File

@ -31,6 +31,7 @@
"preview": "vite preview",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:unit": "vitest run",
"test:dev": "vitest --silent=false",
"lint": "eslint src --quiet",
"lint:fix": "eslint src --fix",

View File

@ -1,5 +1,6 @@
import { describe, it, expect } from 'vitest';
import { sanitizeFilename } from './fileUtils';
import { sanitizeFilename } from './sanitize';
describe('sanitizeFilename', () => {
it('should return normal filenames unchanged', () => {

View File

@ -0,0 +1,87 @@
// Constants definition
/* eslint-disable no-control-regex */
const INVALID_CHARS_REGEX = /[<>:"/\\|?*\u0000-\u001F\u007F-\u009F]/g;
const ZERO_WIDTH_CHARS_REGEX = /[\u200B-\u200D\u2060\uFEFF]/g;
const UNICODE_SPACES_REGEX = /[\u00A0\u2000-\u200A]/g;
const LEADING_TRAILING_DOTS_SPACES_REGEX = /^[\s.]+|[\s.]+$/g;
/* eslint-enable no-control-regex */
const WINDOWS_RESERVED_NAMES = new Set([
'CON',
'PRN',
'AUX',
'NUL',
'COM1',
'COM2',
'COM3',
'COM4',
'COM5',
'COM6',
'COM7',
'COM8',
'COM9',
'LPT1',
'LPT2',
'LPT3',
'LPT4',
'LPT5',
'LPT6',
'LPT7',
'LPT8',
'LPT9',
]);
const DEFAULT_FALLBACK_NAME = 'untitled';
const MAX_FILENAME_LENGTH = 200;
/**
* Sanitizes a filename to be compatible with Mac, Linux, and Windows file systems
*
* Main features:
* - Replace invalid characters (e.g. ":" in hello:world)
* - Handle Windows reserved names
* - Limit filename length
* - Normalize Unicode characters
*
* @param filename - The filename to sanitize (without extension)
* @param maxLength - Maximum filename length (default: 200)
* @returns A sanitized filename (without extension)
*
* @example
* sanitizeFilename('hello:world') // returns 'hello_world'
* sanitizeFilename('CON') // returns '_CON'
* sanitizeFilename('') // returns 'untitled'
*/
export const sanitizeFilename = (
filename: string,
maxLength: number = MAX_FILENAME_LENGTH,
): string => {
// Input validation
if (!filename) {
return DEFAULT_FALLBACK_NAME;
}
let baseName = filename
.trim()
.replace(INVALID_CHARS_REGEX, '_')
.replace(ZERO_WIDTH_CHARS_REGEX, '')
.replace(UNICODE_SPACES_REGEX, ' ')
.replace(LEADING_TRAILING_DOTS_SPACES_REGEX, '');
// Handle empty or invalid filenames after cleaning
if (!baseName) {
baseName = DEFAULT_FALLBACK_NAME;
}
// Handle Windows reserved names
if (WINDOWS_RESERVED_NAMES.has(baseName.toUpperCase())) {
baseName = `_${baseName}`;
}
// Truncate if too long
if (baseName.length > maxLength) {
baseName = baseName.slice(0, maxLength);
}
return baseName;
};

View File

@ -7,3 +7,4 @@ export * from './search/reRankSearchResults';
export * from './search/sublimeSearch';
export * from './sort/sortByProperty';
export * from './string/truncate';
export * from './files/sanitize';

View File

@ -2,6 +2,16 @@
This list shows all the versions which include breaking changes and how to upgrade.
# 1.122.0
### What changed?
The way to add third-party dependencies to the `n8nio/runners` image has changed. More details [here](https://docs.n8n.io/hosting/configuration/task-runners/#adding-extra-dependencies).
### When is action necessary?
If you adding third-party dependencies to the `n8nio/runners` image using `package.json` and `extras.txt` and building the image yourself, please extend the image as instructed in the link above.
# 1.113.0
### What changed?

View File

@ -110,6 +110,7 @@
"@n8n/permissions": "workspace:*",
"@n8n/task-runner": "workspace:*",
"@n8n/typeorm": "catalog:",
"@n8n/utils": "workspace:*",
"@n8n_io/ai-assistant-sdk": "catalog:",
"@n8n_io/license-sdk": "2.24.1",
"@rudderstack/rudder-sdk-node": "2.1.4",

View File

@ -87,6 +87,9 @@ export class AuthService {
'/types/nodes.json',
'/types/credentials.json',
'/mcp-oauth/authorize/',
// Skip browser ID check for chat hub attachments
`/${restEndpoint}/chat/conversations/:sessionId/messages/:messageId/attachments/:index`,
];
}

View File

@ -68,7 +68,9 @@ describe('ExecutionRepository', () => {
await executionRepository.deleteExecutionsByFilter({ id: '1' }, ['1'], { ids: ['1'] });
expect(binaryDataService.deleteMany).toHaveBeenCalledWith([{ executionId: '1', workflowId }]);
expect(binaryDataService.deleteMany).toHaveBeenCalledWith([
{ type: 'execution', executionId: '1', workflowId },
]);
});
});

View File

@ -1,12 +1,15 @@
import { testDb, testModules } from '@n8n/backend-test-utils';
import { mockInstance, testDb, testModules } from '@n8n/backend-test-utils';
import type { User } from '@n8n/db';
import { Container } from '@n8n/di';
import { BinaryDataService } from 'n8n-core';
import { createAdmin, createMember } from '@test-integration/db/users';
import { ChatHubService } from '../chat-hub.service';
import { ChatHubMessageRepository } from '../chat-message.repository';
import { ChatHubSessionRepository } from '../chat-session.repository';
mockInstance(BinaryDataService);
beforeAll(async () => {
await testModules.loadModules(['chat-hub']);
await testDb.init();
@ -45,9 +48,9 @@ describe('chatHub', () => {
describe('getConversations', () => {
it('should list empty conversations', async () => {
const conversations = await chatHubService.getConversations(member.id);
const conversations = await chatHubService.getConversations(member.id, 20);
expect(conversations).toBeDefined();
expect(conversations).toHaveLength(0);
expect(conversations.data).toHaveLength(0);
});
it("should list user's own conversations in expected order", async () => {
@ -80,11 +83,182 @@ describe('chatHub', () => {
tools: [],
});
const conversations = await chatHubService.getConversations(member.id);
expect(conversations).toHaveLength(3);
expect(conversations[0].id).toBe(session1.id);
expect(conversations[1].id).toBe(session2.id);
expect(conversations[2].id).toBe(session3.id);
const conversations = await chatHubService.getConversations(member.id, 20);
expect(conversations.data).toHaveLength(3);
expect(conversations.data[0].id).toBe(session1.id);
expect(conversations.data[1].id).toBe(session2.id);
expect(conversations.data[2].id).toBe(session3.id);
});
describe('pagination', () => {
it('should return hasMore=false and nextCursor=null when all sessions fit in one page', async () => {
await sessionsRepository.createChatSession({
id: crypto.randomUUID(),
ownerId: member.id,
title: 'session 1',
lastMessageAt: new Date('2025-01-01T00:00:00Z'),
tools: [],
});
const conversations = await chatHubService.getConversations(member.id, 10);
expect(conversations.data).toHaveLength(1);
expect(conversations.hasMore).toBe(false);
expect(conversations.nextCursor).toBeNull();
});
it('should fetch next page using cursor', async () => {
const session1 = await sessionsRepository.createChatSession({
id: crypto.randomUUID(),
ownerId: member.id,
title: 'session 1',
lastMessageAt: new Date('2025-01-05T00:00:00Z'),
tools: [],
});
const session2 = await sessionsRepository.createChatSession({
id: crypto.randomUUID(),
ownerId: member.id,
title: 'session 2',
lastMessageAt: new Date('2025-01-04T00:00:00Z'),
tools: [],
});
const session3 = await sessionsRepository.createChatSession({
id: crypto.randomUUID(),
ownerId: member.id,
title: 'session 3',
lastMessageAt: new Date('2025-01-03T00:00:00Z'),
tools: [],
});
const session4 = await sessionsRepository.createChatSession({
id: crypto.randomUUID(),
ownerId: member.id,
title: 'session 4',
lastMessageAt: new Date('2025-01-02T00:00:00Z'),
tools: [],
});
// First page
const page1 = await chatHubService.getConversations(member.id, 2);
expect(page1.data).toHaveLength(2);
expect(page1.data[0].id).toBe(session1.id);
expect(page1.data[1].id).toBe(session2.id);
expect(page1.hasMore).toBe(true);
expect(page1.nextCursor).toBe(session2.id);
// Second page using cursor
const page2 = await chatHubService.getConversations(member.id, 2, page1.nextCursor!);
expect(page2.data).toHaveLength(2);
expect(page2.data[0].id).toBe(session3.id);
expect(page2.data[1].id).toBe(session4.id);
expect(page2.hasMore).toBe(false);
expect(page2.nextCursor).toBeNull();
});
it('should handle sessions with same lastMessageAt using id for ordering', async () => {
const sameDate = new Date('2025-01-01T00:00:00Z');
const session1 = await sessionsRepository.createChatSession({
id: '00000000-0000-0000-0000-000000000001',
ownerId: member.id,
title: 'Session 1',
lastMessageAt: sameDate,
tools: [],
});
const session2 = await sessionsRepository.createChatSession({
id: '00000000-0000-0000-0000-000000000002',
ownerId: member.id,
title: 'Session 2',
lastMessageAt: sameDate,
tools: [],
});
const session3 = await sessionsRepository.createChatSession({
id: '00000000-0000-0000-0000-000000000003',
ownerId: member.id,
title: 'Session 3',
lastMessageAt: sameDate,
tools: [],
});
// Fetch first page
const page1 = await chatHubService.getConversations(member.id, 2);
expect(page1.data).toHaveLength(2);
expect(page1.data[0].id).toBe(session1.id);
expect(page1.data[1].id).toBe(session2.id);
expect(page1.hasMore).toBe(true);
// Fetch second page
const page2 = await chatHubService.getConversations(member.id, 2, page1.nextCursor!);
expect(page2.data).toHaveLength(1);
expect(page2.data[0].id).toBe(session3.id);
expect(page2.hasMore).toBe(false);
});
it('should throw error when cursor session does not exist', async () => {
await sessionsRepository.createChatSession({
id: crypto.randomUUID(),
ownerId: member.id,
title: 'session 1',
lastMessageAt: new Date('2025-01-01T00:00:00Z'),
tools: [],
});
const nonExistentCursor = '00000000-0000-0000-0000-000000000000';
await expect(
chatHubService.getConversations(member.id, 10, nonExistentCursor),
).rejects.toThrow('Cursor session not found');
});
it('should throw error when cursor session belongs to different user', async () => {
await sessionsRepository.createChatSession({
id: crypto.randomUUID(),
ownerId: member.id,
title: 'Member Session',
lastMessageAt: new Date('2025-01-02T00:00:00Z'),
tools: [],
});
const adminSession = await sessionsRepository.createChatSession({
id: crypto.randomUUID(),
ownerId: admin.id,
title: 'Admin Session',
lastMessageAt: new Date('2025-01-01T00:00:00Z'),
tools: [],
});
await expect(
chatHubService.getConversations(member.id, 10, adminSession.id),
).rejects.toThrow('Cursor session not found');
});
it('should handle sessions with null lastMessageAt', async () => {
const session1 = await sessionsRepository.createChatSession({
id: crypto.randomUUID(),
ownerId: member.id,
title: 'Session with date',
lastMessageAt: new Date('2025-01-01T00:00:00Z'),
tools: [],
});
const session2 = await sessionsRepository.createChatSession({
id: crypto.randomUUID(),
ownerId: member.id,
title: 'Session without date',
lastMessageAt: null,
tools: [],
});
const conversations = await chatHubService.getConversations(member.id, 10);
expect(conversations.data).toHaveLength(2);
expect(conversations.data[0].id).toBe(session1.id);
expect(conversations.data[1].id).toBe(session2.id);
});
});
});

View File

@ -32,6 +32,7 @@ export class ChatHubAgentService {
},
createdAt: agent.createdAt.toISOString(),
updatedAt: agent.updatedAt.toISOString(),
allowFileUploads: true,
})),
};
}

View File

@ -11,6 +11,7 @@ import {
} from '@n8n/typeorm';
import type { ChatHubSession } from './chat-hub-session.entity';
import type { IBinaryData } from 'n8n-workflow';
@Entity({ name: 'chat_hub_messages' })
export class ChatHubMessage extends WithTimestamps {
@ -167,4 +168,13 @@ export class ChatHubMessage extends WithTimestamps {
*/
@Column({ type: 'varchar', length: 16, default: 'success' })
status: ChatHubMessageStatus;
/**
* File attachments for the message (if any), stored as JSON.
* Storage strategy depends on the binary data mode configuration:
* - When using external storage (e.g., filesystem-v2): Only metadata is stored, with 'id' referencing the external location
* - When using default mode: Base64-encoded data is stored directly in the 'data' field
*/
@Column({ type: 'json', nullable: true })
attachments: Array<IBinaryData> | null;
}

View File

@ -21,8 +21,10 @@ import {
IWorkflowBase,
MEMORY_BUFFER_WINDOW_NODE_TYPE,
MEMORY_MANAGER_NODE_TYPE,
MERGE_NODE_TYPE,
NodeConnectionTypes,
OperationalError,
type IBinaryData,
} from 'n8n-workflow';
import { v4 as uuidv4 } from 'uuid';
@ -49,6 +51,7 @@ export class ChatHubWorkflowService {
projectId: string,
history: ChatHubMessage[],
humanMessage: string,
attachments: IBinaryData[],
credentials: INodeCredentials,
model: ChatHubConversationModel,
systemMessage: string | undefined,
@ -65,6 +68,7 @@ export class ChatHubWorkflowService {
sessionId,
history,
humanMessage,
attachments,
credentials,
model,
systemMessage,
@ -72,6 +76,7 @@ export class ChatHubWorkflowService {
});
const newWorkflow = new WorkflowEntity();
newWorkflow.versionId = uuidv4();
newWorkflow.name = `Chat ${sessionId}`;
newWorkflow.active = false;
@ -147,6 +152,38 @@ export class ChatHubWorkflowService {
});
}
prepareExecutionData(
triggerNode: INode,
sessionId: string,
message: string,
attachments: IBinaryData[],
): IExecuteData[] {
// Attachments are already processed (id field populated) by the caller
return [
{
node: triggerNode,
data: {
main: [
[
{
json: {
sessionId,
action: 'sendMessage',
chatInput: message,
files: attachments.map(({ data, ...metadata }) => metadata),
},
binary: Object.fromEntries(
attachments.map((attachment, index) => [`data${index}`, attachment]),
),
},
],
],
},
source: null,
},
];
}
private getUniqueNodeName(originalName: string, existingNames: Set<string>): string {
if (!existingNames.has(originalName)) {
return originalName;
@ -168,6 +205,7 @@ export class ChatHubWorkflowService {
sessionId,
history,
humanMessage,
attachments,
credentials,
model,
systemMessage,
@ -177,6 +215,7 @@ export class ChatHubWorkflowService {
sessionId: ChatSessionId;
history: ChatHubMessage[];
humanMessage: string;
attachments: IBinaryData[];
credentials: INodeCredentials;
model: ChatHubConversationModel;
systemMessage?: string;
@ -188,6 +227,7 @@ export class ChatHubWorkflowService {
const memoryNode = this.buildMemoryNode(20);
const restoreMemoryNode = this.buildRestoreMemoryNode(history);
const clearMemoryNode = this.buildClearMemoryNode();
const mergeNode = this.buildMergeNode();
const nodes: INode[] = [
chatTriggerNode,
@ -196,6 +236,7 @@ export class ChatHubWorkflowService {
memoryNode,
restoreMemoryNode,
clearMemoryNode,
mergeNode,
];
const nodeNames = new Set(nodes.map((node) => node.name));
@ -221,10 +262,18 @@ export class ChatHubWorkflowService {
const connections: IConnections = {
[NODE_NAMES.CHAT_TRIGGER]: {
[NodeConnectionTypes.Main]: [
[{ node: NODE_NAMES.RESTORE_CHAT_MEMORY, type: NodeConnectionTypes.Main, index: 0 }],
[
{ node: NODE_NAMES.RESTORE_CHAT_MEMORY, type: NodeConnectionTypes.Main, index: 0 },
{ node: NODE_NAMES.MERGE, type: NodeConnectionTypes.Main, index: 0 },
],
],
},
[NODE_NAMES.RESTORE_CHAT_MEMORY]: {
[NodeConnectionTypes.Main]: [
[{ node: NODE_NAMES.MERGE, type: NodeConnectionTypes.Main, index: 1 }],
],
},
[NODE_NAMES.MERGE]: {
[NodeConnectionTypes.Main]: [
[{ node: NODE_NAMES.REPLY_AGENT, type: NodeConnectionTypes.Main, index: 0 }],
],
@ -271,25 +320,12 @@ export class ChatHubWorkflowService {
}, {}),
};
const nodeExecutionStack: IExecuteData[] = [
{
node: chatTriggerNode,
data: {
main: [
[
{
json: {
sessionId,
action: 'sendMessage',
chatInput: humanMessage,
},
},
],
],
},
source: null,
},
];
const nodeExecutionStack = this.prepareExecutionData(
chatTriggerNode,
sessionId,
humanMessage,
attachments,
);
const executionData = createRunExecutionData({
executionData: {
@ -483,6 +519,24 @@ export class ChatHubWorkflowService {
},
};
}
case 'cohere': {
return {
...common,
parameters: {
model,
options: {},
},
};
}
case 'mistralCloud': {
return {
...common,
parameters: {
model,
options: {},
},
};
}
default:
throw new OperationalError('Unsupported model provider');
}
@ -550,6 +604,22 @@ export class ChatHubWorkflowService {
};
}
private buildMergeNode(): INode {
return {
parameters: {
mode: 'combine',
fieldsToMatchString: 'chatInput',
joinMode: 'enrichInput1',
options: {},
},
type: MERGE_NODE_TYPE,
typeVersion: 3.2,
position: [224, -100],
id: uuidv4(),
name: NODE_NAMES.MERGE,
};
}
private buildTitleGeneratorAgentNode(): INode {
return {
parameters: {

View File

@ -0,0 +1,160 @@
import { Service } from '@n8n/di';
import { BINARY_ENCODING, type IBinaryData } from 'n8n-workflow';
import { sanitizeFilename } from '@n8n/utils';
import { BinaryDataService, FileLocation } from 'n8n-core';
import { Not, IsNull } from '@n8n/typeorm';
import { ChatHubMessageRepository } from './chat-message.repository';
import type { ChatMessageId, ChatSessionId, ChatAttachment } from '@n8n/api-types';
import { NotFoundError } from '@/errors/response-errors/not-found.error';
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import type Stream from 'node:stream';
import FileType from 'file-type';
@Service()
export class ChatHubAttachmentService {
private readonly maxTotalSizeBytes = 200 * 1024 * 1024; // 200 MB
constructor(
private readonly binaryDataService: BinaryDataService,
private readonly messageRepository: ChatHubMessageRepository,
) {}
/**
* Stores attachments through BinaryDataService.
* This populates the 'id' and other metadata for attachments. When external storage is used,
* BinaryDataService replaces base64 data with the storage mode string (e.g., "filesystem-v2").
*/
async store(
sessionId: ChatSessionId,
messageId: ChatMessageId,
attachments: ChatAttachment[],
): Promise<IBinaryData[]> {
let totalSize = 0;
const storedAttachments: IBinaryData[] = [];
for (const attachment of attachments) {
const buffer = Buffer.from(attachment.data, BINARY_ENCODING);
totalSize += buffer.length;
if (totalSize > this.maxTotalSizeBytes) {
const maxSizeMB = Math.floor(this.maxTotalSizeBytes / (1024 * 1024));
throw new BadRequestError(
`Total size of attachments exceeds maximum size of ${maxSizeMB} MB`,
);
}
const stored = await this.processAttachment(sessionId, messageId, attachment, buffer);
storedAttachments.push(stored);
}
return storedAttachments;
}
/*
* Gets a specific attachment from a message by index and returns it as either buffer or stream
*/
async getAttachment(
sessionId: ChatSessionId,
messageId: ChatMessageId,
attachmentIndex: number,
): Promise<
[
IBinaryData,
(
| { type: 'buffer'; buffer: Buffer<ArrayBufferLike>; fileSize: number }
| { type: 'stream'; stream: Stream.Readable; fileSize: number }
),
]
> {
const message = await this.messageRepository.getOneById(messageId, sessionId, []);
if (!message) {
throw new NotFoundError('Message not found');
}
const attachment = message.attachments?.[attachmentIndex];
if (!attachment) {
throw new NotFoundError('Attachment not found');
}
if (attachment.id) {
const metadata = await this.binaryDataService.getMetadata(attachment.id);
const stream = await this.binaryDataService.getAsStream(attachment.id);
return [attachment, { type: 'stream', stream, fileSize: metadata.fileSize }];
}
if (attachment.data) {
const buffer = await this.binaryDataService.getAsBuffer(attachment);
return [attachment, { type: 'buffer', buffer, fileSize: buffer.length }];
}
throw new NotFoundError('Attachment has no stored file');
}
/**
* Deletes all files attached to messages in the session
*/
async deleteAllBySessionId(sessionId: string): Promise<void> {
const messages = await this.messageRepository.getManyBySessionId(sessionId);
await this.deleteAttachments(messages.flatMap((message) => message.attachments ?? []));
}
/**
* Deletes all chat attachment files.
*/
async deleteAll(): Promise<void> {
const messages = await this.messageRepository.find({
where: {
attachments: Not(IsNull()),
},
select: ['attachments'],
});
await this.deleteAttachments(messages.flatMap((message) => message.attachments ?? []));
}
/**
* Deletes attachments by their binary data directly (used for rollback when message wasn't saved)
*/
async deleteAttachments(attachments: IBinaryData[]): Promise<void> {
await this.binaryDataService.deleteManyByBinaryDataId(
attachments.flatMap((attachment) => (attachment.id ? [attachment.id] : [])),
);
}
/**
* Processes a single attachment by populating metadata and storing it.
*/
private async processAttachment(
sessionId: ChatSessionId,
messageId: ChatMessageId,
attachment: ChatAttachment,
buffer: Buffer,
): Promise<IBinaryData> {
const sanitizedFileName = sanitizeFilename(attachment.fileName);
const fileTypeData = await FileType.fromBuffer(buffer);
// Only trust content-based detection for security
const mimeType = fileTypeData?.mime ?? 'application/octet-stream';
// Construct IBinaryData with all required fields
const binaryData: IBinaryData = {
data: attachment.data,
mimeType,
fileName: sanitizedFileName,
fileSize: `${buffer.length}`,
fileExtension: fileTypeData?.ext,
};
return await this.binaryDataService.store(
FileLocation.ofChatHubMessageAttachment(sessionId, messageId),
buffer,
binaryData,
);
}
}

View File

@ -40,6 +40,14 @@ export const PROVIDER_NODE_TYPE_MAP: Record<ChatHubLLMProvider, INodeTypeNameVer
name: '@n8n/n8n-nodes-langchain.lmChatDeepSeek',
version: 1,
},
cohere: {
name: '@n8n/n8n-nodes-langchain.lmChatCohere',
version: 1,
},
mistralCloud: {
name: '@n8n/n8n-nodes-langchain.lmChatMistralCloud',
version: 1,
},
};
export const NODE_NAMES = {
@ -50,6 +58,7 @@ export const NODE_NAMES = {
MEMORY: 'Memory',
RESTORE_CHAT_MEMORY: 'Restore Chat Memory',
CLEAR_CHAT_MEMORY: 'Clear Chat Memory',
MERGE: 'Merge',
} as const;
/* eslint-disable @typescript-eslint/naming-convention */

View File

@ -10,6 +10,7 @@ import {
ChatMessageId,
ChatHubCreateAgentRequest,
ChatHubUpdateAgentRequest,
ChatHubConversationsRequest,
} from '@n8n/api-types';
import { Logger } from '@n8n/backend-common';
import { AuthenticatedRequest } from '@n8n/db';
@ -22,13 +23,17 @@ import {
Delete,
Param,
Patch,
Query,
} from '@n8n/decorators';
import type { Response } from 'express';
import { strict as assert } from 'node:assert';
import { ChatHubAgentService } from './chat-hub-agent.service';
import { ChatHubAttachmentService } from './chat-hub.attachment.service';
import { ChatHubService } from './chat-hub.service';
import { ChatModelsRequestDto } from './dto/chat-models-request.dto';
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import { sanitizeFilename } from '@n8n/utils';
import { ResponseError } from '@/errors/response-errors/abstract/response.error';
@ -37,6 +42,7 @@ export class ChatHubController {
constructor(
private readonly chatService: ChatHubService,
private readonly chatAgentService: ChatHubAgentService,
private readonly chatAttachmentService: ChatHubAttachmentService,
private readonly logger: Logger,
) {}
@ -55,8 +61,9 @@ export class ChatHubController {
async getConversations(
req: AuthenticatedRequest,
_res: Response,
@Query query: ChatHubConversationsRequest,
): Promise<ChatHubConversationsResponse> {
return await this.chatService.getConversations(req.user.id);
return await this.chatService.getConversations(req.user.id, query.limit, query.cursor);
}
@Get('/conversations/:sessionId')
@ -69,6 +76,49 @@ export class ChatHubController {
return await this.chatService.getConversation(req.user.id, sessionId);
}
@Get('/conversations/:sessionId/messages/:messageId/attachments/:index')
@GlobalScope('chatHub:message')
async getMessageAttachment(
req: AuthenticatedRequest,
res: Response,
@Param('sessionId') sessionId: ChatSessionId,
@Param('messageId') messageId: ChatMessageId,
@Param('index') index: string,
) {
const attachmentIndex = Number.parseInt(index, 10);
if (isNaN(attachmentIndex)) {
throw new BadRequestError('Invalid attachment index');
}
// Verify user has access to this session
await this.chatService.getConversation(req.user.id, sessionId);
const [{ mimeType, fileName }, attachmentAsStreamOrBuffer] =
await this.chatAttachmentService.getAttachment(sessionId, messageId, attachmentIndex);
res.setHeader('Content-Type', mimeType ?? 'application/octet-stream');
if (attachmentAsStreamOrBuffer.fileSize) {
res.setHeader('Content-Length', attachmentAsStreamOrBuffer.fileSize);
}
if (fileName) {
res.setHeader('Content-Disposition', `attachment; filename="${sanitizeFilename(fileName)}"`);
}
if (attachmentAsStreamOrBuffer.type === 'buffer') {
res.send(attachmentAsStreamOrBuffer.buffer);
return;
}
return await new Promise<void>((resolve, reject) => {
attachmentAsStreamOrBuffer.stream.on('end', resolve);
attachmentAsStreamOrBuffer.stream.on('error', reject);
attachmentAsStreamOrBuffer.stream.pipe(res);
});
}
@GlobalScope('chatHub:message')
@Post('/conversations/send')
async sendMessage(

View File

@ -33,10 +33,10 @@ import {
jsonParse,
StructuredChunk,
RESPOND_TO_CHAT_NODE_TYPE,
IExecuteData,
IRunExecutionData,
INodeParameters,
INode,
type IBinaryData,
createRunExecutionData,
} from 'n8n-workflow';
@ -45,10 +45,11 @@ import { ChatHubCredentialsService, CredentialWithProjectId } from './chat-hub-c
import type { ChatHubMessage } from './chat-hub-message.entity';
import { ChatHubWorkflowService } from './chat-hub-workflow.service';
import { JSONL_STREAM_HEADERS, NODE_NAMES, PROVIDER_NODE_TYPE_MAP } from './chat-hub.constants';
import type {
import {
HumanMessagePayload,
RegenerateMessagePayload,
EditMessagePayload,
validChatTriggerParamsShape,
} from './chat-hub.types';
import { ChatHubMessageRepository } from './chat-message.repository';
import { ChatHubSessionRepository } from './chat-session.repository';
@ -64,6 +65,7 @@ import { getBase } from '@/workflow-execute-additional-data';
import { WorkflowExecutionService } from '@/workflows/workflow-execution.service';
import { WorkflowFinderService } from '@/workflows/workflow-finder.service';
import { WorkflowService } from '@/workflows/workflow.service';
import { ChatHubAttachmentService } from './chat-hub.attachment.service';
@Service()
export class ChatHubService {
@ -83,6 +85,7 @@ export class ChatHubService {
private readonly chatHubAgentService: ChatHubAgentService,
private readonly chatHubCredentialsService: ChatHubCredentialsService,
private readonly chatHubWorkflowService: ChatHubWorkflowService,
private readonly chatHubAttachmentService: ChatHubAttachmentService,
) {}
async getModels(
@ -163,6 +166,10 @@ export class ChatHubService {
return await this.fetchAwsBedrockModels(credentials, additionalData);
case 'deepSeek':
return await this.fetchDeepSeekModels(credentials, additionalData);
case 'cohere':
return await this.fetchCohereModels(credentials, additionalData);
case 'mistralCloud':
return await this.fetchMistralCloudModels(credentials, additionalData);
case 'n8n':
return await this.fetchAgentWorkflowsAsModels(user);
case 'custom-agent':
@ -193,6 +200,7 @@ export class ChatHubService {
},
createdAt: null,
updatedAt: null,
allowFileUploads: true,
})),
};
}
@ -220,6 +228,7 @@ export class ChatHubService {
},
createdAt: null,
updatedAt: null,
allowFileUploads: true,
})),
};
}
@ -285,6 +294,7 @@ export class ChatHubService {
},
createdAt: null,
updatedAt: null,
allowFileUploads: true,
})),
};
}
@ -343,6 +353,7 @@ export class ChatHubService {
},
createdAt: null,
updatedAt: null,
allowFileUploads: true,
})),
};
}
@ -460,6 +471,126 @@ export class ChatHubService {
},
createdAt: null,
updatedAt: null,
allowFileUploads: true,
})),
};
}
private async fetchMistralCloudModels(
credentials: INodeCredentials,
additionalData: IWorkflowExecuteAdditionalData,
): Promise<ChatModelsResponse['mistralCloud']> {
const results = await this.nodeParametersService.getOptionsViaLoadOptions(
{
routing: {
request: {
method: 'GET',
url: '/models',
},
output: {
postReceive: [
{
type: 'rootProperty',
properties: {
property: 'data',
},
},
{
type: 'filter',
properties: {
pass: "={{ !$responseItem.id.includes('embed') }}",
},
},
{
type: 'setKeyValue',
properties: {
name: '={{ $responseItem.id }}',
value: '={{ $responseItem.id }}',
},
},
{
type: 'sort',
properties: {
key: 'name',
},
},
],
},
},
},
additionalData,
PROVIDER_NODE_TYPE_MAP.mistralCloud,
{},
credentials,
);
return {
models: results.map((result) => ({
name: result.name,
description: result.description ?? String(result.value),
model: {
provider: 'mistralCloud',
model: String(result.value),
},
createdAt: null,
updatedAt: null,
})),
};
}
private async fetchCohereModels(
credentials: INodeCredentials,
additionalData: IWorkflowExecuteAdditionalData,
): Promise<ChatModelsResponse['cohere']> {
const results = await this.nodeParametersService.getOptionsViaLoadOptions(
{
routing: {
request: {
method: 'GET',
url: '/v1/models?page_size=100&endpoint=chat',
},
output: {
postReceive: [
{
type: 'rootProperty',
properties: {
property: 'models',
},
},
{
type: 'setKeyValue',
properties: {
name: '={{$responseItem.name}}',
value: '={{$responseItem.name}}',
description: '={{$responseItem.description}}',
},
},
{
type: 'sort',
properties: {
key: 'name',
},
},
],
},
},
},
additionalData,
PROVIDER_NODE_TYPE_MAP.cohere,
{},
credentials,
);
return {
models: results.map((result) => ({
name: result.name,
description: result.description ?? null,
model: {
provider: 'cohere',
model: String(result.value),
},
createdAt: null,
updatedAt: null,
})),
};
}
@ -539,30 +670,25 @@ export class ChatHubService {
return [];
}
if (chatTrigger.parameters.availableInChat !== true) {
const chatTriggerParams = validChatTriggerParamsShape.safeParse(
chatTrigger.parameters,
).data;
if (!chatTriggerParams) {
return [];
}
const name =
typeof chatTrigger.parameters.agentName === 'string' &&
chatTrigger.parameters.agentName.length > 0
? chatTrigger.parameters.agentName
: workflow.name;
return [
{
name: name ?? 'Unknown Agent',
description:
typeof chatTrigger.parameters.agentDescription === 'string' &&
chatTrigger.parameters.agentDescription.length > 0
? chatTrigger.parameters.agentDescription
: null,
name: chatTriggerParams.agentName ?? workflow.name ?? 'Unknown Agent',
description: chatTriggerParams.agentDescription ?? null,
model: {
provider: 'n8n',
workflowId: workflow.id,
},
createdAt: workflow.createdAt ? workflow.createdAt.toISOString() : null,
updatedAt: workflow.updatedAt ? workflow.updatedAt.toISOString() : null,
allowFileUploads: chatTriggerParams.options?.allowFileUploads ?? false,
},
];
}),
@ -614,12 +740,31 @@ export class ChatHubService {
}
async sendHumanMessage(res: Response, user: User, payload: HumanMessagePayload) {
const { sessionId, messageId, message, model, credentials, previousMessageId, tools } = payload;
const {
sessionId,
messageId,
message,
model,
credentials,
previousMessageId,
tools,
attachments,
} = payload;
const credentialId = this.getModelCredential(model, credentials);
const { executionData, workflowData } = await this.messageRepository.manager.transaction(
async (trx) => {
// Store attachments early to populate 'id' field via BinaryDataService
const processedAttachments = await this.chatHubAttachmentService.store(
sessionId,
messageId,
attachments,
);
let executionData: IRunExecutionData;
let workflowData: IWorkflowBase;
try {
const result = await this.messageRepository.manager.transaction(async (trx) => {
let session = await this.getChatSession(user, sessionId, trx);
session ??= await this.createChatSession(user, sessionId, model, credentialId, tools, trx);
@ -627,36 +772,36 @@ export class ChatHubService {
const messages = Object.fromEntries((session.messages ?? []).map((m) => [m.id, m]));
const history = this.buildMessageHistory(messages, previousMessageId);
await this.saveHumanMessage(payload, user, previousMessageId, model, undefined, trx);
await this.saveHumanMessage(
payload,
processedAttachments,
user,
previousMessageId,
model,
undefined,
trx,
);
if (model.provider === 'n8n') {
return await this.prepareCustomAgentWorkflow(user, sessionId, model.workflowId, message);
}
if (model.provider === 'custom-agent') {
return await this.prepareChatAgentWorkflow(
model.agentId,
user,
sessionId,
history,
message,
trx,
);
}
return await this.prepareBaseChatWorkflow(
return await this.prepareReplyWorkflow(
user,
sessionId,
credentials,
model,
history,
message,
undefined,
session.tools,
tools,
processedAttachments,
trx,
);
},
);
});
executionData = result.executionData;
workflowData = result.workflowData;
} catch (error) {
// Rollback stored attachments if transaction fails
await this.chatHubAttachmentService.deleteAttachments(processedAttachments);
throw error;
}
await this.executeChatWorkflowWithCleanup(
res,
@ -690,10 +835,6 @@ export class ChatHubService {
const messageToEdit = await this.getChatMessage(session.id, editId, [], trx);
if (!['ai', 'human'].includes(messageToEdit.type)) {
throw new BadRequestError('Only human and AI messages can be edited');
}
if (messageToEdit.type === 'ai') {
// AI edits just change the original message without revisioning or response generation
await this.messageRepository.updateChatMessage(editId, { content: payload.message }, trx);
@ -707,8 +848,12 @@ export class ChatHubService {
// If the message to edit isn't the original message, we want to point to the original message
const revisionOfMessageId = messageToEdit.revisionOfMessageId ?? messageToEdit.id;
// Attachments are already processed (from the original message)
const attachments = messageToEdit.attachments ?? [];
await this.saveHumanMessage(
payload,
attachments,
user,
messageToEdit.previousMessageId,
model,
@ -716,34 +861,20 @@ export class ChatHubService {
trx,
);
if (model.provider === 'n8n') {
return await this.prepareCustomAgentWorkflow(user, sessionId, model.workflowId, message);
}
if (model.provider === 'custom-agent') {
return await this.prepareChatAgentWorkflow(
model.agentId,
user,
sessionId,
history,
message,
trx,
);
}
return await this.prepareBaseChatWorkflow(
return await this.prepareReplyWorkflow(
user,
sessionId,
credentials,
model,
history,
message,
undefined,
session.tools,
attachments,
trx,
);
}
return null;
throw new BadRequestError('Only human and AI messages can be edited');
});
if (!workflow) {
@ -765,7 +896,6 @@ export class ChatHubService {
async regenerateAIMessage(res: Response, user: User, payload: RegenerateMessagePayload) {
const { sessionId, retryId, model, credentials } = payload;
const { provider } = model;
const {
workflow: { workflowData, executionData },
@ -802,37 +932,18 @@ export class ChatHubService {
// If the message being retried is itself a retry, we want to point to the original message
const retryOfMessageId = messageToRetry.retryOfMessageId ?? messageToRetry.id;
const message = lastHumanMessage ? lastHumanMessage.content : '';
let workflow;
if (provider === 'n8n') {
workflow = await this.prepareCustomAgentWorkflow(
user,
sessionId,
model.workflowId,
message,
);
} else if (provider === 'custom-agent') {
workflow = await this.prepareChatAgentWorkflow(
model.agentId,
user,
sessionId,
history,
message,
trx,
);
} else {
workflow = await this.prepareBaseChatWorkflow(
user,
sessionId,
credentials,
model,
history,
message,
undefined,
session.tools,
trx,
);
}
const attachments = lastHumanMessage.attachments ?? [];
const workflow = await this.prepareReplyWorkflow(
user,
sessionId,
credentials,
model,
history,
message,
session.tools,
attachments,
trx,
);
return {
workflow,
@ -853,6 +964,53 @@ export class ChatHubService {
);
}
private async prepareReplyWorkflow(
user: User,
sessionId: ChatSessionId,
credentials: INodeCredentials,
model: ChatHubConversationModel,
history: ChatHubMessage[],
message: string,
tools: INode[],
attachments: IBinaryData[],
trx: EntityManager,
) {
if (model.provider === 'n8n') {
return await this.prepareCustomAgentWorkflow(
user,
sessionId,
model.workflowId,
message,
attachments,
);
}
if (model.provider === 'custom-agent') {
return await this.prepareChatAgentWorkflow(
model.agentId,
user,
sessionId,
history,
message,
attachments,
trx,
);
}
return await this.prepareBaseChatWorkflow(
user,
sessionId,
credentials,
model,
history,
message,
undefined,
tools,
attachments,
trx,
);
}
private async prepareBaseChatWorkflow(
user: User,
sessionId: ChatSessionId,
@ -862,6 +1020,7 @@ export class ChatHubService {
message: string,
systemMessage: string | undefined,
tools: INode[],
attachments: IBinaryData[],
trx: EntityManager,
) {
const credential = await this.chatHubCredentialsService.ensureCredentials(
@ -877,6 +1036,7 @@ export class ChatHubService {
credential.projectId,
history,
message,
attachments,
credentials,
model,
systemMessage,
@ -891,6 +1051,7 @@ export class ChatHubService {
sessionId: ChatSessionId,
history: ChatHubMessage[],
message: string,
attachments: IBinaryData[],
trx: EntityManager,
) {
const agent = await this.chatHubAgentService.getAgentById(agentId, user.id);
@ -937,6 +1098,7 @@ export class ChatHubService {
message,
systemMessage,
tools,
attachments,
trx,
);
}
@ -946,6 +1108,7 @@ export class ChatHubService {
sessionId: ChatSessionId,
workflowId: string,
message: string,
attachments: IBinaryData[],
) {
const workflowEntity = await this.workflowFinderService.findWorkflowForUser(
workflowId,
@ -978,25 +1141,12 @@ export class ChatHubService {
);
}
const nodeExecutionStack: IExecuteData[] = [
{
node: chatTriggerNode,
data: {
main: [
[
{
json: {
sessionId,
action: 'sendMessage',
chatInput: message,
},
},
],
],
},
source: null,
},
];
const nodeExecutionStack = this.chatHubWorkflowService.prepareExecutionData(
chatTriggerNode,
sessionId,
message,
attachments,
);
const executionData = createRunExecutionData({
executionData: {
@ -1506,6 +1656,7 @@ export class ChatHubService {
private async saveHumanMessage(
payload: HumanMessagePayload | EditMessagePayload,
attachments: IBinaryData[],
user: User,
previousMessageId: ChatMessageId | null,
model: ChatHubConversationModel,
@ -1522,6 +1673,7 @@ export class ChatHubService {
previousMessageId,
revisionOfMessageId,
name: user.firstName || 'User',
attachments,
...model,
},
trx,
@ -1648,24 +1800,36 @@ export class ChatHubService {
/**
* Get all conversations for a user
*/
async getConversations(userId: string): Promise<ChatHubConversationsResponse> {
const sessions = await this.sessionRepository.getManyByUserId(userId);
async getConversations(
userId: string,
limit: number,
cursor?: string,
): Promise<ChatHubConversationsResponse> {
const sessions = await this.sessionRepository.getManyByUserId(userId, limit + 1, cursor);
return sessions.map((session) => ({
id: session.id,
title: session.title,
ownerId: session.ownerId,
lastMessageAt: session.lastMessageAt?.toISOString() ?? null,
credentialId: session.credentialId,
provider: session.provider,
model: session.model,
workflowId: session.workflowId,
agentId: session.agentId,
agentName: session.agentName,
createdAt: session.createdAt.toISOString(),
updatedAt: session.updatedAt.toISOString(),
tools: session.tools,
}));
const hasMore = sessions.length > limit;
const data = hasMore ? sessions.slice(0, limit) : sessions;
const nextCursor = hasMore ? data[data.length - 1].id : null;
return {
data: data.map((session) => ({
id: session.id,
title: session.title,
ownerId: session.ownerId,
lastMessageAt: session.lastMessageAt?.toISOString() ?? null,
credentialId: session.credentialId,
provider: session.provider,
model: session.model,
workflowId: session.workflowId,
agentId: session.agentId,
agentName: session.agentName,
createdAt: session.createdAt.toISOString(),
updatedAt: session.updatedAt.toISOString(),
tools: session.tools,
})),
nextCursor,
hasMore,
};
}
/**
@ -1720,6 +1884,11 @@ export class ChatHubService {
previousMessageId: message.previousMessageId,
retryOfMessageId: message.retryOfMessageId,
revisionOfMessageId: message.revisionOfMessageId,
attachments: (message.attachments ?? []).map(({ fileName, mimeType }) => ({
fileName,
mimeType,
})),
};
}
@ -1748,6 +1917,7 @@ export class ChatHubService {
}
async deleteAllSessions() {
await this.chatHubAttachmentService.deleteAll();
const result = await this.sessionRepository.deleteAll();
return result;
}
@ -1855,6 +2025,7 @@ export class ChatHubService {
throw new NotFoundError('Session not found');
}
await this.chatHubAttachmentService.deleteAllBySessionId(sessionId);
await this.sessionRepository.deleteChatHubSession(sessionId);
}
}

View File

@ -1,5 +1,21 @@
import type { ChatHubConversationModel, ChatMessageId, ChatSessionId } from '@n8n/api-types';
import type {
ChatHubConversationModel,
ChatHubProvider,
ChatMessageId,
ChatSessionId,
ChatAttachment,
} from '@n8n/api-types';
import type { INode, INodeCredentials } from 'n8n-workflow';
import { z } from 'zod';
export interface ModelWithCredentials {
provider: ChatHubProvider;
model?: string;
workflowId?: string;
credentialId: string | null;
agentId?: string;
name?: string;
}
export interface BaseMessagePayload {
userId: string;
@ -12,6 +28,7 @@ export interface HumanMessagePayload extends BaseMessagePayload {
messageId: ChatMessageId;
message: string;
previousMessageId: ChatMessageId | null;
attachments: ChatAttachment[];
tools: INode[];
}
export interface RegenerateMessagePayload extends BaseMessagePayload {
@ -31,3 +48,14 @@ export interface MessageRecord {
message: string;
hideFromUI: boolean;
}
export const validChatTriggerParamsShape = z.object({
availableInChat: z.literal(true),
agentName: z.string().min(1).optional(),
agentDescription: z.string().min(1).optional(),
options: z
.object({
allowFileUploads: z.boolean().optional(),
})
.optional(),
});

View File

@ -5,6 +5,7 @@ import { DataSource, EntityManager, Repository } from '@n8n/typeorm';
import { ChatHubMessage } from './chat-hub-message.entity';
import { ChatHubSessionRepository } from './chat-session.repository';
import type { IBinaryData } from 'n8n-workflow';
@Service()
export class ChatHubMessageRepository extends Repository<ChatHubMessage> {
@ -33,7 +34,7 @@ export class ChatHubMessageRepository extends Repository<ChatHubMessage> {
async updateChatMessage(
id: ChatMessageId,
fields: { status?: ChatHubMessageStatus; content?: string },
fields: { status?: ChatHubMessageStatus; content?: string; attachments?: IBinaryData[] },
trx?: EntityManager,
) {
return await withTransaction(

View File

@ -2,6 +2,8 @@ import { withTransaction } from '@n8n/db';
import { Service } from '@n8n/di';
import { DataSource, EntityManager, Repository } from '@n8n/typeorm';
import { NotFoundError } from '@/errors/response-errors/not-found.error';
import { ChatHubSession } from './chat-hub-session.entity';
@Service()
@ -56,11 +58,33 @@ export class ChatHubSessionRepository extends Repository<ChatHubSession> {
});
}
async getManyByUserId(userId: string) {
return await this.find({
where: { ownerId: userId },
order: { lastMessageAt: 'DESC', id: 'ASC' },
});
async getManyByUserId(userId: string, limit: number, cursor?: string) {
const queryBuilder = this.createQueryBuilder('session')
.where('session.ownerId = :userId', { userId })
.orderBy("COALESCE(session.lastMessageAt, '1970-01-01')", 'DESC')
.addOrderBy('session.id', 'ASC');
if (cursor) {
const cursorSession = await this.findOne({
where: { id: cursor, ownerId: userId },
});
if (!cursorSession) {
throw new NotFoundError('Cursor session not found');
}
queryBuilder.andWhere(
'(session.lastMessageAt < :lastMessageAt OR (session.lastMessageAt = :lastMessageAt AND session.id > :id))',
{
lastMessageAt: cursorSession.lastMessageAt,
id: cursorSession.id,
},
);
}
queryBuilder.take(limit);
return await queryBuilder.getMany();
}
async getOneById(id: string, userId: string, trx?: EntityManager) {

View File

@ -141,6 +141,8 @@ export const maxContextWindowTokens: Record<ChatHubLLMProvider, Record<string, n
ollama: {},
awsBedrock: {},
deepSeek: {},
cohere: {},
mistralCloud: {},
};
export const getMaxContextWindowTokens = (

View File

@ -7,6 +7,7 @@ import { mock } from 'jest-mock-extended';
import { InstanceSettings } from 'n8n-core';
import { InsightsModule } from '../insights.module';
import { InsightsService } from '../insights.service';
describe('InsightsModule', () => {
let insightsModule: InsightsModule;
@ -23,11 +24,23 @@ describe('InsightsModule', () => {
beforeEach(async () => {
jest.clearAllMocks();
await testDb.truncate(['Project']);
insightsModule = Container.get(InsightsModule);
mockInstanceSettings = mock<InstanceSettings>();
Container.set(InstanceSettings, mockInstanceSettings);
Container.set(Logger, mockLogger());
Container.set(LicenseState, mock<LicenseState>());
Container.set(
InsightsService,
new InsightsService(
mock(),
mock(),
mock(),
Container.get(LicenseState),
mockInstanceSettings,
Container.get(Logger),
),
);
insightsModule = Container.get(InsightsModule);
await createTeamProject();
});

View File

@ -1,17 +1,14 @@
import type { ModuleInterface } from '@n8n/decorators';
import { BackendModule, OnShutdown } from '@n8n/decorators';
import { Container } from '@n8n/di';
import { InstanceSettings } from 'n8n-core';
@BackendModule({ name: 'insights' })
/**
* Only main- and webhook-type instances collect insights because
* only they are informed of finished workflow executions.
*/
@BackendModule({ name: 'insights', instanceTypes: ['main', 'webhook'] })
export class InsightsModule implements ModuleInterface {
async init() {
/**
* Only main- and webhook-type instances collect insights because
* only they are informed of finished workflow executions.
*/
if (Container.get(InstanceSettings).instanceType === 'worker') return;
await import('./insights.controller');
const { InsightsService } = await import('./insights.service');

View File

@ -1,6 +1,15 @@
import { ModuleRegistry, Logger } from '@n8n/backend-common';
import { type AuthenticatedRequest, WorkflowEntity } from '@n8n/db';
import { Body, Post, Get, Patch, RestController, GlobalScope, Param } from '@n8n/decorators';
import {
Body,
Post,
Get,
Patch,
RestController,
GlobalScope,
Param,
ProjectScope,
} from '@n8n/decorators';
import type { Response } from 'express';
import { UpdateMcpSettingsDto } from './dto/update-mcp-settings.dto';
@ -57,7 +66,7 @@ export class McpSettingsController {
return await this.mcpServerApiKeyService.rotateMcpServerApiKey(req.user);
}
@GlobalScope('mcp:manage')
@ProjectScope('workflow:update')
@Patch('/workflows/:workflowId/toggle-access')
async toggleWorkflowMCPAccess(
req: AuthenticatedRequest,

View File

@ -15,7 +15,7 @@ properties:
example: John's Github account
type:
type: string
example: github
example: githubApi
createdAt:
type: string
format: date-time

View File

@ -13,11 +13,11 @@ properties:
example: Joe's Github Credentials
type:
type: string
example: github
example: githubApi
data:
type: object
writeOnly: true
example: { token: 'ada612vad6fa5df4adf5a5dsf4389adsf76da7s' }
example: { accessToken: 'ada612vad6fa5df4adf5a5dsf4389adsf76da7s' }
createdAt:
type: string
format: date-time

View File

@ -18,7 +18,7 @@ import { In } from '@n8n/typeorm';
import type { QueryDeepPartialEntity } from '@n8n/typeorm/query-builder/QueryPartialEntity';
import omit from 'lodash/omit';
import pick from 'lodash/pick';
import { BinaryDataService } from 'n8n-core';
import { FileLocation, BinaryDataService } from 'n8n-core';
import { NodeApiError, PROJECT_ROOT } from 'n8n-workflow';
import { v4 as uuid } from 'uuid';
@ -456,7 +456,9 @@ export class WorkflowService {
select: ['id'],
where: { workflowId },
})
.then((rows) => rows.map(({ id: executionId }) => ({ workflowId, executionId })));
.then((rows) =>
rows.map(({ id: executionId }) => FileLocation.ofExecution(workflowId, executionId)),
);
await this.workflowRepository.delete(workflowId);
await this.binaryDataService.deleteMany(idsForDeletion);

View File

@ -8,6 +8,7 @@ import {
InstanceSettings,
UnrecognizedNodeTypeError,
type DirectoryLoader,
type ErrorReporter,
} from 'n8n-core';
import { Ftp } from 'n8n-nodes-base/credentials/Ftp.credentials';
import { GithubApi } from 'n8n-nodes-base/credentials/GithubApi.credentials';
@ -115,7 +116,8 @@ export async function initBinaryDataService(mode: 'default' | 'filesystem' = 'de
availableModes: [mode],
localStoragePath: '',
});
const binaryDataService = new BinaryDataService(config);
const errorReporter = mock<ErrorReporter>();
const binaryDataService = new BinaryDataService(config, errorReporter);
await binaryDataService.init();
Container.set(BinaryDataService, binaryDataService);
}

View File

@ -2,6 +2,8 @@ import { mock } from 'jest-mock-extended';
import { sign, JsonWebTokenError, TokenExpiredError } from 'jsonwebtoken';
import type { IBinaryData } from 'n8n-workflow';
import type { ErrorReporter } from '@/errors';
import type { BinaryDataConfig } from '../binary-data.config';
import { BinaryDataService } from '../binary-data.service';
@ -11,6 +13,7 @@ jest.useFakeTimers({ now });
describe('BinaryDataService', () => {
const signingSecret = 'test-signing-secret';
const config = mock<BinaryDataConfig>({ signingSecret });
const errorReporter = mock<ErrorReporter>();
const binaryData = mock<IBinaryData>({ id: 'filesystem:id_123' });
const validToken = sign({ id: binaryData.id }, signingSecret, { expiresIn: '1 day' });
@ -19,7 +22,7 @@ describe('BinaryDataService', () => {
jest.resetAllMocks();
config.signingSecret = signingSecret;
service = new BinaryDataService(config);
service = new BinaryDataService(config, errorReporter);
});
describe('createSignedToken', () => {

View File

@ -1,3 +1,4 @@
import { mock } from 'jest-mock-extended';
import fs from 'node:fs';
import fsp from 'node:fs/promises';
import { tmpdir } from 'node:os';
@ -5,14 +6,18 @@ import path from 'node:path';
import { Readable } from 'node:stream';
import { FileSystemManager } from '@/binary-data/file-system.manager';
import type { ErrorReporter } from '@/errors';
import { toFileId, toStream } from '@test/utils';
import type { BinaryData } from '../types';
jest.mock('fs');
jest.mock('fs/promises');
const storagePath = tmpdir();
const errorReporter = mock<ErrorReporter>();
const fsManager = new FileSystemManager(storagePath);
const fsManager = new FileSystemManager(storagePath, errorReporter);
const toFullFilePath = (fileId: string) => path.join(storagePath, fileId);
@ -37,7 +42,11 @@ describe('store()', () => {
it('should store a buffer', async () => {
const metadata = { mimeType: 'text/plain' };
const result = await fsManager.store(workflowId, executionId, mockBuffer, metadata);
const result = await fsManager.store(
{ type: 'execution', workflowId, executionId },
mockBuffer,
metadata,
);
expect(result.fileSize).toBe(mockBuffer.length);
});
@ -104,7 +113,10 @@ describe('copyByFileId()', () => {
// @ts-expect-error - private method
jest.spyOn(fsManager, 'toFileId').mockReturnValue(otherFileId);
const targetFileId = await fsManager.copyByFileId(workflowId, executionId, fileId);
const targetFileId = await fsManager.copyByFileId(
{ type: 'execution', workflowId, executionId },
fileId,
);
const sourcePath = toFullFilePath(fileId);
const targetPath = toFullFilePath(targetFileId);
@ -133,8 +145,7 @@ describe('copyByFilePath()', () => {
fsp.writeFile = jest.fn().mockResolvedValue(undefined);
const result = await fsManager.copyByFilePath(
workflowId,
executionId,
{ type: 'execution', workflowId, executionId },
sourceFilePath,
metadata,
);
@ -156,9 +167,9 @@ describe('deleteMany()', () => {
};
it('should delete many files by workflow ID and execution ID', async () => {
const ids = [
{ workflowId, executionId },
{ workflowId: otherWorkflowId, executionId: otherExecutionId },
const ids: BinaryData.FileLocation[] = [
{ type: 'execution', workflowId, executionId },
{ type: 'execution', workflowId: otherWorkflowId, executionId: otherExecutionId },
];
fsp.rm = jest.fn().mockResolvedValue(undefined);
@ -181,7 +192,9 @@ describe('deleteMany()', () => {
});
it('should suppress error on non-existing filepath', async () => {
const ids = [{ workflowId: 'does-not-exist', executionId: 'does-not-exist' }];
const ids: BinaryData.FileLocation[] = [
{ type: 'execution', workflowId: 'does-not-exist', executionId: 'does-not-exist' },
];
fsp.rm = jest.fn().mockResolvedValue(undefined);

View File

@ -34,7 +34,11 @@ describe('store()', () => {
it('should store a buffer', async () => {
const metadata = { mimeType: 'text/plain' };
const result = await objectStoreManager.store(workflowId, executionId, mockBuffer, metadata);
const result = await objectStoreManager.store(
{ type: 'execution', workflowId, executionId },
mockBuffer,
metadata,
);
expect(result.fileId.startsWith(prefix)).toBe(true);
expect(result.fileSize).toBe(mockBuffer.length);
@ -94,7 +98,10 @@ describe('getMetadata()', () => {
describe('copyByFileId()', () => {
it('should copy by file ID and return the file ID', async () => {
const targetFileId = await objectStoreManager.copyByFileId(workflowId, executionId, fileId);
const targetFileId = await objectStoreManager.copyByFileId(
{ type: 'execution', workflowId, executionId },
fileId,
);
expect(targetFileId.startsWith(prefix)).toBe(true);
expect(objectStoreService.get).toHaveBeenCalledWith(fileId, { mode: 'buffer' });
@ -109,8 +116,7 @@ describe('copyByFilePath()', () => {
fs.readFile = jest.fn().mockResolvedValue(mockBuffer);
const result = await objectStoreManager.copyByFilePath(
workflowId,
executionId,
{ type: 'execution', workflowId, executionId },
sourceFilePath,
metadata,
);

View File

@ -7,6 +7,8 @@ import { readFile, stat } from 'node:fs/promises';
import prettyBytes from 'pretty-bytes';
import type { Readable } from 'stream';
import { ErrorReporter } from '@/errors';
import { BinaryDataConfig } from './binary-data.config';
import type { BinaryData } from './types';
import { areConfigModes, binaryToBuffer } from './utils';
@ -19,7 +21,10 @@ export class BinaryDataService {
private managers: Record<string, BinaryData.Manager> = {};
constructor(private readonly config: BinaryDataConfig) {}
constructor(
private readonly config: BinaryDataConfig,
private readonly errorReporter: ErrorReporter,
) {}
async init() {
const { config } = this;
@ -30,7 +35,7 @@ export class BinaryDataService {
if (config.availableModes.includes('filesystem')) {
const { FileSystemManager } = await import('./file-system.manager');
this.managers.filesystem = new FileSystemManager(config.localStoragePath);
this.managers.filesystem = new FileSystemManager(config.localStoragePath, this.errorReporter);
this.managers['filesystem-v2'] = this.managers.filesystem;
await this.managers.filesystem.init();
@ -66,8 +71,7 @@ export class BinaryDataService {
}
async copyBinaryFile(
workflowId: string,
executionId: string,
location: BinaryData.FileLocation,
binaryData: IBinaryData,
filePath: string,
) {
@ -86,12 +90,7 @@ export class BinaryDataService {
mimeType: binaryData.mimeType,
};
const { fileId, fileSize } = await manager.copyByFilePath(
workflowId,
executionId,
filePath,
metadata,
);
const { fileId, fileSize } = await manager.copyByFilePath(location, filePath, metadata);
binaryData.id = this.createBinaryDataId(fileId);
binaryData.fileSize = prettyBytes(fileSize);
@ -101,8 +100,7 @@ export class BinaryDataService {
}
async store(
workflowId: string,
executionId: string,
location: BinaryData.FileLocation,
bufferOrStream: Buffer | Readable,
binaryData: IBinaryData,
) {
@ -121,12 +119,7 @@ export class BinaryDataService {
mimeType: binaryData.mimeType,
};
const { fileId, fileSize } = await manager.store(
workflowId,
executionId,
bufferOrStream,
metadata,
);
const { fileId, fileSize } = await manager.store(location, bufferOrStream, metadata);
binaryData.id = this.createBinaryDataId(fileId);
binaryData.fileSize = prettyBytes(fileSize);
@ -163,17 +156,28 @@ export class BinaryDataService {
return await this.getManager(mode).getMetadata(fileId);
}
async deleteMany(ids: BinaryData.IdsForDeletion) {
async deleteMany(locations: BinaryData.FileLocation[]) {
const manager = this.managers[this.mode];
if (!manager) return;
if (manager.deleteMany) await manager.deleteMany(ids);
if (manager.deleteMany) await manager.deleteMany(locations);
}
async deleteManyByBinaryDataId(ids: string[]) {
const manager = this.managers[this.mode];
const fileIds = ids.flatMap((attachmentId) => {
const [, fileId] = attachmentId.split(':'); // remove mode
return fileId ? [fileId] : [];
});
await manager.deleteManyByFileId?.(fileIds);
}
async duplicateBinaryData(
workflowId: string,
executionId: string,
location: BinaryData.FileLocation,
inputData: Array<INodeExecutionData[] | null>,
) {
if (inputData && this.managers[this.mode]) {
@ -183,11 +187,7 @@ export class BinaryDataService {
return await Promise.all(
executionDataArray.map(async (executionData) => {
if (executionData.binary) {
return await this.duplicateBinaryDataInExecData(
workflowId,
executionId,
executionData,
);
return await this.duplicateBinaryDataInExecData(location, executionData);
}
return executionData;
@ -222,8 +222,7 @@ export class BinaryDataService {
}
private async duplicateBinaryDataInExecData(
workflowId: string,
executionId: string,
location: BinaryData.FileLocation,
executionData: INodeExecutionData,
) {
const manager = this.managers[this.mode];
@ -242,7 +241,7 @@ export class BinaryDataService {
const [_mode, fileId] = binaryDataId.split(':');
return await manager?.copyByFileId(workflowId, executionId, fileId).then((newFileId) => ({
return await manager?.copyByFileId(location, fileId).then((newFileId) => ({
newId: this.createBinaryDataId(newFileId),
key,
}));

View File

@ -1,32 +1,40 @@
import { jsonParse } from 'n8n-workflow';
import { jsonParse, UnexpectedError } from 'n8n-workflow';
import { createReadStream } from 'node:fs';
import fs from 'node:fs/promises';
import path from 'node:path';
import type { Readable } from 'stream';
import { v4 as uuid } from 'uuid';
import type { ErrorReporter } from '@/errors';
import type { BinaryData } from './types';
import { assertDir, doesNotExist } from './utils';
import { assertDir, doesNotExist, FileLocation } from './utils';
import { DisallowedFilepathError } from '../errors/disallowed-filepath.error';
import { FileNotFoundError } from '../errors/file-not-found.error';
const EXECUTION_ID_EXTRACTOR =
/^(\w+)(?:[0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12})$/;
const EXECUTION_PATH_MATCHER = /^workflows\/([^/]+)\/executions\/([^/]+)\//;
const CHAT_HUB_ATTACHMENT_PATH_MATCHER = /^chat-hub\/sessions\/([^/]+)\/messages\/([^/]+)\//;
export class FileSystemManager implements BinaryData.Manager {
constructor(private storagePath: string) {}
constructor(
private storagePath: string,
private readonly errorReporter: ErrorReporter,
) {}
async init() {
await assertDir(this.storagePath);
}
async store(
workflowId: string,
executionId: string,
location: BinaryData.FileLocation,
bufferOrStream: Buffer | Readable,
{ mimeType, fileName }: BinaryData.PreWriteMetadata,
) {
const fileId = this.toFileId(workflowId, executionId);
const fileId = this.toFileId(location);
const filePath = this.resolvePath(fileId);
await assertDir(path.dirname(filePath));
@ -70,12 +78,14 @@ export class FileSystemManager implements BinaryData.Manager {
return await jsonParse(await fs.readFile(filePath, { encoding: 'utf-8' }));
}
async deleteMany(ids: BinaryData.IdsForDeletion) {
if (ids.length === 0) return;
async deleteMany(locations: BinaryData.FileLocation[]) {
if (locations.length === 0) return;
// binary files stored in single dir - `filesystem`
const executionIds = ids.map((o) => o.executionId);
const executionIds = locations.flatMap((location) =>
location.type === 'execution' ? [location.executionId] : [],
);
const set = new Set(executionIds);
const fileNames = await fs.readdir(this.storagePath);
@ -92,8 +102,8 @@ export class FileSystemManager implements BinaryData.Manager {
// binary files stored in nested dirs - `filesystem-v2`
const binaryDataDirs = ids.map(({ workflowId, executionId }) =>
this.resolvePath(`workflows/${workflowId}/executions/${executionId}`),
const binaryDataDirs = locations.map((location) =>
this.resolvePath(this.toRelativePath(location)),
);
await Promise.all(
@ -104,12 +114,11 @@ export class FileSystemManager implements BinaryData.Manager {
}
async copyByFilePath(
workflowId: string,
executionId: string,
targetLocation: BinaryData.FileLocation,
sourcePath: string,
{ mimeType, fileName }: BinaryData.PreWriteMetadata,
) {
const targetFileId = this.toFileId(workflowId, executionId);
const targetFileId = this.toFileId(targetLocation);
const targetPath = this.resolvePath(targetFileId);
await assertDir(path.dirname(targetPath));
@ -123,8 +132,8 @@ export class FileSystemManager implements BinaryData.Manager {
return { fileId: targetFileId, fileSize };
}
async copyByFileId(workflowId: string, executionId: string, sourceFileId: string) {
const targetFileId = this.toFileId(workflowId, executionId);
async copyByFileId(targetLocation: BinaryData.FileLocation, sourceFileId: string) {
const targetFileId = this.toFileId(targetLocation);
const sourcePath = this.resolvePath(sourceFileId);
const targetPath = this.resolvePath(targetFileId);
const sourceMetadata = await this.getMetadata(sourceFileId);
@ -155,6 +164,21 @@ export class FileSystemManager implements BinaryData.Manager {
await fs.rm(tempDir, { recursive: true });
}
async deleteManyByFileId(ids: string[]): Promise<void> {
const parsedIds = ids.flatMap((id) => {
try {
const parsed = this.parseFileId(id);
return [parsed];
} catch (e) {
this.errorReporter.warn(`Could not parse file ID ${id}. Skip deletion`);
return [];
}
});
await this.deleteMany(parsedIds);
}
// ----------------------------------
// private methods
// ----------------------------------
@ -165,10 +189,35 @@ export class FileSystemManager implements BinaryData.Manager {
* The legacy ID format `{executionId}{uuid}` for `filesystem` mode is
* no longer used on write, only when reading old stored execution data.
*/
private toFileId(workflowId: string, executionId: string) {
if (!executionId) executionId = 'temp'; // missing only in edge case, see PR #7244
private toFileId(location: BinaryData.FileLocation) {
return `${this.toRelativePath(location)}/binary_data/${uuid()}`;
}
return `workflows/${workflowId}/executions/${executionId}/binary_data/${uuid()}`;
private toRelativePath(location: BinaryData.FileLocation) {
switch (location.type) {
case 'execution': {
const executionId = location.executionId || 'temp'; // missing only in edge case, see PR #7244
return `workflows/${location.workflowId}/executions/${executionId}`;
}
case 'chat-hub-message-attachment':
return `chat-hub/sessions/${location.sessionId}/messages/${location.messageId}`;
}
}
private parseFileId(fileId: string): BinaryData.FileLocation {
const executionMatch = fileId.match(EXECUTION_PATH_MATCHER);
if (executionMatch) {
return FileLocation.ofExecution(executionMatch[1], executionMatch[2]);
}
const chatHubMatch = fileId.match(CHAT_HUB_ATTACHMENT_PATH_MATCHER);
if (chatHubMatch) {
return FileLocation.ofChatHubMessageAttachment(chatHubMatch[1], chatHubMatch[2]);
}
throw new UnexpectedError(`File ID ${fileId} has invalid format.`);
}
private resolvePath(...args: string[]) {

View File

@ -2,4 +2,4 @@ export * from './binary-data.service';
export { BinaryDataConfig } from './binary-data.config';
export type * from './types';
export { ObjectStoreService } from './object-store/object-store.service.ee';
export { isStoredMode as isValidNonDefaultMode } from './utils';
export { isStoredMode as isValidNonDefaultMode, FileLocation } from './utils';

View File

@ -16,12 +16,11 @@ export class ObjectStoreManager implements BinaryData.Manager {
}
async store(
workflowId: string,
executionId: string,
location: BinaryData.FileLocation,
bufferOrStream: Buffer | Readable,
metadata: BinaryData.PreWriteMetadata,
) {
const fileId = this.toFileId(workflowId, executionId);
const fileId = this.toFileId(location);
const buffer = await binaryToBuffer(bufferOrStream);
await this.objectStoreService.put(fileId, buffer, metadata);
@ -56,8 +55,8 @@ export class ObjectStoreManager implements BinaryData.Manager {
return metadata;
}
async copyByFileId(workflowId: string, executionId: string, sourceFileId: string) {
const targetFileId = this.toFileId(workflowId, executionId);
async copyByFileId(targetLocation: BinaryData.FileLocation, sourceFileId: string) {
const targetFileId = this.toFileId(targetLocation);
const sourceFile = await this.objectStoreService.get(sourceFileId, { mode: 'buffer' });
@ -70,12 +69,11 @@ export class ObjectStoreManager implements BinaryData.Manager {
* Copy to object store the temp file written by nodes like Webhook, FTP, and SSH.
*/
async copyByFilePath(
workflowId: string,
executionId: string,
targetLocation: BinaryData.FileLocation,
sourcePath: string,
metadata: BinaryData.PreWriteMetadata,
) {
const targetFileId = this.toFileId(workflowId, executionId);
const targetFileId = this.toFileId(targetLocation);
const sourceFile = await fs.readFile(sourcePath);
await this.objectStoreService.put(targetFileId, sourceFile, metadata);
@ -95,9 +93,14 @@ export class ObjectStoreManager implements BinaryData.Manager {
// private methods
// ----------------------------------
private toFileId(workflowId: string, executionId: string) {
if (!executionId) executionId = 'temp'; // missing only in edge case, see PR #7244
return `workflows/${workflowId}/executions/${executionId}/binary_data/${uuid()}`;
private toFileId(location: BinaryData.FileLocation) {
switch (location.type) {
case 'execution': {
const executionId = location.executionId || 'temp'; // missing only in edge case, see PR #7244
return `workflows/${location.workflowId}/executions/${executionId}/binary_data/${uuid()}`;
}
case 'chat-hub-message-attachment':
return `chat-hub/sessions/${location.sessionId}/messages/${location.messageId}/binary_data/${uuid()}`;
}
}
}

View File

@ -32,14 +32,15 @@ export namespace BinaryData {
export type PreWriteMetadata = Omit<Metadata, 'fileSize'>;
export type IdsForDeletion = Array<{ workflowId: string; executionId: string }>;
export type FileLocation =
| { type: 'execution'; workflowId: string; executionId: string }
| { type: 'chat-hub-message-attachment'; sessionId: string; messageId: string };
export interface Manager {
init(): Promise<void>;
store(
workflowId: string,
executionId: string,
location: FileLocation,
bufferOrStream: Buffer | Readable,
metadata: PreWriteMetadata,
): Promise<WriteResult>;
@ -52,12 +53,12 @@ export namespace BinaryData {
/**
* Present for `FileSystem`, absent for `ObjectStore` (delegated to S3 lifecycle config)
*/
deleteMany?(ids: IdsForDeletion): Promise<void>;
deleteMany?(locations: FileLocation[]): Promise<void>;
deleteManyByFileId?(ids: string[]): Promise<void>;
copyByFileId(workflowId: string, executionId: string, sourceFileId: string): Promise<string>;
copyByFileId(targetLocation: FileLocation, sourceFileId: string): Promise<string>;
copyByFilePath(
workflowId: string,
executionId: string,
targetLocation: FileLocation,
sourcePath: string,
metadata: PreWriteMetadata,
): Promise<WriteResult>;

View File

@ -52,3 +52,16 @@ export async function binaryToBuffer(body: Buffer | Readable) {
if (Buffer.isBuffer(body)) return body;
return await streamToBuffer(body);
}
export const FileLocation = {
ofExecution: (workflowId: string, executionId: string): BinaryData.FileLocation => ({
type: 'execution',
workflowId,
executionId,
}),
ofChatHubMessageAttachment: (sessionId: string, messageId: string): BinaryData.FileLocation => ({
type: 'chat-hub-message-attachment',
sessionId,
messageId,
}),
};

View File

@ -286,8 +286,7 @@ export const describeCommonTests = (
expect(result.data).toEqual(data);
expect(binaryDataService.duplicateBinaryData).toHaveBeenCalledWith(
workflow.id,
additionalData.executionId,
{ type: 'execution', workflowId: workflow.id, executionId: additionalData.executionId },
executeWorkflowData.data,
);
});

View File

@ -34,6 +34,7 @@ import {
} from 'n8n-workflow';
import { BinaryDataService } from '@/binary-data/binary-data.service';
import { FileLocation } from '@/binary-data/utils';
import { NodeExecutionContext } from './node-execution-context';
@ -155,8 +156,7 @@ export class BaseExecuteContext extends NodeExecutionContext {
}
const data = await this.binaryDataService.duplicateBinaryData(
this.workflow.id,
this.additionalData.executionId!,
FileLocation.ofExecution(this.workflow.id, this.additionalData.executionId!),
result.data,
);
return { ...result, data };

View File

@ -14,6 +14,7 @@ import { Readable } from 'stream';
import type { BinaryDataConfig } from '@/binary-data';
import { BinaryDataService } from '@/binary-data/binary-data.service';
import type { ErrorReporter } from '@/errors';
import {
assertBinaryData,
@ -44,11 +45,12 @@ describe('test binary data helper methods', () => {
availableModes: ['default', 'filesystem'],
localStoragePath: temporaryDir,
});
const errorReporter = mock<ErrorReporter>();
let binaryDataService: BinaryDataService;
beforeEach(() => {
jest.resetAllMocks();
binaryDataService = new BinaryDataService(binaryDataConfig);
binaryDataService = new BinaryDataService(binaryDataConfig, errorReporter);
Container.set(BinaryDataService, binaryDataService);
});
@ -592,8 +594,7 @@ describe('copyBinaryFile', () => {
expect(result.fileName).toBe(fileName);
expect(binaryDataService.copyBinaryFile).toHaveBeenCalledWith(
workflowId,
executionId,
{ type: 'execution', workflowId, executionId },
{
...binaryData,
fileExtension: 'txt',
@ -614,8 +615,7 @@ describe('copyBinaryFile', () => {
expect(result.fileName).toBe(fileName);
expect(binaryDataService.copyBinaryFile).toHaveBeenCalledWith(
workflowId,
executionId,
{ type: 'execution', workflowId, executionId },
{
...binaryData,
fileExtension: 'bin',
@ -635,7 +635,7 @@ describe('prepareBinaryData', () => {
jest.resetAllMocks();
Container.set(BinaryDataService, binaryDataService);
binaryDataService.store.mockImplementation(async (_w, _e, _b, binaryData) => binaryData);
binaryDataService.store.mockImplementation(async (_l, _b, binaryData) => binaryData);
});
it('parses filenames correctly', async () => {
@ -644,13 +644,17 @@ describe('prepareBinaryData', () => {
const result = await prepareBinaryData(buffer, executionId, workflowId, fileName);
expect(result.fileName).toEqual(fileName);
expect(binaryDataService.store).toHaveBeenCalledWith(workflowId, executionId, buffer, {
data: '',
fileExtension: undefined,
fileName,
fileType: 'text',
mimeType: 'text/plain',
});
expect(binaryDataService.store).toHaveBeenCalledWith(
{ type: 'execution', executionId, workflowId },
buffer,
{
data: '',
fileExtension: undefined,
fileName,
fileType: 'text',
mimeType: 'text/plain',
},
);
});
it('handles IncomingMessage with responseUrl', async () => {

View File

@ -159,8 +159,7 @@ export async function setBinaryDataBuffer(
executionId: string,
): Promise<IBinaryData> {
return await Container.get(BinaryDataService).store(
workflowId,
executionId,
{ type: 'execution', workflowId, executionId },
bufferOrStream,
binaryData,
);
@ -218,8 +217,7 @@ export async function copyBinaryFile(
}
return await Container.get(BinaryDataService).copyBinaryFile(
workflowId,
executionId,
{ type: 'execution', workflowId, executionId },
returnData,
filePath,
);

View File

@ -11,6 +11,7 @@ const props = defineProps<{
file: File;
isRemovable: boolean;
isPreviewable?: boolean;
href?: string;
}>();
const emit = defineEmits<{
@ -30,6 +31,11 @@ const TypeIcon = computed(() => {
});
function onClick() {
if (props.href) {
window.open(props.href, '_blank', 'noopener noreferrer');
return;
}
if (props.isPreviewable) {
window.open(URL.createObjectURL(props.file));
}
@ -41,12 +47,12 @@ function onDelete() {
<template>
<div class="chat-file" @click="onClick">
<TypeIcon />
<TypeIcon class="chat-icon" />
<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" />
<IconPreview v-else-if="isPreviewable || href" class="chat-file-preview" />
</div>
</template>
@ -64,7 +70,14 @@ function onDelete() {
background: white;
color: var(--chat--color-dark);
border: 1px solid var(--chat--color-dark);
cursor: pointer;
&:has(.chat-file-preview) {
cursor: pointer;
}
}
.chat-icon {
flex-shrink: 0;
}
.chat-file-name-tooltip {

View File

@ -188,6 +188,7 @@ import IconLucideSmile from '~icons/lucide/smile';
import IconLucideSparkles from '~icons/lucide/sparkles';
import IconLucideSquare from '~icons/lucide/square';
import IconLucideSquareCheck from '~icons/lucide/square-check';
import IconLucideSquareMinus from '~icons/lucide/square-minus';
import IconLucideSquarePen from '~icons/lucide/square-pen';
import IconLucideSquarePlus from '~icons/lucide/square-plus';
import IconLucideStickyNote from '~icons/lucide/sticky-note';
@ -626,6 +627,7 @@ export const updatedIconSet = {
sparkles: IconLucideSparkles,
square: IconLucideSquare,
'square-check': IconLucideSquareCheck,
'square-minus': IconLucideSquareMinus,
'square-pen': IconLucideSquarePen,
'square-plus': IconLucideSquarePlus,
'sticky-note': IconLucideStickyNote,

View File

@ -42,7 +42,14 @@ const { t } = useI18n();
<div>
<div :class="$style.details">
<span :class="$style.name" data-test-id="node-creator-item-name" v-text="title" />
<ElTag v-if="tag" :class="$style.tag" size="small" round :type="tag.type ?? 'success'">
<ElTag
v-if="tag"
:class="$style.tag"
disable-transitions
size="small"
round
:type="tag.type ?? 'success'"
>
{{ tag.text }}
</ElTag>
<N8nIcon

View File

@ -109,13 +109,13 @@ const onClick = (event: MouseEvent) => {
}
.danger {
--border-color: var(--color--danger--tint-3);
--notice--color--background: var(--color--danger--tint-4);
--border-color: var(--callout--border-color--danger);
--notice--color--background: var(--callout--color--background--danger);
}
.success {
--border-color: var(--color--success--tint-3);
--notice--color--background: var(--color--success--tint-4);
--border-color: var(--callout--border-color--success);
--notice--color--background: var(--callout--color--background--success);
}
.info {

View File

@ -423,6 +423,7 @@ defineExpose({
<N8nTooltip
:content="creditsTooltipContent"
:popper-class="$style.infoPopper"
:show-after="300"
placement="top"
>
<N8nIcon icon="info" size="small" />
@ -432,6 +433,8 @@ defineExpose({
:disabled="!showAskOwnerTooltip"
:content="t('promptInput.askAdminToUpgrade')"
placement="top"
:show-after="300"
:enterable="false"
>
<N8nLink size="small" theme="text" @click="() => emit('upgrade-click')">
{{ t('promptInput.getMore') }}

View File

@ -86,7 +86,7 @@ export default {
'promptInput.askAdminToUpgrade': 'Ask your admin to upgrade the instance to get more credits',
'promptInput.characterLimitReached': "You've reached the {limit} character limit",
'promptInput.remainingCredits': 'Remaining builder AI credits: <b>{count}</b>',
'promptInput.monthlyCredits': 'Monthly credits: <b>{count}</b>',
'promptInput.monthlyCredits': 'Monthly credits: <b>{count}</b> (1 credit = 1 message)',
'promptInput.creditsRenew': 'Credits renew on: <b>{date}</b>',
'promptInput.creditsExpire': 'Unused credits expire {date}',
} as N8nLocale;

View File

@ -0,0 +1,220 @@
# Component specification
Displays short text or icons to represent status, categories, or counts. Badges help users quickly identify important information through visual indicators without cluttering the interface.
- **Component Name:** N8nBadge
- **Figma Component:** TBD
- **Current Implementation:** Custom component (no Element+ dependency)
- **Reka UI Component:** N/A (Reka UI does not provide a Badge primitive)
- **Nuxt UI Reference:** [Badge](https://ui.nuxt.com/docs/components/badge) (custom implementation, not based on Reka UI)
## Public API Definition
**Props**
- `theme?: BadgeTheme` - Visual style variant for the badge. Values: `'default' | 'success' | 'warning' | 'danger' | 'primary' | 'secondary' | 'tertiary'`. Default: `'default'`
- `default`: Subtle border badge with neutral styling
- `success`: Green indicator for positive states
- `warning`: Yellow/orange indicator for cautionary states
- `danger`: Red indicator for error/critical states
- `primary`: Filled badge with contrasting text (pill-shaped)
- `secondary`: Filled badge with tinted background
- `tertiary`: Minimal badge variant with reduced padding
- `size?: TextSize` - Size of the badge text. Values: `'xsmall' | 'small' | 'mini' | 'medium' | 'large' | 'xlarge'`. Default: `'small'`
- `bold?: boolean` - Whether to render text in bold weight. Default: `false`
- `showBorder?: boolean` - Whether to show the badge border. Default: `true`
**Slots**
- `default` - Badge content (text, icons, or mixed content). Wrapped in N8nText component for consistent typography.
### Template usage examples
**Simple text badge:**
```typescript
<script setup lang="ts">
import { N8nBadge } from '@n8n/design-system'
</script>
<template>
<N8nBadge theme="default">
Read Only
</N8nBadge>
</template>
```
**Status indicators:**
```typescript
<script setup lang="ts">
import { N8nBadge } from '@n8n/design-system'
import { computed } from 'vue'
const status = ref<'success' | 'warning' | 'danger'>('success')
const statusTheme = computed(() => {
const themeMap = {
success: 'success',
warning: 'warning',
danger: 'danger',
}
return themeMap[status.value]
})
</script>
<template>
<N8nBadge :theme="statusTheme">
{{ status }}
</N8nBadge>
</template>
```
**Count badge (primary theme):**
```typescript
<script setup lang="ts">
import { N8nBadge } from '@n8n/design-system'
const filterCount = ref(5)
</script>
<template>
<N8nBadge theme="primary">
{{ filterCount }}
</N8nBadge>
</template>
```
**Tertiary minimal label:**
```typescript
<script setup lang="ts">
import { N8nBadge } from '@n8n/design-system'
</script>
<template>
<N8nBadge theme="tertiary" :bold="true">
Pending
</N8nBadge>
</template>
```
**Badge with icon and text:**
```typescript
<script setup lang="ts">
import { N8nBadge } from '@n8n/design-system'
import ProjectIcon from './ProjectIcon.vue'
const projectBadge = {
icon: 'project',
text: 'Team Project'
}
</script>
<template>
<N8nBadge
theme="tertiary"
:show-border="false"
>
<ProjectIcon :icon="projectBadge.icon" size="mini" />
<span>{{ projectBadge.text }}</span>
</N8nBadge>
</template>
```
**Multiple sizes:**
```typescript
<script setup lang="ts">
import { N8nBadge } from '@n8n/design-system'
</script>
<template>
<N8nBadge theme="primary" size="xsmall">XS</N8nBadge>
<N8nBadge theme="primary" size="small">Small</N8nBadge>
<N8nBadge theme="primary" size="medium">Medium</N8nBadge>
</template>
```
**With custom CSS classes:**
```typescript
<script setup lang="ts">
import { N8nBadge } from '@n8n/design-system'
</script>
<template>
<N8nBadge class="ml-3xs" theme="warning" :bold="true">
Action Required
</N8nBadge>
</template>
```
**Conditional rendering:**
```typescript
<script setup lang="ts">
import { N8nBadge } from '@n8n/design-system'
const isReadOnly = ref(true)
const needsSetup = ref(false)
</script>
<template>
<N8nBadge v-if="isReadOnly" theme="tertiary" :bold="true">
Read Only
</N8nBadge>
<N8nBadge v-if="needsSetup" theme="warning">
Setup Required
</N8nBadge>
</template>
```
**With inline styles (advanced):**
```typescript
<script setup lang="ts">
import { N8nBadge } from '@n8n/design-system'
</script>
<template>
<N8nBadge
theme="warning"
style="height: 25px"
>
Custom Height
</N8nBadge>
</template>
```
**Dynamic theme binding:**
```typescript
<script setup lang="ts">
import { N8nBadge } from '@n8n/design-system'
import type { BadgeTheme } from '@n8n/design-system/types'
interface FileStatus {
status: 'completed' | 'pending' | 'failed'
}
const file = ref<FileStatus>({ status: 'completed' })
const getStatusTheme = (status: string): BadgeTheme => {
const themeMap: Record<string, BadgeTheme> = {
completed: 'success',
pending: 'warning',
failed: 'danger',
}
return themeMap[status] || 'default'
}
const statusLabel = (status: string) => {
const labelMap: Record<string, string> = {
completed: 'Completed',
pending: 'Pending',
failed: 'Failed',
}
return labelMap[status] || status
}
</script>
<template>
<N8nBadge :theme="getStatusTheme(file.status)">
{{ statusLabel(file.status) }}
</N8nBadge>
</template>
```

View File

@ -0,0 +1,148 @@
# Component specification
Displays contextual information when users hover over, focus on, or tap an element. Tooltips help users understand the purpose or state of UI elements without cluttering the interface.
- **Component Name:** N8nTooltip
- **Figma Component:** [Figma](https://www.figma.com/design/8zib7Trf2D2CHYXrEGPHkg/n8n-Design-System-V3?node-id=252-3284&m=dev)
- **Element+ Component:** [ElTooltip](https://element-plus.org/en-US/component/tooltip.html)
- **Reka UI Component:** [Tooltip](https://reka-ui.com/docs/components/tooltip)
- **Nuxt UI Component:** [Tooltip](https://ui.nuxt.com/docs/components/tooltip)
## Public API Definition
**Props**
- `content?: string` - Text content for the tooltip. Supports HTML (sanitized for security).
- `placement?: Placement` - Position of tooltip relative to trigger. Values: `'top' | 'top-start' | 'top-end' | 'bottom' | 'bottom-start' | 'bottom-end' | 'left' | 'left-start' | 'left-end' | 'right' | 'right-start' | 'right-end'`. Default: `'top'`
- `disabled?: boolean` - When `true`, prevents the tooltip from showing.
- `showAfter?: number` - Delay in milliseconds before showing tooltip after trigger is hovered. Default: `0`
- `visible?: boolean` - Manual control of tooltip visibility (programmatic show/hide).
- `popperClass?: string` - Custom CSS class name for the tooltip popper element.
- `enterable?: boolean` - Whether the mouse can enter the tooltip content area. Default: `true`
- `popperOptions?: object` - Popper.js configuration object for advanced positioning control.
- `teleported?: boolean` - Whether to append the tooltip to the body. Default: `true`
- `offset?: number` - Offset of the tooltip from the trigger element (in pixels).
- `showArrow?: boolean` - Whether to show the tooltip arrow. Default: `true`
**Slots**
- `default` - The trigger element that activates the tooltip (required).
- `content` - Custom content for the tooltip body. When not provided, renders `content` prop with HTML sanitization.
### Template usage example
**Simple text tooltip:**
```typescript
<script setup lang="ts">
import { N8nTooltip, N8nIcon } from '@n8n/design-system'
</script>
<template>
<N8nTooltip content="This is helpful information" placement="top">
<N8nIcon icon="info" />
</N8nTooltip>
</template>
```
**Custom content slot:**
```typescript
<script setup lang="ts">
import { N8nTooltip, N8nButton } from '@n8n/design-system'
</script>
<template>
<N8nTooltip placement="right">
<template #content>
<div class="custom-tooltip-content">
<strong>Advanced Feature</strong>
<p>This feature requires a pro plan.</p>
</div>
</template>
<N8nButton label="Hover for details" />
</N8nTooltip>
</template>
```
**Delayed tooltip:**
```typescript
<script setup lang="ts">
import { N8nTooltip } from '@n8n/design-system'
</script>
<template>
<N8nTooltip
content="Appears after 500ms"
:show-after="500"
placement="top"
>
<span>Hover and wait...</span>
</N8nTooltip>
</template>
```
**Programmatically controlled visibility:**
```typescript
<script setup lang="ts">
import { ref } from 'vue'
import { N8nTooltip } from '@n8n/design-system'
const isVisible = ref(false)
const showTooltip = () => {
isVisible.value = true
setTimeout(() => {
isVisible.value = false
}, 2000)
}
</script>
<template>
<N8nTooltip
:visible="isVisible"
content="This tooltip is programmatically controlled"
placement="top"
>
<button @click="showTooltip">
Click to show tooltip for 2 seconds
</button>
</N8nTooltip>
</template>
```
**Custom popper class:**
```typescript
<script setup lang="ts">
import { N8nTooltip } from '@n8n/design-system'
</script>
<template>
<N8nTooltip
content="Styled tooltip"
popper-class="custom-tooltip-class"
placement="top"
>
<span>Hover for custom styled tooltip</span>
</N8nTooltip>
</template>
```
**Disabled tooltip:**
```typescript
<script setup lang="ts">
import { ref } from 'vue'
import { N8nTooltip } from '@n8n/design-system'
const showTooltip = ref(false)
</script>
<template>
<N8nTooltip
:disabled="!showTooltip"
content="Conditionally shown"
placement="top"
>
<span>{{ showTooltip ? 'Tooltip enabled' : 'Tooltip disabled' }}</span>
</N8nTooltip>
</template>
```

View File

@ -62,6 +62,7 @@
"generic.folder": "Folder",
"generic.keepBuilding": "Keep building",
"generic.learnMore": "Learn more",
"generic.recommended": "Recommended",
"generic.reset": "Reset",
"generic.resetAllFilters": "Reset all filters",
"generic.communityNode": "Community Node",
@ -1486,7 +1487,6 @@
"nodeCreator.triggerHelperPanel.manualTriggerDescription": "Runs the flow on clicking a button in n8n. Good for getting started quickly",
"nodeCreator.triggerHelperPanel.manualChatTriggerDisplayName": "On chat message",
"nodeCreator.triggerHelperPanel.manualChatTriggerDescription": "Runs the flow when a user sends a chat message. For use with AI nodes",
"nodeCreator.triggerHelperPanel.manualTriggerTag": "Recommended",
"nodeCreator.triggerHelperPanel.chatTriggerDisplayName": "On chat message",
"nodeCreator.triggerHelperPanel.chatTriggerDescription": "Runs the flow when a user sends a chat message. For use with AI nodes",
"nodeCreator.triggerHelperPanel.whatHappensNext": "What happens next?",
@ -1773,7 +1773,7 @@
"nodeWebhooks.webhookUrls.mcpTrigger": "MCP URL",
"openWorkflow.workflowImportError": "Could not import workflow",
"openWorkflow.workflowNotFoundError": "Could not find workflow",
"oauth.consentView.title": "oAuth access consent",
"oauth.consentView.title": "OAuth access consent",
"oauth.consentView.heading": "{clientName} wants access to your n8n instance",
"oauth.consentView.description": "This will allow {clientName} to perform the following actions:",
"oauth.consentView.action.listWorkflows": "Get a list of your workflows",
@ -2254,7 +2254,7 @@
"settings.mcp.empty.description": "Enable MCP access in each workflow's settings to see them here.",
"settings.mcp.toggle.disabled.tooltip": "Only instance admins can change this",
"settings.mcp.toggle.error": "Error updating MCP access",
"settings.mcp.instructions.tabs.oauth": "oAuth",
"settings.mcp.instructions.tabs.oauth": "OAuth",
"settings.mcp.instructions.tabs.apiKey": "Access Token",
"settings.mcp.instructions.enableAccess": "Enable workflow access in at least one workflow via its settings",
"settings.mcp.instructions.serverUrl": "Server URL",
@ -2268,7 +2268,7 @@
"settings.mcp.error.fetching.apiKey": "Error fetching access token",
"settings.mcp.error.rotating.apiKey": "Error generating new access token",
"settings.mcp.error.fetching.oAuthClients": "Error fetching list of OAuth clients",
"settings.mcp.oAuthClients.heading": "Connected oAuth clients",
"settings.mcp.oAuthClients.heading": "Connected OAuth clients",
"settings.mcp.oAuthClients.table.clientName": "Client Name",
"settings.mcp.oAuthClients.table.connectedAt": "Connected At",
"settings.mcp.oAuthClients.table.lastUsedAt": "Last Used At",
@ -2276,7 +2276,7 @@
"settings.mcp.oAuthClients.revoke.success.title": "Access revoked",
"settings.mcp.oAuthClients.revoke.success.message": "Client {name} access has been revoked",
"settings.mcp.oAuthClients.revoke.error": "Error revoking client access",
"settings.mcp.oAuthClients.table.empty.title": "No oAuth clients connected",
"settings.mcp.oAuthClients.table.empty.title": "No OAuth clients connected",
"settings.mcp.refresh.tooltip": "Refresh list",
"settings.mcp.workflowsTable.workflow": "Workflow",
"settings.goBack": "Go back",
@ -2509,12 +2509,24 @@
"settings.provisioning.scopesProjectsRolesClaimName.help": "The claim name used to provision projects and their roles from Oauth. For SAML / LDAP, this will be the attribute name checked.",
"settings.provisioning.toggle": "Provision instance and project roles",
"settings.provisioning.toggle.help": "Project access can only be defined on external provider. Any existing project access configured in n8n, but not on the provider, will be removed once a user logs in.",
"settings.provisioningConfirmDialog.title": "Enable Just-in-time provisioning (JIT)",
"settings.provisioningConfirmDialog.enable.title": "Enable user role provisioning",
"settings.provisioningConfirmDialog.disable.title": "Disable user role provisioning",
"settings.provisioningConfirmDialog.breakingChangeDescription.firstLine": "When you enable Just-in-time provisioning, your external SSO provider becomes the source of truth for all instance and project roles in n8n.",
"settings.provisioningConfirmDialog.breakingChangeDescription.list.one": "If your SSO provider doesn't specify a role for a member, we'll automatically assign the default role: global:member.",
"settings.provisioningConfirmDialog.breakingChangeDescription.list.two": "Any existing instance and project roles in n8n will be replaced by the roles defined in your SSO provider once the user logs in via SSO.",
"settings.provisioningConfirmDialog.breakingChangeRequiredSteps": "To enable you to migrate your current access settings to your SSO provider, download the two CSV files below. This step is mandatory before enabling JIT.",
"settings.provisioningConfirmDialog.button.confirm": "Activate JIT",
"settings.provisioningConfirmDialog.disable.description": "You're switching instance role management back to n8n.",
"settings.provisioningConfirmDialog.disable.whatWillHappen": "What will happen:",
"settings.provisioningConfirmDialog.disable.list.one": "The SSO n8n_instance_role attribute will be ignored.",
"settings.provisioningConfirmDialog.disable.list.two": "Instance roles must be reassigned manually inside n8n.",
"settings.provisioningConfirmDialog.disable.beforeSaving": "Before saving, make sure:",
"settings.provisioningConfirmDialog.disable.checklist.one": "You are ready to reassign instance roles for all users inside n8n.",
"settings.provisioningConfirmDialog.disable.checklist.two": "You understand that role changes made in SSO will no longer be applied.",
"settings.provisioningConfirmDialog.enable.checkbox": "I have downloaded and reviewed the CSV export. My SSO provider is correctly configured to become the source of truth for user role provisioning on this n8n instance.",
"settings.provisioningConfirmDialog.disable.checkbox": "I confirm that I want to no longer provision user roles from my SSO provider.",
"settings.provisioningConfirmDialog.link.docs": "Link to docs",
"settings.provisioningConfirmDialog.button.enable.confirm": "Save and enable",
"settings.provisioningConfirmDialog.button.disable.confirm": "Save and disable",
"settings.provisioningConfirmDialog.button.cancel": "Cancel",
"settings.provisioningConfirmDialog.button.generateCsvExport": "Generate access settings CSV export",
"settings.provisioningConfirmDialog.button.downloadProjectRolesCsv": "Download existing project access settings csv",
@ -3442,6 +3454,15 @@
"settings.sso.settings.oidc.prompt.consent": "Consent (Ask the user to consent)",
"settings.sso.settings.oidc.prompt.select_account": "Select Account (Allow the user to select an account)",
"settings.sso.settings.oidc.prompt.create": "Create (Ask the OP to show the registration page first)",
"settings.sso.settings.userRoleProvisioning.label": "User role provisioning",
"settings.sso.settings.userRoleProvisioning.help": "Manage instance and project roles from your SSO provider.",
"settings.sso.settings.userRoleProvisioning.help.linkText": "Link to docs",
"settings.sso.settings.userRoleProvisioning.option.disabled.label": "Disabled",
"settings.sso.settings.userRoleProvisioning.option.disabled.description": "User and project roles are managed inside the n8n settings.",
"settings.sso.settings.userRoleProvisioning.option.instanceRole.label": "Instance role",
"settings.sso.settings.userRoleProvisioning.option.instanceRole.description": "The instance role of a user is configured in the \"n8n_instance_role\" attribute on your SSO provider. If none is set on the SSO provider, the member role is used as fallback.",
"settings.sso.settings.userRoleProvisioning.option.instanceAndProjectRoles.label": "Instance and project roles",
"settings.sso.settings.userRoleProvisioning.option.instanceAndProjectRoles.description": "The list of projects a user has access to is configured on the \"n8n_projects\" string array attribute on your SSO provider. Project access cannot be granted from within n8n.",
"settings.sso.settings.test": "Test settings",
"settings.sso.settings.save": "Save settings",
"settings.sso.settings.save.activate.title": "Test and activate SAML SSO",

View File

@ -444,7 +444,7 @@ export type SimplifiedNodeType = Pick<
| 'defaults'
| 'outputs'
> & {
tag?: string;
tag?: NodeCreatorTag;
};
export interface SubcategoryItemProps {
description?: string;

View File

@ -3,9 +3,9 @@ import AssistantsHub from '@/features/ai/assistant/components/AssistantsHub.vue'
import AskAssistantFloatingButton from '@/features/ai/assistant/components/Chat/AskAssistantFloatingButton.vue';
import BannerStack from '@/features/shared/banners/components/BannerStack.vue';
import Modals from '@/app/components/Modals.vue';
import Telemetry from '@/app/components/Telemetry.vue';
import { useHistoryHelper } from '@/app/composables/useHistoryHelper';
import { useTelemetryContext } from '@/app/composables/useTelemetryContext';
import { useTelemetryInitializer } from '@/app/composables/useTelemetryInitializer';
import { useWorkflowDiffRouting } from '@/app/composables/useWorkflowDiffRouting';
import {
APP_MODALS_ELEMENT_ID,
@ -63,6 +63,8 @@ useHistoryHelper(route);
// Initialize workflow diff routing management
useWorkflowDiffRouting();
useTelemetryInitializer();
const loading = ref(true);
const defaultLocale = computed(() => rootStore.defaultLocale);
const isDemoMode = computed(() => route.name === VIEWS.DEMO);
@ -181,7 +183,6 @@ useExposeCssVar('--ask-assistant--floating-button--margin-bottom', askAiFloating
@input-change="onCommandBarChange"
@navigate-to="onCommandBarNavigateTo"
/>
<Telemetry />
<AskAssistantFloatingButton v-if="assistantStore.isFloatingButtonShown" />
</div>
<AssistantsHub />

View File

@ -47,7 +47,7 @@ import type { FolderShortInfo } from '@/features/core/folders/folders.types';
import { useFoldersStore } from '@/features/core/folders/folders.store';
import { useNpsSurveyStore } from '@/app/stores/npsSurvey.store';
import { ProjectTypes } from '@/features/collaboration/projects/projects.types';
import { sanitizeFilename } from '@/app/utils/fileUtils';
import { sanitizeFilename } from '@n8n/utils/files/sanitize';
import { hasPermission } from '@/app/utils/rbac/permissions';
import type { PathItem } from '@n8n/design-system/components/N8nBreadcrumbs/Breadcrumbs.vue';
import { type BaseTextKey, useI18n } from '@n8n/i18n';

View File

@ -1,7 +1,6 @@
<script lang="ts" setup>
import { useUserHelpers } from '@/app/composables/useUserHelpers';
import { ABOUT_MODAL_KEY, SSO_JUST_IN_TIME_PROVSIONING_EXPERIMENT, VIEWS } from '@/app/constants';
import { usePostHog } from '@/app/stores/posthog.store';
import { ABOUT_MODAL_KEY, VIEWS } from '@/app/constants';
import { useSettingsStore } from '@/app/stores/settings.store';
import { useUIStore } from '@/app/stores/ui.store';
import { hasPermission } from '@/app/utils/rbac/permissions';
@ -22,7 +21,6 @@ const { canUserAccessRouteByName } = useUserHelpers(router);
const rootStore = useRootStore();
const settingsStore = useSettingsStore();
const posthogStore = usePostHog();
const uiStore = useUIStore();
const sidebarMenuItems = computed<IMenuItem[]>(() => {
@ -99,17 +97,6 @@ const sidebarMenuItems = computed<IMenuItem[]>(() => {
available: canUserAccessRouteByName(VIEWS.LDAP_SETTINGS),
route: { to: { name: VIEWS.LDAP_SETTINGS } },
},
{
id: 'settings-provisioning',
icon: 'toolbox',
label: i18n.baseText('settings.provisioning.title'),
position: 'top',
available:
canUserAccessRouteByName(VIEWS.PROVISIONING_SETTINGS) &&
// TODO: comment this back one once posthog experiment is done: settingsStore.isEnterpriseFeatureEnabled.provisioning,
posthogStore.isFeatureEnabled(SSO_JUST_IN_TIME_PROVSIONING_EXPERIMENT.name),
route: { to: { name: VIEWS.PROVISIONING_SETTINGS } },
},
{
id: 'settings-workersview',
icon: 'waypoints',

View File

@ -1,127 +0,0 @@
import { useRoute } from 'vue-router';
import { createTestingPinia } from '@pinia/testing';
import { createComponentRenderer } from '@/__tests__/render';
import type { MockedStore } from '@/__tests__/utils';
import { mockedStore } from '@/__tests__/utils';
import Telemetry from './Telemetry.vue';
import { useRootStore } from '@n8n/stores/useRootStore';
import { useSettingsStore } from '@/app/stores/settings.store';
import { useUsersStore } from '@/features/settings/users/users.store';
import { useTelemetry } from '@/app/composables/useTelemetry';
vi.mock('vue-router', () => {
const meta = {};
return {
useRouter: vi.fn(),
useRoute: () => ({
meta,
}),
RouterLink: {
template: '<a><slot /></a>',
},
};
});
vi.mock('@/app/composables/useTelemetry', () => {
const init = vi.fn();
return {
useTelemetry: () => ({
init,
}),
};
});
const renderComponent = createComponentRenderer(Telemetry, {
pinia: createTestingPinia(),
});
let route: ReturnType<typeof useRoute>;
let rootStore: MockedStore<typeof useRootStore>;
let settingsStore: MockedStore<typeof useSettingsStore>;
let usersStore: MockedStore<typeof useUsersStore>;
let telemetryPlugin: ReturnType<typeof useTelemetry>;
describe('Telemetry', () => {
beforeEach(() => {
vi.clearAllMocks();
route = useRoute();
rootStore = mockedStore(useRootStore);
settingsStore = mockedStore(useSettingsStore);
usersStore = mockedStore(useUsersStore);
telemetryPlugin = useTelemetry();
});
it('should not throw error when opened', async () => {
expect(() => renderComponent()).not.toThrow();
});
it('should initialize if telemetry is enabled in settings and not disabled on the route', async () => {
settingsStore.telemetry = {
enabled: true,
};
usersStore.currentUserId = '123';
rootStore.instanceId = '456';
renderComponent();
expect(telemetryPlugin.init).toHaveBeenCalledWith(
{
enabled: true,
},
expect.objectContaining({
userId: '123',
instanceId: '456',
}),
);
});
it('should not initialize if telemetry is disabled in settings', async () => {
settingsStore.telemetry = {
enabled: false,
};
renderComponent();
expect(telemetryPlugin.init).not.toHaveBeenCalled();
});
it('should not initialize if telemetry is disabled on the route', async () => {
settingsStore.telemetry = {
enabled: true,
};
route.meta.telemetry = {
disabled: true,
};
renderComponent();
expect(telemetryPlugin.init).not.toHaveBeenCalled();
});
it('should render the iframe with correct src', async () => {
settingsStore.telemetry = {
enabled: true,
};
usersStore.currentUserId = '123';
rootStore.instanceId = '456';
const { container } = renderComponent();
const iframe = container.querySelector('iframe');
expect(iframe).toBeInTheDocument();
expect(iframe).not.toBeVisible();
expect(iframe).toHaveAttribute('src', expect.stringContaining('userId=123'));
expect(iframe).toHaveAttribute('src', expect.stringContaining('instanceId=456'));
});
it('should not render the iframe if telemetry disabled', async () => {
settingsStore.telemetry = {
enabled: false,
};
usersStore.currentUserId = '123';
rootStore.instanceId = '456';
const { container } = renderComponent();
const iframe = container.querySelector('iframe');
expect(iframe).not.toBeInTheDocument();
});
});

View File

@ -1,73 +0,0 @@
<script lang="ts" setup>
import type { ITelemetrySettings } from '@n8n/api-types';
import { useRootStore } from '@n8n/stores/useRootStore';
import { useSettingsStore } from '@/app/stores/settings.store';
import { useUsersStore } from '@/features/settings/users/users.store';
import { useProjectsStore } from '@/features/collaboration/projects/projects.store';
import { computed, onMounted, watch, ref } from 'vue';
import { useTelemetry } from '@/app/composables/useTelemetry';
import { useRoute } from 'vue-router';
const isTelemetryInitialized = ref(false);
const rootStore = useRootStore();
const settingsStore = useSettingsStore();
const usersStore = useUsersStore();
const projectsStore = useProjectsStore();
const telemetryPlugin = useTelemetry();
const route = useRoute();
const currentUserId = computed((): string => {
return usersStore.currentUserId ?? '';
});
const isTelemetryEnabledOnRoute = computed((): boolean => {
const routeMeta = route.meta as { telemetry?: { disabled?: boolean } } | undefined;
return routeMeta?.telemetry ? !routeMeta.telemetry.disabled : true;
});
const telemetry = computed((): ITelemetrySettings => {
return settingsStore.telemetry;
});
const isTelemetryEnabled = computed((): boolean => {
return !!telemetry.value?.enabled;
});
const selfInstallSrc = computed((): string => {
return `https://n8n.io/self-install?instanceId=${rootStore.instanceId}&userId=${currentUserId.value}`;
});
watch(telemetry, () => {
init();
});
watch(isTelemetryEnabledOnRoute, (enabled) => {
if (enabled) {
init();
}
});
onMounted(() => {
init();
});
function init() {
if (isTelemetryInitialized.value || !isTelemetryEnabledOnRoute.value || !isTelemetryEnabled.value)
return;
telemetryPlugin.init(telemetry.value, {
instanceId: rootStore.instanceId,
userId: currentUserId.value,
projectId: projectsStore.personalProject?.id,
versionCli: rootStore.versionCli,
});
isTelemetryInitialized.value = true;
}
</script>
<template>
<iframe v-if="isTelemetryEnabled && currentUserId" v-show="false" :src="selfInstallSrc" />
<span v-else v-show="false" />
</template>

View File

@ -319,7 +319,7 @@ const icons = {
object: DATA_TYPE_ICON_MAP.object,
array: DATA_TYPE_ICON_MAP.array,
['string']: DATA_TYPE_ICON_MAP.string,
null: 'case-upper',
null: 'square-minus',
['number']: DATA_TYPE_ICON_MAP.number,
['boolean']: DATA_TYPE_ICON_MAP.boolean,
function: 'code',

View File

@ -0,0 +1,140 @@
import { createTestingPinia } from '@pinia/testing';
import { mount } from '@vue/test-utils';
import { defineComponent, h, nextTick } from 'vue';
import { mock } from 'vitest-mock-extended';
import { useTelemetryInitializer } from './useTelemetryInitializer';
import { useTelemetry } from '@/app/composables/useTelemetry';
import type { Project } from '@/features/collaboration/projects/projects.types';
const mockRouteMeta: Record<string, unknown> = {};
vi.mock('vue-router', () => ({
useRouter: vi.fn(),
useRoute: () => ({
meta: mockRouteMeta,
}),
RouterLink: {
template: '<a><slot /></a>',
},
}));
vi.mock('@/app/composables/useTelemetry', () => {
const init = vi.fn();
return {
useTelemetry: () => ({
init,
}),
};
});
const TestComponent = defineComponent({
setup() {
useTelemetryInitializer();
return () => h('div');
},
});
describe('useTelemetryInitializer', () => {
beforeEach(() => {
vi.clearAllMocks();
// Clear route meta
Object.keys(mockRouteMeta).forEach((key) => delete mockRouteMeta[key]);
});
it('should not throw error when called', () => {
expect(() =>
mount(TestComponent, { global: { plugins: [createTestingPinia()] } }),
).not.toThrow();
});
it('should initialize if telemetry is enabled in settings and not disabled on the route', async () => {
const pinia = createTestingPinia({
stubActions: false,
initialState: {
users: {
currentUserId: '123',
},
projects: {
personalProject: mock<Project>({ id: '789' }),
},
settings: {
settings: {
telemetry: {
enabled: true,
},
},
},
},
});
const telemetryPlugin = useTelemetry();
const wrapper = mount(TestComponent, { global: { plugins: [pinia] } });
await nextTick();
expect(telemetryPlugin.init).toHaveBeenCalledWith(
{
enabled: true,
},
expect.objectContaining({
userId: '123',
projectId: '789',
}),
);
wrapper.unmount();
});
it('should not initialize if telemetry is disabled in settings', async () => {
const pinia = createTestingPinia({
stubActions: false,
initialState: {
settings: {
settings: {
telemetry: {
enabled: false,
},
},
},
},
});
const telemetryPlugin = useTelemetry();
const wrapper = mount(TestComponent, { global: { plugins: [pinia] } });
await nextTick();
expect(telemetryPlugin.init).not.toHaveBeenCalled();
wrapper.unmount();
});
it('should not initialize if telemetry is disabled on the route', async () => {
// Set route meta before mounting
mockRouteMeta.telemetry = {
disabled: true,
};
const pinia = createTestingPinia({
stubActions: false,
initialState: {
settings: {
settings: {
telemetry: {
enabled: true,
},
},
},
},
});
const telemetryPlugin = useTelemetry();
const wrapper = mount(TestComponent, { global: { plugins: [pinia] } });
await nextTick();
expect(telemetryPlugin.init).not.toHaveBeenCalled();
wrapper.unmount();
});
});

View File

@ -0,0 +1,71 @@
import type { ITelemetrySettings } from '@n8n/api-types';
import { useRootStore } from '@n8n/stores/useRootStore';
import { useSettingsStore } from '@/app/stores/settings.store';
import { useUsersStore } from '@/features/settings/users/users.store';
import { useProjectsStore } from '@/features/collaboration/projects/projects.store';
import { computed, onMounted, watch, ref } from 'vue';
import { useTelemetry } from '@/app/composables/useTelemetry';
import { useRoute } from 'vue-router';
/**
* Initializes the telemetry for the application
*/
export function useTelemetryInitializer() {
const isTelemetryInitialized = ref(false);
const rootStore = useRootStore();
const settingsStore = useSettingsStore();
const usersStore = useUsersStore();
const projectsStore = useProjectsStore();
const telemetryPlugin = useTelemetry();
const route = useRoute();
const currentUserId = computed((): string => {
return usersStore.currentUserId ?? '';
});
const isTelemetryEnabledOnRoute = computed((): boolean => {
const routeMeta = route.meta as { telemetry?: { disabled?: boolean } } | undefined;
return routeMeta?.telemetry ? !routeMeta.telemetry.disabled : true;
});
const telemetry = computed((): ITelemetrySettings => {
return settingsStore.telemetry;
});
const isTelemetryEnabled = computed((): boolean => {
return !!telemetry.value?.enabled;
});
function init() {
if (
isTelemetryInitialized.value ||
!isTelemetryEnabledOnRoute.value ||
!isTelemetryEnabled.value
)
return;
telemetryPlugin.init(telemetry.value, {
instanceId: rootStore.instanceId,
userId: currentUserId.value,
projectId: projectsStore.personalProject?.id,
versionCli: rootStore.versionCli,
});
isTelemetryInitialized.value = true;
}
watch(telemetry, () => {
init();
});
watch(isTelemetryEnabledOnRoute, (enabled) => {
if (enabled) {
init();
}
});
onMounted(() => {
init();
});
}

View File

@ -36,7 +36,6 @@ export const enum VIEWS {
LOG_STREAMING_SETTINGS = 'LogStreamingSettingsView',
SSO_SETTINGS = 'SSoSettings',
EXTERNAL_SECRETS_SETTINGS = 'ExternalSecretsSettings',
PROVISIONING_SETTINGS = 'ProvisioningSettings',
SAML_ONBOARDING = 'SamlOnboarding',
SOURCE_CONTROL = 'SourceControl',
MFA_VIEW = 'MfaView',

View File

@ -1,4 +1,5 @@
import type { NodeCreatorOpenSource } from '@/Interface';
import { DATA_TABLE_NODE_TYPE, DATA_TABLE_TOOL_NODE_TYPE } from './nodeTypes';
export const TEMPLATE_CATEGORY_AI = 'categories/ai';
@ -57,3 +58,6 @@ export const AI_CODE_TOOL_LANGCHAIN_NODE_TYPE = '@n8n/n8n-nodes-langchain.toolCo
export const AI_WORKFLOW_TOOL_LANGCHAIN_NODE_TYPE = '@n8n/n8n-nodes-langchain.toolWorkflow';
export const REQUEST_NODE_FORM_URL = 'https://n8n-community.typeform.com/to/K1fBVTZ3';
export const PRE_BUILT_AGENTS_COLLECTION = 'pre-built-agents-collection';
export const RECOMMENDED_NODES: string[] = [DATA_TABLE_NODE_TYPE, DATA_TABLE_TOOL_NODE_TYPE];
export const BETA_NODES: string[] = [];

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