pi-incarnate 0.2.0 → 0.3.0
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/CHANGELOG.md +8 -1
- package/README.md +13 -11
- package/SECURITY.md +4 -3
- package/characters/README.md +1 -1
- package/package.json +1 -1
- package/src/avatar-manager.ts +61 -19
- package/src/avatar.ts +139 -2
- package/src/character-bundle.ts +66 -13
- package/src/menu.ts +4 -3
- package/src/ui-protocol.ts +8 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,7 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project are documented here.
|
|
4
4
|
|
|
5
|
-
##
|
|
5
|
+
## Unreleased
|
|
6
|
+
|
|
7
|
+
## 0.3.0 - 2026-09-11
|
|
8
|
+
|
|
9
|
+
- Add validated `avatar.png` primary avatars with bounded dimensions and file size, while retaining one `avatar.ansi` or `avatar.txt` fallback.
|
|
10
|
+
- Carry PNG avatars through personal import/removal, portable v1 character packages, and the backward-compatible v1 companion UI snapshot.
|
|
11
|
+
|
|
12
|
+
## 0.2.0 - 2026-09-09
|
|
6
13
|
|
|
7
14
|
- Keep unselected keyboard-menu options readable across light/dark terminal mismatches by using the terminal foreground in bold instead of forcing a theme text color.
|
|
8
15
|
- Add an eight-scenario persona evaluation plan and isolated real-Pi RPC runner with scenario filtering and repeated sampling.
|
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
让角色进入 Pi Agent 的对话现场:通过可编辑角色卡、稳定的人格层和会话内 mood,让非 coding 对话拥有更强的在场感,同时保留 Pi 原有工具、安全边界和任务完成能力。头像与状态显示由可选的 `pi-incarnate-ui` 配套扩展提供。
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
当前版本为 `0.3.0`,新增 PNG 主头像及 ANSI/TXT 兼容回退。项目已在 Pi `0.85.0` 验证,要求 Node.js `>=22.19.0`。当前版本不做世界书、自动长期记忆或隐式角色切换。
|
|
6
6
|
|
|
7
7
|
## 安装与启动
|
|
8
8
|
|
|
@@ -49,8 +49,6 @@ pi install /path/to/pi-incarnate
|
|
|
49
49
|
pi install npm:pi-incarnate
|
|
50
50
|
```
|
|
51
51
|
|
|
52
|
-
如果 npm registry 中尚未提供目标版本,请先使用上面的本地路径安装方式。
|
|
53
|
-
|
|
54
52
|
本地包由 Pi 设置管理;需要移除时运行:
|
|
55
53
|
|
|
56
54
|
```bash
|
|
@@ -122,8 +120,9 @@ pi remove /path/to/pi-incarnate
|
|
|
122
120
|
characters/
|
|
123
121
|
└── my-character/
|
|
124
122
|
├── CHARACTER.md
|
|
123
|
+
├── avatar.png # 可选,支持终端图片协议时作为主头像
|
|
125
124
|
├── avatar.txt # 可选,纯文本头像
|
|
126
|
-
├── avatar.ansi # 可选,受限 ANSI
|
|
125
|
+
├── avatar.ansi # 可选,受限 ANSI 真彩回退;优先于 avatar.txt
|
|
127
126
|
└── forms/ # 可选
|
|
128
127
|
```
|
|
129
128
|
|
|
@@ -174,11 +173,13 @@ Default: warm
|
|
|
174
173
|
|
|
175
174
|
如果个人角色卡已经损坏,运行 `/incarnate` → `Character library` → `Add or restore character` → `Repair invalid character card`。菜单只列出目录 ID 安全、位于个人角色根目录内,且属于“卡片格式错误”或“缺少卡片”的项目。格式错误的 UTF-8 卡片会在原内容上编辑;缺少卡片时会提供完整模板。无效编码、符号链接和越界目录不会在 TUI 中打开。
|
|
176
175
|
|
|
177
|
-
头像可以通过 `/incarnate` → `Character library` → `Edit character` → `Manage character avatar` 导入或移除。选择角色后输入 `.ansi` 或 `.txt` 文件路径;支持绝对路径、相对当前工作目录的路径、`~/...`、`file://...`、成对引号和终端拖放常见的转义空格。导入内置角色时会先请求创建个人覆盖副本,包内资源不会被修改。
|
|
176
|
+
头像可以通过 `/incarnate` → `Character library` → `Edit character` → `Manage character avatar` 导入或移除。选择角色后输入 `.png`、`.ansi` 或 `.txt` 文件路径;支持绝对路径、相对当前工作目录的路径、`~/...`、`file://...`、成对引号和终端拖放常见的转义空格。导入内置角色时会先请求创建个人覆盖副本,包内资源不会被修改。
|
|
177
|
+
|
|
178
|
+
`avatar.png` 是主头像。支持 Kitty 或 iTerm2 图片协议时,`pi-incarnate-ui` 会按 PNG 像素尺寸和终端 cell 尺寸等比缩放;不支持图片、图片被 Pi 禁用或渲染失败时,自动回退到 `avatar.ansi`,其次是 `avatar.txt`。只有 PNG 而无法显示图片时使用紧凑角色状态,不影响人格。PNG 最大 512 KiB,宽高各不超过 2048 px,总像素不超过 4,194,304;导入时检查 PNG 签名、chunk checksum、IHDR、必要数据块和尺寸。
|
|
178
179
|
|
|
179
|
-
`avatar.txt` 是纯文本格式,会去除 ANSI 和终端控制序列。`avatar.ansi`
|
|
180
|
+
`avatar.txt` 是纯文本格式,会去除 ANSI 和终端控制序列。`avatar.ansi` 用于彩色回退,存在时优先于 `avatar.txt`;它只保留标准色、256 色、24-bit 前景/背景色及 reset,光标移动、清屏、OSC、超链接和其他控制序列一律删除。两种文本格式都要求 UTF-8,最大 64 KiB、16 行、每行 48 个终端列。ANSI 每行会强制 reset,防止颜色泄漏到 Pi 界面。头像损坏或不可读时只降级为角色状态行,不会关闭已经启用的人格。
|
|
180
181
|
|
|
181
|
-
|
|
182
|
+
菜单导入会把验证或清理后的安全版本写入个人角色目录,并拒绝超出边界或需要裁剪的资源。导入 PNG 会保留已有文本回退;导入 ANSI/TXT 会互相替换但保留 PNG 主图。写入使用同目录临时文件替换。移除操作需要确认,并删除个人副本中的 `avatar.png`、`avatar.ansi` 和 `avatar.txt`。
|
|
182
183
|
|
|
183
184
|
个人角色可以通过 `/incarnate` → `Character library` → `Manage or export character` 重命名或归档;恢复入口位于 `Add or restore character`。重命名只修改安全目录 ID,不改角色卡中的显示名;如果角色正在使用,会同步更新当前会话。归档会在确认后把完整角色目录移到:
|
|
184
185
|
|
|
@@ -196,11 +197,11 @@ Default: warm
|
|
|
196
197
|
<character-id>.pi-character.json
|
|
197
198
|
```
|
|
198
199
|
|
|
199
|
-
导出只收集 `CHARACTER.md
|
|
200
|
+
导出只收集 `CHARACTER.md`、可用的 PNG 主头像、一个运行时优先的 ANSI/TXT 回退,以及角色卡中已经声明且当前可用的 Markdown 表单。PNG 在 JSON 中使用显式 base64 编码;旧的纯文本 v1 角色包仍可导入。未知文件、未声明文件、缺失或无效表单和被另一文本格式遮蔽的头像不会进入角色包。确认界面会显示实际包含的头像和表单数量;偏好表单可能包含私人信息,分享前应直接打开 JSON 检查。
|
|
200
201
|
|
|
201
|
-
导入会先检查格式版本、安全 ID、UTF-8
|
|
202
|
+
导入会先检查格式版本、安全 ID、UTF-8/base64、文件数量与总大小、角色卡结构、头像安全边界,以及每份表单是否由卡片明确声明。路径穿越、重复路径、未知文件、符号链接来源、多个 PNG 或多个文本回退都会被拒绝。整个角色先在个人目录内的临时位置完成构建和加载,再整体移动到正式位置;不会覆盖已有个人角色。同 ID 只有内置角色时,导入结果会成为个人覆盖副本。导入成功后可选择立即启用。
|
|
202
203
|
|
|
203
|
-
角色包最大 1 MiB、最多 66 个文件;其中角色卡最大 512 KiB
|
|
204
|
+
角色包最大 1 MiB、最多 66 个文件;其中角色卡最大 512 KiB,表单、文本头像和 PNG 分别沿用 256 KiB、64 KiB 和 512 KiB 限制。导出目标和导入来源支持与头像导入相同的绝对路径、相对路径、`~/...`、`file://...`、引号和转义空格输入。导出不会覆盖已有文件。
|
|
204
205
|
|
|
205
206
|
## 故障排查
|
|
206
207
|
|
|
@@ -210,6 +211,7 @@ Default: warm
|
|
|
210
211
|
- `Current Mood ...`:检查 `Default:`、三级标题 preset ID 和对应正文。
|
|
211
212
|
- `Forms: n/m available`:运行 `/incarnate status` 后检查缺失文件;表单路径必须留在角色目录内。
|
|
212
213
|
- 彩色头像不显示:文件名应为 `avatar.ansi` 并位于对应角色目录;任意 ANSI 动画、光标控制或终端命令不会被支持。
|
|
214
|
+
- PNG 头像不显示:确认同时安装 `pi-incarnate-ui`,并使用 Kitty、Ghostty、WezTerm 或 iTerm2 等已启用图片协议的终端;不支持时会自动使用 `avatar.ansi`/`avatar.txt` 回退。
|
|
213
215
|
- 命令没有出现:开发时确认使用 `pi -e ./extensions/index.ts`;本地安装后可用 `pi list` 和 `pi config` 检查资源状态。
|
|
214
216
|
- 项目本地扩展未加载:Pi 只从受信任项目自动加载 `.pi/extensions`;本项目的显式 `-e` 和本地包安装不依赖该目录。
|
|
215
217
|
|
|
@@ -228,7 +230,7 @@ src/persona.ts 有界人格 prompt 组合
|
|
|
228
230
|
src/markdown.ts 识别 fenced code 的 Markdown 章节操作
|
|
229
231
|
src/commands.ts /incarnate 命令
|
|
230
232
|
src/menu.ts 键盘导航菜单与角色卡编辑流程
|
|
231
|
-
src/avatar.ts ASCII/ANSI 头像读取与安全清理
|
|
233
|
+
src/avatar.ts PNG 校验、ASCII/ANSI 头像读取与安全清理
|
|
232
234
|
src/avatar-manager.ts 头像路径解析、安全导入与移除
|
|
233
235
|
src/form-editor.ts 偏好表单有界读取与原子编辑
|
|
234
236
|
src/mood.ts mood 预设解析和 prompt 片段
|
package/SECURITY.md
CHANGED
|
@@ -10,12 +10,13 @@ Pi extensions execute with the same system permissions as Pi. Review this packag
|
|
|
10
10
|
- Character-card writes are validated before an atomic replacement, and copied character resources reject symbolic links and non-regular files.
|
|
11
11
|
- Guided card edits replace only the selected top-level name or level-two section, reject ambiguous duplicates and structural heading injection, and pass through the same complete-card validation and atomic replacement as full edits.
|
|
12
12
|
- Character lifecycle actions operate only on direct personal-character directories. Archive storage must be a real directory, moves refuse existing targets, and the menu exposes recovery instead of permanent deletion.
|
|
13
|
-
- Portable character packages include only the card, one validated avatar, and available forms explicitly declared by that card. Imports reject unknown, duplicate, escaping, oversized, non-UTF-8, and symbolic-link inputs, build in a temporary personal directory, and never overwrite an existing personal character.
|
|
13
|
+
- Portable character packages include only the card, one validated PNG primary avatar, one validated ANSI/TXT fallback, and available forms explicitly declared by that card. Binary PNG data uses explicit base64 encoding. Imports reject unknown, duplicate, escaping, oversized, malformed-encoding, non-UTF-8, and symbolic-link inputs, build in a temporary personal directory, and never overwrite an existing personal character.
|
|
14
14
|
- Declared form paths must resolve to readable files inside their character directory.
|
|
15
15
|
- Form contents are not loaded or cached during character activation or prompt composition. They are read only after an explicit `Manage preference forms` action, with UTF-8, regular-file, path-containment, and 256 KiB editor bounds.
|
|
16
16
|
- Plain avatars have terminal control sequences removed. ANSI avatars use a strict SGR color-only allowlist; cursor movement, screen control, OSC, hyperlinks, and other escape sequences are removed. Both formats are byte-, line-, and width-limited, with a forced reset on every ANSI line.
|
|
17
|
-
-
|
|
18
|
-
-
|
|
17
|
+
- PNG avatars are limited to 512 KiB, 2048 px per dimension, and 4,194,304 total pixels. Imports validate the PNG signature, IHDR fields, required chunks, and declared dimensions before preserving the binary bytes.
|
|
18
|
+
- Avatar imports reject symbolic-link and non-regular sources, invalid UTF-8, malformed PNG data, oversized files, and text resources that would require display truncation. Only the validated or sanitized result is written to a personal character directory.
|
|
19
|
+
- The optional UI protocol broadcasts only bounded character metadata, already-sanitized avatar lines, and validated PNG bytes encoded as base64. It does not expose the complete character-card body, and unknown protocol claims are ignored.
|
|
19
20
|
- The personality layer is appended without replacing Pi's existing tool, permission, or safety instructions, and explicitly tells the model to preserve those boundaries.
|
|
20
21
|
|
|
21
22
|
Do not include secrets in character cards or preference forms. A character can ask Pi to read an available declared form when relevant, so those files should contain only information you intend to expose to the active model.
|
package/characters/README.md
CHANGED
|
@@ -13,4 +13,4 @@ Every character directory must contain a UTF-8 `CHARACTER.md` with a level-one c
|
|
|
13
13
|
|
|
14
14
|
Optional sections such as `Tools and Forms` and `Current Mood` are preserved verbatim and become part of the persona prompt. Declared forms and mood presets are validated by their dedicated loaders.
|
|
15
15
|
|
|
16
|
-
An optional `avatar.
|
|
16
|
+
An optional `avatar.png` is the primary avatar on terminals with supported inline-image protocols. It is limited to 512 KiB, 2048 px per dimension, and 4,194,304 total pixels, with PNG structure and chunk checksums validated by the core extension. An optional `avatar.ansi` or `avatar.txt` provides the terminal-compatible fallback; ANSI takes precedence over plain text and may use only safe SGR foreground/background colors and resets. Both text formats are limited to 64 KiB, 16 lines, and 48 visible columns.
|
package/package.json
CHANGED
package/src/avatar-manager.ts
CHANGED
|
@@ -8,20 +8,35 @@ import {
|
|
|
8
8
|
AVATAR_MAX_BYTES,
|
|
9
9
|
AVATAR_MAX_COLUMNS,
|
|
10
10
|
AVATAR_MAX_LINES,
|
|
11
|
+
AVATAR_PNG_MAX_BYTES,
|
|
11
12
|
sanitizeAnsiAvatar,
|
|
12
13
|
sanitizeAvatar,
|
|
14
|
+
validatePngAvatar,
|
|
15
|
+
AvatarLoadError,
|
|
13
16
|
} from "./avatar.ts";
|
|
14
17
|
import { CharacterEditError, resolvePersonalDirectory } from "./character-editor.ts";
|
|
15
18
|
|
|
16
|
-
export type
|
|
19
|
+
export type TextAvatarFileName = "avatar.ansi" | "avatar.txt";
|
|
20
|
+
export type AvatarFileName = "avatar.png" | TextAvatarFileName;
|
|
17
21
|
|
|
18
|
-
export interface
|
|
19
|
-
|
|
22
|
+
export interface PreparedTextAvatarImport {
|
|
23
|
+
kind: "text";
|
|
24
|
+
filename: TextAvatarFileName;
|
|
20
25
|
content: string;
|
|
21
26
|
width: number;
|
|
22
27
|
height: number;
|
|
23
28
|
}
|
|
24
29
|
|
|
30
|
+
export interface PreparedPngAvatarImport {
|
|
31
|
+
kind: "png";
|
|
32
|
+
filename: "avatar.png";
|
|
33
|
+
content: Buffer;
|
|
34
|
+
width: number;
|
|
35
|
+
height: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export type PreparedAvatarImport = PreparedTextAvatarImport | PreparedPngAvatarImport;
|
|
39
|
+
|
|
25
40
|
function stripWrappingQuotes(value: string): string {
|
|
26
41
|
if (value.length < 2) return value;
|
|
27
42
|
const first = value[0];
|
|
@@ -40,7 +55,7 @@ export function resolveAvatarSourcePath(input: string, cwd: string, userHome = h
|
|
|
40
55
|
return resolveUserPath(input, cwd, userHome);
|
|
41
56
|
}
|
|
42
57
|
|
|
43
|
-
export function prepareAvatarContent(filename:
|
|
58
|
+
export function prepareAvatarContent(filename: TextAvatarFileName, raw: string): PreparedTextAvatarImport {
|
|
44
59
|
if (Buffer.byteLength(raw, "utf8") > AVATAR_MAX_BYTES) {
|
|
45
60
|
throw new CharacterEditError(`Avatar exceeds the ${AVATAR_MAX_BYTES}-byte limit`);
|
|
46
61
|
}
|
|
@@ -50,6 +65,7 @@ export function prepareAvatarContent(filename: AvatarFileName, raw: string): Pre
|
|
|
50
65
|
throw new CharacterEditError(`Avatar must fit within ${AVATAR_MAX_COLUMNS} columns and ${AVATAR_MAX_LINES} lines`);
|
|
51
66
|
}
|
|
52
67
|
return {
|
|
68
|
+
kind: "text",
|
|
53
69
|
filename,
|
|
54
70
|
content: `${avatar.lines.join("\n")}\n`,
|
|
55
71
|
width: avatar.lines.reduce((maximum, line) => Math.max(maximum, visibleWidth(line)), 0),
|
|
@@ -57,12 +73,31 @@ export function prepareAvatarContent(filename: AvatarFileName, raw: string): Pre
|
|
|
57
73
|
};
|
|
58
74
|
}
|
|
59
75
|
|
|
76
|
+
export function preparePngAvatarContent(bytes: Uint8Array): PreparedPngAvatarImport {
|
|
77
|
+
try {
|
|
78
|
+
const image = validatePngAvatar(bytes);
|
|
79
|
+
return {
|
|
80
|
+
kind: "png",
|
|
81
|
+
filename: "avatar.png",
|
|
82
|
+
content: Buffer.from(bytes),
|
|
83
|
+
width: image.widthPx,
|
|
84
|
+
height: image.heightPx,
|
|
85
|
+
};
|
|
86
|
+
} catch (error) {
|
|
87
|
+
if (error instanceof AvatarLoadError) throw new CharacterEditError(error.message);
|
|
88
|
+
throw error;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
60
92
|
export async function prepareAvatarImport(sourcePath: string): Promise<PreparedAvatarImport> {
|
|
61
93
|
const extension = extname(sourcePath).toLowerCase();
|
|
62
94
|
let filename: AvatarFileName;
|
|
95
|
+
let maximumBytes: number;
|
|
63
96
|
if (extension === ".ansi") filename = "avatar.ansi";
|
|
64
97
|
else if (extension === ".txt") filename = "avatar.txt";
|
|
65
|
-
else
|
|
98
|
+
else if (extension === ".png") filename = "avatar.png";
|
|
99
|
+
else throw new CharacterEditError("Avatar file must end in .png, .ansi, or .txt");
|
|
100
|
+
maximumBytes = filename === "avatar.png" ? AVATAR_PNG_MAX_BYTES : AVATAR_MAX_BYTES;
|
|
66
101
|
|
|
67
102
|
let bytes: Buffer;
|
|
68
103
|
try {
|
|
@@ -70,18 +105,20 @@ export async function prepareAvatarImport(sourcePath: string): Promise<PreparedA
|
|
|
70
105
|
if (!info.isFile() || info.isSymbolicLink()) {
|
|
71
106
|
throw new CharacterEditError(`Avatar source must be a regular file: ${sourcePath}`);
|
|
72
107
|
}
|
|
73
|
-
if (info.size >
|
|
74
|
-
throw new CharacterEditError(`Avatar exceeds the ${
|
|
108
|
+
if (info.size > maximumBytes) {
|
|
109
|
+
throw new CharacterEditError(`Avatar exceeds the ${maximumBytes}-byte limit: ${sourcePath}`);
|
|
75
110
|
}
|
|
76
111
|
bytes = await readFile(sourcePath);
|
|
77
|
-
if (bytes.byteLength >
|
|
78
|
-
throw new CharacterEditError(`Avatar exceeds the ${
|
|
112
|
+
if (bytes.byteLength > maximumBytes) {
|
|
113
|
+
throw new CharacterEditError(`Avatar exceeds the ${maximumBytes}-byte limit: ${sourcePath}`);
|
|
79
114
|
}
|
|
80
115
|
} catch (error) {
|
|
81
116
|
if (error instanceof CharacterEditError) throw error;
|
|
82
117
|
throw new CharacterEditError(`Cannot read avatar source: ${sourcePath}`);
|
|
83
118
|
}
|
|
84
119
|
|
|
120
|
+
if (filename === "avatar.png") return preparePngAvatarContent(bytes);
|
|
121
|
+
|
|
85
122
|
let raw: string;
|
|
86
123
|
try {
|
|
87
124
|
raw = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
@@ -108,26 +145,31 @@ export async function installPersonalAvatar(
|
|
|
108
145
|
): Promise<string> {
|
|
109
146
|
const directory = await resolvePersonalDirectory(personalRoot, id);
|
|
110
147
|
const targetPath = join(directory, prepared.filename);
|
|
111
|
-
const alternatePath =
|
|
148
|
+
const alternatePath = prepared.kind === "text"
|
|
149
|
+
? join(directory, prepared.filename === "avatar.ansi" ? "avatar.txt" : "avatar.ansi")
|
|
150
|
+
: undefined;
|
|
112
151
|
await assertReplaceable(targetPath);
|
|
113
|
-
await assertReplaceable(alternatePath);
|
|
152
|
+
if (alternatePath) await assertReplaceable(alternatePath);
|
|
114
153
|
|
|
115
154
|
const nonce = crypto.randomUUID();
|
|
116
155
|
const temporaryPath = join(directory, `.avatar-import-${nonce}.tmp`);
|
|
117
156
|
const alternateBackup = join(directory, `.avatar-replaced-${nonce}.tmp`);
|
|
118
157
|
let movedAlternate = false;
|
|
119
158
|
try {
|
|
120
|
-
await writeFile(temporaryPath, prepared.content, {
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
159
|
+
if (prepared.kind === "png") await writeFile(temporaryPath, prepared.content, { flag: "wx" });
|
|
160
|
+
else await writeFile(temporaryPath, prepared.content, { encoding: "utf8", flag: "wx" });
|
|
161
|
+
if (alternatePath) {
|
|
162
|
+
try {
|
|
163
|
+
await rename(alternatePath, alternateBackup);
|
|
164
|
+
movedAlternate = true;
|
|
165
|
+
} catch (error) {
|
|
166
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
167
|
+
}
|
|
126
168
|
}
|
|
127
169
|
await rename(temporaryPath, targetPath);
|
|
128
170
|
if (movedAlternate) await rm(alternateBackup, { force: true });
|
|
129
171
|
} catch (error) {
|
|
130
|
-
if (movedAlternate) await rename(alternateBackup, alternatePath).catch(() => undefined);
|
|
172
|
+
if (movedAlternate && alternatePath) await rename(alternateBackup, alternatePath).catch(() => undefined);
|
|
131
173
|
throw new CharacterEditError(`Cannot install avatar for ${id}: ${error instanceof Error ? error.message : "unknown error"}`);
|
|
132
174
|
} finally {
|
|
133
175
|
await rm(temporaryPath, { force: true });
|
|
@@ -138,7 +180,7 @@ export async function installPersonalAvatar(
|
|
|
138
180
|
export async function removePersonalAvatars(personalRoot: string, id: string): Promise<number> {
|
|
139
181
|
const directory = await resolvePersonalDirectory(personalRoot, id);
|
|
140
182
|
let removed = 0;
|
|
141
|
-
for (const filename of ["avatar.ansi", "avatar.txt"] as const) {
|
|
183
|
+
for (const filename of ["avatar.png", "avatar.ansi", "avatar.txt"] as const) {
|
|
142
184
|
const path = join(directory, filename);
|
|
143
185
|
try {
|
|
144
186
|
const info = await lstat(path);
|
package/src/avatar.ts
CHANGED
|
@@ -7,13 +7,31 @@ import type { Character } from "./character-loader.ts";
|
|
|
7
7
|
export const AVATAR_MAX_COLUMNS = 48;
|
|
8
8
|
export const AVATAR_MAX_LINES = 16;
|
|
9
9
|
export const AVATAR_MAX_BYTES = 64 * 1024;
|
|
10
|
+
export const AVATAR_PNG_MAX_BYTES = 512 * 1024;
|
|
11
|
+
export const AVATAR_PNG_MAX_DIMENSION = 2048;
|
|
12
|
+
export const AVATAR_PNG_MAX_PIXELS = 2048 * 2048;
|
|
10
13
|
const ANSI_RESET = "\u001b[0m";
|
|
14
|
+
const PNG_SIGNATURE = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
|
|
15
|
+
const CRC32_TABLE = Uint32Array.from({ length: 256 }, (_, value) => {
|
|
16
|
+
let crc = value;
|
|
17
|
+
for (let bit = 0; bit < 8; bit += 1) crc = (crc & 1) === 1 ? 0xedb88320 ^ (crc >>> 1) : crc >>> 1;
|
|
18
|
+
return crc >>> 0;
|
|
19
|
+
});
|
|
11
20
|
const CSI_SEQUENCE_PATTERN = /\u001b\[[0-?]*[ -/]*[@-~]/g;
|
|
12
21
|
const CONTROL_CHARACTER_PATTERN = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F-\u009F]/g;
|
|
13
22
|
|
|
14
23
|
export interface Avatar {
|
|
15
24
|
lines: string[];
|
|
16
25
|
truncated: boolean;
|
|
26
|
+
image?: AvatarImage;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface AvatarImage {
|
|
30
|
+
mimeType: "image/png";
|
|
31
|
+
data: string;
|
|
32
|
+
widthPx: number;
|
|
33
|
+
heightPx: number;
|
|
34
|
+
bytes: number;
|
|
17
35
|
}
|
|
18
36
|
|
|
19
37
|
export class AvatarLoadError extends Error {
|
|
@@ -88,6 +106,94 @@ function isWithin(parent: string, child: string): boolean {
|
|
|
88
106
|
return pathFromParent !== ".." && !pathFromParent.startsWith(`..${sep}`);
|
|
89
107
|
}
|
|
90
108
|
|
|
109
|
+
function pngChunkCrc(buffer: Buffer, start: number, end: number): number {
|
|
110
|
+
let crc = 0xffffffff;
|
|
111
|
+
for (let index = start; index < end; index += 1) {
|
|
112
|
+
crc = CRC32_TABLE[(crc ^ buffer[index]!) & 0xff]! ^ (crc >>> 8);
|
|
113
|
+
}
|
|
114
|
+
return (crc ^ 0xffffffff) >>> 0;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function validatePngAvatar(bytes: Uint8Array): AvatarImage {
|
|
118
|
+
const buffer = Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
119
|
+
if (buffer.byteLength > AVATAR_PNG_MAX_BYTES) {
|
|
120
|
+
throw new AvatarLoadError(`avatar.png exceeds the ${AVATAR_PNG_MAX_BYTES}-byte limit`);
|
|
121
|
+
}
|
|
122
|
+
if (buffer.byteLength < 45 || !buffer.subarray(0, PNG_SIGNATURE.length).equals(PNG_SIGNATURE)) {
|
|
123
|
+
throw new AvatarLoadError("avatar.png is not a valid PNG file");
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
let offset = PNG_SIGNATURE.length;
|
|
127
|
+
let widthPx = 0;
|
|
128
|
+
let heightPx = 0;
|
|
129
|
+
let sawImageData = false;
|
|
130
|
+
let sawEnd = false;
|
|
131
|
+
while (offset + 12 <= buffer.byteLength) {
|
|
132
|
+
const chunkLength = buffer.readUInt32BE(offset);
|
|
133
|
+
const chunkEnd = offset + 12 + chunkLength;
|
|
134
|
+
if (chunkEnd > buffer.byteLength) throw new AvatarLoadError("avatar.png contains a truncated PNG chunk");
|
|
135
|
+
const chunkType = buffer.toString("ascii", offset + 4, offset + 8);
|
|
136
|
+
if (pngChunkCrc(buffer, offset + 4, offset + 8 + chunkLength) !== buffer.readUInt32BE(offset + 8 + chunkLength)) {
|
|
137
|
+
throw new AvatarLoadError(`avatar.png has an invalid ${chunkType} checksum`);
|
|
138
|
+
}
|
|
139
|
+
if (offset === PNG_SIGNATURE.length) {
|
|
140
|
+
if (chunkType !== "IHDR" || chunkLength !== 13) {
|
|
141
|
+
throw new AvatarLoadError("avatar.png must begin with a valid IHDR chunk");
|
|
142
|
+
}
|
|
143
|
+
widthPx = buffer.readUInt32BE(offset + 8);
|
|
144
|
+
heightPx = buffer.readUInt32BE(offset + 12);
|
|
145
|
+
const bitDepth = buffer[offset + 16]!;
|
|
146
|
+
const colorType = buffer[offset + 17]!;
|
|
147
|
+
const validDepths: Record<number, number[]> = {
|
|
148
|
+
0: [1, 2, 4, 8, 16],
|
|
149
|
+
2: [8, 16],
|
|
150
|
+
3: [1, 2, 4, 8],
|
|
151
|
+
4: [8, 16],
|
|
152
|
+
6: [8, 16],
|
|
153
|
+
};
|
|
154
|
+
if (
|
|
155
|
+
widthPx === 0 ||
|
|
156
|
+
heightPx === 0 ||
|
|
157
|
+
widthPx > AVATAR_PNG_MAX_DIMENSION ||
|
|
158
|
+
heightPx > AVATAR_PNG_MAX_DIMENSION ||
|
|
159
|
+
widthPx * heightPx > AVATAR_PNG_MAX_PIXELS
|
|
160
|
+
) {
|
|
161
|
+
throw new AvatarLoadError(
|
|
162
|
+
`avatar.png must be at most ${AVATAR_PNG_MAX_DIMENSION}×${AVATAR_PNG_MAX_DIMENSION} pixels and ${AVATAR_PNG_MAX_PIXELS} total pixels`,
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
if (
|
|
166
|
+
!validDepths[colorType]?.includes(bitDepth) ||
|
|
167
|
+
buffer[offset + 18] !== 0 ||
|
|
168
|
+
buffer[offset + 19] !== 0 ||
|
|
169
|
+
(buffer[offset + 20] !== 0 && buffer[offset + 20] !== 1)
|
|
170
|
+
) {
|
|
171
|
+
throw new AvatarLoadError("avatar.png has an unsupported PNG header");
|
|
172
|
+
}
|
|
173
|
+
} else if (chunkType === "IHDR") {
|
|
174
|
+
throw new AvatarLoadError("avatar.png contains multiple IHDR chunks");
|
|
175
|
+
}
|
|
176
|
+
if (chunkType === "IDAT") sawImageData = true;
|
|
177
|
+
if (chunkType === "IEND") {
|
|
178
|
+
if (chunkLength !== 0 || chunkEnd !== buffer.byteLength) {
|
|
179
|
+
throw new AvatarLoadError("avatar.png has an invalid IEND chunk");
|
|
180
|
+
}
|
|
181
|
+
sawEnd = true;
|
|
182
|
+
break;
|
|
183
|
+
}
|
|
184
|
+
offset = chunkEnd;
|
|
185
|
+
}
|
|
186
|
+
if (!sawImageData || !sawEnd) throw new AvatarLoadError("avatar.png is missing required PNG chunks");
|
|
187
|
+
|
|
188
|
+
return {
|
|
189
|
+
mimeType: "image/png",
|
|
190
|
+
data: buffer.toString("base64"),
|
|
191
|
+
widthPx,
|
|
192
|
+
heightPx,
|
|
193
|
+
bytes: buffer.byteLength,
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
91
197
|
export function sanitizeAvatar(raw: string, maxColumns = AVATAR_MAX_COLUMNS, maxLines = AVATAR_MAX_LINES): Avatar {
|
|
92
198
|
const normalized = raw.replace(/^\uFEFF/, "").replace(/\r\n?/g, "\n");
|
|
93
199
|
const lines = normalized.split("\n").map((line) => {
|
|
@@ -144,8 +250,39 @@ async function loadAvatarFile(character: Character, filename: string, allowAnsi:
|
|
|
144
250
|
return allowAnsi ? sanitizeAnsiAvatar(raw) : sanitizeAvatar(raw);
|
|
145
251
|
}
|
|
146
252
|
|
|
253
|
+
async function loadPngAvatar(character: Character): Promise<AvatarImage | undefined> {
|
|
254
|
+
const filename = "avatar.png";
|
|
255
|
+
const avatarPath = join(character.directory, filename);
|
|
256
|
+
let canonicalAvatarPath: string;
|
|
257
|
+
try {
|
|
258
|
+
canonicalAvatarPath = await realpath(avatarPath);
|
|
259
|
+
} catch (error) {
|
|
260
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
|
|
261
|
+
throw new AvatarLoadError(`Cannot inspect avatar: ${avatarPath}`);
|
|
262
|
+
}
|
|
263
|
+
if (!isWithin(character.directory, canonicalAvatarPath)) {
|
|
264
|
+
throw new AvatarLoadError(`Avatar path escapes the character directory: ${avatarPath}`);
|
|
265
|
+
}
|
|
266
|
+
let bytes: Buffer;
|
|
267
|
+
try {
|
|
268
|
+
bytes = await readFile(canonicalAvatarPath);
|
|
269
|
+
} catch {
|
|
270
|
+
throw new AvatarLoadError(`Cannot read avatar: ${avatarPath}`);
|
|
271
|
+
}
|
|
272
|
+
try {
|
|
273
|
+
return validatePngAvatar(bytes);
|
|
274
|
+
} catch (error) {
|
|
275
|
+
if (error instanceof AvatarLoadError) {
|
|
276
|
+
throw new AvatarLoadError(`${error.message}: ${avatarPath}`);
|
|
277
|
+
}
|
|
278
|
+
throw error;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
147
282
|
export async function loadAvatar(character: Character): Promise<Avatar | undefined> {
|
|
283
|
+
const image = await loadPngAvatar(character);
|
|
148
284
|
const ansi = await loadAvatarFile(character, "avatar.ansi", true);
|
|
149
|
-
|
|
150
|
-
|
|
285
|
+
const fallback = ansi ?? await loadAvatarFile(character, "avatar.txt", false);
|
|
286
|
+
if (!image) return fallback;
|
|
287
|
+
return { lines: fallback?.lines ?? [], truncated: fallback?.truncated ?? false, image };
|
|
151
288
|
}
|
package/src/character-bundle.ts
CHANGED
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
installPersonalAvatar,
|
|
6
6
|
prepareAvatarContent,
|
|
7
7
|
prepareAvatarImport,
|
|
8
|
+
preparePngAvatarContent,
|
|
8
9
|
type PreparedAvatarImport,
|
|
9
10
|
} from "./avatar-manager.ts";
|
|
10
11
|
import {
|
|
@@ -25,6 +26,7 @@ const CHARACTER_BUNDLE_MAX_FILES = 66;
|
|
|
25
26
|
interface CharacterBundleFileV1 {
|
|
26
27
|
path: string;
|
|
27
28
|
content: string;
|
|
29
|
+
encoding?: "base64";
|
|
28
30
|
}
|
|
29
31
|
|
|
30
32
|
interface CharacterBundleDocumentV1 {
|
|
@@ -38,7 +40,7 @@ export interface PreparedCharacterBundle {
|
|
|
38
40
|
id: string;
|
|
39
41
|
name: string;
|
|
40
42
|
card: string;
|
|
41
|
-
|
|
43
|
+
avatars: PreparedAvatarImport[];
|
|
42
44
|
forms: Array<{ path: string; content: string }>;
|
|
43
45
|
}
|
|
44
46
|
|
|
@@ -132,6 +134,19 @@ async function bundleDocument(character: Character): Promise<{ document: Charact
|
|
|
132
134
|
const files: CharacterBundleFileV1[] = [{ path: "CHARACTER.md", content: card }];
|
|
133
135
|
|
|
134
136
|
let avatarIncluded = false;
|
|
137
|
+
for (const filename of ["avatar.png"] as const) {
|
|
138
|
+
const avatarPath = join(character.directory, filename);
|
|
139
|
+
try {
|
|
140
|
+
await lstat(avatarPath);
|
|
141
|
+
} catch (error) {
|
|
142
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") continue;
|
|
143
|
+
throw new CharacterEditError(`Cannot inspect avatar for export: ${avatarPath}`);
|
|
144
|
+
}
|
|
145
|
+
const avatar = await prepareAvatarImport(avatarPath);
|
|
146
|
+
if (avatar.kind !== "png") throw new CharacterEditError(`Unexpected avatar format: ${avatarPath}`);
|
|
147
|
+
files.push({ path: avatar.filename, content: avatar.content.toString("base64"), encoding: "base64" });
|
|
148
|
+
avatarIncluded = true;
|
|
149
|
+
}
|
|
135
150
|
for (const filename of ["avatar.ansi", "avatar.txt"] as const) {
|
|
136
151
|
const avatarPath = join(character.directory, filename);
|
|
137
152
|
try {
|
|
@@ -141,6 +156,7 @@ async function bundleDocument(character: Character): Promise<{ document: Charact
|
|
|
141
156
|
throw new CharacterEditError(`Cannot inspect avatar for export: ${avatarPath}`);
|
|
142
157
|
}
|
|
143
158
|
const avatar = await prepareAvatarImport(avatarPath);
|
|
159
|
+
if (avatar.kind !== "text") throw new CharacterEditError(`Unexpected avatar format: ${avatarPath}`);
|
|
144
160
|
files.push({ path: avatar.filename, content: avatar.content });
|
|
145
161
|
avatarIncluded = true;
|
|
146
162
|
break;
|
|
@@ -219,6 +235,23 @@ export async function writeCharacterBundle(
|
|
|
219
235
|
return summary;
|
|
220
236
|
}
|
|
221
237
|
|
|
238
|
+
function decodeBase64(value: string, path: string): Buffer {
|
|
239
|
+
if (value.length === 0 || value.length % 4 !== 0 || !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value)) {
|
|
240
|
+
throw new CharacterEditError(`Character bundle contains invalid base64 content: ${path}`);
|
|
241
|
+
}
|
|
242
|
+
const decoded = Buffer.from(value, "base64");
|
|
243
|
+
if (decoded.toString("base64") !== value) {
|
|
244
|
+
throw new CharacterEditError(`Character bundle contains non-canonical base64 content: ${path}`);
|
|
245
|
+
}
|
|
246
|
+
return decoded;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function bundleAvatarEntry(avatar: PreparedAvatarImport): CharacterBundleFileV1 {
|
|
250
|
+
return avatar.kind === "png"
|
|
251
|
+
? { path: avatar.filename, content: avatar.content.toString("base64"), encoding: "base64" }
|
|
252
|
+
: { path: avatar.filename, content: avatar.content };
|
|
253
|
+
}
|
|
254
|
+
|
|
222
255
|
function validateBundleDocument(value: unknown): PreparedCharacterBundle {
|
|
223
256
|
if (!isRecord(value) || value.format !== CHARACTER_BUNDLE_FORMAT || value.version !== CHARACTER_BUNDLE_VERSION) {
|
|
224
257
|
throw new CharacterEditError("Unsupported character bundle format or version");
|
|
@@ -230,10 +263,15 @@ function validateBundleDocument(value: unknown): PreparedCharacterBundle {
|
|
|
230
263
|
throw new CharacterEditError(`Character bundle must contain 1-${CHARACTER_BUNDLE_MAX_FILES} files`);
|
|
231
264
|
}
|
|
232
265
|
|
|
233
|
-
const files = new Map<string,
|
|
266
|
+
const files = new Map<string, CharacterBundleFileV1>();
|
|
234
267
|
let contentBytes = 0;
|
|
235
268
|
for (const entry of value.files) {
|
|
236
|
-
if (
|
|
269
|
+
if (
|
|
270
|
+
!isRecord(entry) ||
|
|
271
|
+
typeof entry.path !== "string" ||
|
|
272
|
+
typeof entry.content !== "string" ||
|
|
273
|
+
(entry.encoding !== undefined && entry.encoding !== "base64")
|
|
274
|
+
) {
|
|
237
275
|
throw new CharacterEditError("Character bundle contains an invalid file entry");
|
|
238
276
|
}
|
|
239
277
|
const path = normalizePortablePath(entry.path);
|
|
@@ -242,11 +280,13 @@ function validateBundleDocument(value: unknown): PreparedCharacterBundle {
|
|
|
242
280
|
if (contentBytes > CHARACTER_BUNDLE_MAX_BYTES) {
|
|
243
281
|
throw new CharacterEditError(`Character bundle content exceeds the ${CHARACTER_BUNDLE_MAX_BYTES}-byte limit`);
|
|
244
282
|
}
|
|
245
|
-
files.set(path, entry.content);
|
|
283
|
+
files.set(path, { path, content: entry.content, ...(entry.encoding === "base64" ? { encoding: "base64" as const } : {}) });
|
|
246
284
|
}
|
|
247
285
|
|
|
248
|
-
const
|
|
249
|
-
if (
|
|
286
|
+
const cardEntry = files.get("CHARACTER.md");
|
|
287
|
+
if (cardEntry === undefined) throw new CharacterEditError("Character bundle is missing CHARACTER.md");
|
|
288
|
+
if (cardEntry.encoding !== undefined) throw new CharacterEditError("CHARACTER.md must use UTF-8 text content");
|
|
289
|
+
const card = cardEntry.content;
|
|
250
290
|
if (byteLength(card) > CHARACTER_CARD_MAX_BYTES) {
|
|
251
291
|
throw new CharacterEditError(`CHARACTER.md exceeds the ${CHARACTER_CARD_MAX_BYTES}-byte bundle limit`);
|
|
252
292
|
}
|
|
@@ -261,15 +301,28 @@ function validateBundleDocument(value: unknown): PreparedCharacterBundle {
|
|
|
261
301
|
}
|
|
262
302
|
}
|
|
263
303
|
|
|
264
|
-
|
|
304
|
+
const avatars: PreparedAvatarImport[] = [];
|
|
305
|
+
let textAvatarFound = false;
|
|
306
|
+
let pngAvatarFound = false;
|
|
265
307
|
const forms: Array<{ path: string; content: string }> = [];
|
|
266
|
-
for (const [path,
|
|
308
|
+
for (const [path, entry] of files) {
|
|
267
309
|
if (path === "CHARACTER.md") continue;
|
|
310
|
+
const { content, encoding } = entry;
|
|
311
|
+
if (path === "avatar.png") {
|
|
312
|
+
if (pngAvatarFound) throw new CharacterEditError("Character bundle may contain only one PNG avatar");
|
|
313
|
+
if (encoding !== "base64") throw new CharacterEditError("avatar.png must use base64 content");
|
|
314
|
+
avatars.push(preparePngAvatarContent(decodeBase64(content, path)));
|
|
315
|
+
pngAvatarFound = true;
|
|
316
|
+
continue;
|
|
317
|
+
}
|
|
268
318
|
if (path === "avatar.ansi" || path === "avatar.txt") {
|
|
269
|
-
if (
|
|
270
|
-
|
|
319
|
+
if (textAvatarFound) throw new CharacterEditError("Character bundle may contain only one text avatar fallback");
|
|
320
|
+
if (encoding !== undefined) throw new CharacterEditError(`${path} must use UTF-8 text content`);
|
|
321
|
+
avatars.push(prepareAvatarContent(path, content));
|
|
322
|
+
textAvatarFound = true;
|
|
271
323
|
continue;
|
|
272
324
|
}
|
|
325
|
+
if (encoding !== undefined) throw new CharacterEditError(`Character bundle text file must not use base64: ${path}`);
|
|
273
326
|
if (!allowedForms.has(path)) {
|
|
274
327
|
throw new CharacterEditError(`Character bundle contains an undeclared file: ${path}`);
|
|
275
328
|
}
|
|
@@ -278,7 +331,7 @@ function validateBundleDocument(value: unknown): PreparedCharacterBundle {
|
|
|
278
331
|
}
|
|
279
332
|
forms.push({ path, content });
|
|
280
333
|
}
|
|
281
|
-
return { id: value.id, name: parsed.name, card,
|
|
334
|
+
return { id: value.id, name: parsed.name, card, avatars, forms };
|
|
282
335
|
}
|
|
283
336
|
|
|
284
337
|
export async function prepareCharacterBundleImport(sourcePath: string): Promise<PreparedCharacterBundle> {
|
|
@@ -303,7 +356,7 @@ export async function installCharacterBundle(
|
|
|
303
356
|
id: bundle.id,
|
|
304
357
|
files: [
|
|
305
358
|
{ path: "CHARACTER.md", content: bundle.card },
|
|
306
|
-
...
|
|
359
|
+
...bundle.avatars.map(bundleAvatarEntry),
|
|
307
360
|
...bundle.forms.map((form) => ({ path: form.path, content: form.content })),
|
|
308
361
|
],
|
|
309
362
|
});
|
|
@@ -317,7 +370,7 @@ export async function installCharacterBundle(
|
|
|
317
370
|
for (const form of validated.forms) {
|
|
318
371
|
await savePersonalForm(stagingRoot, validated.id, form.path, form.content);
|
|
319
372
|
}
|
|
320
|
-
|
|
373
|
+
for (const avatar of validated.avatars) await installPersonalAvatar(stagingRoot, validated.id, avatar);
|
|
321
374
|
const staged = await loadCharacter(stagingRoot, validated.id);
|
|
322
375
|
await rename(staged.directory, target);
|
|
323
376
|
return await loadCharacter(canonicalRoot, validated.id);
|
package/src/menu.ts
CHANGED
|
@@ -391,7 +391,7 @@ async function manageAvatarFile(ctx: ExtensionCommandContext, dependencies: Inca
|
|
|
391
391
|
|
|
392
392
|
try {
|
|
393
393
|
if (action === "Import avatar file") {
|
|
394
|
-
const input = await ctx.ui.input("Avatar file", "path to a .ansi or .txt file; drag and drop is supported");
|
|
394
|
+
const input = await ctx.ui.input("Avatar file", "path to a .png, .ansi, or .txt file; drag and drop is supported");
|
|
395
395
|
if (!input?.trim()) return;
|
|
396
396
|
const sourcePath = resolveAvatarSourcePath(input, ctx.cwd);
|
|
397
397
|
const prepared = await prepareAvatarImport(sourcePath);
|
|
@@ -399,7 +399,8 @@ async function manageAvatarFile(ctx: ExtensionCommandContext, dependencies: Inca
|
|
|
399
399
|
if (!character) return;
|
|
400
400
|
const targetPath = await installPersonalAvatar(personalRoot, character.id, prepared);
|
|
401
401
|
await refreshEditedCharacter(ctx, dependencies, character);
|
|
402
|
-
|
|
402
|
+
const unit = prepared.kind === "png" ? "px" : "cells";
|
|
403
|
+
ctx.ui.notify(`Avatar imported: ${prepared.width}×${prepared.height} ${unit}\n${targetPath}`, "info");
|
|
403
404
|
return;
|
|
404
405
|
}
|
|
405
406
|
|
|
@@ -610,7 +611,7 @@ async function importCharacterBundle(
|
|
|
610
611
|
const confirmed = await confirmMenu(
|
|
611
612
|
ctx,
|
|
612
613
|
"Import character package?",
|
|
613
|
-
`${bundle.name} (${bundle.id}) · Avatar: ${bundle.
|
|
614
|
+
`${bundle.name} (${bundle.id}) · Avatar: ${bundle.avatars.length > 0 ? "included" : "none"} · Forms: ${bundle.forms.length}. Existing personal characters are never overwritten.`,
|
|
614
615
|
);
|
|
615
616
|
if (!confirmed) return;
|
|
616
617
|
const character = await installCharacterBundle(personalRoot, bundle);
|
package/src/ui-protocol.ts
CHANGED
|
@@ -26,6 +26,13 @@ export interface IncarnateUiStateV1 {
|
|
|
26
26
|
width: number;
|
|
27
27
|
height: number;
|
|
28
28
|
truncated: boolean;
|
|
29
|
+
image?: {
|
|
30
|
+
mimeType: "image/png";
|
|
31
|
+
data: string;
|
|
32
|
+
widthPx: number;
|
|
33
|
+
heightPx: number;
|
|
34
|
+
bytes: number;
|
|
35
|
+
};
|
|
29
36
|
};
|
|
30
37
|
}
|
|
31
38
|
|
|
@@ -60,6 +67,7 @@ export function createUiStateSnapshot(
|
|
|
60
67
|
width: avatar.lines.reduce((maximum, line) => Math.max(maximum, visibleWidth(line)), 0),
|
|
61
68
|
height: avatar.lines.length,
|
|
62
69
|
truncated: avatar.truncated,
|
|
70
|
+
...(avatar.image ? { image: { ...avatar.image } } : {}),
|
|
63
71
|
};
|
|
64
72
|
}
|
|
65
73
|
return snapshot;
|