feat(core): Use the agent's icon in the Microsoft Teams app package (no-changelog) (#40322)

This commit is contained in:
Bernhard Wittmann 2026-10-16 08:14:01 +00:00 committed by GitHub
parent 1ea18e9ef9
commit cc83b5bd77
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 858 additions and 131 deletions

View File

@ -43,7 +43,8 @@
"validator": "catalog:",
"xss": "catalog:",
"@n8n/permissions": "workspace:*",
"@n8n/inbound-auth": "workspace:*"
"@n8n/inbound-auth": "workspace:*",
"@n8n/utils": "workspace:*"
},
"peerDependencies": {
"zod": "catalog:"

View File

@ -1,3 +1,5 @@
import { oklchToRgb, rgbToHex } from '@n8n/utils/color/oklch';
import { DEFAULT_AGENT_PERSONALISATION, type AgentJsonConfig } from './agent-json-config.schema';
export type AgentPersonalisation = NonNullable<AgentJsonConfig['personalisation']>;
@ -17,35 +19,8 @@ const randomInRange = (random: () => number, min: number, max: number) =>
const randomInteger = (random: () => number, min: number, max: number) =>
Math.round(randomInRange(random, min, max));
function hexChannel(value: number) {
return Math.round(clamp(value, 0, 1) * 255)
.toString(16)
.padStart(2, '0')
.toUpperCase();
}
function linearSrgbToSrgb(value: number) {
return value >= 0.0031308 ? 1.055 * Math.pow(value, 1 / 2.4) - 0.055 : 12.92 * value;
}
function oklchToHex(lightness: number, chroma: number, hue: number) {
const hueRadians = (hue * Math.PI) / 180;
const a = chroma * Math.cos(hueRadians);
const b = chroma * Math.sin(hueRadians);
const l_ = lightness + 0.3963377774 * a + 0.2158037573 * b;
const m_ = lightness - 0.1055613458 * a - 0.0638541728 * b;
const s_ = lightness - 0.0894841775 * a - 1.291485548 * b;
const l = l_ ** 3;
const m = m_ ** 3;
const s = s_ ** 3;
const red = linearSrgbToSrgb(4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s);
const green = linearSrgbToSrgb(-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s);
const blue = linearSrgbToSrgb(-0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s);
return `#${[red, green, blue].map(hexChannel).join('')}`;
return rgbToHex(oklchToRgb([lightness, chroma, hue]));
}
function randomComplementaryColors(random: () => number) {
@ -83,6 +58,15 @@ export function getRandomAgentPersonalisationGradient(
};
}
export function resolveAgentPersonalisation(
value?: Partial<AgentPersonalisation> | null,
): AgentPersonalisation {
return {
icon: value?.icon ?? DEFAULT_AGENT_PERSONALISATION_ICON,
gradient: { ...DEFAULT_AGENT_PERSONALISATION.gradient, ...value?.gradient },
};
}
export function addMissingAgentPersonalisation(
config: AgentJsonConfig,
random?: () => number,

View File

@ -0,0 +1,46 @@
import { hexToRgb, oklchMixer, oklchToRgb, rgbToHex, rgbToOklch } from './oklch';
describe('oklch', () => {
test.each(['#FF0000', '#2563EB', '#00C0CC', '#FFFFFF', '#000000', '#808080'])(
'round-trips %s through oklch',
(hex) => {
expect(rgbToHex(oklchToRgb(rgbToOklch(hexToRgb(hex))))).toBe(hex);
},
);
it('matches the CSS oklch value of red', () => {
const [lightness, chroma, hue] = rgbToOklch(hexToRgb('#FF0000'));
expect(lightness).toBeCloseTo(0.628, 3);
expect(chroma).toBeCloseTo(0.2577, 3);
expect(hue).toBeCloseTo(29.23, 1);
});
it('clips colours outside the sRGB gamut', () => {
expect(oklchToRgb([0.9, 0.4, 145]).every((channel) => channel >= 0 && channel <= 255)).toBe(
true,
);
});
describe('oklchMixer', () => {
it('returns the end colours at 0 and 1', () => {
const mix = oklchMixer('#FF8000', '#0080FF');
expect(rgbToHex(mix(0))).toBe('#FF8000');
expect(rgbToHex(mix(1))).toBe('#0080FF');
});
it('keeps complementary colours saturated in the middle', () => {
// Mixed in sRGB, orange and azure meet at a flat grey.
const middle = oklchMixer('#FF8000', '#0080FF')(0.5);
expect(Math.max(...middle) - Math.min(...middle)).toBeGreaterThan(100);
});
it('takes the hue of the other colour when one end is grey', () => {
const [, , hue] = rgbToOklch(oklchMixer('#808080', '#2563EB')(0.5));
expect(hue).toBeCloseTo(rgbToOklch(hexToRgb('#2563EB'))[2], 0);
});
});
});

View File

@ -0,0 +1,62 @@
/**
* sRGB ⇄ oklch conversions, using Björn Ottosson's matrices. Channels are
* 0..255; oklch is [lightness 0..1, chroma, hue in degrees].
*/
export type Rgb = [number, number, number];
export type Oklch = [number, number, number];
const clamp = (value: number) => Math.min(Math.max(value, 0), 1);
const toLinear = (c: number) => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4);
const fromLinear = (c: number) => (c >= 0.0031308 ? 1.055 * c ** (1 / 2.4) - 0.055 : 12.92 * c);
/** Reads `#RRGGBB`. */
export function hexToRgb(hex: string): Rgb {
const channel = (i: number) => parseInt(hex.slice(i, i + 2), 16);
return [channel(1), channel(3), channel(5)];
}
/** Writes `#RRGGBB` in upper case. */
export function rgbToHex(rgb: Rgb): string {
return `#${rgb.map((channel) => channel.toString(16).padStart(2, '0')).join('')}`.toUpperCase();
}
export function rgbToOklch([r, g, b]: Rgb): Oklch {
const [lr, lg, lb] = [r, g, b].map((c) => toLinear(c / 255));
const l = Math.cbrt(0.4122214708 * lr + 0.5363325363 * lg + 0.0514459929 * lb);
const m = Math.cbrt(0.2119034982 * lr + 0.6806995451 * lg + 0.1073969566 * lb);
const s = Math.cbrt(0.0883024619 * lr + 0.2817188376 * lg + 0.6299787005 * lb);
const L = 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s;
const A = 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s;
const B = 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s;
return [L, Math.hypot(A, B), (Math.atan2(B, A) * 180) / Math.PI];
}
/** Clips to the sRGB gamut. */
export function oklchToRgb([lightness, chroma, hue]: Oklch): Rgb {
const hueRadians = (hue * Math.PI) / 180;
const a = chroma * Math.cos(hueRadians);
const b = chroma * Math.sin(hueRadians);
const l = (lightness + 0.3963377774 * a + 0.2158037573 * b) ** 3;
const m = (lightness - 0.1055613458 * a - 0.0638541728 * b) ** 3;
const s = (lightness - 0.0894841775 * a - 1.291485548 * b) ** 3;
const channel = (value: number) => Math.round(clamp(fromLinear(value)) * 255);
return [
channel(4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s),
channel(-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s),
channel(-0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s),
];
}
/**
* Mixes two colours in oklch along the shorter hue arc, as CSS
* `color-mix(in oklch, …)` and `linear-gradient(in oklch, …)` do.
*/
export function oklchMixer(from: string, to: string): (progress: number) => Rgb {
const [l1, c1, h1] = rgbToOklch(hexToRgb(from));
const [l2, c2, h2] = rgbToOklch(hexToRgb(to));
// A grey has no hue of its own, so it takes the other colour's.
const [hueFrom, hueTo] = [c1 < 1e-4 ? h2 : h1, c2 < 1e-4 ? h1 : h2];
const hueDelta = ((hueTo - hueFrom + 540) % 360) - 180;
return (t) => oklchToRgb([l1 + (l2 - l1) * t, c1 + (c2 - c1) * t, hueFrom + hueDelta * t]);
}

View File

@ -145,6 +145,7 @@
"@chat-adapter/whatsapp": "catalog:",
"@google-cloud/secret-manager": "5.6.0",
"@grpc/grpc-js": "^1.14.3",
"@iconify-json/lucide": "catalog:",
"@joplin/turndown-plugin-gfm": "catalog:",
"@langchain/core": "catalog:",
"@modelcontextprotocol/node": "catalog:",
@ -201,6 +202,7 @@
"@parcel/watcher": "^2.5.1",
"@pinecone-database/pinecone": "catalog:",
"@qdrant/js-client-rest": "catalog:",
"@resvg/resvg-wasm": "2.6.2",
"@rudderstack/rudder-sdk-node": "catalog:",
"@sentry/node": "catalog:sentry",
"@supabase/supabase-js": "catalog:",

View File

@ -0,0 +1,57 @@
import { inflateSync } from 'node:zlib';
export interface Rgba {
r: number;
g: number;
b: number;
a: number;
}
/**
* Minimal reader for the 8-bit RGBA, non-interlaced PNGs the Teams package carries.
* Written out rather than pulling in a decoder and its types for a few assertions.
*/
export function readRgbaPixels(png: Buffer): Rgba[] {
const width = png.readUInt32BE(16);
const height = png.readUInt32BE(20);
expect([png.readUInt8(24), png.readUInt8(25), png.readUInt8(28)]).toEqual([8, 6, 0]);
const idat: Buffer[] = [];
for (let offset = 8; offset + 8 <= png.length; ) {
const length = png.readUInt32BE(offset);
const type = png.toString('ascii', offset + 4, offset + 8);
if (type === 'IDAT') idat.push(png.subarray(offset + 8, offset + 8 + length));
offset += length + 12;
}
const raw = inflateSync(Buffer.concat(idat));
const bpp = 4;
const stride = width * bpp;
const out = Buffer.alloc(height * stride);
for (let y = 0; y < height; y++) {
const filter = raw[y * (stride + 1)];
const line = raw.subarray(y * (stride + 1) + 1, (y + 1) * (stride + 1));
for (let x = 0; x < stride; x++) {
const left = x >= bpp ? out[y * stride + x - bpp] : 0;
const up = y > 0 ? out[(y - 1) * stride + x] : 0;
const upLeft = y > 0 && x >= bpp ? out[(y - 1) * stride + x - bpp] : 0;
let value = line[x];
if (filter === 1) value += left;
else if (filter === 2) value += up;
else if (filter === 3) value += Math.floor((left + up) / 2);
else if (filter === 4) {
const p = left + up - upLeft;
const [dL, dU, dUL] = [Math.abs(p - left), Math.abs(p - up), Math.abs(p - upLeft)];
value += dL <= dU && dL <= dUL ? left : dU <= dUL ? up : upLeft;
}
out[y * stride + x] = value & 0xff;
}
}
const pixels: Rgba[] = [];
for (let i = 0; i < out.length; i += bpp) {
pixels.push({ r: out[i], g: out[i + 1], b: out[i + 2], a: out[i + 3] });
}
return pixels;
}

View File

@ -0,0 +1,93 @@
import { Logger } from '@n8n/backend-common';
import { mockInstance } from '@n8n/backend-test-utils';
import type { EventEmitter } from 'node:events';
import { renderTeamsAppIcons } from '../teams-app-icons';
// A separate file, so the stand-in icon set does not reach the tests that read the real one.
vi.mock('@iconify-json/lucide', () => ({
icons: {
width: 24,
height: 24,
icons: {
line: { body: '<path stroke="currentColor" stroke-width="2" d="M2 2L22 22"/>' },
unclosed: { body: '<g stroke="currentColor"' },
},
},
}));
const fake = vi.hoisted(() => ({
behave: undefined as ((worker: EventEmitter) => void) | undefined,
}));
vi.mock('node:worker_threads', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:worker_threads')>();
const { EventEmitter: Emitter } = await import('node:events');
function Worker(...args: ConstructorParameters<typeof actual.Worker>) {
const behave = fake.behave;
if (!behave) return new actual.Worker(...args);
const worker = Object.assign(new Emitter(), { terminate: async () => 0 });
setImmediate(() => {
worker.emit('online');
behave(worker);
});
return worker;
}
return { ...actual, Worker };
});
const logger = mockInstance(Logger);
const gradient = { from: '#FF0000', to: '#0000FF', angle: 90, fromStop: 0, toStop: 100 };
const expectFallbackWithWarning = async (icon: string) => {
await expect(renderTeamsAppIcons({ icon, gradient })).resolves.toBeUndefined();
expect(logger.warn).toHaveBeenCalledWith(
'Could not draw the agent icon for the Teams app',
expect.objectContaining({ icon }),
);
};
describe('renderTeamsAppIcons when the icon cannot be drawn', () => {
beforeEach(() => {
logger.warn.mockClear();
fake.behave = undefined;
});
afterEach(() => vi.useRealTimers());
it('keeps the bundled icons when resvg cannot read the SVG', async () => {
await expectFallbackWithWarning('unclosed');
});
it('keeps the bundled icons when the worker exits with an error', async () => {
fake.behave = (worker) => worker.emit('exit', 1);
await expectFallbackWithWarning('line');
});
it('keeps the bundled icons when the worker replies without images', async () => {
fake.behave = (worker) => worker.emit('message', { color: 'not an image' });
await expectFallbackWithWarning('line');
});
it('keeps the bundled icons when the worker does not answer in time', async () => {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] });
fake.behave = () => {};
const result = renderTeamsAppIcons({ icon: 'line', gradient });
await vi.waitFor(() => expect(vi.getTimerCount()).toBe(1));
await vi.advanceTimersByTimeAsync(5_000);
await expect(result).resolves.toBeUndefined();
expect(logger.warn).toHaveBeenCalled();
});
it('does not log an icon name Lucide does not have', async () => {
await expect(
renderTeamsAppIcons({ icon: 'not-a-lucide-icon', gradient }),
).resolves.toBeUndefined();
expect(logger.warn).not.toHaveBeenCalled();
});
});

