SillyTavern 角色卡系统技术分析
概述
SillyTavern 的角色卡系统是一个多格式、多层次的复杂架构,支持多种角色卡规范和导入/导出格式。本文档详细分析该系统的实现原理和关键技术点。
1. 角色卡格式体系
1.1 格式对比概览
| 特性 | V1 格式 | V2 格式 | V3 格式 | CharX 格式 |
|---|---|---|---|---|
| 存储方式 | PNG tEXt chunk | PNG tEXt chunk | PNG tEXt chunk | ZIP 压缩包 |
| Chunk 名称 | chara | chara | ccv3 | card.json |
| 编码方式 | Base64 | Base64 | Base64 | UTF-8 JSON |
| 数据结构 | 扁平化 | 嵌套 data 对象 | 嵌套 data 对象 | 嵌套 data 对象 + 资源文件 |
| 支持资源 | 仅头像 | 仅头像 | 仅头像 | 多文件资源 |
| 向后兼容 | - | 读取 V1 | 读取 V2/V1 | 转换为内部格式 |
1.2 V2 格式详解
V2 格式使用 chara PNG chunk 存储 Base64 编码的 JSON 数据。
数据结构示例:
{
"spec": "chara_card_v2",
"spec_version": "2.0",
"name": "Character Name",
"description": "Character description...",
"personality": "Personality traits...",
"scenario": "Scenario context...",
"first_mes": "First message...",
"mes_example": "Example messages...",
"creatorcomment": "Creator notes (legacy)",
"talkativeness": 0.5,
"fav": false,
"tags": ["tag1", "tag2"],
"data": {
"name": "Character Name",
"description": "...",
"personality": "...",
"scenario": "...",
"first_mes": "...",
"mes_example": "...",
"creator_notes": "...",
"system_prompt": "...",
"post_history_instructions": "...",
"alternate_greetings": [],
"tags": [],
"creator": "Creator Name",
"character_version": "1.0",
"extensions": {
"talkativeness": 0.5,
"fav": false,
"world": "",
"depth_prompt": {
"prompt": "",
"depth": 4,
"role": "system"
}
}
}
}
1.3 V3 格式详解
V3 格式使用 ccv3 PNG chunk,与 V2 的主要区别在于:
- Chunk 标识不同:使用
ccv3而非chara - Spec 声明更新:
spec: "chara_card_v3",spec_version: "3.0" - 读取优先级:
ccv3优先于charachunk
V2 vs V3 关键区别:
| 方面 | V2 | V3 |
|---|---|---|
| Chunk 关键字 | chara | ccv3 |
| Spec 值 | chara_card_v2 | chara_card_v3 |
| 版本号 | 2.0 | 3.0 |
| 优先级 | 低 | 高(优先读取) |
| 创建方式 | 原生支持 | 从 V2 数据自动转换 |
写入逻辑(character-card-parser.js):
// 写入时同时创建 V2 和 V3 chunks
export const write = (image, data) => {
// 1. 清除现有 chara/ccv3 chunks
// 2. 添加新的 V2 chunk
chunks.splice(-1, 0, PNGtext.encode('chara', base64EncodedData));
// 3. 尝试添加 V3 chunk(从 V2 数据转换)
const v3Data = JSON.parse(data);
v3Data.spec = 'chara_card_v3';
v3Data.spec_version = '3.0';
chunks.splice(-1, 0, PNGtext.encode('ccv3', base64EncodedV3Data));
};
读取逻辑:
export const read = (image) => {
// V3 (ccv3) 优先
const ccv3Index = textChunks.findIndex(chunk => chunk.keyword.toLowerCase() === 'ccv3');
if (ccv3Index > -1) {
return Buffer.from(textChunks[ccv3Index].text, 'base64').toString('utf8');
}
// 降级到 V2 (chara)
const charaIndex = textChunks.findIndex(chunk => chunk.keyword.toLowerCase() === 'chara');
if (charaIndex > -1) {
return Buffer.from(textChunks[charaIndex].text, 'base64').toString('utf8');
}
};
1.4 CharX 格式详解
CharX 是一种基于 ZIP 压缩包的多文件角色卡格式,支持嵌入多个资源文件。
文件结构:
character.charx (ZIP)
├── card.json # 角色卡元数据(CCv2 或 CCv3 格式)
├── assets/ # 资源文件目录
│ ├── icon_main.png # 主头像
│ ├── emotion_happy.png
│ ├── emotion_sad.png
│ ├── expression_angry.png
│ ├── background_room.png
│ └── ...
└── ...
URI 引用格式:
embedded://assets/icon_main.pngembeded://assets/emotion_happy.png(兼容 RisuAI 的拼写错误)__asset:assets/background_room.png
CharX 资产类型映射(charx.js):
| CharX 类型 | ST 存储类别 | 存储路径 |
|---|---|---|
icon, user_icon | 头像 | 角色 PNG 文件本身 |
emotion, expression | sprite | characters/{charName}/ |
background | background | characters/{charName}/backgrounds/ |
| 其他 | misc | user/images/{charName}/ |
CharX 导入流程:
CharX ZIP 文件
↓
CharXParser.parse()
├─→ 提取 card.json
├─→ 解析 data.assets 数组
├─→ 收集所有嵌入式资源 URI
├─→ 提取头像(type=icon)
└─→ 映射辅助资源(emotion/expression/background)
↓
persistCharXAssets()
├─→ Sprites → characters/{name}/
├─→ Backgrounds → characters/{name}/backgrounds/
└─→ Misc → user/images/{name}/
↓
writeCharacterData() → PNG 文件
CharX 优势:
- 多资源支持:可包含表情、背景、语音等多媒体资源
- 标准化结构:清晰的资产分类和引用方式
- 跨平台兼容:ZIP 格式广泛支持
- 渐进加载:资源按需提取,不一次性加载全部
2. PNG 元数据系统
2.1 PNG tEXt Chunk 结构
PNG 文件使用 tEXt chunk 存储文本元数据,结构如下:
tEXt Chunk Structure:
+------------------+------------------+------------------+
| Keyword (n bytes)| Null separator | Text (m bytes) |
| (Latin-1 编码) | (1 byte: 0x00) | (Latin-1 编码) |
+------------------+------------------+------------------+
关键技术细节:
- 使用
png-chunks-extract库提取 chunks - 使用
png-chunk-text库编码/解码 tEXt chunks - 数据使用 Base64 编码存储
- 新 chunk 插入在 IEND chunk 之前
2.2 PNG 编码实现(png/encode.js)
// PNG 文件签名
const PNG_SIGNATURE = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A];
// Chunk 结构:Length (4 bytes) | Type (4 bytes) | Data (n bytes) | CRC (4 bytes)
// 每个 chunk 的 CRC32 校验涵盖 Type 和 Data 部分
编码流程:
- 写入 PNG 文件签名(8 bytes)
- 遍历所有 chunks:
- 写入数据长度(4 bytes, big-endian)
- 写入 chunk 类型(4 bytes ASCII)
- 写入数据内容
- 写入 CRC32 校验值(4 bytes)
- 返回完整 PNG 数据
2.3 角色卡数据写入流程
输入:图像缓冲区 + JSON 数据
↓
write(image, data)
├─→ extract(Uint8Array) → PNG chunks[]
├─→ 过滤现有 chara/ccv3 tEXt chunks
├─→ 删除旧的元数据 chunks
├─→ Base64 编码数据
├─→ 插入新 chara chunk(在 IEND 之前)
├─→ 尝试插入新 ccv3 chunk
↓
encode(chunks) → Uint8Array
↓
输出:带元数据的新 PNG 缓冲区
3. 角色管理功能
3.1 数据流 Mermaid 流程图
3.2 详细数据流程
角色列表获取流程
POST /api/characters/all
↓
readdir(charactersDir) → .png 文件列表
↓
Promise.all(pngFiles.map(file => processCharacter(file, {shallow})))
↓
processCharacter:
├─→ readCharacterData(imgFile) [带缓存]
├─→ getCharaCardV2(JSON.parse(data))
├─→ calculateChatSize(chatsDir)
├─→ toShallow(character) [如果 shallow=true]
↓
返回角色列表
角色创建/编辑流程
POST /api/characters/create 或 /edit
↓
charaFormatData(request.body) → V2 格式对象
↓
writeCharacterData(inputFile, charJSON, fileName, request, crop)
├─→ 重置内存缓存
├─→ 添加到磁盘缓存同步队列
├─→ getInputImage() / parseImageBuffer()
│ └─→ Jimp.fromBuffer() → crop/resize → PNG 缓冲区
├─→ write(inputImage, charJSON) → 嵌入 PNG chunk
└─→ writeFileAtomicSync() → 原子写入
↓
返回状态
多格式导入流程
POST /api/characters/import
↓
formatImportFunctions[format]
├─→ png: importFromPng → readCharacterData → writeCharacterData
├─→ json: importFromJson → parse → writeCharacterData
├─→ yaml: importFromYaml → yaml.parse → convertToV2 → writeCharacterData
├─→ charx: importFromCharX → CharXParser.parse → persistCharXAssets → writeCharacterData
└─→ byaf: importFromByaf → ByafParser.parse → writeCharacterData
↓
返回 file_name
4. 缓存机制设计
4.1 双层缓存架构
┌─────────────────────────────────────────────────────────────┐
│ 读取请求 │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ Layer 1: Memory Cache (MemoryLimitedMap) │
│ ├─ 容量: 可配置 (默认 100MB) │
│ ├─ 键: `${filePath}-${mtimeMs}` │
│ ├─ 值: 字符卡 JSON 字符串 │
│ ├─ 驱逐策略: LRU (当内存不足时移除最旧条目) │
│ └─ 跳过条件: Android 平台 │
└─────────────────────────────────────────────────────────────┘
│ 未命中
▼
┌─────────────────────────────────────────────────────────────┐
│ Layer 2: Disk Cache (node-persist) │
│ ├─ 位置: `DATA_ROOT/_cache/characters/` │
│ ├─ 键: 同上 │
│ ├─ 值: 同上 │
│ ├─ 同步间隔: 5 分钟 │
│ └─ 验证: 启动时清理无效条目 │
└─────────────────────────────────────────────────────────────┘
│ 未命中
▼
从 PNG 文件解析
4.2 MemoryLimitedMap 实现细节
核心算法:
export class MemoryLimitedMap {
constructor(cacheCapacity) {
this.maxMemory = bytes.parse(cacheCapacity) ?? 0; // 最大内存限制
this.currentMemory = 0; // 当前内存使用
this.map = new Map(); // 键值存储
this.queue = []; // LRU 队列
}
static estimateStringSize(str) {
return str ? str.length * 2 : 0; // UTF-16: 2 bytes/char
}
set(key, value) {
const newValueSize = MemoryLimitedMap.estimateStringSize(value);
// 如果单条超过限制,拒绝
if (newValueSize > this.maxMemory) return;
// 驱逐旧条目直到有足够空间
while (this.currentMemory + newValueSize > this.maxMemory && this.queue.length > 0) {
const oldestKey = this.queue.shift();
const oldestValue = this.map.get(oldestKey);
this.map.delete(oldestKey);
this.currentMemory -= MemoryLimitedMap.estimateStringSize(oldestValue);
}
// 添加新条目
this.map.set(key, value);
this.queue.push(key);
this.currentMemory += newValueSize;
}
}
内存计算:
- 假设 UTF-16 编码,每字符 2 字节
- 典型角色卡大小:5-20KB
- 100MB 缓存可存储约 5000-20000 个角色卡
4.3 DiskCache 设计
配置参数:
class DiskCache {
static DIRECTORY = 'characters';
static SYNC_INTERVAL = 5 * 60 * 1000; // 5 分钟
// node-persist 配置
const storage = {
dir: cachePath,
ttl: false, // 不过期
forgiveParseErrors: true, // 宽容解析错误
expiredInterval: 0, // 不检查过期
maxFileDescriptors: 100, // 最大文件描述符
};
}
同步机制:
// 每 5 分钟执行一次
async #syncCacheEntries() {
const directories = [...this.syncQueue].map(entry => getUserDirectories(entry));
this.syncQueue.clear();
await this.verify(directories); // 清理无效条目
}
// 验证:删除已不存在的角色文件对应的缓存
async verify(directoriesList) {
for (const dir of directoriesList) {
const files = fs.readdirSync(dir.characters, { withFileTypes: true });
// 只保留存在的 PNG 文件对应的缓存键
const validKeys = new Set(files.filter(f => f.isFile() && path.extname(f.name) === '.png')
.map(file => path.parse(cache.getDatumPath(getCacheKey(filePath))).base));
}
}
缓存键生成:
function getCacheKey(inputFile) {
if (fs.existsSync(inputFile)) {
const stat = fs.statSync(inputFile);
return `${inputFile}-${stat.mtimeMs}`; // 包含修改时间
}
return inputFile;
}
5. 角色卡验证系统
5.1 TavernCardValidator 架构
export class TavernCardValidator {
#lastValidationError = null;
validate() {
if (this.validateV1()) return 1;
if (this.validateV2()) return 2;
if (this.validateV3()) return 3;
return false;
}
}
5.2 各版本验证规则
V1 验证(传统格式):
validateV1() {
const requiredFields = [
'name', 'description', 'personality',
'scenario', 'first_mes', 'mes_example'
];
return requiredFields.every(field => Object.hasOwn(this.card, field));
}
V2 验证(完整格式):
validateV2() {
return this.#validateSpecV2() // spec === 'chara_card_v2'
&& this.#validateSpecVersionV2() // spec_version === '2.0'
&& this.#validateDataV2() // data 对象验证
&& this.#validateCharacterBookV2(); // character_book 验证
}
#validateDataV2() {
const requiredFields = [
'name', 'description', 'personality', 'scenario',
'first_mes', 'mes_example', 'creator_notes', 'system_prompt',
'post_history_instructions', 'alternate_greetings', 'tags',
'creator', 'character_version', 'extensions'
];
// 检查所有必需字段 + 数组类型验证
}
V3 验证(最新格式):
validateV3() {
return this.#validateSpecV3() // spec === 'chara_card_v3'
&& this.#validateSpecVersionV3() // 3.0 <= version < 4.0
&& this.#validateDataV3(); // data 对象存在
}
5.3 验证失败处理
router.post('/merge-attributes', async (request, response) => {
const validator = new TavernCardValidator(character);
if (validator.validate()) {
await writeCharacterData(avatarPath, JSON.stringify(character), targetImg, request);
response.sendStatus(200);
} else {
response.status(400).send({
message: `Validation failed for ${character.name}`,
error: validator.lastValidationError
});
}
});
6. 头像处理系统
6.1 Jimp 图像处理配置
// jimp.js - 自定义 Jimp 实例
const Jimp = createJimp({
formats: [
webp, png, jpeg, avif, // WASM 优化格式
bmp, msBmp, gif, tiff // JS 实现格式
],
plugins: [
blit, circle, color, contain, cover,
crop, displace, fisheye, flip, mask,
resize, rotate, threshold, quantize
],
});
6.2 头像裁剪与缩放
标准尺寸:
export const AVATAR_WIDTH = 512;
export const AVATAR_HEIGHT = 768;
处理流程:
export async function applyAvatarCropResize(jimp, crop) {
let finalWidth = image.bitmap.width;
let finalHeight = image.bitmap.height;
// 1. 应用裁剪(如果指定)
if (crop && [crop.x, crop.y, crop.width, crop.height].every(x => typeof x === 'number')) {
image.crop({ x: crop.x, y: crop.y, w: crop.width, h: crop.height });
// 2. 应用标准缩放(如果请求)
if (crop.want_resize) {
finalWidth = AVATAR_WIDTH;
finalHeight = AVATAR_HEIGHT;
}
}
// 3. 使用 cover 模式调整尺寸(保持比例,裁剪溢出)
image.cover({ w: finalWidth, h: finalHeight });
return await image.getBuffer(JimpMime.png);
}
7. 数据转换与迁移
7.1 V1 到 V2 转换
function convertToV2(char, directories) {
return charaFormatData({
json_data: JSON.stringify(char),
ch_name: char.name,
description: char.description,
personality: char.personality,
scenario: char.scenario,
first_mes: char.first_mes,
mes_example: char.mes_example,
creator_notes: char.creatorcomment,
talkativeness: char.talkativeness,
fav: char.fav,
creator: char.creator,
tags: char.tags,
depth_prompt_prompt: char.depth_prompt_prompt,
depth_prompt_depth: char.depth_prompt_depth,
depth_prompt_role: char.depth_prompt_role,
}, directories);
}
7.2 V2 读取与字段映射
function readFromV2(char) {
const fieldMappings = {
name: 'name',
description: 'description',
personality: 'personality',
scenario: 'scenario',
first_mes: 'first_mes',
mes_example: 'mes_example',
talkativeness: 'extensions.talkativeness',
fav: 'extensions.fav',
tags: 'tags',
};
_.forEach(fieldMappings, (v2Path, charField) => {
const v2Value = _.get(char.data, v2Path);
if (_.isUndefined(v2Value)) {
// 填充默认值
if (v2Path === 'extensions.talkativeness') defaultValue = 0.5;
if (v2Path === 'extensions.fav') defaultValue = false;
}
char[charField] = v2Value;
});
}
7.3 隐私字段清理
function unsetPrivateFields(char) {
_.set(char, 'fav', false); // 重置收藏状态
_.set(char, 'data.extensions.fav', false);
_.unset(char, 'chat'); // 移除聊天记录引用
}
8. API Endpoints 清单
| Endpoint | 方法 | 描述 | 关键参数 |
|---|---|---|---|
/api/characters/all | POST | 获取所有角色列表 | shallow 控制详细程度 |
/api/characters/get | POST | 获取单个角色详情 | avatar_url |
/api/characters/create | POST | 创建新角色 | ch_name, file_name, file |
/api/characters/edit | POST | 编辑角色 | avatar_url, ch_name, file |
/api/characters/edit-avatar | POST | 仅编辑头像 | avatar_url, file |
/api/characters/edit-attribute | POST | 编辑单个属性 | avatar_url, field, value |
/api/characters/merge-attributes | POST | 合并属性更新 | avatar, 任意属性 |
/api/characters/rename | POST | 重命名角色 | avatar_url, new_name |
/api/characters/delete | POST | 删除角色 | avatar_url, delete_chats |
/api/characters/duplicate | POST | 复制角色 | avatar_url |
/api/characters/import | POST | 导入角色 | file, file_type |
/api/characters/export | POST | 导出角色 | avatar_url, format |
/api/characters/chats | POST | 获取角色聊天记录 | avatar_url, simple, metadata |
9. 性能优化策略
9.1 缓存优化
- 内存缓存:快速响应,限制内存使用
- 磁盘缓存:持久化,服务重启后保留
- 缓存键包含 mtime:文件修改后自动失效
- 浅层数据模式:列表展示时只加载必要字段
9.2 图像处理优化
- WASM 加速:WebP/PNG/JPEG/AVIF 使用 WASM 解码
- 延迟处理:图像处理只在必要时执行
- 原子写入:使用 write-file-atomic 防止数据损坏
9.3 批量处理
- Promise.all:并行处理多个角色
- 异步 I/O:所有文件操作使用异步 API
- 流式处理:CharX 资源按需提取
10. 安全考虑
10.1 输入验证
- 文件名清理:使用
sanitize-filename库 - 路径遍历防护:验证所有用户输入的路径
- 格式白名单:只允许特定导入格式
10.2 数据处理
- 原子写入:防止写入过程中断导致数据损坏
- 缓存验证:清理已不存在文件的缓存条目
- 错误隔离:单个角色处理失败不影响整体列表
结论
SillyTavern 的角色卡系统采用了高度模块化和可扩展的设计:
- 多格式支持:V1/V2/V3 PNG 格式 + CharX ZIP 格式,向后兼容
- 高效缓存:双层缓存架构平衡速度和内存使用
- 标准化处理:统一的图像处理、数据验证和转换流程
- 资源管理:CharX 支持多媒体资源,自动分类存储
- 健壮性:全面的错误处理和验证机制
这套系统为 SillyTavern 提供了灵活、高效、可靠的角色管理能力。