diff --git a/packages/@n8n/api-types/package.json b/packages/@n8n/api-types/package.json index 79ace0c9e6b..1f7527804cc 100644 --- a/packages/@n8n/api-types/package.json +++ b/packages/@n8n/api-types/package.json @@ -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:" diff --git a/packages/@n8n/api-types/src/agents/agent-personalisation.ts b/packages/@n8n/api-types/src/agents/agent-personalisation.ts index 160cad390a9..7d70084e940 100644 --- a/packages/@n8n/api-types/src/agents/agent-personalisation.ts +++ b/packages/@n8n/api-types/src/agents/agent-personalisation.ts @@ -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; @@ -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 | null, +): AgentPersonalisation { + return { + icon: value?.icon ?? DEFAULT_AGENT_PERSONALISATION_ICON, + gradient: { ...DEFAULT_AGENT_PERSONALISATION.gradient, ...value?.gradient }, + }; +} + export function addMissingAgentPersonalisation( config: AgentJsonConfig, random?: () => number, diff --git a/packages/@n8n/utils/src/color/oklch.test.ts b/packages/@n8n/utils/src/color/oklch.test.ts new file mode 100644 index 00000000000..b6ee5caf95e --- /dev/null +++ b/packages/@n8n/utils/src/color/oklch.test.ts @@ -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); + }); + }); +}); diff --git a/packages/@n8n/utils/src/color/oklch.ts b/packages/@n8n/utils/src/color/oklch.ts new file mode 100644 index 00000000000..55fc18e0c76 --- /dev/null +++ b/packages/@n8n/utils/src/color/oklch.ts @@ -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]); +} diff --git a/packages/cli/package.json b/packages/cli/package.json index 3d6122ae9a3..052c582e409 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -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:", diff --git a/packages/cli/src/modules/agents/integrations/platforms/teams/__tests__/png-pixels.ts b/packages/cli/src/modules/agents/integrations/platforms/teams/__tests__/png-pixels.ts new file mode 100644 index 00000000000..56dd7e44a7f --- /dev/null +++ b/packages/cli/src/modules/agents/integrations/platforms/teams/__tests__/png-pixels.ts @@ -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; +} diff --git a/packages/cli/src/modules/agents/integrations/platforms/teams/__tests__/teams-app-icons.fallback.test.ts b/packages/cli/src/modules/agents/integrations/platforms/teams/__tests__/teams-app-icons.fallback.test.ts new file mode 100644 index 00000000000..04e6a41a364 --- /dev/null +++ b/packages/cli/src/modules/agents/integrations/platforms/teams/__tests__/teams-app-icons.fallback.test.ts @@ -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: '' }, + unclosed: { body: ' ({ + behave: undefined as ((worker: EventEmitter) => void) | undefined, +})); + +vi.mock('node:worker_threads', async (importOriginal) => { + const actual = await importOriginal(); + const { EventEmitter: Emitter } = await import('node:events'); + function Worker(...args: ConstructorParameters) { + 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(); + }); +}); diff --git a/packages/cli/src/modules/agents/integrations/platforms/teams/__tests__/teams-app-icons.test.ts b/packages/cli/src/modules/agents/integrations/platforms/teams/__tests__/teams-app-icons.test.ts new file mode 100644 index 00000000000..63fe591cca1 --- /dev/null +++ b/packages/cli/src/modules/agents/integrations/platforms/teams/__tests__/teams-app-icons.test.ts @@ -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(); + class CountedWorker extends actual.Worker { + constructor(...args: ConstructorParameters) { + 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 => ({ + 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 } = 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()); + }); +}); diff --git a/packages/cli/src/modules/agents/integrations/platforms/teams/__tests__/teams-manifest.service.test.ts b/packages/cli/src/modules/agents/integrations/platforms/teams/__tests__/teams-manifest.service.test.ts index 0e79a3c9cb1..50273955347 100644 --- a/packages/cli/src/modules/agents/integrations/platforms/teams/__tests__/teams-manifest.service.test.ts +++ b/packages/cli/src/modules/agents/integrations/platforms/teams/__tests__/teams-manifest.service.test.ts @@ -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'])); diff --git a/packages/cli/src/modules/agents/integrations/platforms/teams/__tests__/teams-setup.service.test.ts b/packages/cli/src/modules/agents/integrations/platforms/teams/__tests__/teams-setup.service.test.ts index 8e872714053..59cd454a509 100644 --- a/packages/cli/src/modules/agents/integrations/platforms/teams/__tests__/teams-setup.service.test.ts +++ b/packages/cli/src/modules/agents/integrations/platforms/teams/__tests__/teams-setup.service.test.ts @@ -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({ 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 } }]), diff --git a/packages/cli/src/modules/agents/integrations/platforms/teams/teams-app-icons.ts b/packages/cli/src/modules/agents/integrations/platforms/teams/teams-app-icons.ts new file mode 100644 index 00000000000..2a7525725e6 --- /dev/null +++ b/packages/cli/src/modules/agents/integrations/platforms/teams/teams-app-icons.ts @@ -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>(); + +/** Returns undefined for an icon that cannot be drawn, so the caller keeps the bundled icons. */ +export async function renderTeamsAppIcons( + personalisation?: AgentPersonalisation | null, +): Promise { + 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 { + // 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)}` + + `${glyph}`, + ), + outline: svg(OUTLINE_SIZE, viewBox, glyph), + accentColor: rgbToHex(colorAt(stopProgress(gradient, 0.5))), + }; +} + +function svg(size: number, viewBox: string, content: string): string { + return `${content}`; +} + +/** + * 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 ``; + }).join(''); + return ( + '` + + `${stops}` + ); +} + +/** 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> { + 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((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(); + } +} diff --git a/packages/cli/src/modules/agents/integrations/platforms/teams/teams-manifest.service.ts b/packages/cli/src/modules/agents/integrations/platforms/teams/teams-manifest.service.ts index 4a0e197959b..9f55818ee1f 100644 --- a/packages/cli/src/modules/agents/integrations/platforms/teams/teams-manifest.service.ts +++ b/packages/cli/src/modules/agents/integrations/platforms/teams/teams-manifest.service.ts @@ -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 { 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({ diff --git a/packages/cli/src/modules/agents/integrations/platforms/teams/teams-setup.service.ts b/packages/cli/src/modules/agents/integrations/platforms/teams/teams-setup.service.ts index ca4f767e899..834febc5749 100644 --- a/packages/cli/src/modules/agents/integrations/platforms/teams/teams-setup.service.ts +++ b/packages/cli/src/modules/agents/integrations/platforms/teams/teams-setup.service.ts @@ -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, diff --git a/packages/frontend/editor-ui/src/features/agents/channels/teams/AgentChannelTeamsEditView.test.ts b/packages/frontend/editor-ui/src/features/agents/channels/teams/AgentChannelTeamsEditView.test.ts index b0d387b686c..4e5f4ba884d 100644 --- a/packages/frontend/editor-ui/src/features/agents/channels/teams/AgentChannelTeamsEditView.test.ts +++ b/packages/frontend/editor-ui/src/features/agents/channels/teams/AgentChannelTeamsEditView.test.ts @@ -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( + '[data-test-id="agent-personalisation-icon-tile"]', + ); + expect(tile?.style.getPropertyValue('--agent-personalisation-gradient-from')).toBe('#2563EB'); + }); }); diff --git a/packages/frontend/editor-ui/src/features/agents/channels/teams/AgentChannelTeamsSetup.vue b/packages/frontend/editor-ui/src/features/agents/channels/teams/AgentChannelTeamsSetup.vue index b2045e2a002..f39b83d75b1 100644 --- a/packages/frontend/editor-ui/src/features/agents/channels/teams/AgentChannelTeamsSetup.vue +++ b/packages/frontend/editor-ui/src/features/agents/channels/teams/AgentChannelTeamsSetup.vue @@ -1,14 +1,14 @@