@vellumai/assistant 0.12.2-dev.202609170127.2eef54c → 0.12.2-dev.202609170320.2f82e57

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.12.2-dev.202609170127.2eef54c",
3
+ "version": "0.12.2-dev.202609170320.2f82e57",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -0,0 +1,132 @@
1
+ import { readAvatarState } from "../avatar/avatar-manifest.js";
2
+ import { readContainedAvatarRaster } from "../avatar/ensure-raster.js";
3
+ import {
4
+ renderNotificationAvatarPng,
5
+ resolveNotificationAccentHex,
6
+ } from "../avatar/notification-avatar.js";
7
+ import { renderCharacterPng } from "../avatar/png-renderer.js";
8
+ import { getResvg, isResvgAvailable } from "../avatar/resvg-lazy.js";
9
+ import { isTemplatePlaceholder } from "../daemon/handlers/identity.js";
10
+ import { getAssistantName } from "../daemon/identity-helpers.js";
11
+ import { getLogger } from "../util/logger.js";
12
+ import { getAvatarImagePath } from "../util/platform.js";
13
+ import { escapeXmlContent } from "../util/xml.js";
14
+
15
+ function wallpaperWordmark(
16
+ width: number,
17
+ height: number,
18
+ name: string | null,
19
+ ): string {
20
+ const normalized = name?.replace(/\s+/g, " ").trim();
21
+ const label =
22
+ normalized && !isTemplatePlaceholder(normalized) ? normalized : "Vellum";
23
+ const text = `<text id="wordmark" font-family="Liberation Sans, Arial, sans-serif" font-size="64" font-weight="700" letter-spacing="1.5">${escapeXmlContent(`${label} OS`)}</text>`;
24
+ const Resvg = getResvg();
25
+ const bounds = new Resvg(
26
+ `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}">${text}</svg>`,
27
+ ).getBBox();
28
+ if (!bounds || bounds.width === 0) {
29
+ return "";
30
+ }
31
+ const depth = 6;
32
+ const scale = Math.min(
33
+ (height * 0.06) / 64,
34
+ (width * 0.72) / (bounds.width + depth),
35
+ );
36
+ const x = width / 2 - (bounds.x + (bounds.width + depth) / 2) * scale;
37
+ const layers = Array.from({ length: depth }, (_, index) => {
38
+ const offset = depth - index;
39
+ return `<use href="#wordmark" transform="translate(${offset} ${offset})" fill="#40535b"/>`;
40
+ }).join("");
41
+ return `<defs>
42
+ ${text}
43
+ <linearGradient id="letter-face" x2="0" y2="1">
44
+ <stop stop-color="#ffffff"/><stop offset=".48" stop-color="#e0e9e7"/>
45
+ <stop offset="1" stop-color="#9eb4ba"/>
46
+ </linearGradient>
47
+ </defs>
48
+ <g transform="translate(${x} ${height * 0.69}) scale(${scale})">
49
+ <use href="#wordmark" transform="translate(9 12)" fill="#080f14" opacity=".35"/>
50
+ ${layers}
51
+ <use href="#wordmark" fill="url(#letter-face)" stroke="#e8f1ed" stroke-opacity=".4" stroke-width=".5"/>
52
+ </g>`;
53
+ }
54
+
55
+ export function renderDesktopWallpaper(
56
+ width: number,
57
+ height: number,
58
+ avatar: Buffer | null,
59
+ accentHex: string | null,
60
+ assistantName: string | null = null,
61
+ ): Buffer {
62
+ const accent = /^#[\da-f]{6}$/i.test(accentHex ?? "")
63
+ ? accentHex!
64
+ : "#91a6b0";
65
+ const cx = width / 2;
66
+ const cy = height * 0.46;
67
+ const size = Math.min(width, height) * 0.24;
68
+ const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}">
69
+ <defs>
70
+ <linearGradient id="base" x2="1" y2="1">
71
+ <stop stop-color="#202a30"/><stop offset="1" stop-color="#10171c"/>
72
+ </linearGradient>
73
+ <radialGradient id="glow">
74
+ <stop stop-color="${accent}" stop-opacity=".24"/>
75
+ <stop offset="1" stop-color="${accent}" stop-opacity="0"/>
76
+ </radialGradient>
77
+ <radialGradient id="shadow">
78
+ <stop stop-color="#000" stop-opacity=".25"/>
79
+ <stop offset="1" stop-color="#000" stop-opacity="0"/>
80
+ </radialGradient>
81
+ </defs>
82
+ <path fill="url(#base)" d="M0 0H${width}V${height}H0z"/>
83
+ <ellipse cx="${cx}" cy="${cy}" rx="${height * 0.7}" ry="${height * 0.6}" fill="url(#glow)"/>
84
+ <g fill="none" stroke="${accent}">
85
+ <circle cx="${cx}" cy="${cy}" r="${size * 0.76}" stroke-opacity=".16"/>
86
+ <circle cx="${cx}" cy="${cy}" r="${size * 1.25}" stroke-opacity=".09"/>
87
+ <circle cx="${cx}" cy="${cy}" r="${size * 1.9}" stroke-opacity=".05"/>
88
+ </g>
89
+ ${
90
+ avatar
91
+ ? `<ellipse cx="${cx}" cy="${cy + size * 0.48}" rx="${size * 0.7}" ry="${size * 0.16}" fill="url(#shadow)"/>
92
+ <image x="${cx - size / 2}" y="${cy - size / 2}" width="${size}" height="${size}" href="data:image/png;base64,${avatar.toString("base64")}"/>`
93
+ : ""
94
+ }
95
+ ${wallpaperWordmark(width, height, assistantName)}
96
+ </svg>`;
97
+ const Resvg = getResvg();
98
+ return Buffer.from(new Resvg(svg).render().asPng());
99
+ }
100
+
101
+ export async function renderCurrentDesktopWallpaper(
102
+ width: number,
103
+ height: number,
104
+ ): Promise<Buffer | null> {
105
+ if (!isResvgAvailable()) {
106
+ return null;
107
+ }
108
+ const state = readAvatarState();
109
+ const name = getAssistantName();
110
+ let raster = readContainedAvatarRaster(getAvatarImagePath());
111
+ if (!raster && state.kind === "character" && state.traits) {
112
+ try {
113
+ raster = renderCharacterPng(
114
+ state.traits.bodyShape,
115
+ state.traits.eyeStyle,
116
+ state.traits.color,
117
+ );
118
+ } catch (err) {
119
+ getLogger("desktop-wallpaper").warn(
120
+ { err },
121
+ "Could not render wallpaper avatar",
122
+ );
123
+ }
124
+ }
125
+ return renderDesktopWallpaper(
126
+ width,
127
+ height,
128
+ raster ? await renderNotificationAvatarPng(state, raster) : null,
129
+ resolveNotificationAccentHex(state),
130
+ name,
131
+ );
132
+ }
@@ -0,0 +1,17 @@
1
+ import { writeFile } from "node:fs/promises";
2
+
3
+ import { renderCurrentDesktopWallpaper } from "./desktop-wallpaper-renderer.js";
4
+
5
+ const [width, height, output] = process.argv.slice(2);
6
+ if (
7
+ !output ||
8
+ !Number.isFinite(Number(width)) ||
9
+ !Number.isFinite(Number(height))
10
+ ) {
11
+ throw new Error("Wallpaper dimensions and output path are required");
12
+ }
13
+ const png = await renderCurrentDesktopWallpaper(Number(width), Number(height));
14
+ if (png) {
15
+ await writeFile(output, png);
16
+ }
17
+ process.exit(0);
@@ -1,10 +1,18 @@
1
- import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
1
+ import {
2
+ mkdirSync,
3
+ mkdtempSync,
4
+ readdirSync,
5
+ readFileSync,
6
+ rmSync,
7
+ writeFileSync,
8
+ } from "node:fs";
2
9
  import { tmpdir } from "node:os";
3
10
  import { join } from "node:path";
4
11
  import {
5
12
  afterEach,
6
13
  beforeEach,
7
14
  expect,
15
+ mock,
8
16
  setDefaultTimeout,
9
17
  test,
10
18
  } from "bun:test";
@@ -14,11 +22,10 @@ import sharp from "sharp";
14
22
  import {
15
23
  __resetResvgCacheForTests,
16
24
  __setResvgCacheForTests,
25
+ getResvg,
17
26
  } from "../avatar/resvg-lazy.js";
18
- import {
19
- renderCurrentDesktopWallpaper,
20
- renderDesktopWallpaper,
21
- } from "./desktop-wallpaper.js";
27
+ import { renderCurrentDesktopWallpaper } from "./desktop-wallpaper.js";
28
+ import { renderDesktopWallpaper } from "./desktop-wallpaper-renderer.js";
22
29
 
23
30
  // Native resvg renders scan the system font database per instance, and on a
24
31
  // loaded CI runner the file's first render runs past bun's 5s default. Each
@@ -81,7 +88,7 @@ test("renders the current uploaded avatar into an opaque desktop wallpaper", asy
81
88
  expect((await sharp(result).stats()).isOpaque).toBe(true);
82
89
  });
83
90
 
84
- test("regenerates a character from current traits when its raster is missing", async () => {
91
+ test("renders a missing character raster without changing persisted avatar files", async () => {
85
92
  manifest({
86
93
  kind: "character",
87
94
  source: "pool",
@@ -89,9 +96,18 @@ test("regenerates a character from current traits when its raster is missing", a
89
96
  image: null,
90
97
  accent: null,
91
98
  });
99
+ const avatarDir = join(workspace, "data/avatar");
100
+ writeFileSync(join(avatarDir, "character-traits.json"), "preserve traits");
101
+ writeFileSync(join(avatarDir, "character-ascii.txt"), "preserve ASCII");
102
+ const snapshot = () =>
103
+ readdirSync(avatarDir)
104
+ .sort()
105
+ .map((file) => [file, readFileSync(join(avatarDir, file))]);
106
+ const before = snapshot();
92
107
  const result = (await renderCurrentDesktopWallpaper(480, 300))!;
93
108
  const empty = renderDesktopWallpaper(480, 300, null, null);
94
109
  expect(await centerPixel(result)).not.toEqual(await centerPixel(empty));
110
+ expect(snapshot()).toEqual(before);
95
111
  });
96
112
 
97
113
  test("missing and unreadable avatars produce the same neutral background", async () => {
@@ -200,3 +216,17 @@ test("names containing XML are rendered as text without injecting SVG shapes", a
200
216
  );
201
217
  expect((await wordmarkPixels(png)).length).toBeGreaterThan(0);
202
218
  });
219
+
220
+ test("renders in a child without invoking the assistant's native rasterizer", async () => {
221
+ const render = mock(() => {
222
+ throw new Error("Rendering must not run on the assistant event loop");
223
+ });
224
+ __setResvgCacheForTests({
225
+ available: true,
226
+ Resvg: render as unknown as ReturnType<typeof getResvg>,
227
+ });
228
+ const png = await renderCurrentDesktopWallpaper(1600, 900);
229
+ expect(render).not.toHaveBeenCalled();
230
+ const metadata = await sharp(png!).metadata();
231
+ expect([metadata.width, metadata.height]).toEqual([1600, 900]);
232
+ });
@@ -1,98 +1,13 @@
1
- import { readAvatarState } from "../avatar/avatar-manifest.js";
2
- import {
3
- renderNotificationAvatarPng,
4
- resolveNotificationAccentHex,
5
- } from "../avatar/notification-avatar.js";
6
- import { getResvg, isResvgAvailable } from "../avatar/resvg-lazy.js";
7
- import { isTemplatePlaceholder } from "../daemon/handlers/identity.js";
8
- import { getAssistantName } from "../daemon/identity-helpers.js";
9
- import { escapeXmlContent } from "../util/xml.js";
1
+ import { execFile } from "node:child_process";
2
+ import { mkdtemp, readFile, rm } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { promisify } from "node:util";
10
6
 
11
- function wallpaperWordmark(
12
- width: number,
13
- height: number,
14
- name: string | null,
15
- ): string {
16
- const normalized = name?.replace(/\s+/g, " ").trim();
17
- const label =
18
- normalized && !isTemplatePlaceholder(normalized) ? normalized : "Vellum";
19
- const text = `<text id="wordmark" font-family="Liberation Sans, Arial, sans-serif" font-size="64" font-weight="700" letter-spacing="1.5">${escapeXmlContent(`${label} OS`)}</text>`;
20
- const Resvg = getResvg();
21
- const bounds = new Resvg(
22
- `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}">${text}</svg>`,
23
- ).getBBox();
24
- if (!bounds || bounds.width === 0) {
25
- return "";
26
- }
27
- const depth = 6;
28
- const scale = Math.min(
29
- (height * 0.06) / 64,
30
- (width * 0.72) / (bounds.width + depth),
31
- );
32
- const x = width / 2 - (bounds.x + (bounds.width + depth) / 2) * scale;
33
- const layers = Array.from({ length: depth }, (_, index) => {
34
- const offset = depth - index;
35
- return `<use href="#wordmark" transform="translate(${offset} ${offset})" fill="#40535b"/>`;
36
- }).join("");
37
- return `<defs>
38
- ${text}
39
- <linearGradient id="letter-face" x2="0" y2="1">
40
- <stop stop-color="#ffffff"/><stop offset=".48" stop-color="#e0e9e7"/>
41
- <stop offset="1" stop-color="#9eb4ba"/>
42
- </linearGradient>
43
- </defs>
44
- <g transform="translate(${x} ${height * 0.69}) scale(${scale})">
45
- <use href="#wordmark" transform="translate(9 12)" fill="#080f14" opacity=".35"/>
46
- ${layers}
47
- <use href="#wordmark" fill="url(#letter-face)" stroke="#e8f1ed" stroke-opacity=".4" stroke-width=".5"/>
48
- </g>`;
49
- }
7
+ import { isResvgAvailable } from "../avatar/resvg-lazy.js";
8
+ import { resolveWorkerCommand } from "../util/worker-process.js";
50
9
 