View File

@ -0,0 +1,198 @@
import type { AgentPersonalisation } from '@n8n/api-types';
import { Logger } from '@n8n/backend-common';
import { mockInstance } from '@n8n/backend-test-utils';
import { icons as lucide } from '@iconify-json/lucide';
import { initWasm, Resvg } from '@resvg/resvg-wasm';
import { readFileSync } from 'node:fs';
import { createRequire } from 'node:module';
import { join } from 'node:path';
import { readRgbaPixels, type Rgba } from './png-pixels';
import { buildTeamsIconSvgs, renderTeamsAppIcons } from '../teams-app-icons';
const workers = vi.hoisted(() => ({ live: 0, mostAtOnce: 0 }));
// Counts workers alive at once. A worker counts until `terminate()` settles,
// which the renderer awaits before it hands the next render a turn.
vi.mock('node:worker_threads', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:worker_threads')>();
class CountedWorker extends actual.Worker {
constructor(...args: ConstructorParameters<typeof actual.Worker>) {
super(...args);
workers.live++;
workers.mostAtOnce = Math.max(workers.mostAtOnce, workers.live);
}
async terminate() {
const code = await super.terminate();
workers.live--;
return code;
}
}
return { ...actual, Worker: CountedWorker };
});
mockInstance(Logger);
const personalisation = (
icon: string,
gradient: Partial<AgentPersonalisation['gradient']> = {},
): AgentPersonalisation => ({
icon,
gradient: { from: '#FF0000', to: '#0000FF', angle: 90, fromStop: 0, toStop: 100, ...gradient },
});
async function render(value: AgentPersonalisation | null) {
const icons = await renderTeamsAppIcons(value);
if (!icons) throw new Error('expected icons');
return icons;
}
const pixelsOf = (png: Buffer, size = 192) => {
const pixels = readRgbaPixels(png);
return (x: number, y: number): Rgba => pixels[y * size + x];
};
const isWhite = ({ r, g, b, a }: Rgba) => r === 255 && g === 255 && b === 255 && a === 255;
/** The default tolerance allows for rounding in the colour conversions. */
const expectColor = ({ r, g, b }: Rgba, expected: [number, number, number], tolerance = 3) =>
[r, g, b].forEach((channel, i) =>
expect(Math.abs(channel - expected[i])).toBeLessThanOrEqual(tolerance),
);
describe('renderTeamsAppIcons', () => {
it('draws a 192px colour icon and a 32px outline icon', async () => {
const { color, outline } = await render(personalisation('bot'));
expect([color.readUInt32BE(16), color.readUInt32BE(20)]).toEqual([192, 192]);
expect([outline.readUInt32BE(16), outline.readUInt32BE(20)]).toEqual([32, 32]);
});
it('runs the gradient along its angle, from one stop to the other', async () => {
const pixelAt = pixelsOf((await render(personalisation('bot'))).color);
// Row 5 is above the glyph, so only the gradient shows there. Edge pixel
// centres sit half a pixel inside the stops, so they are close but not exact.
expectColor(pixelAt(0, 5), [255, 0, 0], 10);
expectColor(pixelAt(191, 5), [0, 0, 255], 10);
});
it('holds each end colour outside its stop', async () => {
const pixelAt = pixelsOf(
(await render(personalisation('bot', { fromStop: 45, toStop: 55 }))).color,
);
expectColor(pixelAt(60, 5), [255, 0, 0]);
expectColor(pixelAt(130, 5), [0, 0, 255]);
});
it('draws the glyph in white over the gradient', async () => {
// The square's left edge sits at x=3 of 24, inside the glyph area centred on the tile.
const pixelAt = pixelsOf((await render(personalisation('square'))).color);
expect(isWhite(pixelAt(52, 96))).toBe(true);
expect(isWhite(pixelAt(96, 96))).toBe(false);
});
it('mixes the gradient in oklch, so complementary colours stay saturated', async () => {
// Mixed in sRGB, orange and azure meet at a flat grey.
const { accentColor } = await render(
personalisation('bot', { from: '#FF8000', to: '#0080FF' }),
);
const channels = [1, 3, 5].map((i) => parseInt(accentColor.slice(i, i + 2), 16));
expect(Math.max(...channels) - Math.min(...channels)).toBeGreaterThan(100);
});
it('takes the accent colour from the middle of the tile, after the stops', async () => {
const accentFor = async (fromStop: number, toStop: number) => {
const { accentColor } = await render(personalisation('bot', { fromStop, toStop }));
const [red, , blue] = [1, 3, 5].map((i) => parseInt(accentColor.slice(i, i + 2), 16));
return { accentColor, red, blue };
};
const late = await accentFor(45, 100);
const even = await accentFor(0, 100);
const early = await accentFor(0, 55);
expect(late.red).toBeGreaterThan(late.blue);
expect(early.blue).toBeGreaterThan(early.red);
expect(['#FF0000', '#0000FF']).not.toContain(even.accentColor);
});
it('draws the outline icon in white and transparency only', async () => {
const pixels = readRgbaPixels((await render(personalisation('bot'))).outline);
// Teams rejects a coloured outline icon with InvalidOutlineIconTransparency.
expect(
pixels.filter(({ r, g, b, a }) => a > 0 && (r !== 255 || g !== 255 || b !== 255)),
).toEqual([]);
expect(pixels.some(({ a }) => a === 255)).toBe(true);
expect(pixels.some(({ a }) => a === 0)).toBe(true);
});
it('draws the default icon when the agent has no personalisation', async () => {
await expect(renderTeamsAppIcons(null)).resolves.toBeDefined();
});
it('returns undefined for an icon name Lucide does not have', async () => {
await expect(
renderTeamsAppIcons(personalisation('not-a-lucide-icon')),
).resolves.toBeUndefined();
});
it('renders downloads that arrive together one at a time', async () => {
workers.mostAtOnce = 0;
const results = await Promise.all(
['bot', 'heart', 'mail'].map(
async (icon) => await renderTeamsAppIcons(personalisation(icon)),
),
);
expect(results.every((icons) => icons?.color.length)).toBe(true);
expect(workers.mostAtOnce).toBe(1);
});
it('draws every Lucide icon', async () => {
// Rendered here rather than through workers, which would take minutes for 1,803 icons.
await initWasm(readFileSync(require.resolve('@resvg/resvg-wasm/index_bg.wasm')));
const missing: string[] = [];
for (const name of Object.keys(lucide.icons)) {
const svgs = await buildTeamsIconSvgs(name, personalisation(name).gradient);
if (!svgs) {
missing.push(name);
continue;
}
// One wasm instance serves the whole loop, so each render is freed by hand.
const resvg = new Resvg(svgs.outline);
const image = resvg.render();
try {
if (!readRgbaPixels(Buffer.from(image.asPng())).some(({ a }) => a === 255)) {
missing.push(name);
}
} finally {
image.free();
resvg.free();
}
}
expect(missing).toEqual([]);
}, 60_000);
it('reads the same Lucide release the editor picker offers', () => {
// The picker reads Lucide from `@iconify/json`, a separately versioned
// package. If the two drift, a picked icon falls back to the n8n icons.
const designSystem = join(__dirname, '../../../../../../../../frontend/@n8n/design-system');
const editorSet: { lastModified?: number; icons: Record<string, unknown> } = JSON.parse(
readFileSync(
createRequire(join(designSystem, 'package.json')).resolve('@iconify/json/json/lucide.json'),
'utf8',
),
);
expect(lucide.lastModified).toBe(editorSet.lastModified);
expect(Object.keys(lucide.icons).sort()).toEqual(Object.keys(editorSet.icons).sort());
});
});

