mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 05:29:44 +02:00
feat(core): Use the agent's icon in the Microsoft Teams app package (no-changelog) (#40322)
This commit is contained in:
parent
1ea18e9ef9
commit
cc83b5bd77
|
|
@ -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:"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
46
packages/@n8n/utils/src/color/oklch.test.ts
Normal file
46
packages/@n8n/utils/src/color/oklch.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
62
packages/@n8n/utils/src/color/oklch.ts
Normal file
62
packages/@n8n/utils/src/color/oklch.ts
Normal 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]);
|
||||
}
|
||||
|
|
@ -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:",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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());
|
||||
});
|
||||
});
|
||||
|
|
@ -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']));
|
||||
|
|
|
|||
|
|
@ -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 } }]),
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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: [];
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user