@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
|
@@ -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 {
|
|
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
|
-
|
|
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("
|
|
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 {
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
} from "
|
|
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
|
-
|
|
12
|
-
|
|
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
|
-
|
|
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
|
|
105
|
-
const
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
}
|