mirror of
https://github.com/n8n-io/n8n.git
synced 2025-11-20 17:46:34 +00:00
Merge branch 'master' into cha-add-new-providers-3
This commit is contained in:
commit
b62ac45679
119
.github/scripts/determine-runners-tags.sh
vendored
119
.github/scripts/determine-runners-tags.sh
vendored
@ -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
171
.github/scripts/docker/docker-config.mjs
vendored
Normal 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
113
.github/scripts/docker/docker-tags.mjs
vendored
Normal 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;
|
||||
2
.github/workflows/ci-postgres-mysql.yml
vendored
2
.github/workflows/ci-postgres-mysql.yml
vendored
@ -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
|
||||
|
||||
453
.github/workflows/docker-build-push.yml
vendored
453
.github/workflows/docker-build-push.yml
vendored
@ -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' ||
|
||||
|
||||
@ -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
|
||||
# ==============================================================================
|
||||
|
||||
@ -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
|
||||
# ==============================================================================
|
||||
|
||||
@ -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
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
@ -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",
|
||||
|
||||
@ -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>;
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@ -27,10 +27,13 @@ export {
|
||||
emptyChatModelsResponse,
|
||||
type ChatModelsRequest,
|
||||
type ChatModelsResponse,
|
||||
chatAttachmentSchema,
|
||||
type ChatAttachment,
|
||||
ChatHubSendMessageRequest,
|
||||
ChatHubRegenerateMessageRequest,
|
||||
ChatHubEditMessageRequest,
|
||||
ChatHubUpdateConversationRequest,
|
||||
ChatHubConversationsRequest,
|
||||
type ChatMessageId,
|
||||
type ChatSessionId,
|
||||
type ChatHubMessageDto,
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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']);
|
||||
}
|
||||
}
|
||||
@ -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;`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
];
|
||||
|
||||
@ -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,
|
||||
];
|
||||
|
||||
@ -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 };
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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');
|
||||
});
|
||||
});
|
||||
@ -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);
|
||||
};
|
||||
@ -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>;
|
||||
@ -0,0 +1,5 @@
|
||||
export {
|
||||
ContextEstablishmentHookMetadata,
|
||||
ContextEstablishmentHook,
|
||||
} from './context-establishment-hook-metadata';
|
||||
export type * from './context-establishment-hook';
|
||||
@ -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",
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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": [
|
||||
|
||||
@ -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"
|
||||
},
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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', () => {
|
||||
87
packages/@n8n/utils/src/files/sanitize.ts
Normal file
87
packages/@n8n/utils/src/files/sanitize.ts
Normal 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;
|
||||
};
|
||||
@ -7,3 +7,4 @@ export * from './search/reRankSearchResults';
|
||||
export * from './search/sublimeSearch';
|
||||
export * from './sort/sortByProperty';
|
||||
export * from './string/truncate';
|
||||
export * from './files/sanitize';
|
||||
|
||||
@ -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?
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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`,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@ -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 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@ -32,6 +32,7 @@ export class ChatHubAgentService {
|
||||
},
|
||||
createdAt: agent.createdAt.toISOString(),
|
||||
updatedAt: agent.updatedAt.toISOString(),
|
||||
allowFileUploads: true,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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: {
|
||||
|
||||
160
packages/cli/src/modules/chat-hub/chat-hub.attachment.service.ts
Normal file
160
packages/cli/src/modules/chat-hub/chat-hub.attachment.service.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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 */
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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(),
|
||||
});
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -141,6 +141,8 @@ export const maxContextWindowTokens: Record<ChatHubLLMProvider, Record<string, n
|
||||
ollama: {},
|
||||
awsBedrock: {},
|
||||
deepSeek: {},
|
||||
cohere: {},
|
||||
mistralCloud: {},
|
||||
};
|
||||
|
||||
export const getMaxContextWindowTokens = (
|
||||
|
||||
@ -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();
|
||||
});
|
||||
|
||||
|
||||
@ -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');
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -15,7 +15,7 @@ properties:
|
||||
example: John's Github account
|
||||
type:
|
||||
type: string
|
||||
example: github
|
||||
example: githubApi
|
||||
createdAt:
|
||||
type: string
|
||||
format: date-time
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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,
|
||||
);
|
||||
|
||||
@ -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,
|
||||
}));
|
||||
|
||||
@ -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[]) {
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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()}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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>;
|
||||
|
||||
@ -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,
|
||||
}),
|
||||
};
|
||||
|
||||
@ -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,
|
||||
);
|
||||
});
|
||||
|
||||
@ -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 };
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -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,
|
||||
);
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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') }}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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>
|
||||
```
|
||||
@ -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>
|
||||
```
|
||||
@ -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",
|
||||
|
||||
@ -444,7 +444,7 @@ export type SimplifiedNodeType = Pick<
|
||||
| 'defaults'
|
||||
| 'outputs'
|
||||
> & {
|
||||
tag?: string;
|
||||
tag?: NodeCreatorTag;
|
||||
};
|
||||
export interface SubcategoryItemProps {
|
||||
description?: string;
|
||||
|
||||
@ -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 />
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
@ -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>
|
||||
@ -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',
|
||||
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
@ -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();
|
||||
});
|
||||
}
|
||||
@ -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',
|
||||
|
||||
@ -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
Loading…
Reference in New Issue
Block a user