51
- export function renderDesktopWallpaper(
52
- width: number,
53
- height: number,
54
- avatar: Buffer | null,
55
- accentHex: string | null,
56
- assistantName: string | null = null,
57
- ): Buffer {
58
- const accent = /^#[\da-f]{6}$/i.test(accentHex ?? "")
59
- ? accentHex!
60
- : "#91a6b0";
61
- const cx = width / 2;
62
- const cy = height * 0.46;
63
- const size = Math.min(width, height) * 0.24;
64
- const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}">
65
- <defs>
66
- <linearGradient id="base" x2="1" y2="1">
67
- <stop stop-color="#202a30"/><stop offset="1" stop-color="#10171c"/>
68
- </linearGradient>
69
- <radialGradient id="glow">
70
- <stop stop-color="${accent}" stop-opacity=".24"/>
71
- <stop offset="1" stop-color="${accent}" stop-opacity="0"/>
72
- </radialGradient>
73
- <radialGradient id="shadow">
74
- <stop stop-color="#000" stop-opacity=".25"/>
75
- <stop offset="1" stop-color="#000" stop-opacity="0"/>
76
- </radialGradient>
77
- </defs>
78
- <path fill="url(#base)" d="M0 0H${width}V${height}H0z"/>
79
- <ellipse cx="${cx}" cy="${cy}" rx="${height * 0.7}" ry="${height * 0.6}" fill="url(#glow)"/>
80
- <g fill="none" stroke="${accent}">
81
- <circle cx="${cx}" cy="${cy}" r="${size * 0.76}" stroke-opacity=".16"/>
82
- <circle cx="${cx}" cy="${cy}" r="${size * 1.25}" stroke-opacity=".09"/>
83
- <circle cx="${cx}" cy="${cy}" r="${size * 1.9}" stroke-opacity=".05"/>
84
- </g>
85
- ${
86
- avatar
87
- ? `<ellipse cx="${cx}" cy="${cy + size * 0.48}" rx="${size * 0.7}" ry="${size * 0.16}" fill="url(#shadow)"/>
88
- <image x="${cx - size / 2}" y="${cy - size / 2}" width="${size}" height="${size}" href="data:image/png;base64,${avatar.toString("base64")}"/>`
89
- : ""
90
- }
91
- ${wallpaperWordmark(width, height, assistantName)}
92
- </svg>`;
93
- const Resvg = getResvg();
94
- return Buffer.from(new Resvg(svg).render().asPng());
95
- }
10
+ const runFile = promisify(execFile);
96
11
 
97
12
  export async function renderCurrentDesktopWallpaper(
98
13
  width: number,
@@ -101,13 +16,30 @@ export async function renderCurrentDesktopWallpaper(
101
16
  if (!isResvgAvailable()) {
102
17
  return null;
103
18
  }
104
- const state = readAvatarState();
105
- const name = getAssistantName();
106
- return renderDesktopWallpaper(
107
- width,
108
- height,
109
- await renderNotificationAvatarPng(state),
110
- resolveNotificationAccentHex(state),
111
- name,
112
- );
19
+ const directory = await mkdtemp(join(tmpdir(), "vellum-wallpaper-"));
20
+ const output = join(directory, "wallpaper.png");
21
+ try {
22
+ const [executable, ...args] = resolveWorkerCommand(
23
+ new URL("./desktop-wallpaper-worker.ts", import.meta.url),
24
+ undefined,
25
+ );
26
+ await runFile(
27
+ executable!,
28
+ [...args, String(width), String(height), output],
29
+ {
30
+ timeout: 30_000,
31
+ killSignal: "SIGKILL",
32
+ maxBuffer: 64 * 1024,
33
+ windowsHide: true,
34
+ },
35
+ );
36
+ return await readFile(output).catch((error: NodeJS.ErrnoException) => {
37
+ if (error.code === "ENOENT") {
38
+ return null;
39
+ }
40
+ throw error;
41
+ });
42
+ } finally {
43
+ await rm(directory, { recursive: true, force: true });
44
+ }
113
45
  }