View File

@ -2,66 +2,10 @@ import Ajv from 'ajv-draft-04';
import { unzipSync } from 'fflate';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { inflateSync } from 'node:zlib';
import { readRgbaPixels } from './png-pixels';
import { TeamsManifestService } from '../teams-manifest.service';
interface Rgba {
r: number;
g: number;
b: number;
a: number;
}
/**
* Minimal reader for the 8-bit RGBA, non-interlaced PNGs this service bundles.
* Written out rather than pulling in a decoder and its types for one assertion.
*/
function readRgbaPixels(png: Buffer): Rgba[] {
const width = png.readUInt32BE(16);
const height = png.readUInt32BE(20);
expect([png.readUInt8(24), png.readUInt8(25), png.readUInt8(28)]).toEqual([8, 6, 0]);
const idat: Buffer[] = [];
for (let offset = 8; offset + 8 <= png.length; ) {
const length = png.readUInt32BE(offset);
const type = png.toString('ascii', offset + 4, offset + 8);
if (type === 'IDAT') idat.push(png.subarray(offset + 8, offset + 8 + length));
offset += length + 12;
}
const raw = inflateSync(Buffer.concat(idat));
const bpp = 4;
const stride = width * bpp;
const out = Buffer.alloc(height * stride);
for (let y = 0; y < height; y++) {
const filter = raw[y * (stride + 1)];
const line = raw.subarray(y * (stride + 1) + 1, (y + 1) * (stride + 1));
for (let x = 0; x < stride; x++) {
const left = x >= bpp ? out[y * stride + x - bpp] : 0;
const up = y > 0 ? out[(y - 1) * stride + x] : 0;
const upLeft = y > 0 && x >= bpp ? out[(y - 1) * stride + x - bpp] : 0;
let value = line[x];
if (filter === 1) value += left;
else if (filter === 2) value += up;
else if (filter === 3) value += Math.floor((left + up) / 2);
else if (filter === 4) {
const p = left + up - upLeft;
const [dL, dU, dUL] = [Math.abs(p - left), Math.abs(p - up), Math.abs(p - upLeft)];
value += dL <= dU && dL <= dUL ? left : dU <= dUL ? up : upLeft;
}
out[y * stride + x] = value & 0xff;
}
}
const pixels: Rgba[] = [];
for (let i = 0; i < out.length; i += bpp) {
pixels.push({ r: out[i], g: out[i + 1], b: out[i + 2], a: out[i + 3] });
}
return pixels;
}
const schema: object = JSON.parse(
readFileSync(join(__dirname, 'fixtures', 'MicrosoftTeams.schema.v1.16.json'), 'utf8'),
);
@ -415,6 +359,23 @@ describe('TeamsManifestService', () => {
expect(png.readUInt32BE(20)).toBe(size);
});
it("packs the agent's icons as given, and takes their accent colour", async () => {
const icons = {
color: Buffer.from('color-bytes'),
outline: Buffer.from('outline-bytes'),
accentColor: '#2563EB',
};
const entries = unzipSync(await service.buildPackage(options({ icons })));
const manifest = JSON.parse(Buffer.from(entries['manifest.json']).toString('utf8'));
expect(Object.keys(entries).sort()).toEqual(['color.png', 'manifest.json', 'outline.png']);
expect(Buffer.from(entries['color.png'])).toEqual(icons.color);
expect(Buffer.from(entries['outline.png'])).toEqual(icons.outline);
expect(manifest.accentColor).toBe('#2563EB');
const ajv = new Ajv({ strict: false });
expect(ajv.validate(schema, manifest)).toBe(true);
});
it('bundles an outline icon made only of white and transparent pixels', async () => {
const entries = unzipSync(await service.buildPackage(options()));
const pixels = readRgbaPixels(Buffer.from(entries['outline.png']));

View File

@ -1,5 +1,7 @@
import { mock } from 'vitest-mock-extended';
import { unzipSync } from 'fflate';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import type { GlobalConfig } from '@n8n/config';
import type { User } from '@n8n/db';
import type { InstanceSettings } from 'n8n-core';
@ -37,11 +39,11 @@ describe('TeamsSetupService', () => {
let service: TeamsSetupService;
/**
* `integrations` is assigned rather than passed to `mock`, which proxies
* nested objects: the integration's `settings` would come back as mock
* functions, so an absent `displayName` would read as present.
* `integrations` and `schema` are assigned rather than passed to `mock`,
* which proxies nested objects: the integration's `settings` would come back
* as mock functions, so an absent `displayName` would read as present.
*/
const agentWith = (integrations: Agent['integrations']) =>
const agentWith = (integrations: Agent['integrations'], schema: Agent['schema'] = null) =>
Object.assign(
mock<Agent>({
id: AGENT_ID,
@ -49,7 +51,7 @@ describe('TeamsSetupService', () => {
name: 'Support Bot',
updatedAt: new Date('2026-09-15T10:00:00.000Z'),
}),
{ integrations },
{ integrations, schema },
);
const connectTeamsCredential = (type = 'microsoftEntraServicePrincipalApi') => {
@ -263,6 +265,23 @@ describe('TeamsSetupService', () => {
expect((await service.getSetupState(user, scope)).appOutdated).toBe(false);
});
// The package carries the agent's accent colour, which the check does not draw.
it("matches an app drawn with the agent's own icon", async () => {
connectTeamsCredential();
agentRepository.findByIdAndProjectId.mockResolvedValue(
agentWith([{ type: 'teams', credentialId: CREDENTIAL_ID, settings: {} }], {
personalisation: {
icon: 'heart',
gradient: { from: '#2563EB', to: '#7C3AED', angle: 135, fromStop: 0, toStop: 100 },
},
} as Agent['schema']),
);
const { fingerprint } = await service.buildPackage(user, scope);
recorded(fingerprint);
expect((await service.getSetupState(user, scope)).appOutdated).toBe(false);
});
it('flags an agent renamed since then', async () => {
connectTeamsCredential();
const { fingerprint } = await service.buildPackage(user, scope);
@ -505,6 +524,47 @@ describe('TeamsSetupService', () => {
expect(chosen).not.toBe(stored);
});
describe('app icons', () => {
const bundled = (name: string) => readFileSync(join(__dirname, '..', 'assets', name));
const packageFor = async (icon: string) => {
connectTeamsCredential();
agentRepository.findByIdAndProjectId.mockResolvedValue(
agentWith([{ type: 'teams', credentialId: CREDENTIAL_ID, settings: {} }], {
name: 'Support Bot',
model: 'anthropic/claude-sonnet-4-5',
instructions: '',
personalisation: {
icon,
gradient: { from: '#2563EB', to: '#2563EB', angle: 90, fromStop: 0, toStop: 100 },
},
}),
);
const { archive } = await service.buildPackage(user, {
projectId: PROJECT_ID,
agentId: AGENT_ID,
});
return unzipSync(archive);
};
it("draws the agent's icon and colour into the package", async () => {
const entries = await packageFor('heart');
const manifest = JSON.parse(Buffer.from(entries['manifest.json']).toString('utf8'));
expect(Buffer.from(entries['color.png']).equals(bundled('color.png'))).toBe(false);
expect(Buffer.from(entries['outline.png']).equals(bundled('outline.png'))).toBe(false);
expect(manifest.accentColor).toBe('#2563EB');
});
it('keeps the n8n icons for an icon name Lucide does not have', async () => {
const entries = await packageFor('not-a-lucide-icon');
const manifest = JSON.parse(Buffer.from(entries['manifest.json']).toString('utf8'));
expect(Buffer.from(entries['color.png']).equals(bundled('color.png'))).toBe(true);
expect(Buffer.from(entries['outline.png']).equals(bundled('outline.png'))).toBe(true);
expect(manifest.accentColor).toBe('#EA4B71');
});
});
it('falls back to the stored settings when none are given', async () => {
agentRepository.findByIdAndProjectId.mockResolvedValue(
agentWith([{ type: 'teams', credentialId: CREDENTIAL_ID, settings: { groupChats: true } }]),

View File

@ -0,0 +1,207 @@
import { resolveAgentPersonalisation, type AgentPersonalisation } from '@n8n/api-types';
import { Logger } from '@n8n/backend-common';
import { Container } from '@n8n/di';
import { oklchMixer, rgbToHex, type Rgb } from '@n8n/utils/color/oklch';
import { isRecord } from '@n8n/utils/is-record';
import { runSerially } from '@n8n/utils/run-serially';
import { UnexpectedError } from 'n8n-workflow';
import { Worker, type ResourceLimits } from 'node:worker_threads';
/**
* Draws the agent's personalisation icon as the two PNGs a Teams app package
* needs: the Lucide glyph on the agent's gradient, built as SVG here and
* rasterised by resvg in a worker thread.
*
* The worker keeps resvg's wasm memory out of the main isolate, which would
* hold it for the life of the process. The process keeps a flat high-water
* mark instead: about 12 MB of container memory, measured over 20 renders in
* the Alpine image. Memory a terminated worker freed returns to the
* allocator, not to the system, and the next worker reuses it.
*/
export interface TeamsAppIcons {
/** 192×192, full bleed: Teams rounds the corners itself. */
color: Buffer;
/** 32×32, white on transparent: Teams rejects any other colour. */
outline: Buffer;
accentColor: string;
}
interface IconSvgs {
color: string;
outline: string;
accentColor: string;
}
const COLOR_SIZE = 192;
const OUTLINE_SIZE = 32;
/** The glyph share of the tile in `AgentPersonalisationIcon.vue`. */
const GLYPH_RATIO = 0.6;
/** SVG gradients mix in sRGB, so the oklch path is sampled into this many stops. */
const GRADIENT_STOPS = 16;
/** Counted from when the worker runs, so a slow worker start does not use it up. */
const RENDER_TIMEOUT_MS = 5_000;
const WORKER_LIMITS: ResourceLimits = {
maxYoungGenerationSizeMb: 2,
maxOldGenerationSizeMb: 16,
codeRangeSizeMb: 8,
stackSizeMb: 1,
};
type Gradient = AgentPersonalisation['gradient'];
const renders = new Map<string, Promise<unknown>>();
/** Returns undefined for an icon that cannot be drawn, so the caller keeps the bundled icons. */
export async function renderTeamsAppIcons(
personalisation?: AgentPersonalisation | null,
): Promise<TeamsAppIcons | undefined> {
const { icon, gradient } = resolveAgentPersonalisation(personalisation);
try {
const svgs = await buildTeamsIconSvgs(icon, gradient);
if (!svgs) return undefined;
// Downloads at the same time would otherwise each hold a worker and its wasm memory.
const { color, outline } = await runSerially(
renders,
'teams-app-icons',
async () => await rasterise(svgs),
);
return { color, outline, accentColor: svgs.accentColor };
} catch (error) {
// Rendering is optional: the package is still valid with the bundled icons.
Container.get(Logger).warn('Could not draw the agent icon for the Teams app', {
icon,
error,
});
return undefined;
}
}
/** Returns undefined for an icon name Lucide does not have. */
export async function buildTeamsIconSvgs(
iconName: string,
gradient: Gradient,
): Promise<IconSvgs | undefined> {
// Loaded once and cached. Parsing the set again on each call grows the V8
// heap far more than the parsed set costs to keep.
const { icons: lucide } = await import('@iconify-json/lucide');
// Aliases are skipped, as the editor's icon loader skips them.
if (!Object.hasOwn(lucide.icons, iconName)) return undefined;
const icon = lucide.icons[iconName];
const viewBox = `0 0 ${icon.width ?? lucide.width ?? 24} ${icon.height ?? lucide.height ?? 24}`;
const glyph = icon.body.replaceAll('currentColor', '#FFFFFF');
// Mixed in oklch, as the editor does: random agent gradients pair
// complementary hues, which sRGB mixing would turn grey in the middle.
const colorAt = oklchMixer(gradient.from, gradient.to);
const glyphSize = COLOR_SIZE * GLYPH_RATIO;
const offset = (COLOR_SIZE - glyphSize) / 2;
return {
color: svg(
COLOR_SIZE,
`0 0 ${COLOR_SIZE} ${COLOR_SIZE}`,
`${gradientDefinition(gradient, colorAt)}<rect width="${COLOR_SIZE}" height="${COLOR_SIZE}" fill="url(#g)"/>` +
`<svg x="${offset}" y="${offset}" width="${glyphSize}" height="${glyphSize}" viewBox="${viewBox}">${glyph}</svg>`,
),
outline: svg(OUTLINE_SIZE, viewBox, glyph),
accentColor: rgbToHex(colorAt(stopProgress(gradient, 0.5))),
};
}
function svg(size: number, viewBox: string, content: string): string {
return `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="${viewBox}">${content}</svg>`;
}
/**
* CSS angles point up at 0deg and turn clockwise, and the gradient line is
* long enough that the stops land exactly on the corners. `pad` holds the end
* colours outside the stops, as CSS does.
*/
function gradientDefinition(gradient: Gradient, colorAt: (progress: number) => Rgb): string {
const radians = (gradient.angle * Math.PI) / 180;
const [ux, uy] = [Math.sin(radians), -Math.cos(radians)];
const half = (COLOR_SIZE * (Math.abs(ux) + Math.abs(uy))) / 2;
const centre = COLOR_SIZE / 2;
const stops = Array.from({ length: GRADIENT_STOPS + 1 }, (_, i) => {
const progress = i / GRADIENT_STOPS;
const offset = (gradient.fromStop + (gradient.toStop - gradient.fromStop) * progress) / 100;
return `<stop offset="${offset}" stop-color="${rgbToHex(colorAt(progress))}"/>`;
}).join('');
return (
'<defs><linearGradient id="g" gradientUnits="userSpaceOnUse" spreadMethod="pad" ' +
`x1="${centre - ux * half}" y1="${centre - uy * half}" x2="${centre + ux * half}" y2="${centre + uy * half}">` +
`${stops}</linearGradient></defs>`
);
}
/** Where a point at `position` (0..1 along the gradient line) falls between the two stops. */
function stopProgress({ fromStop, toStop }: Gradient, position: number): number {
return Math.max(0, Math.min(1, (position - fromStop / 100) / ((toStop - fromStop) / 100)));
}
// Inline, so no separate worker file has to ship or be built for tests. The
// module paths are resolved here, because eval'd code cannot resolve them.
const WORKER_SOURCE = `
const { parentPort, workerData } = require('node:worker_threads');
const { readFileSync } = require('node:fs');
const { initWasm, Resvg } = require(workerData.modulePath);
initWasm(readFileSync(workerData.wasmPath)).then(() => {
const render = (svg) => {
const image = new Resvg(svg).render();
const png = image.asPng();
image.free();
return png;
};
parentPort.postMessage({ color: render(workerData.color), outline: render(workerData.outline) });
});
`;
async function rasterise(svgs: IconSvgs): Promise<Pick<TeamsAppIcons, 'color' | 'outline'>> {
const worker = new Worker(WORKER_SOURCE, {
eval: true,
resourceLimits: WORKER_LIMITS,
workerData: {
modulePath: require.resolve('@resvg/resvg-wasm'),
wasmPath: require.resolve('@resvg/resvg-wasm/index_bg.wasm'),
color: svgs.color,
outline: svgs.outline,
},
});
try {
const result = await new Promise<unknown>((resolve, reject) => {
let timer: NodeJS.Timeout | undefined;
worker.once('online', () => {
timer = setTimeout(
() => reject(new UnexpectedError('Rendering the Teams app icons timed out')),
RENDER_TIMEOUT_MS,
);
});
worker.once('message', (message) => {
clearTimeout(timer);
resolve(message);
});
worker.once('error', (error) => {
clearTimeout(timer);
reject(error);
});
// A clean exit can arrive right after the message; only a failure decides here.
worker.once('exit', (code) => {
if (code === 0) return;
clearTimeout(timer);
reject(new UnexpectedError(`The icon renderer exited with code ${code}`));
});
});
if (
!isRecord(result) ||
!(result.color instanceof Uint8Array) ||
!(result.outline instanceof Uint8Array)
) {
throw new UnexpectedError('The icon renderer returned no images');
}
return { color: Buffer.from(result.color), outline: Buffer.from(result.outline) };
} finally {
await worker.terminate();
}
}

View File

@ -11,12 +11,13 @@ import { readFile } from 'node:fs/promises';
import { join } from 'node:path';
import { v5 as uuidv5 } from 'uuid';
import type { TeamsAppIcons } from './teams-app-icons';
import { sanitiseAppName, truncateToCodePoints } from '../../integration-helpers';
const MANIFEST_VERSION = '1.16';
const MANIFEST_SCHEMA = `https://developer.microsoft.com/json-schemas/teams/v${MANIFEST_VERSION}/MicrosoftTeams.schema.json`;
/** Matches the colour of the bundled icons. */
/** Matches the colour of the bundled icons, which are used when the agent's are not. */
const ACCENT_COLOR = '#EA4B71';
const DEFAULT_APP_NAME = 'n8n Agent';
@ -56,6 +57,8 @@ export interface BuildTeamsManifestOptions {
* only, with the name and description falling back to the agent's.
*/
settings?: AgentTeamsIntegrationSettings;
/** Drawn from the agent's personalisation. Absent means the bundled n8n icons. */
icons?: TeamsAppIcons;
}
/** The placeholder Microsoft's own resource-specific consent examples carry. */
@ -122,7 +125,7 @@ export class TeamsManifestService {
color: 'color.png',
outline: 'outline.png',
},
accentColor: ACCENT_COLOR,
accentColor: options.icons?.accentColor ?? ACCENT_COLOR,
bots: [
{
botId: options.botId,
@ -155,10 +158,12 @@ export class TeamsManifestService {
*/
async buildPackage(options: BuildTeamsManifestOptions): Promise<Buffer> {
const manifest = this.buildManifest(options);
const [color, outline] = await Promise.all([
readFile(join(__dirname, 'assets', 'color.png')),
readFile(join(__dirname, 'assets', 'outline.png')),
]);
const [color, outline] = options.icons
? [options.icons.color, options.icons.outline]
: await Promise.all([
readFile(join(__dirname, 'assets', 'color.png')),
readFile(join(__dirname, 'assets', 'outline.png')),
]);
return Buffer.from(
zipSync({

View File

@ -6,6 +6,7 @@ import { BadRequestError, NotFoundError } from '@n8n/errors';
import { UrlService } from '@n8n/backend-services';
import { adminConsentRedirectUri, adminConsentUrl } from './teams-admin-consent';
import { renderTeamsAppIcons } from './teams-app-icons';
import { TeamsArmTemplateService } from './teams-arm-template.service';
import { botResourceId } from './teams-bot-provisioning.service';
import { TeamsManifestService } from './teams-manifest.service';
@ -134,7 +135,13 @@ export class TeamsSetupService {
settings: selectedSettings ?? this.teamsSettingsOf(agent),
};
return {
archive: await this.manifestService.buildPackage(options),
archive: await this.manifestService.buildPackage({
...options,
// The draft, like the name: the setup shows the agent as it is being edited.
icons: await renderTeamsAppIcons(agent.schema?.personalisation),
}),
// Without the icons: `appOutdated` does not draw them, and the
// personalisation they come from is part of the fingerprint already.
fingerprint: this.manifestService.fingerprint(
this.manifestService.buildManifest(options),
agent.schema?.personalisation,

View File

@ -3,13 +3,14 @@ import { describe, expect, it, vi, beforeEach } from 'vitest';
import { createTestingPinia } from '@pinia/testing';
import { configure, fireEvent, waitFor, within } from '@testing-library/vue';
import { flushPromises } from '@vue/test-utils';
import { defineComponent, ref } from 'vue';
import { computed, defineComponent, ref } from 'vue';
import AgentChannelTeamsEditView from './AgentChannelTeamsEditView.vue';
import type { TeamsChannelRuntime } from './useTeamsChannelRuntime';
import { checkTeamsFileAccess, fetchTeamsAppPackage, getTeamsSetupState } from './api';
import { fileAccessJustApproved } from './fileAccessConsent';
import { ActionDropdownTestStub } from '../../__tests__/utils/ActionDropdownTestStub';
import { AGENT_PERSONALISATION_KEY } from '../types';
vi.mock('@n8n/i18n', async (importOriginal) => ({
...(await importOriginal()),
@ -587,4 +588,20 @@ describe('AgentChannelTeamsEditView', () => {
expect(queryByTestId('teams-managed-app')).not.toBeInTheDocument();
expect(queryByTestId('teams-settings-publish')).not.toBeInTheDocument();
});
it("shows the agent's own icon on the identity card", async () => {
const personalisation = computed(() => ({
icon: 'heart',
gradient: { from: '#2563EB', to: '#7C3AED', angle: 135, fromStop: 0, toStop: 100 },
}));
const { container, getByTestId } = renderHost({
global: { provide: { [AGENT_PERSONALISATION_KEY]: personalisation } },
});
await waitFor(() => expect(getByTestId('teams-identity')).toBeInTheDocument());
const tile = container.querySelector<HTMLElement>(
'[data-test-id="agent-personalisation-icon-tile"]',
);
expect(tile?.style.getPropertyValue('--agent-personalisation-gradient-from')).toBe('#2563EB');
});
});

View File

@ -1,14 +1,14 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, ref, watch } from 'vue';
import { computed, inject, onBeforeUnmount, ref, watch } from 'vue';
import { saveAs } from 'file-saver';
import { N8nButton, N8nCopyInput, N8nIcon, N8nLink, N8nStepper, N8nText } from '@n8n/design-system';
import type {
AgentJsonConfig,
AgentTeamsIntegrationSettings,
ChatIntegrationDescriptor,
TeamsAgentSetupState,
TeamsCredentialCheck,
TeamsFileAccessCheck,
import {
resolveAgentPersonalisation,
type AgentTeamsIntegrationSettings,
type ChatIntegrationDescriptor,
type TeamsAgentSetupState,
type TeamsCredentialCheck,
type TeamsFileAccessCheck,
} from '@n8n/api-types';
import { useI18n } from '@n8n/i18n';
import { useToast } from '@n8n/composables/useToast';
@ -40,6 +40,7 @@ import {
fetchTeamsAppPackage,
getTeamsSetupState,
} from './api';
import { AGENT_PERSONALISATION_KEY } from '../types';
const credentialId = defineModel<string>({ default: '' });
@ -59,7 +60,6 @@ const props = withDefaults(
agentId: string;
forceNewCredential?: boolean;
savedSettings?: AgentTeamsIntegrationSettings;
personalisation?: AgentJsonConfig['personalisation'] | null;
ensureAgentPersisted?: () => Promise<void>;
/** Saves without closing the modal, so the card can act on saved settings. */
persistChannel?: () => Promise<boolean>;
@ -79,12 +79,17 @@ const props = withDefaults(
errorIsConflict: false,
forceNewCredential: false,
savedSettings: undefined,
personalisation: null,
ensureAgentPersisted: undefined,
persistChannel: undefined,
},
);
// The builder provides it. The default keeps the card drawable when mounted on its own.
const personalisation = inject(
AGENT_PERSONALISATION_KEY,
computed(() => resolveAgentPersonalisation(null)),
);
const emit = defineEmits<{
create: [];
edit: [];

View File

@ -1,16 +1,23 @@
import type {
AgentIntegrationDisconnectWarning,
AgentJsonConfig,
AgentPersonalisation,
AgentIntegrationSettings,
ChatIntegrationDescriptor,
} from '@n8n/api-types';
import type { IconName } from '@n8n/design-system';
import type { BaseTextKey } from '@n8n/i18n';
import type { PermissionsRecord } from '@n8n/permissions';
import type { Component, Ref, VNode } from 'vue';
import type { Component, ComputedRef, InjectionKey, Ref, VNode } from 'vue';
import type { AgentCredentialOption } from '../components/AgentCredentialSelect.vue';
/**
* The agent's icon and gradient, with defaults applied. Injected rather than
* passed down, because only the views that show the agent read it.
*/
export const AGENT_PERSONALISATION_KEY: InjectionKey<ComputedRef<AgentPersonalisation>> =
Symbol('agentPersonalisation');
export type AgentChannelMode = 'setup' | 'edit';
export type AgentChannelView = 'list' | `${string}_${AgentChannelMode}`;
@ -73,7 +80,6 @@ export interface AgentChannelViewProps {
agentName: string;
projectId: string;
agentId: string;
personalisation?: AgentJsonConfig['personalisation'] | null;
ensureAgentPersisted?: () => Promise<void>;
/**
* Saves the channel without closing the modal, and reports whether it

View File

@ -1,8 +1,12 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { computed, provide, ref, watch } from 'vue';
import { N8nCard, N8nIcon, N8nTabs, N8nText } from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
import type { AgentConfigValidationIssue, AgentFileDto } from '@n8n/api-types';
import {
resolveAgentPersonalisation,
type AgentConfigValidationIssue,
type AgentFileDto,
} from '@n8n/api-types';
import type { AgentBuilderMainTab } from '../composables/useAgentBuilderMainTabs';
import type { SetupTask } from './AgentSetupTasks/agentSetupTasks.registry';
@ -20,6 +24,7 @@ import AgentAdvancedPanel from './AgentAdvancedPanel.vue';
import AgentBudgetPanel from './AgentBudgetPanel.vue';
import AgentCapabilitiesSection from './AgentCapabilitiesSection.vue';
import AgentTriggersSection from './AgentTriggersSection.vue';
import { AGENT_PERSONALISATION_KEY } from '../channels/types';
import AgentIdentityHeader from './AgentIdentityHeader.vue';
import AgentInfoPanel from './AgentInfoPanel.vue';
import AgentFilesPanel from './AgentFilesPanel.vue';
@ -68,6 +73,14 @@ const props = defineProps<{
}>();
const childrenDisabled = computed(() => !props.canEditAgent);
provide(
AGENT_PERSONALISATION_KEY,
computed(() =>
resolveAgentPersonalisation(
props.localConfig?.personalisation ?? props.agent?.schema?.personalisation,
),
),
);
const isKnowledgeAdvancedExpanded = ref(false);
const settingsStore = useSettingsStore();
@ -231,7 +244,6 @@ defineExpose({ onSetupTaskAction });
:saved-description="savedDescription"
:save-description="saveDescription"
:task-refs="localConfig?.tasks ?? []"
:personalisation="localConfig?.personalisation ?? agent?.schema?.personalisation"
:reload-key="tasksReloadKey"
@update:connected-triggers="emit('update:connected-triggers', $event)"
@trigger-added="emit('trigger-added', $event)"

View File

@ -1,5 +1,5 @@
<script setup lang="ts">
import type { AgentApproval, AgentJsonConfig } from '@n8n/api-types';
import type { AgentApproval } from '@n8n/api-types';
import { N8N_CHAT_INTEGRATION_TYPE } from '@n8n/api-types';
import { useToast } from '@n8n/composables/useToast';
import { N8nButton, N8nIcon, N8nText, type DropdownMenuItemProps } from '@n8n/design-system';
@ -37,7 +37,6 @@ interface Props {
isPublished?: boolean;
simpleSetup?: boolean;
ensureAgentPersisted?: () => Promise<void>;
personalisation?: AgentJsonConfig['personalisation'] | null;
/** n8n Chat's saved description, seeded into its view when opened. */
savedDescription?: string;
/** Persists n8n Chat's description. It skips the request when nothing changed. */
@ -49,7 +48,6 @@ const props = withDefaults(defineProps<Props>(), {
isPublished: false,
simpleSetup: false,
ensureAgentPersisted: undefined,
personalisation: null,
});
const emit = defineEmits<{
@ -709,7 +707,6 @@ watch(
:agent-name="agentId"
:project-id="projectId"
:agent-id="agentId"
:personalisation="personalisation"
:ensure-agent-persisted="ensureAgentPersisted"
:persist-channel="writeChannelConfig"
:force-new-credential="false"

View File

@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed } from 'vue';
import { DEFAULT_AGENT_PERSONALISATION, type AgentJsonConfig } from '@n8n/api-types';
import { resolveAgentPersonalisation, type AgentJsonConfig } from '@n8n/api-types';
import { N8nIcon } from '@n8n/design-system';
type AgentPersonalisation = NonNullable<AgentJsonConfig['personalisation']>;
@ -13,13 +13,9 @@ const props = withDefaults(
{ personalisation: null, size: 24 },
);
const resolved = computed<AgentPersonalisation>(() => {
const value = props.personalisation;
return {
icon: value?.icon ?? DEFAULT_AGENT_PERSONALISATION.icon,
gradient: { ...DEFAULT_AGENT_PERSONALISATION.gradient, ...value?.gradient },
};
});
const resolved = computed<AgentPersonalisation>(() =>
resolveAgentPersonalisation(props.personalisation),
);
const tileStyle = computed<Record<string, string>>(() => ({
'--agent-personalisation-gradient-from': resolved.value.gradient.from,

View File

@ -2,7 +2,6 @@
import { N8N_CHAT_INTEGRATION_TYPE } from '@n8n/api-types';
import type {
AgentConfigValidationIssue,
AgentJsonConfig,
AgentJsonTaskConfig,
TeamsFileAccessConsentState,
} from '@n8n/api-types';
@ -44,7 +43,6 @@ const props = withDefaults(
/** No agent row exists yet — nothing can be connected to it. */
agentUnsaved?: boolean;
ensureAgentPersisted?: () => Promise<void>;
personalisation?: AgentJsonConfig['personalisation'] | null;
/** n8n Chat's saved description, forwarded to the channel modal. */
savedDescription?: string;
/** Persists n8n Chat's description, forwarded to the channel modal. */
@ -59,7 +57,6 @@ const props = withDefaults(
simpleChannelSetup: false,
taskRefs: () => [],
ensureAgentPersisted: undefined,
personalisation: null,
},
);
@ -366,7 +363,6 @@ function handleChannelDisconnected(channelType: string) {
:disabled="props.disabled"
:simple-setup="simpleChannelSetup"
:ensure-agent-persisted="ensureAgentPersisted"
:personalisation="personalisation"
:saved-description="savedDescription"
:save-description="saveDescription"
@channel-connected="handleChannelConnected"

View File

@ -1621,6 +1621,9 @@ importers:
'@n8n/permissions':
specifier: workspace:*
version: link:../permissions
'@n8n/utils':
specifier: workspace:*
version: link:../utils
'@n8n_io/ai-assistant-sdk':
specifier: 'catalog:'
version: 1.27.0
@ -4789,6 +4792,9 @@ importers:
'@grpc/grpc-js':
specifier: 1.14.5
version: 1.14.5
'@iconify-json/lucide':
specifier: 'catalog:'
version: 1.2.114
'@joplin/turndown-plugin-gfm':
specifier: 'catalog:'
version: 1.0.64
@ -4957,6 +4963,9 @@ importers:
'@qdrant/js-client-rest':
specifier: 'catalog:'
version: 1.19.0(@typescript/typescript6@6.0.2)
'@resvg/resvg-wasm':
specifier: 2.6.2
version: 2.6.2
'@rudderstack/rudder-sdk-node':
specifier: 'catalog:'
version: 3.0.5(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1)
@ -13369,6 +13378,10 @@ packages:
'@codemirror/state': ^6.0.0
'@codemirror/view': ^6.0.0
'@resvg/resvg-wasm@2.6.2':
resolution: {integrity: sha512-FqALmHI8D4o6lk/LRWDnhw95z5eO+eAa6ORjVg09YRR7BkcM6oPHU9uyC0gtQG5vpFLvgpeU4+zEAz2H8APHNw==}
engines: {node: '>= 10'}
'@rolldown/binding-android-arm64@1.0.0-beta.50':
resolution: {integrity: sha512-XlEkrOIHLyGT3avOgzfTFSjG+f+dZMw+/qd+Y3HLN86wlndrB/gSimrJCk4gOhr1XtRtEKfszpadI3Md4Z4/Ag==}
engines: {node: ^20.19.0 || >=22.12.0}
@ -31306,6 +31319,8 @@ snapshots:
'@codemirror/state': 6.5.3
'@codemirror/view': 6.39.8
'@resvg/resvg-wasm@2.6.2': {}
'@rolldown/binding-android-arm64@1.0.0-beta.50':
optional: true